Coverage for src/workstack/cli/tree.py: 99%

121 statements  

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

1"""Tree visualization for workstack. 

2 

3This module builds and renders tree structures showing worktrees and their 

4Graphite dependency relationships. All functions are pure (no I/O) except 

5for the entry point which loads data via WorkstackContext. 

6""" 

7 

8import json 

9from dataclasses import dataclass 

10from pathlib import Path 

11 

12import click 

13 

14from workstack.core.context import WorkstackContext 

15 

16 

17@dataclass(frozen=True) 

18class TreeNode: 

19 """A node in the workstack tree. 

20 

21 Represents a branch that has an active worktree, with its children 

22 (dependent branches that also have worktrees). 

23 

24 Attributes: 

25 branch_name: Git branch name (e.g., "fix-workstack-s") 

26 worktree_name: Worktree directory name (e.g., "root", "fix-plan") 

27 children: List of child TreeNode objects 

28 is_current: True if this worktree is the current working directory 

29 """ 

30 

31 branch_name: str 

32 worktree_name: str 

33 children: list["TreeNode"] 

34 is_current: bool 

35 

36 

37@dataclass(frozen=True) 

38class WorktreeMapping: 

39 """Mapping between branches and their worktrees. 

40 

41 Attributes: 

42 branch_to_worktree: Map of branch name -> worktree name 

43 worktree_to_path: Map of worktree name -> filesystem path 

44 current_worktree: Name of current worktree (None if not in a worktree) 

45 """ 

46 

47 branch_to_worktree: dict[str, str] 

48 worktree_to_path: dict[str, Path] 

49 current_worktree: str | None 

50 

51 

52@dataclass(frozen=True) 

53class BranchGraph: 

54 """Graph of branch relationships from Graphite cache. 

55 

56 Attributes: 

57 parent_of: Map of branch name -> parent branch name 

58 children_of: Map of branch name -> list of child branch names 

59 trunk_branches: List of trunk branch names (branches with no parent) 

60 """ 

61 

62 parent_of: dict[str, str] 

63 children_of: dict[str, list[str]] 

64 trunk_branches: list[str] 

65 

66 

67def build_workstack_tree( 

68 ctx: WorkstackContext, 

69 repo_root: Path, 

70) -> list[TreeNode]: 

71 """Build tree structure of ONLY branches with active worktrees. 

72 

73 This is the main entry point that orchestrates the tree building process: 

74 1. Get all worktrees and their branches from git 

75 2. Load Graphite cache for parent-child relationships (REQUIRED) 

76 3. Build branch graph from cache data 

77 4. Filter graph to ONLY branches that have worktrees 

78 5. Build tree starting from trunk branches 

79 6. Return list of root nodes (typically just "main") 

80 

81 Args: 

82 ctx: Workstack context with git operations 

83 repo_root: Path to repository root 

84 

85 Returns: 

86 List of root TreeNode objects (typically one for trunk) 

87 

88 Raises: 

89 SystemExit: If Graphite cache doesn't exist or can't be loaded 

90 """ 

91 # Step 1: Get worktrees 

92 worktree_mapping = _get_worktree_mapping(ctx, repo_root) 

93 

94 # Step 2: Load Graphite cache (REQUIRED - hard fail if missing) 

95 branch_graph = _load_graphite_branch_graph(ctx, repo_root) 

96 if branch_graph is None: 

97 click.echo( 

98 "Error: Graphite cache not found. The 'tree' command requires Graphite.\n" 

99 "Make sure Graphite is enabled: workstack config set use-graphite true", 

100 err=True, 

101 ) 

102 raise SystemExit(1) 

103 

104 # Step 3: Filter graph to only branches with worktrees 

105 active_branches = set(worktree_mapping.branch_to_worktree.keys()) 

106 filtered_graph = _filter_graph_to_active_branches(branch_graph, active_branches) 

107 

108 # Step 4: Build tree from filtered graph 

109 return _build_tree_from_graph(filtered_graph, worktree_mapping) 

110 

111 

112def _get_worktree_mapping( 

113 ctx: WorkstackContext, 

114 repo_root: Path, 

115) -> WorktreeMapping: 

116 """Get mapping of branches to worktrees. 

117 

118 Queries git for all worktrees and creates mappings between branches, 

119 worktree names, and filesystem paths. Detects the current worktree. 

120 

121 Args: 

122 ctx: Workstack context with git operations 

123 repo_root: Path to repository root 

124 

125 Returns: 

126 WorktreeMapping with all active worktrees and their branches 

127 """ 

128 worktrees = ctx.git_ops.list_worktrees(repo_root) 

129 current_path = Path.cwd().resolve() 

130 

