Skip to main content

mkit_core/ops/
rebase.rs

1//! Rebase state machine.
2//!
3//! Persists rebase state under `.mkit/rebase-apply/` as six files:
4//!
5//! - `head-name`  : symbolic name of the branch being rebased
6//! - `orig-head`  : 64-hex BLAKE3 of the tip before rebase started
7//! - `onto`       : 64-hex BLAKE3 of the rebase target
8//! - `todo`       : newline-separated list of remaining commits
9//! - `actions`    : newline-separated per-commit action parallel to `todo`
10//!   (`pick`/`reword`/`squash`/`fixup`); absent ⇒ every commit is a `pick`,
11//!   for back-compat with rebases started before interactive support
12//! - `done`       : newline-separated list of replayed commits
13//!
14//! Trailing newlines are tolerated on read and always written.
15//!
16//! [`collect_commits_to_replay`] walks the first-parent chain from
17//! the rebase head until it hits `onto` or any ancestor of `onto`.
18//! It returns commits in oldest-first order. The ancestor walk is
19//! inlined here as a private helper (DFS, capped at `10_000` commits)
20//! to keep the module self-contained.
21
22use std::collections::HashSet;
23use std::fs;
24use std::io;
25use std::path::{Path, PathBuf};
26
27use crate::hash::{self, HEX_LEN, Hash};
28use crate::layout::RepoLayout;
29use crate::object::Object;
30use crate::store::ObjectStore;
31
32/// Directory under `.mkit/` that holds the persisted rebase state.
33pub const REBASE_DIR: &str = "rebase-apply";
34
35const HEAD_NAME_FILE: &str = "head-name";
36const ORIG_HEAD_FILE: &str = "orig-head";
37const ONTO_FILE: &str = "onto";
38const TODO_FILE: &str = "todo";
39const DONE_FILE: &str = "done";
40const ACTIONS_FILE: &str = "actions";
41
42const MAX_HEAD_NAME_BYTES: u64 = 4096;
43const MAX_HASH_FILE_BYTES: u64 = 128;
44const MAX_HASH_LIST_BYTES: u64 = 1024 * 1024;
45
46/// Maximum commits walked by [`collect_commits_to_replay`] before bail-out.
47const MAX_REPLAY_DEPTH: usize = 10_000;
48
49/// Maximum commits visited by the inlined ancestor walk.
50const MAX_ANCESTORS: usize = 10_000;
51
52/// Errors raised by this module.
53#[derive(Debug, thiserror::Error)]
54pub enum RebaseError {
55    /// `read_state` was called but `.mkit/rebase-apply/` does not exist.
56    #[error("no rebase in progress")]
57    NoRebaseInProgress,
58    /// On-disk state is malformed (bad hex, missing file, etc.).
59    #[error("rebase state on disk is malformed")]
60    InvalidRebaseState,
61    /// Underlying I/O failure.
62    #[error(transparent)]
63    Io(#[from] io::Error),
64    /// Object decode failure during the walk.
65    #[error(transparent)]
66    Object(#[from] crate::object::MkitError),
67    /// Object-store failure during the walk.
68    #[error(transparent)]
69    Store(#[from] crate::store::StoreError),
70}
71
72/// Result alias.
73pub type RebaseResult<T> = Result<T, RebaseError>;
74
75/// What to do with a commit when it is replayed during an interactive
76/// rebase. Non-interactive rebase uses [`RebaseAction::Pick`] for every
77/// commit. (`drop` is represented by omitting the commit from the todo.)
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum RebaseAction {
80    /// Replay the commit unchanged (default).
81    Pick,
82    /// Replay the commit, then open the editor to rewrite its message.
83    Reword,
84    /// Fold the commit into the previous one, combining their messages
85    /// (the editor opens on the combined message).
86    Squash,
87    /// Fold the commit into the previous one, discarding this commit's
88    /// message (the previous message is kept).
89    Fixup,
90}
91
92impl RebaseAction {
93    /// The todo-file keyword for this action.
94    #[must_use]
95    pub fn keyword(self) -> &'static str {
96        match self {
97            RebaseAction::Pick => "pick",
98            RebaseAction::Reword => "reword",
99            RebaseAction::Squash => "squash",
100            RebaseAction::Fixup => "fixup",
101        }
102    }
103
104    /// `true` for actions that fold into the previous commit rather than
105    /// creating a new one on top of it.
106    #[must_use]
107    pub fn folds_into_previous(self) -> bool {
108        matches!(self, RebaseAction::Squash | RebaseAction::Fixup)
109    }
110
111    /// Parse an action from its persisted keyword.
112    #[must_use]
113    pub fn from_keyword(s: &str) -> Option<Self> {
114        match s {
115            "pick" => Some(RebaseAction::Pick),
116            "reword" => Some(RebaseAction::Reword),
117            "squash" => Some(RebaseAction::Squash),
118            "fixup" => Some(RebaseAction::Fixup),
119            _ => None,
120        }
121    }
122}
123
124/// Persisted rebase state.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct RebaseState {
127    /// Name of the branch being rebased (e.g. `main`, `feature/x`).
128    pub head_name: String,
129    /// Tip of `head_name` before rebase started.
130    pub orig_head: Hash,
131    /// The commit being rebased onto.
132    pub onto: Hash,
133    /// Remaining commits to replay (oldest-first).
134    pub todo: Vec<Hash>,
135    /// Per-commit action, parallel to `todo` (same length). For a
136    /// non-interactive rebase every entry is [`RebaseAction::Pick`].
137    pub actions: Vec<RebaseAction>,
138    /// Commits already replayed.
139    pub done: Vec<Hash>,
140}
141
142impl RebaseState {
143    /// The action for the commit at the front of `todo` (defaults to
144    /// `Pick` if the parallel actions list is somehow short).
145    #[must_use]
146    pub fn front_action(&self) -> RebaseAction {
147        self.actions.first().copied().unwrap_or(RebaseAction::Pick)
148    }
149
150    /// Drop the front `todo` commit and its parallel action together, so
151    /// the two lists stay aligned as commits are consumed.
152    pub fn consume_front(&mut self) {
153        if !self.todo.is_empty() {
154            self.todo.remove(0);
155        }
156        if !self.actions.is_empty() {
157            self.actions.remove(0);
158        }
159    }
160}
161
162/// Returns `true` when `<mkit_dir>/rebase-apply/` exists.
163#[must_use]
164pub fn is_rebase_in_progress(layout: &RepoLayout) -> bool {
165    layout.rebase_dir().is_dir()
166}
167
168/// Read rebase state from `<mkit_dir>/rebase-apply/`.
169///
170/// # Errors
171/// - [`RebaseError::NoRebaseInProgress`] if the directory does not exist.
172/// - [`RebaseError::InvalidRebaseState`] if any file is malformed.
173pub fn read_state(layout: &RepoLayout) -> RebaseResult<RebaseState> {
174    let dir = layout.rebase_dir();
175    if !dir.is_dir() {
176        return Err(RebaseError::NoRebaseInProgress);
177    }
178
179    let head_name = read_text_capped(&dir.join(HEAD_NAME_FILE), MAX_HEAD_NAME_BYTES)
180        .map_err(|_| RebaseError::InvalidRebaseState)?;
181    let head_name = trim_trailing(&head_name).to_string();
182
183    let orig_head = read_hex_hash(&dir.join(ORIG_HEAD_FILE))?;
184    let onto = read_hex_hash(&dir.join(ONTO_FILE))?;
185
186    let todo = read_hash_list(&dir.join(TODO_FILE))?;
187    let done = read_hash_list(&dir.join(DONE_FILE))?;
188    // The actions file is parallel to todo. Absent (a rebase started before
189    // interactive support, or a non-interactive rebase) → every commit is a
190    // Pick. Realign to todo's length defensively.
191    let actions = read_actions(&dir.join(ACTIONS_FILE), todo.len())?;
192
193    Ok(RebaseState {
194        head_name,
195        orig_head,
196        onto,
197        todo,
198        actions,
199        done,
200    })
201}
202
203/// Persist `state` to `<mkit_dir>/rebase-apply/`.
204///
205/// # Errors
206/// - [`RebaseError::Io`] for filesystem failures.
207pub fn write_state(layout: &RepoLayout, state: &RebaseState) -> RebaseResult<()> {
208    let dir = layout.rebase_dir();
209    fs::create_dir_all(&dir)?;
210
211    write_with_newline(&dir.join(HEAD_NAME_FILE), state.head_name.as_bytes())?;
212    write_with_newline(
213        &dir.join(ORIG_HEAD_FILE),
214        hash::to_hex(&state.orig_head).as_bytes(),
215    )?;
216    write_with_newline(&dir.join(ONTO_FILE), hash::to_hex(&state.onto).as_bytes())?;
217    write_hash_list(&dir.join(TODO_FILE), &state.todo)?;
218    write_actions(&dir.join(ACTIONS_FILE), &state.actions)?;
219    write_hash_list(&dir.join(DONE_FILE), &state.done)?;
220    Ok(())
221}
222
223/// Remove `<mkit_dir>/rebase-apply/`. Idempotent.
224///
225/// # Errors
226/// - [`RebaseError::Io`] for filesystem failures other than "not found".
227pub fn cleanup_rebase(layout: &RepoLayout) -> RebaseResult<()> {
228    let dir = layout.rebase_dir();
229    match fs::remove_dir_all(&dir) {
230        Ok(()) => Ok(()),
231        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
232        Err(e) => Err(RebaseError::Io(e)),
233    }
234}
235
236/// Walk first-parent chain from `head_hash` back, stopping when we reach
237/// `onto_hash` or any ancestor of it. Returns commits in oldest-first
238/// order (ready for replay). Caps the walk at `MAX_REPLAY_DEPTH`.
239///
240/// # Errors
241/// - [`RebaseError::Store`] / [`RebaseError::Object`] for store failures.
242pub fn collect_commits_to_replay(
243    store: &ObjectStore,
244    head_hash: Hash,
245    onto_hash: Hash,
246) -> RebaseResult<Vec<Hash>> {
247    if head_hash == onto_hash {
248        return Ok(Vec::new());
249    }
250
251    let mut onto_ancestors: HashSet<Hash> = HashSet::new();
252    collect_ancestor_set(store, onto_hash, &mut onto_ancestors);
253
254    let mut commits: Vec<Hash> = Vec::new();
255    let mut current = head_hash;
256    let mut depth = 0usize;
257
258    while depth < MAX_REPLAY_DEPTH {
259        if current == onto_hash || onto_ancestors.contains(&current) {
260            break;
261        }
262        commits.push(current);
263
264        let Ok(obj) = store.read_object(&current) else {
265            break;
266        };
267        let Object::Commit(commit) = obj else { break };
268        if commit.parents.is_empty() {
269            break;
270        }
271        current = commit.parents[0];
272        depth += 1;
273    }
274
275    commits.reverse();
276    Ok(commits)
277}
278
279// -- Internal helpers --------------------------------------------------------
280
281/// Private ancestor-set walk. Kept private so `ops::graph` owns the
282/// public surface.
283fn collect_ancestor_set(store: &ObjectStore, start: Hash, set: &mut HashSet<Hash>) {
284    let mut stack: Vec<Hash> = vec![start];
285    let mut count = 0usize;
286    while let Some(current) = stack.pop() {
287        if count >= MAX_ANCESTORS {
288            break;
289        }
290        if !set.insert(current) {
291            continue;
292        }
293        count += 1;
294        let Ok(obj) = store.read_object(&current) else {
295            continue;
296        };
297        if let Object::Commit(commit) = obj {
298            for p in commit.parents {
299                stack.push(p);
300            }
301        }
302    }
303}
304
305fn read_hex_hash(path: &Path) -> RebaseResult<Hash> {
306    let raw =
307        read_text_capped(path, MAX_HASH_FILE_BYTES).map_err(|_| RebaseError::InvalidRebaseState)?;
308    let trimmed = trim_trailing(&raw);
309    hash::from_hex(trimmed).map_err(|_| RebaseError::InvalidRebaseState)
310}
311
312fn read_hash_list(path: &Path) -> RebaseResult<Vec<Hash>> {
313    let raw = match read_text_capped(path, MAX_HASH_LIST_BYTES) {
314        Ok(s) => s,
315        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
316        Err(e) => return Err(RebaseError::Io(e)),
317    };
318    let trimmed = trim_trailing(&raw);
319    if trimmed.is_empty() {
320        return Ok(Vec::new());
321    }
322    let mut out = Vec::new();
323    for line in trimmed.split('\n') {
324        let line = line.trim_end_matches(['\r', ' ']);
325        if line.is_empty() {
326            continue;
327        }
328        if line.len() != HEX_LEN {
329            return Err(RebaseError::InvalidRebaseState);
330        }
331        let h = hash::from_hex(line).map_err(|_| RebaseError::InvalidRebaseState)?;
332        out.push(h);
333    }
334    Ok(out)
335}
336
337/// Read the parallel-to-todo action list. A missing file yields all
338/// `Pick` (back-compat with pre-interactive state). The result is always
339/// realigned to `todo_len`: extra entries are dropped, missing ones
340/// default to `Pick`, so a malformed/short file can never desync the two
341/// lists.
342fn read_actions(path: &Path, todo_len: usize) -> RebaseResult<Vec<RebaseAction>> {
343    let raw = match read_text_capped(path, MAX_HASH_LIST_BYTES) {
344        Ok(s) => s,
345        Err(e) if e.kind() == io::ErrorKind::NotFound => {
346            return Ok(vec![RebaseAction::Pick; todo_len]);
347        }
348        Err(e) => return Err(RebaseError::Io(e)),
349    };
350    let mut out = Vec::with_capacity(todo_len);
351    for line in trim_trailing(&raw).split('\n') {
352        let line = line.trim();
353        if line.is_empty() {
354            continue;
355        }
356        let action = RebaseAction::from_keyword(line).ok_or(RebaseError::InvalidRebaseState)?;
357        out.push(action);
358    }
359    out.resize(todo_len, RebaseAction::Pick);
360    Ok(out)
361}
362
363fn write_actions(path: &Path, actions: &[RebaseAction]) -> RebaseResult<()> {
364    if actions.is_empty() {
365        write_with_newline(path, b"")?;
366        return Ok(());
367    }
368    let mut buf = String::with_capacity(actions.len() * 8);
369    for a in actions {
370        buf.push_str(a.keyword());
371        buf.push('\n');
372    }
373    fs::write(path, buf.as_bytes())?;
374    Ok(())
375}
376
377fn write_hash_list(path: &Path, hashes: &[Hash]) -> RebaseResult<()> {
378    if hashes.is_empty() {
379        write_with_newline(path, b"")?;
380        return Ok(());
381    }
382    let mut buf = String::with_capacity(hashes.len() * (HEX_LEN + 1));
383    for h in hashes {
384        buf.push_str(&hash::to_hex(h));
385        buf.push('\n');
386    }
387    fs::write(path, buf.as_bytes())?;
388    Ok(())
389}
390
391fn write_with_newline(path: &Path, content: &[u8]) -> io::Result<()> {
392    let mut buf: Vec<u8> = Vec::with_capacity(content.len() + 1);
393    buf.extend_from_slice(content);
394    if buf.last().copied() != Some(b'\n') {
395        buf.push(b'\n');
396    }
397    fs::write(path, buf)
398}
399
400fn read_text_capped(path: &Path, cap: u64) -> io::Result<String> {
401    let meta = fs::metadata(path)?;
402    if meta.len() > cap {
403        return Err(io::Error::new(io::ErrorKind::InvalidData, "file too large"));
404    }
405    let raw = fs::read(path)?;
406    String::from_utf8(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8"))
407}
408
409fn trim_trailing(s: &str) -> &str {
410    s.trim_end_matches(['\n', '\r', ' '])
411}
412
413/// Convenience: full path to the rebase-apply directory under `mkit_dir`.
414#[must_use]
415pub fn rebase_dir_path(layout: &RepoLayout) -> PathBuf {
416    layout.rebase_dir()
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::object::{Commit, Identity, Tree, TreeEntry};
423    use crate::serialize;
424    use tempfile::TempDir;
425
426    fn fresh_store() -> (TempDir, ObjectStore) {
427        let dir = TempDir::new().unwrap();
428        let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
429        (dir, store)
430    }
431
432    fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
433        let bytes = serialize::serialize(&Object::Blob(crate::object::Blob {
434            data: data.to_vec(),
435        }))
436        .unwrap();
437        store.write(&bytes).unwrap()
438    }
439
440    fn put_tree(store: &ObjectStore, name: &str, blob_h: Hash) -> Hash {
441        let tree = Object::Tree(Tree {
442            entries: vec![TreeEntry {
443                name: name.as_bytes().to_vec(),
444                mode: crate::object::EntryMode::Blob,
445                object_hash: blob_h,
446            }],
447        });
448        let bytes = serialize::serialize(&tree).unwrap();
449        store.write(&bytes).unwrap()
450    }
451
452    fn put_commit(store: &ObjectStore, tree_h: Hash, parents: Vec<Hash>, ts: u64) -> Hash {
453        let commit = Object::Commit(Commit::new_unannotated(
454            tree_h,
455            parents,
456            Identity::ed25519([0u8; 32]),
457            [0u8; 32],
458            b"msg".to_vec(),
459            ts,
460            [0u8; 64],
461        ));
462        let bytes = serialize::serialize(&commit).unwrap();
463        store.write(&bytes).unwrap()
464    }
465
466    #[test]
467    fn state_roundtrip_writes_recoverable_files() {
468        let tmp = TempDir::new().unwrap();
469        let mkit = RepoLayout::single(tmp.path());
470        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
471
472        let state = RebaseState {
473            head_name: "feature-branch".to_string(),
474            orig_head: hash::hash(b"orig-head"),
475            onto: hash::hash(b"onto"),
476            todo: vec![hash::hash(b"t1"), hash::hash(b"t2")],
477            actions: vec![RebaseAction::Pick, RebaseAction::Reword],
478            done: vec![hash::hash(b"d1")],
479        };
480        write_state(&mkit, &state).unwrap();
481
482        // Files should exist on disk — this models the "mid-rebase crash
483        // leaves recoverable state" invariant.
484        let dir = mkit.rebase_dir();
485        assert!(dir.join("head-name").is_file());
486        assert!(dir.join("orig-head").is_file());
487        assert!(dir.join("onto").is_file());
488        assert!(dir.join("todo").is_file());
489        assert!(dir.join("actions").is_file());
490        assert!(dir.join("done").is_file());
491
492        let read = read_state(&mkit).unwrap();
493        assert_eq!(read, state);
494    }
495
496    #[test]
497    fn missing_actions_file_defaults_to_all_pick() {
498        let tmp = TempDir::new().unwrap();
499        let mkit = RepoLayout::single(tmp.path());
500        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
501        let state = RebaseState {
502            head_name: "main".to_string(),
503            orig_head: hash::hash(b"head"),
504            onto: hash::hash(b"onto"),
505            todo: vec![hash::hash(b"t1"), hash::hash(b"t2")],
506            actions: vec![RebaseAction::Pick, RebaseAction::Pick],
507            done: Vec::new(),
508        };
509        write_state(&mkit, &state).unwrap();
510        // Simulate a rebase started before interactive support: delete the
511        // actions sidecar. Reading must still yield one Pick per todo commit.
512        fs::remove_file(mkit.rebase_dir().join("actions")).unwrap();
513        let read = read_state(&mkit).unwrap();
514        assert_eq!(read.actions, vec![RebaseAction::Pick, RebaseAction::Pick]);
515    }
516
517    #[test]
518    fn action_keyword_roundtrip_and_folds() {
519        for a in [
520            RebaseAction::Pick,
521            RebaseAction::Reword,
522            RebaseAction::Squash,
523            RebaseAction::Fixup,
524        ] {
525            assert_eq!(RebaseAction::from_keyword(a.keyword()), Some(a));
526        }
527        assert!(RebaseAction::Squash.folds_into_previous());
528        assert!(RebaseAction::Fixup.folds_into_previous());
529        assert!(!RebaseAction::Pick.folds_into_previous());
530        assert!(!RebaseAction::Reword.folds_into_previous());
531        assert_eq!(RebaseAction::from_keyword("nope"), None);
532    }
533
534    #[test]
535    fn consume_front_keeps_todo_and_actions_aligned() {
536        let mut state = RebaseState {
537            head_name: "main".to_string(),
538            orig_head: hash::hash(b"head"),
539            onto: hash::hash(b"onto"),
540            todo: vec![hash::hash(b"t1"), hash::hash(b"t2")],
541            actions: vec![RebaseAction::Reword, RebaseAction::Pick],
542            done: Vec::new(),
543        };
544        assert_eq!(state.front_action(), RebaseAction::Reword);
545        state.consume_front();
546        assert_eq!(state.todo, vec![hash::hash(b"t2")]);
547        assert_eq!(state.actions, vec![RebaseAction::Pick]);
548        assert_eq!(state.front_action(), RebaseAction::Pick);
549    }
550
551    #[test]
552    fn state_roundtrip_with_empty_todo_and_done() {
553        let tmp = TempDir::new().unwrap();
554        let mkit = RepoLayout::single(tmp.path());
555        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
556
557        let state = RebaseState {
558            head_name: "main".to_string(),
559            orig_head: hash::hash(b"head"),
560            onto: hash::hash(b"onto"),
561            todo: Vec::new(),
562            actions: Vec::new(),
563            done: Vec::new(),
564        };
565        write_state(&mkit, &state).unwrap();
566        let read = read_state(&mkit).unwrap();
567        assert_eq!(read, state);
568    }
569
570    #[test]
571    fn is_rebase_in_progress_detection() {
572        let tmp = TempDir::new().unwrap();
573        let mkit = RepoLayout::single(tmp.path());
574        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
575        assert!(!is_rebase_in_progress(&mkit));
576        fs::create_dir_all(mkit.rebase_dir()).unwrap();
577        assert!(is_rebase_in_progress(&mkit));
578    }
579
580    #[test]
581    fn cleanup_removes_state_dir() {
582        let tmp = TempDir::new().unwrap();
583        let mkit = RepoLayout::single(tmp.path());
584        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
585        fs::create_dir_all(mkit.rebase_dir()).unwrap();
586        fs::write(mkit.rebase_dir().join("head-name"), b"main\n").unwrap();
587        assert!(is_rebase_in_progress(&mkit));
588        cleanup_rebase(&mkit).unwrap();
589        assert!(!is_rebase_in_progress(&mkit));
590    }
591
592    #[test]
593    fn cleanup_on_missing_dir_is_noop() {
594        let tmp = TempDir::new().unwrap();
595        let mkit = RepoLayout::single(tmp.path());
596        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
597        cleanup_rebase(&mkit).unwrap();
598    }
599
600    #[test]
601    fn read_state_when_no_rebase_returns_error() {
602        let tmp = TempDir::new().unwrap();
603        let mkit = RepoLayout::single(tmp.path());
604        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
605        let err = read_state(&mkit).unwrap_err();
606        assert!(matches!(err, RebaseError::NoRebaseInProgress));
607    }
608
609    #[test]
610    fn collect_commits_on_linear_chain() {
611        let (_d, store) = fresh_store();
612        let blob = put_blob(&store, b"data");
613        let tree = put_tree(&store, "f.txt", blob);
614        let c1 = put_commit(&store, tree, vec![], 1);
615        let c2 = put_commit(&store, tree, vec![c1], 2);
616        let c3 = put_commit(&store, tree, vec![c2], 3);
617        let c4 = put_commit(&store, tree, vec![c3], 4);
618
619        let res = collect_commits_to_replay(&store, c4, c2).unwrap();
620        assert_eq!(res, vec![c3, c4]);
621    }
622
623    #[test]
624    fn collect_commits_same_commit_returns_empty() {
625        let (_d, store) = fresh_store();
626        let blob = put_blob(&store, b"data");
627        let tree = put_tree(&store, "f.txt", blob);
628        let c1 = put_commit(&store, tree, vec![], 1);
629        let res = collect_commits_to_replay(&store, c1, c1).unwrap();
630        assert!(res.is_empty());
631    }
632
633    #[test]
634    fn collect_commits_y_shape_stops_at_ancestor_of_onto() {
635        let (_d, store) = fresh_store();
636        let blob = put_blob(&store, b"data");
637        let tree = put_tree(&store, "f.txt", blob);
638        let c1 = put_commit(&store, tree, vec![], 1);
639        let c2 = put_commit(&store, tree, vec![c1], 2);
640        let c3 = put_commit(&store, tree, vec![c2], 3);
641        let c4 = put_commit(&store, tree, vec![c1], 4);
642        let c5 = put_commit(&store, tree, vec![c4], 5);
643
644        // Replay feature (c5) onto main (c3): should yield [c4, c5].
645        let res = collect_commits_to_replay(&store, c5, c3).unwrap();
646        assert_eq!(res, vec![c4, c5]);
647    }
648}