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::ops::merge::{Conflict, ConflictKind};
46
47pub const MERGE_HEAD: &str = "MERGE_HEAD";
49pub const CHERRY_PICK_HEAD: &str = "CHERRY_PICK_HEAD";
51pub const ORIG_HEAD: &str = "ORIG_HEAD";
53pub const MERGE_MSG: &str = "MERGE_MSG";
55pub const CHERRY_PICK_MSG: &str = "CHERRY_PICK_MSG";
57pub const REVERT_HEAD: &str = "REVERT_HEAD";
59pub const REVERT_MSG: &str = "REVERT_MSG";
61pub const CONFLICTS_FILE: &str = "mkit-conflicts";
63
64const MAX_STATE_BYTES: u64 = 1024 * 1024;
67
68#[derive(Debug, thiserror::Error)]
70pub enum ConflictStateError {
71 #[error("conflict state on disk is malformed")]
73 Invalid,
74 #[error(transparent)]
76 Io(#[from] io::Error),
77}
78
79pub type ConflictStateResult<T> = Result<T, ConflictStateError>;
81
82#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ConflictRecord {
86 pub path: String,
88 pub kind: ConflictKind,
90 pub base_hash: Option<Hash>,
92 pub ours_hash: Option<Hash>,
94 pub theirs_hash: Option<Hash>,
96}
97
98impl From<&Conflict> for ConflictRecord {
99 fn from(c: &Conflict) -> Self {
100 Self {
101 path: c.path.clone(),
102 kind: c.kind,
103 base_hash: c.base_hash,
104 ours_hash: c.ours_hash,
105 theirs_hash: c.theirs_hash,
106 }
107 }
108}
109
110fn kind_tag(kind: ConflictKind) -> &'static str {
111 match kind {
112 ConflictKind::ModifyModify => "modify",
113 ConflictKind::AddAdd => "addadd",
114 ConflictKind::DeleteModify => "deletemodify",
115 }
116}
117
118fn kind_from_tag(tag: &str) -> Option<ConflictKind> {
119 match tag {
120 "modify" => Some(ConflictKind::ModifyModify),
121 "addadd" => Some(ConflictKind::AddAdd),
122 "deletemodify" => Some(ConflictKind::DeleteModify),
123 _ => None,
124 }
125}
126
127fn hex_or_dash(h: Option<Hash>) -> String {
128 match h {
129 Some(h) => hash::to_hex(&h),
130 None => "-".to_string(),
131 }
132}
133
134fn parse_hex_or_dash(field: &str) -> Result<Option<Hash>, ConflictStateError> {
135 if field == "-" {
136 return Ok(None);
137 }
138 if field.len() != HEX_LEN {
139 return Err(ConflictStateError::Invalid);
140 }
141 hash::from_hex(field)
142 .map(Some)
143 .map_err(|_| ConflictStateError::Invalid)
144}
145
146#[must_use]
148pub fn serialize_conflicts(records: &[ConflictRecord]) -> Vec<u8> {
149 let mut out = String::new();
150 for r in records {
151 out.push_str(kind_tag(r.kind));
152 out.push('\t');
153 out.push_str(&hex_or_dash(r.base_hash));
154 out.push('\t');
155 out.push_str(&hex_or_dash(r.ours_hash));
156 out.push('\t');
157 out.push_str(&hex_or_dash(r.theirs_hash));
158 out.push('\t');
159 out.push_str(&r.path);
160 out.push('\n');
161 }
162 out.into_bytes()
163}
164
165pub fn deserialize_conflicts(data: &[u8]) -> ConflictStateResult<Vec<ConflictRecord>> {
172 let text = core::str::from_utf8(data).map_err(|_| ConflictStateError::Invalid)?;
173 let mut out = Vec::new();
174 for line in text.split('\n') {
175 if line.is_empty() {
176 continue;
177 }
178 let mut fields = line.splitn(5, '\t');
181 let kind = fields.next().ok_or(ConflictStateError::Invalid)?;
182 let base = fields.next().ok_or(ConflictStateError::Invalid)?;
183 let ours = fields.next().ok_or(ConflictStateError::Invalid)?;
184 let theirs = fields.next().ok_or(ConflictStateError::Invalid)?;
185 let path = fields.next().ok_or(ConflictStateError::Invalid)?;
186 let kind = kind_from_tag(kind).ok_or(ConflictStateError::Invalid)?;
187 if !validate_index_path(path) {
188 return Err(ConflictStateError::Invalid);
189 }
190 out.push(ConflictRecord {
191 path: path.to_string(),
192 kind,
193 base_hash: parse_hex_or_dash(base)?,
194 ours_hash: parse_hex_or_dash(ours)?,
195 theirs_hash: parse_hex_or_dash(theirs)?,
196 });
197 }
198 Ok(out)
199}
200
201fn write_hex_file(mkit_dir: &Path, name: &str, h: &Hash) -> ConflictStateResult<()> {
202 let mut buf = hash::to_hex(h);
203 buf.push('\n');
204 fs::write(mkit_dir.join(name), buf.as_bytes())?;
205 Ok(())
206}
207
208fn read_hex_file(path: &Path) -> ConflictStateResult<Option<Hash>> {
209 let raw = match read_capped(path) {
210 Ok(s) => s,
211 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
212 Err(e) => return Err(ConflictStateError::Io(e)),
213 };
214 let trimmed = raw.trim_end_matches(['\n', '\r', ' ', '\t']);
215 if trimmed.len() != HEX_LEN {
216 return Err(ConflictStateError::Invalid);
217 }
218 hash::from_hex(trimmed)
219 .map(Some)
220 .map_err(|_| ConflictStateError::Invalid)
221}
222
223fn read_capped(path: &Path) -> io::Result<String> {
224 let meta = fs::metadata(path)?;
225 if meta.len() > MAX_STATE_BYTES {
226 return Err(io::Error::new(
227 io::ErrorKind::InvalidData,
228 "state too large",
229 ));
230 }
231 let raw = fs::read(path)?;
232 String::from_utf8(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8"))
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct MergeState {
238 pub merge_head: Hash,
240 pub orig_head: Hash,
242 pub message: Vec<u8>,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct CherryPickState {
249 pub cherry_pick_head: Hash,
251 pub orig_head: Hash,
253 pub message: Vec<u8>,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct RevertState {
260 pub revert_head: Hash,
262 pub orig_head: Hash,
264 pub message: Vec<u8>,
266}
267
268pub fn write_revert_state(
273 mkit_dir: &Path,
274 state: &RevertState,
275 conflicts: &[ConflictRecord],
276) -> ConflictStateResult<()> {
277 fs::create_dir_all(mkit_dir)?;
278 write_hex_file(mkit_dir, REVERT_HEAD, &state.revert_head)?;
279 write_hex_file(mkit_dir, ORIG_HEAD, &state.orig_head)?;
280 fs::write(mkit_dir.join(REVERT_MSG), &state.message)?;
281 fs::write(
282 mkit_dir.join(CONFLICTS_FILE),
283 serialize_conflicts(conflicts),
284 )?;
285 Ok(())
286}
287
288pub fn read_revert_state(mkit_dir: &Path) -> ConflictStateResult<Option<RevertState>> {
293 let Some(revert_head) = read_hex_file(&mkit_dir.join(REVERT_HEAD))? else {
294 return Ok(None);
295 };
296 let orig_head = read_hex_file(&mkit_dir.join(ORIG_HEAD))?.ok_or(ConflictStateError::Invalid)?;
297 let message = match fs::read(mkit_dir.join(REVERT_MSG)) {
298 Ok(m) => m,
299 Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
300 Err(e) => return Err(ConflictStateError::Io(e)),
301 };
302 Ok(Some(RevertState {
303 revert_head,
304 orig_head,
305 message,
306 }))
307}
308
309pub fn clear_revert_state(mkit_dir: &Path) -> ConflictStateResult<()> {
314 remove_if_present(&mkit_dir.join(REVERT_HEAD))?;
315 remove_if_present(&mkit_dir.join(REVERT_MSG))?;
316 remove_if_present(&mkit_dir.join(ORIG_HEAD))?;
317 remove_if_present(&mkit_dir.join(CONFLICTS_FILE))?;
318 Ok(())
319}
320
321#[must_use]
323pub fn is_revert_in_progress(mkit_dir: &Path) -> bool {
324 mkit_dir.join(REVERT_HEAD).exists()
325}
326
327pub fn write_merge_state(
332 mkit_dir: &Path,
333 state: &MergeState,
334 conflicts: &[ConflictRecord],
335) -> ConflictStateResult<()> {
336 fs::create_dir_all(mkit_dir)?;
337 write_hex_file(mkit_dir, MERGE_HEAD, &state.merge_head)?;
338 write_hex_file(mkit_dir, ORIG_HEAD, &state.orig_head)?;
339 fs::write(mkit_dir.join(MERGE_MSG), &state.message)?;
340 fs::write(
341 mkit_dir.join(CONFLICTS_FILE),
342 serialize_conflicts(conflicts),
343 )?;
344 Ok(())
345}
346
347pub fn read_merge_state(mkit_dir: &Path) -> ConflictStateResult<Option<MergeState>> {
352 let Some(merge_head) = read_hex_file(&mkit_dir.join(MERGE_HEAD))? else {
353 return Ok(None);
354 };
355 let orig_head = read_hex_file(&mkit_dir.join(ORIG_HEAD))?.ok_or(ConflictStateError::Invalid)?;
356 let message = match fs::read(mkit_dir.join(MERGE_MSG)) {
357 Ok(m) => m,
358 Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
359 Err(e) => return Err(ConflictStateError::Io(e)),
360 };
361 Ok(Some(MergeState {
362 merge_head,
363 orig_head,
364 message,
365 }))
366}
367
368pub fn write_cherry_pick_state(
373 mkit_dir: &Path,
374 state: &CherryPickState,
375 conflicts: &[ConflictRecord],
376) -> ConflictStateResult<()> {
377 fs::create_dir_all(mkit_dir)?;
378 write_hex_file(mkit_dir, CHERRY_PICK_HEAD, &state.cherry_pick_head)?;
379 write_hex_file(mkit_dir, ORIG_HEAD, &state.orig_head)?;
380 fs::write(mkit_dir.join(CHERRY_PICK_MSG), &state.message)?;
381 fs::write(
382 mkit_dir.join(CONFLICTS_FILE),
383 serialize_conflicts(conflicts),
384 )?;
385 Ok(())
386}
387
388pub fn read_cherry_pick_state(mkit_dir: &Path) -> ConflictStateResult<Option<CherryPickState>> {
393 let Some(cherry_pick_head) = read_hex_file(&mkit_dir.join(CHERRY_PICK_HEAD))? else {
394 return Ok(None);
395 };
396 let orig_head = read_hex_file(&mkit_dir.join(ORIG_HEAD))?.ok_or(ConflictStateError::Invalid)?;
397 let message = match fs::read(mkit_dir.join(CHERRY_PICK_MSG)) {
398 Ok(m) => m,
399 Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
400 Err(e) => return Err(ConflictStateError::Io(e)),
401 };
402 Ok(Some(CherryPickState {
403 cherry_pick_head,
404 orig_head,
405 message,
406 }))
407}
408
409pub fn read_conflicts(dir: &Path) -> ConflictStateResult<Vec<ConflictRecord>> {
416 let path = dir.join(CONFLICTS_FILE);
417 let raw = match read_capped(&path) {
418 Ok(s) => s,
419 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
420 Err(e) => return Err(ConflictStateError::Io(e)),
421 };
422 deserialize_conflicts(raw.as_bytes())
423}
424
425pub fn write_conflicts(dir: &Path, conflicts: &[ConflictRecord]) -> ConflictStateResult<()> {
430 fs::create_dir_all(dir)?;
431 fs::write(dir.join(CONFLICTS_FILE), serialize_conflicts(conflicts))?;
432 Ok(())
433}
434
435fn remove_if_present(path: &Path) -> ConflictStateResult<()> {
436 match fs::remove_file(path) {
437 Ok(()) => Ok(()),
438 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
439 Err(e) => Err(ConflictStateError::Io(e)),
440 }
441}
442
443pub fn clear_merge_state(mkit_dir: &Path) -> ConflictStateResult<()> {
448 remove_if_present(&mkit_dir.join(MERGE_HEAD))?;
449 remove_if_present(&mkit_dir.join(MERGE_MSG))?;
450 remove_if_present(&mkit_dir.join(ORIG_HEAD))?;
451 remove_if_present(&mkit_dir.join(CONFLICTS_FILE))?;
452 Ok(())
453}
454
455pub fn clear_cherry_pick_state(mkit_dir: &Path) -> ConflictStateResult<()> {
460 remove_if_present(&mkit_dir.join(CHERRY_PICK_HEAD))?;
461 remove_if_present(&mkit_dir.join(CHERRY_PICK_MSG))?;
462 remove_if_present(&mkit_dir.join(ORIG_HEAD))?;
463 remove_if_present(&mkit_dir.join(CONFLICTS_FILE))?;
464 Ok(())
465}
466
467#[must_use]
469pub fn is_merge_in_progress(mkit_dir: &Path) -> bool {
470 mkit_dir.join(MERGE_HEAD).exists()
471}
472
473#[must_use]
475pub fn is_cherry_pick_in_progress(mkit_dir: &Path) -> bool {
476 mkit_dir.join(CHERRY_PICK_HEAD).exists()
477}
478
479#[must_use]
482pub fn any_op_in_progress(mkit_dir: &Path) -> bool {
483 is_merge_in_progress(mkit_dir)
484 || is_cherry_pick_in_progress(mkit_dir)
485 || is_revert_in_progress(mkit_dir)
486 || crate::ops::rebase::is_rebase_in_progress(mkit_dir)
487}
488
489#[must_use]
491pub fn in_progress_op_name(mkit_dir: &Path) -> Option<&'static str> {
492 if is_merge_in_progress(mkit_dir) {
493 Some("merge")
494 } else if is_cherry_pick_in_progress(mkit_dir) {
495 Some("cherry-pick")
496 } else if is_revert_in_progress(mkit_dir) {
497 Some("revert")
498 } else if crate::ops::rebase::is_rebase_in_progress(mkit_dir) {
499 Some("rebase")
500 } else {
501 None
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use tempfile::TempDir;
509
510 fn h(seed: &str) -> Hash {
511 hash::hash(seed.as_bytes())
512 }
513
514 #[test]
515 fn conflict_records_round_trip() {
516 let records = vec![
517 ConflictRecord {
518 path: "src/main.rs".into(),
519 kind: ConflictKind::ModifyModify,
520 base_hash: Some(h("b")),
521 ours_hash: Some(h("o")),
522 theirs_hash: Some(h("t")),
523 },
524 ConflictRecord {
525 path: "new.txt".into(),
526 kind: ConflictKind::AddAdd,
527 base_hash: None,
528 ours_hash: Some(h("o2")),
529 theirs_hash: Some(h("t2")),
530 },
531 ConflictRecord {
532 path: "gone.txt".into(),
533 kind: ConflictKind::DeleteModify,
534 base_hash: Some(h("b3")),
535 ours_hash: None,
536 theirs_hash: Some(h("t3")),
537 },
538 ];
539 let bytes = serialize_conflicts(&records);
540 let parsed = deserialize_conflicts(&bytes).unwrap();
541 assert_eq!(parsed, records);
542 }
543
544 #[test]
545 fn rejects_bad_kind() {
546 let line = format!("bogus\t-\t{}\t-\tpath.txt\n", hash::to_hex(&h("o")));
547 assert!(deserialize_conflicts(line.as_bytes()).is_err());
548 }
549
550 #[test]
551 fn rejects_bad_path() {
552 let line = format!("modify\t-\t{}\t-\t../escape\n", hash::to_hex(&h("o")));
553 assert!(deserialize_conflicts(line.as_bytes()).is_err());
554 }
555
556 #[test]
557 fn rejects_short_hex() {
558 let line = "modify\tdeadbeef\t-\t-\tpath.txt\n";
559 assert!(deserialize_conflicts(line.as_bytes()).is_err());
560 }
561
562 #[test]
563 fn rejects_truncated_line() {
564 let line = "modify\t-\t-\n";
565 assert!(deserialize_conflicts(line.as_bytes()).is_err());
566 }
567
568 #[test]
569 fn merge_state_round_trip() {
570 let tmp = TempDir::new().unwrap();
571 let mkit = tmp.path().join(".mkit");
572 fs::create_dir_all(&mkit).unwrap();
573 let state = MergeState {
574 merge_head: h("theirs"),
575 orig_head: h("orig"),
576 message: b"Merge branch 'x'".to_vec(),
577 };
578 let conflicts = vec![ConflictRecord {
579 path: "a.txt".into(),
580 kind: ConflictKind::ModifyModify,
581 base_hash: Some(h("b")),
582 ours_hash: Some(h("o")),
583 theirs_hash: Some(h("t")),
584 }];
585 write_merge_state(&mkit, &state, &conflicts).unwrap();
586 assert!(is_merge_in_progress(&mkit));
587 assert!(any_op_in_progress(&mkit));
588 let read = read_merge_state(&mkit).unwrap().unwrap();
589 assert_eq!(read, state);
590 assert_eq!(read_conflicts(&mkit).unwrap(), conflicts);
591 clear_merge_state(&mkit).unwrap();
592 assert!(!is_merge_in_progress(&mkit));
593 assert!(read_merge_state(&mkit).unwrap().is_none());
594 }
595
596 #[test]
597 fn cherry_pick_state_round_trip() {
598 let tmp = TempDir::new().unwrap();
599 let mkit = tmp.path().join(".mkit");
600 fs::create_dir_all(&mkit).unwrap();
601 let state = CherryPickState {
602 cherry_pick_head: h("pick"),
603 orig_head: h("orig"),
604 message: b"original message".to_vec(),
605 };
606 write_cherry_pick_state(&mkit, &state, &[]).unwrap();
607 assert!(is_cherry_pick_in_progress(&mkit));
608 let read = read_cherry_pick_state(&mkit).unwrap().unwrap();
609 assert_eq!(read, state);
610 clear_cherry_pick_state(&mkit).unwrap();
611 assert!(!is_cherry_pick_in_progress(&mkit));
612 }
613
614 #[test]
615 fn clear_is_idempotent() {
616 let tmp = TempDir::new().unwrap();
617 let mkit = tmp.path().join(".mkit");
618 fs::create_dir_all(&mkit).unwrap();
619 clear_merge_state(&mkit).unwrap();
620 clear_cherry_pick_state(&mkit).unwrap();
621 }
622}