Coverage for src/workstack/cli/commands/list.py: 95%

178 statements  

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

1from pathlib import Path 

2 

3import click 

4 

5from workstack.cli.core import discover_repo_context, ensure_workstacks_dir 

6from workstack.cli.graphite import _load_graphite_cache, get_branch_stack 

7from workstack.core.context import WorkstackContext 

8from workstack.core.github_ops import PullRequestInfo 

9 

10 

11def _format_worktree_line( 

12 name: str, branch: str | None, path: str | None, is_root: bool, is_current: bool 

13) -> str: 

14 """Format a single worktree line with colorization. 

15 

16 Args: 

17 name: Worktree name to display 

18 branch: Branch name (if any) 

19 path: Filesystem path to display (if provided, shows path instead of branch) 

20 is_root: True if this is the root repository worktree 

21 is_current: True if this is the worktree the user is currently in 

22 

23 Returns: 

24 Formatted line with appropriate colorization 

25 """ 

26 # Root worktree gets green to distinguish it from regular worktrees 

27 name_color = "green" if is_root else "cyan" 

28 name_part = click.style(name, fg=name_color, bold=True) 

29 

30 # If path is provided, show path in dim white; otherwise show branch in yellow 

31 if path: 

32 location_part = click.style(f"[{path}]", fg="white", dim=True) 

33 elif branch: 

34 location_part = click.style(f"[{branch}]", fg="yellow") 

35 else: 

36 location_part = "" 

37 

38 # Build the main line 

39 parts = [name_part, location_part] 

40 line = " ".join(p for p in parts if p) 

41 

42 # Add indicator on the right for current worktree 

43 if is_current: 

44 indicator = click.style(" ← (cwd)", fg="bright_blue") 

45 line += indicator 

46 

47 return line 

48 

49 

50def _filter_stack_for_worktree( 

51 stack: list[str], 

52 current_worktree_path: Path, 

53 all_worktree_branches: dict[Path, str | None], 

54 is_root_worktree: bool, 

55) -> list[str]: 

56 """Filter a graphite stack to only show branches relevant to the current worktree. 

57 

58 When displaying a stack for a specific worktree, we want to show: 

59 - Root worktree: Current branch + all ancestors (no descendants) 

60 - Other worktrees: Ancestors + current + descendants that are checked out somewhere 

61 

62 This ensures that: 

63 - Root worktree shows context from trunk down to current branch 

64 - Other worktrees show full context but only "active" descendants with worktrees 

65 - Branches without active worktrees don't clutter non-root displays 

66 

67 Example: 

68 Stack: [main, foo, bar, baz] 

69 Worktrees: 

70 - root on bar 

71 - worktree-baz on baz 

72 

73 Root display: [main, foo, bar] (ancestors + current, no descendants) 

74 Worktree-baz display: [main, foo, bar, baz] (full context with checked-out descendants) 

75 

76 Args: 

77 stack: The full graphite stack (ordered from trunk to leaf) 

78 current_worktree_path: Path to the worktree we're displaying the stack for 

79 all_worktree_branches: Mapping of all worktree paths to their checked-out branches 

80 is_root_worktree: True if this is the root repository worktree 

81 

82 Returns: 

83 Filtered stack with only relevant branches 

84 """ 

85 # Get the branch checked out in the current worktree 

86 current_branch = all_worktree_branches.get(current_worktree_path) 

87 if current_branch is None or current_branch not in stack: 

88 # If current branch is not in stack (shouldn't happen), return full stack 

89 return stack 

90 

91 # Find the index of the current branch in the stack 

92 current_idx = stack.index(current_branch) 

93 

94 # Filter the stack based on whether this is the root worktree 

95 if is_root_worktree: 

96 # Root worktree: show only ancestors + current (no descendants) 

97 # This keeps the display clean and focused on context 

98 return stack[: current_idx + 1] 

99 else: 

100 # Non-root worktree: show ancestors + current + descendants with worktrees 

101 # Build a set of branches that are checked out in ANY worktree 

102 all_checked_out_branches = { 

103 branch for branch in all_worktree_branches.values() if branch is not None 

104 } 

105 

106 result = [] 

107 for i, branch in enumerate(stack): 

108 if i <= current_idx: 

109 # Ancestors and current branch: always keep 

110 result.append(branch) 

111 else: 

112 # Descendants: only keep if checked out in some worktree 

