Skip to main content

mkit_core/ops/
conflict_state.rs

1//! Conflict / operation state sidecar.
2//!
3//! mkit's `.mkit/index` stays a single-stage **resolved** staging area
4//! (see SPEC-INDEX — no unmerged stages 1/2/3). When a merge,
5//! cherry-pick, or rebase pauses on a conflict we instead persist the
6//! state needed to resume or abort in a small set of files under
7//! `.mkit/`, using Git-compatible names where they exist plus one
8//! documented mkit sidecar:
9//!
10//! - `MERGE_HEAD`        — 64-hex of the other parent (merge: theirs).
11//!   Presence ⇒ a merge is in progress.
12//! - `CHERRY_PICK_HEAD`  — 64-hex of the commit being picked.
13//!   Presence ⇒ a cherry-pick is in progress.
14//! - `REVERT_HEAD`       — 64-hex of the commit being reverted.
15//!   Presence ⇒ a revert is in progress.
16//! - `ORIG_HEAD`         — 64-hex of HEAD before the op (for `--abort`).
17//! - `MERGE_MSG` / `CHERRY_PICK_MSG` / `REVERT_MSG` — pending commit message bytes.
18//! - `mkit-conflicts`    — the sidecar: one line per conflicting path,
19//!   recording the conflict kind and the base/ours/theirs blob hashes.
20//!   Used by `--abort` cleanup and by the "unresolved conflicts remain"
21//!   gate on `--continue`.
22//!
23//! Rebase reuses the existing `.mkit/rebase-apply/` directory and writes
24//! the same `mkit-conflicts` sidecar **inside** that directory when it
25//! pauses.
26//!
27//! `mkit-conflicts` line format (tab-separated, one per path):
28//!
29//! ```text
30//! <kind>\t<base_hex|->\t<ours_hex|->\t<theirs_hex|->\t<path>
31//! ```
32//!
33//! where `<kind>` is one of `modify`, `addadd`, `deletemodify` and a
34//! missing side is encoded as a single `-`. The `<path>` is the final
35//! field so it may itself contain no `\t` (validated via
36//! [`crate::index::validate_index_path`] on read) and runs to end of
37//! line.
38
39use 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
48/// File name: other parent of an in-progress merge.
49pub const MERGE_HEAD: &str = "MERGE_HEAD";
50/// File name: commit being applied by an in-progress cherry-pick.
51pub const CHERRY_PICK_HEAD: &str = "CHERRY_PICK_HEAD";
52/// File name: HEAD before the in-progress operation started.
53pub const ORIG_HEAD: &str = "ORIG_HEAD";
54/// File name: pending merge commit message.
55pub const MERGE_MSG: &str = "MERGE_MSG";
56/// File name: pending cherry-pick commit message.
57pub const CHERRY_PICK_MSG: &str = "CHERRY_PICK_MSG";
58/// File name: commit being reverted by an in-progress revert.
59pub const REVERT_HEAD: &str = "REVERT_HEAD";
60/// File name: pending revert commit message.
61pub const REVERT_MSG: &str = "REVERT_MSG";
62/// File name: the conflict sidecar (also used inside `rebase-apply/`).
63pub const CONFLICTS_FILE: &str = "mkit-conflicts";
64/// File name: the operation's full result tree (clean changes + ours-at-
65/// conflict) recorded when a conflict pauses, so `--abort` can distinguish
66/// operation-authored paths from genuine user work.
67pub const RESULT_TREE: &str = "MKIT_OP_RESULT";
68
69/// Hard cap on any single state file we read back (1 MiB). Conflict
70/// sidecars list at most one line per repo path; 1 MiB is generous.
71const MAX_STATE_BYTES: u64 = 1024 * 1024;
72
73/// Errors raised by the conflict-state subsystem.
74#[derive(Debug, thiserror::Error)]
75pub enum ConflictStateError {
76    /// On-disk state was malformed (bad hex, bad kind, bad path, …).
77    #[error("conflict state on disk is malformed")]
78    Invalid,
79    /// Underlying I/O failure.
80    #[error(transparent)]
81    Io(#[from] io::Error),
82}
83
84/// Result alias.
85pub type ConflictStateResult<T> = Result<T, ConflictStateError>;
86
87/// One recorded conflicting path: the conflict kind plus the three blob
88/// hashes carried by [`Conflict`]. A missing side is `None`.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct ConflictRecord {
91    /// Repo-relative path.
92    pub path: String,
93    /// Conflict kind.
94    pub kind: ConflictKind,
95    /// Base-side blob hash (`None` for add/add).
96    pub base_hash: Option<Hash>,
97    /// Ours-side blob hash (`None` when ours deleted).
98    pub ours_hash: Option<Hash>,
99    /// Theirs-side blob hash (`None` when theirs deleted).
100    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/// Serialise conflict records to the `mkit-conflicts` line format.
152#[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
170/// Parse the `mkit-conflicts` line format. Rejects malformed lines.
171///
172/// # Errors
173/// [`ConflictStateError::Invalid`] on any malformed line (bad field
174/// count, unknown kind, bad hex, or a path failing
175/// [`validate_index_path`]).
176pub 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        // kind, base, ours, theirs, path — exactly 5 fields; the path is
184        // last and may not contain a tab.
185        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
206/// Persist the operation's full result tree under `dir` (the per-op state
207/// directory: `.mkit` for merge/cherry-pick/revert, the rebase dir for
208/// rebase). Used by `--abort` to treat operation-authored paths as
209/// discardable. Best-effort cleanup is done by the per-op clear functions
210/// (or by removing the rebase dir).
211///
212/// # Errors
213/// [`ConflictStateError::Io`] if the file cannot be written.
214pub fn write_result_tree(dir: &Path, tree: &Hash) -> ConflictStateResult<()> {
215    write_hex_file(dir, RESULT_TREE, tree)
216}
217
218/// Read the persisted operation result tree under `dir`, if any.
219///
220/// # Errors
221/// [`ConflictStateError::Invalid`] if the file is malformed.
222pub fn read_result_tree(dir: &Path) -> ConflictStateResult<Option<Hash>> {
223    read_hex_file(&dir.join(RESULT_TREE))
224}
225
226/// Remove the persisted operation result tree (best-effort).
227pub 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/// Persisted state for an in-progress merge.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct MergeState {
268    /// The other (theirs) parent of the merge.
269    pub merge_head: Hash,
270    /// HEAD before the merge started.
271    pub orig_head: Hash,
272    /// Pending merge commit message.
273    pub message: Vec<u8>,
274}
275
276/// Persisted state for an in-progress cherry-pick.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct CherryPickState {
279    /// The commit being picked.
280    pub cherry_pick_head: Hash,
281    /// HEAD before the cherry-pick started.
282    pub orig_head: Hash,
283    /// Pending commit message (the picked commit's message).
284    pub message: Vec<u8>,
285}
286
287/// Persisted state for an in-progress revert.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct RevertState {
290    /// The commit being reverted.
291    pub revert_head: Hash,
292    /// HEAD before the revert started.
293    pub orig_head: Hash,
294    /// Pending commit message (the generated `Revert "..."` message).
295    pub message: Vec<u8>,
296}
297
298/// Write revert state + the conflict sidecar.
299///
300/// # Errors
301/// [`ConflictStateError::Io`] on filesystem failures.
302pub 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
315/// Read revert state. Returns `Ok(None)` when none is in progress.
316///
317/// # Errors
318/// [`ConflictStateError::Invalid`] on malformed state.
319pub 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
336/// Remove all revert state files (idempotent).
337///
338/// # Errors
339/// [`ConflictStateError::Io`] on filesystem failures other than absence.
340pub 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/// `true` when a revert is in progress (`REVERT_HEAD` present).
350#[must_use]
351pub fn is_revert_in_progress(layout: &RepoLayout) -> bool {
352    layout.revert_head_file().exists()
353}
354
355/// Write merge state + the conflict sidecar.
356///
357/// # Errors
358/// [`ConflictStateError::Io`] on filesystem failures.
359pub 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
372/// Read merge state. Returns `Ok(None)` when no merge is in progress.
373///
374/// # Errors
375/// [`ConflictStateError::Invalid`] on malformed state.
376pub 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
393/// Write cherry-pick state + the conflict sidecar.
394///
395/// # Errors
396/// [`ConflictStateError::Io`] on filesystem failures.
397pub 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
414/// Read cherry-pick state. Returns `Ok(None)` when none is in progress.
415///
416/// # Errors
417/// [`ConflictStateError::Invalid`] on malformed state.
418pub 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
435/// Read the conflict sidecar from `dir` (either `.mkit/` for merge /
436/// cherry-pick or `.mkit/rebase-apply/` for rebase). Returns an empty
437/// vector when the file is absent.
438///
439/// # Errors
440/// [`ConflictStateError::Invalid`] on malformed lines.
441pub 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
451/// Write the conflict sidecar into `dir`.
452///
453/// # Errors
454/// [`ConflictStateError::Io`] on filesystem failures.
455pub 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
469/// Remove all merge state files (idempotent).
470///
471/// # Errors
472/// [`ConflictStateError::Io`] on filesystem failures other than absence.
473pub 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
482/// Remove all cherry-pick state files (idempotent).
483///
484/// # Errors
485/// [`ConflictStateError::Io`] on filesystem failures other than absence.
486pub 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/// `true` when a merge is in progress (`MERGE_HEAD` present).
496#[must_use]
497pub fn is_merge_in_progress(layout: &RepoLayout) -> bool {
498    layout.merge_head_file().exists()
499}
500
501/// `true` when a cherry-pick is in progress (`CHERRY_PICK_HEAD` present).
502#[must_use]
503pub fn is_cherry_pick_in_progress(layout: &RepoLayout) -> bool {
504    layout.cherry_pick_head_file().exists()
505}
506
507/// `true` when any merge / cherry-pick / rebase is in progress. Used to
508/// refuse starting a second such operation while one is unfinished.
509#[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/// Human-readable name of whichever op is in progress, for error text.
518#[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}