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

1import json 

2import shutil 

3from pathlib import Path 

4 

5import click 

6 

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 

17 

18 

19def _try_git_worktree_remove(git_ops: GitOps, repo_root: Path, wt_path: Path) -> bool: 

20 """Attempt git worktree remove, returning success status. 

21 

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 

27 

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. 

30 

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 

40 

41 

42def _prune_worktrees_safe(git_ops: GitOps, repo_root: Path) -> None: 

43 """Prune worktree metadata, ignoring errors if nothing to prune. 

44 

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 

55 

56 

57def _find_worktree_branch(ctx: WorkstackContext, repo_root: Path, wt_path: Path) -> str | None: 

58 """Find the branch for a given worktree path. 

59 

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 

67 

68 

69def _get_non_trunk_branches(ctx: WorkstackContext, repo_root: Path, stack: list[str]) -> list[str]: 

70 """Filter out trunk branches from a stack. 

71 

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 [] 

79 

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 [] 

84 

85 cache_data = json.loads(cache_file.read_text(encoding="utf-8")) 

86 branches_data = cache_data.get("branches", []) 

87 

88 trunk_branches = { 

89 branch_name 

90 for branch_name, info in branches_data 

91 if info.get("validationResult") == "TRUNK" 

92 } 

93 

94 return [b for b in stack if b not in trunk_branches] 

95 

96 

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. 

101 

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 

114 

115 return _get_non_trunk_branches(ctx, repo_root, stack) 

116 

117 

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. 

122 

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. 

128 

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) 

139 

140 # Validate worktree name before any operations 

141 validate_worktree_name_for_removal(name) 

142 

143 repo = discover_repo_context(ctx, Path.cwd()) 

144 workstacks_dir = ensure_workstacks_dir(repo) 

145 wt_path = worktree_path_for(workstacks_dir, name) 

146 

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) 

150 

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) 

162 

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 

170 

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", []) 

196 

197 trunk_branches = { 

198 branch_name 

199 for branch_name, info in branches_data 

200 if info.get("validationResult") == "TRUNK" 

201 } 

202 

203 branches_to_delete = [b for b in stack if b not in trunk_branches] 

204 

205 if not branches_to_delete: 

206 click.echo( 

207 "No branches to delete (all branches in stack are trunk branches)." 

208 ) 

209 

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}") 

220 

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 

227 

228 # Step 4: Execute operations 

229 

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) 

233 

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) 

241 

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) 

246 

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}") 

254 

255 if not dry_run: 

256 path_text = click.style(str(wt_path), fg="green") 

257 click.echo(f"{path_text}") 

258 

259 

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

281 

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) 

286 

287 

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)