cli.sgit.ai / Research / Design 05: sgit history bisect (roadmap item 2.6)

Design 05: sgit history bisect (roadmap item 2.6)

Date2026-10-28 · 03:05 UTCKinddesignResearch topicsgitWritten bythe sgit-ai CLI team (Claude Code sessions), for Dinis CruzSourceteam/humans/dinis_cruz/claude-code-web/10/28/design__05__bisect.md in SGit-AI__CLI at 036e33b. Published here as written; the CLI repository is where the work happened.

Read with design__00__index-and-reconciliation.md: where the two disagree (shared foundations F1–F9, the in-progress rules), the index wins.

Status: design, not started. Roadmap: roadmap__git-parity.md, priority 2, item 2.6. Code read for this document: this repository at 3a1d72a (claude/charming-babbage-5hzcxq). Git used as the reference: git 2.43.0 (installed here). Every Git behaviour quoted below was either run here against a scratch repository or read from builtin/bisect.c / bisect.c; the run-here ones are marked (run).


1. Summary, and the exact Git behaviour matched

sgit history bisect finds the commit that introduced a change by binary search over the commit graph, like git bisect. The user (or a command, with run) marks commits good or bad; sgit writes each candidate commit into the working copy until one commit is left.

The one structural difference from Git is where "HEAD" is. Git detaches HEAD and leaves the branch alone. sgit has no separate HEAD: the clone branch's ref is the head. So bisect never moves any ref. It records the commit under test in a local state file and writes that commit's files into the working copy; status, cat, ls and checkout read that commit while a bisect is in progress. reset writes the clone head's files back. Because no ref moves, a lost or damaged state file can never lose a commit: the head was never touched.

Git behaviour matched

BehaviourGit 2.43sgit
Startgit bisect start [<bad> [<good>...]] [--] [<pathspec>...], with --term-old/--term-new, --first-parent, --no-checkoutsame, under sgit history bisect
Marksbad/new, good/old (with no rev: the commit under test), skip [<rev>|<range>...]same
One bad, many goodsa later bad replaces the earlier one; goods accumulatesame
Candidate setancestors(bad) minus ancestors(each good), bad included, goods excludedsame (5.3)
Midpointweight(c) = number of candidates reachable from c, c included; choose the commit maximising min(w, n − w); Git returns early on the first commit whose weight is within half, in its counting ordersame score; ties broken by the smaller weight, then generation, then id (5.4). Equal to Git on linear histories and on the vectors in 9.3 (run)
Skipif the best candidate is skipped, Git sorts candidates by distance and picks with skip_away: prn = ((count·1103515245 + 12345) mod 2³²) / 65536 mod 32768, index = (count·prn / 32768) · isqrt(prn) / isqrt(32768)ported exactly (5.5)
Progress lineBisecting: <n> revisions left to test after this (roughly <s> steps) with n = all − weight − 1, s = estimate(all) (run)same text, sgit ids
Step estimateall < 3 → 0; else k = floor(log2 all), e = 2^k, x = all − e, k if e < 3x else k − 1 (run: 15→3, 8→2, 4→1, 2→0)same
Done<id> is the first bad commit, then the commit as git show --stat prints it (run)<id> is the first bad commit, then history show <id>
Only skipped leftThere are only 'skip'ped commits left to test. The first bad commit could be any of: <ids> We cannot bisect more!; run exits 2 (run)same; run exits 2
Good not an ancestor of badtests the merge base first: Bisecting: a merge base must be tested; a bad merge base stops with The merge base <mb> is bad. This means the bug has been fixed between <mb> and [<goods>].same
run <cmd> exit codes0 good; 125 skip; 1–127 except 125 bad; < 0 (signal) or ≥ 128 abort, state kept, run exits with that code (run: 128 → exit 128)same
126/127 on the first runGit re-runs the command on a good commit; the same code there means the command itself is broken: bogus exit code <n> for good revision, exit 1 (run). Otherwise it is "bad"same
run echorunning '<argv>' before each run (run)running <argv>
State.git/BISECT_START (original branch), BISECT_TERMS, BISECT_LOG, BISECT_NAMES (pathspec), BISECT_FIRST_PARENT, BISECT_HEAD (no-checkout), refs/bisect/bad, refs/bisect/good-<sha>, refs/bisect/skip-<sha> (run)one file, .sg_vault/local/bisect.json (5.1); per clone, never synced
log / replayBISECT_LOG is # bad: [...] comments plus git bisect … lines (run); replay <file> re-applies themsame format with sgit history bisect … lines; replay also accepts git bisect … lines
visualize / viewgitk or git log over the remaining candidateshistory log over the remaining candidates
reset [<commit>]back to the original branch, or to <commit>back to the clone head; reset <commit> = reset then history reset <commit> (5.7)
termsprints the termssame
nextre-computes and checks out the next candidatesame

2. Scope and non-goals

In scope: start, bad/new, good/old, custom terms, skip (single revs and A..B ranges), next, reset, log, replay, view/visualize, run, terms, status (sgit extra: the state as text or JSON), --first-parent, --no-checkout, a pathspec, --json on every subcommand. Full, selective (sparse / no-checkout), scoped, shallow, read-only, pack and pack-store clones. The commands that must refuse or change behaviour while a bisect is in progress (7.1).

Not in scope:


3. CLI surface

Rule 9: bisect is about commits, so it goes under history. No top-level command is added.

3.1 Subcommands

