git-bisectlib 0.16.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_bisectlib-0.16.1/LICENSE +21 -0
- git_bisectlib-0.16.1/PKG-INFO +386 -0
- git_bisectlib-0.16.1/README.md +369 -0
- git_bisectlib-0.16.1/bisectlib/__init__.py +1601 -0
- git_bisectlib-0.16.1/bisectlib/_report.py +701 -0
- git_bisectlib-0.16.1/bisectlib/py.typed +0 -0
- git_bisectlib-0.16.1/git_bisectlib.egg-info/PKG-INFO +386 -0
- git_bisectlib-0.16.1/git_bisectlib.egg-info/SOURCES.txt +12 -0
- git_bisectlib-0.16.1/git_bisectlib.egg-info/dependency_links.txt +1 -0
- git_bisectlib-0.16.1/git_bisectlib.egg-info/top_level.txt +1 -0
- git_bisectlib-0.16.1/pyproject.toml +28 -0
- git_bisectlib-0.16.1/setup.cfg +4 -0
- git_bisectlib-0.16.1/tests/test_bisectlib.py +985 -0
- git_bisectlib-0.16.1/tests/test_report.py +376 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Martin Leitner-Ankerl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git_bisectlib
|
|
3
|
+
Version: 0.16.1
|
|
4
|
+
Summary: Write tiny git bisect recipes in Python; watch progress live in .bisect/status.md.
|
|
5
|
+
Author-email: Martin Leitner-Ankerl <martin.ankerl@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/martinus/git_bisectlib
|
|
8
|
+
Keywords: git,bisect,regression,testing,ci
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Software Development :: Testing
|
|
12
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# bisectlib
|
|
19
|
+
|
|
20
|
+
**Reliable `git bisect run` recipes in a few lines of Python.**
|
|
21
|
+
|
|
22
|
+
`git bisect` finds the commit that introduced a bug by binary-searching your history, and
|
|
23
|
+
`git bisect run` automates it — *if* your test script is perfect. But the script is exactly
|
|
24
|
+
where bisects quietly go wrong:
|
|
25
|
+
|
|
26
|
+
- A commit **doesn't build** → your script exits non-zero → git records it as **bad** → the
|
|
27
|
+
search converges on the wrong commit and never tells you.
|
|
28
|
+
- A **flaky test** fails once → same story: an innocent commit takes the blame.
|
|
29
|
+
- A **performance** regression has no exit code to give — so you hand-roll timing math every
|
|
30
|
+
time.
|
|
31
|
+
|
|
32
|
+
bisectlib is the recipe you *meant* to write. It knows the difference between "this commit
|
|
33
|
+
is **bad**" and "I **couldn't test** this commit," retries flaky tests until the verdict is
|
|
34
|
+
real, judges benchmarks, patches un-buildable commits on the fly — and streams a live report
|
|
35
|
+
you can watch.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
# recipe.py
|
|
39
|
+
from bisectlib import run, test
|
|
40
|
+
|
|
41
|
+
run("cmake -B build") # broken build? ABORT — don't guess
|
|
42
|
+
run("cmake --build build -j")
|
|
43
|
+
test("ctest -R foo", attempts=5, min_passes=2) # flaky? 2 of up to 5 passes = good
|
|
44
|
+
# fell off the end → GOOD
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
git bisect start <BAD> <GOOD>
|
|
49
|
+
git bisect run python recipe.py
|
|
50
|
+
git bisect reset # done — return to your branch
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That's the whole thing — three git commands you already half-know, plus a recipe.
|
|
54
|
+
**Pure standard library, no dependencies** — just `git` on your `PATH`.
|
|
55
|
+
|
|
56
|
+
> **Tip:** a recipe is a plain script, so run `python recipe.py` on your current
|
|
57
|
+
> checkout *before* starting — exit `0` means "good", non-zero tells you it works — a
|
|
58
|
+
> five-second smoke test that catches a broken recipe before git spends an hour on it.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Why not just a `git bisect run` shell script?
|
|
63
|
+
|
|
64
|
+
Because the naive script silently gives wrong answers, and the careful one is a pile of
|
|
65
|
+
plumbing you rewrite on every hard bisect. bisectlib is that plumbing, done once and done
|
|
66
|
+
right:
|
|
67
|
+
|
|
68
|
+
| A hand-rolled `git bisect run` script | bisectlib |
|
|
69
|
+
|---|---|
|
|
70
|
+
| A broken build exits non-zero → git reads it as **bad** → **silent mis-bisect** | `run()` **aborts** on build failure — bisect state is kept, you fix the recipe and resume |
|
|
71
|
+
| One flaky failure blames the wrong commit | `test(attempts=5, min_passes=2)` — a quorum that stops the moment the verdict is decided |
|
|
72
|
+
| Benchmarks need custom timing + threshold code | `passed=lambda r: r.seconds < 6.7` — any aggregate (min/median/all) via the quorum |
|
|
73
|
+
| Un-buildable ranges need manual patching each run | `fixup(patch=…)` / `replace(...)` apply a fix, then **auto-revert** to keep the tree clean |
|
|
74
|
+
| Progress is a wall of scrolling output | a live `.bisect/status.md` — watch the range funnel down to the culprit |
|
|
75
|
+
|
|
76
|
+
The headline is the first row: **a broken build is not a bad commit.** Treating the two the
|
|
77
|
+
same is the classic way a `git bisect run` script lands on the wrong answer without a single
|
|
78
|
+
error message. bisectlib makes that distinction the default.
|
|
79
|
+
|
|
80
|
+
## The four things it gets right
|
|
81
|
+
|
|
82
|
+
**1. Infrastructure vs. verdict.** `run()` is for configure/build/setup — if it fails, your
|
|
83
|
+
*harness* is probably broken, so it **aborts** the whole bisect (git keeps its state) rather
|
|
84
|
+
than mis-marking the commit. `test()` is the actual verdict: pass → good, fail → bad. For a
|
|
85
|
+
genuinely un-buildable stretch, opt into skipping with `run(..., skip_on_error=True)`.
|
|
86
|
+
|
|
87
|
+
**2. Flaky tests, both directions.** To *tolerate* a flake, run a quorum — `attempts` is the
|
|
88
|
+
*max* tries, `min_passes` how many must pass; it stops as soon as the outcome is locked in.
|
|
89
|
+
To *hunt* a rare flake, do the opposite: hammer the test and fail on the first bad run.
|
|
90
|
+
`for_seconds` gives a wall-clock budget instead of a fixed count, and `parallel` runs several
|
|
91
|
+
copies at once for throughput:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
test("./integration", attempts=5, min_passes=2) # tolerate: 2 of up to 5 passes = good
|
|
95
|
+
hammer("./flaky") # hunt: all cores for a minute, any fail = bad
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**3. Benchmarks are just a time-aware predicate.** `passed` receives the `Result` (which
|
|
99
|
+
carries `.seconds`), and the quorum count expresses any aggregate:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
test("./bench", attempts=5, min_passes=1, passed=lambda r: r.seconds < 6.7) # min of 5 < 6.7s
|
|
103
|
+
test("./bench", attempts=5, passed=lambda r: r.seconds < 6.7) # all 5 < 6.7s
|
|
104
|
+
test("./bench", attempts=5, min_passes=3, passed=lambda r: r.seconds < 6.7) # median < 6.7s
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**4. Per-range build fixes that clean up after themselves.** Old commits often need a small
|
|
108
|
+
patch to compile with today's toolchain. Apply one for the commits that need it — it reverts
|
|
109
|
+
automatically so `git bisect` can move to the next commit:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
with fixup("fixes/missing-header.patch", when=in_range("abc123..def456")):
|
|
113
|
+
run("cmake -B build")
|
|
114
|
+
run("cmake --build build -j")
|
|
115
|
+
test("ctest -R foo")
|
|
116
|
+
|
|
117
|
+
replace("CMakeLists.txt", "c++14", "c++17") # sed-like edit, also auto-reverted
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
You can also **decide straight from Python** after measuring something — no need to shell
|
|
121
|
+
back out just to compare a value:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from bisectlib import check, bad
|
|
125
|
+
|
|
126
|
+
size = int(check("stat -c%s build/app").out)
|
|
127
|
+
if size > 5 * 1024 * 1024:
|
|
128
|
+
bad("binary too big") # exit 1; reaching the end instead is good
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Watch it work: `.bisect/status.md`
|
|
132
|
+
|
|
133
|
+
As the recipe runs, bisectlib writes a live Markdown report to **`.bisect/status.md`** at
|
|
134
|
+
the root of the repo you're bisecting. Open it in your editor and leave it open — it's a
|
|
135
|
+
fixed path that updates in place, so you watch the range narrow and see what's building
|
|
136
|
+
*right now* without babysitting a terminal.
|
|
137
|
+
|
|
138
|
+
````markdown
|
|
139
|
+
# Bisect report
|
|
140
|
+
**original range:** good `2801e9572` · bad `79cb050c2`
|
|
141
|
+
|
|
142
|
+
## 🎯 First bad commit: `5c9dcafb3` — commit 8: change subsystem 8
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
commit 5c9dcafb3a1e2f00d4c8b9a7e6f5d4c3b2a10987
|
|
146
|
+
Author: Eve <eve@example.com>
|
|
147
|
+
Date: 2026-06-15 11:40:00 +0200
|
|
148
|
+
|
|
149
|
+
commit 8: change subsystem 8
|
|
150
|
+
|
|
151
|
+
src/subsystem8.c | 12 ++++++------
|
|
152
|
+
1 file changed, 6 insertions(+), 6 deletions(-)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
| good | bad | midpoint | range | status |
|
|
156
|
+
|------|-----|----------|-------|--------|
|
|
157
|
+
| `2801e9572` …, Bob | `79cb050c2` …, Alice | `cb5394973` …, Carol | 27d 15h · 11 commits | 🟢 good |
|
|
158
|
+
| `cb5394973` …, Carol | `79cb050c2` …, Alice | `95345541b` …, Dan | 12d 7h · 6 commits | 🔴 bad · 81.2s |
|
|
159
|
+
| `cb5394973` …, Carol | `95345541b` …, Dan | `5c9dcafb3` …, Eve | 6d 3h · 3 commits | 🔴 bad |
|
|
160
|
+
| `cb5394973` …, Carol | `5c9dcafb3` …, Eve | `19d89b121` …, Fay | 3d 5h · 2 commits | 🟢 good |
|
|
161
|
+
````
|
|
162
|
+
|
|
163
|
+
Each row reads in causal order — the **input range** (`good`/`bad`) → the **midpoint** git
|
|
164
|
+
chose → the **result** — so you see the range funnel down as you scan. The report is
|
|
165
|
+
re-rendered the moment each command *starts*, links every step to its live-streamed log
|
|
166
|
+
under `.bisect/<sha>/`, and — when the search resolves — shows the culprit the way
|
|
167
|
+
`git bisect` does, with the full commit and diffstat. When it's done, you have the answer
|
|
168
|
+
without another `git show` — then `git bisect reset` puts you back on the branch you
|
|
169
|
+
started from.
|
|
170
|
+
|
|
171
|
+
> `.bisect/` carries its own `.gitignore` of `*`, so it ignores itself entirely — it stays
|
|
172
|
+
> out of `git status`, is never committed, and survives the checkouts git does between
|
|
173
|
+
> commits, without touching your project's tracked `.gitignore` or `.git/`. Point it
|
|
174
|
+
> elsewhere with `configure(logs="…", status_md="…")` — a relocated dir gets the same
|
|
175
|
+
> `.gitignore`.
|
|
176
|
+
|
|
177
|
+
## Install
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
pip install git_bisectlib
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
That gives you `import bisectlib` for recipes anywhere (the distribution is
|
|
184
|
+
`git_bisectlib`, the import is `bisectlib` — like `pyyaml`/`yaml`). It ships a `py.typed`
|
|
185
|
+
marker, so editors and type-checkers resolve `run`, `test`, … with no warnings.
|
|
186
|
+
|
|
187
|
+
Prefer the bleeding edge? Install straight from `main`:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
pip install --force-reinstall git+https://github.com/martinus/git_bisectlib
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Zero-install alternative:** running `python recipe.py` puts the recipe's own directory on
|
|
194
|
+
`sys.path`, so just dropping the `bisectlib/` package folder next to `recipe.py` is enough —
|
|
195
|
+
no install, no `PYTHONPATH`. Keep that copy **untracked** in the repo you're bisecting so it
|
|
196
|
+
survives every checkout (commit it and it would vanish on older commits and break the
|
|
197
|
+
import mid-bisect).
|
|
198
|
+
|
|
199
|
+
Requires **Python 3.10+**. No third-party dependencies.
|
|
200
|
+
|
|
201
|
+
## API cheat sheet
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from bisectlib import (run, test, hammer, check, once,
|
|
205
|
+
good, bad, skip, abort, replace, fixup, in_range, touches)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
| Verb | Meaning | On failure |
|
|
209
|
+
|------|---------|------------|
|
|
210
|
+
| `run(cmd, skip_on_error=False)` | infrastructure (configure/build/setup) | **abort** (or skip) |
|
|
211
|
+
| `test(cmd, attempts=1, min_passes=None, passed=None, warmup=0, bad_when="fail")` | the verdict | **bad** |
|
|
212
|
+
| `hammer(cmd, for_seconds=60, parallel=<all cores>, passed=None, bad_when="fail")` | hunt a rare flake: run till one fails | **bad** |
|
|
213
|
+
| `check(cmd) -> Result` | run once, **never exits** — introspect `.ok`, `.out`, `.seconds` | — |
|
|
214
|
+
| `good()` / `bad()` / `skip()` / `abort()` | decide the commit **directly from Python** | — |
|
|
215
|
+
|
|
216
|
+
Every verb takes `cwd=` (relative paths resolve against the repo root; `configure(cwd=…)`
|
|
217
|
+
sets a default) and `timeout=` seconds. When a step exceeds `timeout`, `on_timeout` decides
|
|
218
|
+
the outcome — `run` defaults to `abort`, `test` to `skip`.
|
|
219
|
+
|
|
220
|
+
> **Hunting a hang?** If the regression is that a command stops terminating, set
|
|
221
|
+
> `test("./app", timeout=30, on_timeout="bad")` — a commit that runs forever is the bug,
|
|
222
|
+
> so time-out means bad. (The default `on_timeout="skip"` would route *around* every bad
|
|
223
|
+
> commit and stall the bisect.)
|
|
224
|
+
|
|
225
|
+
- **`hammer(cmd, for_seconds=…, parallel=…)`** — hunt a rare flake: run the command up to
|
|
226
|
+
`parallel` at a time (default: **all cores**) for a wall-clock budget (default: **60s**),
|
|
227
|
+
**bad on the first failing run**, good if the budget elapses clean. `passed=`/`bad_when=`
|
|
228
|
+
still define what a failing run is. The report shows total runs, threads used, and runtime.
|
|
229
|
+
- **`once(key="setup")`** — run one-time, commit-independent setup (fetch a dependency,
|
|
230
|
+
create a symlink) exactly once across the whole bisect instead of on every commit.
|
|
231
|
+
- **`replace(path, old, new)`** — sed-like edit, auto-reverted. `old` is a literal `str` or a
|
|
232
|
+
compiled `re.Pattern` (the type decides — no `regex=` flag).
|
|
233
|
+
- **`fixup(patch=… | cherry_pick=…, when=…)`** — apply a patch/cherry-pick for a block, then
|
|
234
|
+
revert.
|
|
235
|
+
- **`in_range("v1.0..v2.0")`, `touches("src/x.c")`** — predicates for `when=`, also usable in
|
|
236
|
+
a plain `if`.
|
|
237
|
+
- **`sha()`, `subject()`, `is_clean()`** — read HEAD's full sha, its commit subject, and
|
|
238
|
+
whether the tree is clean; handy inside a `when=` predicate or before a custom
|
|
239
|
+
`good()`/`bad()` decision.
|
|
240
|
+
- **`configure(clean="reset"|"clean", color=…, cwd=…, logs=…, status_md=…)`** — tune tree
|
|
241
|
+
cleanup (`"clean"` adds `git clean -fdx`, keeping `.bisect/`), force color on/off, or
|
|
242
|
+
relocate the report and logs.
|
|
243
|
+
|
|
244
|
+
Mistyped a string option (`bad_when="Pass"`, `on_timeout="abrot"`) or an impossible
|
|
245
|
+
`min_passes`? The verb raises immediately — which **aborts** the bisect with a clear
|
|
246
|
+
message rather than silently defaulting and quietly bisecting in the wrong direction. The
|
|
247
|
+
fixed-choice options (`bad_when`, `on_timeout`, `if_missing`, `clean`) are typed with
|
|
248
|
+
`Literal`, so your editor autocompletes the valid values and a type-checker flags a typo
|
|
249
|
+
before you even run — no enum import, you still just write `on_timeout="bad"`.
|
|
250
|
+
|
|
251
|
+
### The exit-code contract
|
|
252
|
+
|
|
253
|
+
bisectlib maps outcomes to the exit codes `git bisect run` understands:
|
|
254
|
+
|
|
255
|
+
| Outcome | Exit | Meaning |
|
|
256
|
+
|---------|------|---------|
|
|
257
|
+
| good | `0` | bug absent |
|
|
258
|
+
| bad | `1` | bug present |
|
|
259
|
+
| skip | `125` | commit untestable — route around it |
|
|
260
|
+
| abort | `128` | harness broken — **bisect state preserved**, fix the recipe and re-run |
|
|
261
|
+
|
|
262
|
+
An uncaught exception in a recipe **aborts** (128) — it is never misread as "bad."
|
|
263
|
+
|
|
264
|
+
### Abort → fix the recipe → resume
|
|
265
|
+
|
|
266
|
+
Abort is the *"my harness is wrong"* signal, and it's built to recover from. git keeps the
|
|
267
|
+
whole bisect state with the failing commit checked out, so you fix the recipe and **re-run
|
|
268
|
+
the same command** — do *not* `git bisect start` again (that resets):
|
|
269
|
+
|
|
270
|
+
```sh
|
|
271
|
+
git bisect run python recipe.py # aborts on a broken recipe → state kept
|
|
272
|
+
# … edit recipe.py: add a fixup, set skip_on_error=True, fix a typo …
|
|
273
|
+
git bisect run python recipe.py # SAME command → re-tests the current commit and continues
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
If it was really just *this one commit* being untestable, `git bisect skip` and carry on.
|
|
277
|
+
|
|
278
|
+
## Don't know a good commit yet? Let the recipe guide you
|
|
279
|
+
|
|
280
|
+
`git bisect` needs *two* endpoints — a bad commit **and** a good one. You almost always have
|
|
281
|
+
the bad one (HEAD, where you hit the bug); the good one you have to go find. bisectlib turns
|
|
282
|
+
that hunt into a guided loop: run the **same recipe** by hand and it tells you exactly what
|
|
283
|
+
to do next.
|
|
284
|
+
|
|
285
|
+
```sh
|
|
286
|
+
git bisect start
|
|
287
|
+
git bisect bad # HEAD has the bug
|
|
288
|
+
python recipe.py # ← run the recipe yourself
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Because HEAD is already known-bad, the recipe **doesn't waste time re-testing it** — it
|
|
292
|
+
points you at older commits instead, spaced by a **widening time schedule** (1 day, 3 days, 1
|
|
293
|
+
week, 2 weeks, 1 month, 2 months back) so a handful of probes span a couple of months of
|
|
294
|
+
history. Each is shown `git log`-style — short sha, date, how long ago, subject, author — so
|
|
295
|
+
you can eyeball where to jump; copy a sha into `git checkout`:
|
|
296
|
+
|
|
297
|
+
```text
|
|
298
|
+
━━━ already marked bad — skipping ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
299
|
+
● HEAD (5fa8b7503) is already marked BAD — nothing to test.
|
|
300
|
+
To find a GOOD commit, git checkout an older one and run it there:
|
|
301
|
+
ec0acd2 2026-07-05 1 day ago fix cache eviction <Martin Leitner-Ankerl>
|
|
302
|
+
7fb9e4a 2026-07-03 3 days ago refactor loader <Martin Leitner-Ankerl>
|
|
303
|
+
356a26b 2026-06-29 1 week ago bump deps <Martin Leitner-Ankerl>
|
|
304
|
+
c196853 2026-06-22 2 weeks ago tune scheduler <Martin Leitner-Ankerl>
|
|
305
|
+
85fb7c9 2026-06-06 4 weeks ago add spec + bisectlog <Martin Leitner-Ankerl>
|
|
306
|
+
a59303d 2026-05-07 2 months ago rework parser <Martin Leitner-Ankerl>
|
|
307
|
+
|
|
308
|
+
python recipe.py # run again after checking out
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Check out one and run again — pick a far one to cover ground fast, or a nearer one to tread
|
|
312
|
+
carefully. If the bug is **still there**, the recipe says so and offers a fresh batch of even
|
|
313
|
+
older candidates — mark it `git bisect bad` and keep going. The moment you land on a commit
|
|
314
|
+
where the bug is **gone**, it hands the search back to git:
|
|
315
|
+
|
|
316
|
+
```text
|
|
317
|
+
━━━ found a good commit ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
318
|
+
✓ GOOD — the bug is ABSENT here (948739999).
|
|
319
|
+
You found the good end of the range. Let git bisect take over:
|
|
320
|
+
|
|
321
|
+
git bisect good
|
|
322
|
+
git bisect run python recipe.py
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
From here it's the normal automated bisect. A few things worth knowing:
|
|
326
|
+
|
|
327
|
+
- **It's the same recipe, unchanged.** Guidance activates *only* while a bisect is started
|
|
328
|
+
but has no good commit yet. During the real `git bisect run` (both endpoints known) and
|
|
329
|
+
during a pre-start smoke test (no bisect at all) it stays completely silent.
|
|
330
|
+
- **A candidate that won't build is your call.** Old commits often don't build (toolchain
|
|
331
|
+
drift), and that's neither good nor bad. Rather than guess, the recipe lays out the
|
|
332
|
+
directions and lets **you** choose — jump **older** past the broken range, or come back
|
|
333
|
+
**newer** toward code that builds — and reminds you that if it's the *recipe* that's broken
|
|
334
|
+
you can fix it or set `run(…, skip_on_error=True)`:
|
|
335
|
+
|
|
336
|
+
```text
|
|
337
|
+
━━━ can't build this commit — you decide where to go ━━━━━━━━━━━━━━━━━━━━
|
|
338
|
+
⚠ `cmake --build build` failed at 2addbb8b — this commit won't build.
|
|
339
|
+
An unbuildable commit is neither good nor bad; nothing was
|
|
340
|
+
recorded. git checkout a commit and re-run — your call which way:
|
|
341
|
+
|
|
342
|
+
OLDER — jump past the break (often toolchain drift):
|
|
343
|
+
378ac9e 2026-05-01 9 weeks ago fix cache eviction <Martin Leitner-Ankerl>
|
|
344
|
+
50fa29e 2026-04-25 2 months ago tune scheduler <Martin Leitner-Ankerl>
|
|
345
|
+
NEWER — come back toward code that builds:
|
|
346
|
+
1785673 2026-07-02 4 days ago refactor loader <Martin Leitner-Ankerl>
|
|
347
|
+
9c2d4f1 2026-06-28 1 week ago bump deps <Martin Leitner-Ankerl>
|
|
348
|
+
a30b6ee 2026-06-14 3 weeks ago tune scheduler <Martin Leitner-Ankerl>
|
|
349
|
+
|
|
350
|
+
python recipe.py # run again after checking out
|
|
351
|
+
```
|
|
352
|
+
- **`--force`** re-evaluates the current commit even when it's already marked bad:
|
|
353
|
+
`python recipe.py --force`.
|
|
354
|
+
|
|
355
|
+
## Examples
|
|
356
|
+
|
|
357
|
+
Runnable recipes in [`examples/`](examples/):
|
|
358
|
+
|
|
359
|
+
| File | Shows |
|
|
360
|
+
|------|-------|
|
|
361
|
+
| `minimal.py` | the simplest recipe: build + test |
|
|
362
|
+
| `flaky_with_fixup.py` | a flaky test (`attempts`/`min_passes`) plus a per-range patch `fixup` |
|
|
363
|
+
| `flaky_hunt.py` | hunt a *rare* flake: hammer a test `parallel`-wide for `for_seconds`, any fail = bad |
|
|
364
|
+
| `perf_regression.py` | a benchmark verdict via a time-aware `passed` predicate + `replace` |
|
|
365
|
+
| `find_when_fixed.py` | `bad_when="pass"` — find when something started *working* |
|
|
366
|
+
| `bisect_on_output.py` | bisect on output *content* (when a warning first appeared) |
|
|
367
|
+
| `metric_binary_size.py` | a numeric-budget bisect (binary size crossed a threshold) |
|
|
368
|
+
| `build_fix_cherrypick.py` | keep an un-buildable range testable via `fixup(cherry_pick=…)` |
|
|
369
|
+
|
|
370
|
+
## The mental model
|
|
371
|
+
|
|
372
|
+
A recipe is a normal script that `git bisect run` executes once per commit:
|
|
373
|
+
|
|
374
|
+
- A **passing step continues** to the next line.
|
|
375
|
+
- A failing **`test()` is bad**; a failing **`run()` aborts** (or skips).
|
|
376
|
+
- **Falling off the end is good.**
|
|
377
|
+
|
|
378
|
+
Because passing steps continue, listing several `test()` calls just **ANDs** them — every one
|
|
379
|
+
must pass for the commit to count as good. That's the entire thing to remember.
|
|
380
|
+
|
|
381
|
+
See [`SPEC.md`](SPEC.md) for the full design rationale, and run the tests with
|
|
382
|
+
`python -m unittest discover -s tests -v`.
|
|
383
|
+
|
|
384
|
+
## License
|
|
385
|
+
|
|
386
|
+
MIT © Martin Leitner-Ankerl
|