Coverage for src/workstack/cli/graphite.py: 92%

85 statements  

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

1"""Graphite integration for workstack. 

2 

3Graphite (https://graphite.dev) is a stacked git workflow tool that allows developers 

4to manage dependent branches in linear stacks. This module reads graphite's internal 

5metadata to display stack information for worktrees. 

6 

7For comprehensive gt mental model and command reference, see: 

8.agent/GT_MENTAL_MODEL.md 

9 

10## What is Graphite? 

11 

12Graphite organizes branches into "stacks" - linear chains of dependent branches built 

13on top of each other. For example: 

14 

15 main (trunk) 

16 └─ feature/phase-1 

17 └─ feature/phase-2 

18 └─ feature/phase-3 

19 

20Each branch in the stack depends on its parent, making it easy to work on multiple 

21related changes while keeping them in separate PRs. 

22 

23## Graphite Cache File 

24 

25Graphite maintains a persistent cache of branch relationships at: 

26`.git/.graphite_cache_persist` 

27 

28This is a JSON file with the following structure: 

29 

30```json 

31{ 

32 "branches": [ 

33 ["main", { 

34 "validationResult": "TRUNK", 

35 "children": ["feature/phase-1", "other-feature"] 

36 }], 

37 ["feature/phase-1", { 

38 "parentBranchName": "main", 

39 "children": ["feature/phase-2"] 

40 }], 

41 ["feature/phase-2", { 

42 "parentBranchName": "feature/phase-1", 

43 "children": [] 

44 }] 

45 ] 

46} 

47``` 

48 

49### Cache Structure Details 

50 

51- `branches`: Array of [branch_name, branch_metadata] tuples 

52- `validationResult: "TRUNK"`: Marks the trunk branch (main/master) 

53- `parentBranchName`: The parent branch in the stack (null for trunk) 

54- `children`: Array of child branch names that branch off from this branch 

55 

56### Linear Stacks vs Branching 

57 

58While graphite supports branching (a trunk can have multiple children), individual 

59stacks are typically linear. When displaying a stack for a specific branch, we show 

60only the linear chain that branch belongs to: 

61 

62- Traverse DOWN: From current branch to trunk (collecting ancestors) 

63- Traverse UP: From current branch, following the first child (collecting descendants) 

64 

65This gives us a linear chain even if the full graph has branches: 

66 

67``` 

68main 

69 ├─ feature-a (not shown if we're on feature-b-1) 

70 └─ feature-b-1 

71 └─ feature-b-2 (linear stack: main → feature-b-1 → feature-b-2) 

72``` 

73 

74## Git Worktrees and Common Directory 

75 

76Git worktrees share the same `.git` directory structure. The actual `.git` directory 

77may be in a different location than the worktree. We use `git rev-parse --git-common-dir` 

78to find the shared git directory where `.graphite_cache_persist` is stored. 

79""" 

80 

81import json 

82from pathlib import Path 

83from typing import Any, TypedDict 

84 

85from workstack.core.context import WorkstackContext 

86from workstack.core.gitops import WorktreeInfo 

87 

88 

89class BranchInfo(TypedDict): 

90 """Metadata for a single branch in the graphite stack. 

91 

92 Fields: 

93 parent: The parent branch name, or None if this is a trunk branch 

94 children: List of child branch names that branch off from this branch 

95 is_trunk: True if this is a trunk branch (main/master), False otherwise 

96 """ 

97 

98 parent: str | None 

99 children: list[str] 

100 is_trunk: bool 

101 

102 

103def _load_graphite_cache(cache_file: Path) -> dict[str, Any]: 

104 """Load and parse graphite cache file. 

105 

106 Args: 

107 cache_file: Path to .graphite_cache_persist file 

108 

109 Returns: 

110 Parsed cache data dictionary 

111 

112 Raises: 

113 json.JSONDecodeError: If cache file is corrupted (fail-fast) 

114 FileNotFoundError: If cache file doesn't exist 

115 """ 

116 cache_data: dict[str, Any] = json.loads(cache_file.read_text(encoding="utf-8")) 

117 return cache_data 

118 

119 

120def _load_branch_info(ctx: WorkstackContext, repo_root: Path) -> dict[str, BranchInfo] | None: 

121 """Load and parse branch info from Graphite cache. 

122 

123 Args: 

124 ctx: Workstack context with git operations 

125 repo_root: Path to the repository root (or worktree root) 

126 

127 Returns: 

128 Dictionary mapping branch name to BranchInfo, or None if: 

129 - Git command fails 

130 - Graphite cache file doesn't exist 

131 

132 This helper consolidates the common cache loading logic used by 

133 get_parent_branch, get_child_branches, and get_branch_stack. 

134 """ 

135 git_dir = ctx.git_ops.get_git_common_dir(repo_root) 

136 if git_dir is None: 

137 return None 

138 

139 cache_file = git_dir / ".graphite_cache_persist" 

140 if not cache_file.exists(): 

141 return None 

142 

143 cache_data = _load_graphite_cache(cache_file) 

