flake-update-20260505
1#!/usr/bin/env python3
2from __future__ import annotations
3
4import argparse
5import json
6import random
7import re
8import subprocess
9import sys
10from pathlib import Path
11from typing import Any, Sequence
12
13SCAFFOLD_TEMPLATE = """\
14# Handoff: {name}
15
16## Goal
17
18## Current Progress
19
20## What Worked
21
22## What Didn't Work
23
24## Next Steps
25
26## Notes for Next Agent
27"""
28
29HANDOFF_DIR = Path.home() / ".local" / "share" / "ai" / "handoffs"
30
31
32def run_git(args: Sequence[str], cwd: Path) -> subprocess.CompletedProcess[str]:
33 return subprocess.run(
34 ["git", *args],
35 cwd=cwd,
36 text=True,
37 capture_output=True,
38 )
39
40
41def parse_args() -> argparse.Namespace:
42 parser = argparse.ArgumentParser(
43 description="Gather git context and scaffold a handoff document.",
44 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
45 )
46 parser.add_argument(
47 "--repo", default=".", help="Path inside the target Git repository."
48 )
49 parser.add_argument(
50 "--branch",
51 default=None,
52 help="Branch name override (auto-detected by default).",
53 )
54 parser.add_argument(
55 "--name",
56 required=True,
57 help="Descriptive kebab-case slug for the handoff file (e.g. 'fix-auth-token-expiry').",
58 )
59 parser.add_argument(
60 "--json",
61 action="store_true",
62 dest="emit_json",
63 help="Emit JSON instead of text output.",
64 )
65 parser.add_argument(
66 "--log-count",
67 type=int,
68 default=10,
69 help="Number of recent commits to include.",
70 )
71 parser.add_argument(
72 "--new-copy-if-exists",
73 action="store_true",
74 help="Create a new uniquely named handoff file instead of reusing an existing slug.",
75 )
76 return parser.parse_args()
77
78
79def find_git_root(start: Path) -> Path | None:
80 result = run_git(["rev-parse", "--show-toplevel"], cwd=start)
81 if result.returncode != 0:
82 return None
83 return Path(result.stdout.strip())
84
85
86def detect_repo_name(root: Path) -> str:
87 result = run_git(["remote", "get-url", "origin"], cwd=root)
88 if result.returncode == 0:
89 url = result.stdout.strip()
90 # SSH: git@host:org/repo.git
91 m = re.search(r":([^/]+/[^/]+?)(?:\.git)?$", url)
92 if m:
93 return m.group(1).split("/")[-1]
94 # HTTPS: https://host/org/repo.git
95 m = re.search(r"/([^/]+?)(?:\.git)?$", url)
96 if m:
97 return m.group(1)
98 return root.name
99
100
101def detect_branch(root: Path) -> str | None:
102 result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
103 if result.returncode != 0:
104 return None
105 branch = result.stdout.strip()
106 if branch == "HEAD":
107 return None
108 return branch
109
110
111def sanitize_branch(name: str) -> str:
112 return re.sub(r"[^a-zA-Z0-9_.-]", "-", name)
113
114
115def detect_default_branch(root: Path) -> str:
116 result = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=root)
117 if result.returncode == 0:
118 ref = result.stdout.strip()
119 # refs/remotes/origin/main -> main
120 parts = ref.split("/")
121 if parts:
122 return parts[-1]
123
124 # Fallback: check if main or master exists
125 for candidate in ("main", "master"):
126 result = run_git(["rev-parse", "--verify", candidate], cwd=root)
127 if result.returncode == 0:
128 return candidate
129
130 return "main"
131
132
133def ensure_directory(repo_name: str) -> Path:
134 directory = HANDOFF_DIR / repo_name
135 directory.mkdir(parents=True, exist_ok=True)
136 return directory
137
138
139def check_existing(handoff_path: Path) -> tuple[bool, str | None]:
140 if handoff_path.exists():
141 return False, handoff_path.read_text()
142 return True, None
143
144
145def generate_unique_path(handoff_path: Path) -> Path:
146 """Generate a unique path by appending a random suffix if file exists."""
147 if not handoff_path.exists():
148 return handoff_path
149
150 stem = handoff_path.stem
151 suffix = handoff_path.suffix
152 random_suffix = f"{random.randint(1000, 9999)}"
153 return handoff_path.parent / f"{stem}-{random_suffix}{suffix}"
154
155
156def gather_git_context(
157 root: Path, default_branch: str, log_count: int
158) -> dict[str, Any]:
159 context: dict[str, Any] = {}
160
161 # Recent commits
162 result = run_git(
163 ["log", f"{default_branch}..HEAD", "--oneline", f"-{log_count}"], cwd=root
164 )
165 commits = []
166 if result.returncode == 0:
167 for line in result.stdout.strip().splitlines():
168 if not line:
169 continue
170 parts = line.split(None, 1)
171 commits.append(
172 {
173 "hash": parts[0],
174 "message": parts[1] if len(parts) > 1 else "",
175 }
176 )
177 context["recent_commits"] = commits
178
179 # Files changed
180 result = run_git(["diff", "--name-status", f"{default_branch}...HEAD"], cwd=root)
181 files_changed = []
182 if result.returncode == 0:
183 for line in result.stdout.strip().splitlines():
184 if not line:
185 continue
186 parts = line.split(None, 1)
187 files_changed.append(
188 {
189 "status": parts[0],
190 "path": parts[1] if len(parts) > 1 else "",
191 }
192 )
193 context["files_changed"] = files_changed
194
195 # Ahead/behind
196 result = run_git(
197 ["rev-list", "--left-right", "--count", f"{default_branch}...HEAD"], cwd=root
198 )
199 if result.returncode == 0:
200 parts = result.stdout.strip().split()
201 if len(parts) == 2:
202 context["behind"] = int(parts[0])
203 context["ahead"] = int(parts[1])
204 else:
205 context["ahead"] = 0
206 context["behind"] = 0
207 else:
208 context["ahead"] = 0
209 context["behind"] = 0
210
211 # Working tree
212 result = run_git(["status", "--porcelain"], cwd=root)
213 modified = 0
214 untracked = 0
215 staged = 0
216 if result.returncode == 0:
217 for line in result.stdout.splitlines():
218 if not line or len(line) < 2:
219 continue
220 x, y = line[0], line[1]
221 if x == "?":
222 untracked += 1
223 elif x != " ":
224 staged += 1
225 if y not in (" ", "?"):
226 modified += 1
227 context["working_tree"] = {
228 "modified": modified,
229 "untracked": untracked,
230 "staged": staged,
231 }
232
233 return context
234
235
236def scaffold_template(handoff_path: Path, name: str) -> None:
237 content = SCAFFOLD_TEMPLATE.format(name=name)
238 handoff_path.write_text(content)
239
240
241def main() -> int:
242 args = parse_args()
243 start = Path(args.repo).resolve()
244
245 root = find_git_root(start)
246 if root is None:
247 print("Error: not inside a Git repository.", file=sys.stderr)
248 return 1
249
250 repo_name = detect_repo_name(root)
251
252 branch = args.branch or detect_branch(root)
253 if branch is None:
254 branch = "(detached)"
255
256 sanitized = sanitize_branch(args.name)
257 default_branch = detect_default_branch(root)
258
259 handoff_dir = ensure_directory(repo_name)
260 preferred_path = handoff_dir / f"{sanitized}.md"
261
262 is_new, existing_content = check_existing(preferred_path)
263 handoff_path = preferred_path
264
265 if is_new:
266 scaffold_template(handoff_path, args.name)
267 elif args.new_copy_if_exists:
268 handoff_path = generate_unique_path(preferred_path)
269 is_new = True
270 existing_content = None
271 scaffold_template(handoff_path, args.name)
272
273 git_context = gather_git_context(root, default_branch, args.log_count)
274
275 if args.emit_json:
276 output = {
277 "handoff_file": str(handoff_path),
278 "is_new": is_new,
279 "repo_name": repo_name,
280 "name": args.name,
281 "branch_name": branch,
282 "sanitized_name": sanitized,
283 "default_branch": default_branch,
284 "existing_content": existing_content,
285 "git_context": git_context,
286 }
287 print(json.dumps(output, indent=2))
288 else:
289 render_text(
290 handoff_path=handoff_path,
291 is_new=is_new,
292 repo_name=repo_name,
293 branch=branch,
294 default_branch=default_branch,
295 git_context=git_context,
296 existing_content=existing_content,
297 )
298
299 return 0
300
301
302def render_text(
303 *,
304 handoff_path: Path,
305 is_new: bool,
306 repo_name: str,
307 branch: str,
308 default_branch: str,
309 git_context: dict[str, Any],
310 existing_content: str | None,
311) -> None:
312 status = "created" if is_new else "exists"
313 print(f"Handoff file ({status}): {handoff_path}")
314 print(f"Repo: {repo_name}")
315 print(f"Branch: {branch}")
316 print(f"Default branch: {default_branch}")
317
318 ahead = git_context.get("ahead", 0)
319 behind = git_context.get("behind", 0)
320 print(f"Ahead: {ahead}, Behind: {behind}")
321
322 commits = git_context.get("recent_commits", [])
323 if commits:
324 print(f"\nRecent commits ({len(commits)}):")
325 for c in commits:
326 print(f" {c['hash']} {c['message']}")
327 else:
328 print("\nNo commits ahead of default branch.")
329
330 files = git_context.get("files_changed", [])
331 if files:
332 print(f"\nFiles changed ({len(files)}):")
333 for f in files:
334 print(f" {f['status']}\t{f['path']}")
335
336 wt = git_context.get("working_tree", {})
337 if any(wt.get(k, 0) > 0 for k in ("modified", "untracked", "staged")):
338 print(
339 f"\nWorking tree: {wt.get('modified', 0)} modified, "
340 f"{wt.get('untracked', 0)} untracked, {wt.get('staged', 0)} staged"
341 )
342
343 if existing_content is not None:
344 print("\n--- Existing handoff content ---")
345 print(existing_content)
346 print("--- End existing content ---")
347
348
349if __name__ == "__main__":
350 raise SystemExit(main())