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
« 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
8import click
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
15_SAFE_COMPONENT_RE = re.compile(r"[^A-Za-z0-9._/-]+")
18def sanitize_branch_component(name: str) -> str:
19 """Return a sanitized, predictable branch component from an arbitrary name.
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 """
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"
35def strip_plan_from_filename(filename: str) -> str:
36 """Remove 'plan' or 'implementation plan' from a filename stem intelligently.
38 Handles case-insensitive matching and common separators (-, _, space).
39 If removal would leave empty string, returns original unchanged.
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 """
49 original_trimmed = filename.strip("-_ \t\n\r")
50 original_is_plan = original_trimmed.casefold() == "plan" if original_trimmed else False
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]|$)"
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)
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}"
72 # If entire string is "implementation-plan", keep just "implementation"
73 if not prefix and not suffix:
74 return implementation_word
76 # If in the middle, preserve one separator
77 if prefix and suffix:
78 return prefix if prefix.strip() else suffix
80 # At start or end: remove it and the adjacent separator
81 return ""
83 cleaned = re.sub(impl_pattern, replace_impl_plan, filename, flags=re.IGNORECASE)
85 # Then handle standalone "plan" as a complete word
86 plan_pattern = r"(^|[-_\s])(plan)([-_\s]|$)"
88 def replace_plan(match: re.Match[str]) -> str:
89 prefix = match.group(1)
90 suffix = match.group(3)
92 # If both prefix and suffix are empty (entire string is "plan"), keep it
93 if not prefix and not suffix:
94 return "plan"
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
101 # Plan at start or end: remove it and the adjacent separator
102 return ""
104 cleaned = re.sub(plan_pattern, replace_plan, cleaned, flags=re.IGNORECASE)
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
113 cleaned = clean_separators(cleaned)
115 plan_only_cleaned = clean_separators(
116 re.sub(plan_pattern, replace_plan, filename, flags=re.IGNORECASE)
117 )
119 if (
120 cleaned.casefold() == "plan"
121 and plan_only_cleaned
122 and plan_only_cleaned.casefold() != "plan"
123 ):
124 cleaned = plan_only_cleaned
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
130 return cleaned
133def sanitize_worktree_name(name: str) -> str:
134 """Sanitize a worktree name for use as a directory name.
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 """
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"
156def default_branch_for_worktree(name: str) -> str:
157 """Default branch name for a worktree with the given `name`.
159 Returns the sanitized name directly (without any prefix).
160 """
162 return sanitize_branch_component(name)
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.
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>`.
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>`
184 Otherwise, uses `git worktree add <path> <ref or HEAD>`.
185 """
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)
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)
238def make_env_content(cfg: LoadedConfig, *, worktree_path: Path, repo_root: Path, name: str) -> str:
239 """Render .env content using config templates.
241 Substitution variables:
242 - {worktree_path}
243 - {repo_root}
244 - {name}
245 """
247 variables: Mapping[str, str] = {
248 "worktree_path": str(worktree_path),
249 "repo_root": str(repo_root),
250 "name": name,
251 }
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)}")
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)}")
264 return "\n".join(lines) + "\n"
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}"'
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.
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 """
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)
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)
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)
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
375 if not name:
376 name = sanitize_worktree_name(current_branch)
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
385 if not name:
386 name = sanitize_worktree_name(from_branch)
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)
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)
406 # At this point, name should always be set
407 assert name is not None, "name must be set by now"
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)
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)
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)
429 if wt_path.exists():
430 click.echo(f"Worktree path already exists: {wt_path}")
431 raise SystemExit(1)
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)
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)
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)
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)
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 )
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")
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}")
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 )
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}")
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.
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 """
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)