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::object::{EntryMode, Object};
20use mkit_core::ops::conflict_state::ConflictRecord;
21use mkit_core::ops::merge::{Conflict, ConflictKind};
22use mkit_core::store::ObjectStore;
23use mkit_core::worktree;
24
25/// Classification of how a conflicting path is presented to the user.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ConflictClass {
28    /// Text modify/modify or add/add: classic 2-way Git markers are
29    /// written into the worktree file.
30    TextMarkers,
31    /// Binary blob on either side: no markers (they would corrupt the
32    /// file); the ours-side content is left in place for manual edit.
33    Binary,
34    /// Delete/modify: one side removed the path; the surviving content
35    /// is left in place; resolve by `mkit add` or `mkit rm`.
36    DeleteModify,
37    /// Symlink or executable-mode change, or any other shape unsafe for
38    /// markers: ours-side content/mode is left in place for manual edit.
39    Special,
40}
41
42/// Marker lines, kept as constants so the leftover scanner and the
43/// writer agree byte-for-byte.
44const MARK_OURS: &str = "<<<<<<< ours";
45const MARK_SEP: &str = "=======";
46const MARK_THEIRS: &str = ">>>>>>> theirs";
47
48/// Decide whether a blob's bytes are safe to wrap in text markers.
49fn is_text(data: &[u8]) -> bool {
50    // No NUL bytes and valid UTF-8 — the same heuristic used for the
51    // diff path. A NUL is the classic "this is binary" tell.
52    !data.contains(&0) && core::str::from_utf8(data).is_ok()
53}
54
55fn read_blob(store: &ObjectStore, h: Hash) -> Result<Vec<u8>, String> {
56    match store.read_object(&h) {
57        Ok(Object::Blob(b)) => Ok(b.data),
58        Ok(_) => Err("conflict side is not a blob".to_string()),
59        Err(e) => Err(format!("read conflict blob: {e}")),
60    }
61}
62
63/// `true` when `h` points at a blob object (as opposed to a tree, which
64/// is how a file-vs-directory conflict surfaces on one side).
65fn is_blob(store: &ObjectStore, h: Hash) -> bool {
66    matches!(store.read_object(&h), Ok(Object::Blob(_)))
67}
68
69/// `true` when a conflict side is absent or points at a blob. A side
70/// that points at a tree (file-vs-directory) is neither.
71fn side_is_blob_or_absent(store: &ObjectStore, side: Option<Hash>) -> bool {
72    match side {
73        None => true,
74        Some(h) => is_blob(store, h),
75    }
76}
77
78/// `true` when a side's tree mode is a symlink or executable — shapes
79/// that conflict markers cannot represent and that must round-trip their
80/// exact mode (#214).
81fn side_is_special_mode(mode: Option<EntryMode>) -> bool {
82    matches!(mode, Some(EntryMode::Symlink | EntryMode::Executable))
83}
84
85/// Classify a single conflict given its blob contents.
86///
87/// # Errors
88/// Propagates object-store read failures.
89pub fn classify(store: &ObjectStore, c: &Conflict) -> Result<ConflictClass, String> {
90    match c.kind {
91        ConflictKind::DeleteModify => Ok(ConflictClass::DeleteModify),
92        ConflictKind::ModifyModify | ConflictKind::AddAdd => {
93            // File-vs-directory: one side is a tree. Markers are unsafe;
94            // route to Special (the blob side is left in the worktree).
95            if !side_is_blob_or_absent(store, c.ours_hash)
96                || !side_is_blob_or_absent(store, c.theirs_hash)
97            {
98                return Ok(ConflictClass::Special);
99            }
100            // Symlink / executable on either side (#214): the merge
101            // engine now carries the real `EntryMode`, so we route these
102            // to Special unambiguously instead of guessing from bytes.
103            // Writing conflict markers into a symlink target is
104            // meaningless, and an executable's content is rarely a clean
105            // text merge — the user resolves manually and the ours-side
106            // mode is preserved into the worktree + index.
107            if side_is_special_mode(c.ours_mode) || side_is_special_mode(c.theirs_mode) {
108                return Ok(ConflictClass::Special);
109            }
110            // Otherwise fall back to the byte heuristic: any non-UTF-8 /
111            // NUL-bearing side is binary; everything else is text.
112            let ours_text = match c.ours_hash {
113                Some(h) => is_text(&read_blob(store, h)?),
114                None => true,
115            };
116            let theirs_text = match c.theirs_hash {
117                Some(h) => is_text(&read_blob(store, h)?),
118                None => true,
119            };
120            if ours_text && theirs_text {
121                Ok(ConflictClass::TextMarkers)
122            } else {
123                Ok(ConflictClass::Binary)
124            }
125        }
126    }
127}
128
129/// Materialise every conflict into the worktree and stage the ours-side
130/// blob into the index so each conflicting path is "resolvable":
131///
132/// - **text**: write `<<<<<<< ours / ======= / >>>>>>> theirs` markers.
133/// - **binary / special / delete-modify**: leave the surviving content
134///   in the worktree, print a per-path manual-resolution note.
135///
136/// The index entry for each path is set to the ours-side blob (or
137/// removed for an ours-deleted delete/modify) so a subsequent
138/// `mkit add` after resolution updates it normally and `--continue`
139/// builds the tree from the resolved index/worktree.
140///
141/// `merged_tree` is the operation's full merge-result tree (holding
142/// "ours" at every conflicted path and the clean changes everywhere
143/// else). It is applied to the index + worktree FIRST — otherwise the
144/// non-conflicting changes would never reach the index and `--continue`
145/// (which builds from the index) would silently drop them (#269). The
146/// caller runs [`super::ensure_restore_safe`] over `merged_tree` first,
147/// so this never clobbers dirty tracked or untracked content. Conflict
148/// markers are then overlaid on the conflicted paths.
149///
150/// Returns the per-path [`ConflictRecord`]s for the sidecar.
151///
152/// # Errors
153/// Propagates store / filesystem failures as a message string.
154pub fn materialize_conflicts(
155    root: &Path,
156    store: &ObjectStore,
157    merged_tree: Hash,
158    conflicts: &[Conflict],
159) -> Result<Vec<ConflictRecord>, String> {
160    // Apply the merged result (clean changes + "ours" at conflict paths)
161    // to the index and worktree, then overlay markers below.
162    super::restore_worktree_and_index(root, store, merged_tree)?;
163    let mut idx = index::read_index(root).map_err(|e| format!("read index: {e}"))?;
164    let mut records = Vec::with_capacity(conflicts.len());
165    let mut stderr = std::io::stderr().lock();
166
167    for c in conflicts {
168        let class = classify(store, c)?;
169        let abs = root.join(&c.path);
170        match class {
171            ConflictClass::TextMarkers => {
172                let ours = match c.ours_hash {
173                    Some(h) => read_blob(store, h)?,
174                    None => Vec::new(),
175                };
176                let theirs = match c.theirs_hash {
177                    Some(h) => read_blob(store, h)?,
178                    None => Vec::new(),
179                };
180                write_text_markers(&abs, &ours, &theirs)?;
181                let _ = writeln!(stderr, "  {} (text conflict — edit markers)", c.path);
182                stage_ours(&mut idx, store, c);
183            }
184            ConflictClass::Binary => {
185                materialize_conflict_side(store, &abs, c)?;
186                let _ = writeln!(
187                    stderr,
188                    "  {} (binary conflict — resolve manually, then `mkit add`)",
189                    c.path
190                );
191                stage_ours(&mut idx, store, c);
192            }
193            ConflictClass::DeleteModify => {
194                // Keep the surviving (modified) side in the worktree,
195                // honouring its exec/symlink mode (#214).
196                materialize_conflict_side(store, &abs, c)?;
197                let _ = writeln!(
198                    stderr,
199                    "  {} (delete/modify — keep with `mkit add` or drop with `mkit rm`)",
200                    c.path
201                );
202                stage_ours(&mut idx, store, c);
203            }
204            ConflictClass::Special => {
205                materialize_conflict_side(store, &abs, c)?;
206                let _ = writeln!(
207                    stderr,
208                    "  {} (mode/symlink conflict — resolve manually, then `mkit add`)",
209                    c.path
210                );
211                stage_ours(&mut idx, store, c);
212            }
213        }
214        records.push(ConflictRecord::from(c));
215    }
216
217    index::write_index(root, &idx).map_err(|e| format!("write index: {e}"))?;
218    Ok(records)
219}
220
221/// Map a tree [`EntryMode`] to the index [`EntryStatus`] that preserves
222/// it. `Tree` has no single-file index representation and is reported by
223/// the caller (which only stages blob ours-sides), so it falls back to
224/// `Blob` defensively.
225fn status_for_mode(mode: EntryMode) -> EntryStatus {
226    match mode {
227        EntryMode::Executable => EntryStatus::Executable,
228        EntryMode::Symlink => EntryStatus::Symlink,
229        EntryMode::Blob | EntryMode::Tree => EntryStatus::Blob,
230    }
231}
232
233/// Stage the ours-side blob for a conflict into the index (or mark
234/// removed when ours deleted it). Keeps the index a single-stage
235/// resolved snapshot.
236///
237/// The ours-side [`EntryMode`] carried on the [`Conflict`] (#214) is
238/// preserved into the staged [`EntryStatus`] so executable bits and
239/// symlinks survive `--continue` across merge / cherry-pick / rebase —
240/// `build_tree_from_index` derives the committed tree mode from the
241/// index status, so a default-`Blob` here would silently demote an
242/// executable or symlink to a plain file.
243fn stage_ours(idx: &mut mkit_core::index::Index, store: &ObjectStore, c: &Conflict) {
244    let entry = match c.ours_hash {
245        // Only stage a blob ours-side. A tree ours-side (file-vs-dir)
246        // is left for the user to resolve and `mkit add`.
247        Some(h) if is_blob(store, h) => IndexEntry {
248            path: c.path.clone(),
249            status: c.ours_mode.map_or(EntryStatus::Blob, status_for_mode),
250            object_hash: h,
251            mtime_ns: 0,
252            size: 0,
253            ino: 0,
254            ctime_ns: 0,
255        },
256        Some(_) => return,
257        None => IndexEntry {
258            path: c.path.clone(),
259            status: EntryStatus::Removed,
260            object_hash: mkit_core::hash::ZERO,
261            mtime_ns: 0,
262            size: 0,
263            ino: 0,
264            ctime_ns: 0,
265        },
266    };
267    if let Some(pos) = idx.find_entry(&c.path) {
268        idx.entries[pos] = entry;
269    } else {
270        idx.entries.push(entry);
271    }
272}
273
274fn write_text_markers(abs: &Path, ours: &[u8], theirs: &[u8]) -> Result<(), String> {
275    let mut buf = Vec::new();
276    buf.extend_from_slice(MARK_OURS.as_bytes());
277    buf.push(b'\n');
278    buf.extend_from_slice(ours);
279    if !ours.is_empty() && ours.last() != Some(&b'\n') {
280        buf.push(b'\n');
281    }
282    buf.extend_from_slice(MARK_SEP.as_bytes());
283    buf.push(b'\n');
284    buf.extend_from_slice(theirs);
285    if !theirs.is_empty() && theirs.last() != Some(&b'\n') {
286        buf.push(b'\n');
287    }
288    buf.extend_from_slice(MARK_THEIRS.as_bytes());
289    buf.push(b'\n');
290    write_bytes(abs, &buf)
291}
292
293/// Materialise the surviving side of a binary / special conflict into
294/// the worktree, honouring its tree mode (#214).
295///
296/// We prefer the ours-side (the side `stage_ours` records in the index)
297/// so the worktree file and the staged index entry agree; if ours is
298/// absent or a tree we fall back to theirs. Symlink sides become a real
299/// symlink (not a regular file holding the target text); executable
300/// sides get the exec bit. If neither side is a blob, whatever is
301/// already in the worktree is left untouched.
302fn materialize_conflict_side(store: &ObjectStore, abs: &Path, c: &Conflict) -> Result<(), String> {
303    let pick = [(c.ours_hash, c.ours_mode), (c.theirs_hash, c.theirs_mode)]
304        .into_iter()
305        .find_map(|(h, m)| match h {
306            Some(h) if is_blob(store, h) => Some((h, m)),
307            _ => None,
308        });
309    let Some((h, mode)) = pick else {
310        return Ok(());
311    };
312    match mode {
313        Some(EntryMode::Symlink) => write_symlink_to_worktree(store, abs, h),
314        Some(EntryMode::Executable) => write_blob_to_worktree(store, abs, h, true),
315        _ => write_blob_to_worktree(store, abs, h, false),
316    }
317}
318
319fn write_blob_to_worktree(
320    store: &ObjectStore,
321    abs: &Path,
322    h: Hash,
323    executable: bool,
324) -> Result<(), String> {
325    let data = read_blob(store, h)?;
326    // Replace any existing symlink/file at the path so a prior shape
327    // does not shadow the regular file we are about to write.
328    let _ = fs::remove_file(abs);
329    write_bytes(abs, &data)?;
330    if executable {
331        set_executable(abs)?;
332    }
333    Ok(())
334}
335
336/// Materialise a symlink blob (payload = target string) as a real
337/// symlink, mirroring `restore::restore_symlink`'s `..`-free target
338/// validation so a conflict cannot smuggle an escaping link.
339fn write_symlink_to_worktree(store: &ObjectStore, abs: &Path, h: Hash) -> Result<(), String> {
340    let data = read_blob(store, h)?;
341    let target = core::str::from_utf8(&data)
342        .map_err(|_| format!("symlink target for {} is not UTF-8", abs.display()))?;
343    if !mkit_core::worktree::validate_symlink_target(target) {
344        return Err(format!(
345            "refusing to materialise unsafe symlink target {target:?} for {}",
346            abs.display()
347        ));
348    }
349    if let Some(parent) = abs.parent() {
350        fs::create_dir_all(parent).map_err(|e| format!("create dir {}: {e}", parent.display()))?;
351    }
352    // Remove any existing file/symlink so the create does not race a
353    // stale entry of the wrong shape.
354    let _ = fs::remove_file(abs);
355    create_symlink(target, abs).map_err(|e| format!("create symlink {}: {e}", abs.display()))
356}
357
358#[cfg(unix)]
359fn set_executable(abs: &Path) -> Result<(), String> {
360    use std::os::unix::fs::PermissionsExt;
361    let mut perm = fs::metadata(abs)
362        .map_err(|e| format!("stat {}: {e}", abs.display()))?
363        .permissions();
364    perm.set_mode(0o755);
365    fs::set_permissions(abs, perm).map_err(|e| format!("chmod {}: {e}", abs.display()))
366}
367
368#[cfg(not(unix))]
369#[allow(clippy::unnecessary_wraps)]
370fn set_executable(_abs: &Path) -> Result<(), String> {
371    Ok(())
372}
373
374#[cfg(unix)]
375fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
376    std::os::unix::fs::symlink(target, link)
377}
378
379#[cfg(windows)]
380fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
381    std::os::windows::fs::symlink_file(target, link)
382}
383
384#[cfg(not(any(unix, windows)))]
385fn create_symlink(_target: &str, _link: &Path) -> std::io::Result<()> {
386    Err(std::io::Error::new(
387        std::io::ErrorKind::Unsupported,
388        "symlink creation is not supported on this target",
389    ))
390}
391
392fn write_bytes(abs: &Path, data: &[u8]) -> Result<(), String> {
393    if let Some(parent) = abs.parent() {
394        fs::create_dir_all(parent).map_err(|e| format!("create dir {}: {e}", parent.display()))?;
395    }
396    fs::write(abs, data).map_err(|e| format!("write {}: {e}", abs.display()))
397}
398
399/// Pre-abort safety gate: refuse the abort *before* it mutates anything
400/// when restoring to `target_tree` would overwrite genuine user work on
401/// a path that is **not** part of the recorded conflict set.
402///
403/// `--abort` works by first resetting the conflict paths (discarding the
404/// conflict material mkit itself wrote) and then doing a guarded restore
405/// to the pre-op tree. The conflict-path reset is destructive, so it
406/// must not run if the abort is going to be refused anyway: otherwise a
407/// failed abort would silently throw away the user's in-progress
408/// resolution of the conflicting files while leaving operation state in
409/// place. This check inspects only the non-conflict paths (the conflict
410/// paths are expected to be dirty — they hold markers / partial edits)
411/// and mirrors [`super::ensure_restore_safe`]'s staged / unstaged /
412/// untracked-collision detection for them.
413///
414/// # Errors
415/// Returns a message describing the blocking path when the abort would
416/// be unsafe, or propagates store / filesystem failures.
417pub fn ensure_abort_safe(
418    root: &Path,
419    store: &ObjectStore,
420    records: &[ConflictRecord],
421    target_tree: Hash,
422) -> Result<(), String> {
423    use std::collections::HashSet;
424
425    let conflict_paths: HashSet<&str> = records.iter().map(|r| r.path.as_str()).collect();
426    let is_conflict = |p: &str| conflict_paths.contains(p);
427
428    let current_tree = super::current_head_tree(root, store)?;
429    let idx = super::read_or_seed_index_from_head(root, store)?;
430    // Safety-check snapshot trees are ephemeral — in-memory overlay.
431    let snapshot = mkit_core::store::EphemeralSink::new(store);
432    let index_tree = mkit_core::worktree::build_tree_from_index_with(store, &snapshot, &idx, false)
433        .map_err(|e| format!("check index state: {e}"))?;
434
435    // Staged changes on a non-conflict path.
436    let staged = mkit_core::ops::diff::diff_trees(&snapshot, current_tree, Some(index_tree))
437        .map_err(|e| format!("check staged changes: {e}"))?;
438    if let Some(entry) = staged.entries.iter().find(|e| !is_conflict(&e.path)) {
439        return Err(format!(
440            "abort would overwrite staged changes; commit, stash, or reset '{}' first",
441            entry.path
442        ));
443    }
444
445    // Unstaged worktree edits on a non-conflict path. Pass the seeded index
446    // as the tracked set so a tracked file matching an ignore rule isn't
447    // dropped from the snapshot and misread as a deletion.
448    let worktree_tree = mkit_core::worktree::build_tree_filtered(&snapshot, root, Some(&idx))
449        .map_err(|e| format!("check worktree: {e}"))?;
450    let unstaged =
451        mkit_core::ops::diff::diff_trees(&snapshot, Some(index_tree), Some(worktree_tree))
452            .map_err(|e| format!("check worktree: {e}"))?;
453    if let Some(entry) = unstaged
454        .entries
455        .iter()
456        .find(|e| e.kind != mkit_core::ops::diff::DiffKind::Added && !is_conflict(&e.path))
457    {
458        return Err(format!(
459            "abort would overwrite local changes; commit, stash, or reset '{}' first",
460            entry.path
461        ));
462    }
463
464    // Untracked path that collides with a non-conflict path the restore
465    // would write.
466    let target_writes: Vec<String> =
467        mkit_core::ops::diff::diff_trees(&snapshot, Some(index_tree), Some(target_tree))
468            .map_err(|e| format!("check restore target: {e}"))?
469            .entries
470            .into_iter()
471            .filter(|e| e.kind != mkit_core::ops::diff::DiffKind::Removed)
472            .filter(|e| !is_conflict(&e.path))
473            .map(|e| e.path)
474            .collect();
475    if !target_writes.is_empty() {
476        for entry in &unstaged.entries {
477            if entry.kind == mkit_core::ops::diff::DiffKind::Added
478                && !is_conflict(&entry.path)
479                && target_writes.iter().any(|t| t == &entry.path)
480            {
481                return Err(format!(
482                    "abort would overwrite untracked path '{}'; move or remove it first",
483                    entry.path
484                ));
485            }
486        }
487    }
488    Ok(())
489}
490
491/// Discard conflict material on the recorded conflict paths, resetting
492/// each back to its content in `target_tree` (the pre-op HEAD): write
493/// the target blob into the worktree (or delete the file when the path
494/// is absent from `target_tree`) and align the index entry.
495///
496/// This is the abort precondition: after it runs, the worktree and
497/// index agree with `target_tree` on every conflict path, so the
498/// subsequent guarded restore sees no spurious "local changes" on the
499/// paths we ourselves mutated — while still protecting genuinely
500/// unrelated dirty/untracked paths.
501///
502/// # Errors
503/// Propagates store / filesystem failures.
504pub fn reset_conflict_paths(
505    root: &Path,
506    store: &ObjectStore,
507    records: &[ConflictRecord],
508    target_tree: Hash,
509) -> Result<(), String> {
510    use std::collections::HashMap;
511
512    // Flatten the target tree into path → (mode, hash).
513    let target_idx =
514        index::from_tree(store, target_tree).map_err(|e| format!("read target tree: {e}"))?;
515    let target_map: HashMap<&str, &IndexEntry> = target_idx
516        .entries
517        .iter()
518        .map(|e| (e.path.as_str(), e))
519        .collect();
520
521    let mut idx = super::read_or_seed_index_from_head(root, store)?;
522
523    for r in records {
524        let abs = root.join(&r.path);
525        if let Some(target_entry) = target_map.get(r.path.as_str()) {
526            // Restore the path's pre-op content + index entry, honouring
527            // the recorded symlink/exec mode (#214).
528            match target_entry.status {
529                EntryStatus::Symlink => {
530                    write_symlink_to_worktree(store, &abs, target_entry.object_hash)?;
531                }
532                EntryStatus::Executable => {
533                    write_blob_to_worktree(store, &abs, target_entry.object_hash, true)?;
534                }
535                _ => write_blob_to_worktree(store, &abs, target_entry.object_hash, false)?,
536            }
537            let entry = (*target_entry).clone();
538            if let Some(pos) = idx.find_entry(&r.path) {
539                idx.entries[pos] = entry;
540            } else {
541                idx.entries.push(entry);
542            }
543        } else {
544            // Path did not exist pre-op: remove the worktree file and
545            // drop it from the index.
546            if let Err(e) = fs::remove_file(&abs)
547                && e.kind() != std::io::ErrorKind::NotFound
548            {
549                return Err(format!("remove {}: {e}", abs.display()));
550            }
551            if let Some(pos) = idx.find_entry(&r.path) {
552                idx.entries.remove(pos);
553            }
554        }
555    }
556    index::write_index(root, &idx).map_err(|e| format!("write index: {e}"))?;
557    Ok(())
558}
559
560/// Scan the worktree files listed in `records` for leftover conflict
561/// markers. Returns the first path that still contains markers, if any.
562///
563/// Only text-marker conflicts are scanned; binary/special paths are
564/// resolved out-of-band and are not marker-bearing.
565///
566/// # Errors
567/// Propagates filesystem read failures.
568/// `true` when `meta` has any executable bit set (Unix). On other
569/// platforms mkit never records `Executable`, so this is always false.
570#[cfg(unix)]
571fn is_executable(meta: &std::fs::Metadata) -> bool {
572    use std::os::unix::fs::PermissionsExt;
573    meta.permissions().mode() & 0o111 != 0
574}
575#[cfg(not(unix))]
576fn is_executable(_meta: &std::fs::Metadata) -> bool {
577    false
578}
579
580/// The canonical `(EntryStatus, Hash)` for the current worktree state at
581/// `abs`, mirroring exactly how `mkit add` would stage it (regular →
582/// Blob/Executable + `store_file_object`; symlink → Symlink + blob of the
583/// link target). `None` when the path is absent or a directory — neither
584/// has a single-file index representation.
585///
586/// # Errors
587/// Read/store failures as a message string.
588fn worktree_object(store: &ObjectStore, abs: &Path) -> Result<Option<(EntryStatus, Hash)>, String> {
589    let meta = match abs.symlink_metadata() {
590        Ok(m) => m,
591        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
592        Err(e) => return Err(format!("stat {}: {e}", abs.display())),
593    };
594    let ft = meta.file_type();
595    if ft.is_symlink() {
596        let target =
597            std::fs::read_link(abs).map_err(|e| format!("read link {}: {e}", abs.display()))?;
598        let target_str = target
599            .to_str()
600            .ok_or_else(|| format!("symlink target not UTF-8: {}", abs.display()))?;
601        let h = worktree::store_file_object(store, target_str.as_bytes())
602            .map_err(|e| format!("store symlink: {e}"))?;
603        return Ok(Some((EntryStatus::Symlink, h)));
604    }
605    if ft.is_file() {
606        let (opened, bytes) = worktree::read_regular_file_bounded(abs)
607            .map_err(|e| format!("read {}: {e}", abs.display()))?;
608        let h = worktree::store_file_object(store, &bytes).map_err(|e| format!("store: {e}"))?;
609        let status = if is_executable(&opened) {
610            EntryStatus::Executable
611        } else {
612            EntryStatus::Blob
613        };
614        return Ok(Some((status, h)));
615    }
616    Ok(None) // directory or other special file
617}
618
619/// Refuse `--continue` when a conflicted path's worktree resolution does
620/// not match what is staged in the index. The final tree is built from
621/// the index, so any unstaged resolution would be silently dropped and
622/// then overwritten by the worktree restore (#269).
623///
624/// This compares the worktree's canonical `(status, hash)` against the
625/// staged index entry, so it catches every shape of unstaged resolution:
626/// an edited regular **or executable** file, a path deleted/replaced
627/// (file→symlink, file→dir) without `mkit rm`/`mkit add`, etc. An
628/// *unchanged* conflict (worktree still equals the staged ours-side,
629/// including its exec/symlink mode) matches and continues without a
630/// re-`add` — preserving the #214 mode-resolution contract.
631///
632/// # Errors
633/// Returns a message naming the first unstaged-resolution path.
634pub fn ensure_conflict_paths_staged(
635    root: &Path,
636    store: &ObjectStore,
637    records: &[ConflictRecord],
638) -> Result<(), String> {
639    let idx = index::read_index(root).map_err(|e| format!("read index: {e}"))?;
640    for r in records {
641        let wt = worktree_object(store, &root.join(&r.path))?;
642        // The staged entry for this path (if any). A `Removed` entry means
643        // "ours deleted it"; absence means no staged content.
644        let staged = idx.entries.iter().find(|e| e.path == r.path);
645        let staged_live = staged.filter(|e| e.status != EntryStatus::Removed);
646        let resolved = match (&wt, staged_live) {
647            // Worktree gone (deleted/dir) and nothing live staged → the
648            // deletion is recorded; consistent.
649            (None, None) => true,
650            // Worktree content matches the live staged entry exactly
651            // (content + mode) → resolved (incl. the unchanged #214 case).
652            (Some((ws, wh)), Some(e)) => *ws == e.status && *wh == e.object_hash,
653            // Worktree has content but nothing live staged, or worktree
654            // gone while content is still staged → unstaged resolution.
655            (Some(_), None) | (None, Some(_)) => false,
656        };
657        if !resolved {
658            return Err(format!(
659                "'{0}' is resolved in the worktree but not staged; run `mkit add {0}` (or `mkit rm {0}`) then `--continue`",
660                r.path
661            ));
662        }
663    }
664    Ok(())
665}
666
667pub fn first_unresolved_marker(
668    root: &Path,
669    records: &[ConflictRecord],
670) -> Result<Option<String>, String> {
671    for r in records {
672        let abs = root.join(&r.path);
673        let data = match fs::read(&abs) {
674            Ok(d) => d,
675            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
676            Err(e) => return Err(format!("read {}: {e}", abs.display())),
677        };
678        if file_has_markers(&data) {
679            return Ok(Some(r.path.clone()));
680        }
681    }
682    Ok(None)
683}
684
685fn file_has_markers(data: &[u8]) -> bool {
686    let Ok(text) = core::str::from_utf8(data) else {
687        return false;
688    };
689    let mut saw_ours = false;
690    let mut saw_sep = false;
691    let mut saw_theirs = false;
692    for line in text.lines() {
693        if line == MARK_OURS {
694            saw_ours = true;
695        } else if line == MARK_SEP {
696            saw_sep = true;
697        } else if line == MARK_THEIRS {
698            saw_theirs = true;
699        }
700    }
701    saw_ours && saw_sep && saw_theirs
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    #[test]
709    fn detects_complete_marker_set() {
710        let data = b"<<<<<<< ours\nfoo\n=======\nbar\n>>>>>>> theirs\n";
711        assert!(file_has_markers(data));
712    }
713
714    #[test]
715    fn ignores_partial_markers() {
716        let data = b"<<<<<<< ours\nfoo\n";
717        assert!(!file_has_markers(data));
718    }
719
720    #[test]
721    fn clean_file_has_no_markers() {
722        let data = b"just some resolved content\n";
723        assert!(!file_has_markers(data));
724    }
725
726    #[test]
727    fn text_detection() {
728        assert!(is_text(b"hello world\n"));
729        assert!(!is_text(b"\x00\x01\x02binary"));
730        assert!(!is_text(&[0xff, 0xfe, 0xfd]));
731    }
732}