use std::fs;
use std::io;
use std::path::Path;
use crate::hash::{self, HEX_LEN, Hash};
use crate::index::validate_index_path;
use crate::layout::RepoLayout;
use crate::ops::merge::{Conflict, ConflictKind};
pub const MERGE_HEAD: &str = "MERGE_HEAD";
pub const CHERRY_PICK_HEAD: &str = "CHERRY_PICK_HEAD";
pub const ORIG_HEAD: &str = "ORIG_HEAD";
pub const MERGE_MSG: &str = "MERGE_MSG";
pub const CHERRY_PICK_MSG: &str = "CHERRY_PICK_MSG";
pub const REVERT_HEAD: &str = "REVERT_HEAD";
pub const REVERT_MSG: &str = "REVERT_MSG";
pub const CONFLICTS_FILE: &str = "mkit-conflicts";
pub const RESULT_TREE: &str = "MKIT_OP_RESULT";
const MAX_STATE_BYTES: u64 = 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum ConflictStateError {
#[error("conflict state on disk is malformed")]
Invalid,
#[error(transparent)]
Io(#[from] io::Error),
}
pub type ConflictStateResult<T> = Result<T, ConflictStateError>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictRecord {
pub path: String,
pub kind: ConflictKind,
pub base_hash: Option<Hash>,
pub ours_hash: Option<Hash>,
pub theirs_hash: Option<Hash>,
}
impl From<&Conflict> for ConflictRecord {
fn from(c: &Conflict) -> Self {
Self {
path: c.path.clone(),
kind: c.kind,
base_hash: c.base_hash,
ours_hash: c.ours_hash,
theirs_hash: c.theirs_hash,
}
}
}
fn kind_tag(kind: ConflictKind) -> &'static str {
match kind {
ConflictKind::ModifyModify => "modify",
ConflictKind::AddAdd => "addadd",
ConflictKind::DeleteModify => "deletemodify",
}
}
fn kind_from_tag(tag: &str) -> Option<ConflictKind> {
match tag {
"modify" => Some(ConflictKind::ModifyModify),
"addadd" => Some(ConflictKind::AddAdd),
"deletemodify" => Some(ConflictKind::DeleteModify),
_ => None,
}
}
fn hex_or_dash(h: Option<Hash>) -> String {
match h {
Some(h) => hash::to_hex(&h),
None => "-".to_string(),
}
}
fn parse_hex_or_dash(field: &str) -> Result<Option<Hash>, ConflictStateError> {
if field == "-" {
return Ok(None);
}
if field.len() != HEX_LEN {
return Err(ConflictStateError::Invalid);
}
hash::from_hex(field)
.map(Some)
.map_err(|_| ConflictStateError::Invalid)
}
#[must_use]
pub fn serialize_conflicts(records: &[ConflictRecord]) -> Vec<u8> {
let mut out = String::new();
for r in records {
out.push_str(kind_tag(r.kind));
out.push('\t');
out.push_str(&hex_or_dash(r.base_hash));
out.push('\t');
out.push_str(&hex_or_dash(r.ours_hash));
out.push('\t');
out.push_str(&hex_or_dash(r.theirs_hash));
out.push('\t');
out.push_str(&r.path);
out.push('\n');
}
out.into_bytes()
}
pub fn deserialize_conflicts(data: &[u8]) -> ConflictStateResult<Vec<ConflictRecord>> {
let text = core::str::from_utf8(data).map_err(|_| ConflictStateError::Invalid)?;
let mut out = Vec::new();
for line in text.split('\n') {
if line.is_empty() {
continue;
}
let mut fields = line.splitn(5, '\t');
let kind = fields.next().ok_or(ConflictStateError::Invalid)?;
let base = fields.next().ok_or(ConflictStateError::Invalid)?;
let ours = fields.next().ok_or(ConflictStateError::Invalid)?;
let theirs = fields.next().ok_or(ConflictStateError::Invalid)?;
let path = fields.next().ok_or(ConflictStateError::Invalid)?;
let kind = kind_from_tag(kind).ok_or(ConflictStateError::Invalid)?;
if !validate_index_path(path) {
return Err(ConflictStateError::Invalid);
}
out.push(ConflictRecord {
path: path.to_string(),
kind,
base_hash: parse_hex_or_dash(base)?,
ours_hash: parse_hex_or_dash(ours)?,
theirs_hash: parse_hex_or_dash(theirs)?,
});
}
Ok(out)
}
pub fn write_result_tree(dir: &Path, tree: &Hash) -> ConflictStateResult<()> {
write_hex_file(dir, RESULT_TREE, tree)
}
pub fn read_result_tree(dir: &Path) -> ConflictStateResult<Option<Hash>> {
read_hex_file(&dir.join(RESULT_TREE))
}
pub fn clear_result_tree(dir: &Path) {
let _ = fs::remove_file(dir.join(RESULT_TREE));
}
fn write_hex_file(state_dir: &Path, name: &str, h: &Hash) -> ConflictStateResult<()> {
let mut buf = hash::to_hex(h);
buf.push('\n');
fs::write(state_dir.join(name), buf.as_bytes())?;
Ok(())
}
fn read_hex_file(path: &Path) -> ConflictStateResult<Option<Hash>> {
let raw = match read_capped(path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(ConflictStateError::Io(e)),
};
let trimmed = raw.trim_end_matches(['\n', '\r', ' ', '\t']);
if trimmed.len() != HEX_LEN {
return Err(ConflictStateError::Invalid);
}
hash::from_hex(trimmed)
.map(Some)
.map_err(|_| ConflictStateError::Invalid)
}
fn read_capped(path: &Path) -> io::Result<String> {
let meta = fs::metadata(path)?;
if meta.len() > MAX_STATE_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"state too large",
));
}
let raw = fs::read(path)?;
String::from_utf8(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MergeState {
pub merge_head: Hash,
pub orig_head: Hash,
pub message: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CherryPickState {
pub cherry_pick_head: Hash,
pub orig_head: Hash,
pub message: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RevertState {
pub revert_head: Hash,
pub orig_head: Hash,
pub message: Vec<u8>,
}
pub fn write_revert_state(
layout: &RepoLayout,
state: &RevertState,
conflicts: &[ConflictRecord],
) -> ConflictStateResult<()> {
fs::create_dir_all(layout.worktree_state_dir())?;
write_hex_file(layout.worktree_state_dir(), REVERT_HEAD, &state.revert_head)?;
write_hex_file(layout.worktree_state_dir(), ORIG_HEAD, &state.orig_head)?;
fs::write(layout.revert_msg_file(), &state.message)?;
fs::write(layout.conflicts_file(), serialize_conflicts(conflicts))?;
Ok(())
}
pub fn read_revert_state(layout: &RepoLayout) -> ConflictStateResult<Option<RevertState>> {
let Some(revert_head) = read_hex_file(&layout.revert_head_file())? else {
return Ok(None);
};
let orig_head = read_hex_file(&layout.orig_head_file())?.ok_or(ConflictStateError::Invalid)?;
let message = match fs::read(layout.revert_msg_file()) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(ConflictStateError::Io(e)),
};
Ok(Some(RevertState {
revert_head,
orig_head,
message,
}))
}
pub fn clear_revert_state(layout: &RepoLayout) -> ConflictStateResult<()> {
remove_if_present(&layout.revert_head_file())?;
remove_if_present(&layout.revert_msg_file())?;
remove_if_present(&layout.orig_head_file())?;
remove_if_present(&layout.conflicts_file())?;
remove_if_present(&layout.result_tree_file())?;
Ok(())
}
#[must_use]
pub fn is_revert_in_progress(layout: &RepoLayout) -> bool {
layout.revert_head_file().exists()
}
pub fn write_merge_state(
layout: &RepoLayout,
state: &MergeState,
conflicts: &[ConflictRecord],
) -> ConflictStateResult<()> {
fs::create_dir_all(layout.worktree_state_dir())?;
write_hex_file(layout.worktree_state_dir(), MERGE_HEAD, &state.merge_head)?;
write_hex_file(layout.worktree_state_dir(), ORIG_HEAD, &state.orig_head)?;
fs::write(layout.merge_msg_file(), &state.message)?;
fs::write(layout.conflicts_file(), serialize_conflicts(conflicts))?;
Ok(())
}
pub fn read_merge_state(layout: &RepoLayout) -> ConflictStateResult<Option<MergeState>> {
let Some(merge_head) = read_hex_file(&layout.merge_head_file())? else {
return Ok(None);
};
let orig_head = read_hex_file(&layout.orig_head_file())?.ok_or(ConflictStateError::Invalid)?;
let message = match fs::read(layout.merge_msg_file()) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(ConflictStateError::Io(e)),
};
Ok(Some(MergeState {
merge_head,
orig_head,
message,
}))
}
pub fn write_cherry_pick_state(
layout: &RepoLayout,
state: &CherryPickState,
conflicts: &[ConflictRecord],
) -> ConflictStateResult<()> {
fs::create_dir_all(layout.worktree_state_dir())?;
write_hex_file(
layout.worktree_state_dir(),
CHERRY_PICK_HEAD,
&state.cherry_pick_head,
)?;
write_hex_file(layout.worktree_state_dir(), ORIG_HEAD, &state.orig_head)?;
fs::write(layout.cherry_pick_msg_file(), &state.message)?;
fs::write(layout.conflicts_file(), serialize_conflicts(conflicts))?;
Ok(())
}
pub fn read_cherry_pick_state(layout: &RepoLayout) -> ConflictStateResult<Option<CherryPickState>> {
let Some(cherry_pick_head) = read_hex_file(&layout.cherry_pick_head_file())? else {
return Ok(None);
};
let orig_head = read_hex_file(&layout.orig_head_file())?.ok_or(ConflictStateError::Invalid)?;
let message = match fs::read(layout.cherry_pick_msg_file()) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(ConflictStateError::Io(e)),
};
Ok(Some(CherryPickState {
cherry_pick_head,
orig_head,
message,
}))
}
pub fn read_conflicts(dir: &Path) -> ConflictStateResult<Vec<ConflictRecord>> {
let path = dir.join(CONFLICTS_FILE);
let raw = match read_capped(&path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(ConflictStateError::Io(e)),
};
deserialize_conflicts(raw.as_bytes())
}
pub fn write_conflicts(dir: &Path, conflicts: &[ConflictRecord]) -> ConflictStateResult<()> {
fs::create_dir_all(dir)?;
fs::write(dir.join(CONFLICTS_FILE), serialize_conflicts(conflicts))?;
Ok(())
}
fn remove_if_present(path: &Path) -> ConflictStateResult<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(ConflictStateError::Io(e)),
}
}
pub fn clear_merge_state(layout: &RepoLayout) -> ConflictStateResult<()> {
remove_if_present(&layout.merge_head_file())?;
remove_if_present(&layout.merge_msg_file())?;
remove_if_present(&layout.orig_head_file())?;
remove_if_present(&layout.conflicts_file())?;
remove_if_present(&layout.result_tree_file())?;
Ok(())
}
pub fn clear_cherry_pick_state(layout: &RepoLayout) -> ConflictStateResult<()> {
remove_if_present(&layout.cherry_pick_head_file())?;
remove_if_present(&layout.cherry_pick_msg_file())?;
remove_if_present(&layout.orig_head_file())?;
remove_if_present(&layout.conflicts_file())?;
remove_if_present(&layout.result_tree_file())?;
Ok(())
}
#[must_use]
pub fn is_merge_in_progress(layout: &RepoLayout) -> bool {
layout.merge_head_file().exists()
}
#[must_use]
pub fn is_cherry_pick_in_progress(layout: &RepoLayout) -> bool {
layout.cherry_pick_head_file().exists()
}
#[must_use]
pub fn any_op_in_progress(layout: &RepoLayout) -> bool {
is_merge_in_progress(layout)
|| is_cherry_pick_in_progress(layout)
|| is_revert_in_progress(layout)
|| crate::ops::rebase::is_rebase_in_progress(layout)
}
#[must_use]
pub fn in_progress_op_name(layout: &RepoLayout) -> Option<&'static str> {
if is_merge_in_progress(layout) {
Some("merge")
} else if is_cherry_pick_in_progress(layout) {
Some("cherry-pick")
} else if is_revert_in_progress(layout) {
Some("revert")
} else if crate::ops::rebase::is_rebase_in_progress(layout) {
Some("rebase")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn h(seed: &str) -> Hash {
hash::hash(seed.as_bytes())
}
#[test]
fn conflict_records_round_trip() {
let records = vec![
ConflictRecord {
path: "src/main.rs".into(),
kind: ConflictKind::ModifyModify,
base_hash: Some(h("b")),
ours_hash: Some(h("o")),
theirs_hash: Some(h("t")),
},
ConflictRecord {
path: "new.txt".into(),
kind: ConflictKind::AddAdd,
base_hash: None,
ours_hash: Some(h("o2")),
theirs_hash: Some(h("t2")),
},
ConflictRecord {
path: "gone.txt".into(),
kind: ConflictKind::DeleteModify,
base_hash: Some(h("b3")),
ours_hash: None,
theirs_hash: Some(h("t3")),
},
];
let bytes = serialize_conflicts(&records);
let parsed = deserialize_conflicts(&bytes).unwrap();
assert_eq!(parsed, records);
}
#[test]
fn rejects_bad_kind() {
let line = format!("bogus\t-\t{}\t-\tpath.txt\n", hash::to_hex(&h("o")));
assert!(deserialize_conflicts(line.as_bytes()).is_err());
}
#[test]
fn rejects_bad_path() {
let line = format!("modify\t-\t{}\t-\t../escape\n", hash::to_hex(&h("o")));
assert!(deserialize_conflicts(line.as_bytes()).is_err());
}
#[test]
fn rejects_short_hex() {
let line = "modify\tdeadbeef\t-\t-\tpath.txt\n";
assert!(deserialize_conflicts(line.as_bytes()).is_err());
}
#[test]
fn rejects_truncated_line() {
let line = "modify\t-\t-\n";
assert!(deserialize_conflicts(line.as_bytes()).is_err());
}
#[test]
fn merge_state_round_trip() {
let tmp = TempDir::new().unwrap();
let mkit = RepoLayout::single(tmp.path());
fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
let state = MergeState {
merge_head: h("theirs"),
orig_head: h("orig"),
message: b"Merge branch 'x'".to_vec(),
};
let conflicts = vec![ConflictRecord {
path: "a.txt".into(),
kind: ConflictKind::ModifyModify,
base_hash: Some(h("b")),
ours_hash: Some(h("o")),
theirs_hash: Some(h("t")),
}];
write_merge_state(&mkit, &state, &conflicts).unwrap();
assert!(is_merge_in_progress(&mkit));
assert!(any_op_in_progress(&mkit));
let read = read_merge_state(&mkit).unwrap().unwrap();
assert_eq!(read, state);
assert_eq!(
read_conflicts(mkit.worktree_state_dir()).unwrap(),
conflicts
);
clear_merge_state(&mkit).unwrap();
assert!(!is_merge_in_progress(&mkit));
assert!(read_merge_state(&mkit).unwrap().is_none());
}
#[test]
fn cherry_pick_state_round_trip() {
let tmp = TempDir::new().unwrap();
let mkit = RepoLayout::single(tmp.path());
fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
let state = CherryPickState {
cherry_pick_head: h("pick"),
orig_head: h("orig"),
message: b"original message".to_vec(),
};
write_cherry_pick_state(&mkit, &state, &[]).unwrap();
assert!(is_cherry_pick_in_progress(&mkit));
let read = read_cherry_pick_state(&mkit).unwrap().unwrap();
assert_eq!(read, state);
clear_cherry_pick_state(&mkit).unwrap();
assert!(!is_cherry_pick_in_progress(&mkit));
}
#[test]
fn clear_is_idempotent() {
let tmp = TempDir::new().unwrap();
let mkit = RepoLayout::single(tmp.path());
fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
clear_merge_state(&mkit).unwrap();
clear_cherry_pick_state(&mkit).unwrap();
}
}