Skip to main content

mkit_core/ops/
bisect.rs

1//! Bisect.
2//!
3//! Stores state in `.mkit/bisect` (a single file, NOT a directory). The
4//! on-disk format is a plain-text manifest:
5//!
6//! ```text
7//! <orig_head_hex>
8//! <orig_branch_or_empty>
9//! <bad_hash_or_empty>
10//! <good_hash_1>
11//! <good_hash_2>
12//! ...
13//! skip:<skipped_hash_1>
14//! skip:<skipped_hash_2>
15//! ...
16//! ```
17//!
18//! The `skip:` prefix lines are additive — old state files without them
19//! deserialize with an empty `skipped` set. [`pick_midpoint_skip`] skips
20//! over any candidate whose hash is in `skipped`, searching neighbors.
21//!
22//! Trailing whitespace on any line is tolerated on read.
23//!
24//! [`enumerate_range`] and [`pick_midpoint`] form the search core; the
25//! caller drives the loop, calling `pick_midpoint` on the candidates
26//! returned by `enumerate_range` and updating `bad`/`good` based on the
27//! tester's verdict. [`BisectStep`] is a convenience response type.
28
29use std::collections::{BTreeSet, HashSet};
30use std::fs;
31use std::io;
32use std::path::PathBuf;
33
34use crate::hash::{self, HEX_LEN, Hash, ZERO};
35use crate::layout::RepoLayout;
36use crate::object::Object;
37use crate::store::ObjectStore;
38
39/// Single-file state path under `<mkit_dir>`.
40pub const BISECT_FILE: &str = "bisect";
41
42const MAX_BISECT_FILE_BYTES: u64 = 1024 * 1024;
43
44/// Maximum commits visited by the inlined ancestor walk.
45const MAX_ANCESTORS: usize = 10_000;
46
47/// Errors raised by this module.
48#[derive(Debug, thiserror::Error)]
49pub enum BisectError {
50    #[error("no bisect in progress")]
51    NoBisectInProgress,
52    #[error("bisect state on disk is malformed")]
53    InvalidBisectState,
54    #[error(transparent)]
55    Io(#[from] io::Error),
56    #[error(transparent)]
57    Object(#[from] crate::object::MkitError),
58    #[error(transparent)]
59    Store(#[from] crate::store::StoreError),
60}
61
62/// Result alias.
63pub type BisectResult<T> = Result<T, BisectError>;
64
65/// Persisted bisect state.
66///
67/// Commits in `skipped` are excluded from midpoint selection. Old state
68/// files without `skip:` lines deserialize with an empty `skipped` set.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct BisectState {
71    /// HEAD as it was when bisect started.
72    pub orig_head: Hash,
73    /// Branch HEAD pointed at when bisect started, or `None` for detached.
74    pub orig_branch: Option<String>,
75    /// The known-bad commit, or `None` if not yet provided.
76    pub bad_hash: Option<Hash>,
77    /// All known-good commits.
78    pub good_hashes: Vec<Hash>,
79    /// Commits explicitly skipped by `mkit bisect skip`. Excluded from
80    /// midpoint selection; neighbour search falls back to the nearest
81    /// non-skipped candidate.
82    pub skipped: BTreeSet<Hash>,
83}
84
85/// Outcome of a single bisect iteration.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum BisectStep {
88    /// We are now testing this commit. `remaining` is the candidate
89    /// count BEFORE this iteration's pick.
90    Testing { hash: Hash, remaining: usize },
91    /// First-bad commit identified.
92    Found(Hash),
93    /// Only skipped commits remain between good and bad, so the first-bad
94    /// commit is ambiguous — it could be `bad` or any commit in `skipped`
95    /// (git's "The first bad commit could be any of …"). Distinguished from
96    /// [`Found`](BisectStep::Found) so callers don't report a skipped-over
97    /// guess as definitive.
98    Ambiguous { bad: Hash, skipped: Vec<Hash> },
99    /// We need both at least one good and a bad before we can compute a midpoint.
100    NeedMore,
101}
102
103/// Returns `true` when `<mkit_dir>/bisect` exists.
104#[must_use]
105pub fn is_bisect_in_progress(layout: &RepoLayout) -> bool {
106    layout.bisect_file().is_file()
107}
108
109/// Read bisect state from `<mkit_dir>/bisect`.
110///
111/// # Errors
112/// - [`BisectError::NoBisectInProgress`] if the file does not exist.
113/// - [`BisectError::InvalidBisectState`] for any malformed line.
114pub fn read_state(layout: &RepoLayout) -> BisectResult<BisectState> {
115    let path = layout.bisect_file();
116    let meta = match fs::metadata(&path) {
117        Ok(m) => m,
118        Err(e) if e.kind() == io::ErrorKind::NotFound => {
119            return Err(BisectError::NoBisectInProgress);
120        }
121        Err(e) => return Err(BisectError::Io(e)),
122    };
123    if meta.len() == 0 {
124        return Err(BisectError::InvalidBisectState);
125    }
126    if meta.len() > MAX_BISECT_FILE_BYTES {
127        return Err(BisectError::InvalidBisectState);
128    }
129    let raw = fs::read_to_string(&path)?;
130
131    let mut lines = raw.split('\n');
132
133    let orig_head_line = lines.next().ok_or(BisectError::InvalidBisectState)?;
134    let orig_head_trim = orig_head_line.trim_end_matches(['\r', ' ']);
135    if orig_head_trim.is_empty() {
136        return Err(BisectError::InvalidBisectState);
137    }
138    let orig_head = hash::from_hex(orig_head_trim).map_err(|_| BisectError::InvalidBisectState)?;
139
140    let branch_line = lines.next().ok_or(BisectError::InvalidBisectState)?;
141    let branch_trim = branch_line.trim_end_matches(['\r', ' ']);
142    let orig_branch = if branch_trim.is_empty() {
143        None
144    } else {
145        Some(branch_trim.to_string())
146    };
147
148    let bad_line = lines.next().ok_or(BisectError::InvalidBisectState)?;
149    let bad_trim = bad_line.trim_end_matches(['\r', ' ']);
150    let bad_hash = if bad_trim.is_empty() {
151        None
152    } else {
153        Some(hash::from_hex(bad_trim).map_err(|_| BisectError::InvalidBisectState)?)
154    };
155
156    let mut good_hashes = Vec::new();
157    let mut skipped = BTreeSet::new();
158    for line in lines {
159        let line = line.trim_end_matches(['\r', ' ']);
160        if line.is_empty() {
161            continue;
162        }
163        if let Some(rest) = line.strip_prefix("skip:") {
164            // Skipped hash entry (additive extension; old parsers do not
165            // write these, so old state files get an empty skipped set).
166            if rest.len() != HEX_LEN {
167                return Err(BisectError::InvalidBisectState);
168            }
169            let h = hash::from_hex(rest).map_err(|_| BisectError::InvalidBisectState)?;
170            skipped.insert(h);
171        } else {
172            if line.len() != HEX_LEN {
173                return Err(BisectError::InvalidBisectState);
174            }
175            let h = hash::from_hex(line).map_err(|_| BisectError::InvalidBisectState)?;
176            good_hashes.push(h);
177        }
178    }
179
180    Ok(BisectState {
181        orig_head,
182        orig_branch,
183        bad_hash,
184        good_hashes,
185        skipped,
186    })
187}
188
189/// Persist `state` to `<mkit_dir>/bisect`.
190///
191/// # Errors
192/// - [`BisectError::Io`] for filesystem failures.
193pub fn write_state(layout: &RepoLayout, state: &BisectState) -> BisectResult<()> {
194    let mut buf = String::new();
195    buf.push_str(&hash::to_hex(&state.orig_head));
196    buf.push('\n');
197    if let Some(b) = &state.orig_branch {
198        buf.push_str(b);
199    }
200    buf.push('\n');
201    if let Some(bad) = &state.bad_hash {
202        buf.push_str(&hash::to_hex(bad));
203    }
204    buf.push('\n');
205    for g in &state.good_hashes {
206        buf.push_str(&hash::to_hex(g));
207        buf.push('\n');
208    }
209    for s in &state.skipped {
210        buf.push_str("skip:");
211        buf.push_str(&hash::to_hex(s));
212        buf.push('\n');
213    }
214    // Atomic + durable write (write-to-temp, fsync, rename, fsync parent
215    // dir) so a crash mid-write cannot leave a truncated bisect state file
216    // that `read_state` would reject as InvalidBisectState. Matches the
217    // rest of the ops state writers (stash/recovery/restore).
218    crate::atomic::write_atomic(&layout.bisect_file(), buf.as_bytes(), true)?;
219    Ok(())
220}
221
222/// Remove `<mkit_dir>/bisect`. Idempotent.
223///
224/// # Errors
225/// - [`BisectError::Io`] for filesystem failures other than "not found".
226pub fn cleanup_bisect(layout: &RepoLayout) -> BisectResult<()> {
227    let path = layout.bisect_file();
228    match fs::remove_file(&path) {
229        Ok(()) => Ok(()),
230        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
231        Err(e) => Err(BisectError::Io(e)),
232    }
233}
234
235/// Convenience: full path to the bisect state file under `mkit_dir`.
236#[must_use]
237pub fn bisect_file_path(layout: &RepoLayout) -> PathBuf {
238    layout.bisect_file()
239}
240
241/// Walk ancestors of `bad`, stopping at any commit that is `good` or an
242/// ancestor of `good`. Returns the candidate set in BFS order from `bad`.
243///
244/// # Errors
245/// - [`BisectError::Store`] / [`BisectError::Object`] for store failures.
246pub fn enumerate_range(
247    store: &ObjectStore,
248    bad: Hash,
249    good_hashes: &[Hash],
250) -> BisectResult<Vec<Hash>> {
251    let mut good_set: HashSet<Hash> = HashSet::new();
252    for g in good_hashes {
253        collect_ancestor_set(store, *g, &mut good_set);
254    }
255
256    let mut visited: HashSet<Hash> = HashSet::new();
257    let mut candidates: Vec<Hash> = Vec::new();
258    let mut queue: Vec<Hash> = vec![bad];
259    let mut head = 0usize;
260    while head < queue.len() {
261        let current = queue[head];
262        head += 1;
263        if !visited.insert(current) {
264            continue;
265        }
266        if good_set.contains(&current) {
267            continue;
268        }
269        candidates.push(current);
270        let Ok(obj) = store.read_object(&current) else {
271            continue;
272        };
273        if let Object::Commit(commit) = obj {
274            for p in commit.parents {
275                queue.push(p);
276            }
277        }
278    }
279    Ok(candidates)
280}
281
282/// Pick the midpoint commit. Returns `Hash::ZERO` when `candidates` is
283/// empty.
284#[must_use]
285pub fn pick_midpoint(candidates: &[Hash]) -> Hash {
286    if candidates.is_empty() {
287        return ZERO;
288    }
289    candidates[candidates.len() / 2]
290}
291
292/// Pick the midpoint commit, skipping over any commit in `skipped`.
293///
294/// Starts at `candidates[len/2]` (the natural midpoint). If that commit
295/// is in `skipped`, searches outward — alternating lower and upper
296/// neighbors — until a non-skipped candidate is found. Returns `ZERO`
297/// only when all candidates are skipped (or the list is empty).
298///
299/// This implements a skip-neighbor scan.
300#[must_use]
301pub fn pick_midpoint_skip(candidates: &[Hash], skipped: &BTreeSet<Hash>) -> Hash {
302    if candidates.is_empty() {
303        return ZERO;
304    }
305    let mid = candidates.len() / 2;
306    // Walk outward from mid: try mid, mid-1, mid+1, mid-2, mid+2, …
307    for delta in 0..=candidates.len() {
308        // Positive direction: mid + delta
309        if let Some(idx) = mid.checked_add(delta).filter(|&i| i < candidates.len()) {
310            let h = candidates[idx];
311            if !skipped.contains(&h) {
312                return h;
313            }
314        }
315        // Negative direction: mid - delta (skip delta==0 to avoid double-check)
316        if let (true, Some(idx)) = (delta > 0, mid.checked_sub(delta)) {
317            let h = candidates[idx];
318            if !skipped.contains(&h) {
319                return h;
320            }
321        }
322    }
323    // All candidates are skipped.
324    ZERO
325}
326
327/// Drive a single bisect iteration: enumerate the range, then pick the
328/// midpoint or report completion.
329///
330/// - If both `bad_hash` and at least one `good_hash` are present:
331///     - Empty candidate set ⇒ [`BisectStep::Found`] (the only suspect
332///       is `bad_hash` itself, but the candidate set excludes good
333///       ancestors; if it's empty the bug is at `bad`).
334///     - One candidate ⇒ [`BisectStep::Found`].
335///     - Otherwise pick the midpoint and return [`BisectStep::Testing`].
336/// - Otherwise return [`BisectStep::NeedMore`].
337///
338/// # Errors
339/// - [`BisectError::Store`] / [`BisectError::Object`] for store failures.
340pub fn next_step(store: &ObjectStore, state: &BisectState) -> BisectResult<BisectStep> {
341    let Some(bad) = state.bad_hash else {
342        return Ok(BisectStep::NeedMore);
343    };
344    if state.good_hashes.is_empty() {
345        return Ok(BisectStep::NeedMore);
346    }
347    let candidates = enumerate_range(store, bad, &state.good_hashes)?;
348    if candidates.is_empty() {
349        return Ok(BisectStep::Found(bad));
350    }
351    // Filter out skipped commits for the Found-check, but keep them in
352    // the candidate list for neighbor search.
353    let non_skipped: Vec<Hash> = candidates
354        .iter()
355        .copied()
356        .filter(|h| !state.skipped.contains(h))
357        .collect();
358    // Skipped commits that are still in the live range — the candidates
359    // that make the answer ambiguous if nothing testable is left to
360    // disambiguate them.
361    let skipped_in_range: Vec<Hash> = candidates
362        .iter()
363        .copied()
364        .filter(|h| state.skipped.contains(h))
365        .collect();
366    if non_skipped.len() == 1 {
367        let only = non_skipped[0];
368        // The lone survivor is normally the first-bad commit. But when it is
369        // the `bad` boundary itself and in-range candidates were skipped, the
370        // first bad commit is ambiguous — it could be `bad` or any skipped
371        // one (git's "only skipped commits left") — so don't report a guess
372        // as definitive.
373        if only == bad && !skipped_in_range.is_empty() {
374            return Ok(BisectStep::Ambiguous {
375                bad,
376                skipped: skipped_in_range,
377            });
378        }
379        return Ok(BisectStep::Found(only));
380    }
381    if non_skipped.is_empty() {
382        // Defensive: every candidate (bad not among them) is skipped.
383        return Ok(BisectStep::Ambiguous {
384            bad,
385            skipped: skipped_in_range,
386        });
387    }
388    let mid = pick_midpoint_skip(&candidates, &state.skipped);
389    Ok(BisectStep::Testing {
390        hash: mid,
391        remaining: candidates.len(),
392    })
393}
394
395// -- Internal helpers --------------------------------------------------------
396
397fn collect_ancestor_set(store: &ObjectStore, start: Hash, set: &mut HashSet<Hash>) {
398    let mut stack: Vec<Hash> = vec![start];
399    let mut count = 0usize;
400    while let Some(current) = stack.pop() {
401        if count >= MAX_ANCESTORS {
402            break;
403        }
404        if !set.insert(current) {
405            continue;
406        }
407        count += 1;
408        let Ok(obj) = store.read_object(&current) else {
409            continue;
410        };
411        if let Object::Commit(commit) = obj {
412            for p in commit.parents {
413                stack.push(p);
414            }
415        }
416    }
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        store.write(&serialize::serialize(&tree).unwrap()).unwrap()
449    }
450
451    fn put_commit(store: &ObjectStore, tree_h: Hash, parents: Vec<Hash>, ts: u64) -> Hash {
452        let commit = Object::Commit(Commit::new_unannotated(
453            tree_h,
454            parents,
455            Identity::ed25519([0u8; 32]),
456            [0u8; 32],
457            b"msg".to_vec(),
458            ts,
459            [0u8; 64],
460        ));
461        store
462            .write(&serialize::serialize(&commit).unwrap())
463            .unwrap()
464    }
465
466    #[test]
467    fn state_roundtrip_with_branch_and_bad() {
468        let tmp = TempDir::new().unwrap();
469        let mkit = RepoLayout::single(tmp.path());
470        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
471        let state = BisectState {
472            orig_head: hash::hash(b"orig-head"),
473            orig_branch: Some("main".to_string()),
474            bad_hash: Some(hash::hash(b"bad")),
475            good_hashes: vec![hash::hash(b"g1"), hash::hash(b"g2")],
476            skipped: BTreeSet::new(),
477        };
478        write_state(&mkit, &state).unwrap();
479        let read = read_state(&mkit).unwrap();
480        assert_eq!(read, state);
481    }
482
483    #[test]
484    fn state_roundtrip_with_no_branch_no_bad() {
485        let tmp = TempDir::new().unwrap();
486        let mkit = RepoLayout::single(tmp.path());
487        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
488        let state = BisectState {
489            orig_head: hash::hash(b"head"),
490            orig_branch: None,
491            bad_hash: None,
492            good_hashes: Vec::new(),
493            skipped: BTreeSet::new(),
494        };
495        write_state(&mkit, &state).unwrap();
496        let read = read_state(&mkit).unwrap();
497        assert_eq!(read, state);
498    }
499
500    #[test]
501    fn state_roundtrip_with_skipped_hashes() {
502        let tmp = TempDir::new().unwrap();
503        let mkit = RepoLayout::single(tmp.path());
504        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
505        let s1 = hash::hash(b"skip1");
506        let s2 = hash::hash(b"skip2");
507        let mut skipped = BTreeSet::new();
508        skipped.insert(s1);
509        skipped.insert(s2);
510        let state = BisectState {
511            orig_head: hash::hash(b"head"),
512            orig_branch: Some("main".to_string()),
513            bad_hash: Some(hash::hash(b"bad")),
514            good_hashes: vec![hash::hash(b"good")],
515            skipped,
516        };
517        write_state(&mkit, &state).unwrap();
518        let read = read_state(&mkit).unwrap();
519        assert_eq!(read, state);
520        assert_eq!(read.skipped.len(), 2);
521        assert!(read.skipped.contains(&s1));
522        assert!(read.skipped.contains(&s2));
523    }
524
525    #[test]
526    fn write_state_creates_missing_dir_and_overwrites_atomically() {
527        // Regression: write_state now uses the durable atomic helper
528        // (write-to-temp + rename) instead of a bare fs::write. Verify it
529        // (a) creates the parent dir when absent, (b) cleanly overwrites an
530        // existing state file, and (c) leaves no `.bisect.tmp.*` sibling
531        // behind after the rename.
532        let tmp = TempDir::new().unwrap();
533        let mkit = RepoLayout::single(tmp.path());
534        // Note: directory intentionally NOT pre-created.
535        let first = BisectState {
536            orig_head: hash::hash(b"head"),
537            orig_branch: Some("main".to_string()),
538            bad_hash: Some(hash::hash(b"bad")),
539            good_hashes: vec![hash::hash(b"g1")],
540            skipped: BTreeSet::new(),
541        };
542        write_state(&mkit, &first).unwrap();
543        assert_eq!(read_state(&mkit).unwrap(), first);
544
545        let second = BisectState {
546            orig_head: hash::hash(b"head2"),
547            orig_branch: None,
548            bad_hash: None,
549            good_hashes: Vec::new(),
550            skipped: BTreeSet::new(),
551        };
552        write_state(&mkit, &second).unwrap();
553        assert_eq!(read_state(&mkit).unwrap(), second);
554
555        // No temp artefact left in the directory: only the bisect file.
556        let stray: Vec<_> = fs::read_dir(mkit.worktree_state_dir())
557            .unwrap()
558            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
559            .filter(|n| n != BISECT_FILE)
560            .collect();
561        assert!(stray.is_empty(), "stray temp files left behind: {stray:?}");
562    }
563
564    #[test]
565    fn old_state_file_without_skip_deserializes_with_empty_skipped() {
566        // Simulate a state file written by an old parser (no `skip:` lines).
567        let tmp = TempDir::new().unwrap();
568        let mkit = RepoLayout::single(tmp.path());
569        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
570        let orig_head = hash::hash(b"orig");
571        let bad = hash::hash(b"bad");
572        let good = hash::hash(b"good");
573        // Write the old format manually (no skip: lines).
574        let content = format!(
575            "{}\n\n{}\n{}\n",
576            hash::to_hex(&orig_head),
577            hash::to_hex(&bad),
578            hash::to_hex(&good)
579        );
580        fs::write(mkit.bisect_file(), content.as_bytes()).unwrap();
581        let read = read_state(&mkit).unwrap();
582        assert_eq!(read.orig_head, orig_head);
583        assert_eq!(read.bad_hash, Some(bad));
584        assert_eq!(read.good_hashes, vec![good]);
585        assert!(
586            read.skipped.is_empty(),
587            "old files must deserialize with empty skipped"
588        );
589    }
590
591    #[test]
592    fn read_state_when_no_bisect_returns_error() {
593        let tmp = TempDir::new().unwrap();
594        let mkit = RepoLayout::single(tmp.path());
595        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
596        let err = read_state(&mkit).unwrap_err();
597        assert!(matches!(err, BisectError::NoBisectInProgress));
598    }
599
600    #[test]
601    fn read_state_rejects_empty_file() {
602        let tmp = TempDir::new().unwrap();
603        let mkit = RepoLayout::single(tmp.path());
604        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
605        fs::write(mkit.bisect_file(), b"").unwrap();
606        let err = read_state(&mkit).unwrap_err();
607        assert!(matches!(err, BisectError::InvalidBisectState));
608    }
609
610    #[test]
611    fn is_bisect_in_progress_detection() {
612        let tmp = TempDir::new().unwrap();
613        let mkit = RepoLayout::single(tmp.path());
614        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
615        assert!(!is_bisect_in_progress(&mkit));
616        fs::write(mkit.bisect_file(), b"placeholder").unwrap();
617        assert!(is_bisect_in_progress(&mkit));
618    }
619
620    #[test]
621    fn cleanup_removes_state_file() {
622        let tmp = TempDir::new().unwrap();
623        let mkit = RepoLayout::single(tmp.path());
624        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
625        fs::write(mkit.bisect_file(), b"x\n").unwrap();
626        cleanup_bisect(&mkit).unwrap();
627        assert!(!is_bisect_in_progress(&mkit));
628    }
629
630    #[test]
631    fn cleanup_on_missing_file_is_noop() {
632        let tmp = TempDir::new().unwrap();
633        let mkit = RepoLayout::single(tmp.path());
634        fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
635        cleanup_bisect(&mkit).unwrap();
636    }
637
638    #[test]
639    fn pick_midpoint_returns_middle_element() {
640        let c1 = hash::hash(b"c1");
641        let c2 = hash::hash(b"c2");
642        let c3 = hash::hash(b"c3");
643        let c4 = hash::hash(b"c4");
644        let c5 = hash::hash(b"c5");
645        // len=5, 5/2=2 → c3
646        assert_eq!(pick_midpoint(&[c1, c2, c3, c4, c5]), c3);
647        // len=4, 4/2=2 → c3
648        assert_eq!(pick_midpoint(&[c1, c2, c3, c4]), c3);
649        assert_eq!(pick_midpoint(&[c1]), c1);
650    }
651
652    #[test]
653    fn pick_midpoint_empty_returns_zero() {
654        assert_eq!(pick_midpoint(&[]), ZERO);
655    }
656
657    #[test]
658    fn enumerate_range_on_linear_chain() {
659        let (_d, store) = fresh_store();
660        let blob = put_blob(&store, b"data");
661        let tree = put_tree(&store, "f.txt", blob);
662        let mut commits = Vec::new();
663        commits.push(put_commit(&store, tree, vec![], 1));
664        for i in 1..8 {
665            let parent = commits[i - 1];
666            commits.push(put_commit(&store, tree, vec![parent], (i + 1) as u64));
667        }
668        // bad=c8 (index 7), good=[c2 (index 1)]
669        // Candidates should be c3..c8 (6 commits), starting BFS from c8.
670        let cands = enumerate_range(&store, commits[7], &[commits[1]]).unwrap();
671        assert_eq!(cands.len(), 6);
672        assert_eq!(cands[0], commits[7]);
673    }
674
675    #[test]
676    fn enumerate_range_with_diamond_merge() {
677        let (_d, store) = fresh_store();
678        let blob = put_blob(&store, b"d");
679        let tree = put_tree(&store, "f.txt", blob);
680        let c1 = put_commit(&store, tree, vec![], 1);
681        let c2 = put_commit(&store, tree, vec![c1], 2);
682        let c3 = put_commit(&store, tree, vec![c1], 3);
683        let c4 = put_commit(&store, tree, vec![c2, c3], 4);
684        let cands = enumerate_range(&store, c4, &[c1]).unwrap();
685        assert_eq!(cands.len(), 3);
686        assert_eq!(cands[0], c4);
687    }
688
689    #[test]
690    fn enumerate_range_same_good_and_bad_yields_empty() {
691        let (_d, store) = fresh_store();
692        let blob = put_blob(&store, b"d");
693        let tree = put_tree(&store, "f.txt", blob);
694        let c1 = put_commit(&store, tree, vec![], 1);
695        let cands = enumerate_range(&store, c1, &[c1]).unwrap();
696        assert!(cands.is_empty());
697    }
698
699    #[test]
700    fn next_step_need_more_when_no_bad() {
701        let (_d, store) = fresh_store();
702        let state = BisectState {
703            orig_head: ZERO,
704            orig_branch: None,
705            bad_hash: None,
706            good_hashes: vec![hash::hash(b"x")],
707            skipped: BTreeSet::new(),
708        };
709        assert_eq!(next_step(&store, &state).unwrap(), BisectStep::NeedMore);
710    }
711
712    #[test]
713    fn next_step_drives_bisect_to_completion() {
714        // good < midpoint < bad → midpoint selection.
715        let (_d, store) = fresh_store();
716        let blob = put_blob(&store, b"data");
717        let tree = put_tree(&store, "f.txt", blob);
718        let mut commits = Vec::new();
719        commits.push(put_commit(&store, tree, vec![], 1));
720        for i in 1..6 {
721            let parent = commits[i - 1];
722            commits.push(put_commit(&store, tree, vec![parent], (i + 1) as u64));
723        }
724        // bad = c6 (index 5), good = [c1 (index 0)]
725        let mut state = BisectState {
726            orig_head: commits[5],
727            orig_branch: None,
728            bad_hash: Some(commits[5]),
729            good_hashes: vec![commits[0]],
730            skipped: BTreeSet::new(),
731        };
732        // Drive the bisect, simulating a "first-bad = c4" scenario:
733        // c1 good; c2..c5 unknown; c6 bad. Truth = c4 (index 3) is first bad.
734        let truth = commits[3];
735
736        let mut iters = 0;
737        loop {
738            assert!(iters < 8, "bisect should converge within 8 iterations");
739            iters += 1;
740            match next_step(&store, &state).unwrap() {
741                BisectStep::Found(h) => {
742                    assert_eq!(h, truth);
743                    break;
744                }
745                BisectStep::Testing { hash, .. } => {
746                    // Test: anything older than truth is good, truth and
747                    // newer is bad.
748                    let idx = commits.iter().position(|c| *c == hash).unwrap();
749                    let truth_idx = commits.iter().position(|c| *c == truth).unwrap();
750                    if idx < truth_idx {
751                        state.good_hashes.push(hash);
752                    } else {
753                        state.bad_hash = Some(hash);
754                    }
755                }
756                BisectStep::NeedMore => panic!("unexpected NeedMore"),
757                BisectStep::Ambiguous { .. } => panic!("unexpected ambiguous"),
758            }
759        }
760    }
761
762    #[test]
763    fn skip_advances_midpoint_to_neighbor() {
764        // Linear chain: c1 < c2 < c3 < c4 < c5 < c6
765        // good=c1, bad=c6, natural midpoint=c3 (index 2 of candidates c2..c6).
766        // Skip c3 → should pick neighbor (c2 or c4).
767        let (_d, store) = fresh_store();
768        let blob = put_blob(&store, b"data");
769        let tree = put_tree(&store, "f.txt", blob);
770        let mut commits = Vec::new();
771        commits.push(put_commit(&store, tree, vec![], 1)); // c1 = index 0
772        for i in 1..6 {
773            let parent = commits[i - 1];
774            commits.push(put_commit(&store, tree, vec![parent], (i + 1) as u64));
775        }
776        // Enumerate range first to know what the natural midpoint would be.
777        let cands = enumerate_range(&store, commits[5], &[commits[0]]).unwrap();
778        // Natural midpoint without skip.
779        let natural_mid = pick_midpoint(&cands);
780        assert_ne!(natural_mid, ZERO, "should have a natural midpoint");
781
782        // Now skip the natural midpoint.
783        let mut skipped = BTreeSet::new();
784        skipped.insert(natural_mid);
785
786        let skipped_mid = pick_midpoint_skip(&cands, &skipped);
787        assert_ne!(
788            skipped_mid, ZERO,
789            "should find a neighbor when mid is skipped"
790        );
791        assert_ne!(
792            skipped_mid, natural_mid,
793            "skip must advance past the natural midpoint"
794        );
795        assert!(
796            !skipped.contains(&skipped_mid),
797            "returned hash must not be in skipped set"
798        );
799    }
800
801    #[test]
802    fn next_step_skip_advances_then_finds() {
803        // 5-commit chain: c1 good, c6 bad, truth = c4 (index 3).
804        // Skip a good-side commit (c2) far from the truth: it is bypassed as
805        // `good` advances, so bisect still converges definitively (a skip
806        // that STRANDED the truth next to `bad` would instead be ambiguous —
807        // covered by the CLI `bisect run` tests).
808        let (_d, store) = fresh_store();
809        let blob = put_blob(&store, b"data");
810        let tree = put_tree(&store, "f.txt", blob);
811        let mut commits = Vec::new();
812        commits.push(put_commit(&store, tree, vec![], 1));
813        for i in 1..6 {
814            let p = commits[i - 1];
815            commits.push(put_commit(&store, tree, vec![p], (i + 1) as u64));
816        }
817        let truth = commits[3]; // c4
818
819        let mut skipped = BTreeSet::new();
820        skipped.insert(commits[1]); // skip c2, a good-side commit
821
822        let mut state = BisectState {
823            orig_head: commits[5],
824            orig_branch: None,
825            bad_hash: Some(commits[5]),
826            good_hashes: vec![commits[0]],
827            skipped,
828        };
829
830        let mut iters = 0;
831        loop {
832            assert!(iters < 10, "bisect with skip should converge");
833            iters += 1;
834            match next_step(&store, &state).unwrap() {
835                BisectStep::Found(_) => break,
836                BisectStep::Testing { hash, .. } => {
837                    let idx = commits.iter().position(|c| *c == hash).unwrap();
838                    let truth_idx = commits.iter().position(|c| *c == truth).unwrap();
839                    if idx < truth_idx {
840                        state.good_hashes.push(hash);
841                    } else {
842                        state.bad_hash = Some(hash);
843                    }
844                }
845                BisectStep::NeedMore => panic!("unexpected NeedMore"),
846                BisectStep::Ambiguous { .. } => panic!("unexpected ambiguous"),
847            }
848        }
849    }
850
851    #[test]
852    fn pick_midpoint_skip_all_skipped_returns_zero() {
853        let c1 = hash::hash(b"c1");
854        let c2 = hash::hash(b"c2");
855        let c3 = hash::hash(b"c3");
856        let mut skipped = BTreeSet::new();
857        skipped.insert(c1);
858        skipped.insert(c2);
859        skipped.insert(c3);
860        assert_eq!(pick_midpoint_skip(&[c1, c2, c3], &skipped), ZERO);
861    }
862}