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/_report.py
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reconstruct a `git bisect` session and render it as the Markdown status page.
|
|
3
|
+
|
|
4
|
+
Internal to bisectlib: the engine calls :func:`build_report` + :func:`render_markdown`
|
|
5
|
+
after every step to (re)write ``.bisect/status.md`` — the live, watchable report.
|
|
6
|
+
|
|
7
|
+
Read-only and stateless: the entire report derives from only
|
|
8
|
+
(1) `git bisect log`, and
|
|
9
|
+
(2) per-commit information (git metadata + each commit's optional `eval.json`
|
|
10
|
+
sidecar of recorded facts written by the engine).
|
|
11
|
+
|
|
12
|
+
No reflog, no /proc, no PID, no heuristic inference. If a fact wasn't logged or
|
|
13
|
+
recorded, it isn't shown.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import shlex
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
STATUS_ICON = {"good": "🟢", "bad": "🔴", "skip": "⏭️", "todo": "🕒", "abort": "🛑"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --------------------------------------------------------------------------- git
|
|
29
|
+
class GitError(RuntimeError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def git(repo: str, *args: str, check: bool = True) -> str:
|
|
34
|
+
"""Run a git command in ``repo`` and return stripped stdout."""
|
|
35
|
+
proc = subprocess.run(
|
|
36
|
+
["git", "-C", repo, *args],
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
)
|
|
40
|
+
if check and proc.returncode != 0:
|
|
41
|
+
raise GitError(
|
|
42
|
+
f"git {' '.join(args)} failed ({proc.returncode}): {proc.stderr.strip()}"
|
|
43
|
+
)
|
|
44
|
+
return proc.stdout.strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def git_ok(repo: str, *args: str) -> bool:
|
|
48
|
+
return (
|
|
49
|
+
subprocess.run(
|
|
50
|
+
["git", "-C", repo, *args], capture_output=True, text=True
|
|
51
|
+
).returncode
|
|
52
|
+
== 0
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# `git bisect run` re-renders the report after every step, but commit-graph facts
|
|
57
|
+
# — a rev's sha, ancestry, a range's size, a commit's metadata — and the bisect
|
|
58
|
+
# terms never change within a process; only the sidecars do. Memoise those graph
|
|
59
|
+
# queries keyed by their inputs (repo included, so several repos in one process
|
|
60
|
+
# never collide). The cached values are immutable, so the report a caller gets is
|
|
61
|
+
# identical to the uncached one; only the redundant git subprocesses go away.
|
|
62
|
+
# Sidecars and HEAD are deliberately never cached (they move as the bisect runs).
|
|
63
|
+
_terms_cache: dict = {}
|
|
64
|
+
_ancestor_cache: dict = {}
|
|
65
|
+
_resolve_cache: dict = {}
|
|
66
|
+
_meta_cache: dict = {}
|
|
67
|
+
_rangecount_cache: dict = {}
|
|
68
|
+
_toplevel_cache: dict = {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_ancestor(repo: str, a: str, b: str) -> bool:
|
|
72
|
+
"""True if commit ``a`` is an ancestor of ``b`` (or a == b)."""
|
|
73
|
+
if a == b:
|
|
74
|
+
return True
|
|
75
|
+
key = (repo, a, b)
|
|
76
|
+
hit = _ancestor_cache.get(key)
|
|
77
|
+
if hit is None:
|
|
78
|
+
hit = git_ok(repo, "merge-base", "--is-ancestor", a, b)
|
|
79
|
+
_ancestor_cache[key] = hit
|
|
80
|
+
return hit
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------- bisect terms
|
|
84
|
+
def bisect_terms(repo: str) -> tuple[str, str]:
|
|
85
|
+
"""Return (bad_term, good_term), honouring custom terms; default bad/good."""
|
|
86
|
+
hit = _terms_cache.get(repo)
|
|
87
|
+
if hit is None:
|
|
88
|
+
bad = git(repo, "bisect", "terms", "--term-bad", check=False) or "bad"
|
|
89
|
+
good = git(repo, "bisect", "terms", "--term-good", check=False) or "good"
|
|
90
|
+
hit = (bad.strip() or "bad", good.strip() or "good")
|
|
91
|
+
_terms_cache[repo] = hit
|
|
92
|
+
return hit
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def bisect_log(repo: str) -> str:
|
|
96
|
+
"""Raw `git bisect log` text, or '' if no bisect is in progress."""
|
|
97
|
+
return git(repo, "bisect", "log", check=False)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------- data model
|
|
101
|
+
@dataclass
|
|
102
|
+
class Step:
|
|
103
|
+
verb: str
|
|
104
|
+
cmd: str
|
|
105
|
+
code: Optional[int] = None
|
|
106
|
+
outcome: Optional[str] = None
|
|
107
|
+
duration_s: Optional[float] = None
|
|
108
|
+
log: Optional[str] = None
|
|
109
|
+
extra: dict = field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class Sidecar:
|
|
114
|
+
outcome: Optional[str] = None
|
|
115
|
+
exit_code: Optional[int] = None
|
|
116
|
+
duration_s: Optional[float] = None
|
|
117
|
+
steps: list[Step] = field(default_factory=list)
|
|
118
|
+
fixups: list[dict] = field(default_factory=list)
|
|
119
|
+
pending: bool = True # True until the recipe locked in a verdict (see engine)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class Row:
|
|
124
|
+
bad: str # input-range bad bound (sha) before this evaluation
|
|
125
|
+
good: str # input-range good bound (sha) before this evaluation
|
|
126
|
+
midpoint: str # the commit evaluated this step
|
|
127
|
+
status: str # good | bad | skip | todo
|
|
128
|
+
n_commits: int = 0 # candidate commits still in range at this step
|
|
129
|
+
span_seconds: int = 0 # wall span between good and bad commit dates
|
|
130
|
+
good_date: str = ""
|
|
131
|
+
bad_date: str = ""
|
|
132
|
+
sidecar: Optional[Sidecar] = None
|
|
133
|
+
goods: list = field(default_factory=list) # ALL goods bounding the range here
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class Report:
|
|
138
|
+
repo: str
|
|
139
|
+
bad_term: str
|
|
140
|
+
good_term: str
|
|
141
|
+
orig_bad: Optional[str]
|
|
142
|
+
orig_goods: list[str]
|
|
143
|
+
rows: list[Row]
|
|
144
|
+
current_bad: Optional[str]
|
|
145
|
+
current_good: Optional[str]
|
|
146
|
+
first_bad: Optional[str]
|
|
147
|
+
in_progress: bool
|
|
148
|
+
head: Optional[str]
|
|
149
|
+
subjects: dict[str, str] = field(default_factory=dict)
|
|
150
|
+
dates: dict[str, str] = field(default_factory=dict)
|
|
151
|
+
authors: dict[str, str] = field(default_factory=dict)
|
|
152
|
+
note: str = ""
|
|
153
|
+
|
|
154
|
+
def short(self, sha: Optional[str]) -> str:
|
|
155
|
+
return sha[:9] if sha else "—"
|
|
156
|
+
|
|
157
|
+
def subject(self, sha: Optional[str]) -> str:
|
|
158
|
+
return self.subjects.get(sha, "") if sha else ""
|
|
159
|
+
|
|
160
|
+
def author(self, sha: Optional[str]) -> str:
|
|
161
|
+
return self.authors.get(sha, "") if sha else ""
|
|
162
|
+
|
|
163
|
+
def commit_meta(self, sha: Optional[str]) -> str:
|
|
164
|
+
"""`YYYY-MM-DD HH:MM, Author` for a commit cell — date then author, no subject."""
|
|
165
|
+
if not sha:
|
|
166
|
+
return ""
|
|
167
|
+
iso = self.dates.get(sha, "")
|
|
168
|
+
parts = [p for p in (fmt_date(iso) if iso else "", self.author(sha)) if p]
|
|
169
|
+
return ", ".join(parts)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --------------------------------------------------------------------- log parsing
|
|
173
|
+
def parse_log(log_text: str) -> list[tuple[str, list[str]]]:
|
|
174
|
+
"""Parse `git bisect log` into ordered ('verb', [revs/args]) operations."""
|
|
175
|
+
ops: list[tuple[str, list[str]]] = []
|
|
176
|
+
for line in log_text.splitlines():
|
|
177
|
+
line = line.strip()
|
|
178
|
+
if not line.startswith("git bisect "):
|
|
179
|
+
continue
|
|
180
|
+
try:
|
|
181
|
+
parts = shlex.split(line)
|
|
182
|
+
except ValueError:
|
|
183
|
+
parts = line.split()
|
|
184
|
+
rest = parts[2:] # drop "git", "bisect"
|
|
185
|
+
if not rest:
|
|
186
|
+
continue
|
|
187
|
+
verb, args = rest[0], rest[1:]
|
|
188
|
+
ops.append((verb, args))
|
|
189
|
+
return ops
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------- reconstruction
|
|
193
|
+
def build_report(
|
|
194
|
+
repo: str,
|
|
195
|
+
log_text: Optional[str] = None,
|
|
196
|
+
logs_dir: Optional[str] = None,
|
|
197
|
+
head: Optional[str] = None,
|
|
198
|
+
) -> Optional[Report]:
|
|
199
|
+
"""Reconstruct the full bisect report from `git bisect log` + commit info.
|
|
200
|
+
|
|
201
|
+
``log_text`` and ``head`` may be supplied by a caller that already knows them
|
|
202
|
+
(the engine holds both fixed for its process); when omitted they are read from
|
|
203
|
+
git. Everything else derives from the immutable commit graph and is memoised.
|
|
204
|
+
"""
|
|
205
|
+
given = repo
|
|
206
|
+
repo = _toplevel_cache.get(given)
|
|
207
|
+
if repo is None:
|
|
208
|
+
repo = git(given, "rev-parse", "--show-toplevel")
|
|
209
|
+
_toplevel_cache[given] = repo
|
|
210
|
+
if log_text is None:
|
|
211
|
+
log_text = bisect_log(repo)
|
|
212
|
+
if not log_text.strip():
|
|
213
|
+
return None # no bisect in progress / nothing to render
|
|
214
|
+
|
|
215
|
+
bad_term, good_term = bisect_terms(repo)
|
|
216
|
+
ops = parse_log(log_text)
|
|
217
|
+
|
|
218
|
+
def resolve(rev: str) -> Optional[str]:
|
|
219
|
+
key = (repo, rev)
|
|
220
|
+
if key not in _resolve_cache:
|
|
221
|
+
out = git(repo, "rev-parse", "--verify", "--quiet",
|
|
222
|
+
rev + "^{commit}", check=False)
|
|
223
|
+
_resolve_cache[key] = out or None
|
|
224
|
+
return _resolve_cache[key]
|
|
225
|
+
|
|
226
|
+
orig_bad: Optional[str] = None
|
|
227
|
+
orig_goods: list[str] = []
|
|
228
|
+
current_bad: Optional[str] = None
|
|
229
|
+
current_good: Optional[str] = None
|
|
230
|
+
active_goods: list[str] = [] # every good marked so far (defines the range)
|
|
231
|
+
rows: list[Row] = []
|
|
232
|
+
seen_midpoints: set[str] = set()
|
|
233
|
+
|
|
234
|
+
def ready() -> bool:
|
|
235
|
+
return current_bad is not None and current_good is not None
|
|
236
|
+
|
|
237
|
+
def _add_good(sha: str) -> None:
|
|
238
|
+
if sha not in active_goods:
|
|
239
|
+
active_goods.append(sha)
|
|
240
|
+
|
|
241
|
+
def _anchor_good(sha: str) -> None:
|
|
242
|
+
# An *anchor* good establishes the good bound. Establish it directly the
|
|
243
|
+
# first time (do NOT gate on ancestry — a shallow clone or grafted history
|
|
244
|
+
# can make `merge-base --is-ancestor` fail, which would otherwise leave the
|
|
245
|
+
# range never "ready" and cause the first real evaluation to be swallowed
|
|
246
|
+
# as another anchor). Additional anchor goods tighten via set_good.
|
|
247
|
+
nonlocal current_good
|
|
248
|
+
if current_good is None:
|
|
249
|
+
current_good = sha
|
|
250
|
+
else:
|
|
251
|
+
set_good(sha)
|
|
252
|
+
_add_good(sha)
|
|
253
|
+
|
|
254
|
+
def set_good(sha: str) -> None:
|
|
255
|
+
nonlocal current_good
|
|
256
|
+
# Tighten the good bound to the newest good that is an ancestor of bad.
|
|
257
|
+
if current_bad and not is_ancestor(repo, sha, current_bad):
|
|
258
|
+
return
|
|
259
|
+
if current_good is None or is_ancestor(repo, current_good, sha):
|
|
260
|
+
current_good = sha
|
|
261
|
+
|
|
262
|
+
def add_row(midpoint: str, status: str) -> None:
|
|
263
|
+
rows.append(
|
|
264
|
+
Row(
|
|
265
|
+
bad=current_bad,
|
|
266
|
+
good=current_good,
|
|
267
|
+
midpoint=midpoint,
|
|
268
|
+
status=status,
|
|
269
|
+
goods=list(active_goods),
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
seen_midpoints.add(midpoint)
|
|
273
|
+
|
|
274
|
+
for verb, args in ops:
|
|
275
|
+
revs = [a for a in args if not a.startswith("-")]
|
|
276
|
+
if verb == "start":
|
|
277
|
+
# `git bisect start [<bad> [<good>...]]`: first positional = bad, rest = good
|
|
278
|
+
shas = [s for s in (resolve(r) for r in revs) if s]
|
|
279
|
+
if shas:
|
|
280
|
+
if current_bad is None:
|
|
281
|
+
current_bad = orig_bad = shas[0]
|
|
282
|
+
for g in shas[1:]:
|
|
283
|
+
orig_goods.append(g)
|
|
284
|
+
_anchor_good(g)
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
term = verb
|
|
288
|
+
sha = resolve(revs[0]) if revs else None
|
|
289
|
+
if sha is None:
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
if term == bad_term:
|
|
293
|
+
if not ready(): # anchor
|
|
294
|
+
current_bad = orig_bad = current_bad or sha
|
|
295
|
+
else: # evaluation
|
|
296
|
+
add_row(sha, "bad")
|
|
297
|
+
current_bad = sha
|
|
298
|
+
elif term == good_term:
|
|
299
|
+
if not ready(): # anchor
|
|
300
|
+
orig_goods.append(sha)
|
|
301
|
+
_anchor_good(sha)
|
|
302
|
+
else: # evaluation
|
|
303
|
+
add_row(sha, "good")
|
|
304
|
+
# Trust git: an evaluation `good` is the new good bound (git only
|
|
305
|
+
# marks commits it picked inside the range), exactly mirroring how
|
|
306
|
+
# a `bad` sets current_bad. Do NOT gate this on ancestry — in a
|
|
307
|
+
# DAG the newly-good commit need not be a descendant of the prior
|
|
308
|
+
# good, and on shallow clones the check can't be verified, either
|
|
309
|
+
# of which would otherwise freeze the good bound and the range.
|
|
310
|
+
current_good = sha
|
|
311
|
+
_add_good(sha) # excluded from the range for subsequent rows
|
|
312
|
+
elif term == "skip":
|
|
313
|
+
if ready():
|
|
314
|
+
add_row(sha, "skip")
|
|
315
|
+
# a skip before ready is unusual; ignore for bounds
|
|
316
|
+
|
|
317
|
+
if head is None:
|
|
318
|
+
head = git(repo, "rev-parse", "HEAD", check=False) or None
|
|
319
|
+
|
|
320
|
+
# Determine the first-bad answer / progress. The candidate set is commits
|
|
321
|
+
# reachable from bad but from NONE of the goods (git excludes ancestors of
|
|
322
|
+
# every good, not just the latest — crucial in a DAG where goods diverge).
|
|
323
|
+
def _range_count(bad: str, goods: list) -> int:
|
|
324
|
+
goods = [g for g in goods if g]
|
|
325
|
+
if not goods:
|
|
326
|
+
return 0
|
|
327
|
+
key = (repo, bad, tuple(goods))
|
|
328
|
+
hit = _rangecount_cache.get(key)
|
|
329
|
+
if hit is None:
|
|
330
|
+
hit = int(git(repo, "rev-list", "--count", bad, "--not", *goods) or 0)
|
|
331
|
+
_rangecount_cache[key] = hit
|
|
332
|
+
return hit
|
|
333
|
+
|
|
334
|
+
first_bad: Optional[str] = None
|
|
335
|
+
n_remaining = None
|
|
336
|
+
if ready():
|
|
337
|
+
n_remaining = _range_count(current_bad, active_goods)
|
|
338
|
+
if n_remaining <= 1:
|
|
339
|
+
first_bad = current_bad
|
|
340
|
+
|
|
341
|
+
# In-flight row: HEAD is the midpoint git currently has checked out, awaiting a
|
|
342
|
+
# verdict, and is not yet a logged marking.
|
|
343
|
+
in_progress = False
|
|
344
|
+
if (
|
|
345
|
+
ready()
|
|
346
|
+
and first_bad is None
|
|
347
|
+
and head
|
|
348
|
+
and head not in seen_midpoints
|
|
349
|
+
and head != current_good
|
|
350
|
+
):
|
|
351
|
+
within_range = (
|
|
352
|
+
is_ancestor(repo, current_good, head)
|
|
353
|
+
and is_ancestor(repo, head, current_bad)
|
|
354
|
+
)
|
|
355
|
+
# A per-commit sidecar written by the engine is proof HEAD is the commit
|
|
356
|
+
# being evaluated right now — trust it when the ancestry checks can't
|
|
357
|
+
# confirm the range (shallow/grafted clone, or an anchor good that isn't a
|
|
358
|
+
# topological ancestor of the midpoint). This keeps status.md live (the
|
|
359
|
+
# in-flight row and its steps refresh after every command) on such repos.
|
|
360
|
+
has_sidecar = bool(logs_dir) and (Path(logs_dir) / head / "eval.json").is_file()
|
|
361
|
+
if within_range or has_sidecar:
|
|
362
|
+
add_row(head, "todo")
|
|
363
|
+
in_progress = True
|
|
364
|
+
|
|
365
|
+
# Gather subjects + dates for every sha we reference.
|
|
366
|
+
shas = set()
|
|
367
|
+
for r in rows:
|
|
368
|
+
shas.update([r.bad, r.good, r.midpoint])
|
|
369
|
+
shas.update([orig_bad, current_bad, current_good, *orig_goods])
|
|
370
|
+
shas.discard(None)
|
|
371
|
+
subjects, dates, authors = _commit_meta(repo, shas)
|
|
372
|
+
|
|
373
|
+
# Fill range metrics + sidecars per row.
|
|
374
|
+
for r in rows:
|
|
375
|
+
if r.bad and r.goods:
|
|
376
|
+
r.n_commits = _range_count(r.bad, r.goods)
|
|
377
|
+
elif r.good and r.bad: # fallback (shouldn't happen once ready)
|
|
378
|
+
r.n_commits = int(git(repo, "rev-list", "--count", f"{r.good}..{r.bad}") or 0)
|
|
379
|
+
if r.good and r.bad:
|
|
380
|
+
r.good_date = dates.get(r.good, "")
|
|
381
|
+
r.bad_date = dates.get(r.bad, "")
|
|
382
|
+
r.span_seconds = _date_delta_seconds(r.good_date, r.bad_date)
|
|
383
|
+
if logs_dir:
|
|
384
|
+
r.sidecar = _load_sidecar(logs_dir, r.midpoint)
|
|
385
|
+
|
|
386
|
+
# The in-flight commit (HEAD) is a `todo` row because git hasn't recorded its
|
|
387
|
+
# mark yet — but if the recipe already finished and its sidecar carries a
|
|
388
|
+
# locked-in verdict, show that instead so the saved status.md reflects the
|
|
389
|
+
# completed evaluation rather than a perpetual `todo`.
|
|
390
|
+
if in_progress and rows and rows[-1].status == "todo":
|
|
391
|
+
sc = rows[-1].sidecar
|
|
392
|
+
if sc and not sc.pending and sc.outcome in ("good", "bad", "skip", "abort"):
|
|
393
|
+
rows[-1].status = sc.outcome
|
|
394
|
+
|
|
395
|
+
note = ""
|
|
396
|
+
if ready() and first_bad is None and not in_progress and n_remaining and n_remaining > 1:
|
|
397
|
+
# No current checkout and range unresolved -> likely only skips remain.
|
|
398
|
+
skips = [r for r in rows if r.status == "skip"]
|
|
399
|
+
if skips:
|
|
400
|
+
note = (
|
|
401
|
+
"Bisect stalled: only skipped commits left to test in the current "
|
|
402
|
+
"range — git cannot name a single first-bad commit."
|
|
403
|
+
)
|
|
404
|
+
else:
|
|
405
|
+
note = "Bisect not finished (no commit currently checked out)."
|
|
406
|
+
|
|
407
|
+
return Report(
|
|
408
|
+
repo=repo,
|
|
409
|
+
bad_term=bad_term,
|
|
410
|
+
good_term=good_term,
|
|
411
|
+
orig_bad=orig_bad,
|
|
412
|
+
orig_goods=orig_goods,
|
|
413
|
+
rows=rows,
|
|
414
|
+
current_bad=current_bad,
|
|
415
|
+
current_good=current_good,
|
|
416
|
+
first_bad=first_bad,
|
|
417
|
+
in_progress=in_progress,
|
|
418
|
+
head=head,
|
|
419
|
+
subjects=subjects,
|
|
420
|
+
dates=dates,
|
|
421
|
+
authors=authors,
|
|
422
|
+
note=note,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ------------------------------------------------------------- commit metadata
|
|
427
|
+
def _commit_meta(repo: str, shas) -> tuple[dict, dict, dict]:
|
|
428
|
+
"""Subject, ISO date and author for each sha, in a single `git show` apiece.
|
|
429
|
+
|
|
430
|
+
Replaces three separate per-sha calls (subject/date/author) with one — a
|
|
431
|
+
third of the git spawns per render, and the report re-renders after every
|
|
432
|
+
step. `%s` is always a single line, so splitting the output is unambiguous.
|
|
433
|
+
"""
|
|
434
|
+
subjects: dict[str, str] = {}
|
|
435
|
+
dates: dict[str, str] = {}
|
|
436
|
+
authors: dict[str, str] = {}
|
|
437
|
+
for sha in shas:
|
|
438
|
+
if not sha:
|
|
439
|
+
continue
|
|
440
|
+
key = (repo, sha)
|
|
441
|
+
hit = _meta_cache.get(key)
|
|
442
|
+
if hit is None:
|
|
443
|
+
out = git(repo, "show", "-s", "--format=%s%n%cI%n%an", sha, check=False)
|
|
444
|
+
parts = out.split("\n", 2)
|
|
445
|
+
hit = (parts[0] if len(parts) > 0 else "",
|
|
446
|
+
parts[1] if len(parts) > 1 else "",
|
|
447
|
+
parts[2] if len(parts) > 2 else "")
|
|
448
|
+
_meta_cache[key] = hit
|
|
449
|
+
subjects[sha], dates[sha], authors[sha] = hit
|
|
450
|
+
return subjects, dates, authors
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _parse_iso(s: str) -> datetime:
|
|
454
|
+
"""Parse an ISO-8601 timestamp, tolerating a trailing ``Z``.
|
|
455
|
+
|
|
456
|
+
Recent git emits UTC as ``…T12:00:00Z`` for ``%cI``; Python 3.10's
|
|
457
|
+
``datetime.fromisoformat`` rejects the ``Z`` suffix (3.11+ accepts it), so
|
|
458
|
+
normalise it to an explicit ``+00:00`` offset first.
|
|
459
|
+
"""
|
|
460
|
+
if s.endswith(("Z", "z")):
|
|
461
|
+
s = s[:-1] + "+00:00"
|
|
462
|
+
return datetime.fromisoformat(s)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _date_delta_seconds(a: str, b: str) -> int:
|
|
466
|
+
try:
|
|
467
|
+
return int(abs((_parse_iso(b) - _parse_iso(a)).total_seconds()))
|
|
468
|
+
except (ValueError, TypeError):
|
|
469
|
+
return 0
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def fmt_duration(seconds: int) -> str:
|
|
473
|
+
if seconds <= 0:
|
|
474
|
+
return "0m"
|
|
475
|
+
d, rem = divmod(seconds, 86400)
|
|
476
|
+
h, rem = divmod(rem, 3600)
|
|
477
|
+
m, _ = divmod(rem, 60)
|
|
478
|
+
if d:
|
|
479
|
+
return f"{d}d {h}h {m}m"
|
|
480
|
+
if h:
|
|
481
|
+
return f"{h}h {m}m"
|
|
482
|
+
return f"{m}m"
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def short_seconds(seconds) -> str:
|
|
486
|
+
"""Compact runtime: '0.5s', '12.4s', '45s', '1m03s', '2h05m'.
|
|
487
|
+
|
|
488
|
+
Used for hammer's total wall time. Sub-minute runs keep one decimal (a short
|
|
489
|
+
soak reads '0.5s', not a rounded '1s'), dropping a trailing '.0'; from a
|
|
490
|
+
minute up it switches to m/s then h/m where the fraction stops mattering.
|
|
491
|
+
"""
|
|
492
|
+
try:
|
|
493
|
+
sec = float(seconds)
|
|
494
|
+
except (ValueError, TypeError):
|
|
495
|
+
return "?"
|
|
496
|
+
if sec < 60:
|
|
497
|
+
return f"{sec:.1f}".rstrip("0").rstrip(".") + "s"
|
|
498
|
+
s = int(round(sec))
|
|
499
|
+
m, s = divmod(s, 60)
|
|
500
|
+
if m < 60:
|
|
501
|
+
return f"{m}m{s:02d}s"
|
|
502
|
+
h, m = divmod(m, 60)
|
|
503
|
+
return f"{h}h{m:02d}m"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def fmt_date(iso: str) -> str:
|
|
507
|
+
try:
|
|
508
|
+
return _parse_iso(iso).astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M")
|
|
509
|
+
except (ValueError, TypeError):
|
|
510
|
+
return iso or "—"
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# ------------------------------------------------------------------- sidecars
|
|
514
|
+
def _load_sidecar(logs_dir: str, sha: str) -> Optional[Sidecar]:
|
|
515
|
+
path = Path(logs_dir) / sha / "eval.json"
|
|
516
|
+
if not path.is_file():
|
|
517
|
+
# also try short-sha dirs
|
|
518
|
+
candidates = list(Path(logs_dir).glob(f"{sha[:12]}*/eval.json"))
|
|
519
|
+
if not candidates:
|
|
520
|
+
return None
|
|
521
|
+
path = candidates[0]
|
|
522
|
+
try:
|
|
523
|
+
data = json.loads(path.read_text())
|
|
524
|
+
except (OSError, json.JSONDecodeError):
|
|
525
|
+
return None
|
|
526
|
+
steps = [
|
|
527
|
+
Step(
|
|
528
|
+
verb=s.get("verb", ""),
|
|
529
|
+
cmd=s.get("cmd", ""),
|
|
530
|
+
code=s.get("code"),
|
|
531
|
+
outcome=s.get("outcome"),
|
|
532
|
+
duration_s=s.get("duration_s"),
|
|
533
|
+
log=s.get("log"),
|
|
534
|
+
extra={
|
|
535
|
+
k: v
|
|
536
|
+
for k, v in s.items()
|
|
537
|
+
if k not in {"verb", "cmd", "code", "outcome", "duration_s", "log"}
|
|
538
|
+
},
|
|
539
|
+
)
|
|
540
|
+
for s in data.get("steps", [])
|
|
541
|
+
]
|
|
542
|
+
return Sidecar(
|
|
543
|
+
outcome=data.get("outcome"),
|
|
544
|
+
exit_code=data.get("exit_code"),
|
|
545
|
+
duration_s=data.get("duration_s"),
|
|
546
|
+
steps=steps,
|
|
547
|
+
fixups=data.get("fixups", []),
|
|
548
|
+
pending=data.get("pending", True),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _step_summary(sc: Optional[Sidecar]) -> str:
|
|
553
|
+
"""Inline recorded detail for a row's status cell, e.g. '2/5 · 1.8s'.
|
|
554
|
+
|
|
555
|
+
While a command is executing, its step carries no exit code yet; show what is
|
|
556
|
+
running right now so the top-level table names the in-flight command at a
|
|
557
|
+
glance (the Details section links its live log)."""
|
|
558
|
+
if not sc:
|
|
559
|
+
return ""
|
|
560
|
+
for s in sc.steps:
|
|
561
|
+
if s.code is None:
|
|
562
|
+
cmd = s.cmd if len(s.cmd) <= 60 else s.cmd[:59] + "…"
|
|
563
|
+
return f"⏳ running `{cmd}`"
|
|
564
|
+
bits = []
|
|
565
|
+
for s in sc.steps:
|
|
566
|
+
if s.verb == "hammer":
|
|
567
|
+
# total runs, parallel threads used, and total runtime
|
|
568
|
+
seg = [f"{s.extra.get('executed', 0)} runs"]
|
|
569
|
+
par = s.extra.get("parallel")
|
|
570
|
+
if par:
|
|
571
|
+
seg.append(f"{par}× parallel")
|
|
572
|
+
el = s.extra.get("elapsed_s")
|
|
573
|
+
if el is not None:
|
|
574
|
+
seg.append(short_seconds(el))
|
|
575
|
+
bits.append(" · ".join(seg))
|
|
576
|
+
d = s.extra.get("durations_s")
|
|
577
|
+
if d:
|
|
578
|
+
bits.append(f"min {min(d):.3g}s")
|
|
579
|
+
elif s.verb == "test":
|
|
580
|
+
executed = s.extra.get("executed")
|
|
581
|
+
if executed and executed > 1:
|
|
582
|
+
bits.append(f"{s.extra.get('passes', 0)}/{executed}")
|
|
583
|
+
d = s.extra.get("durations_s")
|
|
584
|
+
if d:
|
|
585
|
+
bits.append(f"min {min(d):.3g}s")
|
|
586
|
+
if not bits and sc.duration_s is not None:
|
|
587
|
+
bits.append(f"{sc.duration_s:.3g}s")
|
|
588
|
+
return " · ".join(bits)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# ------------------------------------------------------------------ resume line
|
|
592
|
+
def resume_command(rep: Report) -> Optional[str]:
|
|
593
|
+
if rep.current_bad and rep.current_good:
|
|
594
|
+
return (
|
|
595
|
+
f"git bisect start {rep.short(rep.current_bad)} "
|
|
596
|
+
f"{rep.short(rep.current_good)}"
|
|
597
|
+
)
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# -------------------------------------------------------------------- Markdown
|
|
602
|
+
def render_markdown(rep: Report, details: bool = False, color: bool = True) -> str:
|
|
603
|
+
def icon(status: str) -> str:
|
|
604
|
+
return (STATUS_ICON.get(status, "") + " " if color else "") + status
|
|
605
|
+
|
|
606
|
+
def cell(sha: str) -> str:
|
|
607
|
+
meta = rep.commit_meta(sha).replace("|", "\\|")
|
|
608
|
+
return f"`{rep.short(sha)}` {meta}" if meta else f"`{rep.short(sha)}`"
|
|
609
|
+
|
|
610
|
+
lines: list[str] = []
|
|
611
|
+
title = "Bisect report"
|
|
612
|
+
lines.append(f"# {title}")
|
|
613
|
+
lines.append("")
|
|
614
|
+
ob, og = rep.orig_bad, (rep.orig_goods[0] if rep.orig_goods else None)
|
|
615
|
+
lines.append(
|
|
616
|
+
f"**original range:** {rep.good_term} `{rep.short(og)}` · "
|
|
617
|
+
f"{rep.bad_term} `{rep.short(ob)}`"
|
|
618
|
+
)
|
|
619
|
+
resume = resume_command(rep)
|
|
620
|
+
if resume and not rep.first_bad:
|
|
621
|
+
lines.append(f"**resume:** `{resume}`")
|
|
622
|
+
if rep.first_bad:
|
|
623
|
+
lines.append("")
|
|
624
|
+
lines.append(
|
|
625
|
+
f"## 🎯 First bad commit: `{rep.short(rep.first_bad)}` "
|
|
626
|
+
f"— {rep.subject(rep.first_bad)}"
|
|
627
|
+
)
|
|
628
|
+
# Show the full commit (header, message, diffstat) the way `git bisect`
|
|
629
|
+
# reports it when it lands on the first bad commit. One `git show --stat`
|
|
630
|
+
# yields the commit metadata, message, and per-file stat without the diff.
|
|
631
|
+
full = git(rep.repo, "show", "--stat", "--format=medium",
|
|
632
|
+
rep.first_bad, check=False)
|
|
633
|
+
if full:
|
|
634
|
+
lines.append("")
|
|
635
|
+
lines.append("```")
|
|
636
|
+
lines.append(full)
|
|
637
|
+
lines.append("```")
|
|
638
|
+
if rep.note:
|
|
639
|
+
lines.append("")
|
|
640
|
+
lines.append(f"> ⚠️ {rep.note}")
|
|
641
|
+
lines.append("")
|
|
642
|
+
|
|
643
|
+
lines.append("| good | bad | midpoint | range | status |")
|
|
644
|
+
lines.append("|------|-----|----------|-------|--------|")
|
|
645
|
+
for r in rep.rows:
|
|
646
|
+
rng = f"{fmt_duration(r.span_seconds)} · {r.n_commits} commits"
|
|
647
|
+
status = icon(r.status)
|
|
648
|
+
extra = _step_summary(r.sidecar)
|
|
649
|
+
if extra:
|
|
650
|
+
status += f" · {extra}"
|
|
651
|
+
lines.append(
|
|
652
|
+
f"| {cell(r.good)} | {cell(r.bad)} | {cell(r.midpoint)} | {rng} | {status} |"
|
|
653
|
+
)
|
|
654
|
+
lines.append("")
|
|
655
|
+
|
|
656
|
+
if details:
|
|
657
|
+
detail_rows = [r for r in rep.rows if r.sidecar]
|
|
658
|
+
if detail_rows:
|
|
659
|
+
lines.append("## Details")
|
|
660
|
+
lines.append("")
|
|
661
|
+
for r in detail_rows:
|
|
662
|
+
lines.append(
|
|
663
|
+
f"### `{rep.short(r.midpoint)}` — {rep.subject(r.midpoint)} "
|
|
664
|
+
f"({icon(r.status)})"
|
|
665
|
+
)
|
|
666
|
+
if r.sidecar.fixups:
|
|
667
|
+
fx = ", ".join(
|
|
668
|
+
f"{f.get('kind')}: `{f.get('detail', f.get('path',''))}`"
|
|
669
|
+
for f in r.sidecar.fixups
|
|
670
|
+
)
|
|
671
|
+
lines.append(f"- fixups: {fx}")
|
|
672
|
+
for s in r.sidecar.steps:
|
|
673
|
+
if s.verb != "hammer":
|
|
674
|
+
continue
|
|
675
|
+
el = s.extra.get("elapsed_s")
|
|
676
|
+
rt = short_seconds(el) if el is not None else "?"
|
|
677
|
+
lines.append(
|
|
678
|
+
f"- hammer: **{s.extra.get('executed', 0)} runs** · "
|
|
679
|
+
f"**{s.extra.get('parallel', '?')}× parallel** · "
|
|
680
|
+
f"**{rt}** total · "
|
|
681
|
+
f"{s.extra.get('passes', 0)} passed, "
|
|
682
|
+
f"{s.extra.get('failures', 0)} failed"
|
|
683
|
+
)
|
|
684
|
+
lines.append("")
|
|
685
|
+
lines.append("| step | cmd | exit | time |")
|
|
686
|
+
lines.append("|------|-----|------|------|")
|
|
687
|
+
for s in r.sidecar.steps:
|
|
688
|
+
# a step with no exit code yet is running right now
|
|
689
|
+
running = s.code is None
|
|
690
|
+
dur = ("running…" if running else
|
|
691
|
+
(f"{s.duration_s:.3g}s" if s.duration_s is not None else ""))
|
|
692
|
+
code = "⏳" if running else str(s.code)
|
|
693
|
+
# link the step to its captured log file (relative to status.md,
|
|
694
|
+
# which sits alongside the per-commit <sha>/ log dirs); the log
|
|
695
|
+
# streams live, so the link is watchable while the step runs
|
|
696
|
+
step = f"[{s.verb}]({r.midpoint}/{s.log})" if s.log else s.verb
|
|
697
|
+
lines.append(
|
|
698
|
+
f"| {step} | `{s.cmd}` | {code} | {dur} |"
|
|
699
|
+
)
|
|
700
|
+
lines.append("")
|
|
701
|
+
return "\n".join(lines).rstrip() + "\n"
|
bisectlib/py.typed
ADDED
|
File without changes
|