git-bisectlib 0.16.1__py3-none-any.whl

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.
bisectlib/__init__.py ADDED
@@ -0,0 +1,1601 @@
1
+ #!/usr/bin/env python3
2
+ """bisectlib - write tiny `git bisect run` recipes in Python.
3
+
4
+ A recipe is a one-shot script. `git bisect run python recipe.py` spawns a fresh
5
+ process per commit, so there is exactly one session per process: no ctx object,
6
+ no decorator, no return value. Falling off the end of the script == GOOD.
7
+
8
+ from bisectlib import run, test
9
+
10
+ run("cmake -B build") # infra: fail -> ABORT (exit 128)
11
+ run("cmake --build build -j") # infra: fail -> ABORT
12
+ test("ctest -R foo", attempts=5, min_passes=2) # flaky verdict: 2 of up to 5
13
+ # reached the end -> GOOD (exit 0)
14
+
15
+ Exit-code contract (what `git bisect run` reads):
16
+ 0 good (bug absent)
17
+ 1 bad (bug present)
18
+ 125 skip (commit untestable)
19
+ 128 abort (harness broken; bisect state preserved -> fix & resume)
20
+
21
+ Verbs:
22
+ run(cmd, skip_on_error=False, ...) infra; ABORTS on error by default
23
+ test(cmd, attempts=1, min_passes=None,…) a verdict; pass->continue, fail->BAD.
24
+ Use several; they AND together.
25
+ hammer(cmd, for_seconds=60, ...) hunt a rare flake: run til one fails
26
+ (default: all cores for a minute)
27
+ check(cmd) -> Result runs once, NEVER exits (introspection)
28
+ once(key="setup") -> bool guard one-time setup: if once(): ...
29
+ good()/bad()/skip()/abort() decide the commit directly from Python
30
+ (e.g. after measuring with check())
31
+ replace(path, old, new, ...) sed-like edit, auto-reverted (clean tree)
32
+ fixup(patch=/cherry_pick=, when=) apply a patch/cherry-pick, auto-reverted
33
+
34
+ Guided mode (automatic): while a bisect is started with a bad commit but no good
35
+ one yet, running `python recipe.py` by hand prints the copy-pasteable next step —
36
+ older candidate commits to try (spaced by a widening time schedule from a day out
37
+ to two months) until you find the good anchor, then the `git bisect run` hand-off. A
38
+ commit that won't build offers a direction choice rather than a verdict. Silent
39
+ during a real `git bisect run`.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import atexit
44
+ import hashlib
45
+ import json
46
+ import os
47
+ import re
48
+ import shlex
49
+ import shutil
50
+ import signal
51
+ import subprocess
52
+ import sys
53
+ import threading
54
+ import time
55
+ from contextlib import contextmanager
56
+ from dataclasses import dataclass
57
+ from datetime import datetime, timezone
58
+ from pathlib import Path
59
+ from typing import Callable, Literal, NoReturn, Optional, Union
60
+
61
+ # Small closed sets of string options, typed so editors autocomplete the choices
62
+ # and type-checkers reject a typo *before* the recipe runs (the runtime `_one_of`
63
+ # check still guards callers without a type-checker). Chosen over an Enum to keep
64
+ # recipes terse — `on_timeout="bad"`, no import, no `OnTimeout.BAD` ceremony.
65
+ _BadWhen = Literal["fail", "pass"]
66
+ _OnTimeout = Literal["abort", "skip", "bad"]
67
+
68
+ __version__ = "0.16.1"
69
+
70
+ __all__ = [
71
+ "run", "test", "hammer", "check", # the verbs
72
+ "once", # guard one-time setup (keyed)
73
+ "good", "bad", "skip", "abort", # verdict primitives
74
+ "replace", "fixup", # tree edits (auto-reverted)
75
+ "in_range", "touches", "sha", "subject", "is_clean", # git helpers
76
+ "configure", "Result",
77
+ "GOOD", "BAD", "SKIP", "ABORT",
78
+ ]
79
+
80
+ # exit codes / outcomes -------------------------------------------------------
81
+ GOOD, BAD, SKIP, ABORT = 0, 1, 125, 128
82
+ _OUTCOME_NAME = {GOOD: "good", BAD: "bad", SKIP: "skip", ABORT: "abort"}
83
+
84
+
85
+ # ----------------------------------------------------------------- configuration
86
+ @dataclass
87
+ class _Config:
88
+ status_md: Optional[str] = None # default: <repo>/.bisect/status.md
89
+ logs: Optional[str] = None # default: <repo>/.bisect/
90
+ clean: str = "reset" # "reset" | "clean"
91
+ color: Optional[bool] = None # None=auto
92
+ cwd: Optional[str] = None # default working dir for commands (repo root)
93
+
94
+
95
+ _cfg = _Config()
96
+ _steps: list[dict] = []
97
+ _reverts: list[Callable[[], None]] = []
98
+ _final: dict = {"outcome": "good", "code": GOOD}
99
+ _finalized = False
100
+ _once_pending: set[str] = set()
101
+
102
+
103
+ def configure(status_md=None, logs=None,
104
+ clean: Optional[Literal["reset", "clean"]] = None,
105
+ color=None, cwd=None) -> None:
106
+ if status_md is not None:
107
+ _cfg.status_md = status_md
108
+ if logs is not None:
109
+ _cfg.logs = logs
110
+ if clean is not None:
111
+ if clean not in ("reset", "clean"):
112
+ raise ValueError(
113
+ f"clean={clean!r} is not valid; expected 'reset' or 'clean'")
114
+ _cfg.clean = clean
115
+ if color is not None:
116
+ _cfg.color = color
117
+ if cwd is not None:
118
+ _cfg.cwd = cwd
119
+
120
+
121
+ def _workdir(cwd: Optional[str]) -> str:
122
+ """Resolve the working directory for a command.
123
+
124
+ Precedence: per-call ``cwd`` > global ``configure(cwd=…)`` > repo root.
125
+ A relative path is resolved against the repo root, so ``cwd="build"`` means
126
+ ``<repo>/build`` regardless of where the recipe was launched from.
127
+ """
128
+ base = cwd if cwd is not None else _cfg.cwd
129
+ if base is None:
130
+ return _toplevel()
131
+ if os.path.isabs(base):
132
+ return base
133
+ return os.path.join(_toplevel(), base)
134
+
135
+
136
+ # ------------------------------------------------------------------------- git
137
+ def _git(*args: str, check: bool = True) -> str:
138
+ p = subprocess.run(["git", *args], capture_output=True, text=True)
139
+ if check and p.returncode != 0:
140
+ raise RuntimeError(f"git {' '.join(args)}: {p.stderr.strip()}")
141
+ return p.stdout.strip()
142
+
143
+
144
+ # `git bisect run` spawns a fresh process per commit, so within one process the
145
+ # repo root, HEAD and "am I in a repo" never change — yet they're queried on
146
+ # every step and every status render (measured: dozens of `git rev-parse`
147
+ # subprocesses per commit). Memoise them for the process, keyed by cwd so the
148
+ # rare in-process caller that switches repositories still gets the right answer.
149
+ _toplevel_cache: dict[str, str] = {}
150
+ _sha_cache: dict[str, str] = {}
151
+
152
+
153
+ def _toplevel() -> str:
154
+ cwd = os.getcwd()
155
+ top = _toplevel_cache.get(cwd)
156
+ if top is None:
157
+ top = _git("rev-parse", "--show-toplevel") # raises (uncached) if not a repo
158
+ _toplevel_cache[cwd] = top
159
+ return top
160
+
161
+
162
+ def sha() -> str:
163
+ """Full sha of the commit currently being evaluated (HEAD)."""
164
+ cwd = os.getcwd()
165
+ head = _sha_cache.get(cwd)
166
+ if head is None:
167
+ head = _git("rev-parse", "HEAD")
168
+ _sha_cache[cwd] = head
169
+ return head
170
+
171
+
172
+ _bisectlog_cache: dict[str, str] = {}
173
+
174
+
175
+ def _bisect_log() -> str:
176
+ """`git bisect log`, cached for the process. git records *this* commit's mark
177
+ only after we exit, so the log is fixed for our lifetime — read it once and
178
+ feed it to every status render (and to the anchor scan) rather than re-running
179
+ it per render."""
180
+ cwd = os.getcwd()
181
+ if cwd not in _bisectlog_cache:
182
+ _bisectlog_cache[cwd] = _git("bisect", "log", check=False)
183
+ return _bisectlog_cache[cwd]
184
+
185
+
186
+ def subject() -> str:
187
+ """Commit subject of HEAD."""
188
+ return _git("show", "-s", "--format=%s", "HEAD")
189
+
190
+
191
+ def is_clean() -> bool:
192
+ """True if the working tree has no uncommitted changes."""
193
+ return _git("status", "--porcelain") == ""
194
+
195
+
196
+ class _Range:
197
+ def __init__(self, lo: str, hi: str):
198
+ self.lo, self.hi = lo, hi
199
+
200
+ def __contains__(self, rev: str) -> bool:
201
+ return _in_range(rev, self.lo, self.hi)
202
+
203
+ def __bool__(self) -> bool:
204
+ return _in_range("HEAD", self.lo, self.hi)
205
+
206
+
207
+ def _in_range(rev: str, lo: str, hi: str) -> bool:
208
+ """True if rev is a descendant of lo (or == lo) and an ancestor of hi (or == hi)."""
209
+ def anc(a, b):
210
+ return a == b or subprocess.run(
211
+ ["git", "merge-base", "--is-ancestor", a, b],
212
+ capture_output=True,
213
+ ).returncode == 0
214
+ rev = _git("rev-parse", rev)
215
+ return anc(lo, rev) and anc(rev, hi)
216
+
217
+
218
+ def in_range(spec: str, hi: Optional[str] = None):
219
+ """Predicate: is HEAD within [lo, hi]? Accepts ('lo..hi') or (lo, hi)."""
220
+ if hi is None and ".." in spec:
221
+ if "..." in spec:
222
+ raise ValueError(
223
+ f"in_range({spec!r}): use two dots 'lo..hi', not three "
224
+ f"(three-dot is git's symmetric-difference syntax, not a range)")
225
+ lo, hi = spec.split("..", 1)
226
+ else:
227
+ lo = spec
228
+ if not lo or not hi:
229
+ raise ValueError(
230
+ f"in_range({spec!r}"
231
+ + (f", {hi!r}" if hi is not None else "")
232
+ + "): need both a low and a high revision, e.g. in_range('v1.0..v2.0')")
233
+ return _Range(lo, hi)
234
+
235
+
236
+ def touches(path: str) -> bool:
237
+ """True if the HEAD commit modified `path`."""
238
+ files = _git("show", "--name-only", "--format=", "HEAD").splitlines()
239
+ return any(f == path or f.startswith(path.rstrip("/") + "/") for f in files)
240
+
241
+
242
+ # --------------------------------------------------------------------- console
243
+ def _use_color() -> bool:
244
+ if _cfg.color is not None:
245
+ return _cfg.color
246
+ return sys.stderr.isatty() and "NO_COLOR" not in os.environ
247
+
248
+
249
+ _C = {"run": "\033[36m", "test": "\033[35m", "hammer": "\033[95m",
250
+ "check": "\033[90m", "good": "\033[32m", "bad": "\033[31m",
251
+ "skip": "\033[33m", "abort": "\033[91m", "dim": "\033[2m",
252
+ "reset": "\033[0m"}
253
+
254
+
255
+ def _echo_start(verb: str, cmd: str) -> None:
256
+ short = sha()[:9] if _in_git() else "?"
257
+ if _use_color():
258
+ sys.stderr.write(f"{_C.get(verb,'')}▶ [{short}] {verb:<5}{_C['reset']} {cmd}\n")
259
+ else:
260
+ sys.stderr.write(f"> [{short}] {verb:<5} {cmd}\n")
261
+ sys.stderr.flush()
262
+
263
+
264
+ def _echo_result(verb: str, cmd: str, ok: bool, seconds: float, label: str) -> None:
265
+ color = _C.get(label, "")
266
+ mark = "✓" if ok else "✗"
267
+ if _use_color():
268
+ sys.stderr.write(f"{color}{mark} {label}{_C['reset']} "
269
+ f"{_C['dim']}({seconds:.1f}s){_C['reset']} {cmd}\n")
270
+ else:
271
+ sys.stderr.write(f"{mark} {label} ({seconds:.1f}s) {cmd}\n")
272
+ sys.stderr.flush()
273
+
274
+
275
+ _ingit_cache: dict[str, bool] = {}
276
+
277
+
278
+ def _in_git() -> bool:
279
+ cwd = os.getcwd()
280
+ inside = _ingit_cache.get(cwd)
281
+ if inside is None:
282
+ inside = subprocess.run(["git", "rev-parse", "--git-dir"],
283
+ capture_output=True).returncode == 0
284
+ _ingit_cache[cwd] = inside
285
+ return inside
286
+
287
+
288
+ # ---------------------------------------------------------------------- Result
289
+ @dataclass
290
+ class Result:
291
+ code: int
292
+ out: str
293
+ seconds: float
294
+
295
+ @property
296
+ def ok(self) -> bool:
297
+ return self.code == 0
298
+
299
+
300
+ # git exports these per-invocation while running a `git bisect run` command; if
301
+ # they leak into the recipe's commands, any `git` those commands call resolves
302
+ # against git's bisect context instead of discovering from the directory — the
303
+ # classic "works when I run it, breaks under `git bisect run`" trap. Strip them so
304
+ # commands behave exactly as in a plain shell.
305
+ _GIT_ENV_STRIP = (
306
+ "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX",
307
+ "GIT_NAMESPACE", "GIT_COMMON_DIR", "GIT_INTERNAL_GETTEXT_TEST_HARNESS",
308
+ )
309
+
310
+
311
+ def _clean_env(workdir: str) -> dict:
312
+ """Environment for spawned commands: git's per-invocation vars removed, and
313
+ PWD kept in sync with the real cwd (subprocess chdir's but leaves PWD stale)."""
314
+ env = {k: v for k, v in os.environ.items() if k not in _GIT_ENV_STRIP}
315
+ env["PWD"] = os.path.abspath(workdir)
316
+ return env
317
+
318
+
319
+ def _exec(cmd: str, timeout: Optional[float], log_path: Optional[Path],
320
+ cwd: Optional[str] = None, stream: bool = True,
321
+ env: Optional[dict] = None) -> Result:
322
+ """Run a shell command, streaming its output live while also capturing it.
323
+
324
+ Combined stdout+stderr is echoed to this process's stderr as it arrives (so
325
+ you watch the build/test run — git bisect run forwards it to your terminal),
326
+ appended line-by-line to the log file so it can be tailed/opened *while the
327
+ command runs*, and collected into the returned Result. The process group is
328
+ killed on timeout.
329
+
330
+ ``stream=False`` suppresses the live stderr echo (but still captures + logs).
331
+ Used when many runs execute concurrently, where interleaving dozens of output
332
+ streams onto one terminal would be unreadable. ``env`` may be pre-built by a
333
+ caller (hammer) to avoid rebuilding it per run.
334
+ """
335
+ start = time.monotonic()
336
+ workdir = _workdir(cwd)
337
+ if env is None:
338
+ env = _clean_env(workdir)
339
+
340
+ # Fast path — nothing to stream or log live, so skip the per-run pump thread
341
+ # and line-by-line loop and let communicate() read the combined output in one
342
+ # go. This is hammer's path: it may fire many thousands of runs, where a
343
+ # thread create/join and a Python read loop per run are pure overhead.
344
+ if not stream and log_path is None:
345
+ proc = subprocess.Popen(
346
+ cmd, shell=True, cwd=workdir, env=env,
347
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
348
+ start_new_session=True,
349
+ )
350
+ timed_out = False
351
+ try:
352
+ out, _ = proc.communicate(timeout=timeout)
353
+ except subprocess.TimeoutExpired:
354
+ timed_out = True
355
+ try:
356
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
357
+ except (ProcessLookupError, PermissionError):
358
+ pass
359
+ out, _ = proc.communicate()
360
+ code = -1 if timed_out else proc.returncode
361
+ return Result(code=code, out=out or "", seconds=time.monotonic() - start)
362
+
363
+ # Open the log up front and write to it as output arrives, so the linked log
364
+ # in status.md exists and grows live (watchable), instead of only appearing
365
+ # once the command finishes. Line-buffered so each line is flushed promptly.
366
+ log_fh = None
367
+ if log_path is not None:
368
+ try:
369
+ log_path.parent.mkdir(parents=True, exist_ok=True)
370
+ log_fh = open(log_path, "w", buffering=1)
371
+ except OSError:
372
+ log_fh = None
373
+ proc = subprocess.Popen(
374
+ cmd, shell=True, cwd=workdir, env=env,
375
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
376
+ bufsize=1, start_new_session=True,
377
+ )
378
+ captured: list[str] = []
379
+
380
+ def _pump() -> None:
381
+ for line in proc.stdout: # line-buffered; live as it arrives
382
+ captured.append(line)
383
+ if stream:
384
+ sys.stderr.write(line)
385
+ if log_fh is not None:
386
+ try:
387
+ log_fh.write(line)
388
+ except OSError:
389
+ pass
390
+ if stream:
391
+ sys.stderr.flush()
392
+
393
+ pump = threading.Thread(target=_pump, daemon=True)
394
+ pump.start()
395
+
396
+ timed_out = False
397
+ try:
398
+ proc.wait(timeout=timeout)
399
+ except subprocess.TimeoutExpired:
400
+ timed_out = True
401
+ try:
402
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
403
+ except (ProcessLookupError, PermissionError):
404
+ pass
405
+ proc.wait()
406
+ pump.join()
407
+ # Close the pipe explicitly rather than leaving it for GC: a `hammer` fires
408
+ # thousands of _exec calls, and one leaked read-fd apiece would march toward
409
+ # the process's open-file limit.
410
+ try:
411
+ if proc.stdout is not None:
412
+ proc.stdout.close()
413
+ except OSError:
414
+ pass
415
+ if log_fh is not None:
416
+ try:
417
+ log_fh.close()
418
+ except OSError:
419
+ pass
420
+
421
+ out = "".join(captured)
422
+ code = -1 if timed_out else proc.returncode # -1 == timeout sentinel
423
+ seconds = time.monotonic() - start
424
+ return Result(code=code, out=out, seconds=seconds)
425
+
426
+
427
+ # --------------------------------------------------------------- log directory
428
+ def _bisect_anchors() -> tuple[str, Optional[str], Optional[str]]:
429
+ """(repo_top, first_bad, first_good) — the ORIGINAL anchors of this session.
430
+
431
+ These are fixed for the whole bisect. `git bisect log` grows with every
432
+ evaluation, so keying on anything else would change the identity each commit
433
+ (breaking the once() markers and scattering logs across directories).
434
+ """
435
+ try:
436
+ top = _toplevel()
437
+ except RuntimeError:
438
+ top = os.getcwd()
439
+ bad0 = good0 = None
440
+ for line in _bisect_log().splitlines():
441
+ if not line.startswith("git bisect "):
442
+ continue
443
+ try:
444
+ verb, *args = shlex.split(line)[2:]
445
+ except ValueError:
446
+ continue
447
+ revs = [a for a in args if not a.startswith("-")]
448
+ if verb == "start" and revs:
449
+ bad0 = bad0 or revs[0]
450
+ good0 = good0 or (revs[1] if len(revs) > 1 else None)
451
+ elif verb == "bad" and revs:
452
+ bad0 = bad0 or revs[0]
453
+ elif verb == "good" and revs:
454
+ good0 = good0 or revs[0]
455
+ return top, bad0, good0
456
+
457
+
458
+ def _bisect_id() -> str:
459
+ top, bad0, good0 = _bisect_anchors()
460
+ anchors = f"{bad0 or ''}\n{good0 or ''}"
461
+ return hashlib.sha1((top + "\n" + anchors).encode()).hexdigest()[:12]
462
+
463
+
464
+ # The report and per-commit logs live in a single `.bisect/` directory at the
465
+ # repo root — right beside the code being bisected, so `status.md` opens in the
466
+ # editor at a fixed, watchable path (``.bisect/status.md``) instead of a
467
+ # per-run subdir buried under ~/.cache. A `.bisect/.gitignore` of `*` (see
468
+ # `_write_ignore`) keeps it out of `git status` and out of commits, while
469
+ # `git checkout` between commits leaves the untracked dir untouched.
470
+ _DIRNAME = ".bisect"
471
+ _session_ready = False
472
+ _SHA_DIR_RE = re.compile(r"^[0-9a-f]{7,40}$")
473
+
474
+
475
+ def _owned_entry(child: Path) -> bool:
476
+ """True if `child` is something bisectlib itself created in the logs dir.
477
+
478
+ Used to scope the new-bisect wipe to our own files (`status.md`, `once-*`
479
+ markers, per-commit `<sha>/` dirs) so pointing ``configure(logs=…)`` at a
480
+ shared or pre-populated directory never deletes the user's unrelated files.
481
+ The `id` marker is preserved by the caller, not here.
482
+ """
483
+ name = child.name
484
+ if name == "status.md" or name.startswith("once-"):
485
+ return True
486
+ if child.is_dir() and (_SHA_DIR_RE.match(name) or name == "unknown"):
487
+ return True
488
+ return False
489
+
490
+
491
+ def _bisect_root() -> Path:
492
+ if _cfg.logs:
493
+ return Path(_cfg.logs)
494
+ try:
495
+ top = _toplevel()
496
+ except RuntimeError:
497
+ top = os.getcwd()
498
+ return Path(top) / _DIRNAME
499
+
500
+
501
+ def _write_ignore(root: Path) -> None:
502
+ """Drop a ``.gitignore`` of ``*`` into the log directory so git ignores the
503
+ whole thing — report, logs, markers, and the ``.gitignore`` itself (the ``*``
504
+ self-ignores). This keeps our working-tree directory out of ``git status`` so
505
+ it is never committed by accident and the recipe's own ``is_clean()`` checks
506
+ don't trip over an untracked dir.
507
+
508
+ Preferred over an entry in ``.git/info/exclude`` because it is
509
+ self-contained: it lives *with* the data (gone the moment you delete the
510
+ dir, no cruft left behind in ``.git/``) and works identically for a relocated
511
+ ``configure(logs=…)`` directory, without reaching into the git dir at all.
512
+ It never touches the project's own tracked ``.gitignore``.
513
+ """
514
+ ignore = root / ".gitignore"
515
+ try:
516
+ if not ignore.exists() or ignore.read_text() != "*\n":
517
+ ignore.write_text("*\n")
518
+ except OSError:
519
+ pass
520
+
521
+
522
+ def _ensure_session() -> None:
523
+ """Prepare `.bisect/` once per process; clear it when a new bisect starts.
524
+
525
+ The directory is reused for every commit of one bisect (its `id` file holds
526
+ the bisect id — repo + original good/bad anchors). When those anchors change
527
+ a *different* bisect is under way, so we wipe the stale report, logs and
528
+ `once()` markers left by the previous one; the same anchors (a resume, or the
529
+ next commit of the same run) keep everything, so `once()` setup stays done.
530
+ """
531
+ global _session_ready
532
+ if _session_ready:
533
+ return
534
+ _session_ready = True
535
+ root = _bisect_root()
536
+ try:
537
+ root.mkdir(parents=True, exist_ok=True)
538
+ _write_ignore(root) # hide the dir from git (default and custom logs alike)
539
+ idfile = root / "id"
540
+ cur = _bisect_id()
541
+ prev = idfile.read_text().strip() if idfile.exists() else None
542
+ if prev != cur:
543
+ # A different bisect (repo + original good/bad anchors changed): drop
544
+ # the previous run's report, logs and once() markers so they don't
545
+ # leak in. Only our own entries are removed — safe even when
546
+ # configure(logs=…) points at a shared/pre-populated directory.
547
+ for child in root.iterdir():
548
+ if child.name == "id" or not _owned_entry(child):
549
+ continue
550
+ if child.is_dir():
551
+ shutil.rmtree(child, ignore_errors=True)
552
+ else:
553
+ try:
554
+ child.unlink()
555
+ except OSError:
556
+ pass
557
+ idfile.write_text(cur)
558
+ except OSError:
559
+ pass
560
+
561
+
562
+ def _logs_dir() -> Path:
563
+ _ensure_session()
564
+ return _bisect_root()
565
+
566
+
567
+ def _status_md_path() -> Path:
568
+ if _cfg.status_md:
569
+ return Path(_cfg.status_md)
570
+ return _logs_dir() / "status.md"
571
+
572
+
573
+ def _commit_log_dir() -> Path:
574
+ return _logs_dir() / (sha() if _in_git() else "unknown")
575
+
576
+
577
+ # --------------------------------------------------------------------- verdict
578
+ def _decide(outcome_code: int) -> NoReturn:
579
+ """Record the verdict and exit the process with the bisect exit code."""
580
+ _final["outcome"] = _OUTCOME_NAME[outcome_code]
581
+ _final["code"] = outcome_code
582
+ sys.exit(outcome_code)
583
+
584
+
585
+ def _verdict(code: int, msg: str) -> NoReturn:
586
+ _arm()
587
+ label = _OUTCOME_NAME[code]
588
+ if _use_color():
589
+ sys.stderr.write(f"{_C.get(label, '')}● {label}{_C['reset']} {msg}\n")
590
+ else:
591
+ sys.stderr.write(f"● {label} {msg}\n")
592
+ sys.stderr.flush()
593
+ _decide(code)
594
+
595
+
596
+ # Explicit verdict primitives — decide the current commit straight from Python
597
+ # (e.g. after measuring something with check()), no shell command needed:
598
+ # size = int(check("stat -c%s build/app").out)
599
+ # if size > 5 * 1024 * 1024:
600
+ # bad("binary too big")
601
+ # Each exits the process immediately; reaching the end of the recipe is good.
602
+ def good(msg: str = "") -> NoReturn:
603
+ """Declare this commit GOOD (exit 0) now — short-circuit the rest of the recipe."""
604
+ _verdict(GOOD, msg)
605
+
606
+
607
+ def bad(msg: str = "") -> NoReturn:
608
+ """Declare this commit BAD (exit 1) now — the bug is present."""
609
+ _verdict(BAD, msg)
610
+
611
+
612
+ def skip(msg: str = "") -> NoReturn:
613
+ """SKIP this commit (exit 125) — it can't be judged, route around it."""
614
+ _verdict(SKIP, msg)
615
+
616
+
617
+ def abort(msg: str = "") -> NoReturn:
618
+ """ABORT the bisect (exit 128) — harness broken; state kept, fix & resume."""
619
+ _verdict(ABORT, msg)
620
+
621
+
622
+ # ------------------------------------------------------------------ guided mode
623
+ # Before `git bisect` can binary-search, it needs *both* a bad and a good commit.
624
+ # You usually have the bad one (HEAD, where you noticed the bug) but not the good
625
+ # one — so first you walk backwards through history to find a commit where the bug
626
+ # is absent. Guided mode turns each manual `python recipe.py` run of that hunt into
627
+ # a step with hand-holding: it refuses to re-test a commit git already knows is
628
+ # bad, and after every verdict it prints the exact next command to copy-paste plus
629
+ # a few older candidate commits (each roughly doubling the distance searched) to
630
+ # try next. Once you find the good end and hand off to `git bisect run`, it goes
631
+ # silent — it activates *only* while a bisect is started but has no good commit
632
+ # yet, so it never intrudes on the automated run (both endpoints known) or on a
633
+ # pre-start smoke test (no bisect at all).
634
+ _guided: dict = {}
635
+
636
+
637
+ def _bad_shas() -> set:
638
+ """Every commit git currently knows is bad in this bisect (resolved shas)."""
639
+ shas: set = set()
640
+ ref = _git("rev-parse", "--verify", "refs/bisect/bad", check=False)
641
+ if ref:
642
+ shas.add(ref)
643
+ for line in _bisect_log().splitlines():
644
+ try:
645
+ parts = shlex.split(line)
646
+ except ValueError:
647
+ continue
648
+ # "git bisect bad <sha>" and "git bisect start <bad> [<good>…]"
649
+ revs = [a for a in parts[3:] if not a.startswith("-")] if len(parts) >= 4 \
650
+ and parts[:2] == ["git", "bisect"] else []
651
+ if not revs:
652
+ continue
653
+ if parts[2] in ("bad", "start"):
654
+ r = _git("rev-parse", "--verify", f"{revs[0]}^{{commit}}", check=False)
655
+ if r:
656
+ shas.add(r)
657
+ return shas
658
+
659
+
660
+ def _guided_state() -> dict:
661
+ """Compute (once) whether we're in the good-hunting phase and the facts the
662
+ guidance needs: HEAD, whether HEAD is already bad, and the anchor (the newest
663
+ known-bad commit) that candidate distances are measured back from."""
664
+ if _guided:
665
+ return _guided
666
+ st = {"active": False, "head": None, "head_is_bad": False, "anchor": None,
667
+ "force": "--force" in sys.argv[1:]}
668
+ try:
669
+ if _in_git() and _bisect_log().strip():
670
+ # A known-good commit means git is already binary-searching for real —
671
+ # stay out of its way. Check both the good refs and the session anchors.
672
+ good_refs = _git("for-each-ref", "--format=%(objectname)",
673
+ "refs/bisect/good-*", check=False)
674
+ _, bad0, good0 = _bisect_anchors()
675
+ if not (good_refs.strip() or good0):
676
+ anchor = None
677
+ if bad0:
678
+ anchor = _git("rev-parse", "--verify",
679
+ f"{bad0}^{{commit}}", check=False) or None
680
+ st["active"] = True
681
+ st["head"] = sha()
682
+ st["anchor"] = anchor or st["head"]
683
+ bad = _bad_shas()
684
+ bad.add(st["anchor"])
685
+ st["head_is_bad"] = st["head"] in bad
686
+ except Exception:
687
+ st["active"] = False
688
+ _guided.update(st)
689
+ return _guided
690
+
691
+
692
+ def _commit_ts(rev: str) -> Optional[int]:
693
+ """The committer unix timestamp of `rev`, or None if it doesn't resolve."""
694
+ out = _git("show", "-s", "--format=%ct", rev, check=False)
695
+ return int(out) if out.isdigit() else None
696
+
697
+
698
+ def _shas_by_date(walk: str, targets: list, exclude: set) -> list:
699
+ """For each unix timestamp in `targets` (in order), the newest ancestor of
700
+ `walk` at/before it — deduped, skipping `exclude`. This turns a list of
701
+ calendar offsets into a list of real commits along `walk`'s history."""
702
+ out: list = []
703
+ seen = set(exclude)
704
+ for ts in targets:
705
+ iso = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
706
+ sha_ = _git("rev-list", "-1", f"--before={iso}", walk, check=False)
707
+ if sha_ and sha_ not in seen:
708
+ seen.add(sha_)
709
+ out.append(sha_)
710
+ return out
711
+
712
+
713
+ # The hunt for a good commit walks back through *time* — commit density is too
714
+ # uneven for "N commits back" to feel predictable, and thinking in calendar
715
+ # distance ("try a month ago") is how people actually reason about regressions.
716
+ # The schedule widens from a day out to two months; going further back is rarely
717
+ # worth an extra probe, so it stops there.
718
+ _TIME_OFFSETS_DAYS = (1, 3, 7, 14, 30, 60)
719
+
720
+
721
+ def _candidate_shas() -> list:
722
+ """Older commits to try next, spaced by the widening time schedule back from
723
+ HEAD's commit date. Deduped, nearest first, ancestors of HEAD only, dropping
724
+ any offset that lands past the start of history."""
725
+ head = _guided_state().get("head")
726
+ ts = _commit_ts(head) if head else None
727
+ if ts is None:
728
+ return []
729
+ return _shas_by_date(head, [ts - d * 86400 for d in _TIME_OFFSETS_DAYS], {head})
730
+
731
+
732
+ # The one-line-per-commit format the report/guidance render candidates with:
733
+ # short sha, committer date, relative age (padded), refs, subject, author.
734
+ _LOG_FMT = ("%C(auto)%h %Cgreen%cs %C(green bold)%<(12)%cr"
735
+ "%C(auto)%d %s %C(bold blue)<%an>%Creset")
736
+
737
+
738
+ def _log_lines(shas: list) -> list:
739
+ """Render `shas` (in the given order) as indented `git log` one-liners, colored
740
+ to match the terminal. Falls back to bare shas if the format ever fails."""
741
+ if not shas:
742
+ return []
743
+ color = "always" if _use_color() else "never"
744
+ out = _git("log", "--no-walk=unsorted", f"--color={color}",
745
+ f"--pretty=format:{_LOG_FMT}", *shas, check=False)
746
+ lines = out.splitlines() if out else []
747
+ if not lines:
748
+ lines = [s[:9] for s in shas]
749
+ return [" " + ln for ln in lines]
750
+
751
+
752
+ def _newer_shas() -> list:
753
+ """Commits between HEAD and the newest-bad anchor — the build-break menu's
754
+ *newer* (likelier-to-build) direction. Mirrors the older schedule but steps
755
+ *forward* from HEAD toward the anchor (never reaching the anchor itself, since
756
+ it's the known-bad boundary). Empty if the bad range is empty (HEAD == anchor)."""
757
+ g = _guided_state()
758
+ anchor, head = g.get("anchor"), g.get("head")
759
+ ht = _commit_ts(head) if head else None
760
+ at = _commit_ts(anchor) if anchor else None
761
+ if ht is None or at is None or at <= ht:
762
+ return []
763
+ targets = [min(ht + d * 86400, at - 1) for d in _TIME_OFFSETS_DAYS]
764
+ return _shas_by_date(anchor, targets, {head, anchor})
765
+
766
+
767
+ # guided output — colored, copy-pasteable next steps -------------------------
768
+ _GW = 74 # width of the horizontal rules
769
+
770
+
771
+ def _g_rule(label: str, color: str) -> str:
772
+ if not label:
773
+ bar = "━" * _GW if _use_color() else "─" * _GW
774
+ return f"{color}{bar}{_C['reset']}" if _use_color() else bar
775
+ if _use_color():
776
+ return f"{color}{'━' * 3} {label} {'━' * max(0, _GW - 5 - len(label))}{_C['reset']}"
777
+ return f"── {label} " + "─" * max(0, _GW - 5 - len(label))
778
+
779
+
780
+ def _g_text(s: str) -> str:
781
+ return f" {s}"
782
+
783
+
784
+ def _g_head(mark: str, text: str, color: str) -> str:
785
+ """A verdict headline: a colored mark (✓/✗/●/⚠) followed by plain text."""
786
+ if _use_color():
787
+ return f" {color}{mark}{_C['reset']} {text}"
788
+ return f" {mark} {text}"
789
+
790
+
791
+ def _g_cmd(line: str, comment: str = "") -> str:
792
+ if _use_color():
793
+ s = f" {_C['run']}{line}{_C['reset']}"
794
+ if comment:
795
+ s += f" {_C['dim']}# {comment}{_C['reset']}"
796
+ else:
797
+ s = f" {line}" + (f" # {comment}" if comment else "")
798
+ return s
799
+
800
+
801
+ def _g_candidate_lines() -> list:
802
+ """The older commits to try next, as `git log` one-liners (copy the sha into
803
+ `git checkout`), or a note that we've reached the start of history."""
804
+ cands = _candidate_shas()
805
+ if not cands:
806
+ return [_g_text("Reached the start of history — the oldest commit is your"),
807
+ _g_text("best bet for GOOD. Check it out; if the recipe passes there,"),
808
+ _g_text("run `git bisect good`.")]
809
+ return _log_lines(cands)
810
+
811
+
812
+ # Per-verdict guidance, printed when a manual run happens during the good-hunt.
813
+ # Each entry: rule label, its color key, the headline (mark + text), then the body
814
+ # lines. RERUN/CANDIDATES/BLANK are placeholders expanded by _guided_print.
815
+ _RERUN = object() # "python recipe.py # run again after checking out"
816
+ _CANDIDATES = object() # the git-checkout candidate list
817
+
818
+
819
+ def _guided_print(kind: str) -> None:
820
+ short = (_guided.get("head") or "")[:9]
821
+ recipe = f"python {sys.argv[0] or 'recipe.py'}"
822
+ specs = {
823
+ "good": ("found a good commit", "good",
824
+ ("✓", f"GOOD — the bug is ABSENT here ({short}).", "good"), [
825
+ _g_text("You found the good end of the range. Let git bisect take over:"),
826
+ "",
827
+ _g_cmd("git bisect good"),
828
+ _g_cmd(f"git bisect run {recipe}")]),
829
+ "bad": ("bug still present — keep hunting for a good commit", "bad",
830
+ ("✗", f"BAD — the bug is present here ({short}).", "bad"), [
831
+ _g_text("Record it, then test an older commit — where was the bug absent?"),
832
+ "",
833
+ _g_cmd("git bisect bad"),
834
+ "",
835
+ _g_text("git checkout one of these older commits and run the recipe again:"),
836
+ _CANDIDATES, "", _RERUN]),
837
+ "skip": ("commit can't be judged — try another", "skip",
838
+ ("●", f"SKIP — this commit can't be tested ({short}).", "skip"), [
839
+ _g_text("git checkout an older commit instead:"),
840
+ _CANDIDATES, "", _RERUN]),
841
+ "abort": ("recipe error — fix the recipe", "abort",
842
+ ("⚠", "The recipe hit an error — not a good/bad verdict.", "abort"), [
843
+ _g_text("This is a harness problem, not evidence about the bug. Fix the"),
844
+ _g_text("recipe, then run it again:"),
845
+ "",
846
+ _g_cmd(recipe)]),
847
+ "already_bad": ("already marked bad — skipping", "skip",
848
+ ("●", f"HEAD ({short}) is already marked BAD — nothing to test.",
849
+ "skip"), [
850
+ _g_text("To find a GOOD commit, git checkout an older one and run it there:"),
851
+ _CANDIDATES, "", _RERUN,
852
+ _g_text(f"(Use {recipe} --force to evaluate this commit anyway.)")]),
853
+ }
854
+ if kind not in specs:
855
+ return
856
+ label, rule_color, (mark, text, mark_color), body = specs[kind]
857
+ lines = [_g_rule(label, _C[rule_color]), _g_head(mark, text, _C[mark_color])]
858
+ for item in body:
859
+ if item is _RERUN:
860
+ lines.append(_g_cmd(recipe, "run again after checking out"))
861
+ elif item is _CANDIDATES:
862
+ lines.extend(_g_candidate_lines())
863
+ else:
864
+ lines.append(item)
865
+ lines.append(_g_rule("", _C[rule_color]))
866
+ sys.stderr.write("\n" + "\n".join(lines) + "\n")
867
+ sys.stderr.flush()
868
+
869
+
870
+ def _guided_build_break() -> None:
871
+ """A build/setup ``run()`` failed while hunting for a good commit.
872
+
873
+ An unbuildable commit is genuinely untestable, and *why* it won't build is a
874
+ judgement call this library can't make: it may be toolchain drift on an old
875
+ commit (jump past it, or come back to newer code that builds) or a bug in the
876
+ recipe itself. So instead of guessing a direction — or worse, mis-recording it
877
+ as good/bad — we lay out the choices with copy-pasteable commands and let the
878
+ user decide which way to continue.
879
+ """
880
+ short = (_guided.get("head") or "")[:9]
881
+ recipe = f"python {sys.argv[0] or 'recipe.py'}"
882
+ cmd = _final.get("abort_cmd", "")
883
+ color = _C["abort"]
884
+ what = f"`{cmd}` failed at {short}" if cmd else f"the build failed at {short}"
885
+ lines = [_g_rule("can't build this commit — you decide where to go", color),
886
+ _g_head("⚠", f"{what} — this commit won't build.", color),
887
+ _g_text("An unbuildable commit is neither good nor bad; nothing was"),
888
+ _g_text("recorded. git checkout a commit and re-run — your call which way:"),
889
+ "",
890
+ _g_text("OLDER — jump past the break (often toolchain drift):")]
891
+ lines += _g_candidate_lines()
892
+ newer = _newer_shas()
893
+ if newer:
894
+ lines += ["", _g_text("NEWER — come back toward code that builds:")]
895
+ lines += _log_lines(newer)
896
+ lines += ["", _g_cmd(recipe, "run again after checking out"), "",
897
+ _g_text("If instead the recipe/build script is what's broken (not the"),
898
+ _g_text("commit), fix it and re-run — or let the recipe route around"),
899
+ _g_text("unbuildable commits with:"),
900
+ _g_cmd('run("…", skip_on_error=True)'),
901
+ _g_rule("", color)]
902
+ sys.stderr.write("\n" + "\n".join(lines) + "\n")
903
+ sys.stderr.flush()
904
+
905
+
906
+ def _arm() -> None:
907
+ """Guided-mode precheck, run once before the first recipe step executes.
908
+
909
+ Refuses to re-evaluate a commit git already knows is bad (there's nothing to
910
+ learn), printing where to go instead — unless ``--force`` was passed.
911
+ """
912
+ if _guided.get("_armed"):
913
+ return
914
+ g = _guided_state()
915
+ _guided["_armed"] = True
916
+ if g["active"] and g["head_is_bad"] and not g["force"]:
917
+ _guided["_handled"] = True
918
+ _guided_print("already_bad")
919
+ # Nothing was evaluated: exit straight away without touching status.md or
920
+ # writing a verdict sidecar (os._exit skips the atexit finalize).
921
+ os._exit(0)
922
+
923
+
924
+ def _guided_finish() -> None:
925
+ """After a real verdict in the good-hunting phase, print the next step."""
926
+ g = _guided_state()
927
+ if not g["active"] or _guided.get("_handled"):
928
+ return
929
+ _guided["_handled"] = True
930
+ # Normally _arm() has already skipped an already-bad HEAD before any step ran;
931
+ # this only catches the degenerate recipe with no steps at all, which falls off
932
+ # the end as "good" — don't misreport an already-bad commit as a good find.
933
+ if g["head_is_bad"] and not g["force"]:
934
+ _guided_print("already_bad")
935
+ return
936
+ code = _final.get("code")
937
+ # A failed build/setup run() is a special abort: the commit is untestable and
938
+ # the user, not us, should choose which way to continue the hunt.
939
+ if code == ABORT and _final.get("abort_reason") == "build":
940
+ _guided_build_break()
941
+ return
942
+ kind = {GOOD: "good", BAD: "bad", SKIP: "skip", ABORT: "abort"}.get(code)
943
+ if kind:
944
+ _guided_print(kind)
945
+
946
+
947
+ # -------------------------------------------------------------------- run/test
948
+ def _one_of(name: str, value: str, allowed: tuple[str, ...]) -> None:
949
+ """Fail fast on a mistyped string option instead of silently defaulting.
950
+
951
+ A typo like ``bad_when="Pass"`` or ``on_timeout="abrot"`` used to fall
952
+ through to a default — for ``bad_when`` that silently inverts the whole
953
+ bisect. Since this library's whole point is never to be silently wrong, an
954
+ unknown value raises (an uncaught error in a recipe ABORTS, never 'bad').
955
+ """
956
+ if value not in allowed:
957
+ raise ValueError(
958
+ f"{name}={value!r} is not valid; expected one of "
959
+ + ", ".join(repr(a) for a in allowed))
960
+
961
+
962
+ def _slugify(text: str, maxlen: int = 40, fallback: str = "cmd") -> str:
963
+ """Lowercase `text`, collapse runs of non-alphanumerics to single dashes, and
964
+ cap at `maxlen` (trimming a dangling dash). Shared by log-file names and the
965
+ once() markers so both stay filesystem-safe the same way."""
966
+ s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
967
+ if len(s) > maxlen:
968
+ s = s[:maxlen].rstrip("-")
969
+ return s or fallback
970
+
971
+
972
+ def _slug(cmd: str, maxlen: int = 40) -> str:
973
+ """A short, filesystem-safe label derived from a command, for log filenames.
974
+
975
+ The program name is reduced to its basename (``./gradlew`` -> ``gradlew``,
976
+ ``/usr/bin/make`` -> ``make``) and everything is lowercased with runs of
977
+ non-alphanumerics collapsed to single dashes, e.g.
978
+ ``./gradlew :nativesdk:fetchAgent`` -> ``gradlew-nativesdk-fetchagent``.
979
+ """
980
+ tokens = cmd.strip().split()
981
+ if tokens:
982
+ tokens[0] = os.path.basename(tokens[0])
983
+ return _slugify(" ".join(tokens), maxlen)
984
+
985
+
986
+ def run(cmd: str, *, skip_on_error: bool = False, timeout: Optional[float] = None,
987
+ on_timeout: _OnTimeout = "abort", cwd: Optional[str] = None) -> Result:
988
+ """Infrastructure step (configure/build/setup).
989
+
990
+ Success -> continue. Failure -> ABORT by default (the harness is presumed
991
+ broken; bisect state is preserved so you can fix the recipe and resume).
992
+ Set skip_on_error=True to SKIP this commit instead.
993
+
994
+ ``cwd`` sets the working directory (relative to the repo root; absolute paths
995
+ honoured); defaults to the repo root or ``configure(cwd=…)``.
996
+ """
997
+ _arm()
998
+ _one_of("on_timeout", on_timeout, ("abort", "skip", "bad"))
999
+ _echo_start("run", cmd)
1000
+ log_name = f"{len(_steps)+1:02d}-run-{_slug(cmd)}.log"
1001
+ _begin_step("run", cmd, log_name)
1002
+ res = _exec(cmd, timeout, _commit_log_dir() / log_name, cwd)
1003
+ timed_out = res.code == -1
1004
+ ok = res.code == 0
1005
+ _record_step("run", cmd, res, ok, log=log_name)
1006
+ if timed_out:
1007
+ label = on_timeout
1008
+ _echo_result("run", cmd, False, res.seconds, label)
1009
+ _decide({"abort": ABORT, "skip": SKIP, "bad": BAD}.get(on_timeout, ABORT))
1010
+ if ok:
1011
+ _echo_result("run", cmd, True, res.seconds, "ok")
1012
+ return res
1013
+ # failure
1014
+ if skip_on_error:
1015
+ _echo_result("run", cmd, False, res.seconds, "skip")
1016
+ _decide(SKIP)
1017
+ else:
1018
+ _echo_result("run", cmd, False, res.seconds, "abort")
1019
+ # Note *why* we aborted so guided mode can distinguish a commit that won't
1020
+ # build (the user picks a direction) from a broken recipe (fix it).
1021
+ _final["abort_reason"], _final["abort_cmd"] = "build", cmd
1022
+ _decide(ABORT)
1023
+
1024
+
1025
+ def test(cmd: str, *, attempts: int = 1, min_passes: Optional[int] = None,
1026
+ passed: Optional[Callable[[Result], bool]] = None, warmup: int = 0,
1027
+ bad_when: _BadWhen = "fail", timeout: Optional[float] = None,
1028
+ on_timeout: _OnTimeout = "skip", cwd: Optional[str] = None) -> Optional[Result]:
1029
+ """A verdict step. Good -> continue; bad -> exit 1 (BAD).
1030
+
1031
+ Like ``run``, a *passing* test continues to the next line, so you can have
1032
+ several ``test`` calls and they combine with logical AND — any one failing is
1033
+ BAD; reaching the end of the recipe is GOOD. Returns the last Result on good.
1034
+
1035
+ ``attempts`` is the *maximum* number of tries and ``min_passes`` how many must
1036
+ pass (default: all). Evaluation **stops as soon as the verdict is known** — at
1037
+ the moment ``min_passes`` is reached (good), or once the remaining attempts can
1038
+ no longer reach it (bad) — so ``attempts`` is an upper bound, not a fixed count.
1039
+ (To hunt a *rare* flake — fail on the first bad run of many — use ``hammer``.)
1040
+
1041
+ ``passed`` decides whether one attempt passed: a callable receiving the
1042
+ :class:`Result` (``.code``, ``.ok``, ``.out``, ``.seconds``) and returning
1043
+ bool. Default: ``lambda r: r.ok`` (exit code 0). Because it sees ``.seconds``,
1044
+ timing thresholds are just predicates combined with the quorum, e.g. the
1045
+ minimum of 5 runs below 6.7s::
1046
+
1047
+ test("./bench", attempts=5, min_passes=1, passed=lambda r: r.seconds < 6.7)
1048
+
1049
+ The quorum count expresses every aggregate: ``min(times) < T`` -> min_passes=1;
1050
+ ``max(times) < T`` (all) -> min_passes=attempts; ``median < T`` ->
1051
+ min_passes=attempts//2 + 1. Combine with success via ``r.ok and r.seconds < T``.
1052
+
1053
+ ``warmup`` runs extra leading throwaway executions (excluded from the pass
1054
+ count). ``bad_when="pass"`` inverts the bug direction.
1055
+
1056
+ A test that **cannot be run** — exit ``127`` (command not found) or ``126``
1057
+ (not executable) — is treated as a broken recipe and **ABORTS**, never BAD:
1058
+ a test that never executed is not evidence the bug is present, and marking it
1059
+ bad would silently mis-bisect. (A crash/signal, exit ``>=128``, stays BAD — it
1060
+ may be the regression itself.)
1061
+ """
1062
+ _arm()
1063
+ _one_of("bad_when", bad_when, ("fail", "pass"))
1064
+ _one_of("on_timeout", on_timeout, ("skip", "bad", "abort"))
1065
+ if attempts < 1:
1066
+ raise ValueError(f"attempts={attempts!r} must be >= 1")
1067
+ if warmup < 0:
1068
+ raise ValueError(f"warmup={warmup!r} must be >= 0")
1069
+ if min_passes is not None and not (1 <= min_passes <= attempts):
1070
+ raise ValueError(
1071
+ f"min_passes={min_passes!r} must be between 1 and attempts={attempts} "
1072
+ f"(min_passes>attempts is unreachable -> always bad)")
1073
+ _echo_start("test", cmd)
1074
+ if passed is None:
1075
+ passed = lambda r: r.ok # noqa: E731
1076
+ if min_passes is None:
1077
+ min_passes = attempts
1078
+
1079
+ durations: list[float] = []
1080
+ passes = 0
1081
+ executed = 0
1082
+ last: Optional[Result] = None
1083
+ idx, slug = len(_steps) + 1, _slug(cmd)
1084
+ log_name = f"{idx:02d}-test-{slug}-1.log" # actual file of the last attempt run
1085
+ _begin_step("test", cmd, log_name)
1086
+ for i in range(warmup + attempts):
1087
+ log_name = f"{idx:02d}-test-{slug}-{i+1}.log"
1088
+ res = _exec(cmd, timeout, _commit_log_dir() / log_name, cwd)
1089
+ last = res
1090
+ if res.code == -1: # timeout
1091
+ _record_step("test", cmd, res, False, log=log_name,
1092
+ extra={"attempts": attempts, "passes": passes,
1093
+ "timeout": True})
1094
+ _echo_result("test", cmd, False, res.seconds, on_timeout)
1095
+ _decide({"skip": SKIP, "bad": BAD, "abort": ABORT}.get(on_timeout, SKIP))
1096
+ if res.code in (126, 127):
1097
+ # The shell couldn't run the test at all — 127 (command not found) or
1098
+ # 126 (not executable): a broken recipe/build, not a "bug present"
1099
+ # verdict. Marking it BAD here would silently mis-bisect (the exact
1100
+ # sin this library exists to prevent), so ABORT instead — fix the
1101
+ # recipe and resume. Retrying is pointless (it's deterministic), so
1102
+ # bail on the first occurrence. A crash/signal (exit >=128) is left
1103
+ # as BAD on purpose: it may *be* the regression.
1104
+ _record_step("test", cmd, res, False, log=log_name,
1105
+ extra={"attempts": attempts, "passes": passes,
1106
+ "unrunnable": res.code})
1107
+ _echo_result("test", cmd, False, res.seconds, "abort")
1108
+ sys.stderr.write(
1109
+ f" test command exited {res.code}: it could not be run "
1110
+ f"(missing binary / wrong path / not built?) — aborting so you "
1111
+ f"can fix the recipe, not marking this commit bad\n")
1112
+ _decide(ABORT)
1113
+ if i < warmup:
1114
+ continue
1115
+ executed += 1
1116
+ durations.append(res.seconds)
1117
+ ok = passed(res)
1118
+ if bad_when == "pass":
1119
+ ok = not ok
1120
+ if ok:
1121
+ passes += 1
1122
+ # stop as soon as the verdict is locked in
1123
+ if passes >= min_passes:
1124
+ break
1125
+ if (attempts - executed) < (min_passes - passes):
1126
+ break
1127
+
1128
+ good = passes >= min_passes
1129
+ extra = {"attempts": attempts, "executed": executed, "passes": passes,
1130
+ "min_passes": min_passes, "durations_s": [round(d, 4) for d in durations]}
1131
+ _record_step("test", cmd, last, good, log=log_name, extra=extra,
1132
+ outcome="good" if good else "bad")
1133
+
1134
+ fastest = f" · min {min(durations):.3g}s" if durations else ""
1135
+ _echo_result("test", cmd, good, last.seconds if last else 0.0,
1136
+ "good" if good else "bad")
1137
+ sys.stderr.write(f" {passes}/{executed}{fastest}\n")
1138
+ if not good:
1139
+ _decide(BAD)
1140
+ return last # good: continue to the next step (multiple tests AND together)
1141
+
1142
+
1143
+ def _hammer_log(fh, n: int, kind: str, res: Result) -> None:
1144
+ """Append one line per completed run to the shared hammer log, plus the full
1145
+ output of any run that didn't pass (so a rare failure is captured even though
1146
+ the thousands of passing runs before it were not individually logged)."""
1147
+ label = {"pass": "pass", "fail": "FAIL", "timeout": "TIMEOUT",
1148
+ "unrunnable": "UNRUNNABLE"}.get(kind, kind)
1149
+ try:
1150
+ fh.write(f"run {n:>6}: {label:<10} exit={res.code} {res.seconds:.3f}s\n")
1151
+ if kind != "pass":
1152
+ fh.write("----- output of the failing run -----\n")
1153
+ fh.write(res.out if (not res.out or res.out.endswith("\n")) else res.out + "\n")
1154
+ fh.write("-------------------------------------\n")
1155
+ except OSError:
1156
+ pass
1157
+
1158
+
1159
+ def hammer(cmd: str, *, for_seconds: float = 60.0, parallel: Optional[int] = None,
1160
+ passed: Optional[Callable[[Result], bool]] = None,
1161
+ bad_when: _BadWhen = "fail", timeout: Optional[float] = None,
1162
+ on_timeout: _OnTimeout = "skip", cwd: Optional[str] = None) -> Optional[Result]:
1163
+ """Hunt a rare flake: run ``cmd`` over and over until one fails.
1164
+
1165
+ The mirror image of the flaky-*tolerant* ``test(attempts=…, min_passes=…)``.
1166
+ Where ``test`` runs a quorum and forgives a few failures, ``hammer`` pounds on
1167
+ the command to *expose* a failure that only shows up once in thousands of runs::
1168
+
1169
+ hammer("./flaky") # one minute, all cores
1170
+ hammer("./flaky", for_seconds=120, parallel=8)
1171
+
1172
+ It launches runs up to ``parallel`` at a time for ``for_seconds`` of wall-clock
1173
+ time; the defaults are **one minute** and **all CPU cores**
1174
+ (``os.cpu_count()``). The commit is **BAD the instant any run fails** (the
1175
+ search stops and the failing run's output is shown), and **GOOD only if the
1176
+ whole budget elapses with no failure**. Like ``test``, a good verdict continues
1177
+ to the next recipe line, so hammers/tests AND together.
1178
+
1179
+ ``passed`` / ``bad_when`` define what "a failing run" is exactly as in ``test``
1180
+ (default: exit code 0 is a pass), so you can hammer a benchmark for a jitter
1181
+ spike too: ``passed=lambda r: r.seconds < T``. A run that **cannot be launched**
1182
+ (exit 126/127) ABORTS rather than counting as a failure, same as ``test``.
1183
+ """
1184
+ import concurrent.futures as cf
1185
+
1186
+ _arm()
1187
+ _one_of("bad_when", bad_when, ("fail", "pass"))
1188
+ _one_of("on_timeout", on_timeout, ("skip", "bad", "abort"))
1189
+ if parallel is None:
1190
+ parallel = os.cpu_count() or 1 # default: use every core
1191
+ elif parallel < 1:
1192
+ raise ValueError(f"parallel={parallel!r} must be >= 1")
1193
+ if for_seconds <= 0:
1194
+ raise ValueError(f"for_seconds={for_seconds!r} must be > 0")
1195
+ _echo_start("hammer", cmd)
1196
+ if passed is None:
1197
+ passed = lambda r: r.ok # noqa: E731
1198
+
1199
+ idx, slug = len(_steps) + 1, _slug(cmd)
1200
+ log_name = f"{idx:02d}-hammer-{slug}.log"
1201
+ _begin_step("hammer", cmd, log_name)
1202
+ log_path = _commit_log_dir() / log_name
1203
+ try:
1204
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1205
+ log_fh = open(log_path, "w", buffering=1)
1206
+ except OSError:
1207
+ log_fh = None
1208
+ # Resolve the working dir and environment once up front: every run is
1209
+ # identical, so re-resolving the repo root (a `git` subprocess) and rebuilding
1210
+ # the env dict per run would be pure overhead when firing thousands of runs.
1211
+ # An absolute cwd passes through _workdir untouched.
1212
+ abs_cwd = _workdir(cwd)
1213
+ run_env = _clean_env(abs_cwd)
1214
+
1215
+ st = {"executed": 0, "passes": 0, "failures": 0, "completed": 0,
1216
+ "min_dur": None, "last": None, "failing": None,
1217
+ "abort_code": None, "timed_out": False, "stop": False}
1218
+ t0 = time.monotonic()
1219
+ deadline = t0 + for_seconds
1220
+
1221
+ def record(res: Result) -> None:
1222
+ # Called only from this (main) thread as futures complete, so no locking:
1223
+ # the worker threads just run _exec and return a Result.
1224
+ st["last"] = res
1225
+ st["completed"] += 1
1226
+ n = st["completed"]
1227
+ if res.code == -1: # timed out
1228
+ st["timed_out"] = True
1229
+ st["stop"] = True
1230
+ kind = "timeout"
1231
+ elif res.code in (126, 127): # unrunnable -> broken recipe
1232
+ st["abort_code"] = res.code
1233
+ st["stop"] = True
1234
+ kind = "unrunnable"
1235
+ else:
1236
+ ok = passed(res)
1237
+ if bad_when == "pass":
1238
+ ok = not ok
1239
+ st["executed"] += 1
1240
+ st["min_dur"] = (res.seconds if st["min_dur"] is None
1241
+ else min(st["min_dur"], res.seconds))
1242
+ if ok:
1243
+ st["passes"] += 1
1244
+ kind = "pass"
1245
+ else:
1246
+ st["failures"] += 1
1247
+ if st["failing"] is None:
1248
+ st["failing"] = res
1249
+ st["stop"] = True # any failure ends the hunt -> BAD
1250
+ kind = "fail"
1251
+ if log_fh is not None:
1252
+ _hammer_log(log_fh, n, kind, res)
1253
+
1254
+ with cf.ThreadPoolExecutor(max_workers=parallel) as ex:
1255
+ inflight: set = set()
1256
+
1257
+ def top_up() -> None:
1258
+ while (len(inflight) < parallel and not st["stop"]
1259
+ and time.monotonic() < deadline):
1260
+ inflight.add(ex.submit(_exec, cmd, timeout, None, abs_cwd, False,
1261
+ run_env))
1262
+
1263
+ top_up()
1264
+ while inflight:
1265
+ done, inflight = cf.wait(inflight, return_when=cf.FIRST_COMPLETED)
1266
+ for fut in done:
1267
+ record(fut.result())
1268
+ if not st["stop"]:
1269
+ top_up()
1270
+ if log_fh is not None:
1271
+ try:
1272
+ log_fh.close()
1273
+ except OSError:
1274
+ pass
1275
+
1276
+ executed, last = st["executed"], st["last"]
1277
+ elapsed = time.monotonic() - t0
1278
+ # The recorded step's duration is the *total* hammer wall time (not the last
1279
+ # run's), so status.md's per-commit total and Details "time" reflect the whole
1280
+ # soak. Carry the run count, thread count and runtime for the report to show.
1281
+ agg = Result(code=(last.code if last else 0), out="", seconds=elapsed)
1282
+ extra = {"executed": executed, "passes": st["passes"],
1283
+ "failures": st["failures"], "parallel": parallel,
1284
+ "for_seconds": for_seconds, "elapsed_s": round(elapsed, 3)}
1285
+ if st["min_dur"] is not None:
1286
+ extra["durations_s"] = [round(st["min_dur"], 4)] # report shows "min Xs"
1287
+
1288
+ if st["abort_code"] is not None: # a run could not be launched
1289
+ extra["unrunnable"] = st["abort_code"]
1290
+ _record_step("hammer", cmd, agg, False, log=log_name, extra=extra, outcome="bad")
1291
+ _echo_result("hammer", cmd, False, elapsed, "abort")
1292
+ sys.stderr.write(
1293
+ f" command exited {st['abort_code']}: it could not be run "
1294
+ f"(missing binary / wrong path / not built?) — aborting so you can fix "
1295
+ f"the recipe, not marking this commit bad\n")
1296
+ _decide(ABORT)
1297
+ if st["timed_out"]: # a run exceeded timeout
1298
+ extra["timeout"] = True
1299
+ _record_step("hammer", cmd, agg, False, log=log_name, extra=extra)
1300
+ _echo_result("hammer", cmd, False, elapsed, on_timeout)
1301
+ _decide({"skip": SKIP, "bad": BAD, "abort": ABORT}.get(on_timeout, SKIP))
1302
+
1303
+ good = st["failures"] == 0
1304
+ _record_step("hammer", cmd, agg, good, log=log_name, extra=extra,
1305
+ outcome="good" if good else "bad")
1306
+ _echo_result("hammer", cmd, good, elapsed, "good" if good else "bad")
1307
+ fastest = f" · min {st['min_dur']:.3g}s" if st["min_dur"] is not None else ""
1308
+ sys.stderr.write(
1309
+ f" {executed} runs, {parallel}× parallel in {elapsed:.1f}s "
1310
+ f"· {st['passes']} passed, {st['failures']} failed{fastest}\n")
1311
+ if not good:
1312
+ fr = st["failing"]
1313
+ if fr is not None and fr.out: # show WHY the hunt found a bad run
1314
+ sys.stderr.write(" --- first failing run output (tail) ---\n")
1315
+ for line in fr.out.splitlines()[-20:]:
1316
+ sys.stderr.write(f" {line}\n")
1317
+ _decide(BAD)
1318
+ return last
1319
+
1320
+
1321
+ def check(cmd: str, *, timeout: Optional[float] = None,
1322
+ cwd: Optional[str] = None) -> Result:
1323
+ """Run once and return the Result. NEVER exits the process."""
1324
+ _arm()
1325
+ _echo_start("check", cmd)
1326
+ log_name = f"{len(_steps)+1:02d}-check-{_slug(cmd)}.log"
1327
+ _begin_step("check", cmd, log_name)
1328
+ res = _exec(cmd, timeout, _commit_log_dir() / log_name, cwd)
1329
+ _record_step("check", cmd, res, res.ok, log=log_name)
1330
+ _echo_result("check", cmd, res.ok, res.seconds, "ok" if res.ok else "fail")
1331
+ return res
1332
+
1333
+
1334
+ def once(key: str = "setup") -> bool:
1335
+ """True the first time this `key` is seen in the bisect session, False after.
1336
+
1337
+ Guard one-time, commit-independent setup (fetch a dependency, create a
1338
+ symlink) that you'd otherwise repeat on every commit::
1339
+
1340
+ if once(): # default key, for a single setup block
1341
+ run("./gradlew :nativesdk:fetchAgent", cwd="test")
1342
+ run("ln -fs $(pwd)/.../liboneagentsdk.so .../liboneagentsdk.so")
1343
+
1344
+ Each `key` has its own independent marker, so distinct setups don't interfere::
1345
+
1346
+ if once("fetch-agent"):
1347
+ run("./gradlew :nativesdk:fetchAgent", cwd="test")
1348
+ if once("symlink"):
1349
+ run("ln -fs $(pwd)/.../liboneagentsdk.so .../liboneagentsdk.so")
1350
+
1351
+ A key's "already ran" marker (scoped to the bisect id) is committed only once
1352
+ an evaluation that armed it finishes with a real verdict — **not on abort**.
1353
+ Keys committed by an *earlier* evaluation stay done; every key armed in an
1354
+ evaluation that then aborts re-runs next time (the library can't tell which
1355
+ key's block completed before the abort, so keep each block idempotent). The
1356
+ setup's artifacts must survive `git checkout` between commits (be untracked /
1357
+ outside the work tree, e.g. build outputs or symlinks in ignored dirs).
1358
+ """
1359
+ if _once_marker(key).exists():
1360
+ return False
1361
+ _once_pending.add(key)
1362
+ return True
1363
+
1364
+
1365
+ def _once_marker(key: str) -> Path:
1366
+ h = hashlib.sha1(key.encode()).hexdigest()[:8]
1367
+ return _logs_dir() / f"once-{_slugify(key, fallback='key')}-{h}"
1368
+
1369
+
1370
+ def _begin_step(verb, cmd, log=None) -> None:
1371
+ """Register a provisional 'running' step and refresh status.md *before* the
1372
+ command executes, so the report immediately shows what is running right now
1373
+ with a link to its (live, growing) log. `_record_step` replaces it once the
1374
+ command finishes.
1375
+ """
1376
+ _steps.append({"verb": verb, "cmd": cmd, "code": None,
1377
+ "duration_s": None, "log": log, "running": True})
1378
+ _flush_status()
1379
+
1380
+
1381
+ def _record_step(verb, cmd, res: Optional[Result], ok, extra=None, outcome=None,
1382
+ log=None):
1383
+ step = {"verb": verb, "cmd": cmd,
1384
+ "code": (res.code if res else None),
1385
+ "duration_s": round(res.seconds, 4) if res else None,
1386
+ "log": log}
1387
+ if outcome:
1388
+ step["outcome"] = outcome
1389
+ if extra:
1390
+ step.update(extra)
1391
+ # Replace the in-flight 'running' placeholder from _begin_step, if present,
1392
+ # rather than appending a duplicate row.
1393
+ if _steps and _steps[-1].get("running"):
1394
+ _steps[-1] = step
1395
+ else:
1396
+ _steps.append(step)
1397
+ _flush_status() # keep status.md current after every step, so it's watchable live
1398
+
1399
+
1400
+ # -------------------------------------------------------------------- replace
1401
+ def replace(path: str, old: Union[str, "re.Pattern"], new: str, *,
1402
+ count: int = 0, when=None,
1403
+ if_missing: Literal["skip", "abort", "ignore"] = "skip") -> None:
1404
+ """sed-like in-file edit, auto-reverted before the process exits.
1405
+
1406
+ `old` is a literal substring (str) or a regex (re.Pattern); type decides.
1407
+ `if_missing`: "skip" (default), "abort", or "ignore" when `old` isn't found.
1408
+ """
1409
+ _one_of("if_missing", if_missing, ("skip", "abort", "ignore"))
1410
+ if when is not None and not _truthy(when):
1411
+ return
1412
+ p = Path(_toplevel()) / path if not os.path.isabs(path) else Path(path)
1413
+ text = p.read_text()
1414
+ if isinstance(old, re.Pattern):
1415
+ new_text, n = old.subn(new, text, count=count or 0)
1416
+ else:
1417
+ n = text.count(old) if count == 0 else min(text.count(old), count)
1418
+ new_text = text.replace(old, new, count if count else -1)
1419
+ if n == 0:
1420
+ if if_missing == "ignore":
1421
+ return
1422
+ sys.stderr.write(f"replace: pattern not found in {path}\n")
1423
+ _decide(SKIP if if_missing == "skip" else ABORT)
1424
+ _register_revert_path(path)
1425
+ p.write_text(new_text)
1426
+ # Record the full replacement (not truncated) so the report can show it in
1427
+ # its entirety; the renderer wraps it in backticks.
1428
+ old_str = old.pattern if isinstance(old, re.Pattern) else str(old)
1429
+ _final.setdefault("fixups", []).append(
1430
+ {"kind": "replace", "path": path,
1431
+ "detail": f"{old_str} → {new}"})
1432
+ sys.stderr.write(f" edit {path}: {n} replacement(s)\n")
1433
+
1434
+
1435
+ # ---------------------------------------------------------------------- fixup
1436
+ @contextmanager
1437
+ def fixup(patch: Optional[str] = None, *, cherry_pick: Optional[str] = None,
1438
+ when=None):
1439
+ """Apply a patch or cherry-pick for the duration of the block; auto-revert.
1440
+
1441
+ `when` (predicate) gates application; if false the block runs unpatched.
1442
+ """
1443
+ applied = False
1444
+ if when is None or _truthy(when):
1445
+ if patch:
1446
+ _git("apply", patch)
1447
+ _final.setdefault("fixups", []).append({"kind": "patch", "detail": patch})
1448
+ applied = True
1449
+ elif cherry_pick:
1450
+ _git("cherry-pick", "--no-commit", cherry_pick)
1451
+ _final.setdefault("fixups", []).append(
1452
+ {"kind": "cherry-pick", "detail": cherry_pick})
1453
+ applied = True
1454
+ try:
1455
+ yield
1456
+ finally:
1457
+ if applied:
1458
+ _revert_tree()
1459
+
1460
+
1461
+ def _truthy(when) -> bool:
1462
+ return bool(when() if callable(when) else when)
1463
+
1464
+
1465
+ # ------------------------------------------------------------- clean-tree revert
1466
+ def _register_revert_path(path: str) -> None:
1467
+ _reverts.append(lambda: subprocess.run(
1468
+ ["git", "checkout", "--", path], cwd=_toplevel(), capture_output=True))
1469
+
1470
+
1471
+ def _revert_tree() -> None:
1472
+ top = _toplevel()
1473
+ subprocess.run(["git", "reset", "-q", "--hard"], cwd=top, capture_output=True)
1474
+ if _cfg.clean == "clean":
1475
+ # keep our own `.bisect/` (status.md + logs) — a plain `git clean -fdx`
1476
+ # would wipe the report mid-bisect.
1477
+ subprocess.run(["git", "clean", "-fdxq", "-e", _DIRNAME],
1478
+ cwd=top, capture_output=True)
1479
+
1480
+
1481
+ # ------------------------------------------------------------------- finalize
1482
+ def _write_sidecar() -> None:
1483
+ if not _in_git():
1484
+ return
1485
+ try:
1486
+ d = _commit_log_dir()
1487
+ d.mkdir(parents=True, exist_ok=True)
1488
+ # `pending` is True for the per-step live writes (verdict not yet decided)
1489
+ # and False once _finalize has locked in the outcome. The renderer uses it
1490
+ # to show the in-flight commit's real verdict in the saved status.md, rather
1491
+ # than leaving it stuck on `todo` (git only records the mark after we exit).
1492
+ data = {"sha": sha(), "outcome": _final["outcome"],
1493
+ "exit_code": _final["code"], "pending": not _finalized,
1494
+ "steps": _steps}
1495
+ if "fixups" in _final:
1496
+ data["fixups"] = _final["fixups"]
1497
+ total = sum(s.get("duration_s") or 0 for s in _steps)
1498
+ data["duration_s"] = round(total, 4)
1499
+ (d / "eval.json").write_text(json.dumps(data, indent=2))
1500
+ except OSError:
1501
+ pass
1502
+
1503
+
1504
+ def _refresh_status_md(decided: bool = False) -> None:
1505
+ """Re-render status.md from the reconstructed report.
1506
+
1507
+ `git bisect run` records the current commit's good/bad/skip mark only *after*
1508
+ this process exits, so `git bisect log` doesn't yet reflect our verdict while
1509
+ we render. That is fine for the live per-step writes (the commit shows as an
1510
+ in-flight row). But on the *final* commit git records the mark, names the
1511
+ first-bad commit, and stops — no further evaluation runs, so status.md would
1512
+ forever miss the answer. When `decided` is set (finalize, real verdict), feed
1513
+ build_report a log with our own mark appended so the finished report is
1514
+ complete and shows the first-bad commit.
1515
+ """
1516
+ try:
1517
+ from . import _report
1518
+ if not _in_git():
1519
+ return
1520
+ head = sha()
1521
+ # Feed the report our process-constant HEAD and bisect log instead of
1522
+ # letting it re-query them on every render. On the *final* commit git has
1523
+ # not yet recorded our mark, so append it so the finished report names the
1524
+ # first-bad commit (see docstring).
1525
+ log_text = _bisect_log()
1526
+ outcome = _final.get("outcome")
1527
+ if decided and outcome in ("good", "bad", "skip") and log_text:
1528
+ log_text = f"{log_text}\ngit bisect {outcome} {head}\n"
1529
+ rep = _report.build_report(
1530
+ _toplevel(), log_text=log_text, logs_dir=str(_logs_dir()), head=head)
1531
+ if rep is None:
1532
+ return
1533
+ path = _status_md_path()
1534
+ path.parent.mkdir(parents=True, exist_ok=True)
1535
+ path.write_text(_report.render_markdown(rep, details=True))
1536
+ except Exception:
1537
+ pass # rendering is best-effort, never breaks the recipe
1538
+
1539
+
1540
+ def _flush_status() -> None:
1541
+ """Persist the in-progress sidecar and re-render status.md.
1542
+
1543
+ Called after every step so the current commit's `eval.json` and the rendered
1544
+ `status.md` reflect progress live (the commit shows as the in-flight `todo`
1545
+ row with its steps so far) — open `.bisect/status.md` in your editor and it
1546
+ refreshes as a long build/test runs, instead of only updating once the
1547
+ verdict lands.
1548
+ """
1549
+ _write_sidecar()
1550
+ _refresh_status_md()
1551
+
1552
+
1553
+ @atexit.register
1554
+ def _finalize() -> None:
1555
+ global _finalized
1556
+ if _finalized:
1557
+ return
1558
+ _finalized = True
1559
+ # leftover applied fixups (no `with` block) get reverted to keep the tree clean
1560
+ if "fixups" in _final or _reverts:
1561
+ _revert_tree()
1562
+ for r in _reverts:
1563
+ r()
1564
+ # commit each armed once() marker only if this evaluation produced a real
1565
+ # verdict (not an abort) — a setup that aborted should re-run next time
1566
+ if _once_pending and _final.get("code") != ABORT:
1567
+ for key in _once_pending:
1568
+ try:
1569
+ m = _once_marker(key)
1570
+ m.parent.mkdir(parents=True, exist_ok=True)
1571
+ m.write_text("done")
1572
+ except OSError:
1573
+ pass
1574
+ _write_sidecar()
1575
+ _refresh_status_md(decided=True)
1576
+ # In the good-hunting phase (bad known, good not yet), print the copy-pasteable
1577
+ # next step for this verdict. No-op during a real `git bisect run` or a
1578
+ # pre-start smoke test, so it never intrudes on the automated search.
1579
+ _guided_finish()
1580
+
1581
+
1582
+ def _excepthook(exc_type, exc, tb):
1583
+ """An uncaught error in a recipe is a harness bug -> ABORT, never 'bad'.
1584
+
1585
+ Finalize by hand first: `os._exit` below skips the atexit-registered
1586
+ `_finalize`, so without this any `fixup`/`replace` edit would be left in the
1587
+ tree (violating the clean-tree guarantee — SPEC §2.2) and status.md would
1588
+ stay stuck on the in-flight step. `_finalize` reverts edits, records the
1589
+ abort, and does *not* commit any armed `once()` markers (code == ABORT).
1590
+ """
1591
+ import traceback
1592
+ traceback.print_exception(exc_type, exc, tb)
1593
+ _final["outcome"], _final["code"] = "abort", ABORT
1594
+ try:
1595
+ _finalize()
1596
+ except Exception:
1597
+ pass # never let cleanup mask the original error or block the exit
1598
+ os._exit(ABORT)
1599
+
1600
+
1601
+ sys.excepthook = _excepthook