mkit-cli 0.4.1

The mkit command-line tool: a content-addressed VCS with native attestation support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
//! Shared CLI helpers for the resolvable-conflict workflow (#177).
//!
//! Materialises conflict material into the worktree + index, classifies
//! each conflict into a presentation class, and scans for leftover
//! conflict markers so `--continue` can refuse to proceed while the user
//! has not resolved a textual conflict.
//!
//! Materialisation always honours the #176 restore guards: callers run
//! [`super::ensure_restore_safe`] over the conflict-time tree before
//! invoking [`materialize_conflicts`], so dirty tracked files and
//! untracked collisions are never clobbered.

use std::fs;
use std::io::Write;
use std::path::Path;

use mkit_core::hash::Hash;
use mkit_core::index::{self, EntryStatus, IndexEntry};
use mkit_core::layout::RepoLayout;
use mkit_core::object::{EntryMode, Object};
use mkit_core::ops::conflict_state::ConflictRecord;
use mkit_core::ops::merge::{Conflict, ConflictKind};
use mkit_core::store::ObjectStore;
use mkit_core::worktree;

/// Classification of how a conflicting path is presented to the user.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictClass {
    /// Text modify/modify or add/add: classic 2-way Git markers are
    /// written into the worktree file.
    TextMarkers,
    /// Binary blob on either side: no markers (they would corrupt the
    /// file); the ours-side content is left in place for manual edit.
    Binary,
    /// Delete/modify: one side removed the path; the surviving content
    /// is left in place; resolve by `mkit add` or `mkit rm`.
    DeleteModify,
    /// Symlink or executable-mode change, or any other shape unsafe for
    /// markers: ours-side content/mode is left in place for manual edit.
    Special,
}

/// Marker lines, kept as constants so the leftover scanner and the
/// writer agree byte-for-byte.
const MARK_OURS: &str = "<<<<<<< ours";
const MARK_SEP: &str = "=======";
const MARK_THEIRS: &str = ">>>>>>> theirs";

/// Decide whether a blob's bytes are safe to wrap in text markers.
fn is_text(data: &[u8]) -> bool {
    // No NUL bytes and valid UTF-8 — the same heuristic used for the
    // diff path. A NUL is the classic "this is binary" tell.
    !data.contains(&0) && core::str::from_utf8(data).is_ok()
}

fn read_blob(store: &ObjectStore, h: Hash) -> Result<Vec<u8>, String> {
    match store.read_object(&h) {
        Ok(Object::Blob(b)) => Ok(b.data),
        Ok(_) => Err("conflict side is not a blob".to_string()),
        Err(e) => Err(format!("read conflict blob: {e}")),
    }
}

/// `true` when `h` points at a blob object (as opposed to a tree, which
/// is how a file-vs-directory conflict surfaces on one side).
fn is_blob(store: &ObjectStore, h: Hash) -> bool {
    matches!(store.read_object(&h), Ok(Object::Blob(_)))
}

/// `true` when a conflict side is absent or points at a blob. A side
/// that points at a tree (file-vs-directory) is neither.
fn side_is_blob_or_absent(store: &ObjectStore, side: Option<Hash>) -> bool {
    match side {
        None => true,
        Some(h) => is_blob(store, h),
    }
}

/// `true` when a side's tree mode is a symlink or executable — shapes
/// that conflict markers cannot represent and that must round-trip their
/// exact mode (#214).
fn side_is_special_mode(mode: Option<EntryMode>) -> bool {
    matches!(mode, Some(EntryMode::Symlink | EntryMode::Executable))
}

/// Classify a single conflict given its blob contents.
///
/// # Errors
/// Propagates object-store read failures.
pub fn classify(store: &ObjectStore, c: &Conflict) -> Result<ConflictClass, String> {
    match c.kind {
        ConflictKind::DeleteModify => Ok(ConflictClass::DeleteModify),
        ConflictKind::ModifyModify | ConflictKind::AddAdd => {
            // File-vs-directory: one side is a tree. Markers are unsafe;
            // route to Special (the blob side is left in the worktree).
            if !side_is_blob_or_absent(store, c.ours_hash)
                || !side_is_blob_or_absent(store, c.theirs_hash)
            {
                return Ok(ConflictClass::Special);
            }
            // Symlink / executable on either side (#214): the merge
            // engine now carries the real `EntryMode`, so we route these
            // to Special unambiguously instead of guessing from bytes.
            // Writing conflict markers into a symlink target is
            // meaningless, and an executable's content is rarely a clean
            // text merge — the user resolves manually and the ours-side
            // mode is preserved into the worktree + index.
            if side_is_special_mode(c.ours_mode) || side_is_special_mode(c.theirs_mode) {
                return Ok(ConflictClass::Special);
            }
            // Otherwise fall back to the byte heuristic: any non-UTF-8 /
            // NUL-bearing side is binary; everything else is text.
            let ours_text = match c.ours_hash {
                Some(h) => is_text(&read_blob(store, h)?),
                None => true,
            };
            let theirs_text = match c.theirs_hash {
                Some(h) => is_text(&read_blob(store, h)?),
                None => true,
            };
            if ours_text && theirs_text {
                Ok(ConflictClass::TextMarkers)
            } else {
                Ok(ConflictClass::Binary)
            }
        }
    }
}

