canopy-cli 3.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. canopy/__init__.py +2 -0
  2. canopy/actions/__init__.py +32 -0
  3. canopy/actions/aliases.py +421 -0
  4. canopy/actions/augments.py +55 -0
  5. canopy/actions/bootstrap.py +249 -0
  6. canopy/actions/bot_resolutions.py +123 -0
  7. canopy/actions/bot_status.py +133 -0
  8. canopy/actions/commit.py +511 -0
  9. canopy/actions/conflicts.py +314 -0
  10. canopy/actions/doctor.py +1459 -0
  11. canopy/actions/draft_replies.py +185 -0
  12. canopy/actions/drift.py +241 -0
  13. canopy/actions/errors.py +115 -0
  14. canopy/actions/evacuate.py +192 -0
  15. canopy/actions/feature_state.py +607 -0
  16. canopy/actions/historian.py +612 -0
  17. canopy/actions/ide_workspace.py +49 -0
  18. canopy/actions/last_visit.py +83 -0
  19. canopy/actions/migrate_slots.py +313 -0
  20. canopy/actions/preflight_state.py +97 -0
  21. canopy/actions/push.py +199 -0
  22. canopy/actions/reads.py +304 -0
  23. canopy/actions/resume.py +582 -0
  24. canopy/actions/review_filter.py +135 -0
  25. canopy/actions/ship.py +399 -0
  26. canopy/actions/slot_details.py +208 -0
  27. canopy/actions/slot_load.py +383 -0
  28. canopy/actions/slots.py +221 -0
  29. canopy/actions/stash.py +230 -0
  30. canopy/actions/switch.py +775 -0
  31. canopy/actions/switch_preflight.py +192 -0
  32. canopy/actions/thread_actions.py +88 -0
  33. canopy/actions/thread_resolutions.py +101 -0
  34. canopy/actions/triage.py +286 -0
  35. canopy/agent/__init__.py +5 -0
  36. canopy/agent/runner.py +129 -0
  37. canopy/agent_setup/__init__.py +264 -0
  38. canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
  39. canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
  40. canopy/cli/__init__.py +0 -0
  41. canopy/cli/main.py +4152 -0
  42. canopy/cli/render.py +98 -0
  43. canopy/cli/ui.py +150 -0
  44. canopy/features/__init__.py +2 -0
  45. canopy/features/coordinator.py +1256 -0
  46. canopy/git/__init__.py +0 -0
  47. canopy/git/hooks.py +173 -0
  48. canopy/git/multi.py +435 -0
  49. canopy/git/repo.py +859 -0
  50. canopy/git/templates/post-checkout.py +67 -0
  51. canopy/graph/__init__.py +0 -0
  52. canopy/integrations/__init__.py +0 -0
  53. canopy/integrations/github.py +983 -0
  54. canopy/integrations/linear.py +307 -0
  55. canopy/integrations/precommit.py +239 -0
  56. canopy/mcp/__init__.py +0 -0
  57. canopy/mcp/client.py +329 -0
  58. canopy/mcp/server.py +1797 -0
  59. canopy/providers/__init__.py +105 -0
  60. canopy/providers/github_issues.py +289 -0
  61. canopy/providers/linear.py +341 -0
  62. canopy/providers/types.py +149 -0
  63. canopy/workspace/__init__.py +4 -0
  64. canopy/workspace/config.py +378 -0
  65. canopy/workspace/context.py +224 -0
  66. canopy/workspace/discovery.py +197 -0
  67. canopy/workspace/workspace.py +173 -0
  68. canopy_cli-3.1.0.dist-info/METADATA +282 -0
  69. canopy_cli-3.1.0.dist-info/RECORD +71 -0
  70. canopy_cli-3.1.0.dist-info/WHEEL +4 -0
  71. canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
