Coverage for src/workstack/cli/commands/create.py: 91%

209 statements  

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

1import re 

2import shlex 

3import shutil 

4import subprocess 

5from collections.abc import Iterable, Mapping 

6from pathlib import Path 

7 

8import click 

9 

10from workstack.cli.config import LoadedConfig, load_config 

11from workstack.cli.core import discover_repo_context, ensure_workstacks_dir, worktree_path_for 

12from workstack.cli.shell_utils import render_cd_script, write_script_to_temp 

13from workstack.core.context import WorkstackContext 

14 

15_SAFE_COMPONENT_RE = re.compile(r"[^A-Za-z0-9._/-]+") 

16 

17 

18def sanitize_branch_component(name: str) -> str: 

19 """Return a sanitized, predictable branch component from an arbitrary name. 

20 

21 - Lowercases input 

22 - Replaces characters outside `[A-Za-z0-9._/-]` with `-` 

23 - Collapses consecutive `-` 

24 - Strips leading/trailing `-` and `/` 

25 Returns `"work"` if the result is empty. 

26 """ 

27 

28 lowered = name.strip().lower() 

29 replaced = _SAFE_COMPONENT_RE.sub("-", lowered) 

30 collapsed = re.sub(r"-+", "-", replaced) 

31 trimmed = collapsed.strip("-/") 

32 return trimmed or "work" 

33 

34 

35def strip_plan_from_filename(filename: str) -> str: 

36 """Remove 'plan' or 'implementation plan' from a filename stem intelligently. 

37 

38 Handles case-insensitive matching and common separators (-, _, space). 

39 If removal would leave empty string, returns original unchanged. 

40 

41 Examples: 

42 "devclikit-extraction-plan" → "devclikit-extraction" 

43 "my-feature-plan" → "my-feature" 

44 "implementation-plan-for-auth" → "for-auth" 

45 "feature_implementation_plan" → "feature" 

46 "plan" → "plan" (preserved - would be empty) 

47 """ 

48 

49 original_trimmed = filename.strip("-_ \t\n\r") 

50 original_is_plan = original_trimmed.casefold() == "plan" if original_trimmed else False 

51 

52 # First, handle "implementation plan" with various separators 

53 # Pattern matches "implementation" + separator + "plan" as complete words 

54 impl_pattern = r"(^|[-_\s])(implementation)([-_\s])(plan)([-_\s]|$)" 

55 

56 def replace_impl_plan(match: re.Match[str]) -> str: 

57 prefix = match.group(1) 

58 implementation_word = match.group(2) # Preserves original case 

59 suffix = match.group(5) 

60 

61 if suffix == "" and prefix: 

62 prefix_start = match.start(1) 

63 preceding_segment = filename[:prefix_start] 

64 trimmed_segment = preceding_segment.strip("-_ \t\n\r") 

65 if trimmed_segment: 

66 preceding_tokens = re.split(r"[-_\s]+", trimmed_segment) 

67 if preceding_tokens: 

68 preceding_token = preceding_tokens[-1] 

69 if preceding_token.casefold() == "plan": 

70 return f"{prefix}{implementation_word}" 

71 

72 # If entire string is "implementation-plan", keep just "implementation" 

73 if not prefix and not suffix: 

74 return implementation_word 

75 

76 # If in the middle, preserve one separator 

77 if prefix and suffix: 

78 return prefix if prefix.strip() else suffix 

79 

80 # At start or end: remove it and the adjacent separator 

81 return "" 

82 

83 cleaned = re.sub(impl_pattern, replace_impl_plan, filename, flags=re.IGNORECASE) 

84 

85 # Then handle standalone "plan" as a complete word 

86 plan_pattern = r"(^|[-_\s])(plan)([-_\s]|$)" 

87 

88 def replace_plan(match: re.Match[str]) -> str: 

89 prefix = match.group(1) 

90 suffix = match.group(3) 

91 

92 # If both prefix and suffix are empty (entire string is "plan"), keep it 

93 if not prefix and not suffix: 

94 return "plan" 

95 

96 # If plan is in the middle, preserve one separator 

97 if prefix and suffix: 

98 # Use the prefix separator if available, otherwise use suffix 

