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