1use std::fs;
40use std::io;
41use std::path::Path;
42
43use crate::hash::{self, HEX_LEN, Hash};
44use crate::index::validate_index_path;
45use crate::layout::RepoLayout;
46use crate::ops::merge::{Conflict, ConflictKind};
47
48pub const MERGE_HEAD: &str = "MERGE_HEAD";
50pub const CHERRY_PICK_HEAD: &str = "CHERRY_PICK_HEAD";
52pub const ORIG_HEAD: &str = "ORIG_HEAD";
54pub const MERGE_MSG: &str = "MERGE_MSG";
56pub const CHERRY_PICK_MSG: &str = "CHERRY_PICK_MSG";
58pub const REVERT_HEAD: &str = "REVERT_HEAD";
60pub const REVERT_MSG: &str = "REVERT_MSG";
62pub const CONFLICTS_FILE: &str = "mkit-conflicts";
64pub const RESULT_TREE: &str = "MKIT_OP_RESULT";
68
69const MAX_STATE_BYTES: u64 = 1024 * 1024;
72
73#[derive(Debug, thiserror::Error)]
75pub enum ConflictStateError {
76 #[error("conflict state on disk is malformed")]
78 Invalid,
79 #[error(transparent)]
81 Io(#[from] io::Error),
82}
83
84pub type ConflictStateResult<T> = Result<T, ConflictStateError>;
86
87#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct ConflictRecord {
91 pub path: String,
93 pub kind: ConflictKind,
95 pub base_hash: Option<Hash>,
97 pub ours_hash: Option<Hash>,
99 pub theirs_hash: Option<Hash>,
101}
102
103impl From<&Conflict> for ConflictRecord {
104 fn from(c: &Conflict) -> Self {
105 Self {
106 path: c.path.clone(),
107 kind: c.kind,
108 base_hash: c.base_hash,
109 ours_hash: c.ours_hash,
110 theirs_hash: c.theirs_hash,
111 }
112 }
113}
114
115fn kind_tag(kind: ConflictKind) -> &'static str {
116 match kind {
117 ConflictKind::ModifyModify => "modify",
118 ConflictKind::AddAdd => "addadd",
119 ConflictKind::DeleteModify => "deletemodify",
120 }
121}
122
123fn kind_from_tag(tag: &str) -> Option<ConflictKind> {
124 match tag {
125 "modify" => Some(ConflictKind::ModifyModify),
126 "addadd" => Some(ConflictKind::AddAdd),
127 "deletemodify" => Some(ConflictKind::DeleteModify),
128 _ => None,
129 }
130}
131
132fn hex_or_dash(h: Option<Hash>) -> String {
133 match h {
134 Some(h) => hash::to_hex(&h),
135 None => "-".to_string(),
136 }
137}
138
139fn parse_hex_or_dash(field: &str) -> Result<Option<Hash>, ConflictStateError> {
140 if field == "-" {
141 return Ok(None);
142 }
143 if field.len() != HEX_LEN {
144 return Err(ConflictStateError::Invalid);
145 }
146 hash::from_hex(field)
147 .map(Some)
148 .map_err(|_| ConflictStateError::Invalid)
149}
150
151#[must_use]
153pub fn serialize_conflicts(records: &[ConflictRecord]) -> Vec<u8> {
154 let mut out = String::new();
155 for r in records {
156 out.push_str(kind_tag(r.kind));
157 out.push('\t');
158 out.push_str(&hex_or_dash(r.base_hash));
159 out.push('\t');
160 out.push_str(&hex_or_dash(r.ours_hash));
161 out.push('\t');
162 out.push_str(&hex_or_dash(r.theirs_hash));
163 out.push('\t');
164 out.push_str(&r.path);
165 out.push('\n');
166 }
167 out.into_bytes()
168}
169
170pub fn deserialize_conflicts(data: &[u8]) -> ConflictStateResult<Vec<ConflictRecord>> {
177 let text = core::str::from_utf8(data).map_err(|_| ConflictStateError::Invalid)?;
178 let mut out = Vec::new();
179 for line in text.split('\n') {
180 if line.is_empty() {
181 continue;
182 }
183 let mut fields = line.splitn(5, '\t');
186 let kind = fields.next().ok_or(ConflictStateError::Invalid)?;
187 let base = fields.next().ok_or(ConflictStateError::Invalid)?;
188 let ours = fields.next().ok_or(ConflictStateError::Invalid)?;
189 let theirs = fields.next().ok_or(ConflictStateError::Invalid)?;
190 let path = fields.next().ok_or(ConflictStateError::Invalid)?;
191 let kind = kind_from_tag(kind).ok_or(ConflictStateError::Invalid)?;
192 if !validate_index_path(path) {
193 return Err(ConflictStateError::Invalid);
194 }
195 out.push(ConflictRecord {
196 path: path.to_string(),
197 kind,
198 base_hash: parse_hex_or_dash(base)?,
199 ours_hash: parse_hex_or_dash(ours)?,
200 theirs_hash: parse_hex_or_dash(theirs)?,
201 });
202 }
203 Ok(out)
204}
205
206pub fn write_result_tree(dir: &Path, tree: &Hash) -> ConflictStateResult<()> {
215 write_hex_file(dir, RESULT_TREE, tree)
216}
217
218pub fn read_result_tree(dir: &Path) -> ConflictStateResult<Option<Hash>> {
223 read_hex_file(&dir.join(RESULT_TREE))
224}
225
226pub fn clear_result_tree(dir: &Path) {
228 let _ = fs::remove_file(dir.join(RESULT_TREE));
229}
230
231fn write_hex_file(state_dir: &Path, name: &str, h: &Hash) -> ConflictStateResult<()> {
232 let mut buf = hash::to_hex(h);
233 buf.push('\n');
234 fs::write(state_dir.join(name), buf.as_bytes())?;
235 Ok(())
236}
237
238fn read_hex_file(path: &Path) -> ConflictStateResult<Option<Hash>> {
239 let raw = match read_capped(path) {
240 Ok(s) => s,
241 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
242 Err(e) => return Err(ConflictStateError::Io(e)),
243 };
244 let trimmed = raw.trim_end_matches(['\n', '\r', ' ', '\t']);
245 if trimmed.len() != HEX_LEN {
246 return Err(ConflictStateError::Invalid);
247 }
248 hash::from_hex(trimmed)
249 .map(Some)
250 .map_err(|_| ConflictStateError::Invalid)
251}
252
253fn read_capped(path: &Path) -> io::Result<String> {
254 let meta = fs::metadata(path)?;
255 if meta.len() > MAX_STATE_BYTES {
256 return Err(io::Error::new(
257 io::ErrorKind::InvalidData,
258 "state too large",
259 ));
260 }
261 let raw = fs::read(path)?;
262 String::from_utf8(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8"))
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct MergeState {
268 pub merge_head: Hash,
270 pub orig_head: Hash,
272 pub message: Vec<u8>,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct CherryPickState {
279 pub cherry_pick_head: Hash,
281 pub orig_head: Hash,
283 pub message: Vec<u8>,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct RevertState {
290 pub revert_head: Hash,
292 pub orig_head: Hash,
294 pub message: Vec<u8>,
296}
297
298pub fn write_revert_state(
303 layout: &RepoLayout,
304 state: &RevertState,
305 conflicts: &[ConflictRecord],
306) -> ConflictStateResult<()> {
307 fs::create_dir_all(layout.worktree_state_dir())?;
308 write_hex_file(layout.worktree_state_dir(), REVERT_HEAD, &state.revert_head)?;
309 write_hex_file(layout.worktree_state_dir(), ORIG_HEAD, &state.orig_head)?;
310 fs::write(layout.revert_msg_file(), &state.message)?;
311 fs::write(layout.conflicts_file(), serialize_conflicts(conflicts))?;
312 Ok(())
313}
314
315pub fn read_revert_state(layout: &RepoLayout) -> ConflictStateResult<Option<RevertState>> {
320 let Some(revert_head) = read_hex_file(&layout.revert_head_file())? else {
321 return Ok(None);
322 };
323 let orig_head = read_hex_file(&layout.orig_head_file())?.ok_or(ConflictStateError::Invalid)?;
324 let message = match fs::read(layout.revert_msg_file()) {
325 Ok(m) => m,
326 Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
327 Err(e) => return Err(ConflictStateError::Io(e)),
328 };
329 Ok(Some(RevertState {
330 revert_head,
331 orig_head,
332 message,
333 }))
334}
335
336pub fn clear_revert_state(layout: &RepoLayout) -> ConflictStateResult<()> {
341 remove_if_present(&layout.revert_head_file())?;
342 remove_if_present(&layout.revert_msg_file())?;
343 remove_if_present(&layout.orig_head_file())?;
344 remove_if_present(&layout.conflicts_file())?;
345 remove_if_present(&layout.result_tree_file())?;
346 Ok(())
347}
348
349#[must_use]
351pub fn is_revert_in_progress(layout: &RepoLayout) -> bool {
352 layout.revert_head_file().exists()
353}
354
355pub fn write_merge_state(
360 layout: &RepoLayout,
361 state: &MergeState,
362 conflicts: &[ConflictRecord],
363) -> ConflictStateResult<()> {
364 fs::create_dir_all(layout.worktree_state_dir())?;
365 write_hex_file(layout.worktree_state_dir(), MERGE_HEAD, &state.merge_head)?;
366 write_hex_file(layout.worktree_state_dir(), ORIG_HEAD, &state.orig_head)?;
367 fs::write(layout.merge_msg_file(), &state.message)?;
368 fs::write(layout.conflicts_file(), serialize_conflicts(conflicts))?;
369 Ok(())
370}
371
372pub fn read_merge_state(layout: &RepoLayout) -> ConflictStateResult<Option<MergeState>> {
377 let Some(merge_head) = read_hex_file(&layout.merge_head_file())? else {
378 return Ok(None);
379 };
380 let orig_head = read_hex_file(&layout.orig_head_file())?.ok_or(ConflictStateError::Invalid)?;
381 let message = match fs::read(layout.merge_msg_file()) {
382 Ok(m) => m,
383 Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
384 Err(e) => return Err(ConflictStateError::Io(e)),
385 };
386 Ok(Some(MergeState {
387 merge_head,
388 orig_head,
389 message,
390 }))
391}
392
393pub fn write_cherry_pick_state(
398 layout: &RepoLayout,
399 state: &CherryPickState,
400 conflicts: &[ConflictRecord],
401) -> ConflictStateResult<()> {
402 fs::create_dir_all(layout.worktree_state_dir())?;
403 write_hex_file(
404 layout.worktree_state_dir(),
405 CHERRY_PICK_HEAD,
406 &state.cherry_pick_head,
407 )?;
408 write_hex_file(layout.worktree_state_dir(), ORIG_HEAD, &state.orig_head)?;
409 fs::write(layout.cherry_pick_msg_file(), &state.message)?;
410 fs::write(layout.conflicts_file(), serialize_conflicts(conflicts))?;
411 Ok(())
412}
413
414pub fn read_cherry_pick_state(layout: &RepoLayout) -> ConflictStateResult<Option<CherryPickState>> {
419 let Some(cherry_pick_head) = read_hex_file(&layout.cherry_pick_head_file())? else {
420 return Ok(None);
421 };
422 let orig_head = read_hex_file(&layout.orig_head_file())?.ok_or(ConflictStateError::Invalid)?;
423 let message = match fs::read(layout.cherry_pick_msg_file()) {
424 Ok(m) => m,
425 Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
426 Err(e) => return Err(ConflictStateError::Io(e)),
427 };
428 Ok(Some(CherryPickState {
429 cherry_pick_head,
430 orig_head,
431 message,
432 }))
433}
434
435pub fn read_conflicts(dir: &Path) -> ConflictStateResult<Vec<ConflictRecord>> {
442 let path = dir.join(CONFLICTS_FILE);
443 let raw = match read_capped(&path) {
444 Ok(s) => s,
445 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
446 Err(e) => return Err(ConflictStateError::Io(e)),
447 };
448 deserialize_conflicts(raw.as_bytes())
449}
450
451pub fn write_conflicts(dir: &Path, conflicts: &[ConflictRecord]) -> ConflictStateResult<()> {
456 fs::create_dir_all(dir)?;
457 fs::write(dir.join(CONFLICTS_FILE), serialize_conflicts(conflicts))?;
458 Ok(())
459}
460
461fn remove_if_present(path: &Path) -> ConflictStateResult<()> {
462 match fs::remove_file(path) {
463 Ok(()) => Ok(()),
464 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
465 Err(e) => Err(ConflictStateError::Io(e)),
466 }
467}
468
469pub fn clear_merge_state(layout: &RepoLayout) -> ConflictStateResult<()> {
474 remove_if_present(&layout.merge_head_file())?;
475 remove_if_present(&layout.merge_msg_file())?;
476 remove_if_present(&layout.orig_head_file())?;
477 remove_if_present(&layout.conflicts_file())?;
478 remove_if_present(&layout.result_tree_file())?;
479 Ok(())
480}
481
482pub fn clear_cherry_pick_state(layout: &RepoLayout) -> ConflictStateResult<()> {
487 remove_if_present(&layout.cherry_pick_head_file())?;
488 remove_if_present(&layout.cherry_pick_msg_file())?;
489 remove_if_present(&layout.orig_head_file())?;
490 remove_if_present(&layout.conflicts_file())?;
491 remove_if_present(&layout.result_tree_file())?;
492 Ok(())
493}
494
495#[must_use]
497pub fn is_merge_in_progress(layout: &RepoLayout) -> bool {
498 layout.merge_head_file().exists()
499}
500
501#[must_use]
503pub fn is_cherry_pick_in_progress(layout: &RepoLayout) -> bool {
504 layout.cherry_pick_head_file().exists()
505}
506
507#[must_use]
510pub fn any_op_in_progress(layout: &RepoLayout) -> bool {
511 is_merge_in_progress(layout)
512 || is_cherry_pick_in_progress(layout)
513 || is_revert_in_progress(layout)
514 || crate::ops::rebase::is_rebase_in_progress(layout)
515}
516
517#[must_use]
519pub fn in_progress_op_name(layout: &RepoLayout) -> Option<&'static str> {
520 if is_merge_in_progress(layout) {
521 Some("merge")
522 } else if is_cherry_pick_in_progress(layout) {
523 Some("cherry-pick")
524 } else if is_revert_in_progress(layout) {
525 Some("revert")
526 } else if crate::ops::rebase::is_rebase_in_progress(layout) {
527 Some("rebase")
528 } else {
529 None
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use tempfile::TempDir;
537
538 fn h(seed: &str) -> Hash {
539 hash::hash(seed.as_bytes())
540 }
541
542 #[test]
543 fn conflict_records_round_trip() {
544 let records = vec![
545 ConflictRecord {
546 path: "src/main.rs".into(),
547 kind: ConflictKind::ModifyModify,
548 base_hash: Some(h("b")),
549 ours_hash: Some(h("o")),
550 theirs_hash: Some(h("t")),
551 },
552 ConflictRecord {
553 path: "new.txt".into(),
554 kind: ConflictKind::AddAdd,
555 base_hash: None,
556 ours_hash: Some(h("o2")),
557 theirs_hash: Some(h("t2")),
558 },
559 ConflictRecord {
560 path: "gone.txt".into(),
561 kind: ConflictKind::DeleteModify,
562 base_hash: Some(h("b3")),
563 ours_hash: None,
564 theirs_hash: Some(h("t3")),
565 },
566 ];
567 let bytes = serialize_conflicts(&records);
568 let parsed = deserialize_conflicts(&bytes).unwrap();
569 assert_eq!(parsed, records);
570 }
571
572 #[test]
573 fn rejects_bad_kind() {
574 let line = format!("bogus\t-\t{}\t-\tpath.txt\n", hash::to_hex(&h("o")));
575 assert!(deserialize_conflicts(line.as_bytes()).is_err());
576 }
577
578 #[test]
579 fn rejects_bad_path() {
580 let line = format!("modify\t-\t{}\t-\t../escape\n", hash::to_hex(&h("o")));
581 assert!(deserialize_conflicts(line.as_bytes()).is_err());
582 }
583
584 #[test]
585 fn rejects_short_hex() {
586 let line = "modify\tdeadbeef\t-\t-\tpath.txt\n";
587 assert!(deserialize_conflicts(line.as_bytes()).is_err());
588 }
589
590 #[test]
591 fn rejects_truncated_line() {
592 let line = "modify\t-\t-\n";
593 assert!(deserialize_conflicts(line.as_bytes()).is_err());
594 }
595
596 #[test]
597 fn merge_state_round_trip() {
598 let tmp = TempDir::new().unwrap();
599 let mkit = RepoLayout::single(tmp.path());
600 fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
601 let state = MergeState {
602 merge_head: h("theirs"),
603 orig_head: h("orig"),
604 message: b"Merge branch 'x'".to_vec(),
605 };
606 let conflicts = vec![ConflictRecord {
607 path: "a.txt".into(),
608 kind: ConflictKind::ModifyModify,
609 base_hash: Some(h("b")),
610 ours_hash: Some(h("o")),
611 theirs_hash: Some(h("t")),
612 }];
613 write_merge_state(&mkit, &state, &conflicts).unwrap();
614 assert!(is_merge_in_progress(&mkit));
615 assert!(any_op_in_progress(&mkit));
616 let read = read_merge_state(&mkit).unwrap().unwrap();
617 assert_eq!(read, state);
618 assert_eq!(
619 read_conflicts(mkit.worktree_state_dir()).unwrap(),
620 conflicts
621 );
622 clear_merge_state(&mkit).unwrap();
623 assert!(!is_merge_in_progress(&mkit));
624 assert!(read_merge_state(&mkit).unwrap().is_none());
625 }
626
627 #[test]
628 fn cherry_pick_state_round_trip() {
629 let tmp = TempDir::new().unwrap();
630 let mkit = RepoLayout::single(tmp.path());
631 fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
632 let state = CherryPickState {
633 cherry_pick_head: h("pick"),
634 orig_head: h("orig"),
635 message: b"original message".to_vec(),
636 };
637 write_cherry_pick_state(&mkit, &state, &[]).unwrap();
638 assert!(is_cherry_pick_in_progress(&mkit));
639 let read = read_cherry_pick_state(&mkit).unwrap().unwrap();
640 assert_eq!(read, state);
641 clear_cherry_pick_state(&mkit).unwrap();
642 assert!(!is_cherry_pick_in_progress(&mkit));
643 }
644
645 #[test]
646 fn clear_is_idempotent() {
647 let tmp = TempDir::new().unwrap();
648 let mkit = RepoLayout::single(tmp.path());
649 fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
650 clear_merge_state(&mkit).unwrap();
651 clear_cherry_pick_state(&mkit).unwrap();
652 }
653}