99 return prefix if prefix.strip() else suffix 

100 

101 # Plan at start or end: remove it and the adjacent separator 

102 return "" 

103 

104 cleaned = re.sub(plan_pattern, replace_plan, cleaned, flags=re.IGNORECASE) 

105 

106 def clean_separators(text: str) -> str: 

107 stripped = text.strip("-_ \t\n\r") 

108 stripped = re.sub(r"--+", "-", stripped) 

109 stripped = re.sub(r"__+", "_", stripped) 

110 stripped = re.sub(r"\s+", " ", stripped) 

111 return stripped 

112 

113 cleaned = clean_separators(cleaned) 

114 

115 plan_only_cleaned = clean_separators( 

116 re.sub(plan_pattern, replace_plan, filename, flags=re.IGNORECASE) 

117 ) 

118 

119 if ( 

120 cleaned.casefold() == "plan" 

121 and plan_only_cleaned 

122 and plan_only_cleaned.casefold() != "plan" 

123 ): 

124 cleaned = plan_only_cleaned 

125 

126 # If stripping left us with nothing or just "plan", preserve original 

127 if not cleaned or (cleaned.casefold() == "plan" and original_is_plan): 

128 return filename 

129 

130 return cleaned 

131 

132 

133def sanitize_worktree_name(name: str) -> str: 

134 """Sanitize a worktree name for use as a directory name. 

135 

136 - Lowercases input 

137 - Replaces underscores with hyphens 

138 - Replaces characters outside `[A-Za-z0-9.-]` with `-` 

139 - Collapses consecutive `-` 

140 - Strips leading/trailing `-` 

141 Returns `"work"` if the result is empty. 

142 """ 

143 

144 lowered = name.strip().lower() 

145 # Replace underscores with hyphens 

146 replaced_underscores = lowered.replace("_", "-") 

147 # Replace unsafe characters with hyphens 

148 replaced = re.sub(r"[^a-z0-9.-]+", "-", replaced_underscores) 

149 # Collapse consecutive hyphens 

150 collapsed = re.sub(r"-+", "-", replaced) 

151 # Strip leading/trailing hyphens 

152 trimmed = collapsed.strip("-") 

153 return trimmed or "work" 

154 

155 

156def default_branch_for_worktree(name: str) -> str: 

157 """Default branch name for a worktree with the given `name`. 

158 

159 Returns the sanitized name directly (without any prefix). 

160 """ 

161 

162 return sanitize_branch_component(name) 

163 

164 

165def add_worktree( 

166 ctx: WorkstackContext, 

167 repo_root: Path, 

168 path: Path, 

169 *, 

170 branch: str | None, 

171 ref: str | None, 

172 use_existing_branch: bool, 

173 use_graphite: bool, 

174) -> None: 

175 """Create a git worktree. 

176 

177 If `use_existing_branch` is True and `branch` is provided, checks out the existing branch 

178 in the new worktree: `git worktree add <path> <branch>`. 

179 

180 If `use_existing_branch` is False and `branch` is provided, creates a new branch: 

181 - With graphite: `gt create <branch>` followed by `git worktree add <path> <branch>` 

182 - Without graphite: `git worktree add -b <branch> <path> <ref or HEAD>` 

183 

184 Otherwise, uses `git worktree add <path> <ref or HEAD>`. 

185 """ 

186 

187 if branch and use_existing_branch: 

188 # Validate branch is not already checked out 

189 existing_path = ctx.git_ops.is_branch_checked_out(repo_root, branch) 

190 if existing_path: 

191 click.echo( 

192 f"Error: Branch '{branch}' is already checked out at {existing_path}\n" 

193 f"Git doesn't allow the same branch to be checked out in multiple worktrees.\n\n" 

194 f"Options:\n" 

195 f" • Use a different branch name\n" 

196 f" • Create a new branch instead: workstack create {path.name}\n" 

197 f" • Switch to that worktree: workstack switch {path.name}", 

198 err=True, 

199 ) 

200 raise SystemExit(1) 

201 

202 ctx.git_ops.add_worktree(repo_root, path, branch=branch, ref=None, create_branch=False) 

203 elif branch: 

204 if use_graphite: 

205 cwd = Path.cwd() 

