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::ops::merge::{Conflict, ConflictKind};
46
47/// File name: other parent of an in-progress merge.
48pub const MERGE_HEAD: &str = "MERGE_HEAD";
49/// File name: commit being applied by an in-progress cherry-pick.
50pub const CHERRY_PICK_HEAD: &str = "CHERRY_PICK_HEAD";
51/// File name: HEAD before the in-progress operation started.
52pub const ORIG_HEAD: &str = "ORIG_HEAD";
53/// File name: pending merge commit message.
54pub const MERGE_MSG: &str = "MERGE_MSG";
55/// File name: pending cherry-pick commit message.
56pub const CHERRY_PICK_MSG: &str = "CHERRY_PICK_MSG";
57/// File name: commit being reverted by an in-progress revert.
58pub const REVERT_HEAD: &str = "REVERT_HEAD";
59/// File name: pending revert commit message.
60pub const REVERT_MSG: &str = "REVERT_MSG";
61/// File name: the conflict sidecar (also used inside `rebase-apply/`).
62pub const CONFLICTS_FILE: &str = "mkit-conflicts";
63
64/// Hard cap on any single state file we read back (1 MiB). Conflict
65/// sidecars list at most one line per repo path; 1 MiB is generous.
66const MAX_STATE_BYTES: u64 = 1024 * 1024;
67
68/// Errors raised by the conflict-state subsystem.
69#[derive(Debug, thiserror::Error)]
70pub enum ConflictStateError {
71    /// On-disk state was malformed (bad hex, bad kind, bad path, …).
72    #[error("conflict state on disk is malformed")]
73    Invalid,
74    /// Underlying I/O failure.
75    #[error(transparent)]
76    Io(#[from] io::Error),
77}
78
79/// Result alias.
80pub type ConflictStateResult<T> = Result<T, ConflictStateError>;
81
82/// One recorded conflicting path: the conflict kind plus the three blob
83/// hashes carried by [`Conflict`]. A missing side is `None`.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ConflictRecord {
86    /// Repo-relative path.
87    pub path: String,
88    /// Conflict kind.
89    pub kind: ConflictKind,
90    /// Base-side blob hash (`None` for add/add).
91    pub base_hash: Option<Hash>,
92    /// Ours-side blob hash (`None` when ours deleted).
93    pub ours_hash: Option<Hash>,
94    /// Theirs-side blob hash (`None` when theirs deleted).
95    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/// Serialise conflict records to the `mkit-conflicts` line format.
147#[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
165/// Parse the `mkit-conflicts` line format. Rejects malformed lines.
166///
167/// # Errors
168/// [`ConflictStateError::Invalid`] on any malformed line (bad field
169/// count, unknown kind, bad hex, or a path failing
170/// [`validate_index_path`]).
171pub 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        // kind, base, ours, theirs, path — exactly 5 fields; the path is
179        // last and may not contain a tab.
180        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/// Persisted state for an in-progress merge.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct MergeState {
238    /// The other (theirs) parent of the merge.
239    pub merge_head: Hash,
240    /// HEAD before the merge started.
241    pub orig_head: Hash,
242    /// Pending merge commit message.
243    pub message: Vec<u8>,
244}
245
246/// Persisted state for an in-progress cherry-pick.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct CherryPickState {
249    /// The commit being picked.
250    pub cherry_pick_head: Hash,
251    /// HEAD before the cherry-pick started.
252    pub orig_head: Hash,
253    /// Pending commit message (the picked commit's message).
254    pub message: Vec<u8>,
255}
256
257/// Persisted state for an in-progress revert.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct RevertState {
260    /// The commit being reverted.
261    pub revert_head: Hash,
262    /// HEAD before the revert started.
263    pub orig_head: Hash,
264    /// Pending commit message (the generated `Revert "..."` message).
265    pub message: Vec<u8>,
266}
267
268/// Write revert state + the conflict sidecar.
269///
270/// # Errors
271/// [`ConflictStateError::Io`] on filesystem failures.
272pub 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
288/// Read revert state. Returns `Ok(None)` when none is in progress.
289///
290/// # Errors
291/// [`ConflictStateError::Invalid`] on malformed state.
292pub 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
309/// Remove all revert state files (idempotent).
310///
311/// # Errors
312/// [`ConflictStateError::Io`] on filesystem failures other than absence.
313pub 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/// `true` when a revert is in progress (`REVERT_HEAD` present).
322#[must_use]
323pub fn is_revert_in_progress(mkit_dir: &Path) -> bool {
324    mkit_dir.join(REVERT_HEAD).exists()
325}
326
327/// Write merge state + the conflict sidecar.
328///
329/// # Errors
330/// [`ConflictStateError::Io`] on filesystem failures.
331pub 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
347/// Read merge state. Returns `Ok(None)` when no merge is in progress.
348///
349/// # Errors
350/// [`ConflictStateError::Invalid`] on malformed state.
351pub 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
368/// Write cherry-pick state + the conflict sidecar.
369///
370/// # Errors
371/// [`ConflictStateError::Io`] on filesystem failures.
372pub 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
388/// Read cherry-pick state. Returns `Ok(None)` when none is in progress.
389///
390/// # Errors
391/// [`ConflictStateError::Invalid`] on malformed state.
392pub 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
409/// Read the conflict sidecar from `dir` (either `.mkit/` for merge /
410/// cherry-pick or `.mkit/rebase-apply/` for rebase). Returns an empty
411/// vector when the file is absent.
412///
413/// # Errors
414/// [`ConflictStateError::Invalid`] on malformed lines.
415pub 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
425/// Write the conflict sidecar into `dir`.
426///
427/// # Errors
428/// [`ConflictStateError::Io`] on filesystem failures.
429pub 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
443/// Remove all merge state files (idempotent).
444///
445/// # Errors
446/// [`ConflictStateError::Io`] on filesystem failures other than absence.
447pub 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
455/// Remove all cherry-pick state files (idempotent).
456///
457/// # Errors
458/// [`ConflictStateError::Io`] on filesystem failures other than absence.
459pub 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/// `true` when a merge is in progress (`MERGE_HEAD` present).
468#[must_use]
469pub fn is_merge_in_progress(mkit_dir: &Path) -> bool {
470    mkit_dir.join(MERGE_HEAD).exists()
471}
472
473/// `true` when a cherry-pick is in progress (`CHERRY_PICK_HEAD` present).
474#[must_use]
475pub fn is_cherry_pick_in_progress(mkit_dir: &Path) -> bool {
476    mkit_dir.join(CHERRY_PICK_HEAD).exists()
477}
478
479/// `true` when any merge / cherry-pick / rebase is in progress. Used to
480/// refuse starting a second such operation while one is unfinished.
481#[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/// Human-readable name of whichever op is in progress, for error text.
490#[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}