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
| Flag | Type | Default | Meaning |
|---|---|---|---|
-r, --range REVSETS | revsets, repeatable, required | — | The revisions to search; several are unioned. Heads are assumed bad, parents of the range's roots good (source). |
COMMAND (positional) | string | required | The program run for each revision, with $JJ_BISECT_TARGET set to the commit ID (source). |
ARGS (positional) | strings | none | Its arguments; put -- first for ones starting with - (source). |
--find-good | bool | false | Look for the first good revision instead. Exit status 0 still means good (source). |
--trust-endpoints | bool | false | Skip the pre-bisection checks of the heads and the roots' parents (source). |
--command CMD | command | hidden | Deprecated form of COMMAND; prints a warning (source, warning). |
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
4. Core architecture touchpoints
| Structure | Read / write | How | Explained on |
|---|---|---|---|
| Revsets | evaluated | The range, its heads, the connected roots' parents, and each step's candidates and bisect() pick (source). | revsets |
| Working-copy commit | created per step | A 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 disk | written per step | Updated to each revision's tree so the command can test it. | working-copy |
| Operation log | one operation per evaluation | Updated to revision <id> for bisection, plus whatever the command itself does. | operations |
Bisector state | in memory | Good, 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 bisectionbefore 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
| Message | When | Source |
|---|---|---|
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 failed | An endpoint check disagreed. | run.rs, error |
Evaluation command returned 127 (command not found) - aborting bisection. then Bisection aborted | The 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 command | The 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:
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.