Coverage for src/workstack/cli/commands/init.py: 93%
180 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 tomllib
3from pathlib import Path
5import click
7from workstack.cli.core import discover_repo_context, ensure_workstacks_dir
8from workstack.core.context import WorkstackContext
9from workstack.core.file_utils import atomic_write
10from workstack.core.global_config_ops import GlobalConfigOps
11from workstack.core.shell_ops import ShellOps
14def detect_root_project_name(repo_root: Path) -> str | None:
15 """Return the declared project name at the repo root, if any.
17 Checks root `pyproject.toml`'s `[project].name`. If absent, tries to heuristically
18 extract from `setup.py` by matching `name="..."` or `name='...'`.
19 """
21 root_pyproject = repo_root / "pyproject.toml"
22 if root_pyproject.exists():
23 data = tomllib.loads(root_pyproject.read_text(encoding="utf-8"))
24 project = data.get("project") or {}
25 name = project.get("name")
26 if isinstance(name, str) and name:
27 return name
29 setup_py = repo_root / "setup.py"
30 if setup_py.exists():
31 text = setup_py.read_text(encoding="utf-8")
32 m = re.search(r"name\s*=\s*['\"]([^'\"]+)['\"]", text)
33 if m:
34 return m.group(1)
36 return None
39def is_repo_named(repo_root: Path, expected_name: str) -> bool:
40 """Return True if the root project name matches `expected_name` (case-insensitive)."""
41 name = detect_root_project_name(repo_root)
42 return (name or "").lower() == expected_name.lower()
45def detect_graphite(shell_ops: ShellOps) -> bool:
46 """Detect if Graphite (gt) is installed and available in PATH."""
47 return shell_ops.check_tool_installed("gt") is not None
50def create_global_config(
51 global_config_ops: GlobalConfigOps,
52 shell_ops: ShellOps,
53 workstacks_root: Path,
54 shell_setup_complete: bool,
55) -> None:
56 """Create global config using the provided config ops."""
57 use_graphite = detect_graphite(shell_ops)
58 global_config_ops.set(
59 workstacks_root=workstacks_root,
60 use_graphite=use_graphite,
61 shell_setup_complete=shell_setup_complete,
62 )
65def discover_presets() -> list[str]:
66 """Discover available preset names by scanning the presets directory.
68 Returns a list of preset names (without .toml extension).
69 """
70 presets_dir = Path(__file__).parent.parent / "presets"
71 if not presets_dir.exists():
72 return []
74 return sorted(p.stem for p in presets_dir.glob("*.toml") if p.is_file())
77def render_config_template(preset: str | None) -> str:
78 """Return default config TOML content, optionally using a preset.
80 If preset is None, uses the "generic" preset by default.
81 Preset files are loaded from src/workstack/presets/<preset>.toml
82 """
83 preset_name = preset if preset is not None else "generic"
84 presets_dir = Path(__file__).parent.parent / "presets"
85 preset_file = presets_dir / f"{preset_name}.toml"
87 if not preset_file.exists():
88 raise ValueError(f"Preset '{preset_name}' not found at {preset_file}")
90 return preset_file.read_text(encoding="utf-8")
93def _add_gitignore_entry(content: str, entry: str, prompt_message: str) -> tuple[str, bool]:
94 """Add an entry to gitignore content if not present and user confirms.
96 Args:
97 content: Current gitignore content
98 entry: Entry to add (e.g., ".PLAN.md")
99 prompt_message: Message to show user when confirming
101 Returns:
102 Tuple of (updated_content, was_modified)
103 """
104 # Entry already present
105 if entry in content:
106 return (content, False)
108 # User declined
109 if not click.confirm(prompt_message, default=True):
110 return (content, False)
112 # Ensure trailing newline before adding
113 if not content.endswith("\n"):
114 content += "\n"
116 content += f"{entry}\n"
117 return (content, True)
120def get_shell_wrapper_content(shell: str) -> str:
121 """Load the shell wrapper function for the given shell type."""
122 shell_integration_dir = Path(__file__).parent.parent / "shell_integration"
124 if shell == "fish":
125 wrapper_file = shell_integration_dir / "fish_wrapper.fish"
126 else:
127 wrapper_file = shell_integration_dir / f"{shell}_wrapper.sh"
129 if not wrapper_file.exists():
130 raise ValueError(f"Shell wrapper not found for {shell}")
132 return wrapper_file.read_text(encoding="utf-8")
135def perform_shell_setup(shell_ops: ShellOps) -> bool:
136 """Interactively set up shell integration (completion + wrapper function).
138 Returns True if setup was completed, False if skipped.
139 """
140 shell_info = shell_ops.detect_shell()
141 if not shell_info:
142 click.echo("Unable to detect shell. Skipping shell integration setup.")
143 return False
145 shell, rc_file = shell_info
147 # Resolve symlinks to real file before any operations
148 if rc_file.exists():
149 rc_file = rc_file.resolve()
151 click.echo(f"\nDetected shell: {shell}")
152 click.echo("Shell integration provides:")
153 click.echo(" - Tab completion for workstack commands")
154 click.echo(" - Automatic worktree activation on 'workstack switch'")
156 if not click.confirm("\nSet up shell integration?", default=True):
157 click.echo("Skipping shell integration. You can run 'workstack init --shell' later.")
158 return False
160 # Read existing content once
161 rc_content = ""
162 if rc_file.exists():
163 rc_content = rc_file.read_text(encoding="utf-8")
165 modifications: list[str] = [] # Track what we're adding
167 # Step 1: Check completion
168 click.echo(f"\n1. Setting up tab completion for {shell}...")
169 completion_line = f"source <(workstack completion {shell})"
171 if completion_line in rc_content:
172 click.echo(" ✓ Completion already configured")
173 else:
174 if click.confirm(f" Add completion to {rc_file}?", default=True):
175 modifications.append(f"# Workstack completion\n{completion_line}")
176 click.echo(" ✓ Will add completion")
177 else:
178 click.echo(" To set up manually, add:")
179 click.echo(f" {completion_line}")
181 # Step 2: Check wrapper
182 click.echo(f"\n2. Setting up auto-activation wrapper for {shell}...")
183 wrapper_content = get_shell_wrapper_content(shell)
185 if "workstack shell integration" in rc_content.lower():
186 click.echo(" ✓ Wrapper already configured")
187 else:
188 if click.confirm(f" Add wrapper function to {rc_file}?", default=True):
189 modifications.append(wrapper_content)
190 click.echo(" ✓ Will add wrapper")
191 else:
192 click.echo(" To set up manually, add:")
193 click.echo(f"\n{wrapper_content}")
195 # Write all modifications at once using atomic write
196 if modifications:
197 # Ensure trailing newline before adding modifications
198 if rc_content and not rc_content.endswith("\n"):
199 rc_content += "\n"
201 rc_content += "\n\n" + "\n\n".join(modifications) + "\n"
203 # Use atomic write context manager for clean, safe file update
204 with atomic_write(rc_file) as f:
205 f.write(rc_content)
207 click.echo("\n✓ Shell integration setup complete!")
208 click.echo(f"Run 'source {rc_file}' or start a new shell to activate.")
210 return True
213@click.command("init")
214@click.option("--force", is_flag=True, help="Overwrite existing repo config if present.")
215@click.option(
216 "--preset",
217 type=str,
218 default="auto",
219 help=(
220 "Config template to use. 'auto' detects preset based on repo characteristics. "
221 f"Available: auto, {', '.join(discover_presets())}."
222 ),
223)
224@click.option(
225 "--list-presets",
226 is_flag=True,
227 help="List available presets and exit.",
228)
229@click.option(
230 "--repo",
231 is_flag=True,
232 help="Initialize repository-level config only (skip global config setup).",
233)
234@click.option(
235 "--shell",
236 is_flag=True,
237 help="Set up shell integration only (completion + auto-activation wrapper).",
238)
239@click.pass_obj
240def init_cmd(
241 ctx: WorkstackContext, force: bool, preset: str, list_presets: bool, repo: bool, shell: bool
242) -> None:
243 """Initialize workstack for this repo and scaffold config.toml."""
245 # Handle --shell flag: only do shell setup
246 if shell:
247 setup_complete = perform_shell_setup(ctx.shell_ops)
248 if setup_complete:
249 ctx.global_config_ops.set(shell_setup_complete=True)
250 return
252 # Discover available presets on demand
253 available_presets = discover_presets()
254 valid_choices = ["auto"] + available_presets
256 # Handle --list-presets flag
257 if list_presets:
258 click.echo("Available presets:")
259 for p in available_presets:
260 click.echo(f" - {p}")
261 return
263 # Validate preset choice
264 if preset not in valid_choices:
265 click.echo(f"Invalid preset '{preset}'. Available options: {', '.join(valid_choices)}")
266 raise SystemExit(1)
268 # Track if this is the first time init is run
269 first_time_init = False
271 # Check for global config first (unless --repo flag is set)
272 if not repo and not ctx.global_config_ops.exists():
273 first_time_init = True
274 click.echo(f"Global config not found at {ctx.global_config_ops.get_path()}")
275 click.echo("Please provide the path where you want to store all worktrees.")
276 click.echo("(This directory will contain subdirectories for each repository)")
277 workstacks_root = click.prompt("Worktrees root directory", type=Path)
278 workstacks_root = workstacks_root.expanduser().resolve()
279 create_global_config(
280 ctx.global_config_ops, ctx.shell_ops, workstacks_root, shell_setup_complete=False
281 )
282 click.echo(f"Created global config at {ctx.global_config_ops.get_path()}")
283 # Show graphite status on first init
284 has_graphite = detect_graphite(ctx.shell_ops)
285 if has_graphite:
286 click.echo("Graphite (gt) detected - will use 'gt create' for new branches")
287 else:
288 click.echo("Graphite (gt) not detected - will use 'git' for branch creation")
290 # When --repo is set, verify that global config exists
291 if repo and not ctx.global_config_ops.exists():
292 click.echo(f"Global config not found at {ctx.global_config_ops.get_path()}", err=True)
293 click.echo("Run 'workstack init' without --repo to create global config first.", err=True)
294 raise SystemExit(1)
296 # Now proceed with repo-specific setup
297 repo_context = discover_repo_context(ctx, Path.cwd())
299 # Determine config path based on --repo flag
300 if repo:
301 # Repository-level config goes in repo root
302 cfg_path = repo_context.root / "config.toml"
303 else:
304 # Worktree-level config goes in workstacks_dir
305 workstacks_dir = ensure_workstacks_dir(repo_context)
306 cfg_path = workstacks_dir / "config.toml"
308 if cfg_path.exists() and not force:
309 click.echo(f"Config already exists: {cfg_path}. Use --force to overwrite.")
310 raise SystemExit(1)
312 effective_preset: str | None
313 choice = preset.lower()
314 if choice == "auto":
315 effective_preset = "dagster" if is_repo_named(repo_context.root, "dagster") else "generic"
316 else:
317 effective_preset = choice
319 content = render_config_template(effective_preset)
320 cfg_path.write_text(content, encoding="utf-8")
321 click.echo(f"Wrote {cfg_path}")
323 # Check for .gitignore and add .PLAN.md and .env
324 gitignore_path = repo_context.root / ".gitignore"
325 if not gitignore_path.exists():
326 # Early return: no gitignore file
327 pass
328 else:
329 gitignore_content = gitignore_path.read_text(encoding="utf-8")
330 modified = False
332 # Add .PLAN.md
333 gitignore_content, plan_added = _add_gitignore_entry(
334 gitignore_content,
335 ".PLAN.md",
336 "Add .PLAN.md to .gitignore?",
337 )
338 modified = modified or plan_added
340 # Add .env
341 gitignore_content, env_added = _add_gitignore_entry(
342 gitignore_content,
343 ".env",
344 "Add .env to .gitignore?",
345 )
346 modified = modified or env_added
348 # Write if modified
349 if modified:
350 gitignore_path.write_text(gitignore_content, encoding="utf-8")
351 click.echo(f"Updated {gitignore_path}")
353 # On first-time init, offer shell setup if not already completed
354 if first_time_init:
355 if not ctx.global_config_ops.get_shell_setup_complete():
356 setup_complete = perform_shell_setup(ctx.shell_ops)
357 if setup_complete:
358 ctx.global_config_ops.set(shell_setup_complete=True)