main
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 default=None,
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 parser.add_argument(
77 "--list",
78 action="store_true",
79 help="List active handoffs for the current repo.",
80 )
81 parser.add_argument(
82 "--pickup",
83 default=None,
84 help="Pick up (read and archive) a handoff by slug name.",
85 )
86 return parser.parse_args()
87
88
89def find_git_root(start: Path) -> Path | None:
90 result = run_git(["rev-parse", "--show-toplevel"], cwd=start)
91 if result.returncode != 0:
92 return None
93 return Path(result.stdout.strip())
94
95
96def detect_repo_name(root: Path) -> str:
97 result = run_git(["remote", "get-url", "origin"], cwd=root)
98 if result.returncode == 0:
99 url = result.stdout.strip()
100 # SSH: git@host:org/repo.git
101 m = re.search(r":([^/]+/[^/]+?)(?:\.git)?$", url)
102 if m:
103 return m.group(1).split("/")[-1]
104 # HTTPS: https://host/org/repo.git
105 m = re.search(r"/([^/]+?)(?:\.git)?$", url)
106 if m:
107 return m.group(1)
108 return root.name
109
110
111def detect_branch(root: Path) -> str | None:
112 result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
113 if result.returncode != 0:
114 return None
115 branch = result.stdout.strip()
116 if branch == "HEAD":
117 return None
118 return branch
119
120
121def sanitize_branch(name: str) -> str:
122 return re.sub(r"[^a-zA-Z0-9_.-]", "-", name)
123
124
125def detect_default_branch(root: Path) -> str:
126 result = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=root)
127 if result.returncode == 0:
128 ref = result.stdout.strip()
129 # refs/remotes/origin/main -> main
130 parts = ref.split("/")
131 if parts:
132 return parts[-1]
133
134 # Fallback: check if main or master exists
135 for candidate in ("main", "master"):
136 result = run_git(["rev-parse", "--verify", candidate], cwd=root)
137 if result.returncode == 0:
138 return candidate
139
140 return "main"
141
142
143def ensure_directory(repo_name: str) -> Path:
144 directory = HANDOFF_DIR / repo_name
145 directory.mkdir(parents=True, exist_ok=True)
146 return directory
147
148
149def check_existing(handoff_path: Path) -> tuple[bool, str | None]:
150 if handoff_path.exists():
151 return False, handoff_path.read_text()
152 return True, None
153
154
155def generate_unique_path(handoff_path: Path) -> Path:
156 """Generate a unique path by appending a random suffix if file exists."""
157 if not handoff_path.exists():
158 return handoff_path
159
160 stem = handoff_path.stem
161 suffix = handoff_path.suffix
162 random_suffix = f"{random.randint(1000, 9999)}"
163 return handoff_path.parent / f"{stem}-{random_suffix}{suffix}"
164
165
166def gather_git_context(
167 root: Path, default_branch: str, log_count: int
168) -> dict[str, Any]:
169 context: dict[str, Any] = {}
170
171 # Recent commits
172 result = run_git(
173 ["log", f"{default_branch}..HEAD", "--oneline", f"-{log_count}"], cwd=root
174 )
175 commits = []
176 if result.returncode == 0:
177 for line in result.stdout.strip().splitlines():
178 if not line:
179 continue
180 parts = line.split(None, 1)
181 commits.append(
182 {
183 "hash": parts[0],
184 "message": parts[1] if len(parts) > 1 else "",
185 }
186 )
187 context["recent_commits"] = commits
188
189 # Files changed
190 result = run_git(["diff", "--name-status", f"{default_branch}...HEAD"], cwd=root)
191 files_changed = []
192 if result.returncode == 0:
193 for line in result.stdout.strip().splitlines():
194 if not line:
195 continue
196 parts = line.split(None, 1)
197 files_changed.append(
198 {
199 "status": parts[0],
200 "path": parts[1] if len(parts) > 1 else "",
201 }
202 )
203 context["files_changed"] = files_changed
204
205 # Ahead/behind
206 result = run_git(
207 ["rev-list", "--left-right", "--count", f"{default_branch}...HEAD"], cwd=root
208 )
209 if result.returncode == 0:
210 parts = result.stdout.strip().split()
211 if len(parts) == 2:
212 context["behind"] = int(parts[0])
213 context["ahead"] = int(parts[1])
214 else:
215 context["ahead"] = 0
216 context["behind"] = 0
217 else:
218 context["ahead"] = 0
219 context["behind"] = 0
220
221 # Working tree
222 result = run_git(["status", "--porcelain"], cwd=root)
223 modified = 0
224 untracked = 0
225 staged = 0
226 if result.returncode == 0:
227 for line in result.stdout.splitlines():
228 if not line or len(line) < 2:
229 continue
230 x, y = line[0], line[1]
231 if x == "?":
232 untracked += 1
233 elif x != " ":
234 staged += 1
235 if y not in (" ", "?"):
236 modified += 1
237 context["working_tree"] = {
238 "modified": modified,
239 "untracked": untracked,
240 "staged": staged,
241 }
242
243 return context
244
245
246def scaffold_template(handoff_path: Path, name: str) -> None:
247 content = SCAFFOLD_TEMPLATE.format(name=name)
248 handoff_path.write_text(content)
249
250
251def list_handoffs(repo_name: str) -> int:
252 """List active (non-archived) handoffs for a repo."""
253 handoff_dir = HANDOFF_DIR / repo_name
254 if not handoff_dir.exists():
255 print(f"No handoffs directory for '{repo_name}'.")
256 return 0
257
258 files = sorted(handoff_dir.glob("*.md"))
259 if not files:
260 print(f"No active handoffs for '{repo_name}'.")
261 return 0
262
263 print(f"Active handoffs for '{repo_name}':")
264 for f in files:
265 # Extract goal line from file
266 goal = ""
267 in_goal = False
268 for line in f.read_text().splitlines():
269 if line.startswith("## Goal"):
270 in_goal = True
271 continue
272 if in_goal and line.strip():
273 goal = line.strip()
274 break
275 if in_goal and line.startswith("##"):
276 break
277 print(f" {f.stem}" + (f" — {goal}" if goal else ""))
278 return 0
279
280
281def pickup_handoff(repo_name: str, slug: str) -> int:
282 """Read a handoff, print its content, and archive it."""
283 sanitized = sanitize_branch(slug)
284 handoff_dir = HANDOFF_DIR / repo_name
285 handoff_path = handoff_dir / f"{sanitized}.md"
286
287 if not handoff_path.exists():
288 print(f"Error: no handoff found at {handoff_path}", file=sys.stderr)
289 return 1
290
291 content = handoff_path.read_text()
292 print(content)
293
294 # Archive it
295 archive_dir = handoff_dir / ".archive"
296 archive_dir.mkdir(parents=True, exist_ok=True)
297 archive_path = archive_dir / handoff_path.name
298 # If archive already has one with same name, add suffix
299 if archive_path.exists():
300 archive_path = generate_unique_path(archive_path)
301 handoff_path.rename(archive_path)
302 print(f"\n--- Archived to {archive_path} ---")
303 return 0
304
305
306def main() -> int:
307 args = parse_args()
308 start = Path(args.repo).resolve()
309
310 root = find_git_root(start)
311 if root is None:
312 print("Error: not inside a Git repository.", file=sys.stderr)
313 return 1
314
315 repo_name = detect_repo_name(root)
316
317 # Handle list/pickup actions before requiring --name
318 if args.list:
319 return list_handoffs(repo_name)
320
321 if args.pickup:
322 return pickup_handoff(repo_name, args.pickup)
323
324 if not args.name:
325 print("Error: --name is required (unless using --list or --pickup).", file=sys.stderr)
326 return 1
327
328 branch = args.branch or detect_branch(root)
329 if branch is None:
330 branch = "(detached)"
331
332 sanitized = sanitize_branch(args.name)
333 default_branch = detect_default_branch(root)
334
335 handoff_dir = ensure_directory(repo_name)
336 preferred_path = handoff_dir / f"{sanitized}.md"
337
338 is_new, existing_content = check_existing(preferred_path)
339 handoff_path = preferred_path
340
341 if is_new:
342 scaffold_template(handoff_path, args.name)
343 elif args.new_copy_if_exists:
344 handoff_path = generate_unique_path(preferred_path)
345 is_new = True
346 existing_content = None
347 scaffold_template(handoff_path, args.name)
348
349 git_context = gather_git_context(root, default_branch, args.log_count)
350
351 if args.emit_json:
352 output = {
353 "handoff_file": str(handoff_path),
354 "is_new": is_new,
355 "repo_name": repo_name,
356 "name": args.name,
357 "branch_name": branch,
358 "sanitized_name": sanitized,
359 "default_branch": default_branch,
360 "existing_content": existing_content,
361 "git_context": git_context,
362 }
363 print(json.dumps(output, indent=2))
364 else:
365 render_text(
366 handoff_path=handoff_path,
367 is_new=is_new,
368 repo_name=repo_name,
369 branch=branch,
370 default_branch=default_branch,
371 git_context=git_context,
372 existing_content=existing_content,
373 )
374
375 return 0
376
377
378def render_text(
379 *,
380 handoff_path: Path,
381 is_new: bool,
382 repo_name: str,
383 branch: str,
384 default_branch: str,
385 git_context: dict[str, Any],
386 existing_content: str | None,
387) -> None:
388 status = "created" if is_new else "exists"
389 print(f"Handoff file ({status}): {handoff_path}")
390 print(f"Repo: {repo_name}")
391 print(f"Branch: {branch}")
392 print(f"Default branch: {default_branch}")
393
394 ahead = git_context.get("ahead", 0)
395 behind = git_context.get("behind", 0)
396 print(f"Ahead: {ahead}, Behind: {behind}")
397
398 commits = git_context.get("recent_commits", [])
399 if commits:
400 print(f"\nRecent commits ({len(commits)}):")
401 for c in commits:
402 print(f" {c['hash']} {c['message']}")
403 else:
404 print("\nNo commits ahead of default branch.")
405
406 files = git_context.get("files_changed", [])
407 if files:
408 print(f"\nFiles changed ({len(files)}):")
409 for f in files:
410 print(f" {f['status']}\t{f['path']}")
411
412 wt = git_context.get("working_tree", {})
413 if any(wt.get(k, 0) > 0 for k in ("modified", "untracked", "staged")):
414 print(
415 f"\nWorking tree: {wt.get('modified', 0)} modified, "
416 f"{wt.get('untracked', 0)} untracked, {wt.get('staged', 0)} staged"
417 )
418
419 if existing_content is not None:
420 print("\n--- Existing handoff content ---")
421 print(existing_content)
422 print("--- End existing content ---")
423
424
425if __name__ == "__main__":
426 raise SystemExit(main())