113 if branch in all_checked_out_branches: 

114 result.append(branch) 

115 

116 return result 

117 

118 

119def _is_trunk_branch( 

120 ctx: WorkstackContext, repo_root: Path, branch: str, cache_data: dict | None = None 

121) -> bool: 

122 """Check if a branch is a trunk branch (has no parent in graphite). 

123 

124 Returns False for missing cache files rather than None because this function 

125 answers a boolean question: "Is this branch trunk?" When cache is missing, 

126 the answer is definitively "no" (we can't determine trunk status, default to False). 

127 

128 This differs from get_branch_stack() which returns None for missing cache because 

129 it's retrieving optional data - None indicates "no stack data available" vs 

130 an empty list which would mean "stack exists but is empty". 

131 

132 Args: 

133 ctx: Workstack context with git operations 

134 repo_root: Path to the repository root 

135 branch: Branch name to check 

136 cache_data: Pre-loaded graphite cache data (optional optimization) 

137 If None, cache will be loaded from disk 

138 

139 Returns: 

140 True if the branch is a trunk branch (no parent), False otherwise 

141 False is also returned when cache is missing/inaccessible (conservative default) 

142 """ 

143 if cache_data is None: 

144 git_dir = ctx.git_ops.get_git_common_dir(repo_root) 

145 if git_dir is None: 

146 return False 

147 

148 cache_file = git_dir / ".graphite_cache_persist" 

149 if not cache_file.exists(): 

150 return False 

151 

152 cache_data = _load_graphite_cache(cache_file) 

153 

154 branches_data = cache_data.get("branches", []) 

155 

156 for branch_name, info in branches_data: 

157 if branch_name == branch: 

158 # Check if this is marked as trunk or has no parent 

159 is_trunk = info.get("validationResult") == "TRUNK" 

160 has_parent = info.get("parentBranchName") is not None 

161 return is_trunk or not has_parent 

162 

163 return False 

164 

165 

166def _get_pr_status_emoji(pr: PullRequestInfo) -> str: 

167 """Determine the emoji to display for a PR based on its status. 

168 

169 Args: 

170 pr: Pull request information 

171 

172 Returns: 

173 Emoji character representing the PR's current state 

174 """ 

175 if pr.is_draft: 

176 return "🚧" 

177 if pr.state == "MERGED": 

178 return "🟣" 

179 if pr.state == "CLOSED": 

180 return "⭕" 

181 if pr.checks_passing is True: 

182 return "✅" 

183 if pr.checks_passing is False: 

184 return "❌" 

185 # Open PR with no checks 

186 return "◯" 

187 

188 

189def _format_pr_info( 

190 ctx: WorkstackContext, 

191 repo_root: Path, 

192 branch: str, 

193 prs: dict[str, PullRequestInfo], 

194) -> str: 

195 """Format PR status indicator with emoji and link. 

196 

197 Args: 

198 ctx: Workstack context with GitHub/Graphite operations 

199 repo_root: Repository root directory 

200 branch: Branch name 

201 prs: Mapping of branch name -> PullRequestInfo 

202 

203 Returns: 

204 Formatted PR info string (e.g., "✅ #23") or empty string if no PR 

205 """ 

206 pr = prs.get(branch) 

207 if pr is None: 

208 return "" 

209 

210 emoji = _get_pr_status_emoji(pr) 

211 

212 # Get Graphite URL (always available since we have owner/repo from GitHub) 

213 url = ctx.graphite_ops.get_graphite_url(pr.owner, pr.repo, pr.number) 

214 

215 # Format as clickable link using OSC 8 terminal escape sequence with cyan color 

216 # Format: \033]8;;URL\033\\TEXT\033]8;;\033\\ 

217 pr_text = f"#{pr.number}" 

218 # Wrap the link text in cyan color to distinguish from non-clickable bright_blue indicators 

219 colored_pr_text = click.style(pr_text, fg="cyan") 

220 clickable_link = f"\033]8;;{url}\033\\{colored_pr_text}\033]8;;\033\\" 

221 

222 return f"{emoji} {clickable_link}" 

223 

224 

225def _format_plan_summary(worktree_path: Path) -> str | None: 

226 """Format plan summary line if .PLAN.md exists. 

227 

228 Args: 

229 worktree_path: Path to the worktree directory 

230 

231 Returns: 

232 Formatted line with plan title, or None if no plan file 

233 """ 

