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 +1601 -0
- bisectlib/_report.py +701 -0
- bisectlib/py.typed +0 -0
- git_bisectlib-0.16.1.dist-info/METADATA +386 -0
- git_bisectlib-0.16.1.dist-info/RECORD +8 -0
- git_bisectlib-0.16.1.dist-info/WHEEL +5 -0
- git_bisectlib-0.16.1.dist-info/licenses/LICENSE +21 -0
- git_bisectlib-0.16.1.dist-info/top_level.txt +1 -0
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
|