Coverage for src/workstack/cli/commands/remove.py: 72%
128 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
1import json
2import shutil
3from pathlib import Path
5import click
7from workstack.cli.commands.switch import complete_worktree_names
8from workstack.cli.core import (
9 discover_repo_context,
10 ensure_workstacks_dir,
11 validate_worktree_name_for_removal,
12 worktree_path_for,
13)
14from workstack.cli.graphite import get_branch_stack
15from workstack.core.context import WorkstackContext, create_context
16from workstack.core.gitops import GitOps
19def _try_git_worktree_remove(git_ops: GitOps, repo_root: Path, wt_path: Path) -> bool:
20 """Attempt git worktree remove, returning success status.
22 This function violates LBYL norms because there's no reliable way to
23 check a priori if git worktree remove will succeed. The worktree might be:
24 - Already removed from git metadata
25 - In a partially corrupted state
26 - Referenced by stale lock files
28 Git's own error handling is unreliable for these edge cases, so we use
29 try/except as an error boundary and rely on manual cleanup + prune.
31 Returns:
32 True if git removal succeeded, False otherwise
33 """
34 try:
35 git_ops.remove_worktree(repo_root, wt_path, force=True)
36 return True
37 except Exception:
38 # Git removal failed - manual cleanup will handle it
39 return False
42def _prune_worktrees_safe(git_ops: GitOps, repo_root: Path) -> None:
43 """Prune worktree metadata, ignoring errors if nothing to prune.
45 This function violates LBYL norms because git worktree prune can fail
46 for various reasons (no stale worktrees, permission issues, etc.) that
47 are not easily detectable beforehand. Since pruning is a cleanup operation
48 and failure doesn't affect the primary operation, we allow silent failure.
49 """
50 try:
51 git_ops.prune_worktrees(repo_root)
52 except Exception:
53 # Prune might fail if there's nothing to prune or other non-critical issues
54 pass
57def _find_worktree_branch(ctx: WorkstackContext, repo_root: Path, wt_path: Path) -> str | None:
58 """Find the branch for a given worktree path.
60 Returns None if worktree is not found or is in detached HEAD state.
61 """
62 worktrees = ctx.git_ops.list_worktrees(repo_root)
63 for wt in worktrees:
64 if wt.path == wt_path:
65 return wt.branch
66 return None
69def _get_non_trunk_branches(ctx: WorkstackContext, repo_root: Path, stack: list[str]) -> list[str]:
70 """Filter out trunk branches from a stack.
72 Returns empty list if git directory cannot be found or cache is missing.
73 Prints warning messages for error conditions.
74 """
75 git_dir = ctx.git_ops.get_git_common_dir(repo_root)
76 if git_dir is None:
77 click.echo("Warning: Could not find git directory. Cannot delete stack.", err=True)
78 return []
80 cache_file = git_dir / ".graphite_cache_persist"
81 if not cache_file.exists():
82 click.echo("Warning: Graphite cache not found. Cannot delete stack.", err=True)
83 return []
85 cache_data = json.loads(cache_file.read_text(encoding="utf-8"))
86 branches_data = cache_data.get("branches", [])
88 trunk_branches = {
89 branch_name
90 for branch_name, info in branches_data
91 if info.get("validationResult") == "TRUNK"
92 }
94 return [b for b in stack if b not in trunk_branches]
97def _get_branches_to_delete(
98 ctx: WorkstackContext, repo_root: Path, worktree_branch: str
99) -> list[str] | None:
100 """Get list of branches to delete for a worktree's stack.
102 Returns:
103 None if deletion should be skipped (warnings already printed)
104 Empty list if no branches to delete
105 List of branch names if branches should be deleted
106 """
107 stack = get_branch_stack(ctx, repo_root, worktree_branch)
108 if stack is None:
109 click.echo(
110 f"Warning: Branch {worktree_branch} is not tracked by Graphite. Cannot delete stack.",
111 err=True,
112 )
113 return None
115 return _get_non_trunk_branches(ctx, repo_root, stack)
118def _remove_worktree(
119 ctx: WorkstackContext, name: str, force: bool, delete_stack: bool, dry_run: bool
120) -> None:
121 """Internal function to remove a worktree.
123 Uses git worktree remove when possible, but falls back to direct rmtree
124 if git fails (e.g., worktree already removed from git metadata but directory exists).
125 This is acceptable exception handling because there's no reliable way to check
126 a priori if git worktree remove will succeed - the worktree might be in various
127 states of partial removal.
129 Args:
130 ctx: Workstack context with git operations
131 name: Name of the worktree to remove
132 force: Skip confirmation prompts
133 delete_stack: Delete all branches in the Graphite stack (requires Graphite)
134 dry_run: Print what would be done without executing destructive operations
135 """
136 # Create dry-run context if needed
137 if dry_run:
138 ctx = create_context(dry_run=True)
140 # Validate worktree name before any operations
141 validate_worktree_name_for_removal(name)
143 repo = discover_repo_context(ctx, Path.cwd())
144 workstacks_dir = ensure_workstacks_dir(repo)
145 wt_path = worktree_path_for(workstacks_dir, name)
147 if not wt_path.exists() or not wt_path.is_dir():
148 click.echo(f"Worktree not found: {wt_path}")
149 raise SystemExit(1)
151 # Step 1: Collect all operations to perform
152 branches_to_delete: list[str] = []
153 if delete_stack:
154 use_graphite = ctx.global_config_ops.get_use_graphite()
155 if not use_graphite:
156 click.echo(
157 "Error: --delete-stack requires Graphite to be enabled. "
158 "Run 'workstack config set use-graphite true'",
159 err=True,
160 )
161 raise SystemExit(1)
163 # Get the branches in the stack before removing the worktree
164 worktrees = ctx.git_ops.list_worktrees(repo.root)
165 worktree_branch = None
166 for wt in worktrees:
167 if wt.path == wt_path:
168 worktree_branch = wt.branch
169 break
171 if worktree_branch is None:
172 click.echo(
173 f"Warning: Worktree {name} is in detached HEAD state. "
174 "Cannot delete stack without a branch.",
175 err=True,
176 )
177 else:
178 stack = get_branch_stack(ctx, repo.root, worktree_branch)
179 if stack is None:
180 click.echo(
181 f"Warning: Branch {worktree_branch} is not tracked by Graphite. "
182 "Cannot delete stack.",
183 err=True,
184 )
185 else:
186 # Filter out trunk branches
187 git_dir = ctx.git_ops.get_git_common_dir(repo.root)
188 if git_dir is None:
189 click.echo(
190 "Warning: Could not find git directory. Cannot delete stack.", err=True
191 )
192 else:
193 cache_file = git_dir / ".graphite_cache_persist"
194 cache_data = json.loads(cache_file.read_text(encoding="utf-8"))
195 branches_data = cache_data.get("branches", [])
197 trunk_branches = {
198 branch_name
199 for branch_name, info in branches_data
200 if info.get("validationResult") == "TRUNK"
201 }
203 branches_to_delete = [b for b in stack if b not in trunk_branches]
205 if not branches_to_delete:
206 click.echo(
207 "No branches to delete (all branches in stack are trunk branches)."
208 )
210 # Step 2: Display all planned operations
211 if branches_to_delete or True:
212 click.echo(click.style("📋 Planning to perform the following operations:", bold=True))
213 worktree_text = click.style(str(wt_path), fg="cyan")
214 click.echo(f" 1. 🗑️ Remove worktree: {worktree_text}")
215 if branches_to_delete:
216 click.echo(" 2. 🌳 Delete branches in stack:")
217 for branch in branches_to_delete:
218 branch_text = click.style(branch, fg="yellow")
219 click.echo(f" - {branch_text}")
221 # Step 3: Single confirmation prompt (unless --force or --dry-run)
222 if not force and not dry_run:
223 prompt_text = click.style("Proceed with these operations?", fg="yellow", bold=True)
224 if not click.confirm(f"\n{prompt_text}", default=False):
225 click.echo(click.style("⭕ Aborted.", fg="red", bold=True))
226 return
228 # Step 4: Execute operations
230 # 4a. Try to remove via git first
231 # This updates git's metadata when possible
232 _try_git_worktree_remove(ctx.git_ops, repo.root, wt_path)
234 # 4b. Always manually delete directory if it still exists
235 # (git worktree remove may have succeeded or failed, but directory might still be there)
236 if wt_path.exists():
237 if ctx.dry_run:
238 click.echo(f"[DRY RUN] Would delete directory: {wt_path}", err=True)
239 else:
240 shutil.rmtree(wt_path)
242 # 4c. Prune worktree metadata to clean up any stale references
243 # This is important if git worktree remove failed or if we manually deleted
244 if not ctx.dry_run:
245 _prune_worktrees_safe(ctx.git_ops, repo.root)
247 # 4c. Delete stack branches (now that worktree is removed)
248 if branches_to_delete:
249 for branch in branches_to_delete:
250 ctx.git_ops.delete_branch_with_graphite(repo.root, branch, force=force)
251 if not dry_run:
252 branch_text = click.style(branch, fg="green")
253 click.echo(f"✅ Deleted branch: {branch_text}")
255 if not dry_run:
256 path_text = click.style(str(wt_path), fg="green")
257 click.echo(f"✅ {path_text}")
260@click.command("remove")
261@click.argument("name", metavar="NAME", shell_complete=complete_worktree_names)
262@click.option("-f", "--force", is_flag=True, help="Do not prompt for confirmation.")
263@click.option(
264 "-s",
265 "--delete-stack",
266 is_flag=True,
267 help="Delete all branches in the Graphite stack (requires Graphite).",
268)
269@click.option(
270 "--dry-run",
271 is_flag=True,
272 # dry_run=False: Allow destructive operations by default
273 default=False,
274 help="Print what would be done without executing destructive operations.",
275)
276@click.pass_obj
277def remove_cmd(
278 ctx: WorkstackContext, name: str, force: bool, delete_stack: bool, dry_run: bool
279) -> None:
280 """Remove the worktree directory (alias: rm).
282 With `-f/--force`, skips the confirmation prompt.
283 Attempts `git worktree remove` before deleting the directory.
284 """
285 _remove_worktree(ctx, name, force, delete_stack, dry_run)
288# Register rm as a hidden alias (won't show in help)
289@click.command("rm", hidden=True)
290@click.argument("name", metavar="NAME", shell_complete=complete_worktree_names)
291@click.option("-f", "--force", is_flag=True, help="Do not prompt for confirmation.")
292@click.option(
293 "-s",
294 "--delete-stack",
295 is_flag=True,
296 help="Delete all branches in the Graphite stack (requires Graphite).",
297)
298@click.option(
299 "--dry-run",
300 is_flag=True,
301 # dry_run=False: Allow destructive operations by default
302 default=False,
303 help="Print what would be done without executing destructive operations.",
304)
305@click.pass_obj
306def rm_cmd(
307 ctx: WorkstackContext, name: str, force: bool, delete_stack: bool, dry_run: bool
308) -> None:
309 """Remove the worktree directory (alias of 'remove')."""
310 _remove_worktree(ctx, name, force, delete_stack, dry_run)