Coverage for src/workstack/cli/commands/switch.py: 81%

135 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-19 09:31 -0400

1from pathlib import Path 

2 

3import click 

4 

5from workstack.cli.activation import render_activation_script 

6from workstack.cli.core import ( 

7 RepoContext, 

8 discover_repo_context, 

9 ensure_workstacks_dir, 

10 worktree_path_for, 

11) 

12from workstack.cli.debug import debug_log 

13from workstack.cli.graphite import find_worktree_for_branch, get_child_branches, get_parent_branch 

14from workstack.cli.shell_utils import write_script_to_temp 

15from workstack.core.context import WorkstackContext, create_context 

16from workstack.core.gitops import WorktreeInfo 

17 

18 

19def _ensure_graphite_enabled(ctx: WorkstackContext) -> None: 

20 """Validate that Graphite is enabled. 

21 

22 Args: 

23 ctx: Workstack context 

24 

25 Raises: 

26 SystemExit: If Graphite is not enabled 

27 """ 

28 if not ctx.global_config_ops.get_use_graphite(): 

29 click.echo( 

30 "Error: This command requires Graphite to be enabled. " 

31 "Run 'workstack config set use_graphite true'", 

32 err=True, 

33 ) 

34 raise SystemExit(1) 

35 

36 

37def _activate_root_repo(repo: RepoContext, script: bool, command_name: str) -> None: 

38 """Activate the root repository and exit. 

39 

40 Args: 

41 repo: Repository context 

42 script: Whether to output script path or user message 

43 command_name: Name of the command (for script generation) 

44 

45 Raises: 

46 SystemExit: Always (successful exit after activation) 

47 """ 

48 root_path = repo.root 

49 if script: 

50 script_content = render_activation_script( 

51 worktree_path=root_path, 

52 final_message='echo "Switched to root repo: $(pwd)"', 

53 comment="work activate-script (root repo)", 

54 ) 

55 script_path = write_script_to_temp( 

56 script_content, 

57 command_name=command_name, 

58 comment="activate root", 

59 ) 

60 click.echo(str(script_path), nl=False) 

61 else: 

62 click.echo(f"Switched to root repo: {root_path}") 

63 click.echo( 

64 "\nShell integration not detected. " 

65 "Run 'workstack init --shell' to set up automatic activation." 

66 ) 

67 if command_name == "switch": 

68 click.echo("Or use: source <(workstack switch root --script)") 

69 else: 

70 click.echo(f"Or use: source <(workstack {command_name} --script)") 

71 raise SystemExit(0) 

72 

73 

74def _activate_worktree( 

75 repo: RepoContext, target_path: Path, script: bool, command_name: str 

76) -> None: 

77 """Activate a worktree and exit. 

78 

79 Args: 

80 repo: Repository context 

81 target_path: Path to the target worktree directory 

82 script: Whether to output script path or user message 

83 command_name: Name of the command (for script generation and debug logging) 

84 

85 Raises: 

86 SystemExit: If worktree not found, or after successful activation 

87 """ 

88 wt_path = target_path 

89 

90 if not wt_path.exists(): 

91 click.echo(f"Worktree not found: {wt_path}", err=True) 

92 raise SystemExit(1) 

93 

94 worktree_name = wt_path.name 

95 

96 if script: 

97 activation_script = render_activation_script(worktree_path=wt_path) 

98 script_path = write_script_to_temp( 

99 activation_script, 

100 command_name=command_name, 

101 comment=f"activate {worktree_name}", 

102 ) 

103 

104 debug_log(f"{command_name.capitalize()}: Generated script at {script_path}") 

105 debug_log(f"{command_name.capitalize()}: Script content:\n{activation_script}") 

106 debug_log(f"{command_name.capitalize()}: File exists? {script_path.exists()}") 

107 

108 click.echo(str(script_path), nl=False) 

109 else: 

110 click.echo( 

111 "Shell integration not detected. " 

112 "Run 'workstack init --shell' to set up automatic activation." 

113 ) 

114 if command_name == "switch": 

115 click.echo(f"\nOr use: source <(workstack switch {worktree_name} --script)") 

116 else: 

117 click.echo(f"\nOr use: source <(workstack {command_name} --script)") 

118 raise SystemExit(0) 

119 

120 

121def _resolve_up_navigation( 

122 ctx: WorkstackContext, repo: RepoContext, current_branch: str, worktrees: list[WorktreeInfo] 

123) -> str: 

124 """Resolve --up navigation to determine target branch name. 

125 

126 Args: 

127 ctx: Workstack context 

128 repo: Repository context 

129 current_branch: Current branch name 

130 worktrees: List of worktrees from git_ops.list_worktrees() 

131 

132 Returns: 

133 Target branch name to switch to 

134 

135 Raises: 

136 SystemExit: If navigation fails (at top of stack or target has no worktree) 

137 """ 