/// Materialise every conflict into the worktree and stage the ours-side
/// blob into the index so each conflicting path is "resolvable":
///
/// - **text**: write `<<<<<<< ours / ======= / >>>>>>> theirs` markers.
/// - **binary / special / delete-modify**: leave the surviving content
///   in the worktree, print a per-path manual-resolution note.
///
/// The index entry for each path is set to the ours-side blob (or
/// removed for an ours-deleted delete/modify) so a subsequent
/// `mkit add` after resolution updates it normally and `--continue`
/// builds the tree from the resolved index/worktree.
///
/// `merged_tree` is the operation's full merge-result tree (holding
/// "ours" at every conflicted path and the clean changes everywhere
/// else). It is applied to the index + worktree FIRST — otherwise the
/// non-conflicting changes would never reach the index and `--continue`
/// (which builds from the index) would silently drop them (#269). The
/// caller runs [`super::ensure_restore_safe`] over `merged_tree` first,
/// so this never clobbers dirty tracked or untracked content. Conflict
/// markers are then overlaid on the conflicted paths.
///
/// Returns the per-path [`ConflictRecord`]s for the sidecar.
///
/// # Errors
/// Propagates store / filesystem failures as a message string.
pub fn materialize_conflicts(
    layout: &RepoLayout,
    store: &ObjectStore,
    merged_tree: Hash,
    conflicts: &[Conflict],
) -> Result<Vec<ConflictRecord>, String> {
    // Apply the merged result (clean changes + "ours" at conflict paths)
    // to the index and worktree, then overlay markers below.
    super::restore_worktree_and_index(layout, store, merged_tree)?;
    let mut idx = index::read_index(layout).map_err(|e| format!("read index: {e}"))?;
    let mut records = Vec::with_capacity(conflicts.len());
    let mut stderr = std::io::stderr().lock();

    for c in conflicts {
        let class = classify(store, c)?;
        let abs = layout.worktree_root().join(&c.path);
        match class {
            ConflictClass::TextMarkers => {
                let ours = match c.ours_hash {
                    Some(h) => read_blob(store, h)?,
                    None => Vec::new(),
                };
                let theirs = match c.theirs_hash {
                    Some(h) => read_blob(store, h)?,
                    None => Vec::new(),
                };
                write_text_markers(&abs, &ours, &theirs)?;
                let _ = writeln!(stderr, "  {} (text conflict — edit markers)", c.path);
                stage_ours(&mut idx, store, c);
            }
            ConflictClass::Binary => {
                materialize_conflict_side(store, &abs, c)?;
                let _ = writeln!(
                    stderr,
                    "  {} (binary conflict — resolve manually, then `mkit add`)",
                    c.path
                );
                stage_ours(&mut idx, store, c);
            }
            ConflictClass::DeleteModify => {
                // Keep the surviving (modified) side in the worktree,
                // honouring its exec/symlink mode (#214).
                materialize_conflict_side(store, &abs, c)?;
                let _ = writeln!(
                    stderr,
                    "  {} (delete/modify — keep with `mkit add` or drop with `mkit rm`)",
                    c.path
                );
                stage_ours(&mut idx, store, c);
            }
            ConflictClass::Special => {
                materialize_conflict_side(store, &abs, c)?;
                let _ = writeln!(
                    stderr,
                    "  {} (mode/symlink conflict — resolve manually, then `mkit add`)",
                    c.path
                );
                stage_ours(&mut idx, store, c);
            }
        }
        records.push(ConflictRecord::from(c));
    }

    index::write_index(layout, &idx).map_err(|e| format!("write index: {e}"))?;
    Ok(records)
}

/// Map a tree [`EntryMode`] to the index [`EntryStatus`] that preserves
/// it. `Tree` has no single-file index representation and is reported by
/// the caller (which only stages blob ours-sides), so it falls back to
/// `Blob` defensively.
fn status_for_mode(mode: EntryMode) -> EntryStatus {
    match mode {
        EntryMode::Executable => EntryStatus::Executable,
        EntryMode::Symlink => EntryStatus::Symlink,
        EntryMode::Blob | EntryMode::Tree => EntryStatus::Blob,
    }
}

