Coverage for src/workstack/cli/commands/move.py: 88%
145 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
1"""Move branches between worktrees with explicit source specification."""
3import subprocess
4from pathlib import Path
6import click
8from workstack.cli.commands.switch import complete_worktree_names
9from workstack.cli.core import discover_repo_context, ensure_workstacks_dir, worktree_path_for
10from workstack.core.context import WorkstackContext
13def _get_worktree_branch(ctx: WorkstackContext, repo_root: Path, wt_path: Path) -> str | None:
14 """Get the branch checked out in a worktree.
16 Returns None if worktree is not found or is in detached HEAD state.
17 """
18 worktrees = ctx.git_ops.list_worktrees(repo_root)
19 # Resolve paths for comparison to handle relative vs absolute paths
20 wt_path_resolved = wt_path.resolve()
21 for wt in worktrees:
22 if wt.path.resolve() == wt_path_resolved:
23 return wt.branch
24 return None
27def _has_uncommitted_changes(cwd: Path) -> bool:
28 """Check if a worktree has uncommitted changes.
30 Uses git status --porcelain to detect any uncommitted changes.
31 Returns False if git command fails (worktree might be in invalid state).
32 """
33 result = subprocess.run(
34 ["git", "status", "--porcelain"],
35 cwd=cwd,
36 capture_output=True,
37 text=True,
38 check=False,
39 )
40 if result.returncode != 0:
41 return False
42 return bool(result.stdout.strip())
45def _find_worktree_containing_path(worktrees: list, target_path: Path) -> Path | None:
46 """Find which worktree contains the given path.
48 Args:
49 worktrees: List of WorktreeInfo objects
50 target_path: Path to check (should be resolved)
52 Returns:
53 Path to the worktree that contains target_path, or None if not found
55 Note:
56 Uses is_relative_to() to check path containment. This is the LBYL approach
57 (vs catching ValueError from relative_to()).
59 Returns the most specific (longest) match to handle nested worktrees.
60 For example, if target_path is /a/b/c and we have worktrees at /a and /a/b,
61 this returns /a/b (the more specific match).
62 """
63 best_match: Path | None = None
64 best_match_depth = -1
66 for wt in worktrees:
67 wt_path = wt.path.resolve()
68 # Check if target_path is within this worktree
69 # is_relative_to() returns True if target_path is under wt_path
70 if target_path.is_relative_to(wt_path):
71 # Count path depth to find most specific match
72 depth = len(wt_path.parts)
73 if depth > best_match_depth:
74 best_match = wt_path
75 best_match_depth = depth
77 return best_match
80def _find_worktree_with_branch(ctx: WorkstackContext, repo_root: Path, branch: str) -> Path | None:
81 """Find the worktree path containing the specified branch.
83 Returns None if the branch is not found in any worktree.
84 """
85 worktrees = ctx.git_ops.list_worktrees(repo_root)
86 for wt in worktrees:
87 if wt.branch == branch:
88 return wt.path
89 return None
92def _resolve_current_worktree(ctx: WorkstackContext, repo_root: Path) -> Path:
93 """Find worktree containing current directory.
95 Raises SystemExit if not in a git repository or not in any worktree.
96 """
97 git_common_dir = ctx.git_ops.get_git_common_dir(Path.cwd())
98 if git_common_dir is None:
99 click.echo("Error: Not in a git repository", err=True)
100 raise SystemExit(1)
102 cwd = Path.cwd().resolve()
103 worktrees = ctx.git_ops.list_worktrees(repo_root)
104 wt_path = _find_worktree_containing_path(worktrees, cwd)
105 if wt_path is None:
106 click.echo(
107 f"Error: Current directory ({cwd}) is not in any worktree.\n"
108 f"Either run this from within a worktree, or use --worktree or "
109 f"--branch to specify the source.",
110 err=True,
111 )
112 raise SystemExit(1)
113 return wt_path
116def resolve_source_worktree(
117 ctx: WorkstackContext,
118 repo_root: Path,
119 *,
120 current: bool,
121 branch: str | None,
122 worktree: str | None,
123 workstacks_dir: Path,
124) -> Path:
125 """Determine source worktree from flags.
127 Defaults to current worktree if no flags provided.
128 Raises SystemExit if multiple flags specified or if source cannot be resolved.
129 """
130 # Count how many source flags are specified
131 flag_count = sum([current, branch is not None, worktree is not None])
133 if flag_count > 1:
134 click.echo(
135 "Error: Only one of --current, --branch, or --worktree can be specified", err=True
136 )
137 raise SystemExit(1)
139 if flag_count == 0 or current:
140 # Default to current worktree (either no flags or --current explicitly set)
141 return _resolve_current_worktree(ctx, repo_root)
143 if branch:
144 # Find worktree containing this branch
145 wt = _find_worktree_with_branch(ctx, repo_root, branch)
146 if wt is None:
147 click.echo(f"Error: Branch '{branch}' not found in any worktree", err=True)
148 raise SystemExit(1)
149 return wt
151 if worktree:
152 # Resolve worktree name to path
153 wt_path = worktree_path_for(workstacks_dir, worktree)
154 # Validate that the worktree exists
155 if not wt_path.exists():
156 click.echo(f"Error: Worktree '{worktree}' does not exist", err=True)
157 raise SystemExit(1)
158 return wt_path
160 click.echo("Error: Invalid state - no source specified", err=True)
161 raise SystemExit(1)
164def detect_operation_type(
165 source_wt: Path, target_wt: Path, ctx: WorkstackContext, repo_root: Path
166) -> str:
167 """Determine whether to move, swap, or create based on target existence.
169 Returns "move", "swap", or "create".
170 """
171 target_exists = target_wt.exists()
173 if not target_exists:
174 return "create"
176 # Target exists - check if it has a branch
177 target_branch = _get_worktree_branch(ctx, repo_root, target_wt)
178 if target_branch:
179 return "swap"
180 else:
181 return "move"
184def execute_move(
185 ctx: WorkstackContext,
186 repo_root: Path,
187 source_wt: Path,
188 target_wt: Path,
189 fallback_ref: str,
190 *,
191 force: bool,
192) -> None:
193 """Execute move operation (target doesn't exist or is in detached HEAD).
195 Moves the branch from source to target, then switches source to fallback_ref.
196 """
197 # Validate source has a branch
198 source_branch = _get_worktree_branch(ctx, repo_root, source_wt)
199 if source_branch is None:
200 click.echo("Error: Source worktree is in detached HEAD state", err=True)
201 raise SystemExit(1)
203 # Check for uncommitted changes in source
204 if _has_uncommitted_changes(source_wt) and not force:
205 click.echo(
206 f"Error: Uncommitted changes in source worktree '{source_wt.name}'.\n"
207 f"Commit, stash, or use --force to override.",
208 err=True,
209 )
210 raise SystemExit(1)
212 target_exists = target_wt.exists()
214 # To move branch from source to target, we need to avoid having the same branch
215 # checked out in two places simultaneously. Strategy:
216 # 1. Detach HEAD in source worktree (frees up source_branch)
217 # 2. Create/checkout source_branch in target worktree
218 # 3. Checkout fallback_ref in source worktree
219 click.echo(f"Moving '{source_branch}' from '{source_wt.name}' to '{target_wt.name}'")
220 ctx.git_ops.checkout_detached(source_wt, source_branch)
222 if target_exists:
223 # Target exists - check for uncommitted changes
224 if _has_uncommitted_changes(target_wt) and not force:
225 click.echo(
226 f"Error: Uncommitted changes in target worktree '{target_wt.name}'.\n"
227 f"Commit, stash, or use --force to override.",
228 err=True,
229 )
230 raise SystemExit(1)
232 # Checkout branch in existing target
233 ctx.git_ops.checkout_branch(target_wt, source_branch)
234 else:
235 # Create new worktree with branch
236 ctx.git_ops.add_worktree(
237 repo_root, target_wt, branch=source_branch, ref=None, create_branch=False
238 )
240 # Check if fallback_ref is already checked out elsewhere, and detach it if needed
241 fallback_wt = ctx.git_ops.is_branch_checked_out(repo_root, fallback_ref)
242 if fallback_wt is not None and fallback_wt.resolve() != source_wt.resolve():
243 # Fallback branch is checked out in another worktree, detach it first
244 ctx.git_ops.checkout_detached(fallback_wt, fallback_ref)
246 # Switch source to fallback branch
247 ctx.git_ops.checkout_branch(source_wt, fallback_ref)
249 click.echo(f"✓ Moved '{source_branch}' from '{source_wt.name}' to '{target_wt.name}'")
252def execute_swap(
253 ctx: WorkstackContext,
254 repo_root: Path,
255 source_wt: Path,
256 target_wt: Path,
257 *,
258 force: bool,
259) -> None:
260 """Execute swap operation (both worktrees exist with branches).
262 Swaps the branches between source and target worktrees.
263 """
264 source_branch = _get_worktree_branch(ctx, repo_root, source_wt)
265 target_branch = _get_worktree_branch(ctx, repo_root, target_wt)
267 if source_branch is None or target_branch is None:
268 click.echo("Error: Both worktrees must have branches checked out for swap", err=True)
269 raise SystemExit(1)
271 # Check for uncommitted changes
272 if _has_uncommitted_changes(source_wt) or _has_uncommitted_changes(target_wt):
273 if not force:
274 click.echo(
275 "Error: Uncommitted changes detected in one or more worktrees.\n"
276 "Commit, stash, or use --force to override.",
277 err=True,
278 )
279 raise SystemExit(1)
281 # Confirm swap unless --force
282 if not force:
283 click.echo("This will swap branches between worktrees:")
284 click.echo(f" '{source_wt.name}': '{source_branch}' → '{target_branch}'")
285 click.echo(f" '{target_wt.name}': '{target_branch}' → '{source_branch}'")
286 if not click.confirm("Continue?"):
287 click.echo("Swap cancelled")
288 raise SystemExit(0)
290 click.echo(f"Swapping branches between '{source_wt.name}' and '{target_wt.name}'")
292 # To swap branches between worktrees, we need to avoid having the same branch
293 # checked out in two places simultaneously. Strategy:
294 # 1. Detach HEAD in source worktree (frees up source_branch)
295 # 2. Checkout source_branch in target worktree
296 # 3. Checkout target_branch in source worktree
297 ctx.git_ops.checkout_detached(source_wt, source_branch)
298 ctx.git_ops.checkout_branch(target_wt, source_branch)
299 ctx.git_ops.checkout_branch(source_wt, target_branch)
301 click.echo(f"✓ Swapped '{source_branch}' ↔ '{target_branch}'")
304@click.command("move")
305@click.option("--current", is_flag=True, help="Use current worktree as source")
306@click.option("--branch", help="Auto-detect worktree containing this branch")
307@click.option("--worktree", help="Use specific worktree as source")
308@click.option("--ref", default="main", help="Fallback branch for source after move (default: main)")
309@click.option("-f", "--force", is_flag=True, help="Skip confirmation prompts")
310@click.argument("target", required=True, shell_complete=complete_worktree_names)
311@click.pass_obj
312def move_cmd(
313 ctx: WorkstackContext,
314 current: bool,
315 branch: str | None,
316 worktree: str | None,
317 ref: str,
318 force: bool,
319 target: str,
320) -> None:
321 """Move branches between worktrees with explicit source specification.
323 Examples:
325 \b
326 # Move current branch back to repository root
327 workstack move root
329 \b
330 # Move from current worktree to new worktree
331 workstack move target-wt
333 \b
334 # Move from current worktree (explicit)
335 workstack move --current target-wt
337 \b
338 # Auto-detect source from branch name
339 workstack move --branch feature-x new-wt
341 \b
342 # Move from specific source to target
343 workstack move --worktree old-wt new-wt
345 \b
346 # Swap branches between current and another worktree
347 workstack move --current existing-wt
349 \b
350 # Force operation without prompts (for scripts)
351 workstack move --current target-wt --force
353 \b
354 # Specify custom fallback branch
355 workstack move --current new-wt --ref develop
356 """
357 # Discover repository context
358 repo = discover_repo_context(ctx, Path.cwd())
359 workstacks_dir = ensure_workstacks_dir(repo)
361 # Resolve source worktree
362 source_wt = resolve_source_worktree(
363 ctx,
364 repo.root,
365 current=current,
366 branch=branch,
367 worktree=worktree,
368 workstacks_dir=workstacks_dir,
369 )
371 # Resolve target worktree path
372 # Special case: "root" refers to the repository root
373 if target == "root":
374 target_wt = repo.root
375 else:
376 target_wt = worktree_path_for(workstacks_dir, target)
378 # Validate source and target are different
379 if source_wt.resolve() == target_wt.resolve():
380 click.echo("Error: Source and target worktrees are the same", err=True)
381 raise SystemExit(1)
383 # Detect operation type
384 operation_type = detect_operation_type(source_wt, target_wt, ctx, repo.root)
386 # Execute operation
387 if operation_type == "swap":
388 execute_swap(ctx, repo.root, source_wt, target_wt, force=force)
389 else:
390 # Auto-detect default branch if using 'main' default and it doesn't exist
391 if ref == "main":
392 detected_default = ctx.git_ops.detect_default_branch(repo.root)
393 ref = detected_default
395 execute_move(ctx, repo.root, source_wt, target_wt, ref, force=force)