131 branch_to_worktree: dict[str, str] = {} 

132 worktree_to_path: dict[str, Path] = {} 

133 current_worktree: str | None = None 

134 

135 for wt in worktrees: 

136 # Skip worktrees with detached HEAD 

137 if wt.branch is None: 

138 continue 

139 

140 # Determine worktree name 

141 if wt.path.resolve() == repo_root.resolve(): 

142 worktree_name = "root" 

143 else: 

144 # Use directory name from workstack's work directory 

145 worktree_name = wt.path.name 

146 

147 branch_to_worktree[wt.branch] = worktree_name 

148 worktree_to_path[worktree_name] = wt.path 

149 

150 # Check if current path is within this worktree (handles subdirectories) 

151 try: 

152 current_path.relative_to(wt.path.resolve()) 

153 current_worktree = worktree_name 

154 except ValueError: 

155 # Not within this worktree 

156 pass 

157 

158 return WorktreeMapping( 

159 branch_to_worktree=branch_to_worktree, 

160 worktree_to_path=worktree_to_path, 

161 current_worktree=current_worktree, 

162 ) 

163 

164 

165def _load_graphite_branch_graph( 

166 ctx: WorkstackContext, 

167 repo_root: Path, 

168) -> BranchGraph | None: 

169 """Load branch graph from Graphite cache. 

170 

171 Reads .git/.graphite_cache_persist JSON file and extracts parent-child 

172 relationships between branches. 

173 

174 Args: 

175 ctx: Workstack context with git operations 

176 repo_root: Path to repository root 

177 

178 Returns: 

179 BranchGraph if cache exists and is valid, None otherwise 

180 """ 

181 # Get git common directory (handles both main repos and worktrees) 

182 git_dir = ctx.git_ops.get_git_common_dir(repo_root) 

183 if git_dir is None: 

184 return None 

185 

186 # Check if Graphite cache file exists 

187 cache_file = git_dir / ".graphite_cache_persist" 

188 if not cache_file.exists(): 

189 return None 

190 

191 # Parse JSON 

192 cache_data = json.loads(cache_file.read_text(encoding="utf-8")) 

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

194 

195 # Build relationship maps 

196 parent_of: dict[str, str] = {} 

197 children_of: dict[str, list[str]] = {} 

198 trunk_branches: list[str] = [] 

199 

200 for branch_name, info in branches_data: 

201 parent = info.get("parentBranchName") 

202 children = info.get("children", []) 

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

204 

205 # Record parent relationship 

206 if parent: 

207 parent_of[branch_name] = parent 

208 

209 # Record children 

210 children_of[branch_name] = children 

211 

212 # Record trunk branches 

213 if is_trunk or parent is None: 

214 trunk_branches.append(branch_name) 

215 

216 return BranchGraph( 

217 parent_of=parent_of, 

218 children_of=children_of, 

219 trunk_branches=trunk_branches, 

220 ) 

221 

222 

223def _filter_graph_to_active_branches( 

224 graph: BranchGraph, 

225 active_branches: set[str], 

226) -> BranchGraph: 

227 """Filter branch graph to ONLY include branches with active worktrees. 

228 

229 This removes branches without worktrees from the graph while preserving 

230 the tree structure. Only active branches and their relationships are kept. 

231 

232 Args: 

233 graph: Full branch graph from Graphite cache 

234 active_branches: Set of branch names that have worktrees 

235 

236 Returns: 

237 Filtered BranchGraph containing only active branches 

238 

239 Example: 

240 Input graph: main -> [feature-a, feature-b -> feature-b-2] 

241 Active branches: {main, feature-a} 

242 Output graph: main -> [feature-a] 

243 (feature-b and feature-b-2 are removed) 

244 """ 

245 filtered_parent_of: dict[str, str] = {} 

246 filtered_children_of: dict[str, list[str]] = {} 

247 filtered_trunk: list[str] = [] 

248 

249 for branch in active_branches: 

250 # Keep parent relationship only if branch is active 

251 if branch in graph.parent_of: 

252 filtered_parent_of[branch] = graph.parent_of[branch] 

253 

254 # Keep only children that are also active 

255 if branch in graph.children_of: 

256 active_children = [ 

257 child for child in graph.children_of[branch] if child in active_branches 

258 ] 

259 if active_children: 

260 filtered_children_of[branch] = active_children 

261 

262 # Keep trunk status if active 

263 if branch in graph.trunk_branches: 

264 filtered_trunk.append(branch) 

265 

266 return BranchGraph( 

267 parent_of=filtered_parent_of, 

268 children_of=filtered_children_of, 

269 trunk_branches=filtered_trunk, 

270 ) 

271 

272 