CommandArguments and flagsDoes
sgit history bisect start[<bad> [<good>...]] [--term-old T] [--term-new T] [--term-good T] [--term-bad T] [--first-parent] [--no-checkout] [-- <path>...]records the state; if a bad and at least one good are known, checks out the first candidate
sgit history bisect bad / new / <term-new>[<rev>...] (Git takes one for bad; extras refused)marks bad (default: the commit under test)
sgit history bisect good / old / <term-old>[<rev>...]marks each good (default: the commit under test)
sgit history bisect skip[<rev> | <A>..<B>]...marks skipped (default: the commit under test)
sgit history bisect nextre-computes and checks out the next candidate
sgit history bisect reset[<commit>] [--force]ends the bisect, writes the clone head back (5.7)
sgit history bisect logprints the replayable log
sgit history bisect replay<file>resets, then re-applies a log
sgit history bisect view / visualize[--oneline] [-n N]history log of the remaining candidates
sgit history bisect run[--shell] [--timeout SECONDS] -- <cmd> [<arg>...]loops: run, mark, next (5.8)
sgit history bisect terms[--term-good | --term-bad]prints the terms
sgit history bisect statussgit extra: state, current commit, counts, the next step

Every subcommand takes --directory DIR (default .) and --json.

Custom terms. Git accepts git bisect fixed after --term-new fixed. argparse cannot add sub-parsers at run time, so bisect is one parser with subcommand (positional) and args (argparse.REMAINDER), and CLI__Bisect.dispatch maps the word: a built-in subcommand, good/bad/old/new, or the recorded term names. A term may not be a subcommand name (help start skip next reset visualize view replay log run terms status), and old and new may not be equal: Git's rule, enforced by Safe_Str__Bisect_Term plus a check in start (decision D2).

3.2 Output (text)

$ sgit history bisect start HEAD HEAD~15
Bisecting: 7 revisions left to test after this (roughly 3 steps)
[a1b2c3d4e5f6] c8 — the commit message's first line
  wrote 1 file, removed 0 (working copy is now at a1b2c3d4e5f6; your branch head is unchanged)

$ sgit history bisect good
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[…] c12

$ sgit history bisect bad
…
obj-cas-imm-9f8e7d6c5b4a is the first bad commit
<history show output for it>
  (sgit history bisect reset   returns the working copy to your branch head)

Waiting states, as Git words them: status: waiting for both good and bad commits, status: waiting for good commit(s), bad commit known, status: waiting for bad commit, 1 good commit known.

status during a bisect gains a first line: Bisecting: working copy at <id> (<k> candidates left); branch head <id> unchanged. sgit history bisect reset to return.

3.3 Exit codes

SituationExit
any mark, start, next, skip, view, log, terms, status, reset that succeeded (including "first bad commit found")0
a refusal (bad revision, dirty files in the way, a bisect not in progress, a merge in progress, bad term)1
run: first bad commit found0
run: only skipped commits left2
run: the command exited < 0 or ≥ 128that code (a signal: 128 + signal number, as a shell reports it)
run: bogus 126/127 (same code on a good commit)1
run: the merge base is bad1

3.4 --json shape

Every subcommand prints one JSON object (run prints one at the end; with --json it prints no per-step text):

{
  "status":      "bisecting",           // waiting | bisecting | found | only_skipped | merge_base_bad | reset
  "terms":       {"old": "good", "new": "bad"},
  "bad":         "obj-cas-imm-…",       // null while unknown
  "good":        ["obj-cas-imm-…"],
  "skipped":     [],
  "current":     "obj-cas-imm-…",       // the commit in the working copy; null with --no-checkout until marked
  "remaining":   7,                     // Git's "revisions left to test after this"
  "steps":       3,                     // Git's "roughly N steps"
  "candidates":  15,                    // size of the candidate set
  "first_bad":   null,                  // the id when status = found
  "possible":    [],                    // the ids when status = only_skipped
  "branch_head": "obj-cas-imm-…",       // never changes during a bisect
  "written":     1, "deleted": 0, "kept_dirty": [], "not_written": [],
  "paths":       [],                    // the pathspec
  "first_parent": false, "no_checkout": false
}

run --json adds "runs": [{"commit": "…", "exit_code": 0, "verdict": "good", "seconds": 0.4}].


4. How the code works today (file:line)