/// Stage the ours-side blob for a conflict into the index (or mark
/// removed when ours deleted it). Keeps the index a single-stage
/// resolved snapshot.
///
/// The ours-side [`EntryMode`] carried on the [`Conflict`] (#214) is
/// preserved into the staged [`EntryStatus`] so executable bits and
/// symlinks survive `--continue` across merge / cherry-pick / rebase —
/// `build_tree_from_index` derives the committed tree mode from the
/// index status, so a default-`Blob` here would silently demote an
/// executable or symlink to a plain file.
fn stage_ours(idx: &mut mkit_core::index::Index, store: &ObjectStore, c: &Conflict) {
    let entry = match c.ours_hash {
        // Only stage a blob ours-side. A tree ours-side (file-vs-dir)
        // is left for the user to resolve and `mkit add`.
        Some(h) if is_blob(store, h) => IndexEntry {
            path: c.path.clone(),
            status: c.ours_mode.map_or(EntryStatus::Blob, status_for_mode),
            object_hash: h,
            mtime_ns: 0,
            size: 0,
            ino: 0,
            ctime_ns: 0,
        },
        Some(_) => return,
        None => IndexEntry {
            path: c.path.clone(),
            status: EntryStatus::Removed,
            object_hash: mkit_core::hash::ZERO,
            mtime_ns: 0,
            size: 0,
            ino: 0,
            ctime_ns: 0,
        },
    };
    idx.upsert_entry(entry);
}

fn write_text_markers(abs: &Path, ours: &[u8], theirs: &[u8]) -> Result<(), String> {
    let mut buf = Vec::new();
    buf.extend_from_slice(MARK_OURS.as_bytes());
    buf.push(b'\n');
    buf.extend_from_slice(ours);
    if !ours.is_empty() && ours.last() != Some(&b'\n') {
        buf.push(b'\n');
    }
    buf.extend_from_slice(MARK_SEP.as_bytes());
    buf.push(b'\n');
    buf.extend_from_slice(theirs);
    if !theirs.is_empty() && theirs.last() != Some(&b'\n') {
        buf.push(b'\n');
    }
    buf.extend_from_slice(MARK_THEIRS.as_bytes());
    buf.push(b'\n');
    write_bytes(abs, &buf)
}

/// Materialise the surviving side of a binary / special conflict into
/// the worktree, honouring its tree mode (#214).
///
/// We prefer the ours-side (the side `stage_ours` records in the index)
/// so the worktree file and the staged index entry agree; if ours is
/// absent or a tree we fall back to theirs. Symlink sides become a real
/// symlink (not a regular file holding the target text); executable
/// sides get the exec bit. If neither side is a blob, whatever is
/// already in the worktree is left untouched.
fn materialize_conflict_side(store: &ObjectStore, abs: &Path, c: &Conflict) -> Result<(), String> {
    let pick = [(c.ours_hash, c.ours_mode), (c.theirs_hash, c.theirs_mode)]
        .into_iter()
        .find_map(|(h, m)| match h {
            Some(h) if is_blob(store, h) => Some((h, m)),
            _ => None,
        });
    let Some((h, mode)) = pick else {
        return Ok(());
    };
    // File-vs-directory conflict: the merged result tree already materialized
    // the directory side at `abs` (restore_worktree_and_index ran first), so
    // the path is a real directory. We cannot write the surviving blob over a
    // directory — `fs::remove_file` no-ops on it and the write fails, aborting
    // materialization AFTER the worktree was mutated but BEFORE MERGE_HEAD is
    // written, leaving no `--abort` path. Keep the directory (ours-wins, like
    // `stage_ours` does for a tree side) and record the conflict for manual
    // resolution. Use symlink_metadata so a symlink-to-a-directory still gets
    // a normal blob write below.
    if std::fs::symlink_metadata(abs).is_ok_and(|m| m.is_dir()) {
        return Ok(());
    }
    match mode {
        Some(EntryMode::Symlink) => write_symlink_to_worktree(store, abs, h),
        Some(EntryMode::Executable) => write_blob_to_worktree(store, abs, h, true),
        _ => write_blob_to_worktree(store, abs, h, false),
    }
}

fn write_blob_to_worktree(
    store: &ObjectStore,
    abs: &Path,
    h: Hash,
    executable: bool,
) -> Result<(), String> {
    let data = read_blob(store, h)?;
    // Replace any existing symlink/file at the path so a prior shape
    // does not shadow the regular file we are about to write.
    let _ = fs::remove_file(abs);
    write_bytes(abs, &data)?;
    if executable {
        set_executable(abs)?;
    }
    Ok(())
}

