Skill: wpank/finishing-branch (version 0.1.0)

--- name: finishing-branch model: fast description: Complete development work by presenting structured options for merge, PR, or cleanup. Use when implementation is complete, all tests pass, and you need to decide how to integrate work. Triggers on finish branch, complete branch, merge branch, create PR, done with feature, implementation complete. --- # Finishing a Development Branch Complete development work by presenting clear options and executing the chosen workflow. ## WHAT This Skill Does After implementation is complete, guides you through verifying tests, presenting integration options, and executing the chosen path (merge, PR, keep, or discard). ## WHEN To Use - Implementation is complete - All tests pass - Ready to integrate work into the main branch **KEYWORDS**: finish branch, complete branch, merge, PR, done with feature --- ## The Process ### Step 1: Verify Tests ```bash npm test / cargo test / pytest / go test ./... ``` **If tests fail:** Stop. Cannot proceed until tests pass. ``` Tests failing (N failures). Must fix before completing: [Show failures] ``` **If tests pass:** Continue to Step 2. ### Step 2: Determine Base Branch ```bash git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null ``` Or confirm: "This branch split from main - is that correct?" ### Step 3: Present Options Present exactly these 4 options: ``` Implementation complete. What would you like to do? 1. Merge back to <base-branch> locally 2. Push and create a Pull Request 3. Keep the branch as-is (I'll handle it later) 4. Discard this work Which option? ``` ### Step 4: Execute Choice #### Option 1: Merge Locally ```bash git checkout <base-branch> git pull git merge <feature-branch> <run tests again> git branch -d <feature-branch> ``` Then: Cleanup worktree (Step 5) #### Option 2: Push and Create PR ```bash git push -u origin <feature-branch> gh pr create --title "<title>" --body "$(cat <<'EOF' ## Summary <2-3 bullets of what changed> ## Test Plan - [ ] <verification steps> EOF )" ``` Then: Cleanup worktree (Step 5) #### Option 3: Keep As-Is Report: "Keeping branch `<name>`. Worktree preserved at `<path>`." **Do NOT cleanup worktree.** #### Option 4: Discard **Confirm first:** ``` This will permanently delete: - Branch <name> - All commits: <commit-list> - Worktree at <path> Type 'discard' to confirm. ``` Wait for exact confirmation. If confirmed: ```bash git checkout <base-branch> git branch -D <feature-branch> ``` Then: Cleanup worktree (Step 5) ### Step 5: Cleanup Worktree **For Options 1, 2, 4 only:** ```bash # Check if in worktree git worktree list | grep $(git branch --show-current) # If yes: git worktree remove <worktree-path> ``` **For Option 3:** Keep worktree. --- ## Quick Reference | Option | Merge | Push | Keep Worktree | Cleanup Branch | |--------|-------|------|---------------|----------------| | 1. Merge locally | ✓ | - | - | ✓ | | 2. Create PR | - | ✓ | ✓ | - | | 3. Keep as-is | - | - | ✓ | - | | 4. Discard | - | - | - | ✓ (force) | --- ## NEVER - Proceed with failing tests - Merge without verifying tests on the result - Delete work without typed confirmation ("discard") - Force-push without explicit request - Skip presenting all 4 options - Automatically cleanup worktree for Options 2 or 3 - Ask open-ended "What should I do next?" (use structured options) --- ## Integration **Called by:** - `subagent-development` (after all tasks complete) - `executing-plans` (after all batches complete) **Pairs with:** - `git-worktrees` - Cleans up worktree created by that skill

path: README.md
content: # Finishing a Development Branch

Complete development work by presenting structured options for merge, PR, or cleanup. After implementation is complete, guides you through verifying tests, presenting integration options, and executing the chosen path.

## What's Inside

- Test verification before proceeding
- Base branch detection
- Four integration options: Merge locally, Push and create PR, Keep as-is, Discard
- Worktree cleanup procedures
- Quick reference table for each option's behavior
- Safety guards (typed confirmation for destructive actions)

## When to Use