PieceWhereWhat matters for bisect
Revision syntaxcore/actions/history/Vault__Revision.py:25-165resolve() (:27) turns HEAD, ~n, ^n, @{n}, tags, short ids into a commit id, local only; _parent() (:159) already says "history stops before … (shallow clone?)"
Commit graph walkscore/actions/fetch/Vault__Fetch.py:78 find_lcareturns one LCA and caps each ancestor walk at limit=200: not usable for bisect (needs all merge bases, unbounded)
core/Vault__Sync__Base.py:767 _walk_commit_idsBFS, limit=200: same problem
core/actions/pull/Vault__Ref_Guard.py:23 is_ancestorlocal walk capped at LOCAL_WALK_LIMIT = 100000 (:17): reusable for single checks
storage/graph/Vault__Graph_Walk.py:4tree walks only, not commits
Generation numberscore/actions/pack/Vault__Pack_Index.py:137 generation_of1 + max(parent generations), a non-local parent counts 0, memoised in .sg_vault/local/index/generations.json (:70, :115-135). It only needs the object store, so it works on loose vaults too
Tree diffstorage/Vault__Sub_Tree.py:259 diff_treeswalks only folders whose ids differ: the cost of a step follows the change
Flatten + cachestorage/Vault__Sub_Tree.py:133 flatten, :186 _flatten_incremental, :11-14in-process cache of 4 trees, on-disk cache of the newest 2 heads (flat-cache/<tree id>.json)
Scoped flattenstorage/Vault__Scoped_Tree.py:39 flatten(tree, key, scope) -> (flat, opaque)in-scope files only
Scope / shallowstorage/Vault__Scope.py:65 from_local_config, :74 is_scoped, :77 is_shallow, :83 is_boundaryboundaries are the commits whose parents were never fetched
Writing filescore/Vault__Sync__Base.py:674 _checkout_flat_mapwrites, seeds the scan cache, reports failures per path
Deleting filescore/Vault__Sync__Base.py:738 _remove_deleted_flatguarded by Vault__Path_Guard.is_writable
Dirty guardcore/actions/pull/Vault__Pull__Guard.py:30 dirty_paths, :59 plan, :101 blocked_targets, :127 message; used by workflow/pull/Step__Pull__Merge.py:327 _guard_working_treecarries dirty files a move does not touch; refuses before writing when it would overwrite one
Selective working copyworkflow/pull/Pull__Selective.py:13 (split :37, after_write :59); storage/Vault__Checkout_Set.py:151 materialisedwhich changed paths a selective clone writes
Selective checkoutcore/actions/checkout/Vault__Sync__Checkout.py:35 checkout, :150 _changed_since--changed-since diffs a commit against the clone head: the right tool between head and a commit, not between two bisect steps (5.6 uses diff_trees directly)
Head flat for cat / ls / checkoutcore/actions/sparse/Vault__Sync__Sparse.py:12 _get_head_flat_map, :87 sparse_ls, :177 sparse_cat, :248 _local_head_commitwhere the "working commit" hook goes (5.9)
Statuscore/actions/status/Vault__Sync__Status.py:16; head flat at :57-67; read-only path :339compares the disk with the clone head's flat
Current "detached" checkoutcli/CLI__Vault.py:1519 cmd_checkout; core/actions/revert/Vault__Revert.py:43 revert_to_commitwrites every file of the commit, decrypting every blob, no dirty check, no state; status and commit then see a revert. Bisect must not reuse it
History resetcore/actions/pull/Vault__Sync__Pull.py:24 resetmoves the clone ref and writes the full flat
Reflogstorage/Vault__Reflog.py:24; every Vault__Ref_Manager.write_ref appendsbisect writes no ref, so nothing reaches it
Merge state patterncore/actions/merge/Vault__Merge__State.py:9 (MERGE_STATE_FILE), :42 check_not_in_progress; called by pull (Vault__Sync__Pull.py:88), push (Vault__Sync__Push.py:50)the model for Vault__Bisect__State
Stashcore/actions/stash/Vault__Stash.py:27 stash (reverts to the clone head), :76 popwould overwrite the bisect working copy
History CLIplugins/history/CLI__History.py:70 register; plugin wiring plugins/history/Plugin__History.py:9; legacy cli/CLI__Main.py:1285where bisect is registered
Friendly errorscli/CLI__Main.py:1196 _print_friendly_error (the tuple at :1203-1213)new errors are added there
Subprocess patterncore/serve/Container__Command_Runner.py:15argv list, no shell, FileNotFoundError → 127, timeout → 124
In-memory hostnetwork/api/Vault__API__In_Memory.py:8 (_store :11)snapshot for the "nothing written" test

5. Design

5.1 State: .sg_vault/local/bisect.json

Written with Vault__Secret_File.write (crypto/Vault__Secret_File.py:14: temp file 0600, rename). It holds commit ids and paths, which .sg_vault/local/ already holds (reflog, flat cache), so 0600 is consistency, not secrecy.

New types:

FileClassDefinition
sgit_ai/safe_types/Safe_Str__Bisect_Term.pySafe_Str__Bisect_Term(Safe_Str)regex = re.compile(r'^[a-z][a-z0-9_-]{0,31}$'), regex_mode = MATCH, max_length = 32, allow_empty = False, strict_validation = True
sgit_ai/safe_types/Enum__Bisect_Verdict.pyEnum__Bisect_Verdict(Enum)GOOD = 'good', BAD = 'bad', SKIP = 'skip' (the internal meaning; terms are display)
sgit_ai/safe_types/Enum__Bisect_Status.pyEnum__Bisect_Status(Enum)WAITING, BISECTING, FOUND, ONLY_SKIPPED, MERGE_BASE_BAD
sgit_ai/safe_types/Enum__Bisect_Log_Action.pyEnum__Bisect_Log_Action(Enum)START, GOOD, BAD, SKIP, FOUND

Schemas (folder sgit_ai/schemas/bisect/, like schemas/merge/):

class Schema__Bisect_Log_Entry(Type_Safe):
    action    : Enum__Bisect_Log_Action  = None
    commit_id : Safe_Str__Commit_Id      = None
    args      : list[Safe_Str__Commit_Id]          # START: [bad, good...]; SKIP of a range: every id
    subject   : Safe_Str__Commit_Message = None    # first line, for the "# bad: [...] subject" comment

class Schema__Bisect_Transition(Type_Safe):         # a step in flight (crash recovery, 5.6.4)
    from_commit : Safe_Str__Commit_Id = None
    to_commit   : Safe_Str__Commit_Id = None

class Schema__Bisect_State(Type_Safe):
    schema_version  : Safe_UInt                      # 1
    branch_id       : Safe_Str__Branch_Id   = None   # the clone branch when the bisect started
    branch_head     : Safe_Str__Commit_Id   = None   # its head then; checked on every command (7.1)
    term_old        : Safe_Str__Bisect_Term = None   # 'good' unless --term-old
    term_new        : Safe_Str__Bisect_Term = None   # 'bad'  unless --term-new
    bad             : Safe_Str__Commit_Id   = None
    good            : list[Safe_Str__Commit_Id]
    skipped         : list[Safe_Str__Commit_Id]
    current         : Safe_Str__Commit_Id   = None   # the commit whose files the working copy holds
    paths           : list[Safe_Str__File_Path]       # the pathspec ([] = everything)
    first_parent    : bool                  = False
    no_checkout     : bool                  = False
    merge_bases_ok  : bool                  = False   # Git's BISECT_ANCESTORS_OK
    transition      : Schema__Bisect_Transition = None
    first_bad       : Safe_Str__Commit_Id   = None
    started_ms      : Safe_UInt__Timestamp
    log             : list[Schema__Bisect_Log_Entry]