234 from workstack.core.file_utils import extract_plan_title 

235 

236 plan_path = worktree_path / ".PLAN.md" 

237 title = extract_plan_title(plan_path) 

238 

239 if title is None: 

240 return None 

241 

242 # Format: " 📋 <title in bright magenta>" 

243 title_colored = click.style(title, fg="bright_magenta") 

244 return f" 📋 {title_colored}" 

245 

246 

247def _display_branch_stack( 

248 ctx: WorkstackContext, 

249 repo_root: Path, 

250 worktree_path: Path, 

251 branch: str, 

252 all_branches: dict[Path, str | None], 

253 is_root_worktree: bool, 

254 cache_data: dict | None = None, # If None, cache will be loaded from disk 

255 prs: dict[str, PullRequestInfo] | None = None, # If None, no PR info displayed 

256) -> None: 

257 """Display the graphite stack for a worktree with colorization and PR info. 

258 

259 Shows branches with colored markers indicating which is currently checked out. 

260 Current branch is emphasized with bright green, others are de-emphasized with gray. 

261 Also displays PR status and links for branches that have PRs. 

262 

263 Args: 

264 ctx: Workstack context with git operations 

265 repo_root: Path to the repository root 

266 worktree_path: Path to the current worktree 

267 branch: Branch name to display stack for 

268 all_branches: Mapping of all worktree paths to their checked-out branches 

269 cache_data: Pre-loaded graphite cache data (if None, loaded from disk) 

270 prs: Mapping of branch names to PR information (if None, no PR info displayed) 

271 """ 

272 stack = get_branch_stack(ctx, repo_root, branch) 

273 if not stack: 

274 return 

275 

276 filtered_stack = _filter_stack_for_worktree( 

277 stack, worktree_path, all_branches, is_root_worktree 

278 ) 

279 if not filtered_stack: 

280 return 

281 

282 # Determine which branch to highlight 

283 actual_branch = ctx.git_ops.get_current_branch(worktree_path) 

284 highlight_branch = actual_branch if actual_branch else branch 

285 

286 # Display stack with colored markers and PR info 

287 for branch_name in reversed(filtered_stack): 

288 is_current = branch_name == highlight_branch 

289 

290 if is_current: 

291 # Current branch: bright green marker + bright green bold text 

292 marker = click.style("◉", fg="bright_green") 

293 branch_text = click.style(branch_name, fg="bright_green", bold=True) 

294 else: 

295 # Other branches: gray marker + normal text 

296 marker = click.style("◯", fg="bright_black") 

297 branch_text = branch_name # Normal white text 

298 

299 # Add PR info if available 

300 if prs: 

301 pr_info = _format_pr_info(ctx, repo_root, branch_name, prs) 

302 if pr_info: 

303 line = f" {marker} {branch_text} {pr_info}" 

304 else: 

305 line = f" {marker} {branch_text}" 

306 else: 

307 line = f" {marker} {branch_text}" 

308 

309 click.echo(line) 

310 

311 

312def _list_worktrees(ctx: WorkstackContext, show_stacks: bool, show_checks: bool) -> None: 

313 """Internal function to list worktrees.""" 

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

315 current_dir = Path.cwd().resolve() 

316 

317 # Get branch info for all worktrees 

318 worktrees = ctx.git_ops.list_worktrees(repo.root) 

319 branches = {wt.path: wt.branch for wt in worktrees} 

320 

321 # Determine which worktree the user is currently in 

322 current_worktree_path = None 

323 for wt_path in branches.keys(): 

324 if wt_path.exists(): 

325 wt_path_resolved = wt_path.resolve() 

326 if current_dir == wt_path_resolved or current_dir.is_relative_to(wt_path_resolved): 

327 current_worktree_path = wt_path_resolved 

328 break 

329 

330 # Load graphite cache once if showing stacks 

331 cache_data = None 

332 if show_stacks: 

333 if not ctx.global_config_ops.get_use_graphite(): 

334 click.echo( 

335 "Error: --stacks requires graphite to be enabled. " 

336 "Run 'workstack config set use_graphite true'", 

337 err=True, 

338 ) 

339 raise SystemExit(1) 

340 

341 # Load cache once for all worktrees 

342 git_dir = ctx.git_ops.get_git_common_dir(repo.root) 

343 if git_dir is not None: 

344 cache_file = git_dir / ".graphite_cache_persist" 

345 if cache_file.exists(): 