206 original_branch = ctx.git_ops.get_current_branch(cwd) 

207 if original_branch is None: 

208 raise ValueError("Cannot create graphite branch from detached HEAD") 

209 if ctx.git_ops.has_staged_changes(repo_root): 

210 click.echo( 

211 "Error: Staged changes detected. " 

212 "Graphite cannot create a branch while staged changes are present.\n" 

213 "`gt create --no-interactive` attempts to commit staged files but fails when " 

214 "no commit message is provided.\n\n" 

215 "Resolve the staged changes before running `workstack create`:\n" 

216 ' • Commit them: git commit -m "message"\n' 

217 " • Unstage them: git reset\n" 

218 " • Stash them: git stash\n" 

219 " • Disable Graphite: workstack config set use_graphite false", 

220 err=True, 

221 ) 

222 raise SystemExit(1) 

223 subprocess.run( 

224 ["gt", "create", "--no-interactive", branch], 

225 cwd=cwd, 

226 check=True, 

227 capture_output=True, 

228 text=True, 

229 ) 

230 ctx.git_ops.checkout_branch(cwd, original_branch) 

231 ctx.git_ops.add_worktree(repo_root, path, branch=branch, ref=None, create_branch=False) 

232 else: 

233 ctx.git_ops.add_worktree(repo_root, path, branch=branch, ref=ref, create_branch=True) 

234 else: 

235 ctx.git_ops.add_worktree(repo_root, path, branch=None, ref=ref, create_branch=False) 

236 

237 

238def make_env_content(cfg: LoadedConfig, *, worktree_path: Path, repo_root: Path, name: str) -> str: 

239 """Render .env content using config templates. 

240 

241 Substitution variables: 

242 - {worktree_path} 

243 - {repo_root} 

244 - {name} 

245 """ 

246 

247 variables: Mapping[str, str] = { 

248 "worktree_path": str(worktree_path), 

249 "repo_root": str(repo_root), 

250 "name": name, 

251 } 

252 

253 lines: list[str] = [] 

254 for key, template in cfg.env.items(): 

255 value = template.format(**variables) 

256 # Quote value to be safe; dotenv parsers commonly accept quotes. 

257 lines.append(f"{key}={quote_env_value(value)}") 

258 

259 # Always include these basics for convenience 

260 lines.append(f"WORKTREE_PATH={quote_env_value(str(worktree_path))}") 

261 lines.append(f"REPO_ROOT={quote_env_value(str(repo_root))}") 

262 lines.append(f"WORKTREE_NAME={quote_env_value(name)}") 

263 

264 return "\n".join(lines) + "\n" 

265 

266 

267def quote_env_value(value: str) -> str: 

268 """Return a quoted value suitable for .env files.""" 

269 escaped = value.replace("\\", "\\\\").replace('"', '\\"') 

270 return f'"{escaped}"' 

271 

272 

273@click.command("create") 

274@click.argument("name", metavar="NAME", required=False) 

275@click.option( 

276 "--branch", 

277 "branch", 

278 type=str, 

279 help=("Branch name to create and check out in the worktree. Defaults to NAME if omitted."), 

280) 

281@click.option( 

282 "--ref", 

283 "ref", 

284 type=str, 

285 default=None, 

286 help=("Git ref to base the worktree on (e.g. HEAD, origin/main). Defaults to HEAD if omitted."), 

287) 

288@click.option( 

289 "--no-post", 

290 is_flag=True, 

291 help="Skip running post-create commands from config.toml.", 

292) 

293@click.option( 

294 "--plan", 

295 "plan_file", 

296 type=click.Path(exists=True, dir_okay=False, path_type=Path), 

297 help=( 

298 "Path to a plan markdown file. Will derive worktree name from filename " 

299 "and move to .PLAN.md in the worktree." 

300 ), 

301) 

302@click.option( 

303 "--keep-plan", 

304 is_flag=True, 

305 help="Copy the plan file instead of moving it (requires --plan).", 

306) 

307@click.option( 

308 "--from-current-branch", 

309 is_flag=True, 

310 help=( 

311 "Move the current branch to the new worktree, then switch current worktree to --ref " 

312 "(defaults to main/master). NAME defaults to current branch name." 

313 ), 

314) 