138 # Navigate up to child branch 

139 children = get_child_branches(ctx, repo.root, current_branch) 

140 if not children: 

141 click.echo("Already at the top of the stack (no child branches)", err=True) 

142 raise SystemExit(1) 

143 

144 # Use first child (future enhancement: handle multiple children interactively) 

145 target_branch = children[0] 

146 if len(children) > 1: 

147 click.echo( 

148 f"Note: Branch '{current_branch}' has multiple children. " 

149 f"Selecting first child: '{target_branch}'", 

150 err=True, 

151 ) 

152 

153 # Check if target branch has a worktree 

154 target_wt_path = find_worktree_for_branch(worktrees, target_branch) 

155 if target_wt_path is None: 

156 click.echo( 

157 f"Branch '{target_branch}' is the next branch up in the stack " 

158 f"but has no worktree.\n" 

159 f"To create a worktree for it, run:\n" 

160 f" workstack create {target_branch}", 

161 err=True, 

162 ) 

163 raise SystemExit(1) 

164 

165 return target_branch 

166 

167 

168def _resolve_down_navigation( 

169 ctx: WorkstackContext, repo: RepoContext, current_branch: str, worktrees: list[WorktreeInfo] 

170) -> str: 

171 """Resolve --down navigation to determine target branch name. 

172 

173 Args: 

174 ctx: Workstack context 

175 repo: Repository context 

176 current_branch: Current branch name 

177 worktrees: List of worktrees from git_ops.list_worktrees() 

178 

179 Returns: 

180 Target branch name or 'root' to switch to 

181 

182 Raises: 

183 SystemExit: If navigation fails (at bottom of stack or target has no worktree) 

184 """ 

185 # Navigate down to parent branch 

186 parent_branch = get_parent_branch(ctx, repo.root, current_branch) 

187 if parent_branch is None: 

188 # Check if we're already on trunk 

189 trunk_branch = ctx.git_ops.detect_default_branch(repo.root) 

190 if current_branch == trunk_branch: 

191 click.echo( 

192 f"Already at the bottom of the stack (on trunk branch '{trunk_branch}')", 

193 err=True, 

194 ) 

195 raise SystemExit(1) 

196 else: 

197 click.echo( 

198 "Error: Could not determine parent branch from Graphite metadata", 

199 err=True, 

200 ) 

201 raise SystemExit(1) 

202 

203 # Check if parent is the trunk - if so, switch to root 

204 trunk_branch = ctx.git_ops.detect_default_branch(repo.root) 

205 if parent_branch == trunk_branch: 

206 # Check if trunk is checked out in root (repo.root path) 

207 trunk_wt_path = find_worktree_for_branch(worktrees, trunk_branch) 

208 if trunk_wt_path is not None and trunk_wt_path == repo.root: 

209 # Trunk is in root repository, not in a dedicated worktree 

210 return "root" 

211 else: 

212 # Trunk has a dedicated worktree 

213 if trunk_wt_path is None: 

214 click.echo( 

215 f"Branch '{parent_branch}' is the parent branch but has no worktree.\n" 

216 f"To switch to the root repository, run:\n" 

217 f" workstack switch root", 

218 err=True, 

219 ) 

220 raise SystemExit(1) 

221 return parent_branch 

222 else: 

223 # Parent is not trunk, check if it has a worktree 

224 target_wt_path = find_worktree_for_branch(worktrees, parent_branch) 

225 if target_wt_path is None: 

226 click.echo( 

227 f"Branch '{parent_branch}' is the parent branch but has no worktree.\n" 

228 f"To create a worktree for it, run:\n" 

229 f" workstack create {parent_branch}", 

230 err=True, 

231 ) 

232 raise SystemExit(1) 

233 return parent_branch 

234 

235 

236def complete_worktree_names( 

237 ctx: click.Context, param: click.Parameter | None, incomplete: str 

238) -> list[str]: 

239 """Shell completion for worktree names. Includes 'root' for the repository root. 

240 

241 This is a shell completion function, which is an acceptable error boundary. 

242 Exceptions are caught to provide graceful degradation - if completion fails, 

243 we return an empty list rather than breaking the user's shell experience. 

244 

245 Args: 

246 ctx: Click context 

247 param: Click parameter (unused, but required by Click's completion protocol) 

248 incomplete: Partial input string to complete 

249 """ 

250 try: 

251 # During shell completion, ctx.obj may be None if the CLI group callback 

252 # hasn't run yet. Create a default context in this case. 

253 workstack_ctx = ctx.find_root().obj 

254 if workstack_ctx is None: 

255 workstack_ctx = create_context(dry_run=False) 