- Implementation is complete
- All tests pass
- Ready to integrate work into the main branch
- Triggered by: "finish branch", "complete branch", "merge branch", "create PR", "done with feature", "implementation complete"

## Installation

```bash
npx add https://github.com/wpank/ai/tree/main/skills/tools/finishing-branch
```

### Manual Installation

#### Cursor (per-project)

From your project root:

```bash
mkdir -p .cursor/skills
cp -r ~/.ai-skills/skills/tools/finishing-branch .cursor/skills/finishing-branch
```

#### Cursor (global)

```bash
mkdir -p ~/.cursor/skills
cp -r ~/.ai-skills/skills/tools/finishing-branch ~/.cursor/skills/finishing-branch
```

#### Claude Code (per-project)

From your project root:

```bash
mkdir -p .claude/skills
cp -r ~/.ai-skills/skills/tools/finishing-branch .claude/skills/finishing-branch
```

#### Claude Code (global)

```bash
mkdir -p ~/.claude/skills
cp -r ~/.ai-skills/skills/tools/finishing-branch ~/.claude/skills/finishing-branch
```

## Related Skills

- **subagent-driven-development** — Calls this skill after all tasks complete
- **session-handoff** — For preserving context when pausing work

---

Part of the [Tools](..) skill category.


path: scripts/cleanup_branches.py
content: #!/usr/bin/env python3
"""Clean up stale and merged branches.

Usage:
    python cleanup_branches.py                 # list stale branches
    python cleanup_branches.py --delete        # delete merged branches
    python cleanup_branches.py --days 14       # custom stale threshold
    python cleanup_branches.py --dry-run       # preview only
"""

import argparse
import subprocess
import sys
from datetime import datetime, timezone


def run_git(*args, check=True):
    """Run a git command and return stripped stdout."""
    cmd = ["git"] + list(args)
    result = subprocess.run(cmd, capture_output=True, text=True, check=check)
    return result.stdout.strip()


def run_git_rc(*args):
    """Run a git command and return (return_code, stdout, stderr)."""
    cmd = ["git"] + list(args)
    result = subprocess.run(cmd, capture_output=True, text=True)
    return result.returncode, result.stdout.strip(), result.stderr.strip()


def get_main_branch():
    """Detect whether the repo uses 'main' or 'master'."""
    for candidate in ("main", "master"):
        rc, _, _ = run_git_rc("rev-parse", "--verify", candidate)
        if rc == 0:
            return candidate
    print("Error: Could not find a 'main' or 'master' branch.")
    sys.exit(1)


def get_merged_branches(main_branch):
    """Return list of local branches already merged into main."""
    output = run_git("branch", "--merged", main_branch)
    branches = []
    for line in output.splitlines():
        name = line.strip().lstrip("* ")
        if name and name not in (main_branch, "HEAD"):
            branches.append(name)
    return branches


def get_all_local_branches():
    """Return list of all local branch names."""
    output = run_git("branch", "--format=%(refname:short)")
    return [b.strip() for b in output.splitlines() if b.strip()]


def get_branch_last_commit_date(branch):
    """Return the datetime of the last commit on the branch."""
    iso = run_git("log", "-1", "--format=%cI", branch, check=False)
    if not iso:
        return None
    try:
        return datetime.fromisoformat(iso)
    except ValueError:
        return None


def get_branch_last_commit_subject(branch):
    """Return the subject of the last commit on the branch."""
    return run_git("log", "-1", "--format=%s", branch, check=False)


def days_ago(dt):
    """Return how many days ago a datetime was."""
    if dt is None:
        return float("inf")
    now = datetime.now(timezone.utc)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    delta = now - dt
    return delta.days


def find_stale_branches(main_branch, threshold_days):
    """Return list of (branch, days_stale, last_subject) for stale branches."""
    all_branches = get_all_local_branches()
    stale = []
    for branch in all_branches:
        if branch in (main_branch, "HEAD"):
            continue
        last_date = get_branch_last_commit_date(branch)
        age = days_ago(last_date)
        if age >= threshold_days:
            subject = get_branch_last_commit_subject(branch)
            stale.append((branch, age, subject))
    stale.sort(key=lambda x: x[1], reverse=True)
    return stale


