1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ConflictClass {
28 TextMarkers,
31 Binary,
34 DeleteModify,
37 Special,
40}
41
42const MARK_OURS: &str = "<<<<<<< ours";
45const MARK_SEP: &str = "=======";
46const MARK_THEIRS: &str = ">>>>>>> theirs";
47
48fn is_text(data: &[u8]) -> bool {
50 !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
63fn is_blob(store: &ObjectStore, h: Hash) -> bool {
66 matches!(store.read_object(&h), Ok(Object::Blob(_)))
67}
68
69fn 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
78fn side_is_special_mode(mode: Option<EntryMode>) -> bool {
82 matches!(mode, Some(EntryMode::Symlink | EntryMode::Executable))
83}
84
85pub 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 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 if side_is_special_mode(c.ours_mode) || side_is_special_mode(c.theirs_mode) {
108 return Ok(ConflictClass::Special);
109 }
110 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
129pub fn materialize_conflicts(
155 root: &Path,
156 store: &ObjectStore,
157 merged_tree: Hash,
158 conflicts: &[Conflict],
159) -> Result<Vec<ConflictRecord>, String> {
160 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 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
221fn 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
233fn stage_ours(idx: &mut mkit_core::index::Index, store: &ObjectStore, c: &Conflict) {
244 let entry = match c.ours_hash {
245 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
293fn 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 let _ = fs::remove_file(abs);
329 write_bytes(abs, &data)?;
330 if executable {
331 set_executable(abs)?;
332 }
333 Ok(())
334}
335
336fn 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 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
399pub 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 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 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 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 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
491pub 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 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 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 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#[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
580fn 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) }
618
619pub 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 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 (None, None) => true,
650 (Some((ws, wh)), Some(e)) => *ws == e.status && *wh == e.object_hash,
653 (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}