346 cache_data = _load_graphite_cache(cache_file) 

347 

348 # Fetch PR information based on config and flags 

349 prs: dict[str, PullRequestInfo] | None = None 

350 if ctx.global_config_ops.get_show_pr_info(): 

351 # Determine if we need CI check status 

352 need_checks = show_checks or ctx.global_config_ops.get_show_pr_checks() 

353 

354 if need_checks: 

355 # Fetch from GitHub with check status (slower) 

356 prs = ctx.github_ops.get_prs_for_repo(repo.root, include_checks=True) 

357 else: 

358 # Try Graphite first (fast - no CI status) 

359 prs = ctx.graphite_ops.get_prs_from_graphite(ctx.git_ops, repo.root) 

360 

361 # If Graphite data not available, fall back to GitHub without checks 

362 if not prs: 

363 prs = ctx.github_ops.get_prs_for_repo(repo.root, include_checks=False) 

364 

365 # Show root repo first (display as "root" to distinguish from worktrees) 

366 root_branch = branches.get(repo.root) 

367 is_current_root = repo.root.resolve() == current_worktree_path 

368 click.echo( 

369 _format_worktree_line( 

370 "root", root_branch, path=str(repo.root), is_root=True, is_current=is_current_root 

371 ) 

372 ) 

373 

374 # Add plan summary if exists (only when showing stacks) 

375 if show_stacks: 

376 plan_summary = _format_plan_summary(repo.root) 

377 if plan_summary: 

378 click.echo(plan_summary) 

379 

380 if show_stacks and root_branch: 

381 _display_branch_stack( 

382 ctx, repo.root, repo.root, root_branch, branches, True, cache_data, prs 

383 ) 

384 

385 # Show worktrees 

386 workstacks_dir = ensure_workstacks_dir(repo) 

387 if not workstacks_dir.exists(): 

388 return 

389 entries = sorted(p for p in workstacks_dir.iterdir() if p.is_dir()) 

390 for p in entries: 

391 name = p.name 

392 # Find the actual worktree path from git worktree list 

393 # The path p might be a symlink or different from the actual worktree path 

394 wt_path = None 

395 wt_branch = None 

396 for branch_path, branch_name in branches.items(): 

397 if branch_path.resolve() == p.resolve(): 

398 wt_path = branch_path 

399 wt_branch = branch_name 

400 break 

401 

402 # Add blank line before each worktree (except first) when showing stacks 

403 if show_stacks and (root_branch or entries.index(p) > 0): 

404 click.echo() 

405 

406 is_current_wt = bool(wt_path and wt_path.resolve() == current_worktree_path) 

407 click.echo( 

408 _format_worktree_line( 

409 name, wt_branch, path=str(p), is_root=False, is_current=is_current_wt 

410 ) 

411 ) 

412 

413 # Add plan summary if exists (only when showing stacks) 

414 if show_stacks and wt_path: 

415 plan_summary = _format_plan_summary(wt_path) 

416 if plan_summary: 

417 click.echo(plan_summary) 

418 

419 if show_stacks and wt_branch and wt_path: 

420 _display_branch_stack( 

421 ctx, repo.root, wt_path, wt_branch, branches, False, cache_data, prs 

422 ) 

423 

424 

425@click.command("list") 

426@click.option("--stacks", "-s", is_flag=True, help="Show graphite stacks for each worktree") 

427@click.option( 

428 "--checks", "-c", is_flag=True, help="Show CI check status (requires GitHub API call)" 

429) 

430@click.pass_obj 

431def list_cmd(ctx: WorkstackContext, stacks: bool, checks: bool) -> None: 

432 """List worktrees with activation hints (alias: ls).""" 

433 _list_worktrees(ctx, show_stacks=stacks, show_checks=checks) 

434 

435 

436# Register ls as a hidden alias (won't show in help) 

437@click.command("ls", hidden=True) 

438@click.option("--stacks", "-s", is_flag=True, help="Show graphite stacks for each worktree") 

439@click.option( 

440 "--checks", "-c", is_flag=True, help="Show CI check status (requires GitHub API call)" 

441) 

442@click.pass_obj 

443def ls_cmd(ctx: WorkstackContext, stacks: bool, checks: bool) -> None: 

444 """List worktrees with activation hints (alias of 'list').""" 

445 _list_worktrees(ctx, show_stacks=stacks, show_checks=checks)