jj / How jj bisect run works

How jj bisect run works

jj bisect run binary-searches a range of revisions for the first "bad" one. For each candidate it checks the revision out in your own working copy, runs your command there, and reads the exit status: 0 is good, 125 is skip, 127 aborts, anything else is bad.

On this page
  1. 1. Command-line surface
  2. 2. Configuration
  3. 3. End-to-end flow
  4. 4. Core architecture touchpoints
  5. 5. Transaction and operation
  6. 6. Algorithm and invariants
  7. Setting up
  8. Each step
  9. The result
  10. 7. Errors and edge cases
  11. 8. Worked example

In short: jj resolves --range and assumes its heads are bad and the parents of its connected roots are good. Unless --trust-endpoints is given, it first runs the command on those endpoints to confirm. Then, until no candidates remain, it picks the middle of the commits between the latest good and the earliest bad ones. It checks that commit out, which records an operation and puts a new empty working-copy commit on top, and runs the command. The commits found are reported as the first bad ones, and the operation to restore to undo the search is printed. --find-good inverts the meaning of the result.

The command is cmd_bisect_run, the only subcommand of jj bisect (source). The search itself is jj-lib's Bisector, a small state machine the CLI drives one step at a time. The help text's example runs a fix from another revision on top of each candidate: jj bisect run --range v1.0..main -- bash -c "jj duplicate xyz -B @ && cargo test" (source).

1. Command-line surface

FlagTypeDefaultMeaning
-r, --range REVSETSrevsets, repeatable, required—The revisions to search; several are unioned. Heads are assumed bad, parents of the range's roots good (source).
COMMAND (positional)stringrequiredThe program run for each revision, with $JJ_BISECT_TARGET set to the commit ID (source).
ARGS (positional)stringsnoneIts arguments; put -- first for ones starting with - (source).
--find-goodboolfalseLook for the first good revision instead. Exit status 0 still means good (source).
--trust-endpointsboolfalseSkip the pre-bisection checks of the heads and the roots' parents (source).
--command CMDcommandhiddenDeprecated form of COMMAND; prints a warning (source, warning).

BisectRunArgs

2. Configuration

No bisect-specific keys. The commit summaries printed at each step use the usual commit-summary template (templates.commit_summary). Checking out each revision follows the same rules as jj new, so snapshot.* settings apply when the workspace is reloaded between steps. The command receives two environment variables: JJ_BISECT_TARGET, the full commit ID, and JJ_EXECUTABLE_PATH, the running jj binary (source).

3. End-to-end flow

jj bisect run: call flow cmd_bisect_run loads the workspace, requires a command, resolves the range, and remembers the starting repo. Bisector::new records the range's heads as bad and, unless --trust-endpoints is given, queues checks for the heads (bad) and the parents of the connected range (good). The loop asks next_step: a Verify step evaluates an endpoint and stops if it disagrees; an Evaluate step prints the remaining count, evaluates the middle candidate, marks it, and reloads the workspace; Done ends the loop. Each evaluation is a transaction that checks the commit out, then runs the command and maps its exit status. Finally jj prints the jj op restore command and the result. load workspace; require COMMAND; resolve --range; remember the starting operation Bisector::new: bad = heads(range) queue checks unless --trust-endpoints: heads must be bad, parents(connected(range)) ~ connected(range) good loop: bisector.next_step() Verify(commit, expected) "Pre-bisection check: …" evaluate; mismatch → stop, "Cannot bisect: …" Evaluate(commit) "Bisecting: N revisions left …" evaluate; mark good/bad/skip/abort reload the workspace Done(result) Found, FoundDespiteSkips, Indeterminate, Abort, VerificationFailed evaluate_commit(commit) tx.check_out(commit): new empty @ on top of it; tx.finish(…) run COMMAND with JJ_BISECT_TARGET, JJ_EXECUTABLE_PATH 0 → good, 125 → skip, 127 → abort, other → bad report "jj op restore <start op>" first bad/good revision(s), or an error each evaluation records one operation: "Updated to revision <id> for bisection"

4. Core architecture touchpoints

StructureRead / writeHowExplained on
RevsetsevaluatedThe range, its heads, the connected roots' parents, and each step's candidates and bisect() pick (source).revsets
Working-copy commitcreated per stepA new empty commit on top of each evaluated revision; the previous one is abandoned if it is discardable (empty, no description) and nothing else points to it (check_out, abandon rule).commits
Working copy on diskwritten per stepUpdated to each revision's tree so the command can test it.working-copy
Operation logone operation per evaluationUpdated to revision <id> for bisection, plus whatever the command itself does.operations
Bisector statein memoryGood, bad and skipped commit sets, the abort flag, and the queued endpoint checks. Nothing is persisted (source).—