273def _build_tree_from_graph( 

274 graph: BranchGraph, 

275 mapping: WorktreeMapping, 

276) -> list[TreeNode]: 

277 """Build TreeNode structure from filtered branch graph. 

278 

279 Recursively builds tree nodes starting from trunk branches, following 

280 parent-child relationships to create the full tree structure. 

281 

282 Args: 

283 graph: Filtered graph containing only active branches 

284 mapping: Worktree mapping for annotations 

285 

286 Returns: 

287 List of root TreeNode objects (one per trunk branch) 

288 """ 

289 

290 def build_node(branch: str) -> TreeNode: 

291 """Recursively build a tree node and its children.""" 

292 worktree_name = mapping.branch_to_worktree[branch] 

293 is_current = worktree_name == mapping.current_worktree 

294 

295 # Recursively build children 

296 children_branches = graph.children_of.get(branch, []) 

297 children = [build_node(child) for child in children_branches] 

298 

299 return TreeNode( 

300 branch_name=branch, 

301 worktree_name=worktree_name, 

302 children=children, 

303 is_current=is_current, 

304 ) 

305 

306 # Build tree starting from trunk branches 

307 return [build_node(trunk) for trunk in graph.trunk_branches] 

308 

309 

310def render_tree(roots: list[TreeNode]) -> str: 

311 """Render tree structure as ASCII art with Unicode box-drawing characters. 

312 

313 Uses Unicode box-drawing characters: 

314 - ├─ for middle children (branch continues below) 

315 - └─ for last child (no more branches below) 

316 - │ for continuation lines (shows vertical connection) 

317 

318 Args: 

319 roots: List of root TreeNode objects 

320 

321 Returns: 

322 Multi-line string with rendered tree 

323 

324 Example: 

325 Input: 

326 TreeNode("main", "root", [ 

327 TreeNode("feature-a", "feature-a", []), 

328 TreeNode("feature-b", "feature-b", []) 

329 ]) 

330 

331 Output: 

332 main [@root] 

333 ├─ feature-a [@feature-a] 

334 └─ feature-b [@feature-b] 

335 """ 

336 lines: list[str] = [] 

337 

338 def render_node(node: TreeNode, prefix: str, is_last: bool, is_root: bool) -> None: 

339 """Recursively render a node and its children. 

340 

341 Args: 

342 node: TreeNode to render 

343 prefix: Prefix string for indentation (contains │ and spaces) 

344 is_last: True if this is the last child of its parent 

345 is_root: True if this is a top-level root node 

346 """ 

347 # Format current line 

348 connector = "└─" if is_last else "├─" 

349 branch_text = _format_branch_name(node.branch_name, node.is_current) 

350 worktree_text = _format_worktree_annotation(node.worktree_name) 

351 

352 if is_root: 

353 # Root node: no connector 

354 line = f"{branch_text} {worktree_text}" 

355 else: 

356 # All other nodes get connectors 

357 line = f"{prefix}{connector} {branch_text} {worktree_text}" 

358 

359 lines.append(line) 

360 

361 # Render children 

362 if node.children: 

363 # Determine prefix for children 

364 # Build prefix based on whether this node is the last child of its parent 

365 if prefix: 

366 # Non-root node: extend existing prefix 

367 # Add vertical bar if more siblings below, space otherwise 

368 child_prefix = prefix + (" " if is_last else "│ ") 

369 else: 

370 # Root node's children: start with appropriate spacing 

371 # Use spaces if this is last root, vertical bar otherwise 

372 child_prefix = " " if is_last else "│ " 

373 

374 for i, child in enumerate(node.children): 

375 is_last_child = i == len(node.children) - 1 

376 render_node(child, child_prefix, is_last_child, is_root=False) 

377 

378 # Render all roots 

379 for i, root in enumerate(roots): 

380 is_last_root = i == len(roots) - 1 

381 render_node(root, "", is_last_root, is_root=True) 

382 

383 return "\n".join(lines) 

384 

385 

386def _format_branch_name(branch: str, is_current: bool) -> str: 

387 """Format branch name with color. 

388 

389 Args: 

390 branch: Branch name to format 

391 is_current: True if this is the current worktree 

392 

393 Returns: 

394 Colored branch name (bright green if current, normal otherwise) 

395 """ 

396 if is_current: 

397 return click.style(branch, fg="bright_green", bold=True) 

398 else: 

399 return branch 

400 

401 

402def _format_worktree_annotation(worktree_name: str) -> str: 

403 """Format worktree annotation [@name]. 

404 

405 Args: 

406 worktree_name: Name of the worktree 

407 

408 Returns: 

409 Dimmed annotation text 

410 """ 

411 return click.style(f"[@{worktree_name}]", fg="bright_black")