Safe_Str__Commit_Message exists (safe_types/Safe_Str__Commit_Message.py); the subject is its first line.

Round-trip invariant (CLAUDE.md rule 6): Schema__Bisect_State.from_json(s.json()).json() == s.json().

5.2 Classes and responsibilities

All new code under sgit_ai/core/actions/bisect/ (with an empty __init__.py, as every action package has).

ClassBaseResponsibility
Vault__Bisect__StateType_Safepath(directory), exists, read (a damaged file raises Vault__Bisect_Error naming the file and the remedy bisect reset), write, delete, check_not_in_progress(directory, action) (mirrors Vault__Merge__State.check_not_in_progress), working_commit(directory) -> str ('' when no bisect)
Vault__Bisect__GraphType_Safethe commit graph and the choice: parents(cid), generation(cid), candidates(state) -> list, weights(cands) -> dict, best(cands, weights, skipped) -> str, skip_away(sorted_cands, count), prn(count), isqrt(n), estimate_steps(all), merge_bases(a, b) -> list, touches_paths(cid, paths) -> bool
Vault__Bisect__Working_CopyVault__Sync__Basemoves the working copy from one commit to another (5.6); move(directory, from_id, to_id, state) -> dict(written, deleted, kept_dirty, not_written)
Vault__Bisect__RunnerType_Saferuns the user's command (5.8): run(argv, cwd, env, timeout, shell) -> Schema__Bisect_Run
Vault__Bisect__LogType_Saferender(state) -> str, parse(text) -> list[Schema__Bisect_Log_Entry] (parsing only; never executes)
Vault__Sync__BisectVault__Sync__Basethe commands: start, mark(verdict, revs), skip(specs), next, reset(commit=None, force=False), log, replay(file), view(n), run(argv, ...), terms, status
CLI__BisectType_Safe (sgit_ai/cli/CLI__Bisect.py)argparse registration, dispatch(args), text and JSON rendering

Plus Schema__Bisect_Run(Type_Safe): exit_code : Safe_Int, seconds : Safe_Float, timed_out : bool, verdict : Enum__Bisect_Verdict = None, aborted : bool.

Errors (sgit_ai/core/Vault__Errors.py): Vault__Bisect_Error(Exception) for refusals by bisect itself and Vault__Bisect_In_Progress_Error(Exception) for other commands refused during a bisect. Both are added to the "refused on purpose" tuple in CLI__Main._print_friendly_error (cli/CLI__Main.py:1203-1213), so the user sees error: <message> and no traceback.

5.3 The candidate set