144 branches_data: list[Any] = cache_data.get("branches", []) 

145 

146 # Build parent-child relationship graph 

147 branch_info: dict[str, BranchInfo] = {} 

148 for branch_name, info in branches_data: 

149 parent: str | None = info.get("parentBranchName") 

150 children: list[str] = info.get("children", []) 

151 is_trunk: bool = info.get("validationResult") == "TRUNK" 

152 branch_info[branch_name] = BranchInfo(parent=parent, children=children, is_trunk=is_trunk) 

153 

154 return branch_info 

155 

156 

157def get_branch_stack(ctx: WorkstackContext, repo_root: Path, branch: str) -> list[str] | None: 

158 """Get the linear graphite stack for a given branch. 

159 

160 This function reads graphite's cache file and builds the linear chain of branches 

161 that the given branch belongs to. The chain includes: 

162 - All ancestor branches from current up to trunk 

163 - All descendant branches from current down to the leaf 

164 

165 Args: 

166 ctx: Workstack context with git operations 

167 repo_root: Path to the repository root (or worktree root) 

168 branch: Name of the branch to get the stack for 

169 

170 Returns: 

171 List of branch names in the stack, ordered from trunk to leaf 

172 (e.g., ["main", "feature-1", "feature-2", "feature-3"]). 

173 Returns None if: 

174 - Graphite cache file doesn't exist 

175 - Git command fails 

176 - Branch is not tracked by graphite 

177 

178 Algorithm: 

179 1. Find the common git directory using ctx.git_ops.get_git_common_dir() 

180 (This handles both main repos and worktrees correctly) 

181 

182 2. Load and parse `.graphite_cache_persist` JSON file 

183 

184 3. Build a parent-child relationship graph from the cache data 

185 

186 4. Traverse DOWN from current branch to trunk, collecting ancestors: 

187 current → parent → grandparent → ... → trunk 

188 

189 5. Traverse UP from current branch, following the first child only: 

190 current → child → grandchild → ... → leaf 

191 

192 6. Combine into linear chain: [trunk, ..., parent, current, child, ..., leaf] 

193 

194 Note on Linear vs Branching: 

195 While graphite's cache can represent a tree structure (trunk with multiple 

196 children), this function returns only the LINEAR chain that the given branch 

197 belongs to. When a branch has multiple children, we follow only the first one. 

198 

199 This means if you have: 

200 main 

201 ├─ feature-a 

202 └─ feature-b-1 

203 └─ feature-b-2 

204 

205 And you call get_branch_stack(ctx, root, "feature-b-2"), you get: 

206 ["main", "feature-b-1", "feature-b-2"] 

207 

208 Not: ["main", "feature-a", "feature-b-1", "feature-b-2"] 

209 

210 Future Enhancement: Could use smarter logic to follow the child that's actually 

211 in the same stack as the target branch, rather than always taking the first child. 

212 

213 Example: 

214 >>> stack = get_branch_stack(ctx, Path("/repo"), "feature/phase-2") 

215 >>> print(stack) 

216 ["main", "feature/phase-1", "feature/phase-2", "feature/phase-3"] 

217 """ 

218 branch_info = _load_branch_info(ctx, repo_root) 

219 if branch_info is None: 

220 return None 

221 

222 # Check if the requested branch exists in graphite's cache 

223 if branch not in branch_info: 

224 return None 

225 

226 # Step 5: Traverse DOWN the stack to collect ancestors (current → parent → ... → trunk) 

227 # In graphite terminology, "down" means towards the trunk/base 

228 ancestors: list[str] = [] 

229 current = branch 

230 while current in branch_info: 

231 ancestors.append(current) 

232 parent = branch_info[current]["parent"] 

233 if parent is None or parent not in branch_info: 

234 # Reached trunk (no parent) or parent not in cache 

235 break 

236 current = parent 

237 

238 # Reverse to get [trunk, ..., grandparent, parent, current] 

239 ancestors.reverse() 

240 

241 # Step 6: Traverse UP the stack to collect descendants (current → child → ... → leaf) 

242 # In graphite terminology, "up" means away from trunk towards the tip of the stack 

243 # Only follow the first child to maintain a linear chain 

244 descendants: list[str] = [] 

245 current = branch 

246 while True: 

247 children = branch_info[current]["children"] 

248 if not children: 

249 # Reached a leaf node (no children) 

250 break 

251 # For linear stacks, follow the first child 

252 # NOTE: If there are multiple children (branching), we only follow the first one 

253 # This keeps the stack linear, but may not show all related branches 

254 first_child = children[0] 

255 if first_child not in branch_info: 

256 # Child exists in metadata but not in cache (shouldn't happen normally) 

257 break 

258 descendants.append(first_child) 

259 current = first_child 

260 

261 # Step 7: Combine ancestors and descendants 

262 # ancestors already includes the current branch, so we just append descendants 

263 return ancestors + descendants 

264 

265 

266def get_parent_branch(ctx: WorkstackContext, repo_root: Path, branch: str) -> str | None: 

