oc_chat
Loading...
Searching...
No Matches
migration_preflight.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/scripts/migration_preflight.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/unit/test_migration_preflight.py"
6"""
7M3.1 monorepo migration pre-flight artifact generator.
8
9Purpose
10-------
11Capture the non-destructive baselines needed before the future M3.2
12``git filter-repo`` migration into the Option_C monorepo.
13
14Responsibilities
15----------------
16- Write branch/worktree inventory artifacts.
17- Capture ``oct health --json`` output or a structured skip stub.
18- Record the pytest collection count used for later parity checks.
19- Run a filter-repo rehearsal only inside a throwaway temp clone.
20- Write a summary artifact that separates automated and manual steps.
21
22Diagnostics
23-----------
24Domain: OC_CHAT.MAIN
25Levels:
26 L2 - CLI lifecycle and artifact writes
27 L3 - subprocess and tool-availability decisions
28 L4 - row-level inventory details
29
30Contracts
31---------
32- No live branch or repository history is rewritten.
33- Missing optional tools write SKIPPED artifacts and return success.
34- Subprocesses run with argument lists and never through ``shell=True``.
35- The filter-repo command is never run against the source repository root.
36
37Dependencies
38------------
39- git (not imported; CLI run as a subprocess for the branch and worktree inventory, the temp clone and the optional git filter-repo rehearsal)
40- pytest (not imported; run as python -m pytest --collect-only for the collection-count baseline)
41- oct (not imported; optional CLI run as oct health --json for the health baseline)
42- oc_diagnostics (structured debug logging via _dbg)
43"""
44from __future__ import annotations
45
46import argparse
47from dataclasses import dataclass
48from datetime import datetime, timezone
49import json
50from pathlib import Path
51import re
52import shutil
53import subprocess
54import sys
55import tempfile
56from typing import Sequence
57
58from oc_diagnostics import _dbg
59
60
61_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
62
63DEFAULT_OUT_DIR = ".migration"
64DEFAULT_TIMEOUT_SECONDS = 300
65COUNT_RE = re.compile(r"(\d+)\s+(?:tests?|items?)\s+collected", re.IGNORECASE)
66PER_FILE_COUNT_RE = re.compile(r"^[^:\n]+:\s*(\d+)\s*$", re.MULTILINE)
67
68
69@dataclass(frozen=True)
70class Stamp:
71 """Shared artifact timestamp for one invocation."""
72
73 date: str
74 full: str
75
76
77@dataclass(frozen=True)
79 """One local branch inventory row."""
80
81 name: str
82 sha: str
83 date: str
84 subject: str
85 ahead_behind: str
86 merged: str
87
88
89@dataclass(frozen=True)
91 """One git worktree inventory row."""
92
93 path: str
94 branch: str
95 sha: str
96 date: str
97 subject: str
98 ahead_behind: str
99 merged: str
100
101
102def _iso_now() -> Stamp:
103 now = datetime.now(timezone.utc).replace(microsecond=0)
104 return Stamp(
105 date=now.date().isoformat(),
106 full=now.isoformat().replace("+00:00", "Z"),
107 )
108
109
110def _stamp_from_arg(value: str | None) -> Stamp:
111 if value is None:
112 return _iso_now()
113 date = value.split("T", 1)[0]
114 full = value if "T" in value else f"{value}T00:00:00Z"
115 return Stamp(date=date, full=full)
116
117
119 args: Sequence[str],
120 cwd: Path,
121 timeout: int = DEFAULT_TIMEOUT_SECONDS,
122) -> tuple[int, str, str]:
123 """Run a subprocess command with captured output."""
124
125 func = "_run_cmd"
126 try:
127 completed = subprocess.run(
128 list(args),
129 cwd=str(cwd),
130 capture_output=True,
131 text=True,
132 timeout=timeout,
133 check=False,
134 )
135 except FileNotFoundError as exc:
136 _dbg("OC_CHAT.MAIN", func, f"missing command: {args[0]}", 3)
137 return 127, "", str(exc)
138 except subprocess.TimeoutExpired as exc:
139 _dbg("OC_CHAT.MAIN", func, f"timeout: {' '.join(args)}", 3)
140 stdout = exc.stdout if isinstance(exc.stdout, str) else ""
141 stderr = exc.stderr if isinstance(exc.stderr, str) else ""
142 return 124, stdout, stderr or f"timed out after {timeout}s"
143 return completed.returncode, completed.stdout, completed.stderr
144
145
147 repo_root: Path,
148 out_dir: Path,
149 stamp: Stamp,
150 *,
151 timeout: int = DEFAULT_TIMEOUT_SECONDS,
152) -> Path:
153 """Write branch/worktree inventory markdown."""
154
155 func = "write_branch_inventory"
156 out_dir.mkdir(parents=True, exist_ok=True)
157 path = out_dir / f"branch-inventory-{stamp.date}.md"
158 branches = _collect_branches(repo_root, timeout)
159 worktrees = _collect_worktrees(repo_root, timeout)
160 lines = [
161 "# M3.1 Branch / Worktree Inventory",
162 "",
163 f"Generated: {stamp.full}",
164 f"Repository: `{repo_root}`",
165 "",
166 "## Local Branches",
167 "",
168 "| Branch | Short SHA | Date | Subject | Ahead/Behind vs main | Merged into main? | Disposition |",
169 "|---|---|---|---|---:|---|---|",
170 ]
171 for row in branches:
172 lines.append(
173 "| "
174 + " | ".join(
175 [
176 _md(row.name),
177 _md(row.sha),
178 _md(row.date),
179 _md(row.subject),
180 _md(row.ahead_behind),
181 row.merged,
182 "",
183 ]
184 )
185 + " |"
186 )
187 lines.extend(
188 [
189 "",
190 "## Worktrees",
191 "",
192 "| Path | Branch | Short SHA | Date | Subject | Ahead/Behind vs main | Merged into main? | Disposition |",
193 "|---|---|---|---|---|---:|---|---|",
194 ]
195 )
196 for row in worktrees:
197 lines.append(
198 "| "
199 + " | ".join(
200 [
201 _md(row.path),
202 _md(row.branch),
203 _md(row.sha),
204 _md(row.date),
205 _md(row.subject),
206 _md(row.ahead_behind),
207 row.merged,
208 "",
209 ]
210 )
211 + " |"
212 )
213 path.write_text("\n".join(lines) + "\n", encoding="utf-8")
214 _dbg("OC_CHAT.MAIN", func, f"wrote {path}", 2)
215 return path
216
217
219 repo_root: Path,
220 out_dir: Path,
221 stamp: Stamp,
222 *,
223 timeout: int = DEFAULT_TIMEOUT_SECONDS,
224) -> Path:
225 """Write ``oct health --json`` output or a structured SKIPPED stub."""
226
227 func = "write_health_baseline"
228 out_dir.mkdir(parents=True, exist_ok=True)
229 path = out_dir / f"health-baseline-{stamp.date}.json"
230 rc, stdout, stderr = _run_cmd(["oct", "health", "--json"], repo_root, timeout)
231 if rc == 0 and stdout.strip():
232 text = stdout
233 else:
234 status = "SKIPPED" if rc == 127 else "ERROR"
235 text = json.dumps(
236 {
237 "status": status,
238 "generated_at": stamp.full,
239 "command": "oct health --json",
240 "returncode": rc,
241 "reason": (stderr or stdout or "no output").strip(),
242 },
243 indent=2,
244 sort_keys=True,
245 )
246 text += "\n"
247 path.write_text(text, encoding="utf-8")
248 _dbg("OC_CHAT.MAIN", func, f"wrote {path}", 2)
249 return path
250
251
253 repo_root: Path,
254 out_dir: Path,
255 stamp: Stamp,
256 *,
257 timeout: int = DEFAULT_TIMEOUT_SECONDS,
258) -> Path:
259 """Write pytest collection-count baseline."""
260
261 func = "write_test_baseline"
262 out_dir.mkdir(parents=True, exist_ok=True)
263 path = out_dir / f"test-baseline-{stamp.date}.txt"
264 command = [sys.executable, "-m", "pytest", "oc_chat/tests/", "--collect-only", "-q"]
265 rc, stdout, stderr = _run_cmd(command, repo_root, timeout)
266 combined = "\n".join(part for part in (stdout, stderr) if part)
267 count = _parse_test_count(combined)
268 lines = [
269 "# M3.1 Test Baseline",
270 "",
271 f"Generated: {stamp.full}",
272 f"Command: {' '.join(command)}",
273 f"Return code: {rc}",
274 f"Collected tests: {count if count is not None else 'UNKNOWN'}",
275 "",
276 "## Raw Tail",
277 "",
278 "```text",
279 _tail(combined),
280 "```",
281 ]
282 path.write_text("\n".join(lines) + "\n", encoding="utf-8")
283 _dbg("OC_CHAT.MAIN", func, f"wrote {path} count={count}", 2)
284 return path
285
286
288 repo_root: Path,
289 out_dir: Path,
290 stamp: Stamp,
291 *,
292 timeout: int = DEFAULT_TIMEOUT_SECONDS,
293) -> Path:
294 """Run filter-repo only inside a throwaway clone and write the report."""
295
296 func = "write_filter_repo_dryrun"
297 out_dir.mkdir(parents=True, exist_ok=True)
298 path = out_dir / f"filter-repo-dryrun-{stamp.date}.txt"
299 if shutil.which("git-filter-repo") is None:
301 path,
302 stamp,
303 "SKIPPED",
304 "git-filter-repo not available on PATH",
305 )
306 _dbg("OC_CHAT.MAIN", func, "git-filter-repo unavailable", 2)
307 return path
308
309 mode = _filter_repo_mode(repo_root, timeout)
310 if mode is None:
312 path,
313 stamp,
314 "SKIPPED",
315 "installed git-filter-repo exposes neither --dry-run nor --analyze",
316 )
317 return path
318
319 clone_dir: Path | None = None
320 safe_to_remove = False
321 try:
322 clone_dir = Path(
323 tempfile.mkdtemp(
324 prefix="oc-chat-filter-repo-",
325 dir=tempfile.gettempdir(),
326 )
327 )
328 safe_to_remove = _safe_clone_dir(clone_dir, repo_root)
329 if not safe_to_remove:
331 path,
332 stamp,
333 "ERROR",
334 f"refusing unsafe clone dir: {clone_dir}",
335 )
336 return path
337 clone_rc, clone_out, clone_err = _run_cmd(
338 ["git", "clone", "--no-hardlinks", str(repo_root), str(clone_dir)],
339 Path(tempfile.gettempdir()),
340 timeout,
341 )
342 if clone_rc != 0:
344 path,
345 stamp,
346 "ERROR",
347 "git clone failed",
348 command="git clone --no-hardlinks",
349 returncode=clone_rc,
350 stdout=clone_out,
351 stderr=clone_err,
352 )
353 return path
354 command = _filter_repo_command(mode)
355 rc, stdout, stderr = _run_cmd(command, clone_dir, timeout)
357 path,
358 stamp,
359 "OK" if rc == 0 else "ERROR",
360 f"git filter-repo completed with rc={rc}",
361 command=" ".join(command),
362 returncode=rc,
363 stdout=stdout,
364 stderr=stderr,
365 )
366 return path
367 finally:
368 if clone_dir is not None and safe_to_remove:
369 shutil.rmtree(clone_dir, ignore_errors=True)
370
371
373 repo_root: Path,
374 out_dir: Path,
375 stamp: Stamp,
376 *,
377 timeout: int = DEFAULT_TIMEOUT_SECONDS,
378) -> list[Path]:
379 """Run all pre-flight producers and write the summary."""
380
381 func = "run_all"
382 artifacts = [
383 write_branch_inventory(repo_root, out_dir, stamp, timeout=timeout),
384 write_health_baseline(repo_root, out_dir, stamp, timeout=timeout),
385 write_test_baseline(repo_root, out_dir, stamp, timeout=timeout),
386 write_filter_repo_dryrun(repo_root, out_dir, stamp, timeout=timeout),
387 ]
388 summary = write_summary(out_dir, stamp, artifacts)
389 _dbg("OC_CHAT.MAIN", func, f"wrote {len(artifacts)} artifacts", 2)
390 return [*artifacts, summary]
391
392
393def write_summary(out_dir: Path, stamp: Stamp, artifacts: Sequence[Path]) -> Path:
394 """Write the pre-flight summary markdown."""
395
396 out_dir.mkdir(parents=True, exist_ok=True)
397 path = out_dir / f"PREFLIGHT-SUMMARY-{stamp.date}.md"
398 lines = [
399 "# M3.1 Monorepo Migration Pre-flight Summary",
400 "",
401 f"Generated: {stamp.full}",
402 "",
403 "## Automated Artifacts",
404 "",
405 ]
406 for artifact in artifacts:
407 lines.append(f"- [{artifact.name}]({artifact.name})")
408 lines.extend(
409 [
410 "",
411 "## Orchestrator-owned Manual Steps",
412 "",
413 "- Create tag `v0.7.8` at the approved pre-migration commit.",
414 "- Announce the 48-hour branch freeze before M3.2 begins.",
415 "- Announce the later M3.8 unfreeze after the migration settles.",
416 "",
417 "## M3.2 Consumption",
418 "",
419 "- Use the branch inventory to account for every branch/worktree disposition.",
420 "- Compare the health baseline against post-migration `oct health --json`.",
421 "- Match the collected test count before accepting the migrated workspace.",
422 "- Review the filter-repo dry-run output before the live history rewrite.",
423 ]
424 )
425 path.write_text("\n".join(lines) + "\n", encoding="utf-8")
426 return path
427
428
429def _collect_branches(repo_root: Path, timeout: int) -> list[BranchRow]:
430 rc, stdout, stderr = _run_cmd(
431 [
432 "git",
433 "for-each-ref",
434 "--format=%(refname:short)%09%(objectname:short)%09%(committerdate:short)%09%(contents:subject)",
435 "refs/heads",
436 ],
437 repo_root,
438 timeout,
439 )
440 if rc != 0:
441 return [
442 BranchRow(
443 name="ERROR",
444 sha="",
445 date="",
446 subject=(stderr or stdout or "git for-each-ref failed").strip(),
447 ahead_behind="",
448 merged="no",
449 )
450 ]
451 merged = _merged_branches(repo_root, timeout)
452 rows: list[BranchRow] = []
453 for line in stdout.splitlines():
454 if not line.strip():
455 continue
456 name, sha, date, subject = _split_tab4(line)
457 ahead_behind = _ahead_behind(repo_root, name, timeout)
458 rows.append(
459 BranchRow(
460 name=name,
461 sha=sha,
462 date=date,
463 subject=subject,
464 ahead_behind=ahead_behind,
465 merged="yes" if name in merged else "no",
466 )
467 )
468 return rows
469
470
471def _collect_worktrees(repo_root: Path, timeout: int) -> list[WorktreeRow]:
472 rc, stdout, stderr = _run_cmd(["git", "worktree", "list", "--porcelain"], repo_root, timeout)
473 if rc != 0:
474 return [
476 path="ERROR",
477 branch="",
478 sha="",
479 date="",
480 subject=(stderr or stdout or "git worktree list failed").strip(),
481 ahead_behind="",
482 merged="no",
483 )
484 ]
485 rows: list[WorktreeRow] = []
486 for record in _parse_worktree_records(stdout):
487 worktree = record.get("worktree", "")
488 branch = record.get("branch", "(detached)").removeprefix("refs/heads/")
489 sha, date, subject = _worktree_commit_info(repo_root, worktree, timeout)
490 rows.append(
492 path=worktree,
493 branch=branch,
494 sha=sha or record.get("HEAD", "")[:7],
495 date=date,
496 subject=subject,
497 ahead_behind=_ahead_behind(repo_root, branch, timeout)
498 if branch != "(detached)"
499 else "",
500 merged="yes"
501 if branch != "(detached)" and branch in _merged_branches(repo_root, timeout)
502 else "no",
503 )
504 )
505 return rows
506
507
508def _merged_branches(repo_root: Path, timeout: int) -> set[str]:
509 rc, stdout, _stderr = _run_cmd(
510 ["git", "branch", "--merged", "main", "--format=%(refname:short)"],
511 repo_root,
512 timeout,
513 )
514 if rc != 0:
515 return set()
516 return {line.strip().lstrip("* ").strip() for line in stdout.splitlines() if line.strip()}
517
518
519def _ahead_behind(repo_root: Path, branch: str, timeout: int) -> str:
520 rc, stdout, _stderr = _run_cmd(
521 ["git", "rev-list", "--left-right", "--count", f"main...{branch}"],
522 repo_root,
523 timeout,
524 )
525 if rc != 0:
526 return "unknown"
527 parts = stdout.strip().split()
528 if len(parts) < 2:
529 return "unknown"
530 behind, ahead = parts[0], parts[1]
531 return f"{ahead}/{behind}"
532
533
535 repo_root: Path,
536 worktree: str,
537 timeout: int,
538) -> tuple[str, str, str]:
539 rc, stdout, stderr = _run_cmd(
540 ["git", "-C", worktree, "log", "-1", "--format=%h%x09%cs%x09%s"],
541 repo_root,
542 timeout,
543 )
544 if rc != 0:
545 return "", "", (stderr or stdout or "git log failed").strip()
546 parts = stdout.strip().split("\t", 2)
547 while len(parts) < 3:
548 parts.append("")
549 return parts[0], parts[1], parts[2]
550
551
552def _parse_worktree_records(text: str) -> list[dict[str, str]]:
553 records: list[dict[str, str]] = []
554 current: dict[str, str] = {}
555 for line in text.splitlines():
556 if not line.strip():
557 if current:
558 records.append(current)
559 current = {}
560 continue
561 key, _, value = line.partition(" ")
562 current[key] = value
563 if current:
564 records.append(current)
565 return records
566
567
568def _parse_test_count(text: str) -> int | None:
569 matches = COUNT_RE.findall(text)
570 if matches:
571 return int(matches[-1])
572 per_file_counts = [int(value) for value in PER_FILE_COUNT_RE.findall(text)]
573 if per_file_counts:
574 return sum(per_file_counts)
575 return None
576
577
578def _filter_repo_mode(repo_root: Path, timeout: int) -> str | None:
579 rc, stdout, stderr = _run_cmd(["git", "filter-repo", "--help"], repo_root, timeout)
580 if rc not in {0, 1}:
581 return None
582 help_text = f"{stdout}\n{stderr}"
583 if "--dry-run" in help_text:
584 return "--dry-run"
585 if "--analyze" in help_text:
586 return "--analyze"
587 return None
588
589
590def _filter_repo_command(mode: str) -> list[str]:
591 if mode == "--dry-run":
592 return ["git", "filter-repo", "--to-subdirectory-filter", "oc_chat/", "--dry-run"]
593 return ["git", "filter-repo", "--analyze"]
594
595
596def _safe_clone_dir(clone_dir: Path, repo_root: Path) -> bool:
597 clone_resolved = clone_dir.resolve()
598 repo_resolved = repo_root.resolve()
599 temp_resolved = Path(tempfile.gettempdir()).resolve()
600 return clone_resolved != repo_resolved and (
601 clone_resolved == temp_resolved or temp_resolved in clone_resolved.parents
602 )
603
604
606 path: Path,
607 stamp: Stamp,
608 status: str,
609 reason: str,
610 *,
611 command: str | None = None,
612 returncode: int | None = None,
613 stdout: str = "",
614 stderr: str = "",
615) -> None:
616 lines = [
617 "# M3.1 filter-repo dry-run",
618 "",
619 f"Generated: {stamp.full}",
620 f"Status: {status}",
621 f"Reason: {reason}",
622 ]
623 if command is not None:
624 lines.extend(["", f"Command: {command}", f"Return code: {returncode}"])
625 if stdout:
626 lines.extend(["", "## stdout", "", "```text", stdout.rstrip(), "```"])
627 if stderr:
628 lines.extend(["", "## stderr", "", "```text", stderr.rstrip(), "```"])
629 path.write_text("\n".join(lines) + "\n", encoding="utf-8")
630
631
632def _split_tab4(line: str) -> tuple[str, str, str, str]:
633 parts = line.split("\t", 3)
634 while len(parts) < 4:
635 parts.append("")
636 return parts[0], parts[1], parts[2], parts[3]
637
638
639def _tail(text: str, lines: int = 25) -> str:
640 return "\n".join(text.splitlines()[-lines:])
641
642
643def _md(value: str) -> str:
644 return str(value).replace("|", "\\|").replace("\n", " ").strip()
645
646
647def _resolve_repo_root(raw: str | None) -> Path:
648 return Path(raw or ".").resolve()
649
650
651def _resolve_out_dir(repo_root: Path, raw: str | None) -> Path:
652 candidate = Path(raw or DEFAULT_OUT_DIR)
653 return candidate.resolve() if candidate.is_absolute() else (repo_root / candidate).resolve()
654
655
656def _add_common(parser: argparse.ArgumentParser) -> None:
657 parser.add_argument("--repo-root", help="Repository root. Defaults to cwd.")
658 parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR, help="Artifact output directory.")
659 parser.add_argument("--stamp", help="Override artifact ISO date/full timestamp for tests.")
660 parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS)
661
662
663def _dispatch(args: argparse.Namespace) -> list[Path]:
664 repo_root = _resolve_repo_root(args.repo_root)
665 out_dir = _resolve_out_dir(repo_root, args.out_dir)
666 stamp = _stamp_from_arg(args.stamp)
667 if args.command == "branch-inventory":
668 return [write_branch_inventory(repo_root, out_dir, stamp, timeout=args.timeout)]
669 if args.command == "health-baseline":
670 return [write_health_baseline(repo_root, out_dir, stamp, timeout=args.timeout)]
671 if args.command == "test-baseline":
672 return [write_test_baseline(repo_root, out_dir, stamp, timeout=args.timeout)]
673 if args.command == "filter-repo-dryrun":
674 return [write_filter_repo_dryrun(repo_root, out_dir, stamp, timeout=args.timeout)]
675 if args.command == "run":
676 return run_all(repo_root, out_dir, stamp, timeout=args.timeout)
677 raise ValueError(f"unknown command: {args.command}")
678
679
680def build_parser() -> argparse.ArgumentParser:
681 parser = argparse.ArgumentParser(
682 prog="python -m oc_chat.scripts.migration_preflight",
683 description="Capture M3.1 monorepo migration pre-flight artifacts.",
684 )
685 sub = parser.add_subparsers(dest="command", required=True)
686 for name, help_text in (
687 ("run", "Run every pre-flight producer and write a summary."),
688 ("branch-inventory", "Write branch/worktree inventory markdown."),
689 ("health-baseline", "Capture oct health --json."),
690 ("test-baseline", "Record pytest collection count."),
691 ("filter-repo-dryrun", "Run filter-repo in a temp clone or record SKIPPED."),
692 ):
693 child = sub.add_parser(name, help=help_text)
694 _add_common(child)
695 return parser
696
697
698def main(argv: list[str] | None = None) -> int:
699 func = "main"
700 parser = build_parser()
701 args = parser.parse_args(argv)
702 try:
703 paths = _dispatch(args)
704 except Exception as exc: # noqa: BLE001 - CLI boundary
705 print(f"migration preflight failed: {type(exc).__name__}: {exc}", file=sys.stderr)
706 return 1
707 for path in paths:
708 print(path)
709 _dbg("OC_CHAT.MAIN", func, f"completed {args.command}", 2)
710 return 0
711
712
713if __name__ == "__main__":
714 sys.exit(main())
tuple[str, str, str] _worktree_commit_info(Path repo_root, str worktree, int timeout)
list[BranchRow] _collect_branches(Path repo_root, int timeout)
Path _resolve_out_dir(Path repo_root, str|None raw)
str _ahead_behind(Path repo_root, str branch, int timeout)
None _add_common(argparse.ArgumentParser parser)
list[dict[str, str]] _parse_worktree_records(str text)
None _write_filter_report(Path path, Stamp stamp, str status, str reason, *, str|None command=None, int|None returncode=None, str stdout="", str stderr="")
tuple[int, str, str] _run_cmd(Sequence[str] args, Path cwd, int timeout=DEFAULT_TIMEOUT_SECONDS)
str|None _filter_repo_mode(Path repo_root, int timeout)
Path write_branch_inventory(Path repo_root, Path out_dir, Stamp stamp, *, int timeout=DEFAULT_TIMEOUT_SECONDS)
list[Path] _dispatch(argparse.Namespace args)
bool _safe_clone_dir(Path clone_dir, Path repo_root)
set[str] _merged_branches(Path repo_root, int timeout)
list[Path] run_all(Path repo_root, Path out_dir, Stamp stamp, *, int timeout=DEFAULT_TIMEOUT_SECONDS)
Path write_health_baseline(Path repo_root, Path out_dir, Stamp stamp, *, int timeout=DEFAULT_TIMEOUT_SECONDS)
list[WorktreeRow] _collect_worktrees(Path repo_root, int timeout)
tuple[str, str, str, str] _split_tab4(str line)
Path write_filter_repo_dryrun(Path repo_root, Path out_dir, Stamp stamp, *, int timeout=DEFAULT_TIMEOUT_SECONDS)
Path write_summary(Path out_dir, Stamp stamp, Sequence[Path] artifacts)
Path write_test_baseline(Path repo_root, Path out_dir, Stamp stamp, *, int timeout=DEFAULT_TIMEOUT_SECONDS)