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())