7M3.1 monorepo migration pre-flight artifact generator.
11Capture the non-destructive baselines needed before the future M3.2
12``git filter-repo`` migration into the Option_C monorepo.
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.
26 L2 - CLI lifecycle and artifact writes
27 L3 - subprocess and tool-availability decisions
28 L4 - row-level inventory details
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.
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)
44from __future__
import annotations
47from dataclasses
import dataclass
48from datetime
import datetime, timezone
50from pathlib
import Path
56from typing
import Sequence
58from oc_diagnostics
import _dbg
61_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
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)
69@dataclass(frozen=True)
71 """Shared artifact timestamp for one invocation."""
77@dataclass(frozen=True)
79 """One local branch inventory row."""
89@dataclass(frozen=True)
91 """One git worktree inventory row."""
103 now = datetime.now(timezone.utc).replace(microsecond=0)
105 date=now.date().isoformat(),
106 full=now.isoformat().replace(
"+00:00",
"Z"),
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)
121 timeout: int = DEFAULT_TIMEOUT_SECONDS,
122) -> tuple[int, str, str]:
123 """Run a subprocess command with captured output."""
127 completed = subprocess.run(
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
151 timeout: int = DEFAULT_TIMEOUT_SECONDS,
153 """Write branch/worktree inventory markdown."""
155 func =
"write_branch_inventory"
156 out_dir.mkdir(parents=
True, exist_ok=
True)
157 path = out_dir / f
"branch-inventory-{stamp.date}.md"
161 "# M3.1 Branch / Worktree Inventory",
163 f
"Generated: {stamp.full}",
164 f
"Repository: `{repo_root}`",
168 "| Branch | Short SHA | Date | Subject | Ahead/Behind vs main | Merged into main? | Disposition |",
169 "|---|---|---|---|---:|---|---|",
180 _md(row.ahead_behind),
192 "| Path | Branch | Short SHA | Date | Subject | Ahead/Behind vs main | Merged into main? | Disposition |",
193 "|---|---|---|---|---|---:|---|---|",
196 for row
in worktrees:
206 _md(row.ahead_behind),
213 path.write_text(
"\n".join(lines) +
"\n", encoding=
"utf-8")
214 _dbg(
"OC_CHAT.MAIN", func, f
"wrote {path}", 2)
223 timeout: int = DEFAULT_TIMEOUT_SECONDS,
225 """Write ``oct health --json`` output or a structured SKIPPED stub."""
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():
234 status =
"SKIPPED" if rc == 127
else "ERROR"
238 "generated_at": stamp.full,
239 "command":
"oct health --json",
241 "reason": (stderr
or stdout
or "no output").strip(),
247 path.write_text(text, encoding=
"utf-8")
248 _dbg(
"OC_CHAT.MAIN", func, f
"wrote {path}", 2)
257 timeout: int = DEFAULT_TIMEOUT_SECONDS,
259 """Write pytest collection-count baseline."""
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)
269 "# M3.1 Test Baseline",
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'}",
282 path.write_text(
"\n".join(lines) +
"\n", encoding=
"utf-8")
283 _dbg(
"OC_CHAT.MAIN", func, f
"wrote {path} count={count}", 2)
292 timeout: int = DEFAULT_TIMEOUT_SECONDS,
294 """Run filter-repo only inside a throwaway clone and write the report."""
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:
304 "git-filter-repo not available on PATH",
306 _dbg(
"OC_CHAT.MAIN", func,
"git-filter-repo unavailable", 2)
315 "installed git-filter-repo exposes neither --dry-run nor --analyze",
319 clone_dir: Path |
None =
None
320 safe_to_remove =
False
324 prefix=
"oc-chat-filter-repo-",
325 dir=tempfile.gettempdir(),
329 if not safe_to_remove:
334 f
"refusing unsafe clone dir: {clone_dir}",
337 clone_rc, clone_out, clone_err =
_run_cmd(
338 [
"git",
"clone",
"--no-hardlinks", str(repo_root), str(clone_dir)],
339 Path(tempfile.gettempdir()),
348 command=
"git clone --no-hardlinks",
355 rc, stdout, stderr =
_run_cmd(command, clone_dir, timeout)
359 "OK" if rc == 0
else "ERROR",
360 f
"git filter-repo completed with rc={rc}",
361 command=
" ".join(command),
368 if clone_dir
is not None and safe_to_remove:
369 shutil.rmtree(clone_dir, ignore_errors=
True)
377 timeout: int = DEFAULT_TIMEOUT_SECONDS,
379 """Run all pre-flight producers and write the summary."""
389 _dbg(
"OC_CHAT.MAIN", func, f
"wrote {len(artifacts)} artifacts", 2)
390 return [*artifacts, summary]
393def write_summary(out_dir: Path, stamp: Stamp, artifacts: Sequence[Path]) -> Path:
394 """Write the pre-flight summary markdown."""
396 out_dir.mkdir(parents=
True, exist_ok=
True)
397 path = out_dir / f
"PREFLIGHT-SUMMARY-{stamp.date}.md"
399 "# M3.1 Monorepo Migration Pre-flight Summary",
401 f
"Generated: {stamp.full}",
403 "## Automated Artifacts",
406 for artifact
in artifacts:
407 lines.append(f
"- [{artifact.name}]({artifact.name})")
411 "## Orchestrator-owned Manual Steps",
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.",
417 "## M3.2 Consumption",
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.",
425 path.write_text(
"\n".join(lines) +
"\n", encoding=
"utf-8")
434 "--format=%(refname:short)%09%(objectname:short)%09%(committerdate:short)%09%(contents:subject)",
446 subject=(stderr
or stdout
or "git for-each-ref failed").strip(),
452 rows: list[BranchRow] = []
453 for line
in stdout.splitlines():
464 ahead_behind=ahead_behind,
465 merged=
"yes" if name
in merged
else "no",
472 rc, stdout, stderr =
_run_cmd([
"git",
"worktree",
"list",
"--porcelain"], repo_root, timeout)
480 subject=(stderr
or stdout
or "git worktree list failed").strip(),
485 rows: list[WorktreeRow] = []
487 worktree = record.get(
"worktree",
"")
488 branch = record.get(
"branch",
"(detached)").removeprefix(
"refs/heads/")
494 sha=sha
or record.get(
"HEAD",
"")[:7],
498 if branch !=
"(detached)"
501 if branch !=
"(detached)" and branch
in _merged_branches(repo_root, timeout)
510 [
"git",
"branch",
"--merged",
"main",
"--format=%(refname:short)"],
516 return {line.strip().lstrip(
"* ").strip()
for line
in stdout.splitlines()
if line.strip()}
521 [
"git",
"rev-list",
"--left-right",
"--count", f
"main...{branch}"],
527 parts = stdout.strip().split()
530 behind, ahead = parts[0], parts[1]
531 return f
"{ahead}/{behind}"
538) -> tuple[str, str, str]:
540 [
"git",
"-C", worktree,
"log",
"-1",
"--format=%h%x09%cs%x09%s"],
545 return "",
"", (stderr
or stdout
or "git log failed").strip()
546 parts = stdout.strip().split(
"\t", 2)
547 while len(parts) < 3:
549 return parts[0], parts[1], parts[2]
553 records: list[dict[str, str]] = []
554 current: dict[str, str] = {}
555 for line
in text.splitlines():
558 records.append(current)
561 key, _, value = line.partition(
" ")
564 records.append(current)
569 matches = COUNT_RE.findall(text)
571 return int(matches[-1])
572 per_file_counts = [int(value)
for value
in PER_FILE_COUNT_RE.findall(text)]
574 return sum(per_file_counts)
579 rc, stdout, stderr =
_run_cmd([
"git",
"filter-repo",
"--help"], repo_root, timeout)
582 help_text = f
"{stdout}\n{stderr}"
583 if "--dry-run" in help_text:
585 if "--analyze" in help_text:
591 if mode ==
"--dry-run":
592 return [
"git",
"filter-repo",
"--to-subdirectory-filter",
"oc_chat/",
"--dry-run"]
593 return [
"git",
"filter-repo",
"--analyze"]
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
611 command: str |
None =
None,
612 returncode: int |
None =
None,
617 "# M3.1 filter-repo dry-run",
619 f
"Generated: {stamp.full}",
623 if command
is not None:
624 lines.extend([
"", f
"Command: {command}", f
"Return code: {returncode}"])
626 lines.extend([
"",
"## stdout",
"",
"```text", stdout.rstrip(),
"```"])
628 lines.extend([
"",
"## stderr",
"",
"```text", stderr.rstrip(),
"```"])
629 path.write_text(
"\n".join(lines) +
"\n", encoding=
"utf-8")
633 parts = line.split(
"\t", 3)
634 while len(parts) < 4:
636 return parts[0], parts[1], parts[2], parts[3]
639def _tail(text: str, lines: int = 25) -> str:
640 return "\n".join(text.splitlines()[-lines:])
643def _md(value: str) -> str:
644 return str(value).replace(
"|",
"\\|").replace(
"\n",
" ").strip()
648 return Path(raw
or ".").resolve()
652 candidate = Path(raw
or DEFAULT_OUT_DIR)
653 return candidate.resolve()
if candidate.is_absolute()
else (repo_root / candidate).resolve()
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)
667 if args.command ==
"branch-inventory":
669 if args.command ==
"health-baseline":
671 if args.command ==
"test-baseline":
673 if args.command ==
"filter-repo-dryrun":
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}")
681 parser = argparse.ArgumentParser(
682 prog=
"python -m oc_chat.scripts.migration_preflight",
683 description=
"Capture M3.1 monorepo migration pre-flight artifacts.",
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."),
693 child = sub.add_parser(name, help=help_text)
698def main(argv: list[str] |
None =
None) -> int:
701 args = parser.parse_args(argv)
704 except Exception
as exc:
705 print(f
"migration preflight failed: {type(exc).__name__}: {exc}", file=sys.stderr)
709 _dbg(
"OC_CHAT.MAIN", func, f
"completed {args.command}", 2)
713if __name__ ==
"__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)
list[str] _filter_repo_command(str mode)
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)
int|None _parse_test_count(str text)
Path write_branch_inventory(Path repo_root, Path out_dir, Stamp stamp, *, int timeout=DEFAULT_TIMEOUT_SECONDS)
Path _resolve_repo_root(str|None raw)
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)
str _tail(str text, int lines=25)
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)
argparse.ArgumentParser build_parser()
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)
Stamp _stamp_from_arg(str|None value)