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