Coverage for src/workstack/cli/commands/gt.py: 46%

67 statements  

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

1"""Graphite integration commands for workstack. 

2 

3Provides machine-readable access to Graphite metadata for scripting and automation. 

4""" 

5 

6import json 

7from dataclasses import asdict 

8from pathlib import Path 

9 

10import click 

11 

12from workstack.cli.core import discover_repo_context 

13from workstack.core.branch_metadata import BranchMetadata 

14from workstack.core.context import WorkstackContext 

15from workstack.core.gitops import GitOps 

16 

17 

18@click.group("graphite") 

19@click.pass_obj 

20def graphite_group(ctx: WorkstackContext) -> None: 

21 """Graphite integration commands for machine-readable metadata. 

22 

23 Requires use-graphite enabled. 

24 """ 

25 pass 

26 

27 

28@graphite_group.command("branches") 

29@click.option( 

30 "--format", 

31 type=click.Choice(["text", "json", "tree"]), 

32 default="text", 

33 help="Output format (text, json, or tree)", 

34) 

35@click.option( 

36 "--stack", 

37 type=str, 

38 default=None, 

39 help="Show only this branch and its descendants (tree format only)", 

40) 

41@click.pass_obj 

42def graphite_branches_cmd(ctx: WorkstackContext, format: str, stack: str | None) -> None: 

43 """List all Graphite-tracked branches with machine-readable metadata. 

44 

45 By default, outputs a simple list of branch names (one per line). 

46 Use --format json for structured output with full metadata. 

47 Use --format tree for hierarchical tree visualization. 

48 

49 Examples: 

50 $ workstack graphite branches 

51 main 

52 feature-1 

53 feature-2 

54 

55 $ workstack graphite branches --format json 

56 { 

57 "branches": [ 

58 { 

59 "name": "main", 

60 "parent": null, 

61 "children": ["feature-1"], 

62 "is_trunk": true, 

63 "commit_sha": "abc123..." 

64 } 

65 ] 

66 } 

67 

68 $ workstack graphite branches --format tree 

69 main (abc123f) "Initial commit" 

70 ├─ feature-a (def456g) "Add user authentication" 

71 │ └─ feature-a-tests (789hij0) "Add tests for auth" 

72 └─ feature-b (klm123n) "Refactor database layer" 

73 

74 $ workstack graphite branches --format tree --stack feature-a 

75 feature-a (def456g) "Add user authentication" 

76 └─ feature-a-tests (789hij0) "Add tests for auth" 

77 

78 Requires: 

79 - Graphite enabled (use_graphite config) 

80 - Valid .git/.graphite_cache_persist file 

81 """ 

82 # Check if graphite is enabled 

83 if not ctx.global_config_ops.get_use_graphite(): 

84 click.echo( 

85 "Error: Graphite not enabled. Run 'workstack config set use_graphite true'", 

86 err=True, 

87 ) 

88 raise SystemExit(1) 

89 

90 # Check if --stack is used without tree format 

91 if stack is not None and format != "tree": 

92 click.echo( 

93 "Error: --stack option can only be used with --format tree", 

94 err=True, 

95 ) 

96 raise SystemExit(1) 

97 

98 # Discover repository 

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

100 

101 # Get branches from GraphiteOps 

102 branches_dict = ctx.graphite_ops.get_all_branches(ctx.git_ops, repo.root) 

103 

104 if format == "json": 

105 # Convert to list of dicts for JSON output 

106 branches_list = [asdict(metadata) for metadata in branches_dict.values()] 

107 output = {"branches": branches_list} 

108 click.echo(json.dumps(output, indent=2)) 

109 elif format == "tree": 

110 # Tree format: hierarchical display with commit info 

111 output = _format_branches_as_tree(branches_dict, ctx.git_ops, repo.root, root_branch=stack) 

112 click.echo(output) 

113 else: 

114 # Text format: simple list of branch names 

115 for branch_name in sorted(branches_dict.keys()): 

116 click.echo(branch_name) 

117 

118 

119def _format_branches_as_tree( 

120 branches: dict[str, BranchMetadata], 

121 git_ops: GitOps, 

122 repo_root: Path, 

123 *, 

124 root_branch: str | None, 

125) -> str: 

126 """Format branches as a hierarchical tree. 

127 

128 Args: 

129 branches: Mapping of branch name to metadata 

130 git_ops: GitOps instance for retrieving commit messages 

131 repo_root: Repository root path 

132 root_branch: Optional branch to use as root (shows only this branch and descendants) 

133 

134 Returns: 

135 Multi-line string with tree visualization 

136 """ 

137 # Determine which branches to show as roots 

138 if root_branch is not None: 

139 # Filter to specific branch and its descendants 

140 if root_branch not in branches: 

141 return f"Error: Branch '{root_branch}' not found" 

142 roots = [root_branch] 

143 else: 

144 # Show all trunk branches (branches with no parent) 

145 roots = [name for name, meta in branches.items() if meta.is_trunk] 

146 

147 if not roots: 

148 return "No branches found" 

149 

150 # Build tree lines 

151 lines: list[str] = [] 

152 for i, root in enumerate(roots): 

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

154 _format_branch_recursive( 

155 branch_name=root, 

156 branches=branches, 

157 git_ops=git_ops, 

158 repo_root=repo_root, 

159 lines=lines, 

160 prefix="", 

161 is_last=is_last_root, 

162 is_root=True, 

163 ) 

164 

165 return "\n".join(lines) 

166 

167 

168def _format_branch_recursive( 

169 branch_name: str, 

170 branches: dict[str, BranchMetadata], 

171 git_ops: GitOps, 

172 repo_root: Path, 

173 lines: list[str], 

174 prefix: str, 

175 is_last: bool, 

176 is_root: bool, 

177) -> None: 

178 """Recursively format a branch and its children. 

179 

180 Args: 

181 branch_name: Name of current branch to format 

182 branches: All branches metadata 

183 git_ops: GitOps instance for retrieving commit messages 

184 repo_root: Repository root path 

185 lines: List to append formatted lines to 

186 prefix: Prefix string for indentation 

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

188 is_root: True if this is a root node 

189 """ 

190 if branch_name not in branches: 

191 return 

192 

193 metadata = branches[branch_name] 

194 

195 # Get commit info 

196 short_sha = metadata.commit_sha[:7] if metadata.commit_sha else "unknown" 

197 commit_message = ( 

198 git_ops.get_commit_message(repo_root, metadata.commit_sha) or "No commit message" 

199 ) 

200 

201 # Format current line 

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

203 branch_info = f'{branch_name} ({short_sha}) "{commit_message}"' 

204 

205 if is_root: 

206 # Root node: no connector 

207 line = branch_info 

208 else: 

209 # All other nodes get connectors 

210 line = f"{prefix}{connector} {branch_info}" 

211 

212 lines.append(line) 

213 

214 # Process children 

215 children = metadata.children 

216 if children: 

217 # Determine prefix for children 

218 if prefix: 

219 # Non-root node: extend existing prefix 

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

221 else: 

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

223 child_prefix = " " if is_last else "│ " 

224 

225 for i, child in enumerate(children): 

226 is_last_child = i == len(children) - 1 

227 _format_branch_recursive( 

228 branch_name=child, 

229 branches=branches, 

230 git_ops=git_ops, 

231 repo_root=repo_root, 

232 lines=lines, 

233 prefix=child_prefix, 

234 is_last=is_last_child, 

235 is_root=False, 

236 )