canopy/git/__init__.py ADDED
File without changes
canopy/git/hooks.py ADDED
@@ -0,0 +1,173 @@
1
+ """Install, uninstall, and read canopy git hooks.
2
+
3
+ Canopy installs a post-checkout hook in every managed repo and worktree so
4
+ it has real-time ground truth of HEAD per repo without polling. The hook
5
+ writes to .canopy/state/heads.json under the workspace root.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shutil
11
+ import stat
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ _HOOK_NAME = "post-checkout"
17
+ _CHAINED_NAME = "post-checkout.canopy-chained"
18
+ _MARKER = "__CANOPY_HOOK_MARKER__"
19
+ _TEMPLATE_PATH = Path(__file__).parent / "templates" / "post-checkout.py"
20
+
21
+
22
+ @dataclass
23
+ class InstallResult:
24
+ repo: str
25
+ path: str
26
+ action: str # "installed", "reinstalled", "chained_existing"
27
+
28
+
29
+ def install_hook(repo_path: Path, repo_name: str, workspace_root: Path) -> InstallResult:
30
+ """Install the canopy post-checkout hook in a repo or linked worktree.
31
+
32
+ If a user hook already exists, it's moved to ``post-checkout.canopy-chained``
33
+ and invoked after the canopy hook runs. If a previous canopy hook is
34
+ present, it's replaced.
35
+ """
36
+ hooks_dir = resolve_hooks_dir(repo_path)
37
+ hooks_dir.mkdir(parents=True, exist_ok=True)
38
+ hook_path = hooks_dir / _HOOK_NAME
39
+
40
+ template = _TEMPLATE_PATH.read_text()
41
+ rendered = template.replace(
42
+ '"__CANOPY_REPO__"', json.dumps(repo_name),
43
+ ).replace(
44
+ '"__CANOPY_WORKSPACE_ROOT__"', json.dumps(str(workspace_root.resolve())),
45
+ )
46
+
47
+ action = "installed"
48
+ if hook_path.exists():
49
+ existing = hook_path.read_text()
50
+ if _MARKER in existing:
51
+ action = "reinstalled"
52
+ else:
53
+ chained = hooks_dir / _CHAINED_NAME
54
+ if chained.exists():
55
+ chained.unlink()
56
+ shutil.move(str(hook_path), str(chained))
57
+ _make_executable(chained)
58
+ action = "chained_existing"
59
+
60
+ hook_path.write_text(rendered)
61
+ _make_executable(hook_path)
62
+
63
+ return InstallResult(repo=repo_name, path=str(hook_path), action=action)
64
+
65
+
66
+ @dataclass
67
+ class UninstallResult:
68
+ repo: str
69
+ action: str # "uninstalled", "uninstalled_and_restored", "skipped", "not_installed"
70
+ reason: str | None = None
71
+
72
+
73
+ def uninstall_hook(repo_path: Path, repo_name: str) -> UninstallResult:
74
+ """Remove the canopy hook; restore any chained user hook."""
75
+ hooks_dir = resolve_hooks_dir(repo_path)
76
+ hook_path = hooks_dir / _HOOK_NAME
77
+ chained = hooks_dir / _CHAINED_NAME
78
+
79
+ if not hook_path.exists():
80
+ return UninstallResult(repo=repo_name, action="not_installed")
81
+
82
+ if _MARKER not in hook_path.read_text():
83
+ return UninstallResult(
84
+ repo=repo_name, action="skipped",
85
+ reason="hook exists but is not a canopy hook",
86
+ )
87
+
88
+ hook_path.unlink()
89
+ if chained.exists():
90
+ shutil.move(str(chained), str(hook_path))
91
+ _make_executable(hook_path)
92
+ return UninstallResult(repo=repo_name, action="uninstalled_and_restored")
93
+ return UninstallResult(repo=repo_name, action="uninstalled")
94
+
95
+
96
+ def hook_status(repo_path: Path) -> dict:
97
+ """Inspect current hook state for a repo."""
98
+ hooks_dir = resolve_hooks_dir(repo_path)
99
+ hook_path = hooks_dir / _HOOK_NAME
100
+ chained = hooks_dir / _CHAINED_NAME
101
+
102
+ if not hook_path.exists():
103
+ return {"installed": False, "hook_path": str(hook_path)}
104
+
105
+ content = hook_path.read_text()
106
+ return {
107
+ "installed": _MARKER in content,
108
+ "foreign_hook": _MARKER not in content,
109
+ "chained_present": chained.exists(),
110
+ "hook_path": str(hook_path),
111
+ }
112
+
113
+
114
+ def read_heads_state(workspace_root: Path) -> dict:
115
+ """Return ``{repo_name: {branch, sha, prev_sha, ts}}`` from the state file."""
116
+ path = workspace_root / ".canopy" / "state" / "heads.json"
117
+ try:
118
+ return json.loads(path.read_text())
119
+ except (FileNotFoundError, json.JSONDecodeError):
120
+ return {}
121
+
122
+
123
+ def resolve_hooks_dir(repo_path: Path) -> Path:
124
+ """Resolve the ``hooks`` dir git actually uses for this repo / worktree.
125
+
126
+ Resolution order:
127
+ 1. ``core.hooksPath`` if set in the repo's config (e.g., Husky uses
128
+ ``.husky/_``). Relative paths resolve against the repo root.
129
+ Without this we'd install a hook git would never run.
130
+ 2. For a linked worktree, follow ``.git`` (file) → worktree gitdir →
131
+ ``commondir`` → main ``.git/hooks``. Hooks are shared across all
132
+ worktrees of a repo by default.
133
+ 3. Otherwise ``<repo>/.git/hooks``.
134
+ """
135
+ custom = _get_core_hooks_path(repo_path)
136
+ if custom is not None:
137
+ return custom
138
+
139
+ git_path = repo_path / ".git"
140
+ if git_path.is_file():
141
+ contents = git_path.read_text().strip()
142
+ if contents.startswith("gitdir:"):
143
+ worktree_gitdir = Path(contents.split(":", 1)[1].strip())
144
+ if not worktree_gitdir.is_absolute():
145
+ worktree_gitdir = (repo_path / worktree_gitdir).resolve()
146
+ commondir_file = worktree_gitdir / "commondir"
147
+ if commondir_file.is_file():
148
+ common = Path(commondir_file.read_text().strip())
149
+ if not common.is_absolute():
150
+ common = (worktree_gitdir / common).resolve()
151
+ return common / "hooks"
152
+ return worktree_gitdir / "hooks"
153
+ return git_path / "hooks"
154
+
155
+
156
+ def _get_core_hooks_path(repo_path: Path) -> Path | None:
157
+ """Read ``core.hooksPath`` from the repo's config. Returns None if unset."""
158
+ result = subprocess.run(
159
+ ["git", "config", "--get", "core.hooksPath"],
160
+ cwd=repo_path, capture_output=True, text=True, check=False,
161
+ )
162
+ value = result.stdout.strip()
163
+ if result.returncode != 0 or not value:
164
+ return None
165
+ p = Path(value)
166
+ if not p.is_absolute():
167
+ p = (repo_path / p).resolve()
168
+ return p
169
+
170
+
171
+ def _make_executable(path: Path) -> None:
172
+ mode = path.stat().st_mode
173
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
canopy/git/multi.py ADDED
@@ -0,0 +1,435 @@
1
+ """
2
+ Cross-repo Git operations.
3
+
4
+ Calls git.repo functions across multiple repos in a workspace.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from ..workspace.workspace import Workspace, RepoState
12
+ from . import repo as git
13
+
14
+
15
+ def workspace_status(workspace: Workspace) -> list[RepoState]:
16
+ """Refresh and return enriched state for all repos."""
17
+ workspace.refresh()
18
+ return workspace.repos
19
+
20
+
21
+ def create_branch_all(
22
+ workspace: Workspace,
23
+ branch: str,
24
+ repos: list[str] | None = None,
25
+ ) -> dict[str, bool | str]:
26
+ """Create a branch in all (or specified) repos.
27
+
28
+ Returns:
29
+ {repo_name: True} on success, {repo_name: error_message} on failure.
30
+ """
31
+ results: dict[str, bool | str] = {}
32
+ targets = _filter_repos(workspace, repos)
33
+
34
+ for state in targets:
35
+ try:
36
+ if git.branch_exists(state.abs_path, branch):
37
+ results[state.config.name] = True # already exists
38
+ else:
39
+ git.create_branch(state.abs_path, branch)
40
+ results[state.config.name] = True
41
+ except git.GitError as e:
42
+ results[state.config.name] = str(e)
43
+
44
+ return results
45
+
46
+
47
+ def checkout_all(
48
+ workspace: Workspace,
49
+ branch: str,
50
+ repos: list[str] | None = None,
51
+ ) -> dict[str, bool | str]:
52
+ """Checkout a branch in all (or specified) repos.
53
+
54
+ Returns:
55
+ {repo_name: True} on success, {repo_name: error_message} on failure.
56
+ """
57
+ results: dict[str, bool | str] = {}
58
+ targets = _filter_repos(workspace, repos)
59
+
60
+ for state in targets:
61
+ try:
62
+ if not git.branch_exists(state.abs_path, branch):
63
+ results[state.config.name] = f"branch '{branch}' does not exist"
64
+ continue
65
+ git.checkout(state.abs_path, branch)
66
+ results[state.config.name] = True
67
+ except git.GitError as e:
68
+ results[state.config.name] = str(e)
69
+
70
+ return results
71
+
72
+
73
+ def cross_repo_diff(workspace: Workspace, feature: str) -> dict:
74
+ """Aggregate diff across all repos for a feature branch vs default.
75
+
76
+ Returns:
77
+ {
78
+ repo_name: {
79
+ files_changed, insertions, deletions,
80
+ changed_files, has_branch
81
+ }
82
+ }
83
+ """
84
+ result = {}
85
+
86
+ for state in workspace.repos:
87
+ base = state.config.default_branch
88
+ repo_name = state.config.name
89
+
90
+ if not git.branch_exists(state.abs_path, feature):
91
+ result[repo_name] = {
92
+ "has_branch": False,
93
+ "files_changed": 0,
94
+ "insertions": 0,
95
+ "deletions": 0,
96
+ "changed_files": [],
97
+ }
98
+ continue
99
+
100
+ try:
101
+ stat = git.diff_stat(state.abs_path, base, feature)
102
+ files = git.changed_files(state.abs_path, feature, base)
103
+ result[repo_name] = {
104
+ "has_branch": True,
105
+ "files_changed": stat["files_changed"],
106
+ "insertions": stat["insertions"],
107
+ "deletions": stat["deletions"],
108
+ "changed_files": files,
109
+ }
110
+ except git.GitError as e:
111
+ result[repo_name] = {
112
+ "has_branch": True,
113
+ "error": str(e),
114
+ "files_changed": 0,
115
+ "insertions": 0,
116
+ "deletions": 0,
117
+ "changed_files": [],
118
+ }
119
+
120
+ return result
121
+
122
+
123
+ def find_type_overlaps(workspace: Workspace, feature: str) -> list[dict]:
124
+ """Find files with similar names changed across multiple repos.
125
+
126
+ Looks for potential shared type/interface conflicts where, e.g.,
127
+ api/src/models.py and ui/src/types.ts both change user-related types.
128
+
129
+ Returns:
130
+ [{file_pattern, repos: [repo_names], files: [{repo, path}]}]
131
+ """
132
+ # Collect changed file basenames per repo
133
+ file_map: dict[str, list[dict]] = {}
134
+
135
+ for state in workspace.repos:
136
+ base = state.config.default_branch
137
+ if not git.branch_exists(state.abs_path, feature):
138
+ continue
139
+ try:
140
+ files = git.changed_files(state.abs_path, feature, base)
141
+ except git.GitError:
142
+ continue
143
+
144
+ for f in files:
145
+ basename = os.path.splitext(os.path.basename(f))[0].lower()
146
+ # Normalize common type-related names
147
+ file_map.setdefault(basename, []).append({
148
+ "repo": state.config.name,
149
+ "path": f,
150
+ })
151
+
152
+ # Filter to basenames that appear in 2+ repos
153
+ overlaps = []
154
+ for basename, entries in file_map.items():
155
+ repos = {e["repo"] for e in entries}
156
+ if len(repos) >= 2:
157
+ overlaps.append({
158
+ "file_pattern": basename,
159
+ "repos": sorted(repos),
160
+ "files": entries,
161
+ })
162
+
163
+ return overlaps
164
+
165
+
166
+ def sync_all(
167
+ workspace: Workspace,
168
+ strategy: str = "rebase",
169
+ ) -> dict[str, str]:
170
+ """Pull default branch and rebase/merge feature branches.
171
+
172
+ Returns:
173
+ {repo_name: "ok" | error_message}
174
+ """
175
+ results: dict[str, str] = {}
176
+
177
+ for state in workspace.repos:
178
+ try:
179
+ base = state.config.default_branch
180
+ current = git.current_branch(state.abs_path)
181
+
182
+ # First, update the default branch
183
+ git.checkout(state.abs_path, base)
184
+ git.pull_rebase(state.abs_path)
185
+
186
+ # If we were on a feature branch, rebase it
187
+ if current != base and current != "(detached)":
188
+ git.checkout(state.abs_path, current)
189
+ if strategy == "rebase":
190
+ git._run(["rebase", base], cwd=state.abs_path)
191
+ else:
192
+ git._run(["merge", base], cwd=state.abs_path)
193
+
194
+ results[state.config.name] = "ok"
195
+ except git.GitError as e:
196
+ results[state.config.name] = str(e)
197
+ # Try to recover: abort rebase if in progress
198
+ try:
199
+ git._run_ok(["rebase", "--abort"], cwd=state.abs_path)
200
+ except Exception:
201
+ pass
202
+
203
+ return results
204
+
205
+
206
+ def stash_save_all(
207
+ workspace: Workspace,
208
+ message: str = "",
209
+ repos: list[str] | None = None,
210
+ include_untracked: bool = False,
211
+ ) -> dict[str, str]:
212
+ """Stash uncommitted changes across repos.
213
+
214
+ Returns:
215
+ {repo_name: "stashed" | "clean" | error_message}
216
+ """
217
+ results: dict[str, str] = {}
218
+ targets = _filter_repos(workspace, repos)
219
+
220
+ for state in targets:
221
+ try:
222
+ stashed = git.stash_save(
223
+ state.abs_path, message, include_untracked=include_untracked,
224
+ )
225
+ results[state.config.name] = "stashed" if stashed else "clean"
226
+ except git.GitError as e:
227
+ results[state.config.name] = str(e)
228
+
229
+ return results
230
+
231
+
232
+ def stash_pop_all(
233
+ workspace: Workspace,
234
+ index: int = 0,
235
+ repos: list[str] | None = None,
236
+ ) -> dict[str, str]:
237
+ """Pop stash across repos.
238
+
239
+ Returns:
240
+ {repo_name: "ok" | "no stash" | error_message}
241
+ """
242
+ results: dict[str, str] = {}
243
+ targets = _filter_repos(workspace, repos)
244
+
245
+ for state in targets:
246
+ stashes = git.stash_list(state.abs_path)
247
+ if not stashes:
248
+ results[state.config.name] = "no stash"
249
+ continue
250
+ try:
251
+ git.stash_pop(state.abs_path, index)
252
+ results[state.config.name] = "ok"
253
+ except git.GitError as e:
254
+ results[state.config.name] = str(e)
255
+
256
+ return results
257
+
258
+
259
+ def stash_list_all(workspace: Workspace) -> dict[str, list[dict]]:
260
+ """List stashes across all repos.
261
+
262
+ Returns:
263
+ {repo_name: [{index, ref, message}, ...]}
264
+ """
265
+ results: dict[str, list[dict]] = {}
266
+
267
+ for state in workspace.repos:
268
+ stashes = git.stash_list(state.abs_path)
269
+ if stashes:
270
+ results[state.config.name] = stashes
271
+
272
+ return results
273
+
274
+
275
+ def stash_drop_all(
276
+ workspace: Workspace,
277
+ index: int = 0,
278
+ repos: list[str] | None = None,
279
+ ) -> dict[str, str]:
280
+ """Drop stash entry across repos.
281
+
282
+ Returns:
283
+ {repo_name: "ok" | "no stash" | error_message}
284
+ """
285
+ results: dict[str, str] = {}
286
+ targets = _filter_repos(workspace, repos)
287
+
288
+ for state in targets:
289
+ stashes = git.stash_list(state.abs_path)
290
+ if not stashes:
291
+ results[state.config.name] = "no stash"
292
+ continue
293
+ try:
294
+ git.stash_drop(state.abs_path, index)
295
+ results[state.config.name] = "ok"
296
+ except git.GitError as e:
297
+ results[state.config.name] = str(e)
298
+
299
+ return results
300
+
301
+
302
+ def commit_all(
303
+ workspace: Workspace,
304
+ message: str,
305
+ repos: list[str] | None = None,
306
+ ) -> dict[str, str]:
307
+ """Commit staged changes across repos with the same message.
308
+
309
+ Returns:
310
+ {repo_name: new_sha | "nothing to commit" | error_message}
311
+ """
312
+ results: dict[str, str] = {}
313
+ targets = _filter_repos(workspace, repos)
314
+
315
+ for state in targets:
316
+ status = git.status_porcelain(state.abs_path)
317
+ staged = [e for e in status if e["index_status"]]
318
+ if not staged:
319
+ results[state.config.name] = "nothing to commit"
320
+ continue
321
+ try:
322
+ result = git.commit(state.abs_path, message)
323
+ results[state.config.name] = result["sha"][:12]
324
+ except git.GitError as e:
325
+ results[state.config.name] = str(e)
326
+
327
+ return results
328
+
329
+
330
+ def log_all(
331
+ workspace: Workspace,
332
+ max_count: int = 20,
333
+ feature: str | None = None,
334
+ ) -> list[dict]:
335
+ """Interleaved log across repos, sorted by date.
336
+
337
+ Returns:
338
+ List of {repo, sha, short_sha, author, date, subject}
339
+ """
340
+ all_entries = []
341
+
342
+ for state in workspace.repos:
343
+ ref = feature if feature and git.branch_exists(state.abs_path, feature) else "HEAD"
344
+ entries = git.log_structured(state.abs_path, ref=ref, max_count=max_count)
345
+ for entry in entries:
346
+ entry["repo"] = state.config.name
347
+ all_entries.append(entry)
348
+
349
+ # Sort by date descending
350
+ all_entries.sort(key=lambda e: e["date"], reverse=True)
351
+ return all_entries[:max_count]
352
+
353
+
354
+ def delete_branch_all(
355
+ workspace: Workspace,
356
+ branch: str,
357
+ force: bool = False,
358
+ repos: list[str] | None = None,
359
+ ) -> dict[str, str]:
360
+ """Delete a branch across repos.
361
+
362
+ Returns:
363
+ {repo_name: "ok" | "not found" | error_message}
364
+ """
365
+ results: dict[str, str] = {}
366
+ targets = _filter_repos(workspace, repos)
367
+
368
+ for state in targets:
369
+ if not git.branch_exists(state.abs_path, branch):
370
+ results[state.config.name] = "not found"
371
+ continue
372
+ # Don't delete the branch we're currently on
373
+ if git.current_branch(state.abs_path) == branch:
374
+ results[state.config.name] = "currently checked out"
375
+ continue
376
+ try:
377
+ git.delete_branch(state.abs_path, branch, force=force)
378
+ results[state.config.name] = "ok"
379
+ except git.GitError as e:
380
+ results[state.config.name] = str(e)
381
+
382
+ return results
383
+
384
+
385
+ def rename_branch_all(
386
+ workspace: Workspace,
387
+ old_name: str,
388
+ new_name: str,
389
+ repos: list[str] | None = None,
390
+ ) -> dict[str, str]:
391
+ """Rename a branch across repos.
392
+
393
+ Returns:
394
+ {repo_name: "ok" | "not found" | error_message}
395
+ """
396
+ results: dict[str, str] = {}
397
+ targets = _filter_repos(workspace, repos)
398
+
399
+ for state in targets:
400
+ if not git.branch_exists(state.abs_path, old_name):
401
+ results[state.config.name] = "not found"
402
+ continue
403
+ try:
404
+ git.rename_branch(state.abs_path, old_name, new_name)
405
+ results[state.config.name] = "ok"
406
+ except git.GitError as e:
407
+ results[state.config.name] = str(e)
408
+
409
+ return results
410
+
411
+
412
+ def branches_all(workspace: Workspace) -> dict[str, list[dict]]:
413
+ """List all branches across repos.
414
+
415
+ Returns:
416
+ {repo_name: [{name, is_current, sha, subject}, ...]}
417
+ """
418
+ results: dict[str, list[dict]] = {}
419
+
420
+ for state in workspace.repos:
421
+ entries = git.all_branches(state.abs_path)
422
+ results[state.config.name] = entries
423
+
424
+ return results
425
+
426
+
427
+ def _filter_repos(
428
+ workspace: Workspace,
429
+ repo_names: list[str] | None,
430
+ ) -> list[RepoState]:
431
+ """Filter repos by name, or return all if names is None."""
432
+ if repo_names is None:
433
+ return workspace.repos
434
+ name_set = set(repo_names)
435
+ return [s for s in workspace.repos if s.config.name in name_set]