scripts/validate_handoff.py
scripts/validate_handoff.pyBrowse 4 files
2,365 tokens
10,326 bytes
Token encoding: o200k_base
Snapshot 1d17ca4
← Back to SKILL.md
1#!/usr/bin/env python32"""Validate the Git invariants of an implementation-kickoff handoff."""3 4from __future__ import annotations5 6import argparse7import json8import os9import re10import stat11import subprocess12import sys13from pathlib import Path, PurePosixPath14 15 16class GitCommandError(RuntimeError):17 """Report a failed Git inspection command."""18 19 20def run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:21 result = subprocess.run(22 ["git", *args],23 cwd=repo,24 check=False,25 capture_output=True,26 text=True,27 errors="surrogateescape",28 )29 if check and result.returncode != 0:30 command = "git " + " ".join(args)31 detail = result.stderr.strip() or result.stdout.strip() or "unknown Git error"32 raise GitCommandError(f"{command} failed: {detail}")33 return result34 35 36def parse_args() -> argparse.Namespace:37 parser = argparse.ArgumentParser(38 description="Validate a clean, single-commit implementation-kickoff handoff."39 )40 parser.add_argument("--repo", type=Path, required=True, help="Path to the task worktree.")41 parser.add_argument(42 "--base",43 required=True,44 help="Expected parent commit or ref for the single handoff commit.",45 )46 parser.add_argument(47 "--expected-branch",48 required=True,49 help="Exact local branch name expected at HEAD.",50 )51 parser.add_argument(52 "--required-trailer-email",53 action="append",54 default=[],55 help="Email that must appear in a Co-authored-by trailer. Repeat as needed.",56 )57 parser.add_argument(58 "--shipped-path-manifest",59 type=Path,60 help=(61 "File containing the exact repository-relative paths expected in the handoff commit, "62 "one per line."63 ),64 )65 parser.add_argument("--json", action="store_true", help="Emit the result as JSON.")66 return parser.parse_args()67 68 69def _nonblocking_opener(path: str, flags: int) -> int:70 return os.open(path, flags | getattr(os, "O_NONBLOCK", 0))71 72 73def load_shipped_paths(path: Path) -> set[str]:74 with open(path, "rb", opener=_nonblocking_opener) as file:75 if not stat.S_ISREG(os.fstat(file.fileno()).st_mode):76 raise ValueError(f"Shipped-path manifest must be a regular file: {path}")77 lines = file.read().decode().splitlines()78 if not lines:79 raise ValueError(f"Shipped-path manifest is empty: {path}")80 81 shipped_paths: set[str] = set()82 for line_number, raw_path in enumerate(lines, start=1):83 if not raw_path:84 raise ValueError(f"Shipped-path manifest contains a blank line at {line_number}.")85 path_value = PurePosixPath(raw_path)86 if path_value.is_absolute() or ".." in path_value.parts or str(path_value) != raw_path:87 raise ValueError(88 "Shipped-path manifest entries must be normalized repository-relative paths: "89 f"{raw_path!r}."90 )91 if raw_path in shipped_paths:92 raise ValueError(f"Duplicate shipped-path manifest entry: {raw_path}")93 shipped_paths.add(raw_path)94 return shipped_paths95 96 97def is_repository_root(path: Path) -> bool:98 if not path.is_dir():99 return False100 result = run_git(path, "rev-parse", "--show-toplevel", check=False)101 return result.returncode == 0 and Path(result.stdout.strip()).resolve() == path.resolve()102 103 104def hidden_index_paths(105 repo: Path,106 prefix: str = "",107 seen_repositories: frozenset[Path] = frozenset(),108) -> list[str]:109 resolved_repo = repo.resolve()110 if resolved_repo in seen_repositories:111 return []112 seen_repositories |= {resolved_repo}113 114 def display_path(relative_path: str) -> str:115 return f"{prefix}/{relative_path}" if prefix else relative_path116 117 hidden_paths: list[str] = []118 for entry in run_git(repo, "ls-files", "-v", "-z").stdout.split("\0"):119 if len(entry) < 3 or entry[1] != " ":120 continue121 tag = entry[0]122 relative_path = entry[2:]123 if tag.islower():124 hidden_paths.append(f"assume-unchanged={display_path(relative_path)}")125 elif tag == "S":126 candidate = repo / relative_path127 if candidate.exists() or candidate.is_symlink():128 hidden_paths.append(f"materialized skip-worktree={display_path(relative_path)}")129 for entry in run_git(repo, "ls-files", "--stage", "-z").stdout.split("\0"):130 metadata, separator, relative_path = entry.partition("\t")131 fields = metadata.split()132 if not separator or len(fields) != 3 or fields[0] != "160000" or fields[2] != "0":133 continue134 submodule_path = repo / relative_path135 if is_repository_root(submodule_path):136 hidden_paths.extend(137 hidden_index_paths(138 submodule_path,139 display_path(relative_path),140 seen_repositories,141 )142 )143 return sorted(hidden_paths)144 145 146def validate(args: argparse.Namespace) -> tuple[dict[str, object], list[str]]:147 repo = args.repo.expanduser().resolve()148 failures: list[str] = []149 150 if not repo.is_dir():151 return {"repo": str(repo), "valid": False}, [f"Repository path does not exist: {repo}"]152 153 top_level = Path(run_git(repo, "rev-parse", "--show-toplevel").stdout.strip()).resolve()154 if top_level != repo:155 failures.append(f"--repo must be the worktree root: expected {top_level}, got {repo}")156 157 status = run_git(158 repo,159 "status",160 "--porcelain=v1",161 "--untracked-files=all",162 "--ignore-submodules=none",163 ).stdout164 if status:165 failures.append("Worktree is not clean.")166 hidden_paths = hidden_index_paths(repo)167 if hidden_paths:168 failures.append(f"Index flags can hide worktree changes: {hidden_paths}.")169 170 branch_result = run_git(repo, "symbolic-ref", "--quiet", "--short", "HEAD", check=False)171 branch = branch_result.stdout.strip() if branch_result.returncode == 0 else None172 if branch is None:173 failures.append("HEAD is detached.")174 elif branch != args.expected_branch:175 failures.append(f"Current branch is {branch!r}, expected {args.expected_branch!r}.")176 177 base = run_git(repo, "rev-parse", f"{args.base}^{{commit}}").stdout.strip()178 head = run_git(repo, "rev-parse", "HEAD").stdout.strip()179 parent_line = run_git(repo, "show", "-s", "--format=%P", "HEAD").stdout.strip()180 parents = parent_line.split() if parent_line else []181 if len(parents) != 1:182 failures.append(f"HEAD must have exactly one parent, found {len(parents)}.")183 elif parents[0] != base:184 failures.append(f"HEAD parent is {parents[0]}, expected base {base}.")185 186 ahead_text = run_git(repo, "rev-list", "--count", f"{base}..{head}").stdout.strip()187 ahead = int(ahead_text)188 if ahead != 1:189 failures.append(f"HEAD must be exactly one commit ahead of base, found {ahead} commits.")190 191 shipped_manifest: str | None = None192 shipped_paths: list[str] | None = None193 if args.shipped_path_manifest is not None:194 manifest_path = args.shipped_path_manifest.expanduser().resolve()195 expected_paths = load_shipped_paths(manifest_path)196 actual_paths = {197 path198 for path in run_git(199 repo,200 "diff",201 "--name-only",202 "--no-renames",203 "-z",204 f"{base}..{head}",205 ).stdout.split("\0")206 if path207 }208 missing_paths = sorted(expected_paths - actual_paths)209 unexpected_paths = sorted(actual_paths - expected_paths)210 if missing_paths or unexpected_paths:211 failures.append(212 "Committed paths do not match the shipped-path manifest: "213 f"missing={missing_paths}, unexpected={unexpected_paths}."214 )215 shipped_manifest = str(manifest_path)216 shipped_paths = sorted(expected_paths)217 218 subject = run_git(repo, "show", "-s", "--format=%s", "HEAD").stdout.strip()219 if not subject:220 failures.append("HEAD commit subject is empty.")221 222 trailer_values = run_git(223 repo,224 "show",225 "-s",226 "--format=%(trailers:key=Co-authored-by,valueonly,unfold,separator=%x00)",227 "HEAD",228 ).stdout.rstrip("\n")229 email_pattern = re.compile(r"^.+\s+<([^>\n]+)>$")230 trailer_emails = {231 match.group(1).strip().casefold()232 for value in trailer_values.split("\0")233 if (match := email_pattern.match(value)) is not None234 }235 for email in args.required_trailer_email:236 if email.strip().casefold() not in trailer_emails:237 failures.append(f"Missing required Co-authored-by trailer for {email}.")238 239 report: dict[str, object] = {240 "repo": str(repo),241 "base": base,242 "head": head,243 "branch": branch,244 "subject": subject,245 "ahead": ahead,246 "clean": not status and not hidden_paths,247 "coauthor_trailer_emails": sorted(trailer_emails),248 "shipped_path_manifest": shipped_manifest,249 "shipped_paths": shipped_paths,250 "valid": not failures,251 }252 return report, failures253 254 255def main() -> int:256 args = parse_args()257 try:258 report, failures = validate(args)259 except (GitCommandError, OSError, UnicodeError, ValueError) as exc:260 report = {"repo": str(args.repo.expanduser().resolve()), "valid": False}261 failures = [str(exc)]262 263 if args.json:264 print(json.dumps({**report, "failures": failures}, indent=2, sort_keys=True))265 else:266 status = "valid" if not failures else "invalid"267 print(f"Implementation handoff: {status}")268 for key in (269 "repo",270 "base",271 "head",272 "branch",273 "subject",274 "ahead",275 "clean",276 "shipped_path_manifest",277 "shipped_paths",278 ):279 if key in report:280 print(f"{key}: {report[key]}")281 for failure in failures:282 print(f"error: {failure}", file=sys.stderr)283 284 return 1 if failures else 0285 286 287if __name__ == "__main__":288 raise SystemExit(main())289