315@click.option( 

316 "--from-branch", 

317 "from_branch", 

318 type=str, 

319 default=None, 

320 help=("Create worktree from an existing branch. NAME defaults to the branch name."), 

321) 

322@click.option( 

323 "--script", 

324 is_flag=True, 

325 hidden=True, 

326 help="Output shell script for directory change instead of messages.", 

327) 

328@click.pass_obj 

329def create( 

330 ctx: WorkstackContext, 

331 name: str | None, 

332 branch: str | None, 

333 ref: str | None, 

334 no_post: bool, 

335 plan_file: Path | None, 

336 keep_plan: bool, 

337 from_current_branch: bool, 

338 from_branch: str | None, 

339 script: bool, 

340) -> None: 

341 """Create a worktree and write a .env file. 

342 

343 Reads config.toml for env templates and post-create commands (if present). 

344 If --plan is provided, derives name from the plan filename and moves it to 

345 .PLAN.md in the worktree. 

346 If --from-current-branch is provided, moves the current branch to the new worktree. 

347 If --from-branch is provided, creates a worktree from an existing branch. 

348 """ 

349 

350 # Validate mutually exclusive options 

351 flags_set = sum([from_current_branch, from_branch is not None, plan_file is not None]) 

352 if flags_set > 1: 

353 click.echo("Cannot use multiple of: --from-current-branch, --from-branch, --plan") 

354 raise SystemExit(1) 

355 

356 # Validate --keep-plan requires --plan 

357 if keep_plan and not plan_file: 

358 click.echo("Error: --keep-plan requires --plan", err=True) 

359 raise SystemExit(1) 

360 

361 # Handle --from-current-branch flag 

362 if from_current_branch: 

363 # Get the current branch 

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

365 if current_branch is None: 

366 click.echo("Error: HEAD is detached (not on a branch)", err=True) 

367 raise SystemExit(1) 

368 

369 # Set branch to current branch and derive name if not provided 

370 if branch: 

371 click.echo("Cannot specify --branch with --from-current-branch (uses current branch).") 

372 raise SystemExit(1) 

373 branch = current_branch 

374 

375 if not name: 

376 name = sanitize_worktree_name(current_branch) 

377 

378 # Handle --from-branch flag 

379 elif from_branch: 

380 if branch: 

381 click.echo("Cannot specify --branch with --from-branch (uses the specified branch).") 

382 raise SystemExit(1) 

383 branch = from_branch 

384 

385 if not name: 

386 name = sanitize_worktree_name(from_branch) 

387 

388 # Handle --plan flag 

389 elif plan_file: 

390 if name: 

391 click.echo("Cannot specify both NAME and --plan. Use one or the other.") 

392 raise SystemExit(1) 

393 # Derive name from plan filename (strip extension) 

394 plan_stem = plan_file.stem # filename without extension 

395 cleaned_stem = strip_plan_from_filename(plan_stem) 

396 name = sanitize_worktree_name(cleaned_stem) 

397 

398 # Regular create (no special flags) 

399 else: 

400 if not name: 

401 click.echo( 

402 "Must provide NAME or --plan or --from-branch or --from-current-branch option." 

403 ) 

404 raise SystemExit(1) 

405 

406 # At this point, name should always be set 

407 assert name is not None, "name must be set by now" 

408 

409 # Validate that name is not a reserved word 

410 if name.lower() == "root": 

411 click.echo('Error: "root" is a reserved name and cannot be used for a worktree.', err=True) 

412 raise SystemExit(1) 

413 

414 # Validate that name is not main or master (common branch names that should use root) 

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

416 click.echo( 

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

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

419 f" workstack switch root", 

420 err=True, 

421 ) 

422 raise SystemExit(1) 

423 

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

425 workstacks_dir = ensure_workstacks_dir(repo) 

426 cfg = load_config(workstacks_dir) 

427 wt_path = worktree_path_for(workstacks_dir, name) 

428 

429 if wt_path.exists(): 

430 click.echo(f"Worktree path already exists: {wt_path}") 

431 raise SystemExit(1) 

432 

433 # Handle from-current-branch logic: switch current worktree first 

434 to_branch = None 

435 if from_current_branch: 