256 

257 repo = discover_repo_context(workstack_ctx, Path.cwd()) 

258 

259 names = ["root"] if "root".startswith(incomplete) else [] 

260 

261 if repo.workstacks_dir.exists(): 

262 names.extend( 

263 p.name 

264 for p in repo.workstacks_dir.iterdir() 

265 if p.is_dir() and p.name.startswith(incomplete) 

266 ) 

267 

268 return names 

269 except Exception: 

270 # Shell completion error boundary: return empty list for graceful degradation 

271 return [] 

272 

273 

274@click.command("switch") 

275@click.argument("name", metavar="NAME", required=False, shell_complete=complete_worktree_names) 

276@click.option( 

277 "--script", is_flag=True, help="Print only the activation script without usage instructions." 

278) 

279@click.option( 

280 "--up", is_flag=True, help="Move to child branch in Graphite stack (requires Graphite)." 

281) 

282@click.option( 

283 "--down", is_flag=True, help="Move to parent branch in Graphite stack (requires Graphite)." 

284) 

285@click.pass_obj 

286def switch_cmd(ctx: WorkstackContext, name: str | None, script: bool, up: bool, down: bool) -> None: 

287 """Switch to a worktree and activate its environment. 

288 

289 With shell integration (recommended): 

290 workstack switch NAME 

291 workstack switch --up 

292 workstack switch --down 

293 

294 The shell wrapper function automatically activates the worktree. 

295 Run 'workstack init --shell' to set up shell integration. 

296 

297 Without shell integration: 

298 source <(workstack switch NAME --script) 

299 

300 NAME can be a worktree name, or 'root' to switch to the root repo. 

301 Use --up to navigate to the child branch in the Graphite stack. 

302 Use --down to navigate to the parent branch in the Graphite stack. 

303 This will cd to the worktree, create/activate .venv, and load .env variables. 

304 """ 

305 

306 # Validate command arguments 

307 if up and down: 

308 click.echo("Error: Cannot use both --up and --down", err=True) 

309 raise SystemExit(1) 

310 

311 if name and (up or down): 

312 click.echo("Error: Cannot specify NAME with --up or --down", err=True) 

313 raise SystemExit(1) 

314 

315 if not name and not up and not down: 

316 click.echo("Error: Must specify NAME, --up, or --down", err=True) 

317 raise SystemExit(1) 

318 

319 # Check Graphite requirement for --up/--down 

320 if up or down: 

321 _ensure_graphite_enabled(ctx) 

322 

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

324 

325 # Check if user is trying to switch to main/master (should use root instead) 

326 if name and name.lower() in ("main", "master"): 

327 click.echo( 

328 f'Error: "{name}" cannot be used as a worktree name.\n' 

329 f"To switch to the {name} branch in the root repository, use:\n" 

330 f" workstack switch root", 

331 err=True, 

332 ) 

333 raise SystemExit(1) 

334 

335 # Determine target name based on command arguments 

336 target_name: str 

337 if up or down: 

338 # Get current branch 

339 current_branch = ctx.git_ops.get_current_branch(Path.cwd()) 

340 if current_branch is None: 

341 click.echo("Error: Not currently on a branch (detached HEAD)", err=True) 

342 raise SystemExit(1) 

343 

344 # Get all worktrees for checking if target has a worktree 

345 worktrees = ctx.git_ops.list_worktrees(repo.root) 

346 

347 if up: 

348 target_name = _resolve_up_navigation(ctx, repo, current_branch, worktrees) 

349 else: # down 

350 target_name = _resolve_down_navigation(ctx, repo, current_branch, worktrees) 

351 

352 # Check if target_name refers to 'root' which means root repo 

353 if target_name == "root": 

354 _activate_root_repo(repo, script, "switch") 

355 

356 # Resolve to actual worktree path 

357 target_wt_path = find_worktree_for_branch(worktrees, target_name) 

358 if target_wt_path is None: 

359 click.echo( 

360 f"Error: Branch '{target_name}' has no worktree. This should not happen.", 

361 err=True, 

362 ) 

363 raise SystemExit(1) 

364 

365 _activate_worktree(repo, target_wt_path, script, "switch") 

366 else: 

367 # NAME argument was provided (validated earlier) 

368 target_name = name if name else "" # This branch is unreachable due to validation 

369 

370 # Check if target_name refers to 'root' which means root repo 

371 if target_name == "root": 

372 _activate_root_repo(repo, script, "switch") 

373 

374 # For explicit name, use worktree_path_for since user provided the worktree name 

375 workstacks_dir = ensure_workstacks_dir(repo) 

376 wt_path = worktree_path_for(workstacks_dir, target_name) 

377 

378 _activate_worktree(repo, wt_path, script, "switch")