/// Materialise a symlink blob (payload = target string) as a real
/// symlink, mirroring `restore::restore_symlink`'s `..`-free target
/// validation so a conflict cannot smuggle an escaping link.
fn write_symlink_to_worktree(store: &ObjectStore, abs: &Path, h: Hash) -> Result<(), String> {
    let data = read_blob(store, h)?;
    let target = core::str::from_utf8(&data)
        .map_err(|_| format!("symlink target for {} is not UTF-8", abs.display()))?;
    if !mkit_core::worktree::validate_symlink_target(target) {
        return Err(format!(
            "refusing to materialise unsafe symlink target {target:?} for {}",
            abs.display()
        ));
    }
    if let Some(parent) = abs.parent() {
        fs::create_dir_all(parent).map_err(|e| format!("create dir {}: {e}", parent.display()))?;
    }
    // Remove any existing file/symlink so the create does not race a
    // stale entry of the wrong shape.
    let _ = fs::remove_file(abs);
    create_symlink(target, abs).map_err(|e| format!("create symlink {}: {e}", abs.display()))
}

#[cfg(unix)]
fn set_executable(abs: &Path) -> Result<(), String> {
    use std::os::unix::fs::PermissionsExt;
    let mut perm = fs::metadata(abs)
        .map_err(|e| format!("stat {}: {e}", abs.display()))?
        .permissions();
    perm.set_mode(0o755);
    fs::set_permissions(abs, perm).map_err(|e| format!("chmod {}: {e}", abs.display()))
}

#[cfg(not(unix))]
#[allow(clippy::unnecessary_wraps)]
fn set_executable(_abs: &Path) -> Result<(), String> {
    Ok(())
}

#[cfg(unix)]
fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
    std::os::unix::fs::symlink(target, link)
}

#[cfg(windows)]
fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
    std::os::windows::fs::symlink_file(target, link)
}

#[cfg(not(any(unix, windows)))]
fn create_symlink(_target: &str, _link: &Path) -> std::io::Result<()> {
    Err(std::io::Error::new(
        std::io::ErrorKind::Unsupported,
        "symlink creation is not supported on this target",
    ))
}

fn write_bytes(abs: &Path, data: &[u8]) -> Result<(), String> {
    if let Some(parent) = abs.parent() {
        fs::create_dir_all(parent).map_err(|e| format!("create dir {}: {e}", parent.display()))?;
    }
    fs::write(abs, data).map_err(|e| format!("write {}: {e}", abs.display()))
}

