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

1"""Move branches between worktrees with explicit source specification.""" 

2 

3import subprocess 

4from pathlib import Path 

5 

6import click 

7 

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 

11 

12 

13def _get_worktree_branch(ctx: WorkstackContext, repo_root: Path, wt_path: Path) -> str | None: 

14 """Get the branch checked out in a worktree. 

15 

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 

25 

26 

27def _has_uncommitted_changes(cwd: Path) -> bool: 

28 """Check if a worktree has uncommitted changes. 

29 

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

43 

44 

45def _find_worktree_containing_path(worktrees: list, target_path: Path) -> Path | None: 

46 """Find which worktree contains the given path. 

47 

48 Args: 

49 worktrees: List of WorktreeInfo objects 

50 target_path: Path to check (should be resolved) 

51 

52 Returns: 

53 Path to the worktree that contains target_path, or None if not found 

54 

55 Note: 

56 Uses is_relative_to() to check path containment. This is the LBYL approach 

57 (vs catching ValueError from relative_to()). 

58 

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 

65 

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 

76 

77 return best_match 

78 

79 

80def _find_worktree_with_branch(ctx: WorkstackContext, repo_root: Path, branch: str) -> Path | None: 

81 """Find the worktree path containing the specified branch. 

82 

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 

90 

91 

92def _resolve_current_worktree(ctx: WorkstackContext, repo_root: Path) -> Path: 

93 """Find worktree containing current directory. 

94 

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) 

101 

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 

114 

115 

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. 

126 

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

132 

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) 

138 

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) 

142 

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 

150 

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 

159 

160 click.echo("Error: Invalid state - no source specified", err=True) 

161 raise SystemExit(1) 

162 

163 

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. 

168 

169 Returns "move", "swap", or "create". 

170 """ 

171 target_exists = target_wt.exists() 

172 

173 if not target_exists: 

174 return "create" 

175 

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" 

182 

183 

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

194 

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) 

202 

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) 

211 

212 target_exists = target_wt.exists() 

213 

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) 

221 

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) 

231 

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 ) 

239 

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) 

245 

246 # Switch source to fallback branch 

247 ctx.git_ops.checkout_branch(source_wt, fallback_ref) 

248 

249 click.echo(f"✓ Moved '{source_branch}' from '{source_wt.name}' to '{target_wt.name}'") 

250 

251 

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

261 

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) 

266 

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) 

270 

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) 

280 

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) 

289 

290 click.echo(f"Swapping branches between '{source_wt.name}' and '{target_wt.name}'") 

291 

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) 

300 

301 click.echo(f"✓ Swapped '{source_branch}' ↔ '{target_branch}'") 

302 

303 

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. 

322 

323 Examples: 

324 

325 \b 

326 # Move current branch back to repository root 

327 workstack move root 

328 

329 \b 

330 # Move from current worktree to new worktree 

331 workstack move target-wt 

332 

333 \b 

334 # Move from current worktree (explicit) 

335 workstack move --current target-wt 

336 

337 \b 

338 # Auto-detect source from branch name 

339 workstack move --branch feature-x new-wt 

340 

341 \b 

342 # Move from specific source to target 

343 workstack move --worktree old-wt new-wt 

344 

345 \b 

346 # Swap branches between current and another worktree 

347 workstack move --current existing-wt 

348 

349 \b 

350 # Force operation without prompts (for scripts) 

351 workstack move --current target-wt --force 

352 

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) 

360 

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 ) 

370 

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) 

377 

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) 

382 

383 # Detect operation type 

384 operation_type = detect_operation_type(source_wt, target_wt, ctx, repo.root) 

385 

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 

394 

395 execute_move(ctx, repo.root, source_wt, target_wt, ref, force=force)