Files
ElyOffice/apps/ely-tree/src/conflict.rs
T
ZacharyZhang-NY d59159cea2 Fixes for M-02 to M-14, G-01 to G-14, K-02 to K-04, X-04 to X-14, not yet reviewed
Committed before review so Windows and WSL builds can start.
Each fix was tested and mutation-checked here; the codex review
of each item is still to come, so tasks/BUGS.md stays unticked.

M-02 is mid-review: round 1 findings are fixed (apps/ely-mark/src/tally.rs).
K-01 stays deferred. The ElyMark window test mark-outside.sh failed
once at its crash-recovery step in the last run; not yet looked into.
2026-10-10 02:29:19 -04:00

316 lines
11 KiB
Rust

//! A conflict's three stages and hand-kept result (TREE-09).
use std::{io, path::Path, rc::Rc};
use ely_gpui_component::{
git::{MARKER, Region, RegionKind, Take, conflicts, marked, resolve},
primitives::Severity,
};
use gpui::Context;
use crate::{
asks::Ask,
git::Git,
tree::{Tree, text},
work::Write,
};
/// What Git holds for a conflicted path.
#[derive(Clone, Debug, PartialEq)]
pub enum Stages {
/// Text on all three sides, and the marked work file.
Text {
sides: [Option<String>; 3],
work: String,
regions: Vec<Region>,
/// Marker width, from `conflict-marker-size`.
size: usize,
},
/// Links, submodules, bytes, missing sides: taken whole.
Whole { present: [bool; 3] },
}
/// Stage, mode and blob id from `ls-files -u -z`.
fn staged(bytes: &[u8]) -> io::Result<Vec<(usize, String, String)>> {
let text = std::str::from_utf8(bytes).map_err(io::Error::other)?;
text.split('\0')
.filter(|record| !record.is_empty())
.map(|record| {
let bad = || {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread stage {record:?}"),
)
};
let (head, _) = record.split_once('\t').ok_or_else(bad)?;
let mut fields = head.split(' ');
let (mode, id, stage) = (
fields.next().ok_or_else(bad)?,
fields.next().ok_or_else(bad)?,
fields.next().ok_or_else(bad)?,
);
let stage: usize = stage.parse().map_err(|_| bad())?;
if !(1..=3).contains(&stage) {
return Err(bad());
}
Ok((stage, mode.to_string(), id.to_string()))
})
.collect()
}
/// Each block's base, from Git's own diff3 merge.
fn bases(
git: &Git,
root: &Path,
sides: &[Option<String>; 3],
size: usize,
) -> io::Result<Vec<Vec<String>>> {
let dir = tempfile::tempdir()?;
let mut names = Vec::new();
for (side, text) in ["base", "ours", "theirs"].iter().zip(sides) {
let file = dir.path().join(side);
std::fs::write(&file, text.as_deref().unwrap_or(""))?;
names.push(file.to_string_lossy().into_owned());
}
let out = git.run_counted(
root,
&[
"merge-file",
"-p",
"--zdiff3",
&format!("--marker-size={size}"),
&names[1],
&names[0],
&names[2],
],
)?;
let merged = String::from_utf8(out).map_err(io::Error::other)?;
Ok(marked(&merged, size)
.into_iter()
.filter(|region| region.kind == RegionKind::Conflict)
.map(|region| region.base)
.collect())
}
/// The marker width `conflict-marker-size` sets for `path`.
fn marker_size(git: &Git, root: &Path, path: &str) -> io::Result<usize> {
let said = String::from_utf8(git.run(
root,
&["check-attr", "-z", "conflict-marker-size", "--", path],
)?)
.map_err(io::Error::other)?;
// `-z` gives path, attribute and value, each NUL-ended.
Ok(said.split('\0').nth(2).map_or(MARKER, git_width))
}
/// As Git's `strtol_i`: whole value, else 7.
fn git_width(value: &str) -> usize {
match value
.trim_start_matches(|c: char| c.is_ascii_whitespace() || c == '\x0b')
.parse::<i32>()
{
Ok(size) if size > 0 => size as usize,
_ => MARKER,
}
}
/// A NUL early on: Git calls it binary.
fn binary(bytes: &[u8]) -> bool {
bytes[..bytes.len().min(8000)].contains(&0)
}
/// What Git holds for `path`: marked text, or whole files.
pub fn read(git: &Git, root: &Path, path: &str) -> io::Result<Stages> {
let mut sides: [Option<String>; 3] = Default::default();
let (mut present, mut text) = ([false; 3], true);
for (stage, mode, id) in staged(&git.run(root, &["ls-files", "-u", "-z", "--", path])?)? {
present[stage - 1] = true;
// Links and submodules are no text to merge by line.
if mode == "120000" || mode == "160000" {
text = false;
continue;
}
let bytes = git.run(root, &["cat-file", "blob", &id])?;
match String::from_utf8(bytes)
.ok()
.filter(|side| !binary(side.as_bytes()))
{
Some(side) => sides[stage - 1] = Some(side),
None => text = false,
}
}
let file = root.join(path);
let work = match std::fs::symlink_metadata(&file) {
Ok(meta) if meta.file_type().is_file() => String::from_utf8(std::fs::read(&file)?).ok(),
_ => None,
};
let Some(work) = work.filter(|_| text && present[1] && present[2]) else {
return Ok(Stages::Whole { present });
};
let size = marker_size(git, root, path)?;
let mut regions = marked(&work, size);
let blocks = conflicts(&work, size);
let bases = bases(git, root, &sides, size)?;
// Fill only blocks Git left without a base.
if bases.len() == blocks.len() {
let conflicted = regions
.iter_mut()
.filter(|region| region.kind == RegionKind::Conflict);
for ((region, block), base) in conflicted.zip(&blocks).zip(bases) {
if block.base.is_none() {
region.base = base;
}
}
}
Ok(Stages::Text {
sides,
work,
regions,
size,
})
}
/// A text conflict as the middle holds it, being worked.
pub struct Merging {
pub stages: Stages,
pub regions: Rc<Vec<Region>>,
pub takes: Vec<Option<Take>>,
/// The text last loaded or saved, to tell unsaved edits.
pub saved: String,
}
impl Merging {
/// This file's marker width.
pub fn size(&self) -> usize {
match self.stages {
Stages::Text { size, .. } => size,
Stages::Whole { .. } => MARKER,
}
}
/// None for a whole-file conflict.
pub fn new(stages: Stages) -> Option<Self> {
let Stages::Text { work, regions, .. } = &stages else {
return None;
};
let count = regions
.iter()
.filter(|region| region.kind == RegionKind::Conflict)
.count();
let (regions, saved) = (Rc::new(regions.clone()), work.clone());
Some(Merging {
stages,
regions,
takes: vec![None; count],
saved,
})
}
}
impl Tree {
/// Settles conflict `at` in the result, among those open.
pub fn take(&mut self, at: usize, take: Take, cx: &mut Context<Self>) {
let Some(merging) = self.merging.as_mut() else {
return;
};
let result = self.merge_editor.read(cx).text().to_string();
let open = merging.takes.iter().filter(|taken| taken.is_none()).count();
if merging.takes.get(at).copied().flatten().is_some() {
let words = text(cx, "tree.conflict.taken", &[]);
return self.toast(words, Severity::Warning, cx);
}
// Hand edits that moved the markers end block choices.
if conflicts(&result, merging.size()).len() != open {
let words = text(cx, "tree.conflict.hand", &[]);
return self.toast(words, Severity::Warning, cx);
}
let place = merging.takes[..at]
.iter()
.filter(|taken| taken.is_none())
.count();
let Some(settled) = resolve(&result, place, take, merging.size()) else {
return;
};
merging.takes[at] = Some(take);
self.merge_editor
.update(cx, |editor, cx| editor.set_text(settled, cx));
cx.notify();
}
/// Queues the result for its work file; staging is apart.
pub fn save_merge(&mut self, cx: &mut Context<Self>) {
let Some(shown) = &self.shown else { return };
let (repo, path) = (shown.repo, shown.path.clone());
let text = self.merge_editor.read(cx).text().to_string();
self.write(repo, Write::Save { path, text }, cx);
}
/// A save landed; it counts if still this file's.
pub fn merge_saved(&mut self, id: u64, path: &str, text: &str, cx: &mut Context<Self>) {
let here = self
.shown
.as_ref()
.is_some_and(|shown| shown.repo == id && shown.path == path);
if let Some(merging) = self.merging.as_mut().filter(|_| here) {
merging.saved = text.to_string();
cx.notify();
}
}
/// Marks the saved result resolved; markers left ask first.
pub fn mark_resolved(&mut self, cx: &mut Context<Self>) {
let Some(shown) = &self.shown else { return };
let (repo, path) = (shown.repo, shown.path.clone());
let result = self.merge_editor.read(cx).text().to_string();
if self
.merging
.as_ref()
.is_some_and(|merging| merging.saved != result)
{
let words = text(cx, "tree.conflict.unsaved", &[]);
return self.toast(words, Severity::Warning, cx);
}
let write = Write::Git(vec!["add".into(), "--".into(), path.clone()]);
let size = self.merging.as_ref().map_or(MARKER, Merging::size);
if conflicts(&result, size).is_empty() {
return self.write(repo, write, cx);
}
self.ask = Some(Ask::Confirm {
repo,
title: text(cx, "tree.conflict.marked.title", &[("name", &path)]),
message: text(cx, "tree.conflict.marked.message", &[("name", &path)]),
go: text(cx, "tree.conflict.marked.go", &[]),
write,
});
cx.notify();
}
/// One side's whole file into the work tree.
pub fn take_side(&mut self, ours: bool, cx: &mut Context<Self>) {
let Some(shown) = &self.shown else { return };
let side = if ours { "--ours" } else { "--theirs" };
let write = Write::Git(vec![
"checkout".into(),
side.into(),
"--".into(),
shown.path.clone(),
]);
let repo = shown.repo;
self.write(repo, write, cx);
}
/// Settles the conflict by removing the file; asks first.
pub fn resolve_deleted(&mut self, cx: &mut Context<Self>) {
let Some(shown) = &self.shown else { return };
let (repo, path) = (shown.repo, shown.path.clone());
self.ask = Some(Ask::Confirm {
repo,
title: text(cx, "tree.conflict.delete.title", &[("name", &path)]),
message: text(cx, "tree.conflict.delete.message", &[("name", &path)]),
go: text(cx, "tree.conflict.delete.go", &[]),
write: Write::Git(vec!["rm".into(), "--".into(), path]),
});
cx.notify();
}
}