5. Transaction and operation

  • One transaction per evaluated revision, including each pre-bisection check. It checks the revision out and is finished as Updated to revision <full hex id> for bisection before the command runs (source).
  • The command may run jj itself. After every Evaluate step the workspace is reloaded, because "the evaluation command may run `jj` commands" (source).
  • Undoing the search: the final output names the operation that was current at the start; jj op restore <id> discards every revision created during the search (source).
  • Afterwards the working copy is left on top of the last evaluated revision, not on the result.

6. Algorithm and invariants

Setting up

Bisector::new records heads(range) as bad. The good set starts empty. The endpoints to verify are heads(range), expected bad, and parents(connected(range)) ~ connected(range), expected good. They are popped from the end of a list, so the heads are checked first. A passing check isn't recorded; it only lets the search continue (source, CLI).

Each step

The candidates are range & (heads(good)..roots(bad)) ~ bad ~ skipped (source). The next revision is latest(bisect(candidates), 1), where bisect() in the default index takes the middle element of the candidate list in index order. Upstream notes this is "somewhat poor" for non-linear history (engine, docs). The "N revisions left" count printed first is the size of that candidate set, which still includes the revision about to be tested, and the step estimate is ceil(log2(N + 1)) (source).

The command's exit status is mapped to an Evaluation: 0 is good, 125 skip, 127 abort, anything else bad, including a signal (source). With --find-good the result is inverted before it's recorded, so the bisector always looks for "bad" (source, invert).

The result

When no candidates remain, next_step takes roots(bad). None means Indeterminate. Otherwise it walks parents from those roots through skipped commits: any found are "possibly bad", giving FoundDespiteSkips; else Found. Several roots are possible when the range has several disjoint heads (source).

Invariants: a commit is marked at most once (asserted). Bad is assumed to propagate to descendants within the range. Only the working-copy commit and operation log change; the range itself is never rewritten by jj.

7. Errors and edge cases

MessageWhenSource
Command argument is required (exit status 2)No COMMAND and no legacy --command.run.rs
Cannot bisect: this revision was expected to be <good|bad>, but was … (exit status: N) instead. then Bisection preconditions failedAn endpoint check disagreed.run.rs, error
Evaluation command returned 127 (command not found) - aborting bisection. then Bisection abortedThe command exited 127.run.rs, error
Could not find the first <bad|good> revision. Was the input range empty?No bad commit at the end, e.g. --range=none().run.rs
The first bad revisions are: … These revisions may also be bad, but couldn't be evaluated:Skipped commits sit between the last good and the first bad.run.rs
Failed to run evaluation commandThe program couldn't be started.run.rs
Warning: `--command` is deprecated; …The hidden legacy flag was used.run.rs

8. Worked example

From upstream's test_bisect_run. A linear history a–f, each adding one file, and a command that always fails, run as jj bisect run --range=.. --trust-endpoints <cmd>. f is the head and so is assumed bad; the candidates are a–e:

jj bisect run over a to f with an always-failing command Linear history a, b, c, d, e, f. f is the head, assumed bad. Step 1: five candidates a to e; the middle one, c, is evaluated and is bad. Step 2: candidates a and b; a is evaluated and is bad. No candidates remain, so the first bad revision is a. The working copy ends as a new empty commit on top of a. a 2: bad b c 1: bad d e f head: bad step 1: candidates a b c d e (5) middle → c; command fails → bad step 2: candidates a b (2) middle → a; command fails → bad no candidates left roots(bad) = a; no skipped parents The first bad revision is: a @ = new empty commit on top of a

Upstream's snapshot of standard output (source):

Bisecting: 5 revisions left to test after this (roughly 3 steps)
Now evaluating: royxmykx dffaa0d4 c | c
fake-bisector testing commit dffaa0d4daccf6cee70bac3498fae3b3fd5d6b5b
The revision is bad.

Bisecting: 2 revisions left to test after this (roughly 2 steps)
Now evaluating: rlvkpnrz 7d980be7 a | a
fake-bisector testing commit 7d980be7a1d499e4d316ab4c01242885032f7eaf
The revision is bad.

Search complete. To discard any revisions created during search, run:
  jj op restore e953363a3d33
The first bad revision is: rlvkpnrz 7d980be7 a | a

On standard error, each step prints Working copy (@) now at: … (empty) (no description set) with the evaluated revision as its parent: the revision is checked out with a new commit on top, not edited. The final log shows one leftover empty commit on a, because the one on c was abandoned when @ moved. In test_bisect_run_skip, where every evaluation exits 125, the result is The first bad revisions are: b followed by These revisions may also be bad, but couldn't be evaluated: a.