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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
1from pathlib import Path
3import click
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
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.
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
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)
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 = ""
38 # Build the main line
39 parts = [name_part, location_part]
40 line = " ".join(p for p in parts if p)
42 # Add indicator on the right for current worktree
43 if is_current:
44 indicator = click.style(" ← (cwd)", fg="bright_blue")
45 line += indicator
47 return line
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.
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
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
67 Example:
68 Stack: [main, foo, bar, baz]
69 Worktrees:
70 - root on bar
71 - worktree-baz on baz
73 Root display: [main, foo, bar] (ancestors + current, no descendants)
74 Worktree-baz display: [main, foo, bar, baz] (full context with checked-out descendants)
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
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
91 # Find the index of the current branch in the stack
92 current_idx = stack.index(current_branch)
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 }
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)
116 return result
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).
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).
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".
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
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
148 cache_file = git_dir / ".graphite_cache_persist"
149 if not cache_file.exists():
150 return False
152 cache_data = _load_graphite_cache(cache_file)
154 branches_data = cache_data.get("branches", [])
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
163 return False
166def _get_pr_status_emoji(pr: PullRequestInfo) -> str:
167 """Determine the emoji to display for a PR based on its status.
169 Args:
170 pr: Pull request information
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 "◯"
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.
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
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 ""
210 emoji = _get_pr_status_emoji(pr)
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)
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\\"
222 return f"{emoji} {clickable_link}"
225def _format_plan_summary(worktree_path: Path) -> str | None:
226 """Format plan summary line if .PLAN.md exists.
228 Args:
229 worktree_path: Path to the worktree directory
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
236 plan_path = worktree_path / ".PLAN.md"
237 title = extract_plan_title(plan_path)
239 if title is None:
240 return None
242 # Format: " 📋 <title in bright magenta>"
243 title_colored = click.style(title, fg="bright_magenta")
244 return f" 📋 {title_colored}"
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.
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.
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
276 filtered_stack = _filter_stack_for_worktree(
277 stack, worktree_path, all_branches, is_root_worktree
278 )
279 if not filtered_stack:
280 return
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
286 # Display stack with colored markers and PR info
287 for branch_name in reversed(filtered_stack):
288 is_current = branch_name == highlight_branch
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
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}"
309 click.echo(line)
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()
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}
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
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)
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)
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()
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)
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)
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 )
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)
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 )
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
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()
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 )
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)
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 )
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)
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)