/// Pre-abort safety gate: refuse the abort *before* it mutates anything
/// when restoring to `target_tree` would overwrite genuine user work on
/// a path that is **not** part of the recorded conflict set.
///
/// `--abort` works by first resetting the conflict paths (discarding the
/// conflict material mkit itself wrote) and then doing a guarded restore
/// to the pre-op tree. The conflict-path reset is destructive, so it
/// must not run if the abort is going to be refused anyway: otherwise a
/// failed abort would silently throw away the user's in-progress
/// resolution of the conflicting files while leaving operation state in
/// place. This check inspects only the non-conflict paths (the conflict
/// paths are expected to be dirty — they hold markers / partial edits)
/// and mirrors [`super::ensure_restore_safe`]'s staged / unstaged /
/// untracked-collision detection for them.
///
/// # Errors
/// Returns a message describing the blocking path when the abort would
/// be unsafe, or propagates store / filesystem failures.
#[allow(clippy::too_many_lines)] // a sequence of independent pre-mutation safety checks
pub fn ensure_abort_safe(
    layout: &RepoLayout,
    store: &ObjectStore,
    records: &[ConflictRecord],
    target_tree: Hash,
    op_result_tree: Option<Hash>,
) -> Result<(), String> {
    use std::collections::HashSet;

    let root = layout.worktree_root();
    let current_tree = super::current_head_tree(layout, store)?;
    let idx = super::read_or_seed_index_from_head(layout, store)?;
    // Safety-check snapshot trees are ephemeral — in-memory overlay.
    let snapshot = mkit_core::store::EphemeralSink::new(store);
    let index_tree = mkit_core::worktree::build_tree_from_index_with(store, &snapshot, &idx, false)
        .map_err(|e| format!("check index state: {e}"))?;
    // Pass the seeded index as the tracked set so a tracked file matching an
    // ignore rule isn't dropped from the snapshot and misread as a deletion.
    let worktree_tree = mkit_core::worktree::build_tree_filtered(&snapshot, root, Some(&idx))
        .map_err(|e| format!("check worktree: {e}"))?;

    // Discardable = the operation's OWN work that the user has not touched:
    //   * recorded conflict paths (abort always throws away resolutions);
    //   * operation-authored clean hunks whose current index AND worktree
    //     content still match the operation result.
    // A clean path the user has since edited (staged or in the worktree) is
    // THEIR work — keep it non-discardable so the checks below refuse to
    // destroy it. Without a result tree (legacy state) we fall back to the
    // conflict records alone.
    let conflict_paths: HashSet<String> = records.iter().map(|r| r.path.clone()).collect();
    let mut discardable = conflict_paths.clone();
    if let Some(result_tree) = op_result_tree {
        let authored = mkit_core::ops::diff::diff_trees(&snapshot, current_tree, Some(result_tree))
            .map_err(|e| format!("check operation changes: {e}"))?;
        // Paths whose current index or worktree diverges from the operation
        // result — i.e. the user changed them after the operation paused.
        let mut modified: HashSet<String> = HashSet::new();
        for e in mkit_core::ops::diff::diff_trees(&snapshot, Some(result_tree), Some(index_tree))
            .map_err(|e| format!("check operation changes: {e}"))?
            .entries
        {
            modified.insert(e.path);
        }
        for e in mkit_core::ops::diff::diff_trees(&snapshot, Some(result_tree), Some(worktree_tree))
            .map_err(|e| format!("check operation changes: {e}"))?
            .entries
        {
            modified.insert(e.path);
        }
        for e in authored.entries {
            if conflict_paths.contains(&e.path) || !modified.contains(&e.path) {
                discardable.insert(e.path);
            }
        }
    }
    let is_discardable = |p: &str| discardable.contains(p);

    // Staged changes on a non-discardable path.
    let staged = mkit_core::ops::diff::diff_trees(&snapshot, current_tree, Some(index_tree))
        .map_err(|e| format!("check staged changes: {e}"))?;
    if let Some(entry) = staged.entries.iter().find(|e| !is_discardable(&e.path)) {
        return Err(format!(
            "abort would overwrite staged changes; commit, stash, or reset '{}' first",
            entry.path
        ));
    }

    // Unstaged worktree edits on a non-discardable path.
    let unstaged =
        mkit_core::ops::diff::diff_trees(&snapshot, Some(index_tree), Some(worktree_tree))
            .map_err(|e| format!("check worktree: {e}"))?;
    if let Some(entry) = unstaged
        .entries
        .iter()
        .find(|e| e.kind != mkit_core::ops::diff::DiffKind::Added && !is_discardable(&e.path))
    {
        return Err(format!(
            "abort would overwrite local changes; commit, stash, or reset '{}' first",
            entry.path
        ));
    }

    // Untracked path that collides with a non-conflict path the restore
    // would write.
    let target_writes: Vec<String> =
        mkit_core::ops::diff::diff_trees(&snapshot, Some(index_tree), Some(target_tree))
            .map_err(|e| format!("check restore target: {e}"))?
            .entries
            .into_iter()
            .filter(|e| e.kind != mkit_core::ops::diff::DiffKind::Removed)
            .filter(|e| !is_discardable(&e.path))
            .map(|e| e.path)
            .collect();
    if !target_writes.is_empty() {
        for entry in &unstaged.entries {
            if entry.kind == mkit_core::ops::diff::DiffKind::Added
                && !is_discardable(&entry.path)
                && target_writes.iter().any(|t| t == &entry.path)
            {
                return Err(format!(
                    "abort would overwrite untracked path '{}'; move or remove it first",
                    entry.path
                ));
            }
        }
    }

    // Restoring `target_tree` writes a file at every path it adds/changes
    // relative to the current index — INCLUDING discardable paths (e.g. a
    // file the operation cleanly deleted). Refuse if any such path is now a
    // DIRECTORY in the worktree (e.g. the user created `d/keep` after the
    // operation deleted file `d`): the restore would fail part-way and
    // removing the directory would destroy the user's untracked content.
    // Checked here, before any mutation, so abort stays all-or-nothing.
    for entry in &mkit_core::ops::diff::diff_trees(&snapshot, Some(index_tree), Some(target_tree))
        .map_err(|e| format!("check restore target: {e}"))?
        .entries
    {
        if entry.kind == mkit_core::ops::diff::DiffKind::Removed {
            continue;
        }
        // The restore writes a file at `entry.path`. Refuse if the path itself
        // is now a DIRECTORY (e.g. the user created `d/keep` after the
        // operation deleted file `d`)...
        if std::fs::symlink_metadata(root.join(&entry.path)).is_ok_and(|m| m.is_dir()) {
            return Err(format!(
                "abort would replace directory '{}' with a file; move or remove it first",
                entry.path
            ));
        }
        // ...or if any ANCESTOR component is now a non-directory file (e.g.
        // target has `p/file`; the user replaced the deleted directory `p`
        // with a file `p`). `create_dir_all` would fail mid-restore, breaking
        // abort atomicity. Checked here, before any mutation.
        let mut prefix = String::new();
        for comp in entry.path.split('/') {
            if !prefix.is_empty() {
                prefix.push('/');
            }
            prefix.push_str(comp);
            if prefix == entry.path {
                break; // the leaf is handled by the is_dir check above
            }
            if std::fs::symlink_metadata(root.join(&prefix)).is_ok_and(|m| !m.is_dir()) {
                return Err(format!(
                    "abort would restore '{}' but '{prefix}' is a file; move or remove it first",
                    entry.path
                ));
            }
        }
    }
    Ok(())
}

