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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
1from pathlib import Path
3import click
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
19def _ensure_graphite_enabled(ctx: WorkstackContext) -> None:
20 """Validate that Graphite is enabled.
22 Args:
23 ctx: Workstack context
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)
37def _activate_root_repo(repo: RepoContext, script: bool, command_name: str) -> None:
38 """Activate the root repository and exit.
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)
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)
74def _activate_worktree(
75 repo: RepoContext, target_path: Path, script: bool, command_name: str
76) -> None:
77 """Activate a worktree and exit.
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)
85 Raises:
86 SystemExit: If worktree not found, or after successful activation
87 """
88 wt_path = target_path
90 if not wt_path.exists():
91 click.echo(f"Worktree not found: {wt_path}", err=True)
92 raise SystemExit(1)
94 worktree_name = wt_path.name
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 )
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()}")
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)
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.
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()
132 Returns:
133 Target branch name to switch to
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)
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 )
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)
165 return target_branch
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.
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()
179 Returns:
180 Target branch name or 'root' to switch to
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)
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
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.
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.
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)
257 repo = discover_repo_context(workstack_ctx, Path.cwd())
259 names = ["root"] if "root".startswith(incomplete) else []
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 )
268 return names
269 except Exception:
270 # Shell completion error boundary: return empty list for graceful degradation
271 return []
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.
289 With shell integration (recommended):
290 workstack switch NAME
291 workstack switch --up
292 workstack switch --down
294 The shell wrapper function automatically activates the worktree.
295 Run 'workstack init --shell' to set up shell integration.
297 Without shell integration:
298 source <(workstack switch NAME --script)
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 """
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)
311 if name and (up or down):
312 click.echo("Error: Cannot specify NAME with --up or --down", err=True)
313 raise SystemExit(1)
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)
319 # Check Graphite requirement for --up/--down
320 if up or down:
321 _ensure_graphite_enabled(ctx)
323 repo = discover_repo_context(ctx, Path.cwd())
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)
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)
344 # Get all worktrees for checking if target has a worktree
345 worktrees = ctx.git_ops.list_worktrees(repo.root)
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)
352 # Check if target_name refers to 'root' which means root repo
353 if target_name == "root":
354 _activate_root_repo(repo, script, "switch")
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)
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
370 # Check if target_name refers to 'root' which means root repo
371 if target_name == "root":
372 _activate_root_repo(repo, script, "switch")
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)
378 _activate_worktree(repo, wt_path, script, "switch")