Skip to main content

mkit_cli/commands/
conflict.rs

1//! Shared CLI helpers for the resolvable-conflict workflow (#177).
2//!
3//! Materialises conflict material into the worktree + index, classifies
4//! each conflict into a presentation class, and scans for leftover
5//! conflict markers so `--continue` can refuse to proceed while the user
6//! has not resolved a textual conflict.
7//!
8//! Materialisation always honours the #176 restore guards: callers run
9//! [`super::ensure_restore_safe`] over the conflict-time tree before
10//! invoking [`materialize_conflicts`], so dirty tracked files and
11//! untracked collisions are never clobbered.
12
13use std::fs;
14use std::io::Write;
15use std::path::Path;
16
17use mkit_core::hash::Hash;
18use mkit_core::index::{self, EntryStatus, IndexEntry};
19use mkit_core::layout::RepoLayout;
20use mkit_core::object::{EntryMode, Object};
21use mkit_core::ops::conflict_state::ConflictRecord;
22use mkit_core::ops::merge::{Conflict, ConflictKind};
23use mkit_core::store::ObjectStore;
24use mkit_core::worktree;
25
26/// Classification of how a conflicting path is presented to the user.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ConflictClass {
29    /// Text modify/modify or add/add: classic 2-way Git markers are
30    /// written into the worktree file.
31    TextMarkers,
32    /// Binary blob on either side: no markers (they would corrupt the
33    /// file); the ours-side content is left in place for manual edit.
34    Binary,
35    /// Delete/modify: one side removed the path; the surviving content
36    /// is left in place; resolve by `mkit add` or `mkit rm`.
37    DeleteModify,
38    /// Symlink or executable-mode change, or any other shape unsafe for
39    /// markers: ours-side content/mode is left in place for manual edit.
40    Special,
41}
42
43/// Marker lines, kept as constants so the leftover scanner and the
44/// writer agree byte-for-byte.
45const MARK_OURS: &str = "<<<<<<< ours";
46const MARK_SEP: &str = "=======";
47const MARK_THEIRS: &str = ">>>>>>> theirs";
48
49/// Decide whether a blob's bytes are safe to wrap in text markers.
50fn is_text(data: &[u8]) -> bool {
51    // No NUL bytes and valid UTF-8 — the same heuristic used for the
52    // diff path. A NUL is the classic "this is binary" tell.
53    !data.contains(&0) && core::str::from_utf8(data).is_ok()
54}
55
56fn read_blob(store: &ObjectStore, h: Hash) -> Result<Vec<u8>, String> {
57    match store.read_object(&h) {
58        Ok(Object::Blob(b)) => Ok(b.data),
59        Ok(_) => Err("conflict side is not a blob".to_string()),
60        Err(e) => Err(format!("read conflict blob: {e}")),
61    }
62}
63
64/// `true` when `h` points at a blob object (as opposed to a tree, which
65/// is how a file-vs-directory conflict surfaces on one side).
66fn is_blob(store: &ObjectStore, h: Hash) -> bool {
67    matches!(store.read_object(&h), Ok(Object::Blob(_)))
68}
69
70/// `true` when a conflict side is absent or points at a blob. A side
71/// that points at a tree (file-vs-directory) is neither.
72fn side_is_blob_or_absent(store: &ObjectStore, side: Option<Hash>) -> bool {
73    match side {
74        None => true,
75        Some(h) => is_blob(store, h),
76    }
77}
78
79/// `true` when a side's tree mode is a symlink or executable — shapes
80/// that conflict markers cannot represent and that must round-trip their
81/// exact mode (#214).
82fn side_is_special_mode(mode: Option<EntryMode>) -> bool {
83    matches!(mode, Some(EntryMode::Symlink | EntryMode::Executable))
84}
85
86/// Classify a single conflict given its blob contents.
87///
88/// # Errors
89/// Propagates object-store read failures.
90pub fn classify(store: &ObjectStore, c: &Conflict) -> Result<ConflictClass, String> {
91    match c.kind {
92        ConflictKind::DeleteModify => Ok(ConflictClass::DeleteModify),
93        ConflictKind::ModifyModify | ConflictKind::AddAdd => {
94            // File-vs-directory: one side is a tree. Markers are unsafe;
95            // route to Special (the blob side is left in the worktree).
96            if !side_is_blob_or_absent(store, c.ours_hash)
97                || !side_is_blob_or_absent(store, c.theirs_hash)
98            {
99                return Ok(ConflictClass::Special);
100            }
101            // Symlink / executable on either side (#214): the merge
102            // engine now carries the real `EntryMode`, so we route these
103            // to Special unambiguously instead of guessing from bytes.
104            // Writing conflict markers into a symlink target is
105            // meaningless, and an executable's content is rarely a clean
106            // text merge — the user resolves manually and the ours-side
107            // mode is preserved into the worktree + index.
108            if side_is_special_mode(c.ours_mode) || side_is_special_mode(c.theirs_mode) {
109                return Ok(ConflictClass::Special);
110            }
111            // Otherwise fall back to the byte heuristic: any non-UTF-8 /
112            // NUL-bearing side is binary; everything else is text.
113            let ours_text = match c.ours_hash {
114                Some(h) => is_text(&read_blob(store, h)?),
115                None => true,
116            };
117            let theirs_text = match c.theirs_hash {
118                Some(h) => is_text(&read_blob(store, h)?),
119                None => true,
120            };
121            if ours_text && theirs_text {
122                Ok(ConflictClass::TextMarkers)
123            } else {
124                Ok(ConflictClass::Binary)
125            }
126        }
127    }
128}
129
130/// Materialise every conflict into the worktree and stage the ours-side
131/// blob into the index so each conflicting path is "resolvable":
132///
133/// - **text**: write `<<<<<<< ours / ======= / >>>>>>> theirs` markers.
134/// - **binary / special / delete-modify**: leave the surviving content
135///   in the worktree, print a per-path manual-resolution note.
136///
137/// The index entry for each path is set to the ours-side blob (or
138/// removed for an ours-deleted delete/modify) so a subsequent
139/// `mkit add` after resolution updates it normally and `--continue`
140/// builds the tree from the resolved index/worktree.
141///
142/// `merged_tree` is the operation's full merge-result tree (holding
143/// "ours" at every conflicted path and the clean changes everywhere
144/// else). It is applied to the index + worktree FIRST — otherwise the
145/// non-conflicting changes would never reach the index and `--continue`
146/// (which builds from the index) would silently drop them (#269). The
147/// caller runs [`super::ensure_restore_safe`] over `merged_tree` first,
148/// so this never clobbers dirty tracked or untracked content. Conflict
149/// markers are then overlaid on the conflicted paths.
150///
151/// Returns the per-path [`ConflictRecord`]s for the sidecar.
152///
153/// # Errors
154/// Propagates store / filesystem failures as a message string.
155pub fn materialize_conflicts(
156    layout: &RepoLayout,
157    store: &ObjectStore,
158    merged_tree: Hash,
159    conflicts: &[Conflict],
160) -> Result<Vec<ConflictRecord>, String> {
161    // Apply the merged result (clean changes + "ours" at conflict paths)
162    // to the index and worktree, then overlay markers below.
163    super::restore_worktree_and_index(layout, store, merged_tree)?;
164    let mut idx = index::read_index(layout).map_err(|e| format!("read index: {e}"))?;
165    let mut records = Vec::with_capacity(conflicts.len());
166    let mut stderr = std::io::stderr().lock();
167
168    for c in conflicts {
169        let class = classify(store, c)?;
170        let abs = layout.worktree_root().join(&c.path);
171        match class {
172            ConflictClass::TextMarkers => {
173                let ours = match c.ours_hash {
174                    Some(h) => read_blob(store, h)?,
175                    None => Vec::new(),
176                };
177                let theirs = match c.theirs_hash {
178                    Some(h) => read_blob(store, h)?,
179                    None => Vec::new(),
180                };
181                write_text_markers(&abs, &ours, &theirs)?;
182                let _ = writeln!(stderr, "  {} (text conflict — edit markers)", c.path);
183                stage_ours(&mut idx, store, c);
184            }
185            ConflictClass::Binary => {
186                materialize_conflict_side(store, &abs, c)?;
187                let _ = writeln!(
188                    stderr,
189                    "  {} (binary conflict — resolve manually, then `mkit add`)",
190                    c.path
191                );
192                stage_ours(&mut idx, store, c);
193            }
194            ConflictClass::DeleteModify => {
195                // Keep the surviving (modified) side in the worktree,
196                // honouring its exec/symlink mode (#214).
197                materialize_conflict_side(store, &abs, c)?;
198                let _ = writeln!(
199                    stderr,
200                    "  {} (delete/modify — keep with `mkit add` or drop with `mkit rm`)",
201                    c.path
202                );
203                stage_ours(&mut idx, store, c);
204            }
205            ConflictClass::Special => {
206                materialize_conflict_side(store, &abs, c)?;
207                let _ = writeln!(
208                    stderr,
209                    "  {} (mode/symlink conflict — resolve manually, then `mkit add`)",
210                    c.path
211                );
212                stage_ours(&mut idx, store, c);
213            }
214        }
215        records.push(ConflictRecord::from(c));
216    }
217
218    index::write_index(layout, &idx).map_err(|e| format!("write index: {e}"))?;
219    Ok(records)
220}
221
222/// Map a tree [`EntryMode`] to the index [`EntryStatus`] that preserves
223/// it. `Tree` has no single-file index representation and is reported by
224/// the caller (which only stages blob ours-sides), so it falls back to
225/// `Blob` defensively.
226fn status_for_mode(mode: EntryMode) -> EntryStatus {
227    match mode {
228        EntryMode::Executable => EntryStatus::Executable,
229        EntryMode::Symlink => EntryStatus::Symlink,
230        EntryMode::Blob | EntryMode::Tree => EntryStatus::Blob,
231    }
232}
233
234/// Stage the ours-side blob for a conflict into the index (or mark
235/// removed when ours deleted it). Keeps the index a single-stage
236/// resolved snapshot.
237///
238/// The ours-side [`EntryMode`] carried on the [`Conflict`] (#214) is
239/// preserved into the staged [`EntryStatus`] so executable bits and
240/// symlinks survive `--continue` across merge / cherry-pick / rebase —
241/// `build_tree_from_index` derives the committed tree mode from the
242/// index status, so a default-`Blob` here would silently demote an
243/// executable or symlink to a plain file.
244fn stage_ours(idx: &mut mkit_core::index::Index, store: &ObjectStore, c: &Conflict) {
245    let entry = match c.ours_hash {
246        // Only stage a blob ours-side. A tree ours-side (file-vs-dir)
247        // is left for the user to resolve and `mkit add`.
248        Some(h) if is_blob(store, h) => IndexEntry {
249            path: c.path.clone(),
250            status: c.ours_mode.map_or(EntryStatus::Blob, status_for_mode),
251            object_hash: h,
252            mtime_ns: 0,
253            size: 0,
254            ino: 0,
255            ctime_ns: 0,
256        },
257        Some(_) => return,
258        None => IndexEntry {
259            path: c.path.clone(),
260            status: EntryStatus::Removed,
261            object_hash: mkit_core::hash::ZERO,
262            mtime_ns: 0,
263            size: 0,
264            ino: 0,
265            ctime_ns: 0,
266        },
267    };
268    idx.upsert_entry(entry);
269}
270
271fn write_text_markers(abs: &Path, ours: &[u8], theirs: &[u8]) -> Result<(), String> {
272    let mut buf = Vec::new();
273    buf.extend_from_slice(MARK_OURS.as_bytes());
274    buf.push(b'\n');
275    buf.extend_from_slice(ours);
276    if !ours.is_empty() && ours.last() != Some(&b'\n') {
277        buf.push(b'\n');
278    }
279    buf.extend_from_slice(MARK_SEP.as_bytes());
280    buf.push(b'\n');
281    buf.extend_from_slice(theirs);
282    if !theirs.is_empty() && theirs.last() != Some(&b'\n') {
283        buf.push(b'\n');
284    }
285    buf.extend_from_slice(MARK_THEIRS.as_bytes());
286    buf.push(b'\n');
287    write_bytes(abs, &buf)
288}
289
290/// Materialise the surviving side of a binary / special conflict into
291/// the worktree, honouring its tree mode (#214).
292///
293/// We prefer the ours-side (the side `stage_ours` records in the index)
294/// so the worktree file and the staged index entry agree; if ours is
295/// absent or a tree we fall back to theirs. Symlink sides become a real
296/// symlink (not a regular file holding the target text); executable
297/// sides get the exec bit. If neither side is a blob, whatever is
298/// already in the worktree is left untouched.
299fn materialize_conflict_side(store: &ObjectStore, abs: &Path, c: &Conflict) -> Result<(), String> {
300    let pick = [(c.ours_hash, c.ours_mode), (c.theirs_hash, c.theirs_mode)]
301        .into_iter()
302        .find_map(|(h, m)| match h {
303            Some(h) if is_blob(store, h) => Some((h, m)),
304            _ => None,
305        });
306    let Some((h, mode)) = pick else {
307        return Ok(());
308    };
309    // File-vs-directory conflict: the merged result tree already materialized
310    // the directory side at `abs` (restore_worktree_and_index ran first), so
311    // the path is a real directory. We cannot write the surviving blob over a
312    // directory — `fs::remove_file` no-ops on it and the write fails, aborting
313    // materialization AFTER the worktree was mutated but BEFORE MERGE_HEAD is
314    // written, leaving no `--abort` path. Keep the directory (ours-wins, like
315    // `stage_ours` does for a tree side) and record the conflict for manual
316    // resolution. Use symlink_metadata so a symlink-to-a-directory still gets
317    // a normal blob write below.
318    if std::fs::symlink_metadata(abs).is_ok_and(|m| m.is_dir()) {
319        return Ok(());
320    }
321    match mode {
322        Some(EntryMode::Symlink) => write_symlink_to_worktree(store, abs, h),
323        Some(EntryMode::Executable) => write_blob_to_worktree(store, abs, h, true),
324        _ => write_blob_to_worktree(store, abs, h, false),
325    }
326}
327
328fn write_blob_to_worktree(
329    store: &ObjectStore,
330    abs: &Path,
331    h: Hash,
332    executable: bool,
333) -> Result<(), String> {
334    let data = read_blob(store, h)?;
335    // Replace any existing symlink/file at the path so a prior shape
336    // does not shadow the regular file we are about to write.
337    let _ = fs::remove_file(abs);
338    write_bytes(abs, &data)?;
339    if executable {
340        set_executable(abs)?;
341    }
342    Ok(())
343}
344
345/// Materialise a symlink blob (payload = target string) as a real
346/// symlink, mirroring `restore::restore_symlink`'s `..`-free target
347/// validation so a conflict cannot smuggle an escaping link.
348fn write_symlink_to_worktree(store: &ObjectStore, abs: &Path, h: Hash) -> Result<(), String> {
349    let data = read_blob(store, h)?;
350    let target = core::str::from_utf8(&data)
351        .map_err(|_| format!("symlink target for {} is not UTF-8", abs.display()))?;
352    if !mkit_core::worktree::validate_symlink_target(target) {
353        return Err(format!(
354            "refusing to materialise unsafe symlink target {target:?} for {}",
355            abs.display()
356        ));
357    }
358    if let Some(parent) = abs.parent() {
359        fs::create_dir_all(parent).map_err(|e| format!("create dir {}: {e}", parent.display()))?;
360    }
361    // Remove any existing file/symlink so the create does not race a
362    // stale entry of the wrong shape.
363    let _ = fs::remove_file(abs);
364    create_symlink(target, abs).map_err(|e| format!("create symlink {}: {e}", abs.display()))
365}
366
367#[cfg(unix)]
368fn set_executable(abs: &Path) -> Result<(), String> {
369    use std::os::unix::fs::PermissionsExt;
370    let mut perm = fs::metadata(abs)
371        .map_err(|e| format!("stat {}: {e}", abs.display()))?
372        .permissions();
373    perm.set_mode(0o755);
374    fs::set_permissions(abs, perm).map_err(|e| format!("chmod {}: {e}", abs.display()))
375}
376
377#[cfg(not(unix))]
378#[allow(clippy::unnecessary_wraps)]
379fn set_executable(_abs: &Path) -> Result<(), String> {
380    Ok(())
381}
382
383#[cfg(unix)]
384fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
385    std::os::unix::fs::symlink(target, link)
386}
387
388#[cfg(windows)]
389fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
390    std::os::windows::fs::symlink_file(target, link)
391}
392
393#[cfg(not(any(unix, windows)))]
394fn create_symlink(_target: &str, _link: &Path) -> std::io::Result<()> {
395    Err(std::io::Error::new(
396        std::io::ErrorKind::Unsupported,
397        "symlink creation is not supported on this target",
398    ))
399}
400
401fn write_bytes(abs: &Path, data: &[u8]) -> Result<(), String> {
402    if let Some(parent) = abs.parent() {
403        fs::create_dir_all(parent).map_err(|e| format!("create dir {}: {e}", parent.display()))?;
404    }
405    fs::write(abs, data).map_err(|e| format!("write {}: {e}", abs.display()))
406}
407
408/// Pre-abort safety gate: refuse the abort *before* it mutates anything
409/// when restoring to `target_tree` would overwrite genuine user work on
410/// a path that is **not** part of the recorded conflict set.
411///
412/// `--abort` works by first resetting the conflict paths (discarding the
413/// conflict material mkit itself wrote) and then doing a guarded restore
414/// to the pre-op tree. The conflict-path reset is destructive, so it
415/// must not run if the abort is going to be refused anyway: otherwise a
416/// failed abort would silently throw away the user's in-progress
417/// resolution of the conflicting files while leaving operation state in
418/// place. This check inspects only the non-conflict paths (the conflict
419/// paths are expected to be dirty — they hold markers / partial edits)
420/// and mirrors [`super::ensure_restore_safe`]'s staged / unstaged /
421/// untracked-collision detection for them.
422///
423/// # Errors
424/// Returns a message describing the blocking path when the abort would
425/// be unsafe, or propagates store / filesystem failures.
426#[allow(clippy::too_many_lines)] // a sequence of independent pre-mutation safety checks
427pub fn ensure_abort_safe(
428    layout: &RepoLayout,
429    store: &ObjectStore,
430    records: &[ConflictRecord],
431    target_tree: Hash,
432    op_result_tree: Option<Hash>,
433) -> Result<(), String> {
434    use std::collections::HashSet;
435
436    let root = layout.worktree_root();
437    let current_tree = super::current_head_tree(layout, store)?;
438    let idx = super::read_or_seed_index_from_head(layout, store)?;
439    // Safety-check snapshot trees are ephemeral — in-memory overlay.
440    let snapshot = mkit_core::store::EphemeralSink::new(store);
441    let index_tree = mkit_core::worktree::build_tree_from_index_with(store, &snapshot, &idx, false)
442        .map_err(|e| format!("check index state: {e}"))?;
443    // Pass the seeded index as the tracked set so a tracked file matching an
444    // ignore rule isn't dropped from the snapshot and misread as a deletion.
445    let worktree_tree = mkit_core::worktree::build_tree_filtered(&snapshot, root, Some(&idx))
446        .map_err(|e| format!("check worktree: {e}"))?;
447
448    // Discardable = the operation's OWN work that the user has not touched:
449    //   * recorded conflict paths (abort always throws away resolutions);
450    //   * operation-authored clean hunks whose current index AND worktree
451    //     content still match the operation result.
452    // A clean path the user has since edited (staged or in the worktree) is
453    // THEIR work — keep it non-discardable so the checks below refuse to
454    // destroy it. Without a result tree (legacy state) we fall back to the
455    // conflict records alone.
456    let conflict_paths: HashSet<String> = records.iter().map(|r| r.path.clone()).collect();
457    let mut discardable = conflict_paths.clone();
458    if let Some(result_tree) = op_result_tree {
459        let authored = mkit_core::ops::diff::diff_trees(&snapshot, current_tree, Some(result_tree))
460            .map_err(|e| format!("check operation changes: {e}"))?;
461        // Paths whose current index or worktree diverges from the operation
462        // result — i.e. the user changed them after the operation paused.
463        let mut modified: HashSet<String> = HashSet::new();
464        for e in mkit_core::ops::diff::diff_trees(&snapshot, Some(result_tree), Some(index_tree))
465            .map_err(|e| format!("check operation changes: {e}"))?
466            .entries
467        {
468            modified.insert(e.path);
469        }
470        for e in mkit_core::ops::diff::diff_trees(&snapshot, Some(result_tree), Some(worktree_tree))
471            .map_err(|e| format!("check operation changes: {e}"))?
472            .entries
473        {
474            modified.insert(e.path);
475        }
476        for e in authored.entries {
477            if conflict_paths.contains(&e.path) || !modified.contains(&e.path) {
478                discardable.insert(e.path);
479            }
480        }
481    }
482    let is_discardable = |p: &str| discardable.contains(p);
483
484    // Staged changes on a non-discardable path.
485    let staged = mkit_core::ops::diff::diff_trees(&snapshot, current_tree, Some(index_tree))
486        .map_err(|e| format!("check staged changes: {e}"))?;
487    if let Some(entry) = staged.entries.iter().find(|e| !is_discardable(&e.path)) {
488        return Err(format!(
489            "abort would overwrite staged changes; commit, stash, or reset '{}' first",
490            entry.path
491        ));
492    }
493
494    // Unstaged worktree edits on a non-discardable path.
495    let unstaged =
496        mkit_core::ops::diff::diff_trees(&snapshot, Some(index_tree), Some(worktree_tree))
497            .map_err(|e| format!("check worktree: {e}"))?;
498    if let Some(entry) = unstaged
499        .entries
500        .iter()
501        .find(|e| e.kind != mkit_core::ops::diff::DiffKind::Added && !is_discardable(&e.path))
502    {
503        return Err(format!(
504            "abort would overwrite local changes; commit, stash, or reset '{}' first",
505            entry.path
506        ));
507    }
508
509    // Untracked path that collides with a non-conflict path the restore
510    // would write.
511    let target_writes: Vec<String> =
512        mkit_core::ops::diff::diff_trees(&snapshot, Some(index_tree), Some(target_tree))
513            .map_err(|e| format!("check restore target: {e}"))?
514            .entries
515            .into_iter()
516            .filter(|e| e.kind != mkit_core::ops::diff::DiffKind::Removed)
517            .filter(|e| !is_discardable(&e.path))
518            .map(|e| e.path)
519            .collect();
520    if !target_writes.is_empty() {
521        for entry in &unstaged.entries {
522            if entry.kind == mkit_core::ops::diff::DiffKind::Added
523                && !is_discardable(&entry.path)
524                && target_writes.iter().any(|t| t == &entry.path)
525            {
526                return Err(format!(
527                    "abort would overwrite untracked path '{}'; move or remove it first",
528                    entry.path
529                ));
530            }
531        }
532    }
533
534    // Restoring `target_tree` writes a file at every path it adds/changes
535    // relative to the current index — INCLUDING discardable paths (e.g. a
536    // file the operation cleanly deleted). Refuse if any such path is now a
537    // DIRECTORY in the worktree (e.g. the user created `d/keep` after the
538    // operation deleted file `d`): the restore would fail part-way and
539    // removing the directory would destroy the user's untracked content.
540    // Checked here, before any mutation, so abort stays all-or-nothing.
541    for entry in &mkit_core::ops::diff::diff_trees(&snapshot, Some(index_tree), Some(target_tree))
542        .map_err(|e| format!("check restore target: {e}"))?
543        .entries
544    {
545        if entry.kind == mkit_core::ops::diff::DiffKind::Removed {
546            continue;
547        }
548        // The restore writes a file at `entry.path`. Refuse if the path itself
549        // is now a DIRECTORY (e.g. the user created `d/keep` after the
550        // operation deleted file `d`)...
551        if std::fs::symlink_metadata(root.join(&entry.path)).is_ok_and(|m| m.is_dir()) {
552            return Err(format!(
553                "abort would replace directory '{}' with a file; move or remove it first",
554                entry.path
555            ));
556        }
557        // ...or if any ANCESTOR component is now a non-directory file (e.g.
558        // target has `p/file`; the user replaced the deleted directory `p`
559        // with a file `p`). `create_dir_all` would fail mid-restore, breaking
560        // abort atomicity. Checked here, before any mutation.
561        let mut prefix = String::new();
562        for comp in entry.path.split('/') {
563            if !prefix.is_empty() {
564                prefix.push('/');
565            }
566            prefix.push_str(comp);
567            if prefix == entry.path {
568                break; // the leaf is handled by the is_dir check above
569            }
570            if std::fs::symlink_metadata(root.join(&prefix)).is_ok_and(|m| !m.is_dir()) {
571                return Err(format!(
572                    "abort would restore '{}' but '{prefix}' is a file; move or remove it first",
573                    entry.path
574                ));
575            }
576        }
577    }
578    Ok(())
579}
580
581/// Discard conflict material on the recorded conflict paths, resetting
582/// each back to its content in `target_tree` (the pre-op HEAD): write
583/// the target blob into the worktree (or delete the file when the path
584/// is absent from `target_tree`) and align the index entry.
585///
586/// This is the abort precondition: after it runs, the worktree and
587/// index agree with `target_tree` on every conflict path, so the
588/// subsequent guarded restore sees no spurious "local changes" on the
589/// paths we ourselves mutated — while still protecting genuinely
590/// unrelated dirty/untracked paths.
591///
592/// # Errors
593/// Propagates store / filesystem failures.
594#[allow(clippy::too_many_lines)] // a pre-flight pass + the mutation pass, kept together
595pub fn reset_conflict_paths(
596    layout: &RepoLayout,
597    store: &ObjectStore,
598    records: &[ConflictRecord],
599    target_tree: Hash,
600    op_result_tree: Option<Hash>,
601) -> Result<(), String> {
602    use std::collections::{BTreeSet, HashMap};
603
604    let root = layout.worktree_root();
605
606    // Flatten the target tree into path → (mode, hash).
607    let target_idx =
608        index::from_tree(store, target_tree).map_err(|e| format!("read target tree: {e}"))?;
609    let target_map: HashMap<&str, &IndexEntry> = target_idx
610        .entries
611        .iter()
612        .map(|e| (e.path.as_str(), e))
613        .collect();
614
615    // Reset every path the operation authored: the recorded conflict paths
616    // PLUS the operation's clean hunks (paths it changed vs the pre-op HEAD).
617    // The reset is purely target-content driven, so it generalizes from
618    // conflict records to any operation-authored path.
619    let mut paths: BTreeSet<String> = records.iter().map(|r| r.path.clone()).collect();
620    if let Some(result_tree) = op_result_tree {
621        let snapshot = mkit_core::store::EphemeralSink::new(store);
622        let authored =
623            mkit_core::ops::diff::diff_trees(&snapshot, Some(target_tree), Some(result_tree))
624                .map_err(|e| format!("check operation changes: {e}"))?;
625        for e in authored.entries {
626            paths.insert(e.path);
627        }
628    }
629
630    // Pre-flight (no mutation): reject the abort up front for any path whose
631    // reset would fail mid-loop, so abort stays all-or-nothing regardless of
632    // which target the caller resets toward. (Rebase vets `ensure_abort_safe`
633    // against `orig_tree` but resets `reset_conflict_paths` toward
634    // `head_tree`; a path present in `head_tree` but outside `orig_tree`'s
635    // change set would otherwise escape every pre-check and only fail in the
636    // mutation loop, after earlier-sorted paths were already reset.) This
637    // covers every way the loop below can error: writing a file where a
638    // directory now sits, writing under an ancestor that is now a file, and
639    // removing an op-added path the user replaced with a non-empty directory.
640    for path in &paths {
641        let abs = root.join(path);
642        if target_map.contains_key(path.as_str()) {
643            // The loop will WRITE target content here.
644            if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir()) {
645                return Err(format!(
646                    "abort would replace directory '{path}' with a file; move or remove it first"
647                ));
648            }
649            let mut prefix = String::new();
650            for comp in path.split('/') {
651                if !prefix.is_empty() {
652                    prefix.push('/');
653                }
654                prefix.push_str(comp);
655                if prefix == *path {
656                    break; // the leaf is handled by the is_dir check above
657                }
658                if fs::symlink_metadata(root.join(&prefix)).is_ok_and(|m| !m.is_dir()) {
659                    return Err(format!(
660                        "abort would restore '{path}' but '{prefix}' is a file; \
661                         move or remove it first"
662                    ));
663                }
664            }
665            continue;
666        }
667        let dir_prefix = format!("{path}/");
668        if target_map
669            .keys()
670            .any(|k| k.starts_with(dir_prefix.as_str()))
671        {
672            continue; // a pre-op directory left in place
673        }
674        // The loop will REMOVE this op-added path; refuse a non-empty dir.
675        if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir())
676            && fs::read_dir(&abs).is_ok_and(|mut it| it.next().is_some())
677        {
678            return Err(format!(
679                "abort would discard the untracked directory '{path}'; move or remove it first"
680            ));
681        }
682    }
683
684    let mut idx = super::read_or_seed_index_from_head(layout, store)?;
685
686    for path in &paths {
687        let abs = root.join(path);
688        if let Some(target_entry) = target_map.get(path.as_str()) {
689            // Restore the path's pre-op content + index entry, honouring
690            // the recorded symlink/exec mode (#214).
691            match target_entry.status {
692                EntryStatus::Symlink => {
693                    write_symlink_to_worktree(store, &abs, target_entry.object_hash)?;
694                }
695                EntryStatus::Executable => {
696                    write_blob_to_worktree(store, &abs, target_entry.object_hash, true)?;
697                }
698                _ => write_blob_to_worktree(store, &abs, target_entry.object_hash, false)?,
699            }
700            let entry = (*target_entry).clone();
701            idx.upsert_entry(entry);
702        } else {
703            // `path` is absent from the target tree as a FILE. But it may be a
704            // DIRECTORY there (a file-vs-directory conflict records the path
705            // `p` while the target carries `p/<children>`): in that case the
706            // pre-op directory already sits in the worktree — leave it and let
707            // the final restore align its contents; only drop any stale index
708            // entry literally at `p`.
709            let dir_prefix = format!("{path}/");
710            if target_map
711                .keys()
712                .any(|k| k.starts_with(dir_prefix.as_str()))
713            {
714                idx.remove_path(path);
715                continue;
716            }
717            // Otherwise the path did not exist pre-op (the operation added it):
718            // remove it and drop it from the index. If the user has since
719            // replaced it with a DIRECTORY, remove it only when EMPTY
720            // (`remove_dir`) — a non-empty directory holds untracked user
721            // content that abort must NOT silently destroy, so we fail closed.
722            if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir()) {
723                if let Err(e) = fs::remove_dir(&abs)
724                    && e.kind() != std::io::ErrorKind::NotFound
725                {
726                    return Err(format!(
727                        "abort would discard the untracked directory '{path}'; \
728                         move or remove it first"
729                    ));
730                }
731            } else if let Err(e) = fs::remove_file(&abs)
732                && e.kind() != std::io::ErrorKind::NotFound
733            {
734                return Err(format!("remove {}: {e}", abs.display()));
735            }
736            idx.remove_path(path);
737        }
738    }
739    index::write_index(layout, &idx).map_err(|e| format!("write index: {e}"))?;
740    Ok(())
741}
742
743/// Scan the worktree files listed in `records` for leftover conflict
744/// markers. Returns the first path that still contains markers, if any.
745///
746/// Only text-marker conflicts are scanned; binary/special paths are
747/// resolved out-of-band and are not marker-bearing.
748///
749/// # Errors
750/// Propagates filesystem read failures.
751/// `true` when `meta` has any executable bit set (Unix). On other
752/// platforms mkit never records `Executable`, so this is always false.
753#[cfg(unix)]
754fn is_executable(meta: &std::fs::Metadata) -> bool {
755    use std::os::unix::fs::PermissionsExt;
756    meta.permissions().mode() & 0o111 != 0
757}
758#[cfg(not(unix))]
759fn is_executable(_meta: &std::fs::Metadata) -> bool {
760    false
761}
762
763/// The canonical `(EntryStatus, Hash)` for the current worktree state at
764/// `abs`, mirroring exactly how `mkit add` would stage it (regular →
765/// Blob/Executable + `store_file_object`; symlink → Symlink + blob of the
766/// link target). `None` when the path is absent or a directory — neither
767/// has a single-file index representation.
768///
769/// # Errors
770/// Read/store failures as a message string.
771fn worktree_object(store: &ObjectStore, abs: &Path) -> Result<Option<(EntryStatus, Hash)>, String> {
772    let meta = match abs.symlink_metadata() {
773        Ok(m) => m,
774        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
775        Err(e) => return Err(format!("stat {}: {e}", abs.display())),
776    };
777    let ft = meta.file_type();
778    if ft.is_symlink() {
779        let target =
780            std::fs::read_link(abs).map_err(|e| format!("read link {}: {e}", abs.display()))?;
781        let target_str = target
782            .to_str()
783            .ok_or_else(|| format!("symlink target not UTF-8: {}", abs.display()))?;
784        let h = worktree::store_file_object(store, target_str.as_bytes())
785            .map_err(|e| format!("store symlink: {e}"))?;
786        return Ok(Some((EntryStatus::Symlink, h)));
787    }
788    if ft.is_file() {
789        let (opened, bytes) = worktree::read_regular_file_bounded(abs)
790            .map_err(|e| format!("read {}: {e}", abs.display()))?;
791        let h = worktree::store_file_object(store, &bytes).map_err(|e| format!("store: {e}"))?;
792        let status = if is_executable(&opened) {
793            EntryStatus::Executable
794        } else {
795            EntryStatus::Blob
796        };
797        return Ok(Some((status, h)));
798    }
799    Ok(None) // directory or other special file
800}
801
802/// Refuse `--continue` when a conflicted path's worktree resolution does
803/// not match what is staged in the index. The final tree is built from
804/// the index, so any unstaged resolution would be silently dropped and
805/// then overwritten by the worktree restore (#269).
806///
807/// This compares the worktree's canonical `(status, hash)` against the
808/// staged index entry, so it catches every shape of unstaged resolution:
809/// an edited regular **or executable** file, a path deleted/replaced
810/// (file→symlink, file→dir) without `mkit rm`/`mkit add`, etc. An
811/// *unchanged* conflict (worktree still equals the staged ours-side,
812/// including its exec/symlink mode) matches and continues without a
813/// re-`add` — preserving the #214 mode-resolution contract.
814///
815/// # Errors
816/// Returns a message naming the first unstaged-resolution path.
817pub fn ensure_conflict_paths_staged(
818    layout: &RepoLayout,
819    store: &ObjectStore,
820    records: &[ConflictRecord],
821) -> Result<(), String> {
822    let idx = index::read_index(layout).map_err(|e| format!("read index: {e}"))?;
823    for r in records {
824        let wt = worktree_object(store, &layout.worktree_root().join(&r.path))?;
825        // The staged entry for this path (if any). A `Removed` entry means
826        // "ours deleted it"; absence means no staged content.
827        let staged = idx.entries.iter().find(|e| e.path == r.path);
828        let staged_live = staged.filter(|e| e.status != EntryStatus::Removed);
829        let resolved = match (&wt, staged_live) {
830            // Worktree gone (deleted/dir) and nothing live staged → the
831            // deletion is recorded; consistent.
832            (None, None) => true,
833            // Worktree content matches the live staged entry exactly
834            // (content + mode) → resolved (incl. the unchanged #214 case).
835            (Some((ws, wh)), Some(e)) => *ws == e.status && *wh == e.object_hash,
836            // Worktree has content but nothing live staged, or worktree
837            // gone while content is still staged → unstaged resolution.
838            (Some(_), None) | (None, Some(_)) => false,
839        };
840        if !resolved {
841            return Err(format!(
842                "'{0}' is resolved in the worktree but not staged; run `mkit add {0}` (or `mkit rm {0}`) then `--continue`",
843                r.path
844            ));
845        }
846    }
847    Ok(())
848}
849
850pub fn first_unresolved_marker(
851    root: &Path,
852    records: &[ConflictRecord],
853) -> Result<Option<String>, String> {
854    for r in records {
855        let abs = root.join(&r.path);
856        // A file-vs-directory conflict kept the ours-DIRECTORY at the record
857        // path (the round-9 D/F pause). A directory holds no conflict markers,
858        // so skip it rather than letting `fs::read` fail "Is a directory" —
859        // which would make `--continue` permanently impossible for that pause.
860        if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir()) {
861            continue;
862        }
863        let data = match fs::read(&abs) {
864            Ok(d) => d,
865            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
866            Err(e) => return Err(format!("read {}: {e}", abs.display())),
867        };
868        if file_has_markers(&data) {
869            return Ok(Some(r.path.clone()));
870        }
871    }
872    Ok(None)
873}
874
875fn file_has_markers(data: &[u8]) -> bool {
876    let Ok(text) = core::str::from_utf8(data) else {
877        return false;
878    };
879    let mut saw_ours = false;
880    let mut saw_sep = false;
881    let mut saw_theirs = false;
882    for line in text.lines() {
883        if line == MARK_OURS {
884            saw_ours = true;
885        } else if line == MARK_SEP {
886            saw_sep = true;
887        } else if line == MARK_THEIRS {
888            saw_theirs = true;
889        }
890    }
891    saw_ours && saw_sep && saw_theirs
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897
898    #[test]
899    fn detects_complete_marker_set() {
900        let data = b"<<<<<<< ours\nfoo\n=======\nbar\n>>>>>>> theirs\n";
901        assert!(file_has_markers(data));
902    }
903
904    #[test]
905    fn ignores_partial_markers() {
906        let data = b"<<<<<<< ours\nfoo\n";
907        assert!(!file_has_markers(data));
908    }
909
910    #[test]
911    fn clean_file_has_no_markers() {
912        let data = b"just some resolved content\n";
913        assert!(!file_has_markers(data));
914    }
915
916    #[test]
917    fn text_detection() {
918        assert!(is_text(b"hello world\n"));
919        assert!(!is_text(b"\x00\x01\x02binary"));
920        assert!(!is_text(&[0xff, 0xfe, 0xfd]));
921    }
922}