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_observed_with_source(
446        &snapshot,
447        &snapshot,
448        root,
449        Some(&idx),
450        &mut Vec::new(),
451    )
452    .map_err(|e| format!("check worktree: {e}"))?;
453
454    // Discardable = the operation's OWN work that the user has not touched:
455    //   * recorded conflict paths (abort always throws away resolutions);
456    //   * operation-authored clean hunks whose current index AND worktree
457    //     content still match the operation result.
458    // A clean path the user has since edited (staged or in the worktree) is
459    // THEIR work — keep it non-discardable so the checks below refuse to
460    // destroy it. Without a result tree (legacy state) we fall back to the
461    // conflict records alone.
462    let conflict_paths: HashSet<String> = records.iter().map(|r| r.path.clone()).collect();
463    let mut discardable = conflict_paths.clone();
464    if let Some(result_tree) = op_result_tree {
465        let authored = mkit_core::ops::diff::diff_trees(&snapshot, current_tree, Some(result_tree))
466            .map_err(|e| format!("check operation changes: {e}"))?;
467        // Paths whose current index or worktree diverges from the operation
468        // result — i.e. the user changed them after the operation paused.
469        let mut modified: HashSet<String> = HashSet::new();
470        for e in mkit_core::ops::diff::diff_trees(&snapshot, Some(result_tree), Some(index_tree))
471            .map_err(|e| format!("check operation changes: {e}"))?
472            .entries
473        {
474            modified.insert(e.path);
475        }
476        for e in mkit_core::ops::diff::diff_trees(&snapshot, Some(result_tree), Some(worktree_tree))
477            .map_err(|e| format!("check operation changes: {e}"))?
478            .entries
479        {
480            modified.insert(e.path);
481        }
482        for e in authored.entries {
483            if conflict_paths.contains(&e.path) || !modified.contains(&e.path) {
484                discardable.insert(e.path);
485            }
486        }
487    }
488    let is_discardable = |p: &str| discardable.contains(p);
489
490    // Staged changes on a non-discardable path.
491    let staged = mkit_core::ops::diff::diff_trees(&snapshot, current_tree, Some(index_tree))
492        .map_err(|e| format!("check staged changes: {e}"))?;
493    if let Some(entry) = staged.entries.iter().find(|e| !is_discardable(&e.path)) {
494        return Err(format!(
495            "abort would overwrite staged changes; commit, stash, or reset '{}' first",
496            entry.path
497        ));
498    }
499
500    // Unstaged worktree edits on a non-discardable path.
501    let unstaged =
502        mkit_core::ops::diff::diff_trees(&snapshot, Some(index_tree), Some(worktree_tree))
503            .map_err(|e| format!("check worktree: {e}"))?;
504    if let Some(entry) = unstaged
505        .entries
506        .iter()
507        .find(|e| e.kind != mkit_core::ops::diff::DiffKind::Added && !is_discardable(&e.path))
508    {
509        return Err(format!(
510            "abort would overwrite local changes; commit, stash, or reset '{}' first",
511            entry.path
512        ));
513    }
514
515    // Untracked path that collides with a non-conflict path the restore
516    // would write.
517    let target_writes: Vec<String> =
518        mkit_core::ops::diff::diff_trees(&snapshot, Some(index_tree), Some(target_tree))
519            .map_err(|e| format!("check restore target: {e}"))?
520            .entries
521            .into_iter()
522            .filter(|e| e.kind != mkit_core::ops::diff::DiffKind::Removed)
523            .filter(|e| !is_discardable(&e.path))
524            .map(|e| e.path)
525            .collect();
526    if !target_writes.is_empty() {
527        for entry in &unstaged.entries {
528            if entry.kind == mkit_core::ops::diff::DiffKind::Added
529                && !is_discardable(&entry.path)
530                && target_writes.iter().any(|t| t == &entry.path)
531            {
532                return Err(format!(
533                    "abort would overwrite untracked path '{}'; move or remove it first",
534                    entry.path
535                ));
536            }
537        }
538    }
539
540    // Restoring `target_tree` writes a file at every path it adds/changes
541    // relative to the current index — INCLUDING discardable paths (e.g. a
542    // file the operation cleanly deleted). Refuse if any such path is now a
543    // DIRECTORY in the worktree (e.g. the user created `d/keep` after the
544    // operation deleted file `d`): the restore would fail part-way and
545    // removing the directory would destroy the user's untracked content.
546    // Checked here, before any mutation, so abort stays all-or-nothing.
547    for entry in &mkit_core::ops::diff::diff_trees(&snapshot, Some(index_tree), Some(target_tree))
548        .map_err(|e| format!("check restore target: {e}"))?
549        .entries
550    {
551        if entry.kind == mkit_core::ops::diff::DiffKind::Removed {
552            continue;
553        }
554        // The restore writes a file at `entry.path`. Refuse if the path itself
555        // is now a DIRECTORY (e.g. the user created `d/keep` after the
556        // operation deleted file `d`)...
557        if std::fs::symlink_metadata(root.join(&entry.path)).is_ok_and(|m| m.is_dir()) {
558            return Err(format!(
559                "abort would replace directory '{}' with a file; move or remove it first",
560                entry.path
561            ));
562        }
563        // ...or if any ANCESTOR component is now a non-directory file (e.g.
564        // target has `p/file`; the user replaced the deleted directory `p`
565        // with a file `p`). `create_dir_all` would fail mid-restore, breaking
566        // abort atomicity. Checked here, before any mutation.
567        let mut prefix = String::new();
568        for comp in entry.path.split('/') {
569            if !prefix.is_empty() {
570                prefix.push('/');
571            }
572            prefix.push_str(comp);
573            if prefix == entry.path {
574                break; // the leaf is handled by the is_dir check above
575            }
576            if std::fs::symlink_metadata(root.join(&prefix)).is_ok_and(|m| !m.is_dir()) {
577                return Err(format!(
578                    "abort would restore '{}' but '{prefix}' is a file; move or remove it first",
579                    entry.path
580                ));
581            }
582        }
583    }
584    Ok(())
585}
586
587/// Discard conflict material on the recorded conflict paths, resetting
588/// each back to its content in `target_tree` (the pre-op HEAD): write
589/// the target blob into the worktree (or delete the file when the path
590/// is absent from `target_tree`) and align the index entry.
591///
592/// This is the abort precondition: after it runs, the worktree and
593/// index agree with `target_tree` on every conflict path, so the
594/// subsequent guarded restore sees no spurious "local changes" on the
595/// paths we ourselves mutated — while still protecting genuinely
596/// unrelated dirty/untracked paths.
597///
598/// # Errors
599/// Propagates store / filesystem failures.
600#[allow(clippy::too_many_lines)] // a pre-flight pass + the mutation pass, kept together
601pub fn reset_conflict_paths(
602    layout: &RepoLayout,
603    store: &ObjectStore,
604    records: &[ConflictRecord],
605    target_tree: Hash,
606    op_result_tree: Option<Hash>,
607) -> Result<(), String> {
608    use std::collections::{BTreeSet, HashMap};
609
610    let root = layout.worktree_root();
611
612    // Flatten the target tree into path → (mode, hash).
613    let target_idx =
614        index::from_tree(store, target_tree).map_err(|e| format!("read target tree: {e}"))?;
615    let target_map: HashMap<&str, &IndexEntry> = target_idx
616        .entries
617        .iter()
618        .map(|e| (e.path.as_str(), e))
619        .collect();
620
621    // Reset every path the operation authored: the recorded conflict paths
622    // PLUS the operation's clean hunks (paths it changed vs the pre-op HEAD).
623    // The reset is purely target-content driven, so it generalizes from
624    // conflict records to any operation-authored path.
625    let mut paths: BTreeSet<String> = records.iter().map(|r| r.path.clone()).collect();
626    if let Some(result_tree) = op_result_tree {
627        let snapshot = mkit_core::store::EphemeralSink::new(store);
628        let authored =
629            mkit_core::ops::diff::diff_trees(&snapshot, Some(target_tree), Some(result_tree))
630                .map_err(|e| format!("check operation changes: {e}"))?;
631        for e in authored.entries {
632            paths.insert(e.path);
633        }
634    }
635
636    // Pre-flight (no mutation): reject the abort up front for any path whose
637    // reset would fail mid-loop, so abort stays all-or-nothing regardless of
638    // which target the caller resets toward. (Rebase vets `ensure_abort_safe`
639    // against `orig_tree` but resets `reset_conflict_paths` toward
640    // `head_tree`; a path present in `head_tree` but outside `orig_tree`'s
641    // change set would otherwise escape every pre-check and only fail in the
642    // mutation loop, after earlier-sorted paths were already reset.) This
643    // covers every way the loop below can error: writing a file where a
644    // directory now sits, writing under an ancestor that is now a file, and
645    // removing an op-added path the user replaced with a non-empty directory.
646    for path in &paths {
647        let abs = root.join(path);
648        if target_map.contains_key(path.as_str()) {
649            // The loop will WRITE target content here.
650            if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir()) {
651                return Err(format!(
652                    "abort would replace directory '{path}' with a file; move or remove it first"
653                ));
654            }
655            let mut prefix = String::new();
656            for comp in path.split('/') {
657                if !prefix.is_empty() {
658                    prefix.push('/');
659                }
660                prefix.push_str(comp);
661                if prefix == *path {
662                    break; // the leaf is handled by the is_dir check above
663                }
664                if fs::symlink_metadata(root.join(&prefix)).is_ok_and(|m| !m.is_dir()) {
665                    return Err(format!(
666                        "abort would restore '{path}' but '{prefix}' is a file; \
667                         move or remove it first"
668                    ));
669                }
670            }
671            continue;
672        }
673        let dir_prefix = format!("{path}/");
674        if target_map
675            .keys()
676            .any(|k| k.starts_with(dir_prefix.as_str()))
677        {
678            continue; // a pre-op directory left in place
679        }
680        // The loop will REMOVE this op-added path; refuse a non-empty dir.
681        if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir())
682            && fs::read_dir(&abs).is_ok_and(|mut it| it.next().is_some())
683        {
684            return Err(format!(
685                "abort would discard the untracked directory '{path}'; move or remove it first"
686            ));
687        }
688    }
689
690    let mut idx = super::read_or_seed_index_from_head(layout, store)?;
691
692    for path in &paths {
693        let abs = root.join(path);
694        if let Some(target_entry) = target_map.get(path.as_str()) {
695            // Restore the path's pre-op content + index entry, honouring
696            // the recorded symlink/exec mode (#214).
697            match target_entry.status {
698                EntryStatus::Symlink => {
699                    write_symlink_to_worktree(store, &abs, target_entry.object_hash)?;
700                }
701                EntryStatus::Executable => {
702                    write_blob_to_worktree(store, &abs, target_entry.object_hash, true)?;
703                }
704                _ => write_blob_to_worktree(store, &abs, target_entry.object_hash, false)?,
705            }
706            let entry = (*target_entry).clone();
707            idx.upsert_entry(entry);
708        } else {
709            // `path` is absent from the target tree as a FILE. But it may be a
710            // DIRECTORY there (a file-vs-directory conflict records the path
711            // `p` while the target carries `p/<children>`): in that case the
712            // pre-op directory already sits in the worktree — leave it and let
713            // the final restore align its contents; only drop any stale index
714            // entry literally at `p`.
715            let dir_prefix = format!("{path}/");
716            if target_map
717                .keys()
718                .any(|k| k.starts_with(dir_prefix.as_str()))
719            {
720                idx.remove_path(path);
721                continue;
722            }
723            // Otherwise the path did not exist pre-op (the operation added it):
724            // remove it and drop it from the index. If the user has since
725            // replaced it with a DIRECTORY, remove it only when EMPTY
726            // (`remove_dir`) — a non-empty directory holds untracked user
727            // content that abort must NOT silently destroy, so we fail closed.
728            if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir()) {
729                if let Err(e) = fs::remove_dir(&abs)
730                    && e.kind() != std::io::ErrorKind::NotFound
731                {
732                    return Err(format!(
733                        "abort would discard the untracked directory '{path}'; \
734                         move or remove it first"
735                    ));
736                }
737            } else if let Err(e) = fs::remove_file(&abs)
738                && e.kind() != std::io::ErrorKind::NotFound
739            {
740                return Err(format!("remove {}: {e}", abs.display()));
741            }
742            idx.remove_path(path);
743        }
744    }
745    index::write_index(layout, &idx).map_err(|e| format!("write index: {e}"))?;
746    Ok(())
747}
748
749/// Scan the worktree files listed in `records` for leftover conflict
750/// markers. Returns the first path that still contains markers, if any.
751///
752/// Only text-marker conflicts are scanned; binary/special paths are
753/// resolved out-of-band and are not marker-bearing.
754///
755/// # Errors
756/// Propagates filesystem read failures.
757/// `true` when `meta` has any executable bit set (Unix). On other
758/// platforms mkit never records `Executable`, so this is always false.
759#[cfg(unix)]
760fn is_executable(meta: &std::fs::Metadata) -> bool {
761    use std::os::unix::fs::PermissionsExt;
762    meta.permissions().mode() & 0o111 != 0
763}
764#[cfg(not(unix))]
765fn is_executable(_meta: &std::fs::Metadata) -> bool {
766    false
767}
768
769/// The canonical `(EntryStatus, Hash)` for the current worktree state at
770/// `abs`, mirroring exactly how `mkit add` would stage it (regular →
771/// Blob/Executable + `store_file_object`; symlink → Symlink + blob of the
772/// link target). `None` when the path is absent or a directory — neither
773/// has a single-file index representation.
774///
775/// # Errors
776/// Read/store failures as a message string.
777fn worktree_object(store: &ObjectStore, abs: &Path) -> Result<Option<(EntryStatus, Hash)>, String> {
778    let meta = match abs.symlink_metadata() {
779        Ok(m) => m,
780        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
781        Err(e) => return Err(format!("stat {}: {e}", abs.display())),
782    };
783    let ft = meta.file_type();
784    if ft.is_symlink() {
785        let target =
786            std::fs::read_link(abs).map_err(|e| format!("read link {}: {e}", abs.display()))?;
787        let target_str = target
788            .to_str()
789            .ok_or_else(|| format!("symlink target not UTF-8: {}", abs.display()))?;
790        let h = worktree::store_file_object(store, target_str.as_bytes())
791            .map_err(|e| format!("store symlink: {e}"))?;
792        return Ok(Some((EntryStatus::Symlink, h)));
793    }
794    if ft.is_file() {
795        let (opened, bytes) = worktree::read_regular_file_bounded(abs)
796            .map_err(|e| format!("read {}: {e}", abs.display()))?;
797        let h = worktree::store_file_object(store, &bytes).map_err(|e| format!("store: {e}"))?;
798        let status = if is_executable(&opened) {
799            EntryStatus::Executable
800        } else {
801            EntryStatus::Blob
802        };
803        return Ok(Some((status, h)));
804    }
805    Ok(None) // directory or other special file
806}
807
808/// Refuse `--continue` when a conflicted path's worktree resolution does
809/// not match what is staged in the index. The final tree is built from
810/// the index, so any unstaged resolution would be silently dropped and
811/// then overwritten by the worktree restore (#269).
812///
813/// This compares the worktree's canonical `(status, hash)` against the
814/// staged index entry, so it catches every shape of unstaged resolution:
815/// an edited regular **or executable** file, a path deleted/replaced
816/// (file→symlink, file→dir) without `mkit rm`/`mkit add`, etc. An
817/// *unchanged* conflict (worktree still equals the staged ours-side,
818/// including its exec/symlink mode) matches and continues without a
819/// re-`add` — preserving the #214 mode-resolution contract.
820///
821/// # Errors
822/// Returns a message naming the first unstaged-resolution path.
823pub fn ensure_conflict_paths_staged(
824    layout: &RepoLayout,
825    store: &ObjectStore,
826    records: &[ConflictRecord],
827) -> Result<(), String> {
828    let idx = index::read_index(layout).map_err(|e| format!("read index: {e}"))?;
829    for r in records {
830        let wt = worktree_object(store, &layout.worktree_root().join(&r.path))?;
831        // The staged entry for this path (if any). A `Removed` entry means
832        // "ours deleted it"; absence means no staged content.
833        let staged = idx.entries.iter().find(|e| e.path == r.path);
834        let staged_live = staged.filter(|e| e.status != EntryStatus::Removed);
835        let resolved = match (&wt, staged_live) {
836            // Worktree gone (deleted/dir) and nothing live staged → the
837            // deletion is recorded; consistent.
838            (None, None) => true,
839            // Worktree content matches the live staged entry exactly
840            // (content + mode) → resolved (incl. the unchanged #214 case).
841            (Some((ws, wh)), Some(e)) => *ws == e.status && *wh == e.object_hash,
842            // Worktree has content but nothing live staged, or worktree
843            // gone while content is still staged → unstaged resolution.
844            (Some(_), None) | (None, Some(_)) => false,
845        };
846        if !resolved {
847            return Err(format!(
848                "'{0}' is resolved in the worktree but not staged; run `mkit add {0}` (or `mkit rm {0}`) then `--continue`",
849                r.path
850            ));
851        }
852    }
853    Ok(())
854}
855
856pub fn first_unresolved_marker(
857    root: &Path,
858    records: &[ConflictRecord],
859) -> Result<Option<String>, String> {
860    for r in records {
861        let abs = root.join(&r.path);
862        // A file-vs-directory conflict kept the ours-DIRECTORY at the record
863        // path (the round-9 D/F pause). A directory holds no conflict markers,
864        // so skip it rather than letting `fs::read` fail "Is a directory" —
865        // which would make `--continue` permanently impossible for that pause.
866        if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir()) {
867            continue;
868        }
869        let data = match fs::read(&abs) {
870            Ok(d) => d,
871            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
872            Err(e) => return Err(format!("read {}: {e}", abs.display())),
873        };
874        if file_has_markers(&data) {
875            return Ok(Some(r.path.clone()));
876        }
877    }
878    Ok(None)
879}
880
881fn file_has_markers(data: &[u8]) -> bool {
882    let Ok(text) = core::str::from_utf8(data) else {
883        return false;
884    };
885    let mut saw_ours = false;
886    let mut saw_sep = false;
887    let mut saw_theirs = false;
888    for line in text.lines() {
889        if line == MARK_OURS {
890            saw_ours = true;
891        } else if line == MARK_SEP {
892            saw_sep = true;
893        } else if line == MARK_THEIRS {
894            saw_theirs = true;
895        }
896    }
897    saw_ours && saw_sep && saw_theirs
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    #[test]
905    fn detects_complete_marker_set() {
906        let data = b"<<<<<<< ours\nfoo\n=======\nbar\n>>>>>>> theirs\n";
907        assert!(file_has_markers(data));
908    }
909
910    #[test]
911    fn ignores_partial_markers() {
912        let data = b"<<<<<<< ours\nfoo\n";
913        assert!(!file_has_markers(data));
914    }
915
916    #[test]
917    fn clean_file_has_no_markers() {
918        let data = b"just some resolved content\n";
919        assert!(!file_has_markers(data));
920    }
921
922    #[test]
923    fn text_detection() {
924        assert!(is_text(b"hello world\n"));
925        assert!(!is_text(b"\x00\x01\x02binary"));
926        assert!(!is_text(&[0xff, 0xfe, 0xfd]));
927    }
928}