/// Discard conflict material on the recorded conflict paths, resetting
/// each back to its content in `target_tree` (the pre-op HEAD): write
/// the target blob into the worktree (or delete the file when the path
/// is absent from `target_tree`) and align the index entry.
///
/// This is the abort precondition: after it runs, the worktree and
/// index agree with `target_tree` on every conflict path, so the
/// subsequent guarded restore sees no spurious "local changes" on the
/// paths we ourselves mutated — while still protecting genuinely
/// unrelated dirty/untracked paths.
///
/// # Errors
/// Propagates store / filesystem failures.
#[allow(clippy::too_many_lines)] // a pre-flight pass + the mutation pass, kept together
pub fn reset_conflict_paths(
    layout: &RepoLayout,
    store: &ObjectStore,
    records: &[ConflictRecord],
    target_tree: Hash,
    op_result_tree: Option<Hash>,
) -> Result<(), String> {
    use std::collections::{BTreeSet, HashMap};

    let root = layout.worktree_root();

    // Flatten the target tree into path → (mode, hash).
    let target_idx =
        index::from_tree(store, target_tree).map_err(|e| format!("read target tree: {e}"))?;
    let target_map: HashMap<&str, &IndexEntry> = target_idx
        .entries
        .iter()
        .map(|e| (e.path.as_str(), e))
        .collect();

    // Reset every path the operation authored: the recorded conflict paths
    // PLUS the operation's clean hunks (paths it changed vs the pre-op HEAD).
    // The reset is purely target-content driven, so it generalizes from
    // conflict records to any operation-authored path.
    let mut paths: BTreeSet<String> = records.iter().map(|r| r.path.clone()).collect();
    if let Some(result_tree) = op_result_tree {
        let snapshot = mkit_core::store::EphemeralSink::new(store);
        let authored =
            mkit_core::ops::diff::diff_trees(&snapshot, Some(target_tree), Some(result_tree))
                .map_err(|e| format!("check operation changes: {e}"))?;
        for e in authored.entries {
            paths.insert(e.path);
        }
    }

    // Pre-flight (no mutation): reject the abort up front for any path whose
    // reset would fail mid-loop, so abort stays all-or-nothing regardless of
    // which target the caller resets toward. (Rebase vets `ensure_abort_safe`
    // against `orig_tree` but resets `reset_conflict_paths` toward
    // `head_tree`; a path present in `head_tree` but outside `orig_tree`'s
    // change set would otherwise escape every pre-check and only fail in the
    // mutation loop, after earlier-sorted paths were already reset.) This
    // covers every way the loop below can error: writing a file where a
    // directory now sits, writing under an ancestor that is now a file, and
    // removing an op-added path the user replaced with a non-empty directory.
    for path in &paths {
        let abs = root.join(path);
        if target_map.contains_key(path.as_str()) {
            // The loop will WRITE target content here.
            if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir()) {
                return Err(format!(
                    "abort would replace directory '{path}' with a file; move or remove it first"
                ));
            }
            let mut prefix = String::new();
            for comp in path.split('/') {
                if !prefix.is_empty() {
                    prefix.push('/');
                }
                prefix.push_str(comp);
                if prefix == *path {
                    break; // the leaf is handled by the is_dir check above
                }
                if fs::symlink_metadata(root.join(&prefix)).is_ok_and(|m| !m.is_dir()) {
                    return Err(format!(
                        "abort would restore '{path}' but '{prefix}' is a file; \
                         move or remove it first"
                    ));
                }
            }
            continue;
        }
        let dir_prefix = format!("{path}/");
        if target_map
            .keys()
            .any(|k| k.starts_with(dir_prefix.as_str()))
        {
            continue; // a pre-op directory left in place
        }
        // The loop will REMOVE this op-added path; refuse a non-empty dir.
        if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir())
            && fs::read_dir(&abs).is_ok_and(|mut it| it.next().is_some())
        {
            return Err(format!(
                "abort would discard the untracked directory '{path}'; move or remove it first"
            ));
        }
    }

    let mut idx = super::read_or_seed_index_from_head(layout, store)?;

    for path in &paths {
        let abs = root.join(path);
        if let Some(target_entry) = target_map.get(path.as_str()) {
            // Restore the path's pre-op content + index entry, honouring
            // the recorded symlink/exec mode (#214).
            match target_entry.status {
                EntryStatus::Symlink => {
                    write_symlink_to_worktree(store, &abs, target_entry.object_hash)?;
                }
                EntryStatus::Executable => {
                    write_blob_to_worktree(store, &abs, target_entry.object_hash, true)?;
                }
                _ => write_blob_to_worktree(store, &abs, target_entry.object_hash, false)?,
            }
            let entry = (*target_entry).clone();
            idx.upsert_entry(entry);
        } else {
            // `path` is absent from the target tree as a FILE. But it may be a
            // DIRECTORY there (a file-vs-directory conflict records the path
            // `p` while the target carries `p/<children>`): in that case the
            // pre-op directory already sits in the worktree — leave it and let
            // the final restore align its contents; only drop any stale index
            // entry literally at `p`.
            let dir_prefix = format!("{path}/");
            if target_map
                .keys()
                .any(|k| k.starts_with(dir_prefix.as_str()))
            {
                idx.remove_path(path);
                continue;
            }
            // Otherwise the path did not exist pre-op (the operation added it):
            // remove it and drop it from the index. If the user has since
            // replaced it with a DIRECTORY, remove it only when EMPTY
            // (`remove_dir`) — a non-empty directory holds untracked user
            // content that abort must NOT silently destroy, so we fail closed.
            if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir()) {
                if let Err(e) = fs::remove_dir(&abs)
                    && e.kind() != std::io::ErrorKind::NotFound
                {
                    return Err(format!(
                        "abort would discard the untracked directory '{path}'; \
                         move or remove it first"
                    ));
                }
            } else if let Err(e) = fs::remove_file(&abs)
                && e.kind() != std::io::ErrorKind::NotFound
            {
                return Err(format!("remove {}: {e}", abs.display()));
            }
            idx.remove_path(path);
        }
    }
    index::write_index(layout, &idx).map_err(|e| format!("write index: {e}"))?;
    Ok(())
}