def delete_branch(branch, dry_run=False):
    """Delete a branch locally and attempt remote deletion."""
    if dry_run:
        print(f"  [dry-run] Would delete local branch: {branch}")
        print(f"  [dry-run] Would delete remote branch: origin/{branch}")
        return

    rc, _, err = run_git_rc("branch", "-d", branch)
    if rc == 0:
        print(f"  Deleted local branch: {branch}")
    else:
        print(f"  Failed to delete local '{branch}': {err}")
        print(f"  Tip: use 'git branch -D {branch}' to force-delete.")
        return

    rc, _, err = run_git_rc("push", "origin", "--delete", branch)
    if rc == 0:
        print(f"  Deleted remote branch: origin/{branch}")
    else:
        # Remote branch may not exist — not an error
        pass


def main():
    parser = argparse.ArgumentParser(
        description="Clean up stale and merged git branches."
    )
    parser.add_argument(
        "--delete",
        action="store_true",
        help="Delete branches that have been merged into main.",
    )
    parser.add_argument(
        "--days",
        type=int,
        default=30,
        help="Threshold in days for considering a branch stale (default: 30).",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Preview actions without making changes.",
    )
    args = parser.parse_args()

    main_branch = get_main_branch()
    print(f"Main branch: {main_branch}")
    print(f"Stale threshold: {args.days} days")
    print()

    # --- Merged branches ---
    merged = get_merged_branches(main_branch)
    print(f"Branches merged into {main_branch} ({len(merged)}):")
    if merged:
        for b in merged:
            subject = get_branch_last_commit_subject(b)
            print(f"  - {b:30s}  {subject}")
    else:
        print("  (none)")
    print()

    # --- Stale branches ---
    stale = find_stale_branches(main_branch, args.days)
    # Exclude already-merged from stale list to avoid duplicate reporting
    merged_set = set(merged)
    stale_unmerged = [(b, d, s) for b, d, s in stale if b not in merged_set]

    print(f"Stale branches (no commits in {args.days}+ days, not merged) ({len(stale_unmerged)}):")
    if stale_unmerged:
        for branch, age, subject in stale_unmerged:
            print(f"  - {branch:30s}  {age:>4d} days  {subject}")
    else:
        print("  (none)")
    print()

    # --- Deletion ---
    if args.delete:
        targets = merged  # only delete branches confirmed merged
        if not targets:
            print("Nothing to delete — no merged branches found.")
            return

        print(f"Deleting {len(targets)} merged branch(es)...")
        for branch in targets:
            delete_branch(branch, dry_run=args.dry_run)
        print("\nDone.")
    else:
        if merged:
            print("To delete merged branches, run with --delete.")
        if stale_unmerged:
            print("Stale unmerged branches require manual review before deletion.")


if __name__ == "__main__":
    main()


path: scripts/finish_branch.py
content: #!/usr/bin/env python3
"""Safely finish a development branch with merge summary and cleanup.

Usage:
    python finish_branch.py                    # analyze current branch
    python finish_branch.py --squash-msg       # generate squash commit message
    python finish_branch.py --cleanup          # delete branch after merge
    python finish_branch.py --dry-run          # preview only
"""

import argparse
import os
import re
import subprocess
import sys
from datetime import datetime


def run_git(*args, check=True, capture=True):
    """Run a git command and return stripped stdout."""
    cmd = ["git"] + list(args)
    result = subprocess.run(
        cmd,
        capture_output=capture,
        text=True,
        check=check,
    )
    return result.stdout.strip() if capture else ""


def run_git_rc(*args):
    """Run a git command and return (return_code, stdout, stderr)."""
    cmd = ["git"] + list(args)
    result = subprocess.run(cmd, capture_output=True, text=True)
    return result.returncode, result.stdout.strip(), result.stderr.strip()


