diff --git a/.agents/skills/conversation-done/SKILL.md b/.agents/skills/conversation-done/SKILL.md new file mode 100644 index 0000000000..11a5b21309 --- /dev/null +++ b/.agents/skills/conversation-done/SKILL.md @@ -0,0 +1,95 @@ +--- +name: conversation-done +description: >- + Clean up files and resources that aren't part of the agent conversation + (e.g. Bazel output base, Git worktree, local/remote branches) +--- + +# Conversation Done Skill + +Cleans up external workspace resources created during agent workflows while +preserving agent conversation data, chat history, and brain transcripts. + +## Overview + +When agents work on tasks, several external resources outside the agent +conversation directory (`~/.gemini/jetski/brain/`) are created: + +1. **Bazel Output Base(s)**: Intermediate build artifacts, execroot trees, + and running Bazel server and persistent worker processes (consuming tens of + gigabytes of disk and substantial RAM). +2. **Git Worktree**: The checked-out directory on disk. +3. **Local Git Branch**: The feature branch associated with the worktree. +4. **Remote Git Branch**: The feature branch pushed to the user's fork + (e.g., `origin`). + +This skill safely shuts down active build processes and reclaims these +resources without removing the conversation transcript or chat history. + +## Script Location + +The cleanup script is located at: +`./.agents/skills/conversation-done/scripts/cleanup.py` + +## Usage + +### 1. Clean Up Current Workspace + +When invoked from within the agent's worktree: + +```bash +# Preview what would be removed: +./.agents/skills/conversation-done/scripts/cleanup.py --dry-run + +# Clean up resources (interactive confirmation if TTY): +./.agents/skills/conversation-done/scripts/cleanup.py + +# Force cleanup non-interactively (ideal for agents/scripts): +./.agents/skills/conversation-done/scripts/cleanup.py --force +``` + +### 2. Clean Up by Worktree Path or Branches + +```bash +# By worktree path: +./.agents/skills/conversation-done/scripts/cleanup.py \ + --worktree /path/to/worktree --force + +# By branch name: +./.agents/skills/conversation-done/scripts/cleanup.py \ + --branches my-feature-branch --force + +# By multiple branches: +./.agents/skills/conversation-done/scripts/cleanup.py \ + --branches branch-one branch-two --force +``` + +### 3. Inspect Active Resources + +List all worktrees, branches, and Bazel output bases: + +```bash +./.agents/skills/conversation-done/scripts/cleanup.py --list +``` + +## Selective Cleanup Flags + +Fine-tune what gets cleaned up using selective leave flags: + +* `--leave-bazel-output-base`: Leave Bazel output bases and build caches + intact. +* `--leave-worktree`: Leave Git worktree directory on disk. +* `--leave-branch`: Keep local Git branch. +* `--leave-remote`: Do not delete branch from remote fork. +* `--remote `: Override remote destination (default: `pushRemote` or + `origin`). + +## Safety Guarantees + +* **Main Repository Protection**: Never removes the primary Git repository + worktree. +* **Protected Branches**: Never deletes `main`, `master`, `release/*`, or + `HEAD`. +* **Upstream Remote Protection**: Never deletes branches on `upstream`. +* **No Expunge Rule**: Never runs `bazel clean --expunge`; shuts down servers + gracefully and deletes the specific output base directory directly. diff --git a/.agents/skills/conversation-done/scripts/cleanup.py b/.agents/skills/conversation-done/scripts/cleanup.py new file mode 100755 index 0000000000..24d13a3cd2 --- /dev/null +++ b/.agents/skills/conversation-done/scripts/cleanup.py @@ -0,0 +1,781 @@ +#!/usr/bin/env python3 +"""Cleans up workspace resources created for an agent conversation. + +This script cleans up files and resources that are external to the agent +conversation itself (such as Bazel output bases, Git worktrees, and local or +remote Git branches), while leaving the conversation history, transcripts, +and brain state intact. + +Usage examples: + # Clean up current worktree and its associated resources: + cleanup.py + + # Preview what would be cleaned up without making changes: + cleanup.py --dry-run + + # Clean up a specific worktree by path: + cleanup.py --worktree /path/to/worktree + + # Clean up resources for specific branches: + cleanup.py --branches my-feature-branch + + # Clean up resources for multiple branches: + cleanup.py --branches branch-one branch-two + + # List worktrees and their associated resources: + cleanup.py --list +""" + +from __future__ import annotations + +import argparse +import asyncio +import dataclasses +import glob +import os +import shutil +import signal +import stat +import subprocess +import sys + +PROTECTED_BRANCHES = frozenset({"main", "master", "HEAD"}) +PROTECTED_REMOTES = frozenset({"upstream"}) + + +@dataclasses.dataclass +class WorktreeInfo: + """Information about a Git worktree.""" + + path: str + head: str + branch: str | None + is_main: bool + + +@dataclasses.dataclass +class CleanupTarget: + """Target resources identified for cleanup.""" + + worktree_path: str | None = None + branch: str | None = None + remote: str | None = None + remote_branch_exists: bool = False + bazel_output_bases: list[str] = dataclasses.field(default_factory=list) + is_main_worktree: bool = False + + +def log(msg: str) -> None: + """Prints an informational message.""" + print(msg, flush=True) + + +def log_warn(msg: str) -> None: + """Prints a warning message to stderr.""" + print(f"Warning: {msg}", file=sys.stderr, flush=True) + + +def log_error(msg: str) -> None: + """Prints an error message to stderr.""" + print(f"Error: {msg}", file=sys.stderr, flush=True) + + +async def run_command( + cmd: list[str], + cwd: str | None = None, + capture_output: bool = True, + timeout: float | None = None, +) -> subprocess.CompletedProcess[str]: + """Executes a subprocess command asynchronously and safely.""" + stdout_pipe = asyncio.subprocess.PIPE if capture_output else None + stderr_pipe = asyncio.subprocess.PIPE if capture_output else None + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + cwd=cwd, + stdout=stdout_pipe, + stderr=stderr_pipe, + ) + try: + stdout_b, stderr_b = await asyncio.wait_for( + proc.communicate(), timeout=timeout + ) + except asyncio.TimeoutError: + try: + proc.kill() + await proc.wait() + except OSError: + pass + return subprocess.CompletedProcess( + args=cmd, + returncode=1, + stdout="", + stderr=f"Command timed out after {timeout} seconds", + ) + stdout = stdout_b.decode("utf-8", errors="replace") if stdout_b else "" + stderr = stderr_b.decode("utf-8", errors="replace") if stderr_b else "" + return subprocess.CompletedProcess( + args=cmd, + returncode=proc.returncode if proc.returncode is not None else 0, + stdout=stdout, + stderr=stderr, + ) + except (FileNotFoundError, OSError) as exc: + return subprocess.CompletedProcess( + args=cmd, + returncode=1, + stdout="", + stderr=str(exc), + ) + + +async def get_main_repo(cwd: str | None = None) -> str: + """Finds the root directory of the primary Git repository.""" + res = await run_command(["git", "rev-parse", "--git-common-dir"], cwd=cwd) + if res.returncode == 0 and res.stdout.strip(): + git_common = os.path.abspath(res.stdout.strip()) + if os.path.basename(git_common) == ".git": + return os.path.dirname(git_common) + return git_common + + res = await run_command(["git", "rev-parse", "--show-toplevel"], cwd=cwd) + if res.returncode == 0 and res.stdout.strip(): + return os.path.abspath(res.stdout.strip()) + + raise RuntimeError("Not inside a Git repository.") + + +async def get_current_worktree(cwd: str | None = None) -> str | None: + """Returns the top-level directory of the current worktree, if any.""" + res = await run_command(["git", "rev-parse", "--show-toplevel"], cwd=cwd) + if res.returncode == 0 and res.stdout.strip(): + return os.path.abspath(res.stdout.strip()) + return None + + +async def get_worktrees(main_repo: str) -> list[WorktreeInfo]: + """Returns all Git worktrees registered in the repository.""" + res = await run_command(["git", "-C", main_repo, "worktree", "list", "--porcelain"]) + if res.returncode != 0: + log_error(f"Failed to list Git worktrees: {res.stderr.strip()}") + return [] + + worktrees: list[WorktreeInfo] = [] + current_wt: str | None = None + current_head = "" + current_branch: str | None = None + + for line in res.stdout.splitlines(): + line = line.strip() + if line.startswith("worktree "): + if current_wt: + worktrees.append( + WorktreeInfo( + path=current_wt, + head=current_head, + branch=current_branch, + is_main=len(worktrees) == 0, + ) + ) + current_wt = line.split("worktree ", 1)[1].strip() + current_head = "" + current_branch = None + elif line.startswith("HEAD "): + current_head = line.split("HEAD ", 1)[1].strip() + elif line.startswith("branch refs/heads/"): + current_branch = line.split("branch refs/heads/", 1)[1].strip() + + if current_wt: + worktrees.append( + WorktreeInfo( + path=current_wt, + head=current_head, + branch=current_branch, + is_main=len(worktrees) == 0, + ) + ) + + return worktrees + + +def is_protected_branch(branch: str | None) -> bool: + """Checks if a branch is protected from deletion.""" + if not branch: + return True + if branch in PROTECTED_BRANCHES: + return True + if branch.startswith("release/") or branch.startswith("release-"): + return True + return False + + +async def get_push_remote_for_branch(main_repo: str, branch: str) -> str: + """Determines the push remote for a branch, defaulting to 'origin'.""" + res = await run_command( + ["git", "-C", main_repo, "config", f"branch.{branch}.pushRemote"] + ) + remote = res.stdout.strip() + if remote: + return remote + + res = await run_command( + ["git", "-C", main_repo, "config", f"branch.{branch}.remote"] + ) + remote = res.stdout.strip() + if remote and remote not in PROTECTED_REMOTES: + return remote + + return "origin" + + +async def remote_branch_exists(main_repo: str, remote: str, branch: str) -> bool: + """Checks whether a branch exists on the specified remote.""" + if remote in PROTECTED_REMOTES or is_protected_branch(branch): + return False + res = await run_command( + ["git", "-C", main_repo, "ls-remote", "--heads", remote, branch], + timeout=10, + ) + return res.returncode == 0 and bool(res.stdout.strip()) + + +def find_bazel_output_bases(target_worktree: str) -> list[str]: + """Finds all Bazel output bases associated with a worktree path.""" + target_worktree = os.path.realpath(target_worktree) + output_bases: list[str] = [] + + user_cache = os.path.expanduser("~/.cache/bazel") + if not os.path.isdir(user_cache): + return output_bases + + for user_dir in glob.glob(os.path.join(user_cache, "_bazel_*")): + if not os.path.isdir(user_dir): + continue + for ob in glob.glob(os.path.join(user_dir, "*")): + if not os.path.isdir(ob) or os.path.basename(ob) in ("cache", "install"): + continue + dnbh = os.path.join(ob, "DO_NOT_BUILD_HERE") + if os.path.isfile(dnbh): + try: + with open(dnbh, "r", encoding="utf-8") as f: + ws = os.path.realpath(f.read().strip()) + if ws == target_worktree or ws.startswith(target_worktree + os.sep): + output_bases.append(ob) + except OSError: + pass + + return sorted(list(set(output_bases))) + + +async def get_path_size_human(path: str) -> str: + """Returns human-readable size of a directory path.""" + if not os.path.exists(path): + return "0 B" + res = await run_command(["du", "-sk", path], timeout=5) + if res.returncode == 0 and res.stdout.strip(): + try: + kb = int(res.stdout.split()[0]) + if kb < 1024: + return f"{kb} KB" + mb = kb / 1024.0 + if mb < 1024: + return f"{mb:.1f} MB" + gb = mb / 1024.0 + return f"{gb:.2f} GB" + except (ValueError, IndexError): + pass + return "unknown size" + + +async def shutdown_and_remove_bazel_output_base( + output_base: str, dry_run: bool = False +) -> bool: + """Cleanly shuts down the Bazel server and removes the output base directory.""" + if dry_run: + log( + f" [Dry Run] Would shutdown Bazel server and delete output base: {output_base}" + ) + return True + + log(f" Shutting down Bazel server for output base: {output_base}") + # 1. Graceful shutdown command + await run_command( + ["bazel", f"--output_base={output_base}", "shutdown"], + timeout=10, + ) + + # 2. Check for running server process from server.pid.txt + server_pid_file = os.path.join(output_base, "server", "server.pid.txt") + if os.path.isfile(server_pid_file): + try: + with open(server_pid_file, "r", encoding="utf-8") as f: + pid = int(f.read().strip()) + os.kill(pid, 0) + log(f" Terminating lingering Bazel server process (PID: {pid})...") + os.kill(pid, signal.SIGTERM) + await asyncio.sleep(0.5) + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + except (ValueError, OSError): + pass + + # 3. Terminate any persistent workers or subprocesses tied to this output base + terminate_processes_under_path(output_base) + + # 4. Remove output base directory (NEVER run bazel clean --expunge per rules) + log(f" Removing Bazel output base directory: {output_base}") + return await remove_directory_safely(output_base) + + +def terminate_processes_under_path(path: str) -> None: + """Terminates active processes running binaries or workers inside path.""" + current_pid = os.getpid() + if not os.path.isdir("/proc"): + return + + real_path = os.path.realpath(path) + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + pid = int(entry) + if pid == current_pid or pid == 1: + continue + try: + exe = os.path.realpath(f"/proc/{pid}/exe") + if exe.startswith(real_path + os.sep): + os.kill(pid, signal.SIGTERM) + continue + with open(f"/proc/{pid}/cmdline", "rb") as f: + cmdline = f.read().decode("utf-8", errors="ignore") + if real_path in cmdline: + os.kill(pid, signal.SIGTERM) + except (OSError, FileNotFoundError): + continue + + +async def remove_directory_safely(path: str) -> bool: + """Recursively removes a directory tree, making read-only files writable.""" + if not os.path.exists(path) and not os.path.islink(path): + return True + + res = await run_command(["rm", "-rf", path]) + if res.returncode == 0 and not os.path.exists(path): + return True + + def _sync_rmtree(): + def _make_writable_and_retry(func, fpath, unused_exc_info): + try: + os.chmod(fpath, stat.S_IWRITE | stat.S_IWUSR | stat.S_IRUSR) + func(fpath) + except OSError: + pass + + try: + shutil.rmtree(path, onerror=_make_writable_and_retry) + except Exception as exc: + log_warn(f"Failed to completely remove {path}: {exc}") + + await asyncio.to_thread(_sync_rmtree) + return not os.path.exists(path) + + +async def remove_git_worktree( + main_repo: str, worktree_path: str, dry_run: bool = False +) -> bool: + """Removes a Git worktree safely.""" + real_wt = os.path.realpath(worktree_path) + real_main = os.path.realpath(main_repo) + + if real_wt == real_main: + log_error(f"Cannot remove main repository worktree: {worktree_path}") + return False + + if dry_run: + log(f" [Dry Run] Would remove Git worktree: {worktree_path}") + return True + + # If current working directory is inside worktree, cd to main repo + try: + cwd = os.path.realpath(os.getcwd()) + if cwd == real_wt or cwd.startswith(real_wt + os.sep): + os.chdir(real_main) + except OSError: + os.chdir(real_main) + + log(f" Removing Git worktree: {worktree_path}") + res = await run_command( + ["git", "-C", real_main, "worktree", "remove", "--force", real_wt] + ) + if res.returncode != 0: + log_warn( + f"git worktree remove failed ({res.stderr.strip()}), removing directly..." + ) + await remove_directory_safely(real_wt) + await run_command(["git", "-C", real_main, "worktree", "prune"]) + + return not os.path.exists(real_wt) + + +async def delete_local_branch( + main_repo: str, branch: str, dry_run: bool = False +) -> tuple[bool, str]: + """Deletes a local Git branch.""" + if is_protected_branch(branch): + return False, f"Branch '{branch}' is protected and will not be deleted." + + if dry_run: + return True, f"[Dry Run] Would delete local branch '{branch}'." + + # Verify branch is not checked out in another worktree + worktrees = await get_worktrees(main_repo) + for wt in worktrees: + if wt.branch == branch and os.path.exists(wt.path): + return ( + False, + f"Branch '{branch}' is still checked out in active worktree: {wt.path}", + ) + + res = await run_command(["git", "-C", main_repo, "branch", "-D", branch]) + if res.returncode == 0: + return True, f"Deleted local branch '{branch}'." + return False, f"Failed to delete local branch '{branch}': {res.stderr.strip()}" + + +async def delete_remote_branch( + main_repo: str, + branch: str, + remote: str, + dry_run: bool = False, +) -> tuple[bool, str]: + """Deletes a branch on the remote fork.""" + if is_protected_branch(branch): + return False, f"Branch '{branch}' is protected and will not be deleted." + + if remote in PROTECTED_REMOTES: + return ( + False, + f"Remote '{remote}' is protected (canonical upstream). Branch will not be deleted.", + ) + + exists = await remote_branch_exists(main_repo, remote, branch) + if not exists: + return True, f"Remote branch '{branch}' does not exist on '{remote}'." + + if dry_run: + return ( + True, + f"[Dry Run] Would delete remote branch '{branch}' on remote '{remote}'.", + ) + + res = await run_command( + ["git", "-C", main_repo, "push", remote, "--delete", branch], + timeout=30, + ) + if res.returncode == 0: + return True, f"Deleted remote branch '{branch}' from '{remote}'." + return ( + False, + f"Failed to delete remote branch '{branch}' on '{remote}': {res.stderr.strip()}", + ) + + +async def build_cleanup_target( + main_repo: str, + worktree_path: str | None, + branch: str | None, + remote: str | None = None, +) -> CleanupTarget: + """Assembles all resources associated with a target worktree/branch.""" + target = CleanupTarget( + worktree_path=worktree_path, + branch=branch, + remote=remote, + ) + + if worktree_path: + real_wt = os.path.realpath(worktree_path) + real_main = os.path.realpath(main_repo) + target.is_main_worktree = real_wt == real_main + target.bazel_output_bases = find_bazel_output_bases(worktree_path) + + if branch: + if not target.remote: + target.remote = await get_push_remote_for_branch(main_repo, branch) + if target.remote and target.remote not in PROTECTED_REMOTES: + target.remote_branch_exists = await remote_branch_exists( + main_repo, target.remote, branch + ) + + return target + + +async def execute_cleanup_target( + main_repo: str, + target: CleanupTarget, + leave_bazel_output_base: bool = False, + leave_worktree: bool = False, + leave_branch: bool = False, + leave_remote: bool = False, + dry_run: bool = False, +) -> None: + """Executes cleanup on a single target.""" + log(f"\nCleaning up target resources for: {target.worktree_path or target.branch}") + + if target.is_main_worktree: + log_error( + "Target is the main repository! Refusing to clean up main repository." + ) + return + + # 1. Clean up Bazel output bases + if not leave_bazel_output_base and target.bazel_output_bases: + log("1. Cleaning up Bazel output bases:") + for ob in target.bazel_output_bases: + size_str = await get_path_size_human(ob) + log(f" - Output base: {ob} ({size_str})") + await shutdown_and_remove_bazel_output_base(ob, dry_run=dry_run) + elif leave_bazel_output_base: + log("1. Leaving Bazel output base intact.") + else: + log("1. No associated Bazel output bases found.") + + # 2. Clean up Git worktree + if not leave_worktree and target.worktree_path: + log("2. Cleaning up Git worktree:") + await remove_git_worktree(main_repo, target.worktree_path, dry_run=dry_run) + elif leave_worktree: + log("2. Leaving Git worktree directory on disk.") + else: + log("2. No worktree directory to remove.") + + # 3. Clean up local Git branch + if not leave_branch and target.branch: + log(f"3. Cleaning up local branch '{target.branch}':") + success, msg = await delete_local_branch( + main_repo, target.branch, dry_run=dry_run + ) + if success: + log(f" {msg}") + else: + log_warn(f" {msg}") + elif leave_branch: + log("3. Leaving local branch intact.") + else: + log("3. No local branch specified.") + + # 4. Clean up remote Git branch + if ( + not leave_remote + and target.branch + and target.remote + and target.remote_branch_exists + ): + log(f"4. Cleaning up remote branch '{target.branch}' on '{target.remote}':") + success, msg = await delete_remote_branch( + main_repo, target.branch, target.remote, dry_run=dry_run + ) + if success: + log(f" {msg}") + else: + log_warn(f" {msg}") + elif leave_remote: + log("4. Leaving remote branch intact.") + elif target.branch and target.remote: + log(f"4. Remote branch '{target.branch}' not present on '{target.remote}'.") + + +async def list_resources(main_repo: str) -> None: + """Lists all worktrees and their associated resources.""" + worktrees = await get_worktrees(main_repo) + log(f"Found {len(worktrees)} Git worktrees in {main_repo}:\n") + for wt in worktrees: + kind = " [MAIN REPO]" if wt.is_main else "" + branch_str = f" (branch: {wt.branch})" if wt.branch else " (detached HEAD)" + log(f"* {wt.path}{kind}{branch_str}") + obs = find_bazel_output_bases(wt.path) + if obs: + for ob in obs: + size_str = await get_path_size_human(ob) + log(f" - Bazel output base: {ob} ({size_str})") + else: + log(" - No Bazel output base found.") + + +def parse_args() -> argparse.Namespace: + """Parses command-line arguments.""" + parser = argparse.ArgumentParser( + description="Clean up agent workspace resources (Bazel output base, worktree, branches)." + ) + parser.add_argument( + "--worktree", + type=str, + default=None, + help="Path to the Git worktree to clean up.", + ) + parser.add_argument( + "--branches", + nargs="+", + default=[], + help="One or more branch names to clean up.", + ) + parser.add_argument( + "--remote", + type=str, + default=None, + help="Remote name for remote branch deletion (default: pushRemote or origin).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview resources to delete without making changes.", + ) + parser.add_argument( + "-f", + "--force", + action="store_true", + help="Proceed with cleanup without interactive confirmation.", + ) + parser.add_argument( + "--list", + action="store_true", + help="List worktrees, branches, and associated Bazel output bases.", + ) + parser.add_argument( + "--leave-bazel-output-base", + action="store_true", + help="Leave Bazel output bases and build caches intact.", + ) + parser.add_argument( + "--leave-worktree", + action="store_true", + help="Leave Git worktree directory on disk.", + ) + parser.add_argument( + "--leave-branch", + action="store_true", + help="Keep local Git branch.", + ) + parser.add_argument( + "--leave-remote", + action="store_true", + help="Do not delete branch from remote fork.", + ) + return parser.parse_args() + + +async def async_main() -> None: + """Async CLI entrypoint.""" + args = parse_args() + main_repo = await get_main_repo() + + if args.list: + await list_resources(main_repo) + return + + worktrees = await get_worktrees(main_repo) + targets: list[CleanupTarget] = [] + + # If branches were explicitly provided + if args.branches: + for branch in args.branches: + wt_path = None + for wt in worktrees: + if wt.branch == branch: + wt_path = wt.path + break + target = await build_cleanup_target( + main_repo, wt_path, branch, remote=args.remote + ) + targets.append(target) + + # If worktree was explicitly provided + elif args.worktree: + real_target = os.path.realpath(args.worktree) + found_branch = None + for wt in worktrees: + if os.path.realpath(wt.path) == real_target: + found_branch = wt.branch + break + target = await build_cleanup_target( + main_repo, args.worktree, found_branch, remote=args.remote + ) + targets.append(target) + + # Otherwise default to current worktree + else: + curr_wt = await get_current_worktree() + if not curr_wt: + log_error( + "Could not determine current worktree. " + "Specify --worktree or --branches." + ) + sys.exit(1) + + real_curr = os.path.realpath(curr_wt) + real_main = os.path.realpath(main_repo) + if real_curr == real_main: + log_error( + "Currently inside the main repository. Refusing to clean up main repository.\n" + "Specify --worktree or --branches." + ) + sys.exit(1) + + found_branch = None + for wt in worktrees: + if os.path.realpath(wt.path) == real_curr: + found_branch = wt.branch + break + + target = await build_cleanup_target( + main_repo, curr_wt, found_branch, remote=args.remote + ) + targets.append(target) + + if not targets: + log_error("No valid targets identified for cleanup.") + sys.exit(1) + + # Show preview + for target in targets: + log("\nIdentified resources for cleanup:") + if target.worktree_path: + log(f" Worktree directory: {target.worktree_path}") + if target.bazel_output_bases: + for ob in target.bazel_output_bases: + size_str = await get_path_size_human(ob) + log(f" Bazel output base: {ob} ({size_str})") + else: + log(" Bazel output base: None found") + if target.branch: + log(f" Local branch: {target.branch}") + if target.remote and target.remote_branch_exists: + log(f" Remote branch: {target.remote}/{target.branch}") + + if not args.force and not args.dry_run and sys.stdin.isatty(): + confirm = input("\nProceed with cleanup? [y/N]: ").strip() + if confirm.lower() not in ("y", "yes"): + log("Aborted.") + return + + for target in targets: + await execute_cleanup_target( + main_repo, + target, + leave_bazel_output_base=args.leave_bazel_output_base, + leave_worktree=args.leave_worktree, + leave_branch=args.leave_branch, + leave_remote=args.leave_remote, + dry_run=args.dry_run, + ) + log("\nCleanup completed.") + + +def main() -> None: + """Main CLI entrypoint.""" + asyncio.run(async_main()) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/conversation-done/scripts/cleanup_test.py b/.agents/skills/conversation-done/scripts/cleanup_test.py new file mode 100644 index 0000000000..18a38b1ef3 --- /dev/null +++ b/.agents/skills/conversation-done/scripts/cleanup_test.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Unit tests for the async cleanup script.""" + +import os +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +# Ensure scripts dir is in sys.path +sys_path = os.path.dirname(os.path.abspath(__file__)) +if sys_path not in sys.path: + sys.path.insert(0, sys_path) + +import cleanup # noqa: E402 + + +class CleanupScriptTest(unittest.IsolatedAsyncioTestCase): + """Tests for cleanup.py async logic and safety guards.""" + + def test_is_protected_branch(self): + """Verifies protected branches cannot be deleted.""" + self.assertTrue(cleanup.is_protected_branch("main")) + self.assertTrue(cleanup.is_protected_branch("master")) + self.assertTrue(cleanup.is_protected_branch("HEAD")) + self.assertTrue(cleanup.is_protected_branch("release/1.0")) + self.assertTrue(cleanup.is_protected_branch("release/2026-08")) + self.assertTrue(cleanup.is_protected_branch("release-1.0")) + self.assertTrue(cleanup.is_protected_branch("release-2026-08")) + self.assertTrue(cleanup.is_protected_branch(None)) + self.assertFalse(cleanup.is_protected_branch("feature/my-cool-branch")) + self.assertFalse(cleanup.is_protected_branch("bugfix-1234")) + + async def test_worktrees_parsing(self): + """Verifies parsing git worktree list porcelain output.""" + sample_output = """worktree /path/to/main/repo +HEAD 03f212c3f3b590a7103d512650ce09818d30d218 +branch refs/heads/main + +worktree /path/to/worktrees/feat1 +HEAD f130aea7fa0eab7d558897ed0ad915a1057e065c +branch refs/heads/feat1 + +worktree /path/to/worktrees/detached +HEAD 21e9bd5b3b069d4057abac69adf985b7907308f8 +detached +""" + with mock.patch("cleanup.run_command") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout=sample_output, stderr="" + ) + worktrees = await cleanup.get_worktrees("/path/to/main/repo") + + self.assertEqual(len(worktrees), 3) + self.assertEqual(worktrees[0].path, "/path/to/main/repo") + self.assertTrue(worktrees[0].is_main) + self.assertEqual(worktrees[0].branch, "main") + + self.assertEqual(worktrees[1].path, "/path/to/worktrees/feat1") + self.assertFalse(worktrees[1].is_main) + self.assertEqual(worktrees[1].branch, "feat1") + + self.assertEqual(worktrees[2].path, "/path/to/worktrees/detached") + self.assertFalse(worktrees[2].is_main) + self.assertIsNone(worktrees[2].branch) + + def test_find_bazel_output_bases(self): + """Verifies finding Bazel output bases matching target workspace.""" + with tempfile.TemporaryDirectory() as temp_dir: + user_dir = os.path.join(temp_dir, "_bazel_testuser") + os.makedirs(user_dir) + + # Output base 1: matches main worktree + ob1 = os.path.join(user_dir, "hash1") + os.makedirs(ob1) + with open(os.path.join(ob1, "DO_NOT_BUILD_HERE"), "w") as f: + f.write("/home/user/worktrees/my_feature\n") + + # Output base 2: matches nested integration workspace + ob2 = os.path.join(user_dir, "hash2") + os.makedirs(ob2) + with open(os.path.join(ob2, "DO_NOT_BUILD_HERE"), "w") as f: + f.write("/home/user/worktrees/my_feature/tests/integration/nested\n") + + # Output base 3: unrelated workspace + ob3 = os.path.join(user_dir, "hash3") + os.makedirs(ob3) + with open(os.path.join(ob3, "DO_NOT_BUILD_HERE"), "w") as f: + f.write("/home/user/worktrees/different_feature\n") + + with mock.patch("os.path.expanduser", return_value=temp_dir): + bases = cleanup.find_bazel_output_bases( + "/home/user/worktrees/my_feature" + ) + + self.assertEqual(sorted(bases), sorted([ob1, ob2])) + + async def test_main_repo_safety(self): + """Verifies refusal to remove main repository worktree.""" + main_repo = "/repo/main" + target = cleanup.CleanupTarget( + worktree_path="/repo/main", + branch="main", + is_main_worktree=True, + ) + with mock.patch("cleanup.remove_git_worktree") as mock_rm_wt, mock.patch( + "cleanup.log_error" + ) as mock_log_err: + await cleanup.execute_cleanup_target(main_repo, target) + mock_rm_wt.assert_not_called() + mock_log_err.assert_called_with( + "Target is the main repository! Refusing to clean up main repository." + ) + + async def test_protected_branch_safety(self): + """Verifies refusal to delete protected branch.""" + success, msg = await cleanup.delete_local_branch("/repo/main", "main") + self.assertFalse(success) + self.assertIn("protected", msg) + + success, msg = await cleanup.delete_remote_branch( + "/repo/main", "main", "origin" + ) + self.assertFalse(success) + self.assertIn("protected", msg) + + async def test_upstream_remote_safety(self): + """Verifies refusal to delete branch on upstream remote.""" + success, msg = await cleanup.delete_remote_branch( + "/repo/main", "feature-x", "upstream" + ) + self.assertFalse(success) + self.assertIn("protected", msg) + + async def test_build_cleanup_target_branches(self): + """Verifies building cleanup target for a branch.""" + with mock.patch( + "cleanup.get_push_remote_for_branch", return_value="origin" + ), mock.patch("cleanup.remote_branch_exists", return_value=True): + target = await cleanup.build_cleanup_target( + "/repo/main", + "/repo/worktrees/feat1", + "feat1", + ) + self.assertEqual(target.worktree_path, "/repo/worktrees/feat1") + self.assertEqual(target.branch, "feat1") + self.assertEqual(target.remote, "origin") + self.assertTrue(target.remote_branch_exists) + + +if __name__ == "__main__": + unittest.main()