436 # Determine which branch to switch to (use ref if provided, else main/master) 

437 to_branch = ref if ref else ctx.git_ops.detect_default_branch(repo.root) 

438 

439 # Check for edge case: can't move main to worktree then switch to main 

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

441 if current_branch == to_branch: 

442 click.echo( 

443 f"Error: Cannot use --from-current-branch when on '{current_branch}'.\n" 

444 f"The current branch cannot be moved to a worktree and then checked out again.\n\n" 

445 f"Alternatives:\n" 

446 f" • Create a new branch: workstack create {name}\n" 

447 f" • Switch to a feature branch first, then use --from-current-branch\n" 

448 f" • Use --from-branch to create from a different existing branch", 

449 err=True, 

450 ) 

451 raise SystemExit(1) 

452 

453 # Switch the current worktree so the branch is free before creating the new worktree 

454 ctx.git_ops.checkout_branch(Path.cwd(), to_branch) 

455 

456 # Create worktree with existing branch 

457 add_worktree( 

458 ctx, 

459 repo.root, 

460 wt_path, 

461 branch=branch, 

462 ref=None, 

463 use_existing_branch=True, 

464 use_graphite=False, 

465 ) 

466 elif from_branch: 

467 # Create worktree with existing branch 

468 add_worktree( 

469 ctx, 

470 repo.root, 

471 wt_path, 

472 branch=branch, 

473 ref=None, 

474 use_existing_branch=True, 

475 use_graphite=False, 

476 ) 

477 else: 

478 # Create worktree via git. If no branch provided, derive a sensible default. 

479 if branch is None: 

480 branch = default_branch_for_worktree(name) 

481 

482 # Get graphite setting from global config 

483 use_graphite = ctx.global_config_ops.get_use_graphite() 

484 add_worktree( 

485 ctx, 

486 repo.root, 

487 wt_path, 

488 branch=branch, 

489 ref=ref, 

490 use_graphite=use_graphite, 

491 use_existing_branch=False, 

492 ) 

493 

494 # Write .env based on config 

495 env_content = make_env_content(cfg, worktree_path=wt_path, repo_root=repo.root, name=name) 

496 (wt_path / ".env").write_text(env_content, encoding="utf-8") 

497 

498 # Move or copy plan file if provided 

499 if plan_file: 

500 plan_dest = wt_path / ".PLAN.md" 

501 if keep_plan: 

502 shutil.copy2(str(plan_file), str(plan_dest)) 

503 if not script: 

504 click.echo(f"Copied plan to {plan_dest}") 

505 else: 

506 shutil.move(str(plan_file), str(plan_dest)) 

507 if not script: 

508 click.echo(f"Moved plan to {plan_dest}") 

509 

510 # Post-create commands 

511 if not no_post and cfg.post_create_commands: 

512 click.echo("Running post-create commands...") 

513 run_commands_in_worktree( 

514 commands=cfg.post_create_commands, 

515 worktree_path=wt_path, 

516 shell=cfg.post_create_shell, 

517 ) 

518 

519 if script: 

520 script_content = render_cd_script( 

521 wt_path, 

522 comment="cd to new worktree", 

523 success_message="✓ Switched to new worktree.", 

524 ) 

525 script_path = write_script_to_temp( 

526 script_content, 

527 command_name="create", 

528 comment=f"cd to {name}", 

529 ) 

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

531 else: 

532 click.echo(f"Created workstack at {wt_path} checked out at branch '{branch}'") 

533 click.echo(f"\nworkstack switch {name}") 

534 

535 

536def run_commands_in_worktree( 

537 *, commands: Iterable[str], worktree_path: Path, shell: str | None 

538) -> None: 

539 """Run commands serially in the worktree directory. 

540 

541 Each command is executed in its own subprocess. If `shell` is provided, commands 

542 run through that shell (e.g., "bash -lc <cmd>"). Otherwise, commands are tokenized 

543 via `shlex.split` and run directly. 

544 """ 

545 

546 for cmd in commands: 

547 if shell: 

548 subprocess.run([shell, "-lc", cmd], cwd=worktree_path, check=True) 

549 else: 

550 subprocess.run(shlex.split(cmd), cwd=worktree_path, check=True)