267 """Get the parent branch of a given branch in the Graphite stack. 

268 

269 Args: 

270 ctx: Workstack context with git operations 

271 repo_root: Path to the repository root (or worktree root) 

272 branch: Name of the branch to get the parent for 

273 

274 Returns: 

275 The parent branch name, or None if: 

276 - Graphite cache file doesn't exist 

277 - Git command fails 

278 - Branch is not tracked by graphite 

279 - Branch is at trunk (no parent) 

280 

281 Example: 

282 >>> parent = get_parent_branch(ctx, Path("/repo"), "feature/phase-2") 

283 >>> print(parent) 

284 "feature/phase-1" 

285 """ 

286 branch_info = _load_branch_info(ctx, repo_root) 

287 if branch_info is None: 

288 return None 

289 

290 # Check if the requested branch exists in graphite's cache 

291 if branch not in branch_info: 

292 return None 

293 

294 return branch_info[branch]["parent"] 

295 

296 

297def get_child_branches(ctx: WorkstackContext, repo_root: Path, branch: str) -> list[str]: 

298 """Get the child branches of a given branch in the Graphite stack. 

299 

300 Args: 

301 ctx: Workstack context with git operations 

302 repo_root: Path to the repository root (or worktree root) 

303 branch: Name of the branch to get children for 

304 

305 Returns: 

306 List of child branch names, or empty list if: 

307 - Graphite cache file doesn't exist 

308 - Git command fails 

309 - Branch is not tracked by graphite 

310 - Branch has no children (at tip of stack) 

311 

312 Example: 

313 >>> children = get_child_branches(ctx, Path("/repo"), "feature/phase-1") 

314 >>> print(children) 

315 ["feature/phase-2", "feature/phase-2-alt"] 

316 """ 

317 branch_info = _load_branch_info(ctx, repo_root) 

318 if branch_info is None: 

319 return [] 

320 

321 # Check if the requested branch exists in graphite's cache 

322 if branch not in branch_info: 

323 return [] 

324 

325 return branch_info[branch]["children"] 

326 

327 

328def find_worktrees_containing_branch( 

329 ctx: WorkstackContext, 

330 repo_root: Path, 

331 worktrees: list[WorktreeInfo], 

332 target_branch: str, 

333) -> list[WorktreeInfo]: 

334 """Find all worktrees that have target_branch in their Graphite stack. 

335 

336 Args: 

337 ctx: Workstack context with git operations 

338 repo_root: Path to the repository root 

339 worktrees: List of all worktrees from list_worktrees() 

340 target_branch: Branch name to search for 

341 

342 Returns: 

343 List of WorktreeInfo objects whose stacks contain target_branch. 

344 Empty list if no worktrees contain the branch. 

345 

346 Algorithm: 

347 1. For each worktree: 

348 a. Get the worktree's checked-out branch 

349 b. Skip worktrees with detached HEAD (branch=None) 

350 c. Call get_branch_stack() to get the full stack for that branch 

351 d. Check if target_branch is in that stack 

352 e. If yes, add worktree to results 

353 2. Return all matching worktrees 

354 

355 Example: 

356 >>> worktrees = ctx.git_ops.list_worktrees(repo.root) 

357 >>> matching = find_worktrees_containing_branch(ctx, repo.root, worktrees, "feature-2") 

358 >>> print([wt.path for wt in matching]) 

359 [Path("/path/to/work/feature-work")] 

360 """ 

361 matching_worktrees: list[WorktreeInfo] = [] 

362 

363 for wt in worktrees: 

364 # Skip worktrees with detached HEAD 

365 if wt.branch is None: 

366 continue 

367 

368 # Get the stack for this worktree's branch 

369 stack = get_branch_stack(ctx, repo_root, wt.branch) 

370 if stack is None: 

371 # Graphite cache doesn't exist or branch not tracked 

372 continue 

373 

374 # Check if target_branch is in this stack 

375 if target_branch in stack: 

376 matching_worktrees.append(wt) 

377 

378 return matching_worktrees 

379 

380 

381def find_worktree_for_branch(worktrees: list[WorktreeInfo], branch: str) -> Path | None: 

382 """Find the worktree path for a given branch. 

383 

384 Args: 

385 worktrees: List of WorktreeInfo from ctx.git_ops.list_worktrees() 

386 branch: Branch name to search for 

387 

388 Returns: 

389 Path to the worktree if branch is checked out in a worktree, 

390 None if no worktree exists for the branch. 

391 

392 Note: 

393 This function expects WorktreeInfo objects with 'path' and 'branch' attributes. 

394 

395 Example: 

396 >>> worktrees = ctx.git_ops.list_worktrees(repo.root) 

397 >>> wt_path = find_worktree_for_branch(worktrees, "feature-1") 

398 >>> print(wt_path) 

399 Path("/path/to/work/feature-1") 

400 """ 

401 for wt in worktrees: 

402 if hasattr(wt, "branch") and wt.branch == branch: 

403 return wt.path 

404 return None