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