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/_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