def get_current_branch():
    return run_git("rev-parse", "--abbrev-ref", "HEAD")


def get_main_branch():
    """Detect whether the repo uses 'main' or 'master'."""
    for candidate in ("main", "master"):
        rc, _, _ = run_git_rc("rev-parse", "--verify", candidate)
        if rc == 0:
            return candidate
    print("Error: Could not find a 'main' or 'master' branch.")
    sys.exit(1)


def check_not_main(branch, main_branch):
    if branch in (main_branch, "HEAD"):
        print(f"Error: You are on '{branch}'. Switch to a feature branch first.")
        sys.exit(1)


def check_clean_worktree():
    """Abort if the working tree has uncommitted changes."""
    rc, out, _ = run_git_rc("status", "--porcelain")
    if rc != 0:
        print("Error: Could not determine worktree status.")
        sys.exit(1)
    if out:
        print("Error: Working tree is dirty. Commit or stash changes first.")
        print(out)
        sys.exit(1)


def detect_test_runner():
    """Return a shell command to run tests, or None."""
    markers = {
        "Makefile": "make test",
        "package.json": "npm test",
        "Cargo.toml": "cargo test",
        "pyproject.toml": "python -m pytest",
        "setup.py": "python -m pytest",
        "pytest.ini": "python -m pytest",
        "tox.ini": "tox",
    }
    for filename, cmd in markers.items():
        if os.path.isfile(filename):
            return cmd
    return None


def run_tests(dry_run=False):
    """Run tests if a test runner is detected. Return True on success."""
    runner = detect_test_runner()
    if runner is None:
        print("  No test runner detected — skipping tests.")
        return True
    print(f"  Detected test runner: {runner}")
    if dry_run:
        print("  [dry-run] Would run tests.")
        return True
    rc = subprocess.run(runner, shell=True).returncode
    if rc != 0:
        print(f"  Tests failed (exit {rc}). Fix before finishing the branch.")
        return False
    print("  Tests passed.")
    return True


def check_up_to_date(branch, main_branch):
    """Check if the branch is up to date with main."""
    run_git("fetch", "origin", main_branch, check=False)
    behind = run_git(
        "rev-list", "--count", f"{branch}..origin/{main_branch}"
    )
    behind_count = int(behind) if behind.isdigit() else 0
    if behind_count > 0:
        print(f"  Warning: Branch is {behind_count} commit(s) behind origin/{main_branch}.")
        print(f"  Consider rebasing: git rebase origin/{main_branch}")
    else:
        print(f"  Branch is up to date with origin/{main_branch}.")
    return behind_count


def get_merge_base(branch, main_branch):
    return run_git("merge-base", branch, main_branch)


def get_commit_log(base, branch):
    """Return list of (hash, subject) tuples."""
    log = run_git(
        "log", "--oneline", "--no-decorate", f"{base}..{branch}"
    )
    if not log:
        return []
    commits = []
    for line in log.splitlines():
        parts = line.split(" ", 1)
        commits.append((parts[0], parts[1] if len(parts) > 1 else ""))
    return commits


def get_files_changed(base, branch):
    diff = run_git("diff", "--stat", f"{base}..{branch}")
    return diff


def find_todos_completed(base, branch):
    """Look for TODO/FIXME removals in the diff."""
    diff = run_git("diff", f"{base}..{branch}")
    pattern = re.compile(r"^-.*\b(TODO|FIXME|HACK|XXX)\b", re.IGNORECASE)
    return [line[1:].strip() for line in diff.splitlines() if pattern.match(line)]


def generate_merge_summary(branch, main_branch):
    """Print a human-readable merge summary."""
    base = get_merge_base(branch, main_branch)
    commits = get_commit_log(base, branch)
    files = get_files_changed(base, branch)
    todos = find_todos_completed(base, branch)

    print(f"\n{'=' * 60}")
    print(f"  MERGE SUMMARY: {branch} → {main_branch}")
    print(f"{'=' * 60}")

    print(f"\n  Commits ({len(commits)}):")
    for sha, msg in commits:
        print(f"    {sha} {msg}")

    print(f"\n  Files changed:")
    for line in files.splitlines():
        print(f"    {line}")

    if todos:
        print(f"\n  TODOs resolved ({len(todos)}):")
        for t in todos:
            print(f"    - {t}")
    else:
        print("\n  No TODOs resolved in this branch.")

    print(f"\n{'=' * 60}\n")
    return commits