Input: bad, goods (≥ 1), skipped, paths, first_parent.

  1. Painting walk (Vault__Bisect__Graph.candidates). A max-heap of (generation, commit_id) starts with bad (flag NEW) and every good (flag OLD). Pop the highest generation; merge flags if seen; push its parents (only the first with first_parent) carrying the flag. Stop when every entry left in the heap is OLD (the paint_down_to_common rule: nothing below can be NEW-only). Candidates = commits painted NEW and not OLD. Generations come from Vault__Pack_Index.generation_of (Vault__Pack_Index.py:137), memoised in local/index/generations.json. On a loose vault the memo is built on first use (one decrypt per commit, once per clone). A commit not in the store (a shallow boundary's parent) has generation 0 and no parents. Unbounded: no limit=200 (unlike find_lca, Vault__Fetch.py:78).
  2. Pathspec (when paths is non-empty, or by default on a scoped clone, 7.3). A candidate is kept when touches_paths is true:
  1. Skipped commits stay candidates for counting (as in Git) and are excluded only when choosing.
  2. Ancestry check before the first step (merge_bases_ok false): for each good g, if is_ancestor(g, bad) is false (Vault__Ref_Guard.is_ancestor, :23), compute merge_bases(g, bad) (all of them, by painting).

5.4 The midpoint

n = len(candidates). For each candidate c, w(c) = number of candidates reachable from c through the graph (c included). Computation: process candidates in increasing generation; reach(c) = {c} ∪ ⋃ reach(p) over parents restricted to the candidate subgraph. A bitset per commit (Python int used as a bitset, candidates indexed 0..n−1) keeps this exact for DAGs at n · n / 8 bytes: 10,000 candidates is 12.5 MB, acceptable. For n > 50,000, use Git's approach: exact counts for single-parent chains (w = w(parent) + 1) and a bitset only below merges. Linear histories are always O(n).

Choice (best):

That tie rule reproduces Git on every vector run here (9.3): 15 linear → c8 (w = 7, not c9 with w = 8); first-parent 5 → m3; the merge graph → b3. Git's own tie order depends on its counting order and, under skip, on SHA-1 order, so for DAGs with ties the tests assert this documented rule, not Git's pick.

Progress numbers: remaining = n − w(chosen) − 1, steps = estimate_steps(n); for 15 → 7, 3.

Termination:

5.5 Skip (skip_away, ported from Git)

When the best candidate is skipped:

  1. Sort the non-skipped candidates by score descending, ties by w ascending, then id (Git sorts by distance then oid).
  2. count = len(sorted), prn = ((count * 1103515245 + 12345) & 0xFFFFFFFF) // 65536 % 32768, index = (count * prn // 32768) * isqrt(prn) // isqrt(32768).
  3. Choose sorted[index]; if that is bad, choose the entry before it, or sorted[0].

Vectors (count → index): 14 → 0, 13 → 6, 12 → 0, 7 → 3, 6 → 0 (computed with this formula; with Git on the 16-commit history, skipping c8 gave c9 (index 0) (run)).

skip A..B marks every commit in ancestors(B) − ancestors(A), as Git does.

5.6 Moving the working copy between steps

Vault__Bisect__Working_Copy.move(directory, from_id, to_id, state):

  1. Diff. diff_trees(tree(to), tree(from)) (Vault__Sub_Tree.py:259): only differing folders are opened. On a scoped clone, flatten both with Vault__Scoped_Tree.flatten (in-scope only) and diff the two maps. Result: writes (added and modified in to), deletes (in from, not in to).
  2. Guard (before any write). The pull guard, with "ours" = flat(from) and "merged" = flat(to):
  1. Selective clones. Pull__Selective().setup(directory, obj_store, local_config, no_checkout=False); to_write, skipped = selective.split(writes). Paths not checked out stay not checked out and are reported as not_written. After writing, selective.after_write(written, deletes, stale=selective.stale_of(skipped, ours)) keeps the checkout record right (Pull__Selective.py:59).
  2. Missing blobs (a loose sparse clone): batch-read through api.batch_read, verified before they are saved, exactly as Vault__Sync__Checkout._download (Vault__Sync__Checkout.py:160). This is a read; see 6.3.
  3. Write. _remove_deleted_flat for deletes, then _checkout_flat_map(directory, to_write, …, failed=failed) (Vault__Sync__Base.py:674), which also seeds the scan cache. Any failed entry raises after the state records current = to_id (the files that were written are to's), with the paths and reasons; status shows them as modified, and next or reset retries.

5.6.1 Write order and crash recovery

  1. Write the state with transition = {from, to}.
  2. Apply the move.
  3. Write the state with current = to, transition = None.

If a process dies between 1 and 3, the next bisect command sees transition and re-applies the move from from to to. Files already written equal to's versions, and the guard's plan treats bytes equal to the incoming version as current (Vault__Pull__Guard.plan), so the retry writes only what is left. reset does the same before returning to the head.

5.6.2 --no-checkout

Like Git's --no-checkout (which keeps BISECT_HEAD instead of moving HEAD): nothing is written; current moves and is shown. The run command gets SGIT_BISECT_COMMIT (5.8). During the bisect, sgit cat <path> and sgit ls read the commit under test (5.9), so a script can do sgit cat src/app.py | … with no checkout. This is the agent-friendly mode and the cheapest on a 76,000-file vault.

5.7 reset

  1. If a transition is recorded, finish it (5.6.1).
  2. Move the working copy from current back to the clone head (5.6), with the same guard: dirty files that the move would overwrite refuse the reset, naming them (--force overwrites, as git bisect reset does not need; we keep the guard by default because sgit has no index to protect them). Not touching the head's other files means a bisect on a 76,000-file vault resets in the time of its last diff.
  3. Delete bisect.json.
  4. reset <commit> (Git: "go to this commit instead of the original branch"): after 1–3, Vault__Sync__Pull.reset (directory, commit) (Vault__Sync__Pull.py:24), the existing history reset, which moves the clone ref and writes the reflog. That is the only path in this design that moves a ref, and it is an explicit user request.
  5. If bisect.json is unreadable, current is unknown, and reset cannot tell the user's edits from files the bisect wrote. It compares the disk with the clone head's flat (_scan_local_directory against flatten(head)), refuses, and lists the paths that differ; --force writes the head's version of each. Commits are never at risk: no ref moved.

If the clone head moved during the bisect (it cannot through sgit, 7.1, but a second process or a manual ref edit could), reset still returns to the current head and says your branch head moved during the bisect (from X to Y).

5.8 run

Loop:

if status is WAITING:      refuse: "bisect run needs a good and a bad commit first"
first = True
while status == BISECTING:
    r = Runner.run(argv, cwd=directory, env=env(current), timeout, shell)
    if r.aborted (exit < 0 or >= 128, or timeout):   print Git's message; exit with that code (state kept)
    code = r.exit_code
    if first and code in (126, 127):
        # Git's check: is the command itself broken? run it once on a good commit
        move working copy to good[0]; r2 = run(...); move back to current
        if r2.exit_code == code: error "bogus exit code {code} for good revision"; exit 1
    first = False
    verdict = GOOD if code == 0 else SKIP if code == 125 else BAD
    mark(verdict, [current])                         # prints the next "Bisecting: …" or the result
return 0 if FOUND, 2 if ONLY_SKIPPED, 1 if MERGE_BASE_BAD

Vault__Bisect__Runner.run:

5.9 The "working commit" hook (what other commands read during a bisect)

One method, Vault__Bisect__State.working_commit(directory) -> str, returns current during a bisect and '' otherwise. Three places use it:

PlaceTodayDuring a bisect
Vault__Sync__Status.status (status/Vault__Sync__Status.py:57-67, read-only :339)flat of the clone headflat of working_commit; adds bisecting, bisect_current, bisect_candidates, bisect_branch_head to the result; ahead/behind still use the clone head
Vault__Sync__Sparse._get_head_flat_map (sparse/Vault__Sync__Sparse.py:12), used by cat, ls, checkout <paths>the clone headworking_commit
Vault__Sync__History_Edit._require_clean (history/Vault__Sync__History_Edit.py:116)statusunchanged (status already answers against the working commit)

history log, history diff and history show keep reading the clone head and explicit revisions; view is the bisect-specific log. (Git's git log during a bisect starts from the detached HEAD. sgit users get the same through history log <current>; decision D6.)

5.10 File-by-file changes

New files:

FileContents
sgit_ai/safe_types/Safe_Str__Bisect_Term.py5.1
sgit_ai/safe_types/Enum__Bisect_Verdict.py, Enum__Bisect_Status.py, Enum__Bisect_Log_Action.py5.1
sgit_ai/schemas/bisect/__init__.py, Schema__Bisect_State.py, Schema__Bisect_Log_Entry.py, Schema__Bisect_Transition.py, Schema__Bisect_Run.py5.1, 5.2
sgit_ai/core/actions/bisect/__init__.py, Vault__Bisect__State.py, Vault__Bisect__Graph.py, Vault__Bisect__Working_Copy.py, Vault__Bisect__Runner.py, Vault__Bisect__Log.py, Vault__Sync__Bisect.py5.2–5.8
sgit_ai/cli/CLI__Bisect.py3, rendering

Changed files:

FileChange
sgit_ai/plugins/history/CLI__History.pyfield bisect : object = None; in register (:70), bisect_p = hist_sub.add_parser('bisect', …) with subcommand, args (REMAINDER), --directory, --json; set_defaults(func=self.bisect.dispatch); module docstring lists bisect
sgit_ai/plugins/history/Plugin__History.pyns.bisect = context.get('bisect') or CLI__Bisect()
sgit_ai/cli/CLI__Main.pybuilds CLI__Bisect with the vault/diff CLIs and passes it in the plugin context; _cmd_log_dispatch (:1285) unchanged; the error tuple (:1203-1213) gains both bisect errors
sgit_ai/core/Vault__Errors.pyVault__Bisect_Error, Vault__Bisect_In_Progress_Error
sgit_ai/core/actions/status/Vault__Sync__Status.py5.9
sgit_ai/cli/CLI__Vault.py cmd_status (:826)prints the bisect line first
sgit_ai/core/actions/sparse/Vault__Sync__Sparse.py _get_head_flat_map (:12)5.9
sgit_ai/core/actions/commit/Vault__Sync__Commit.py commit (:32), write_file (:231)Vault__Bisect__State().check_not_in_progress(directory, 'commit' / 'write') first
sgit_ai/core/actions/push/Vault__Sync__Push.py (:50)the same check beside the merge one
sgit_ai/core/actions/pull/Vault__Sync__Pull.py pull (:88), reset (:24)the same check (bisect's own reset <commit> calls reset after deleting the state)
sgit_ai/core/actions/branch/Vault__Branch_Switch.py switch (:30), branch_new (:167)the same check
sgit_ai/core/actions/stash/Vault__Stash.py stash (:27), pop (:76)the same check
sgit_ai/core/actions/history/Vault__Sync__History_Edit.py undo (:39), revert_commit (:60)the same check
sgit_ai/core/actions/revert/Vault__Revert.py revert_to_commit / revert_to_head (:27, :43)the same check (branch checkout <commit> and history revert without --as-commit)
sgit_ai/core/actions/merge/Vault__Merge__State.pynone; Vault__Sync__Bisect.start calls check_not_in_progress(directory, 'bisect')
sgit_ai/core/actions/tag/Vault__Sync__Tag.py create (:92)during a bisect, a create with no commit refuses: say which commit (the working copy is at <current>, your branch head is <head>)

The check_not_in_progress message: Cannot <action>: a bisect is in progress (working copy at <current>; your branch head <head> is unchanged). sgit history bisect reset ends it.

5.11 Compatibility


6. Security and threat model

6.1 Running user commands (run)

RiskTreatment
Shell interpretation of arguments (paths, vault file names)argv list, no shell, unless --shell is given explicitly (5.8)
The command runs code from an old commitinherent, as in Git: run executes whatever the commit under test contains. The help text says so: "run executes the code in each commit it tests; run it only on vaults you trust"
Planted files on sys.path / PATH (TM-F06: pip.py in the folder ran as the user)cwd is the working copy by design (the test needs it). sgit itself never runs Python from there: bisect's own code runs in the sgit process, and the runner does not invoke python. The help text recommends python -I for Python test commands
Secrets in the child's environmentbisect adds only the SGIT_BISECT_* ids; no key, token or base URL is added. The user's existing environment is passed as Git passes it
Output injection into sgit's own parsingnone: sgit reads only the exit code
Long-running or hung commands--timeout; Ctrl-C keeps the state

6.2 State tampering

bisect.json is local. Whoever can write it can already write the working copy and the refs. What a damaged or edited state can and cannot do:

6.3 No network writes during a bisect (proof)

6.4 Threat-model rows


7. Interactions

7.1 Other commands during a bisect

CommandGit during a bisectsgit
statusworks (HEAD detached)works, against the commit under test, with the bisect line
commit, commit --amend, writeallowed on the detached HEAD; bisect reset orphans those commitsrefused (D1)
pushallowed (pushes branches, not HEAD)refused: sgit's push runs an implicit pull that writes the working copy
pull, branch mergegit pull fails on a detached HEADrefused
fetch <path> (writes files)n/arefused
fetch --unshallowallowedallowed: it adds commits only; the candidate set is recomputed on the next command
checkout <paths>, cat, lsread HEAD (the bisect commit)read the commit under test (5.9)
branch switch / new / checkout, history reset / undo / revert (all forms)allowed, and confuse the bisectrefused
vault stash / stash popallowedrefused: stash reverts to the clone head (Vault__Stash.py:28)
history log / diff / show / reflog, check *, vault tag list/showallowedallowed
vault tag create with no committags HEAD = the bisect commitrefused, names both candidates (5.10)
A merge in progressgit bisect start refusesstart refuses (Vault__Merge__State.check_not_in_progress)

7.2 Head moved under the bisect

Every bisect command compares the clone head with state.branch_head. If they differ (another process, a manual edit), it continues and warns once: your branch head moved during the bisect (from X to Y); the bisect is unaffected and reset returns to Y.

7.3 Clone shapes

CloneBehaviour
Fullas designed
Selective (--sparse, --no-checkout)each step writes only the changed paths that are checked out (5.6.3); not_written lists the rest. With nothing checked out, start warns no files are checked out: the test will see nothing (sgit checkout <paths> first, or use --no-checkout and sgit cat)
Scoped (--path)the pathspec defaults to the scope, so only commits that change a held folder are candidates, and the working copy holds only those folders. A user pathspec outside the scope refuses (Vault__Scope.paths_outside, :122)
Shallow (--depth)a good below the boundary cannot be resolved (Vault__Revision already says "shallow clone? sgit fetch --unshallow"). Boundary commits are roots. A first bad commit at the boundary is reported as such (5.4)
Read-onlyallowed: bisect writes no ref and nothing to the host; status's read-only path gets the same hook (5.9)
Pack vaultevery object is local after the clone (packs are extracted on arrival), so no step downloads. Generations may come from the index packs' commit graph members instead of decrypting commits (optimisation, step 6 in 10)
Pack-store (--pack-store)objects are read in place by offset through Vault__Object_Store; nothing changes
Worktree (design 06)bisect.json lives in the worktree's own .sg_vault/local/, so each worktree has its own bisect, as Git keeps BISECT_* per worktree. The recommended agent pattern: sgit worktree add ../bisect-wt then bisect there, leaving the main working copy alone
Stashrefused during a bisect (7.1); a stash made before survives untouched

8. Performance


9. Test plan

All tests are real objects over Vault__API__In_Memory with Vault__Test_Env (fixed TEST_VAULT_KEY); no mocks, no __init__.py in test folders. Git is the oracle where the result is defined by Git (9.3); git is installed in CI's unit image (check, else mark those tests to skip when shutil.which('git') is empty, with a clear reason).

9.1 Files and classes

FileClassCovers
tests/unit/schemas/bisect/test_Schema__Bisect_State.pyTest_Schema__Bisect_Stateround trip, defaults, bad ids rejected, list fields
tests/unit/safe_types/test_Safe_Str__Bisect_Term.pyTest_Safe_Str__Bisect_Termvalid terms, uppercase/space/empty/33-char rejected
tests/unit/core/actions/bisect/test_Vault__Bisect__Graph.pyTest_Vault__Bisect__Graphcandidates, weights, best, skip_away vectors, estimate_steps vectors, merge_bases, first-parent, pathspec, boundaries
tests/unit/core/actions/bisect/test_Vault__Bisect__Graph__Git_Oracle.pyTest_Vault__Bisect__Graph__Git_Oracle9.3
tests/unit/core/actions/bisect/test_Vault__Bisect__Working_Copy.pyTest_Vault__Bisect__Working_Copywrites/deletes only the diff, dirty guard, carry-over, selective, scoped, crash recovery
tests/unit/core/actions/bisect/test_Vault__Sync__Bisect.pyTest_Vault__Sync__Bisectthe command flows (9.2)
tests/unit/core/actions/bisect/test_Vault__Bisect__Runner.pyTest_Vault__Bisect__Runnerexit-code mapping, 126/127 rule, abort, timeout, env, argv vs --shell
tests/unit/core/actions/bisect/test_Vault__Bisect__Log.pyTest_Vault__Bisect__Logrender, parse, hostile lines refused, replay equals the original
tests/unit/core/actions/bisect/test_Bisect__Refusals.pyTest_Bisect__Refusalsevery row of 7.1
tests/unit/core/actions/bisect/test_Bisect__Nothing_Leaves_The_Clone.pyTest_Bisect__Nothing_Leaves_The_Clone6.3
tests/unit/core/actions/bisect/test_Bisect__No_Write_Calls.pyTest_Bisect__No_Write_Callssource grep, 6.3
tests/unit/cli/test_CLI__Bisect.pyTest_CLI__Bisectparsing, custom terms, text and --json, exit codes
tests/unit/cli/test_CLI__B07__Namespace_Moves.py(existing)unchanged: no top-level command added; the test must still pass
tests/security/test_Security__Fixed__Bisect.pyTest_Security__Fixed__Bisecthostile replay file, state naming an unknown commit, a path in state outside the tree, run never via a shell without --shell

9.2 Cases (the synthetic histories)

Helper Bisect__History_Builder (in tests/_helpers/, a class) builds a vault with commits whose content is known: commit ck writes f.txt = k; from commit c11 on, bug.txt exists. The oracle command is test ! -f bug.txt (or a Python equivalent for Windows CI).

#CaseExpect
1linear 16 commits, start c16 c1, marks by handsteps c8 → c12 → c10 → c11; c11 is the first bad commit; 4 steps; each "Bisecting" line equals Git's numbers (7/3, 3/2, 1/1, 0/0)
2same with runexit 0, same sequence, runs has 4 entries
3start with nothing, then bad, then good c1waiting messages in Git's words; first checkout only after both
4merge graph (m1, m2, side b1–b3, m3, m4, merge m5, m6), bug in b2first step b3 (Git's choice, 9.3), result b2
5same, --first-parent, bug in b2first step m3; result m5 (the merge that brought it), as Git
6skip the first proposed commit (c8)next c9 (index 0); skip again → index 6 rule applied
7all remaining candidates skippedONLY_SKIPPED, possible lists them; run exits 2
8run command exiting 125 everywhereONLY_SKIPPED, exit 2
9run exiting 128 / killed by a signalstops, state kept, exit 128 / 128+n
10run command not found (127 on every commit)bogus exit code 127 for good revision, exit 1, state kept
11run exiting 127 only on bad commitstreated as bad; result correct
12good not an ancestor of bad (bug fixed on a side branch)merge base tested first; a bad merge base stops with Git's message and the terms hint
13custom terms --term-old broken --term-new fixed, sgit history bisect fixedfinds the fixing commit
14invalid terms (start, equal terms, uppercase)refused
15pathspec -- docs/ with commits touching other foldersonly commits touching docs/ tested; result equals Git's with the same pathspec (linear)
16dirty file the next step changesrefused before writing, path named; working copy and state unchanged
17dirty file the next step does not touchcarried over across steps and through reset
18untracked file at a path the step createsrefused (blocked_targets)
19process killed between transition write and completion (simulate by writing the transition and half the files)next command finishes the move; content equals the target commit
20reset after foundworking copy equals the clone head byte for byte; bisect.json gone; clone ref, reflog unchanged
21reset <commit>working copy at commit; clone ref moved; one reflog entry
22damaged bisect.jsonreset refuses listing differing paths; reset --force restores the head
23log then replay in a fresh bisectsame state, same current commit
24replay file with run rm -rf …, shell metacharacters, unknown idsrefused lines named; nothing executed
25selective clone (--no-checkout, two files checked out)steps write only those two when changed; checkout record correct after reset
26scoped clone (--path a)default pathspec = scope; commits changing only b/ never tested
27shallow clone --depth 5, bug older than the boundaryfirst bad reported "at the boundary", at_boundary: true
28read-only clonefull bisect works; nothing written to the host
29pack vault and pack-store clonesame results as loose; no download during steps
30--no-checkoutworking copy bytes never change; sgit cat returns the commit under test's bytes; SGIT_BISECT_COMMIT set in run
31each refused command of 7.1refused with the bisect message; nothing changed
32status during bisectcompares with the commit under test (clean right after a step)
33the clone head moved by a second process during the bisectwarning once; reset returns to the new head
346.3 snapshot testhost store, refs, baselines, index, reflog byte-identical

9.3 Git as the oracle

Test_Vault__Bisect__Graph__Git_Oracle builds the same commit graph in a scratch Git repository (git init, fixed author/committer dates) and in an sgit vault, runs git bisect start/good/bad and sgit's Vault__Bisect__Graph.best, and compares the position of the chosen commit (its message ck, since ids differ). Graphs where the choice is tie-free or where the tie rule is known to agree:

GraphGit's choice (run here)
16 linear, good c1, bad c16c8, then (good) c12, then (bad) c10, then (good) c11
merge graph of case 4, good m1, bad m6b3
same, --first-parentm3
16 linear, skip c8c9

Also compared: the "N revisions left (roughly S steps)" numbers for each step, and the final "first bad commit" on cases 1, 4, 5, 13, 15. Skip picks after the first are not compared with Git (Git's order among equal distances is SHA-1 order); skip_away is tested against the formula vectors in 5.5 instead.

9.4 Acceptance checklist (implementation and deployment)


10. Implementation steps (ordered commits)

#CommitContentsEstimate
1feat(bisect): state and types5.1 types, schemas, Vault__Bisect__State, errors; schema and state tests0.5 day
2feat(bisect): graph — candidates, weights, midpoint, skipVault__Bisect__Graph without pathspec; graph tests, formula vectors, Git oracle (linear, merge, first-parent, skip)1 day
3feat(bisect): move the working copy between commitsVault__Bisect__Working_Copy; guard, selective, scoped, crash recovery tests1 day
4feat(bisect): start / good / bad / skip / next / reset / terms / statusVault__Sync__Bisect, CLI__Bisect, registration; flows 1, 3–7, 12–14, 16–23, 31–331 day
5feat(bisect): run, log, replay, view, --no-checkoutrunner, log, view, the working-commit hook in status/cat/ls/checkout; cases 2, 8–11, 23–24, 301 day
6feat(bisect): pathspec, scoped default, shallow boundary, pack generationstouches_paths, cases 15, 25–29; optional index-pack generations0.5–1 day
7feat(bisect): refuse other commands during a bisect; security tests; docsthe check_not_in_progress calls (5.10), 6.3 tests, security tests, threat-model rows, CHANGELOG, README, the 76k measurement0.5–1 day

Total: about 5–6 days. The roadmap's "1–2 days" covered the core loop only; the clone shapes, the refusals and the no-write proof are most of the work.


11. Open decisions for Dinis

#DecisionOptionsRecommendation
D1Commits during a bisect(a) refuse; (b) allow, as Git does on a detached HEAD (where reset orphans them)(a) refuse. In sgit a commit moves the clone branch itself, so "allow" would mean committing an old tree onto the branch: a silent revert
D2Custom-term syntax(a) exact Git syntax (sgit history bisect fixed) via one REMAINDER parser and our own dispatcher; (b) fixed argparse sub-parsers plus mark <term>(a): Git parity, and bisect's grammar is small enough to dispatch by hand; --help text is written by CLI__Bisect
D3run and the shell(a) argv by default, --shell opt-in; (b) always a shell, as Git(a). Same power (--shell 'make test && ./check'), no surprise interpretation of a file name
D4Pathspec on merges(a) the approximation in 5.3 (a merge counts when it differs from every parent); (b) port Git's full history simplification(a) now; it equals Git on linear history and is documented. Revisit if someone bisects path-limited merge-heavy histories
D5--timeout result(a) abort the run (state kept, exit 124); (b) count as skip(a): a hang usually means the test is broken, not the commit; skip would quietly widen the answer
D6What history log shows during a bisect(a) the clone head (unchanged); (b) the commit under test, like Git's git log on a detached HEAD(a), plus bisect view; keeps history log meaning "my branch"
D7The existing branch checkout <commit> "detached" mode (CLI__Vault.py:1567)(a) leave it; (b) re-implement it on this design's working-commit state (working_commit with a reason checkout), so status, commit and reset understand it(b), as a separate follow-up: today it writes every file with no dirty check and makes the next commit a silent revert. Bisect lays the groundwork (state file, diff-only moves, refusals)
D8reset with dirty files(a) refuse unless --force (sgit default); (b) carry over silently like Git(a) for files the move would overwrite, carry the rest. Same rule as pull

This page as markdown · its source, admin/content/research/git-parity-design-bisect.md