/// Scan the worktree files listed in `records` for leftover conflict
/// markers. Returns the first path that still contains markers, if any.
///
/// Only text-marker conflicts are scanned; binary/special paths are
/// resolved out-of-band and are not marker-bearing.
///
/// # Errors
/// Propagates filesystem read failures.
/// `true` when `meta` has any executable bit set (Unix). On other
/// platforms mkit never records `Executable`, so this is always false.
#[cfg(unix)]
fn is_executable(meta: &std::fs::Metadata) -> bool {
    use std::os::unix::fs::PermissionsExt;
    meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn is_executable(_meta: &std::fs::Metadata) -> bool {
    false
}

/// The canonical `(EntryStatus, Hash)` for the current worktree state at
/// `abs`, mirroring exactly how `mkit add` would stage it (regular →
/// Blob/Executable + `store_file_object`; symlink → Symlink + blob of the
/// link target). `None` when the path is absent or a directory — neither
/// has a single-file index representation.
///
/// # Errors
/// Read/store failures as a message string.
fn worktree_object(store: &ObjectStore, abs: &Path) -> Result<Option<(EntryStatus, Hash)>, String> {
    let meta = match abs.symlink_metadata() {
        Ok(m) => m,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(format!("stat {}: {e}", abs.display())),
    };
    let ft = meta.file_type();
    if ft.is_symlink() {
        let target =
            std::fs::read_link(abs).map_err(|e| format!("read link {}: {e}", abs.display()))?;
        let target_str = target
            .to_str()
            .ok_or_else(|| format!("symlink target not UTF-8: {}", abs.display()))?;
        let h = worktree::store_file_object(store, target_str.as_bytes())
            .map_err(|e| format!("store symlink: {e}"))?;
        return Ok(Some((EntryStatus::Symlink, h)));
    }
    if ft.is_file() {
        let (opened, bytes) = worktree::read_regular_file_bounded(abs)
            .map_err(|e| format!("read {}: {e}", abs.display()))?;
        let h = worktree::store_file_object(store, &bytes).map_err(|e| format!("store: {e}"))?;
        let status = if is_executable(&opened) {
            EntryStatus::Executable
        } else {
            EntryStatus::Blob
        };
        return Ok(Some((status, h)));
    }
    Ok(None) // directory or other special file
}

/// Refuse `--continue` when a conflicted path's worktree resolution does
/// not match what is staged in the index. The final tree is built from
/// the index, so any unstaged resolution would be silently dropped and
/// then overwritten by the worktree restore (#269).
///
/// This compares the worktree's canonical `(status, hash)` against the
/// staged index entry, so it catches every shape of unstaged resolution:
/// an edited regular **or executable** file, a path deleted/replaced
/// (file→symlink, file→dir) without `mkit rm`/`mkit add`, etc. An
/// *unchanged* conflict (worktree still equals the staged ours-side,
/// including its exec/symlink mode) matches and continues without a
/// re-`add` — preserving the #214 mode-resolution contract.
///
/// # Errors
/// Returns a message naming the first unstaged-resolution path.
pub fn ensure_conflict_paths_staged(
    layout: &RepoLayout,
    store: &ObjectStore,
    records: &[ConflictRecord],
) -> Result<(), String> {
    let idx = index::read_index(layout).map_err(|e| format!("read index: {e}"))?;
    for r in records {
        let wt = worktree_object(store, &layout.worktree_root().join(&r.path))?;
        // The staged entry for this path (if any). A `Removed` entry means
        // "ours deleted it"; absence means no staged content.
        let staged = idx.entries.iter().find(|e| e.path == r.path);
        let staged_live = staged.filter(|e| e.status != EntryStatus::Removed);
        let resolved = match (&wt, staged_live) {
            // Worktree gone (deleted/dir) and nothing live staged → the
            // deletion is recorded; consistent.
            (None, None) => true,
            // Worktree content matches the live staged entry exactly
            // (content + mode) → resolved (incl. the unchanged #214 case).
            (Some((ws, wh)), Some(e)) => *ws == e.status && *wh == e.object_hash,
            // Worktree has content but nothing live staged, or worktree
            // gone while content is still staged → unstaged resolution.
            (Some(_), None) | (None, Some(_)) => false,
        };
        if !resolved {
            return Err(format!(
                "'{0}' is resolved in the worktree but not staged; run `mkit add {0}` (or `mkit rm {0}`) then `--continue`",
                r.path
            ));
        }
    }
    Ok(())
}

pub fn first_unresolved_marker(
    root: &Path,
    records: &[ConflictRecord],
) -> Result<Option<String>, String> {
    for r in records {
        let abs = root.join(&r.path);
        // A file-vs-directory conflict kept the ours-DIRECTORY at the record
        // path (the round-9 D/F pause). A directory holds no conflict markers,
        // so skip it rather than letting `fs::read` fail "Is a directory" —
        // which would make `--continue` permanently impossible for that pause.
        if fs::symlink_metadata(&abs).is_ok_and(|m| m.is_dir()) {
            continue;
        }
        let data = match fs::read(&abs) {
            Ok(d) => d,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
            Err(e) => return Err(format!("read {}: {e}", abs.display())),
        };
        if file_has_markers(&data) {
            return Ok(Some(r.path.clone()));
        }
    }
    Ok(None)
}

fn file_has_markers(data: &[u8]) -> bool {
    let Ok(text) = core::str::from_utf8(data) else {
        return false;
    };
    let mut saw_ours = false;
    let mut saw_sep = false;
    let mut saw_theirs = false;
    for line in text.lines() {
        if line == MARK_OURS {
            saw_ours = true;
        } else if line == MARK_SEP {
            saw_sep = true;
        } else if line == MARK_THEIRS {
            saw_theirs = true;
        }
    }
    saw_ours && saw_sep && saw_theirs
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detects_complete_marker_set() {
        let data = b"<<<<<<< ours\nfoo\n=======\nbar\n>>>>>>> theirs\n";
        assert!(file_has_markers(data));
    }

    #[test]
    fn ignores_partial_markers() {
        let data = b"<<<<<<< ours\nfoo\n";
        assert!(!file_has_markers(data));
    }

    #[test]
    fn clean_file_has_no_markers() {
        let data = b"just some resolved content\n";
        assert!(!file_has_markers(data));
    }

    #[test]
    fn text_detection() {
        assert!(is_text(b"hello world\n"));
        assert!(!is_text(b"\x00\x01\x02binary"));
        assert!(!is_text(&[0xff, 0xfe, 0xfd]));
    }
}