def generate_squash_message(branch, main_branch):
    """Generate a squash commit message from the branch history."""
    base = get_merge_base(branch, main_branch)
    commits = get_commit_log(base, branch)

    title = branch.replace("-", " ").replace("_", " ").title()
    lines = [f"{title}\n"]
    lines.append(f"Squash merge of branch '{branch}' into {main_branch}.\n")

    if commits:
        lines.append("Included commits:")
        for sha, msg in commits:
            lines.append(f"  - {msg} ({sha})")
        lines.append("")

    message = "\n".join(lines)
    print("Generated squash commit message:\n")
    print(message)
    return message


def cleanup_branch(branch, dry_run=False):
    """Delete the branch locally and from origin."""
    print(f"\nCleanup: deleting branch '{branch}'")

    if dry_run:
        print(f"  [dry-run] Would run: git branch -d {branch}")
        print(f"  [dry-run] Would run: git push origin --delete {branch}")
        return

    # Delete local branch — must not be on it
    current = get_current_branch()
    if current == branch:
        main = get_main_branch()
        print(f"  Switching to {main} first...")
        run_git("checkout", main)

    rc, _, err = run_git_rc("branch", "-d", branch)
    if rc == 0:
        print(f"  Deleted local branch '{branch}'.")
    else:
        print(f"  Could not delete local branch: {err}")
        print(f"  If unmerged, use: git branch -D {branch}")

    # Delete remote branch
    rc, _, err = run_git_rc("push", "origin", "--delete", branch)
    if rc == 0:
        print(f"  Deleted remote branch 'origin/{branch}'.")
    else:
        print(f"  Could not delete remote branch (may not exist): {err}")


def main():
    parser = argparse.ArgumentParser(
        description="Safely finish a development branch."
    )
    parser.add_argument(
        "--squash-msg",
        action="store_true",
        help="Generate a squash commit message from the branch history.",
    )
    parser.add_argument(
        "--cleanup",
        action="store_true",
        help="Delete the branch locally and from origin after merge.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Preview actions without making changes.",
    )
    args = parser.parse_args()

    branch = get_current_branch()
    main_branch = get_main_branch()

    print(f"Branch : {branch}")
    print(f"Target : {main_branch}")
    print(f"Date   : {datetime.now().strftime('%Y-%m-%d %H:%M')}")
    print()

    # --- Step 1: verify not on main ---
    check_not_main(branch, main_branch)

    # --- Step 2: check for uncommitted changes ---
    print("[1/5] Checking working tree...")
    check_clean_worktree()
    print("  Working tree is clean.")

    # --- Step 3: run tests ---
    print("[2/5] Running tests...")
    if not run_tests(dry_run=args.dry_run):
        sys.exit(1)

    # --- Step 4: check up-to-date with main ---
    print("[3/5] Checking sync with main...")
    check_up_to_date(branch, main_branch)

    # --- Step 5: merge summary ---
    print("[4/5] Generating merge summary...")
    generate_merge_summary(branch, main_branch)

    # --- Step 6: squash message ---
    if args.squash_msg:
        print("[5/5] Generating squash commit message...")
        generate_squash_message(branch, main_branch)
    else:
        print("[5/5] Skipping squash message (use --squash-msg to generate).")

    # --- Step 7: cleanup ---
    if args.cleanup:
        cleanup_branch(branch, dry_run=args.dry_run)
    else:
        print("\nCleanup commands (run manually after merging):")
        print(f"  git checkout {main_branch}")
        print(f"  git branch -d {branch}")
        print(f"  git push origin --delete {branch}")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)