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

1import re 

2import tomllib 

3from pathlib import Path 

4 

5import click 

6 

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 

12 

13 

14def detect_root_project_name(repo_root: Path) -> str | None: 

15 """Return the declared project name at the repo root, if any. 

16 

17 Checks root `pyproject.toml`'s `[project].name`. If absent, tries to heuristically 

18 extract from `setup.py` by matching `name="..."` or `name='...'`. 

19 """ 

20 

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 

28 

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) 

35 

36 return None 

37 

38 

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

43 

44 

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 

48 

49 

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 ) 

63 

64 

65def discover_presets() -> list[str]: 

66 """Discover available preset names by scanning the presets directory. 

67 

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

73 

74 return sorted(p.stem for p in presets_dir.glob("*.toml") if p.is_file()) 

75 

76 

77def render_config_template(preset: str | None) -> str: 

78 """Return default config TOML content, optionally using a preset. 

79 

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" 

86 

87 if not preset_file.exists(): 

88 raise ValueError(f"Preset '{preset_name}' not found at {preset_file}") 

89 

90 return preset_file.read_text(encoding="utf-8") 

91 

92 

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. 

95 

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 

100 

101 Returns: 

102 Tuple of (updated_content, was_modified) 

103 """ 

104 # Entry already present 

105 if entry in content: 

106 return (content, False) 

107 

108 # User declined 

109 if not click.confirm(prompt_message, default=True): 

110 return (content, False) 

111 

112 # Ensure trailing newline before adding 

113 if not content.endswith("\n"): 

114 content += "\n" 

115 

116 content += f"{entry}\n" 

117 return (content, True) 

118 

119 

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" 

123 

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" 

128 

129 if not wrapper_file.exists(): 

130 raise ValueError(f"Shell wrapper not found for {shell}") 

131 

132 return wrapper_file.read_text(encoding="utf-8") 

133 

134 

135def perform_shell_setup(shell_ops: ShellOps) -> bool: 

136 """Interactively set up shell integration (completion + wrapper function). 

137 

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 

144 

145 shell, rc_file = shell_info 

146 

147 # Resolve symlinks to real file before any operations 

148 if rc_file.exists(): 

149 rc_file = rc_file.resolve() 

150 

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'") 

155 

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 

159 

160 # Read existing content once 

161 rc_content = "" 

162 if rc_file.exists(): 

163 rc_content = rc_file.read_text(encoding="utf-8") 

164 

165 modifications: list[str] = [] # Track what we're adding 

166 

167 # Step 1: Check completion 

168 click.echo(f"\n1. Setting up tab completion for {shell}...") 

169 completion_line = f"source <(workstack completion {shell})" 

170 

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}") 

180 

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) 

184 

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}") 

194 

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" 

200 

201 rc_content += "\n\n" + "\n\n".join(modifications) + "\n" 

202 

203 # Use atomic write context manager for clean, safe file update 

204 with atomic_write(rc_file) as f: 

205 f.write(rc_content) 

206 

207 click.echo("\n✓ Shell integration setup complete!") 

208 click.echo(f"Run 'source {rc_file}' or start a new shell to activate.") 

209 

210 return True 

211 

212 

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

244 

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 

251 

252 # Discover available presets on demand 

253 available_presets = discover_presets() 

254 valid_choices = ["auto"] + available_presets 

255 

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 

262 

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) 

267 

268 # Track if this is the first time init is run 

269 first_time_init = False 

270 

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

289 

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) 

295 

296 # Now proceed with repo-specific setup 

297 repo_context = discover_repo_context(ctx, Path.cwd()) 

298 

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" 

307 

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) 

311 

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 

318 

319 content = render_config_template(effective_preset) 

320 cfg_path.write_text(content, encoding="utf-8") 

321 click.echo(f"Wrote {cfg_path}") 

322 

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 

331 

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 

339 

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 

347 

348 # Write if modified 

349 if modified: 

350 gitignore_path.write_text(gitignore_content, encoding="utf-8") 

351 click.echo(f"Updated {gitignore_path}") 

352 

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)