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;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictClass {
TextMarkers,
Binary,
DeleteModify,
Special,
}
const MARK_OURS: &str = "<<<<<<< ours";
const MARK_SEP: &str = "=======";
const MARK_THEIRS: &str = ">>>>>>> theirs";
fn is_text(data: &[u8]) -> bool {
!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}")),
}
}
fn is_blob(store: &ObjectStore, h: Hash) -> bool {
matches!(store.read_object(&h), Ok(Object::Blob(_)))
}
fn side_is_blob_or_absent(store: &ObjectStore, side: Option<Hash>) -> bool {
match side {
None => true,
Some(h) => is_blob(store, h),
}
}
fn side_is_special_mode(mode: Option<EntryMode>) -> bool {
matches!(mode, Some(EntryMode::Symlink | EntryMode::Executable))
}
pub fn classify(store: &ObjectStore, c: &Conflict) -> Result<ConflictClass, String> {
match c.kind {
ConflictKind::DeleteModify => Ok(ConflictClass::DeleteModify),
ConflictKind::ModifyModify | ConflictKind::AddAdd => {
if !side_is_blob_or_absent(store, c.ours_hash)
|| !side_is_blob_or_absent(store, c.theirs_hash)
{
return Ok(ConflictClass::Special);
}
if side_is_special_mode(c.ours_mode) || side_is_special_mode(c.theirs_mode) {
return Ok(ConflictClass::Special);
}
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)
}
}
}
}
pub fn materialize_conflicts(
layout: &RepoLayout,
store: &ObjectStore,
merged_tree: Hash,
conflicts: &[Conflict],
) -> Result<Vec<ConflictRecord>, String> {
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 => {
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)
}
fn status_for_mode(mode: EntryMode) -> EntryStatus {
match mode {
EntryMode::Executable => EntryStatus::Executable,
EntryMode::Symlink => EntryStatus::Symlink,
EntryMode::Blob | EntryMode::Tree => EntryStatus::Blob,
}
}
fn stage_ours(idx: &mut mkit_core::index::Index, store: &ObjectStore, c: &Conflict) {
let entry = match c.ours_hash {
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)
}
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(());
};
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)?;
let _ = fs::remove_file(abs);
write_bytes(abs, &data)?;
if executable {
set_executable(abs)?;
}
Ok(())
}
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()))?;
}
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()))
}
#[allow(clippy::too_many_lines)] 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)?;
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}"))?;
let worktree_tree = mkit_core::worktree::build_tree_filtered(&snapshot, root, Some(&idx))
.map_err(|e| format!("check worktree: {e}"))?;
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}"))?;
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);
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
));
}
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
));
}
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
));
}
}
}
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;
}
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
));
}
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; }
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(())
}
#[allow(clippy::too_many_lines)] 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();
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();
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);
}
}
for path in &paths {
let abs = root.join(path);
if target_map.contains_key(path.as_str()) {
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; }
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; }
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()) {
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 {
let dir_prefix = format!("{path}/");
if target_map
.keys()
.any(|k| k.starts_with(dir_prefix.as_str()))
{
idx.remove_path(path);
continue;
}
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(())
}
#[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
}
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) }
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))?;
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) {
(None, None) => true,
(Some((ws, wh)), Some(e)) => *ws == e.status && *wh == e.object_hash,
(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);
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]));
}
}