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.
- canopy/__init__.py +2 -0
- canopy/actions/__init__.py +32 -0
- canopy/actions/aliases.py +421 -0
- canopy/actions/augments.py +55 -0
- canopy/actions/bootstrap.py +249 -0
- canopy/actions/bot_resolutions.py +123 -0
- canopy/actions/bot_status.py +133 -0
- canopy/actions/commit.py +511 -0
- canopy/actions/conflicts.py +314 -0
- canopy/actions/doctor.py +1459 -0
- canopy/actions/draft_replies.py +185 -0
- canopy/actions/drift.py +241 -0
- canopy/actions/errors.py +115 -0
- canopy/actions/evacuate.py +192 -0
- canopy/actions/feature_state.py +607 -0
- canopy/actions/historian.py +612 -0
- canopy/actions/ide_workspace.py +49 -0
- canopy/actions/last_visit.py +83 -0
- canopy/actions/migrate_slots.py +313 -0
- canopy/actions/preflight_state.py +97 -0
- canopy/actions/push.py +199 -0
- canopy/actions/reads.py +304 -0
- canopy/actions/resume.py +582 -0
- canopy/actions/review_filter.py +135 -0
- canopy/actions/ship.py +399 -0
- canopy/actions/slot_details.py +208 -0
- canopy/actions/slot_load.py +383 -0
- canopy/actions/slots.py +221 -0
- canopy/actions/stash.py +230 -0
- canopy/actions/switch.py +775 -0
- canopy/actions/switch_preflight.py +192 -0
- canopy/actions/thread_actions.py +88 -0
- canopy/actions/thread_resolutions.py +101 -0
- canopy/actions/triage.py +286 -0
- canopy/agent/__init__.py +5 -0
- canopy/agent/runner.py +129 -0
- canopy/agent_setup/__init__.py +264 -0
- canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
- canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
- canopy/cli/__init__.py +0 -0
- canopy/cli/main.py +4152 -0
- canopy/cli/render.py +98 -0
- canopy/cli/ui.py +150 -0
- canopy/features/__init__.py +2 -0
- canopy/features/coordinator.py +1256 -0
- canopy/git/__init__.py +0 -0
- canopy/git/hooks.py +173 -0
- canopy/git/multi.py +435 -0
- canopy/git/repo.py +859 -0
- canopy/git/templates/post-checkout.py +67 -0
- canopy/graph/__init__.py +0 -0
- canopy/integrations/__init__.py +0 -0
- canopy/integrations/github.py +983 -0
- canopy/integrations/linear.py +307 -0
- canopy/integrations/precommit.py +239 -0
- canopy/mcp/__init__.py +0 -0
- canopy/mcp/client.py +329 -0
- canopy/mcp/server.py +1797 -0
- canopy/providers/__init__.py +105 -0
- canopy/providers/github_issues.py +289 -0
- canopy/providers/linear.py +341 -0
- canopy/providers/types.py +149 -0
- canopy/workspace/__init__.py +4 -0
- canopy/workspace/config.py +378 -0
- canopy/workspace/context.py +224 -0
- canopy/workspace/discovery.py +197 -0
- canopy/workspace/workspace.py +173 -0
- canopy_cli-3.1.0.dist-info/METADATA +282 -0
- canopy_cli-3.1.0.dist-info/RECORD +71 -0
- canopy_cli-3.1.0.dist-info/WHEEL +4 -0
- canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
canopy/cli/main.py
ADDED
|
@@ -0,0 +1,4152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Canopy CLI — workspace-first development orchestrator.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
init Auto-detect repos, generate canopy.toml
|
|
6
|
+
status Cross-repo workspace status
|
|
7
|
+
checkout <branch> Checkout branch across repos
|
|
8
|
+
log Interleaved log across repos
|
|
9
|
+
sync Pull + rebase across all repos
|
|
10
|
+
feature create <name> Create a feature lane across repos
|
|
11
|
+
feature list List active feature lanes
|
|
12
|
+
feature switch <name> Checkout feature branch in all repos
|
|
13
|
+
feature diff <name> Aggregate diff for a feature lane
|
|
14
|
+
feature status <name> Detailed feature lane status
|
|
15
|
+
branch list List branches across repos
|
|
16
|
+
branch delete <name> Delete a branch across repos
|
|
17
|
+
branch rename <old> <new> Rename a branch across repos
|
|
18
|
+
stash save Stash changes across repos
|
|
19
|
+
stash pop Pop stash across repos
|
|
20
|
+
stash list List stashes across repos
|
|
21
|
+
stash drop Drop stash across repos
|
|
22
|
+
worktree Show worktree info for repos
|
|
23
|
+
list List all feature lanes
|
|
24
|
+
switch <name> Switch to a feature lane
|
|
25
|
+
preflight Context-aware add + run hooks (from worktree dir)
|
|
26
|
+
review <feature> Fetch PR comments + run pre-commit + preflight
|
|
27
|
+
code <feature|.> Open VS Code for feature or workspace
|
|
28
|
+
cursor <feature|.> Open Cursor for feature or workspace
|
|
29
|
+
fork <feature|.> Open Fork.app for feature or workspace
|
|
30
|
+
context Show detected canopy context (debug)
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _print_json(data: dict | list) -> None:
|
|
42
|
+
"""Print JSON to stdout."""
|
|
43
|
+
print(json.dumps(data, indent=2, default=str))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_workspace():
|
|
47
|
+
"""Load workspace from canopy.toml in current directory tree."""
|
|
48
|
+
from ..workspace.config import load_config, ConfigNotFoundError
|
|
49
|
+
from ..workspace.workspace import Workspace
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
config = load_config()
|
|
53
|
+
except ConfigNotFoundError:
|
|
54
|
+
_print_no_workspace_error()
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
return Workspace(config)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _print_no_workspace_error() -> None:
|
|
61
|
+
"""Render the canonical 'no canopy.toml' error.
|
|
62
|
+
|
|
63
|
+
Centralised so every workspace-scoped command prints the same helpful
|
|
64
|
+
message instead of a terse "No canopy.toml found." See test-findings
|
|
65
|
+
F-1: this is the first error a fresh user is likely to hit.
|
|
66
|
+
"""
|
|
67
|
+
print("Error: no canopy.toml found here or in any parent directory.",
|
|
68
|
+
file=sys.stderr)
|
|
69
|
+
print(file=sys.stderr)
|
|
70
|
+
print(
|
|
71
|
+
"Canopy needs to be run from a workspace — a non-git directory that holds",
|
|
72
|
+
file=sys.stderr,
|
|
73
|
+
)
|
|
74
|
+
print(
|
|
75
|
+
"your repos as subdirectories along with canopy.toml. Either:",
|
|
76
|
+
file=sys.stderr,
|
|
77
|
+
)
|
|
78
|
+
print(file=sys.stderr)
|
|
79
|
+
print(" • cd into your existing workspace root, or", file=sys.stderr)
|
|
80
|
+
print(
|
|
81
|
+
" • run `canopy init` from a non-git directory containing the repos to bootstrap one.",
|
|
82
|
+
file=sys.stderr,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Commands ──────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def cmd_init(args: argparse.Namespace) -> None:
|
|
89
|
+
"""Auto-detect repos and generate canopy.toml."""
|
|
90
|
+
from ..workspace.discovery import discover_repos, generate_toml
|
|
91
|
+
from ..workspace.config import load_config, ConfigNotFoundError
|
|
92
|
+
from .ui import console, spinner, print_error, print_warning
|
|
93
|
+
|
|
94
|
+
root = Path(args.path).resolve() if args.path else Path.cwd().resolve()
|
|
95
|
+
|
|
96
|
+
# Check if canopy.toml already exists
|
|
97
|
+
toml_path = root / "canopy.toml"
|
|
98
|
+
if toml_path.exists() and not args.force:
|
|
99
|
+
print_error(f"canopy.toml already exists at [path]{toml_path}[/]")
|
|
100
|
+
console.print(f" [muted]Use [info]--force[/] to overwrite.[/]")
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
is_reinit = toml_path.exists() and args.force
|
|
104
|
+
scan_msg = "Rescanning workspace..." if is_reinit else "Scanning for repos..."
|
|
105
|
+
|
|
106
|
+
with spinner(scan_msg):
|
|
107
|
+
repos = discover_repos(root)
|
|
108
|
+
|
|
109
|
+
if not repos:
|
|
110
|
+
print_error(f"No Git repositories found in [path]{root}[/]")
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
|
|
113
|
+
toml_content = generate_toml(root, workspace_name=args.name)
|
|
114
|
+
|
|
115
|
+
if is_reinit:
|
|
116
|
+
print_warning("Overwriting existing canopy.toml")
|
|
117
|
+
|
|
118
|
+
if args.json:
|
|
119
|
+
all_dirs = [d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")]
|
|
120
|
+
skipped = [d.name for d in all_dirs if not (d / ".git").exists()]
|
|
121
|
+
# Detect existing feature worktrees
|
|
122
|
+
worktrees_dir = root / ".canopy" / "worktrees"
|
|
123
|
+
active_worktrees = {}
|
|
124
|
+
if worktrees_dir.is_dir():
|
|
125
|
+
for feat_dir in worktrees_dir.iterdir():
|
|
126
|
+
if feat_dir.is_dir():
|
|
127
|
+
active_worktrees[feat_dir.name] = sorted(
|
|
128
|
+
d.name for d in feat_dir.iterdir() if d.is_dir()
|
|
129
|
+
)
|
|
130
|
+
_print_json({
|
|
131
|
+
"root": str(root),
|
|
132
|
+
"repos": [{
|
|
133
|
+
"name": r.name, "path": r.path, "role": r.role, "lang": r.lang,
|
|
134
|
+
"is_worktree": r.is_worktree, "worktree_main": r.worktree_main,
|
|
135
|
+
} for r in repos],
|
|
136
|
+
"skipped": skipped,
|
|
137
|
+
"active_worktrees": active_worktrees,
|
|
138
|
+
"toml": toml_content,
|
|
139
|
+
})
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
if args.dry_run:
|
|
143
|
+
print(toml_content)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
from .ui import console, print_success, print_warning, separator, SYM_ARROW, SYM_CHECK
|
|
147
|
+
|
|
148
|
+
toml_path.write_text(toml_content)
|
|
149
|
+
|
|
150
|
+
# Install drift-tracking post-checkout hooks in each non-worktree repo.
|
|
151
|
+
# Worktrees inherit hooks from their main repo via commondir.
|
|
152
|
+
hook_results = _install_hooks_for_repos(root, repos)
|
|
153
|
+
|
|
154
|
+
# Count non-git dirs that were skipped
|
|
155
|
+
all_dirs = [d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")]
|
|
156
|
+
skipped = [d.name for d in all_dirs if not (d / ".git").exists()]
|
|
157
|
+
|
|
158
|
+
console.print()
|
|
159
|
+
print_success(f"Created [path]{toml_path}[/]")
|
|
160
|
+
console.print()
|
|
161
|
+
console.print(f" [header]Found {len(repos)} repos[/]")
|
|
162
|
+
|
|
163
|
+
for r in repos:
|
|
164
|
+
tags = []
|
|
165
|
+
if r.role:
|
|
166
|
+
tags.append(r.role)
|
|
167
|
+
if r.lang:
|
|
168
|
+
tags.append(r.lang)
|
|
169
|
+
if r.is_worktree:
|
|
170
|
+
tags.append(f"worktree {SYM_ARROW} {r.worktree_main}")
|
|
171
|
+
tag_str = f" [muted]{', '.join(tags)}[/]" if tags else ""
|
|
172
|
+
console.print(f" [repo]{r.name}[/]{tag_str}")
|
|
173
|
+
|
|
174
|
+
if skipped:
|
|
175
|
+
console.print(f" [muted]Skipped {len(skipped)} non-git dirs: {', '.join(skipped)}[/]")
|
|
176
|
+
|
|
177
|
+
if hook_results:
|
|
178
|
+
installed = [h for h in hook_results if h["action"] in ("installed", "reinstalled")]
|
|
179
|
+
chained = [h for h in hook_results if h["action"] == "chained_existing"]
|
|
180
|
+
if installed or chained:
|
|
181
|
+
console.print()
|
|
182
|
+
console.print(f" [header]Drift hooks ({len(installed) + len(chained)})[/]")
|
|
183
|
+
for h in installed + chained:
|
|
184
|
+
note = "" if h["action"] == "installed" else f" [muted]({h['action']})[/]"
|
|
185
|
+
console.print(f" [repo]{h['repo']}[/]{note}")
|
|
186
|
+
|
|
187
|
+
if not args.no_agent:
|
|
188
|
+
from ..agent_setup import setup_agent as _setup_agent
|
|
189
|
+
agent_result = _setup_agent(root, do_skill=True, do_mcp=True, reinstall=False)
|
|
190
|
+
skill = agent_result.get("skill", {})
|
|
191
|
+
mcp = agent_result.get("mcp", {})
|
|
192
|
+
console.print()
|
|
193
|
+
console.print(f" [header]Claude Code agent setup[/]")
|
|
194
|
+
if skill.get("action") in ("installed", "reinstalled"):
|
|
195
|
+
console.print(f" skill [success]{SYM_CHECK}[/] {skill['action']} [muted]{skill['path']}[/]")
|
|
196
|
+
else:
|
|
197
|
+
note = skill.get("reason") or skill.get("action", "")
|
|
198
|
+
console.print(f" skill [muted]· {note}[/]")
|
|
199
|
+
if mcp.get("action") in ("added", "updated", "created"):
|
|
200
|
+
console.print(f" mcp [success]{SYM_CHECK}[/] {mcp['action']} [muted]{mcp['path']}[/]")
|
|
201
|
+
else:
|
|
202
|
+
note = mcp.get("reason") or mcp.get("action", "")
|
|
203
|
+
console.print(f" mcp [muted]· {note}[/]")
|
|
204
|
+
console.print(f" [muted]Restart Claude Code to pick up the skill + MCP. Skip with --no-agent.[/]")
|
|
205
|
+
|
|
206
|
+
# Report existing feature worktrees under .canopy/
|
|
207
|
+
canopy_dir = root / ".canopy"
|
|
208
|
+
worktrees_dir = canopy_dir / "worktrees"
|
|
209
|
+
if worktrees_dir.is_dir():
|
|
210
|
+
features_with_wt = sorted(
|
|
211
|
+
d.name for d in worktrees_dir.iterdir() if d.is_dir()
|
|
212
|
+
)
|
|
213
|
+
if features_with_wt:
|
|
214
|
+
console.print()
|
|
215
|
+
console.print(f" [header]Active worktrees ({len(features_with_wt)})[/]")
|
|
216
|
+
for feat in features_with_wt:
|
|
217
|
+
feat_dir = worktrees_dir / feat
|
|
218
|
+
wt_repos = sorted(
|
|
219
|
+
d.name for d in feat_dir.iterdir() if d.is_dir()
|
|
220
|
+
)
|
|
221
|
+
console.print(f" [feature]{feat}[/] [muted]{SYM_ARROW}[/] {', '.join(wt_repos)}")
|
|
222
|
+
console.print()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def cmd_status(args: argparse.Namespace) -> None:
|
|
226
|
+
"""Show cross-repo workspace status."""
|
|
227
|
+
from .ui import console, separator, spinner, SYM_BRANCH
|
|
228
|
+
|
|
229
|
+
workspace = _load_workspace()
|
|
230
|
+
with spinner("Reading workspace state…"):
|
|
231
|
+
workspace.refresh()
|
|
232
|
+
|
|
233
|
+
if args.json:
|
|
234
|
+
_print_json(workspace.to_dict())
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
console.print()
|
|
238
|
+
console.print(f" [header]{workspace.config.name}[/] [path]{workspace.config.root}[/]")
|
|
239
|
+
separator()
|
|
240
|
+
|
|
241
|
+
for state in workspace.repos:
|
|
242
|
+
role = f" [muted]{state.config.role}[/]" if state.config.role else ""
|
|
243
|
+
console.print(f"\n [repo]{state.config.name}[/]{role}")
|
|
244
|
+
|
|
245
|
+
# Branch line with status indicators
|
|
246
|
+
parts = []
|
|
247
|
+
if state.is_dirty:
|
|
248
|
+
parts.append(f"[dirty]{state.dirty_count} dirty[/]")
|
|
249
|
+
if state.ahead_of_default:
|
|
250
|
+
parts.append(f"[ahead]↑{state.ahead_of_default}[/]")
|
|
251
|
+
if state.behind_default:
|
|
252
|
+
parts.append(f"[behind]↓{state.behind_default}[/]")
|
|
253
|
+
status_str = f" {' '.join(parts)}" if parts else ""
|
|
254
|
+
|
|
255
|
+
console.print(f" {SYM_BRANCH} [branch]{state.current_branch}[/]{status_str}")
|
|
256
|
+
console.print(f" [muted]{state.head_sha}[/]")
|
|
257
|
+
|
|
258
|
+
features = workspace.active_features()
|
|
259
|
+
if features:
|
|
260
|
+
separator()
|
|
261
|
+
feat_str = " ".join(f"[feature]{f}[/]" for f in features)
|
|
262
|
+
console.print(f" Active features: {feat_str}")
|
|
263
|
+
|
|
264
|
+
console.print()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cmd_feature_create(args: argparse.Namespace) -> None:
|
|
268
|
+
"""Create a feature lane across repos."""
|
|
269
|
+
workspace = _load_workspace()
|
|
270
|
+
from ..features.coordinator import FeatureCoordinator
|
|
271
|
+
|
|
272
|
+
coordinator = FeatureCoordinator(workspace)
|
|
273
|
+
repos = args.repos.split(",") if args.repos else None
|
|
274
|
+
use_worktrees = getattr(args, "worktree", False)
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
lane = coordinator.create(args.name, repos, use_worktrees=use_worktrees)
|
|
278
|
+
except (ValueError, RuntimeError) as e:
|
|
279
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
280
|
+
sys.exit(1)
|
|
281
|
+
|
|
282
|
+
if args.json:
|
|
283
|
+
_print_json(lane.to_dict())
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
if use_worktrees:
|
|
287
|
+
print(f"Created feature lane with worktrees: {lane.name}")
|
|
288
|
+
# Show worktree paths
|
|
289
|
+
paths = coordinator.resolve_paths(lane.name)
|
|
290
|
+
for repo_name, path in paths.items():
|
|
291
|
+
print(f" {repo_name}: {path}")
|
|
292
|
+
print(f"\nOpen in VS Code: canopy code {lane.name}")
|
|
293
|
+
print(f"Open in Cursor: canopy cursor {lane.name}")
|
|
294
|
+
else:
|
|
295
|
+
print(f"Created feature lane: {lane.name}")
|
|
296
|
+
print(f" Repos: {', '.join(lane.repos)}")
|
|
297
|
+
print(f"\nSwitch to it with: canopy feature switch {lane.name}")
|
|
298
|
+
print(f"Or create with worktrees: canopy feature create --worktree {lane.name}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def cmd_feature_list(args: argparse.Namespace) -> None:
|
|
302
|
+
"""List active feature lanes."""
|
|
303
|
+
from .ui import console, separator, SYM_LINK
|
|
304
|
+
|
|
305
|
+
workspace = _load_workspace()
|
|
306
|
+
from ..features.coordinator import FeatureCoordinator
|
|
307
|
+
|
|
308
|
+
coordinator = FeatureCoordinator(workspace)
|
|
309
|
+
lanes = coordinator.list_active()
|
|
310
|
+
|
|
311
|
+
if args.json:
|
|
312
|
+
_print_json([lane.to_dict() for lane in lanes])
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
if not lanes:
|
|
316
|
+
console.print()
|
|
317
|
+
console.print(" [muted]No active feature lanes.[/]")
|
|
318
|
+
console.print(f" [muted]Create one with:[/] [info]canopy worktree <name>[/]")
|
|
319
|
+
console.print()
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
console.print()
|
|
323
|
+
console.print(f" [header]Feature Lanes ({len(lanes)})[/]")
|
|
324
|
+
|
|
325
|
+
for lane in lanes:
|
|
326
|
+
separator()
|
|
327
|
+
linear_str = ""
|
|
328
|
+
if lane.linear_issue:
|
|
329
|
+
title_bit = f" — {lane.linear_title}" if lane.linear_title else ""
|
|
330
|
+
linear_str = f" [linear]{SYM_LINK} {lane.linear_issue}{title_bit}[/]"
|
|
331
|
+
console.print(f" [feature]{lane.name}[/]{linear_str}")
|
|
332
|
+
|
|
333
|
+
for repo_name, state in lane.repo_states.items():
|
|
334
|
+
if "error" in state:
|
|
335
|
+
console.print(f" [repo]{repo_name}[/] [error]error — {state['error']}[/]")
|
|
336
|
+
continue
|
|
337
|
+
if not state.get("has_branch"):
|
|
338
|
+
console.print(f" [repo]{repo_name}[/] [muted]no branch[/]")
|
|
339
|
+
continue
|
|
340
|
+
parts = []
|
|
341
|
+
if state.get("ahead"):
|
|
342
|
+
parts.append(f"[ahead]↑{state['ahead']}[/]")
|
|
343
|
+
if state.get("behind"):
|
|
344
|
+
parts.append(f"[behind]↓{state['behind']}[/]")
|
|
345
|
+
if state.get("dirty"):
|
|
346
|
+
parts.append("[dirty]dirty[/]")
|
|
347
|
+
if state.get("changed_file_count"):
|
|
348
|
+
parts.append(f"[muted]{state['changed_file_count']} files[/]")
|
|
349
|
+
status = " ".join(parts) if parts else "[clean]up to date[/]"
|
|
350
|
+
console.print(f" [repo]{repo_name}[/] {status}")
|
|
351
|
+
|
|
352
|
+
console.print()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def cmd_feature_diff(args: argparse.Namespace) -> None:
|
|
356
|
+
"""Show aggregate diff for a feature lane."""
|
|
357
|
+
workspace = _load_workspace()
|
|
358
|
+
from ..features.coordinator import FeatureCoordinator
|
|
359
|
+
|
|
360
|
+
coordinator = FeatureCoordinator(workspace)
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
diff = coordinator.diff(args.name)
|
|
364
|
+
except ValueError as e:
|
|
365
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
366
|
+
sys.exit(1)
|
|
367
|
+
|
|
368
|
+
if args.json:
|
|
369
|
+
_print_json(diff)
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
summary = diff["summary"]
|
|
373
|
+
print(f"\n Feature: {args.name}")
|
|
374
|
+
print(f" {summary['participating_repos']}/{summary['total_repos']} repos, "
|
|
375
|
+
f"{summary['total_files_changed']} files, "
|
|
376
|
+
f"+{summary['total_insertions']} -{summary['total_deletions']}")
|
|
377
|
+
print(f" {'─' * 60}")
|
|
378
|
+
|
|
379
|
+
for repo_name, data in diff["repos"].items():
|
|
380
|
+
if not data.get("has_branch"):
|
|
381
|
+
print(f"\n {repo_name}: (no branch)")
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
ins = data.get("insertions", 0)
|
|
385
|
+
dele = data.get("deletions", 0)
|
|
386
|
+
files = data.get("changed_files", [])
|
|
387
|
+
print(f"\n {repo_name} ({len(files)} files, +{ins} -{dele})")
|
|
388
|
+
for f in files[:10]:
|
|
389
|
+
print(f" {f}")
|
|
390
|
+
if len(files) > 10:
|
|
391
|
+
print(f" ... and {len(files) - 10} more")
|
|
392
|
+
|
|
393
|
+
if diff.get("type_overlaps"):
|
|
394
|
+
print(f"\n {'─' * 60}")
|
|
395
|
+
print(f" Type Overlaps:")
|
|
396
|
+
for o in diff["type_overlaps"]:
|
|
397
|
+
repos = ", ".join(o["repos"])
|
|
398
|
+
print(f" '{o['file_pattern']}' modified in {repos}")
|
|
399
|
+
for f in o["files"]:
|
|
400
|
+
print(f" {f['repo']}: {f['path']}")
|
|
401
|
+
|
|
402
|
+
print()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def cmd_feature_changes(args: argparse.Namespace) -> None:
|
|
406
|
+
"""Show per-file change status (M/A/D/?) for each repo in a feature."""
|
|
407
|
+
workspace = _load_workspace()
|
|
408
|
+
from ..features.coordinator import FeatureCoordinator
|
|
409
|
+
|
|
410
|
+
coordinator = FeatureCoordinator(workspace)
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
result = coordinator.feature_changes(args.name)
|
|
414
|
+
except ValueError as e:
|
|
415
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
416
|
+
sys.exit(1)
|
|
417
|
+
|
|
418
|
+
if args.json:
|
|
419
|
+
_print_json(result)
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
print(f"\n Feature: {result['feature']}")
|
|
423
|
+
print(f" {'─' * 60}")
|
|
424
|
+
|
|
425
|
+
for repo_name, data in result["repos"].items():
|
|
426
|
+
if data.get("error"):
|
|
427
|
+
print(f"\n {repo_name}: error — {data['error']}")
|
|
428
|
+
continue
|
|
429
|
+
if not data.get("has_branch"):
|
|
430
|
+
print(f"\n {repo_name}: (no branch)")
|
|
431
|
+
continue
|
|
432
|
+
changes = data.get("changes", [])
|
|
433
|
+
print(f"\n {repo_name} ({len(changes)} change{'s' if len(changes) != 1 else ''})")
|
|
434
|
+
for c in changes:
|
|
435
|
+
print(f" {c['status']} {c['path']}")
|
|
436
|
+
|
|
437
|
+
print()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def cmd_feature_status(args: argparse.Namespace) -> None:
|
|
441
|
+
"""Show detailed feature lane status."""
|
|
442
|
+
from .ui import console, separator, print_success, SYM_CHECK, SYM_CROSS, SYM_LINK
|
|
443
|
+
|
|
444
|
+
workspace = _load_workspace()
|
|
445
|
+
from ..features.coordinator import FeatureCoordinator
|
|
446
|
+
|
|
447
|
+
coordinator = FeatureCoordinator(workspace)
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
lane = coordinator.status(args.name)
|
|
451
|
+
except ValueError as e:
|
|
452
|
+
from .ui import print_error
|
|
453
|
+
print_error(str(e))
|
|
454
|
+
sys.exit(1)
|
|
455
|
+
|
|
456
|
+
if args.json:
|
|
457
|
+
_print_json(lane.to_dict())
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
console.print()
|
|
461
|
+
linear_str = ""
|
|
462
|
+
if lane.linear_issue:
|
|
463
|
+
title_bit = f" — {lane.linear_title}" if lane.linear_title else ""
|
|
464
|
+
linear_str = f" [linear]{SYM_LINK} {lane.linear_issue}{title_bit}[/]"
|
|
465
|
+
console.print(f" [feature]{lane.name}[/]{linear_str}")
|
|
466
|
+
console.print(f" [muted]status: {lane.status}[/]")
|
|
467
|
+
if lane.created_at:
|
|
468
|
+
console.print(f" [muted]created: {lane.created_at}[/]")
|
|
469
|
+
separator()
|
|
470
|
+
|
|
471
|
+
for repo_name, state in lane.repo_states.items():
|
|
472
|
+
if "error" in state:
|
|
473
|
+
console.print(f"\n [repo]{repo_name}[/] [error]error — {state['error']}[/]")
|
|
474
|
+
continue
|
|
475
|
+
if not state.get("has_branch"):
|
|
476
|
+
console.print(f"\n [repo]{repo_name}[/] [muted]no branch[/]")
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
parts = []
|
|
480
|
+
if state.get("ahead"):
|
|
481
|
+
parts.append(f"[ahead]↑{state['ahead']} ahead[/]")
|
|
482
|
+
if state.get("behind"):
|
|
483
|
+
parts.append(f"[behind]↓{state['behind']} behind[/]")
|
|
484
|
+
if state.get("dirty"):
|
|
485
|
+
parts.append("[dirty]uncommitted changes[/]")
|
|
486
|
+
divergence = " ".join(parts) if parts else "[clean]up to date[/]"
|
|
487
|
+
|
|
488
|
+
console.print(f"\n [repo]{repo_name}[/] {divergence}")
|
|
489
|
+
files = state.get("changed_files", [])
|
|
490
|
+
if files:
|
|
491
|
+
console.print(f" [muted]files ({len(files)}):[/]")
|
|
492
|
+
for f in files[:8]:
|
|
493
|
+
console.print(f" [path]{f}[/]")
|
|
494
|
+
if len(files) > 8:
|
|
495
|
+
console.print(f" [muted]... and {len(files) - 8} more[/]")
|
|
496
|
+
|
|
497
|
+
# Merge readiness
|
|
498
|
+
readiness = coordinator.merge_readiness(lane.name)
|
|
499
|
+
separator()
|
|
500
|
+
if readiness["ready"]:
|
|
501
|
+
console.print(f" [success]{SYM_CHECK} Merge ready[/]")
|
|
502
|
+
else:
|
|
503
|
+
console.print(f" [error]{SYM_CROSS} Not merge ready[/]")
|
|
504
|
+
for issue in readiness["issues"]:
|
|
505
|
+
console.print(f" [muted]•[/] {issue}")
|
|
506
|
+
|
|
507
|
+
console.print()
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def cmd_sync(args: argparse.Namespace) -> None:
|
|
511
|
+
"""Pull + rebase across all repos."""
|
|
512
|
+
workspace = _load_workspace()
|
|
513
|
+
from ..git.multi import sync_all
|
|
514
|
+
from .ui import spinner
|
|
515
|
+
|
|
516
|
+
repo_count = len(workspace.repos)
|
|
517
|
+
with spinner(f"Syncing {repo_count} repo{'s' if repo_count != 1 else ''}…"):
|
|
518
|
+
results = sync_all(workspace, strategy=args.strategy)
|
|
519
|
+
|
|
520
|
+
if args.json:
|
|
521
|
+
_print_json({"results": results})
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
for repo, result in results.items():
|
|
525
|
+
icon = "ok" if result == "ok" else f"failed: {result}"
|
|
526
|
+
print(f" {repo}: {icon}")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def cmd_checkout(args: argparse.Namespace) -> None:
|
|
530
|
+
"""Checkout a branch across repos."""
|
|
531
|
+
workspace = _load_workspace()
|
|
532
|
+
from ..git.multi import checkout_all
|
|
533
|
+
|
|
534
|
+
repos = args.repos.split(",") if args.repos else None
|
|
535
|
+
results = checkout_all(workspace, args.branch, repos)
|
|
536
|
+
|
|
537
|
+
if args.json:
|
|
538
|
+
_print_json({"branch": args.branch, "results": results})
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
for repo, result in results.items():
|
|
542
|
+
status = "ok" if result is True else f"failed: {result}"
|
|
543
|
+
print(f" {repo}: {status}")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def cmd_log(args: argparse.Namespace) -> None:
|
|
548
|
+
"""Interleaved log across repos."""
|
|
549
|
+
workspace = _load_workspace()
|
|
550
|
+
from ..git.multi import log_all
|
|
551
|
+
|
|
552
|
+
entries = log_all(workspace, max_count=args.count, feature=args.feature)
|
|
553
|
+
|
|
554
|
+
if args.json:
|
|
555
|
+
_print_json(entries)
|
|
556
|
+
return
|
|
557
|
+
|
|
558
|
+
if not entries:
|
|
559
|
+
print(" No commits found.")
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
for entry in entries:
|
|
563
|
+
date_short = entry["date"][:10] if entry.get("date") else ""
|
|
564
|
+
print(f" {entry.get('short_sha', '')} [{entry.get('repo', '')}] "
|
|
565
|
+
f"{entry.get('subject', '')} ({entry.get('author', '')}, {date_short})")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def cmd_branch_list(args: argparse.Namespace) -> None:
|
|
569
|
+
"""List branches across repos."""
|
|
570
|
+
workspace = _load_workspace()
|
|
571
|
+
from ..git.multi import branches_all
|
|
572
|
+
|
|
573
|
+
results = branches_all(workspace)
|
|
574
|
+
|
|
575
|
+
if args.json:
|
|
576
|
+
_print_json(results)
|
|
577
|
+
return
|
|
578
|
+
|
|
579
|
+
for repo_name, branches in results.items():
|
|
580
|
+
print(f"\n {repo_name}")
|
|
581
|
+
for b in branches:
|
|
582
|
+
marker = "* " if b["is_current"] else " "
|
|
583
|
+
print(f" {marker}{b['name']} {b['sha']} {b['subject']}")
|
|
584
|
+
|
|
585
|
+
print()
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def cmd_branch_delete(args: argparse.Namespace) -> None:
|
|
589
|
+
"""Delete a branch across repos."""
|
|
590
|
+
workspace = _load_workspace()
|
|
591
|
+
from ..git.multi import delete_branch_all
|
|
592
|
+
|
|
593
|
+
repos = args.repos.split(",") if args.repos else None
|
|
594
|
+
results = delete_branch_all(workspace, args.name, force=args.force, repos=repos)
|
|
595
|
+
|
|
596
|
+
if args.json:
|
|
597
|
+
_print_json({"branch": args.name, "results": results})
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
for repo, result in results.items():
|
|
601
|
+
print(f" {repo}: {result}")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def cmd_branch_rename(args: argparse.Namespace) -> None:
|
|
605
|
+
"""Rename a branch across repos."""
|
|
606
|
+
workspace = _load_workspace()
|
|
607
|
+
from ..git.multi import rename_branch_all
|
|
608
|
+
|
|
609
|
+
repos = args.repos.split(",") if args.repos else None
|
|
610
|
+
results = rename_branch_all(workspace, args.old, args.new, repos)
|
|
611
|
+
|
|
612
|
+
if args.json:
|
|
613
|
+
_print_json({"old": args.old, "new": args.new, "results": results})
|
|
614
|
+
return
|
|
615
|
+
|
|
616
|
+
for repo, result in results.items():
|
|
617
|
+
print(f" {repo}: {result}")
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def cmd_stash_save(args: argparse.Namespace) -> None:
|
|
621
|
+
"""Stash uncommitted changes across repos."""
|
|
622
|
+
# Route to the feature-tagged path when --feature is passed.
|
|
623
|
+
if getattr(args, "feature", None):
|
|
624
|
+
cmd_stash_save_feature(args)
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
workspace = _load_workspace()
|
|
628
|
+
from ..git.multi import stash_save_all
|
|
629
|
+
|
|
630
|
+
repos = args.repos.split(",") if args.repos else None
|
|
631
|
+
results = stash_save_all(workspace, message=args.message or "", repos=repos)
|
|
632
|
+
|
|
633
|
+
if args.json:
|
|
634
|
+
_print_json({"results": results})
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
for repo, result in results.items():
|
|
638
|
+
print(f" {repo}: {result}")
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def cmd_stash_pop(args: argparse.Namespace) -> None:
|
|
642
|
+
"""Pop stash across repos."""
|
|
643
|
+
# Route to feature-tagged pop when --feature is passed.
|
|
644
|
+
if getattr(args, "feature", None):
|
|
645
|
+
cmd_stash_pop_feature(args)
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
workspace = _load_workspace()
|
|
649
|
+
from ..git.multi import stash_pop_all
|
|
650
|
+
|
|
651
|
+
repos = args.repos.split(",") if args.repos else None
|
|
652
|
+
results = stash_pop_all(workspace, index=args.index, repos=repos)
|
|
653
|
+
|
|
654
|
+
if args.json:
|
|
655
|
+
_print_json({"results": results})
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
for repo, result in results.items():
|
|
659
|
+
print(f" {repo}: {result}")
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def cmd_stash_list(args: argparse.Namespace) -> None:
|
|
663
|
+
"""List stashes across repos."""
|
|
664
|
+
# Route to grouped list whenever --feature is passed (or always if you
|
|
665
|
+
# want grouping by default; for now keep flat list as default).
|
|
666
|
+
if getattr(args, "feature", None):
|
|
667
|
+
cmd_stash_list_grouped(args)
|
|
668
|
+
return
|
|
669
|
+
|
|
670
|
+
workspace = _load_workspace()
|
|
671
|
+
from ..git.multi import stash_list_all
|
|
672
|
+
|
|
673
|
+
results = stash_list_all(workspace)
|
|
674
|
+
|
|
675
|
+
if args.json:
|
|
676
|
+
_print_json(results)
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
if not results:
|
|
680
|
+
print(" No stashes found.")
|
|
681
|
+
return
|
|
682
|
+
|
|
683
|
+
for repo_name, stashes in results.items():
|
|
684
|
+
print(f"\n {repo_name}")
|
|
685
|
+
for s in stashes:
|
|
686
|
+
print(f" {s['ref']}: {s['message']}")
|
|
687
|
+
|
|
688
|
+
print()
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def cmd_stash_drop(args: argparse.Namespace) -> None:
|
|
692
|
+
"""Drop stash across repos."""
|
|
693
|
+
workspace = _load_workspace()
|
|
694
|
+
from ..git.multi import stash_drop_all
|
|
695
|
+
|
|
696
|
+
repos = args.repos.split(",") if args.repos else None
|
|
697
|
+
results = stash_drop_all(workspace, index=args.index, repos=repos)
|
|
698
|
+
|
|
699
|
+
if args.json:
|
|
700
|
+
_print_json({"results": results})
|
|
701
|
+
return
|
|
702
|
+
|
|
703
|
+
for repo, result in results.items():
|
|
704
|
+
print(f" {repo}: {result}")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def cmd_worktree(args: argparse.Namespace) -> None:
|
|
708
|
+
"""Dispatch: list worktrees or create a new one."""
|
|
709
|
+
if args.name:
|
|
710
|
+
cmd_worktree_create(args)
|
|
711
|
+
else:
|
|
712
|
+
cmd_worktree_list(args)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def cmd_worktree_create(args: argparse.Namespace) -> None:
|
|
716
|
+
"""Create a feature with worktrees, optionally linked to a Linear issue."""
|
|
717
|
+
from .ui import console, spinner, print_success, print_warning, print_error, separator, SYM_ARROW, SYM_LINK
|
|
718
|
+
|
|
719
|
+
workspace = _load_workspace()
|
|
720
|
+
from ..features.coordinator import FeatureCoordinator
|
|
721
|
+
|
|
722
|
+
name = args.name
|
|
723
|
+
issue_id = args.issue
|
|
724
|
+
repos = args.repos
|
|
725
|
+
|
|
726
|
+
# ── Linear integration ──
|
|
727
|
+
linear_issue = ""
|
|
728
|
+
linear_title = ""
|
|
729
|
+
linear_url = ""
|
|
730
|
+
|
|
731
|
+
if issue_id:
|
|
732
|
+
from ..integrations.linear import (
|
|
733
|
+
is_linear_configured,
|
|
734
|
+
get_issue,
|
|
735
|
+
format_branch_name,
|
|
736
|
+
LinearNotConfiguredError,
|
|
737
|
+
LinearIssueNotFoundError,
|
|
738
|
+
)
|
|
739
|
+
from ..mcp.client import McpClientError
|
|
740
|
+
|
|
741
|
+
if is_linear_configured(workspace.config.root):
|
|
742
|
+
try:
|
|
743
|
+
with spinner(f"Fetching {issue_id} from Linear..."):
|
|
744
|
+
issue_data = get_issue(workspace.config.root, issue_id)
|
|
745
|
+
linear_issue = issue_data.get("identifier", issue_id)
|
|
746
|
+
linear_title = issue_data.get("title", "")
|
|
747
|
+
linear_url = issue_data.get("url", "")
|
|
748
|
+
if linear_title:
|
|
749
|
+
console.print(f" [linear]{SYM_LINK} {linear_issue}: {linear_title}[/]")
|
|
750
|
+
except (LinearNotConfiguredError, LinearIssueNotFoundError, McpClientError) as e:
|
|
751
|
+
print_warning(f"Could not fetch Linear issue: {e}")
|
|
752
|
+
console.print(f" [muted]Continuing without Linear link...[/]")
|
|
753
|
+
linear_issue = issue_id
|
|
754
|
+
else:
|
|
755
|
+
print_warning(f"Linear MCP not configured — storing '{issue_id}' without fetching.")
|
|
756
|
+
linear_issue = issue_id
|
|
757
|
+
|
|
758
|
+
# ── Create the feature with worktrees ──
|
|
759
|
+
coordinator = FeatureCoordinator(workspace)
|
|
760
|
+
try:
|
|
761
|
+
with spinner(f"Creating worktrees for {name}..."):
|
|
762
|
+
lane = coordinator.create(
|
|
763
|
+
name,
|
|
764
|
+
repos=repos,
|
|
765
|
+
use_worktrees=True,
|
|
766
|
+
linear_issue=linear_issue,
|
|
767
|
+
linear_title=linear_title,
|
|
768
|
+
linear_url=linear_url,
|
|
769
|
+
)
|
|
770
|
+
except (RuntimeError,) as e:
|
|
771
|
+
print_error(str(e))
|
|
772
|
+
sys.exit(1)
|
|
773
|
+
except ValueError as e:
|
|
774
|
+
# Check if this is a worktree limit error
|
|
775
|
+
from ..features.coordinator import WorktreeLimitError
|
|
776
|
+
if isinstance(e, WorktreeLimitError):
|
|
777
|
+
print_error(f"Worktree limit reached ({e.current}/{e.limit})")
|
|
778
|
+
if e.stale:
|
|
779
|
+
console.print()
|
|
780
|
+
console.print(f" [muted]Suggested cleanup:[/]")
|
|
781
|
+
for s in e.stale:
|
|
782
|
+
console.print(f" [feature]{s['name']}[/] [muted]{s['reason']}[/]")
|
|
783
|
+
console.print()
|
|
784
|
+
console.print(f" [muted]Run:[/] [info]canopy done <feature>[/]")
|
|
785
|
+
else:
|
|
786
|
+
console.print(f" [muted]Run:[/] [info]canopy done <feature>[/] to free a slot")
|
|
787
|
+
console.print(f" [muted]Or:[/] [info]canopy config slots {e.limit + 1}[/]")
|
|
788
|
+
else:
|
|
789
|
+
print_error(str(e))
|
|
790
|
+
sys.exit(1)
|
|
791
|
+
|
|
792
|
+
result = lane.to_dict()
|
|
793
|
+
result["worktree_paths"] = coordinator.resolve_paths(name)
|
|
794
|
+
from ..actions import slots as _slots_mod
|
|
795
|
+
_slot_id = _slots_mod.slot_for_feature(workspace, name)
|
|
796
|
+
if _slot_id is not None:
|
|
797
|
+
result["slot_id"] = _slot_id
|
|
798
|
+
|
|
799
|
+
if args.json:
|
|
800
|
+
_print_json(result)
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
console.print()
|
|
804
|
+
for repo_name, path in result["worktree_paths"].items():
|
|
805
|
+
print_success(f"[repo]{repo_name}[/] [muted]{SYM_ARROW}[/] [path]{path}[/]")
|
|
806
|
+
|
|
807
|
+
if linear_issue and not linear_title:
|
|
808
|
+
console.print(f"\n [linear]{SYM_LINK} {linear_issue}[/]")
|
|
809
|
+
|
|
810
|
+
console.print()
|
|
811
|
+
console.print(f" [muted]Open in IDE:[/]")
|
|
812
|
+
console.print(f" [info]canopy code {name}[/]")
|
|
813
|
+
console.print(f" [info]canopy cursor {name}[/]")
|
|
814
|
+
console.print(f" [info]canopy fork {name}[/]")
|
|
815
|
+
console.print()
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def cmd_worktree_list(args: argparse.Namespace) -> None:
|
|
819
|
+
"""Show live worktree status — always reflects current filesystem."""
|
|
820
|
+
from .ui import console, spinner, separator, SYM_BRANCH, SYM_LINK
|
|
821
|
+
|
|
822
|
+
workspace = _load_workspace()
|
|
823
|
+
from ..features.coordinator import FeatureCoordinator
|
|
824
|
+
|
|
825
|
+
coordinator = FeatureCoordinator(workspace)
|
|
826
|
+
|
|
827
|
+
with spinner("Scanning worktrees..."):
|
|
828
|
+
data = coordinator.worktrees_live()
|
|
829
|
+
|
|
830
|
+
if args.json:
|
|
831
|
+
_print_json(data)
|
|
832
|
+
return
|
|
833
|
+
|
|
834
|
+
features = data.get("features", {})
|
|
835
|
+
repos_wt = data.get("repos", {})
|
|
836
|
+
|
|
837
|
+
# Also load feature metadata for Linear links
|
|
838
|
+
features_json = coordinator._load_features()
|
|
839
|
+
|
|
840
|
+
if not features and all(
|
|
841
|
+
len(r.get("worktrees", [])) <= 1 for r in repos_wt.values()
|
|
842
|
+
):
|
|
843
|
+
console.print()
|
|
844
|
+
console.print(" [muted]No active worktrees.[/]")
|
|
845
|
+
console.print(f" [muted]Create one with:[/] [info]canopy worktree <name>[/]")
|
|
846
|
+
console.print()
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
# ── Feature worktrees ──
|
|
850
|
+
if features:
|
|
851
|
+
console.print()
|
|
852
|
+
console.print(f" [header]Worktrees ({len(features)})[/]")
|
|
853
|
+
for feat_name, feat_data in features.items():
|
|
854
|
+
separator()
|
|
855
|
+
# Show Linear link if present
|
|
856
|
+
meta = features_json.get(feat_name, {})
|
|
857
|
+
linear_id = meta.get("linear_issue", "")
|
|
858
|
+
linear_title = meta.get("linear_title", "")
|
|
859
|
+
if linear_id:
|
|
860
|
+
title_str = f" — {linear_title}" if linear_title else ""
|
|
861
|
+
console.print(f" [feature]{feat_name}[/] [linear]{SYM_LINK} {linear_id}{title_str}[/]")
|
|
862
|
+
else:
|
|
863
|
+
console.print(f" [feature]{feat_name}[/]")
|
|
864
|
+
|
|
865
|
+
for repo_name, info in feat_data.get("repos", {}).items():
|
|
866
|
+
branch = info.get("branch", "?")
|
|
867
|
+
dirty = info.get("dirty", False)
|
|
868
|
+
dirty_count = info.get("dirty_count", 0)
|
|
869
|
+
ahead = info.get("ahead", 0)
|
|
870
|
+
behind = info.get("behind", 0)
|
|
871
|
+
|
|
872
|
+
parts = []
|
|
873
|
+
if dirty:
|
|
874
|
+
parts.append(f"[dirty]{dirty_count} dirty[/]")
|
|
875
|
+
if ahead:
|
|
876
|
+
parts.append(f"[ahead]↑{ahead}[/]")
|
|
877
|
+
if behind:
|
|
878
|
+
parts.append(f"[behind]↓{behind}[/]")
|
|
879
|
+
status_str = f" {' '.join(parts)}" if parts else ""
|
|
880
|
+
|
|
881
|
+
console.print(f" [repo]{repo_name}[/] {SYM_BRANCH} [branch]{branch}[/]{status_str}")
|
|
882
|
+
console.print(f" [path]{info.get('path', '?')}[/]")
|
|
883
|
+
|
|
884
|
+
# ── Per-repo git worktrees (only show if repo has >1 worktree) ──
|
|
885
|
+
multi_wt = {
|
|
886
|
+
name: info for name, info in repos_wt.items()
|
|
887
|
+
if len(info.get("worktrees", [])) > 1
|
|
888
|
+
}
|
|
889
|
+
if multi_wt:
|
|
890
|
+
console.print()
|
|
891
|
+
console.print(f" [subheader]Git worktrees per repo[/]")
|
|
892
|
+
for repo_name, info in multi_wt.items():
|
|
893
|
+
separator()
|
|
894
|
+
console.print(f" [repo]{repo_name}[/] [path]{info['main_path']}[/]")
|
|
895
|
+
for wt in info["worktrees"]:
|
|
896
|
+
branch = wt.get("branch", "(detached)")
|
|
897
|
+
console.print(f" [path]{wt['path']}[/] [branch]\\[{branch}][/]")
|
|
898
|
+
|
|
899
|
+
console.print()
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def cmd_slots(args: argparse.Namespace) -> None:
|
|
903
|
+
"""Show slot occupancy: canonical + warm slots + last_touched.
|
|
904
|
+
|
|
905
|
+
``--json`` always returns the rich shape (single call powers the
|
|
906
|
+
dashboard + agent); pretty terminal output stays compact unless
|
|
907
|
+
``--rich`` is passed.
|
|
908
|
+
"""
|
|
909
|
+
from ..actions import slots as slots_mod
|
|
910
|
+
from .ui import console
|
|
911
|
+
|
|
912
|
+
workspace = _load_workspace()
|
|
913
|
+
state = slots_mod.read_state(workspace)
|
|
914
|
+
if args.json:
|
|
915
|
+
from ..actions.slot_details import rich_slots
|
|
916
|
+
_print_json(rich_slots(workspace))
|
|
917
|
+
return
|
|
918
|
+
if state is None:
|
|
919
|
+
console.print()
|
|
920
|
+
console.print(" [muted]No slot state yet — run `canopy switch <feature>`.[/]")
|
|
921
|
+
console.print()
|
|
922
|
+
return
|
|
923
|
+
console.print()
|
|
924
|
+
if state.canonical:
|
|
925
|
+
console.print(f" [header]Canonical:[/] [info]{state.canonical.feature}[/]"
|
|
926
|
+
f" [muted]({state.canonical.activated_at[:16]})[/]")
|
|
927
|
+
console.print(f" [header]Slots ({len(state.slots)}/{state.slot_count}):[/]")
|
|
928
|
+
for i in range(1, state.slot_count + 1):
|
|
929
|
+
sid = f"worktree-{i}"
|
|
930
|
+
entry = state.slots.get(sid)
|
|
931
|
+
if entry:
|
|
932
|
+
last = state.last_touched.get(entry.feature, "")
|
|
933
|
+
console.print(f" {sid}: [info]{entry.feature}[/]"
|
|
934
|
+
f" [muted]touched {last[:16]}[/]")
|
|
935
|
+
else:
|
|
936
|
+
console.print(f" {sid}: [muted]<empty>[/]")
|
|
937
|
+
console.print()
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def cmd_slot_load(args: argparse.Namespace) -> None:
|
|
941
|
+
"""Warm a cold feature into a slot without changing canonical."""
|
|
942
|
+
from ..actions.slot_load import slot_load
|
|
943
|
+
from .ui import console
|
|
944
|
+
|
|
945
|
+
workspace = _load_workspace()
|
|
946
|
+
result = slot_load(
|
|
947
|
+
workspace, args.feature,
|
|
948
|
+
slot_id=args.slot_id, replace=args.replace, bootstrap=args.bootstrap,
|
|
949
|
+
)
|
|
950
|
+
if args.json:
|
|
951
|
+
_print_json(result)
|
|
952
|
+
return
|
|
953
|
+
console.print(f"[ok]Loaded[/] [info]{result['feature']}[/] into [info]{result['slot_id']}[/]")
|
|
954
|
+
if result.get("evicted"):
|
|
955
|
+
console.print(f" Evicted: [muted]{result['evicted']['feature']}[/]")
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def cmd_slot_clear(args: argparse.Namespace) -> None:
|
|
959
|
+
"""Evict a slot's occupant to cold."""
|
|
960
|
+
from ..actions.slot_load import slot_clear
|
|
961
|
+
from .ui import console
|
|
962
|
+
|
|
963
|
+
workspace = _load_workspace()
|
|
964
|
+
result = slot_clear(workspace, args.slot_id)
|
|
965
|
+
if args.json:
|
|
966
|
+
_print_json(result)
|
|
967
|
+
return
|
|
968
|
+
console.print(f"[ok]Cleared[/] {result['slot_id']}: evicted [info]{result['feature']}[/]")
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def cmd_slot_swap(args: argparse.Namespace) -> None:
|
|
972
|
+
"""Exchange occupants of two slots."""
|
|
973
|
+
from ..actions.slot_load import slot_swap
|
|
974
|
+
from .ui import console
|
|
975
|
+
|
|
976
|
+
workspace = _load_workspace()
|
|
977
|
+
result = slot_swap(workspace, args.slot_a, args.slot_b)
|
|
978
|
+
if args.json:
|
|
979
|
+
_print_json(result)
|
|
980
|
+
return
|
|
981
|
+
console.print(f"[ok]Swapped:[/] {result['swapped'][0]} ({result['slot_a']} ↔ {result['slot_b']})")
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def cmd_migrate_slots(args: argparse.Namespace) -> None:
|
|
985
|
+
"""One-shot migration from pre-3.0 layout to 3.0 slot model."""
|
|
986
|
+
from ..actions.migrate_slots import migrate, AlreadyMigratedError, NotLegacyError
|
|
987
|
+
from .ui import console
|
|
988
|
+
|
|
989
|
+
# Don't use _load_workspace() — pre-3.0 canopy.toml fails load_config validation.
|
|
990
|
+
# Walk up from cwd looking for canopy.toml directly.
|
|
991
|
+
root = Path.cwd().resolve()
|
|
992
|
+
while root != root.parent:
|
|
993
|
+
if (root / "canopy.toml").exists():
|
|
994
|
+
break
|
|
995
|
+
root = root.parent
|
|
996
|
+
else:
|
|
997
|
+
print("Error: not inside a canopy workspace (no canopy.toml found)", file=sys.stderr)
|
|
998
|
+
sys.exit(1)
|
|
999
|
+
|
|
1000
|
+
try:
|
|
1001
|
+
result = migrate(root)
|
|
1002
|
+
except AlreadyMigratedError as e:
|
|
1003
|
+
print(f"Error: already migrated: {e}", file=sys.stderr)
|
|
1004
|
+
sys.exit(1)
|
|
1005
|
+
except NotLegacyError as e:
|
|
1006
|
+
console.print(f" [muted]Nothing to migrate: {e}[/]")
|
|
1007
|
+
return
|
|
1008
|
+
|
|
1009
|
+
if args.json:
|
|
1010
|
+
_print_json(result)
|
|
1011
|
+
return
|
|
1012
|
+
|
|
1013
|
+
console.print()
|
|
1014
|
+
console.print(f" [success]Migrated {len(result['moved'])} worktree dir(s) to slots[/]")
|
|
1015
|
+
for sid, feat in result["slots"].items():
|
|
1016
|
+
console.print(f" {sid}: [info]{feat}[/]")
|
|
1017
|
+
if result["canonical"]:
|
|
1018
|
+
console.print(f" [header]Canonical:[/] [info]{result['canonical']}[/]")
|
|
1019
|
+
console.print()
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _open_ide(ide_cmd: str, args: argparse.Namespace) -> None:
|
|
1023
|
+
"""Open an IDE with the right directories for a feature or workspace.
|
|
1024
|
+
|
|
1025
|
+
Supports two modes:
|
|
1026
|
+
- `canopy code <feature>` — open repos/worktrees for a feature lane
|
|
1027
|
+
- `canopy code .` — open all repos in the workspace
|
|
1028
|
+
"""
|
|
1029
|
+
workspace = _load_workspace()
|
|
1030
|
+
|
|
1031
|
+
target = args.target
|
|
1032
|
+
|
|
1033
|
+
if target == ".":
|
|
1034
|
+
# Open all repos in workspace
|
|
1035
|
+
paths = [str(state.abs_path) for state in workspace.repos
|
|
1036
|
+
if state.abs_path.exists()]
|
|
1037
|
+
label = workspace.config.name
|
|
1038
|
+
else:
|
|
1039
|
+
# Open repos for a feature lane
|
|
1040
|
+
from ..features.coordinator import FeatureCoordinator
|
|
1041
|
+
coordinator = FeatureCoordinator(workspace)
|
|
1042
|
+
try:
|
|
1043
|
+
paths_dict = coordinator.resolve_paths(target)
|
|
1044
|
+
except ValueError as e:
|
|
1045
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1046
|
+
sys.exit(1)
|
|
1047
|
+
|
|
1048
|
+
if not paths_dict:
|
|
1049
|
+
print(f"No paths found for feature '{target}'", file=sys.stderr)
|
|
1050
|
+
sys.exit(1)
|
|
1051
|
+
|
|
1052
|
+
paths = list(paths_dict.values())
|
|
1053
|
+
label = target
|
|
1054
|
+
|
|
1055
|
+
if not paths:
|
|
1056
|
+
print("No directories to open.", file=sys.stderr)
|
|
1057
|
+
sys.exit(1)
|
|
1058
|
+
|
|
1059
|
+
if args.json:
|
|
1060
|
+
_print_json({"ide": ide_cmd, "target": target, "paths": paths})
|
|
1061
|
+
return
|
|
1062
|
+
|
|
1063
|
+
# If multiple paths, generate a .code-workspace file for multi-root
|
|
1064
|
+
if len(paths) > 1:
|
|
1065
|
+
workspace_file = _generate_workspace_file(
|
|
1066
|
+
workspace.config.root, label, paths
|
|
1067
|
+
)
|
|
1068
|
+
cmd = [ide_cmd, workspace_file]
|
|
1069
|
+
print(f" Opening {ide_cmd} with workspace: {workspace_file}")
|
|
1070
|
+
else:
|
|
1071
|
+
cmd = [ide_cmd, paths[0]]
|
|
1072
|
+
print(f" Opening {ide_cmd}: {paths[0]}")
|
|
1073
|
+
|
|
1074
|
+
try:
|
|
1075
|
+
subprocess.Popen(
|
|
1076
|
+
cmd,
|
|
1077
|
+
stdout=subprocess.DEVNULL,
|
|
1078
|
+
stderr=subprocess.DEVNULL,
|
|
1079
|
+
)
|
|
1080
|
+
except FileNotFoundError:
|
|
1081
|
+
print(f"Error: '{ide_cmd}' not found. Is it installed and on PATH?",
|
|
1082
|
+
file=sys.stderr)
|
|
1083
|
+
print(f" VS Code: install 'code' command from Command Palette",
|
|
1084
|
+
file=sys.stderr)
|
|
1085
|
+
print(f" Cursor: install 'cursor' command from Command Palette",
|
|
1086
|
+
file=sys.stderr)
|
|
1087
|
+
sys.exit(1)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _generate_workspace_file(
|
|
1091
|
+
root: Path,
|
|
1092
|
+
label: str,
|
|
1093
|
+
paths: list[str],
|
|
1094
|
+
) -> str:
|
|
1095
|
+
"""Generate a .code-workspace file for multi-root workspace.
|
|
1096
|
+
|
|
1097
|
+
Returns the path to the generated file.
|
|
1098
|
+
"""
|
|
1099
|
+
canopy_dir = root / ".canopy"
|
|
1100
|
+
canopy_dir.mkdir(parents=True, exist_ok=True)
|
|
1101
|
+
|
|
1102
|
+
workspace_data = {
|
|
1103
|
+
"folders": [{"path": p} for p in paths],
|
|
1104
|
+
"settings": {
|
|
1105
|
+
"canopy.feature": label,
|
|
1106
|
+
},
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
ws_file = canopy_dir / f"{label}.code-workspace"
|
|
1110
|
+
ws_file.write_text(json.dumps(workspace_data, indent=2))
|
|
1111
|
+
return str(ws_file)
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def cmd_code(args: argparse.Namespace) -> None:
|
|
1115
|
+
"""Open VS Code with feature or workspace directories."""
|
|
1116
|
+
_open_ide("code", args)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def cmd_cursor(args: argparse.Namespace) -> None:
|
|
1120
|
+
"""Open Cursor with feature or workspace directories."""
|
|
1121
|
+
_open_ide("cursor", args)
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def cmd_fork(args: argparse.Namespace) -> None:
|
|
1125
|
+
"""Open Fork.app with feature or workspace repos."""
|
|
1126
|
+
workspace = _load_workspace()
|
|
1127
|
+
|
|
1128
|
+
target = args.target
|
|
1129
|
+
|
|
1130
|
+
if target == ".":
|
|
1131
|
+
paths = [str(state.abs_path) for state in workspace.repos
|
|
1132
|
+
if state.abs_path.exists()]
|
|
1133
|
+
else:
|
|
1134
|
+
from ..features.coordinator import FeatureCoordinator
|
|
1135
|
+
coordinator = FeatureCoordinator(workspace)
|
|
1136
|
+
try:
|
|
1137
|
+
paths_dict = coordinator.resolve_paths(target)
|
|
1138
|
+
except ValueError as e:
|
|
1139
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1140
|
+
sys.exit(1)
|
|
1141
|
+
paths = list(paths_dict.values())
|
|
1142
|
+
|
|
1143
|
+
if not paths:
|
|
1144
|
+
print("No directories to open.", file=sys.stderr)
|
|
1145
|
+
sys.exit(1)
|
|
1146
|
+
|
|
1147
|
+
if args.json:
|
|
1148
|
+
_print_json({"ide": "fork", "target": target, "paths": paths})
|
|
1149
|
+
return
|
|
1150
|
+
|
|
1151
|
+
# Fork opens repos individually — each path becomes a tab
|
|
1152
|
+
import platform
|
|
1153
|
+
import shutil
|
|
1154
|
+
|
|
1155
|
+
use_fork_cli = shutil.which("fork") is not None
|
|
1156
|
+
is_macos = platform.system() == "Darwin"
|
|
1157
|
+
|
|
1158
|
+
if not use_fork_cli and not is_macos:
|
|
1159
|
+
print(
|
|
1160
|
+
"Error: 'fork' CLI not found.\n"
|
|
1161
|
+
" Install it from Fork → Preferences → Integration → Install CLI Tool.",
|
|
1162
|
+
file=sys.stderr,
|
|
1163
|
+
)
|
|
1164
|
+
sys.exit(1)
|
|
1165
|
+
|
|
1166
|
+
import time
|
|
1167
|
+
|
|
1168
|
+
for i, p in enumerate(paths):
|
|
1169
|
+
if use_fork_cli:
|
|
1170
|
+
subprocess.Popen(
|
|
1171
|
+
["fork", p],
|
|
1172
|
+
stdout=subprocess.DEVNULL,
|
|
1173
|
+
stderr=subprocess.DEVNULL,
|
|
1174
|
+
)
|
|
1175
|
+
else:
|
|
1176
|
+
# macOS fallback: open -a Fork
|
|
1177
|
+
result = subprocess.run(
|
|
1178
|
+
["open", "-a", "Fork", p],
|
|
1179
|
+
capture_output=True, text=True,
|
|
1180
|
+
)
|
|
1181
|
+
if result.returncode != 0:
|
|
1182
|
+
print(f"Error: could not open Fork. Is Fork.app installed?",
|
|
1183
|
+
file=sys.stderr)
|
|
1184
|
+
sys.exit(1)
|
|
1185
|
+
print(f" opened: {p}")
|
|
1186
|
+
# Small delay between opens so Fork can register each repo
|
|
1187
|
+
if i < len(paths) - 1:
|
|
1188
|
+
time.sleep(0.5)
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _cmd_preflight_feature(args: argparse.Namespace) -> None:
|
|
1192
|
+
"""Feature-scoped preflight via coordinator.review_prep (records result)."""
|
|
1193
|
+
from ..features.coordinator import FeatureCoordinator
|
|
1194
|
+
from .ui import console, separator, spinner, SYM_CHECK, SYM_DOT, SYM_CROSS
|
|
1195
|
+
|
|
1196
|
+
workspace = _load_workspace()
|
|
1197
|
+
coord = FeatureCoordinator(workspace)
|
|
1198
|
+
with spinner(f"Running preflight on {args.feature}…"):
|
|
1199
|
+
result = coord.review_prep(args.feature)
|
|
1200
|
+
|
|
1201
|
+
if args.json:
|
|
1202
|
+
_print_json(result)
|
|
1203
|
+
return
|
|
1204
|
+
|
|
1205
|
+
console.print()
|
|
1206
|
+
console.print(f" [feature]{result['feature']}[/] preflight")
|
|
1207
|
+
separator()
|
|
1208
|
+
for repo, info in result["repos"].items():
|
|
1209
|
+
pc = info.get("precommit") or {}
|
|
1210
|
+
glyph = "[success]✓[/]" if pc.get("passed") else "[error]✗[/]"
|
|
1211
|
+
kind = pc.get("type", "")
|
|
1212
|
+
dirty = info.get("dirty_count", 0)
|
|
1213
|
+
console.print(f" [repo]{repo}[/] {glyph} {dirty} files [muted]{kind}[/]")
|
|
1214
|
+
console.print()
|
|
1215
|
+
if result.get("all_passed"):
|
|
1216
|
+
console.print(" [success]Ready to commit.[/]")
|
|
1217
|
+
else:
|
|
1218
|
+
console.print(" [error]One or more repos failed checks.[/]")
|
|
1219
|
+
console.print()
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def cmd_preflight(args: argparse.Namespace) -> None:
|
|
1223
|
+
if getattr(args, "feature", None):
|
|
1224
|
+
_cmd_preflight_feature(args)
|
|
1225
|
+
else:
|
|
1226
|
+
_cmd_preflight_context(args)
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def _cmd_preflight_context(args: argparse.Namespace) -> None:
|
|
1230
|
+
"""Context-aware pre-commit quality gate.
|
|
1231
|
+
|
|
1232
|
+
Detects which feature/repos you're in, stages all changes (git add -A),
|
|
1233
|
+
runs pre-commit hooks, and reports results. Does NOT commit — that's
|
|
1234
|
+
your job when you're satisfied.
|
|
1235
|
+
|
|
1236
|
+
When run from inside a feature worktree directory, checks all repo
|
|
1237
|
+
worktrees in that feature.
|
|
1238
|
+
|
|
1239
|
+
When run from inside a single repo worktree, checks just that repo.
|
|
1240
|
+
"""
|
|
1241
|
+
from ..workspace.context import detect_context
|
|
1242
|
+
from ..workspace.config import load_config, ConfigNotFoundError, ConfigError
|
|
1243
|
+
from ..git import repo as git_repo
|
|
1244
|
+
from ..integrations.precommit import run_precommit
|
|
1245
|
+
from ..actions.augments import repo_augments
|
|
1246
|
+
|
|
1247
|
+
ctx = detect_context()
|
|
1248
|
+
|
|
1249
|
+
if ctx.context_type == "unknown":
|
|
1250
|
+
print("Error: can't detect canopy context from current directory.", file=sys.stderr)
|
|
1251
|
+
print("Run this from inside a feature worktree or a workspace repo.", file=sys.stderr)
|
|
1252
|
+
sys.exit(1)
|
|
1253
|
+
|
|
1254
|
+
if not ctx.repo_paths:
|
|
1255
|
+
print("Error: no repos found in current context.", file=sys.stderr)
|
|
1256
|
+
sys.exit(1)
|
|
1257
|
+
|
|
1258
|
+
workspace_config = None
|
|
1259
|
+
if ctx.workspace_root:
|
|
1260
|
+
try:
|
|
1261
|
+
workspace_config = load_config(ctx.workspace_root)
|
|
1262
|
+
except (ConfigNotFoundError, ConfigError):
|
|
1263
|
+
workspace_config = None
|
|
1264
|
+
|
|
1265
|
+
results: dict[str, dict] = {}
|
|
1266
|
+
all_passed = True
|
|
1267
|
+
|
|
1268
|
+
for repo_path, repo_name in zip(ctx.repo_paths, ctx.repo_names):
|
|
1269
|
+
# Check if there are any changes
|
|
1270
|
+
status = git_repo.status_porcelain(repo_path)
|
|
1271
|
+
if not status:
|
|
1272
|
+
results[repo_name] = {"status": "clean", "hooks": None}
|
|
1273
|
+
continue
|
|
1274
|
+
|
|
1275
|
+
# Stage everything so hooks can inspect staged changes
|
|
1276
|
+
try:
|
|
1277
|
+
git_repo._run(["add", "-A"], cwd=repo_path)
|
|
1278
|
+
except git_repo.GitError as e:
|
|
1279
|
+
results[repo_name] = {"status": "error", "error": str(e), "hooks": None}
|
|
1280
|
+
all_passed = False
|
|
1281
|
+
continue
|
|
1282
|
+
|
|
1283
|
+
# Run pre-commit hooks (honoring per-repo augments.preflight_cmd)
|
|
1284
|
+
augments = (
|
|
1285
|
+
repo_augments(workspace_config, repo_name) if workspace_config else None
|
|
1286
|
+
)
|
|
1287
|
+
hook_result = run_precommit(repo_path, augments=augments)
|
|
1288
|
+
passed = hook_result["passed"]
|
|
1289
|
+
if not passed:
|
|
1290
|
+
all_passed = False
|
|
1291
|
+
|
|
1292
|
+
dirty_count = len(status)
|
|
1293
|
+
results[repo_name] = {
|
|
1294
|
+
"status": "staged" if passed else "hooks_failed",
|
|
1295
|
+
"dirty_count": dirty_count,
|
|
1296
|
+
"hooks": hook_result,
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
# Persist preflight result so feature_state can distinguish
|
|
1300
|
+
# IN_PROGRESS from READY_TO_COMMIT. Maps detect_context's directory
|
|
1301
|
+
# names back to canopy-registered repo names so feature_state
|
|
1302
|
+
# (which uses canonical names) can match.
|
|
1303
|
+
#
|
|
1304
|
+
# F-11: when run from the workspace root (not inside a worktree),
|
|
1305
|
+
# ``ctx.feature`` is None — but we may still have a canonical
|
|
1306
|
+
# feature in ``slots.json`` whose repos overlap. Fall back
|
|
1307
|
+
# to that so `canopy preflight` from the workspace root persists a
|
|
1308
|
+
# record for the canonical feature, which is what `canopy state`
|
|
1309
|
+
# then keys off to surface ready_to_commit.
|
|
1310
|
+
feature_for_record = ctx.feature
|
|
1311
|
+
if feature_for_record is None and ctx.workspace_root:
|
|
1312
|
+
try:
|
|
1313
|
+
from ..actions import slots as _slots_mod
|
|
1314
|
+
from ..workspace.config import load_config as _load_config
|
|
1315
|
+
from ..workspace.workspace import Workspace as _WS
|
|
1316
|
+
_ws = _WS(_load_config(ctx.workspace_root))
|
|
1317
|
+
_state = _slots_mod.read_state(_ws)
|
|
1318
|
+
if _state and _state.canonical and _state.canonical.feature:
|
|
1319
|
+
feature_for_record = _state.canonical.feature
|
|
1320
|
+
except Exception:
|
|
1321
|
+
pass
|
|
1322
|
+
|
|
1323
|
+
if feature_for_record and ctx.workspace_root:
|
|
1324
|
+
try:
|
|
1325
|
+
from ..actions.preflight_state import record_result
|
|
1326
|
+
from ..workspace.config import load_config
|
|
1327
|
+
from ..workspace.workspace import Workspace as _WS
|
|
1328
|
+
cfg = load_config(ctx.workspace_root)
|
|
1329
|
+
ws = _WS(cfg)
|
|
1330
|
+
path_to_canonical = {
|
|
1331
|
+
str(state.abs_path.resolve()): state.config.name
|
|
1332
|
+
for state in ws.repos
|
|
1333
|
+
}
|
|
1334
|
+
head_sha_per_repo: dict[str, str] = {}
|
|
1335
|
+
for path in ctx.repo_paths:
|
|
1336
|
+
resolved = str(Path(path).resolve())
|
|
1337
|
+
canonical = path_to_canonical.get(resolved)
|
|
1338
|
+
if not canonical:
|
|
1339
|
+
continue
|
|
1340
|
+
head_sha_per_repo[canonical] = git_repo.head_sha(path)
|
|
1341
|
+
if head_sha_per_repo:
|
|
1342
|
+
record_result(
|
|
1343
|
+
ctx.workspace_root, feature_for_record,
|
|
1344
|
+
passed=all_passed,
|
|
1345
|
+
head_sha_per_repo=head_sha_per_repo,
|
|
1346
|
+
summary=("preflight passed" if all_passed
|
|
1347
|
+
else "preflight failed"),
|
|
1348
|
+
)
|
|
1349
|
+
except Exception:
|
|
1350
|
+
pass
|
|
1351
|
+
|
|
1352
|
+
if args.json:
|
|
1353
|
+
_print_json({
|
|
1354
|
+
"feature": ctx.feature,
|
|
1355
|
+
"context_type": ctx.context_type,
|
|
1356
|
+
"all_passed": all_passed,
|
|
1357
|
+
"results": results,
|
|
1358
|
+
})
|
|
1359
|
+
return
|
|
1360
|
+
|
|
1361
|
+
from .ui import console, separator, SYM_CHECK, SYM_DOT, SYM_CROSS
|
|
1362
|
+
|
|
1363
|
+
console.print()
|
|
1364
|
+
if ctx.feature:
|
|
1365
|
+
console.print(f" [feature]{ctx.feature}[/] preflight")
|
|
1366
|
+
else:
|
|
1367
|
+
console.print(f" preflight")
|
|
1368
|
+
separator()
|
|
1369
|
+
|
|
1370
|
+
for repo, result in results.items():
|
|
1371
|
+
status = result["status"]
|
|
1372
|
+
if status == "clean":
|
|
1373
|
+
console.print(f" [repo]{repo}[/] [muted]{SYM_DOT} clean[/]")
|
|
1374
|
+
elif status == "error":
|
|
1375
|
+
console.print(f" [repo]{repo}[/] [error]{SYM_CROSS} {result['error']}[/]")
|
|
1376
|
+
elif status == "hooks_failed":
|
|
1377
|
+
dirty = result["dirty_count"]
|
|
1378
|
+
console.print(f" [repo]{repo}[/] [error]{SYM_CROSS} hooks failed[/] [muted]{dirty} staged[/]")
|
|
1379
|
+
# Show hook output indented
|
|
1380
|
+
hook_output = result["hooks"]["output"]
|
|
1381
|
+
if hook_output:
|
|
1382
|
+
for line in hook_output.splitlines()[:20]:
|
|
1383
|
+
console.print(f" [muted]{line}[/]")
|
|
1384
|
+
if len(hook_output.splitlines()) > 20:
|
|
1385
|
+
console.print(f" [muted]... ({len(hook_output.splitlines()) - 20} more lines)[/]")
|
|
1386
|
+
else:
|
|
1387
|
+
# staged, hooks passed
|
|
1388
|
+
dirty = result["dirty_count"]
|
|
1389
|
+
hook_type = result["hooks"]["type"] if result["hooks"] else "none"
|
|
1390
|
+
if hook_type == "none":
|
|
1391
|
+
console.print(f" [repo]{repo}[/] [success]{SYM_CHECK} {dirty} staged[/] [muted]no hooks[/]")
|
|
1392
|
+
else:
|
|
1393
|
+
console.print(f" [repo]{repo}[/] [success]{SYM_CHECK} {dirty} staged[/] [success]hooks passed[/]")
|
|
1394
|
+
|
|
1395
|
+
console.print()
|
|
1396
|
+
if all_passed:
|
|
1397
|
+
console.print(" [success]Ready to commit.[/]")
|
|
1398
|
+
else:
|
|
1399
|
+
console.print(" [error]Fix hook failures, then run preflight again.[/]")
|
|
1400
|
+
console.print()
|
|
1401
|
+
print()
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def cmd_review(args: argparse.Namespace) -> None:
|
|
1405
|
+
"""Fetch PR review comments and run preflight.
|
|
1406
|
+
|
|
1407
|
+
Full workflow:
|
|
1408
|
+
1. Check if PRs exist for the feature
|
|
1409
|
+
2. Fetch unresolved review comments
|
|
1410
|
+
3. Run pre-commit hooks + stage changes (preflight)
|
|
1411
|
+
"""
|
|
1412
|
+
from .ui import console, spinner, separator, print_success, print_warning, print_error, SYM_CHECK, SYM_CROSS, SYM_LINK
|
|
1413
|
+
from ..integrations.github import GitHubNotConfiguredError, PullRequestNotFoundError
|
|
1414
|
+
|
|
1415
|
+
workspace = _load_workspace()
|
|
1416
|
+
from ..features.coordinator import FeatureCoordinator
|
|
1417
|
+
|
|
1418
|
+
coordinator = FeatureCoordinator(workspace)
|
|
1419
|
+
feature = args.name
|
|
1420
|
+
|
|
1421
|
+
# ── Step 1: Check PR status ──
|
|
1422
|
+
try:
|
|
1423
|
+
with spinner(f"Checking PRs for {feature}..."):
|
|
1424
|
+
status = coordinator.review_status(feature)
|
|
1425
|
+
except GitHubNotConfiguredError as e:
|
|
1426
|
+
print_error(str(e))
|
|
1427
|
+
sys.exit(1)
|
|
1428
|
+
except ValueError as e:
|
|
1429
|
+
print_error(str(e))
|
|
1430
|
+
sys.exit(1)
|
|
1431
|
+
|
|
1432
|
+
if not status["has_prs"]:
|
|
1433
|
+
print_error(f"No open PRs found for feature '{feature}'")
|
|
1434
|
+
console.print(f" [muted]Push your branch and create a PR first.[/]")
|
|
1435
|
+
if args.json:
|
|
1436
|
+
_print_json(status)
|
|
1437
|
+
sys.exit(1)
|
|
1438
|
+
|
|
1439
|
+
# ── Step 2: Fetch comments ──
|
|
1440
|
+
try:
|
|
1441
|
+
with spinner(f"Fetching review comments..."):
|
|
1442
|
+
comments_data = coordinator.review_comments(feature)
|
|
1443
|
+
except PullRequestNotFoundError as e:
|
|
1444
|
+
print_error(str(e))
|
|
1445
|
+
sys.exit(1)
|
|
1446
|
+
|
|
1447
|
+
# ── Step 3: Run pre-commit + stage ──
|
|
1448
|
+
prep_data = None
|
|
1449
|
+
if not args.comments_only:
|
|
1450
|
+
with spinner(f"Running pre-commit hooks..."):
|
|
1451
|
+
prep_data = coordinator.review_prep(
|
|
1452
|
+
feature, message=args.message or "",
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
if args.json:
|
|
1456
|
+
result = {
|
|
1457
|
+
"review_status": status,
|
|
1458
|
+
"comments": comments_data,
|
|
1459
|
+
}
|
|
1460
|
+
if prep_data:
|
|
1461
|
+
result["prep"] = prep_data
|
|
1462
|
+
_print_json(result)
|
|
1463
|
+
return
|
|
1464
|
+
|
|
1465
|
+
# ── Display PR status ──
|
|
1466
|
+
console.print()
|
|
1467
|
+
console.print(f" [header]Review: {feature}[/]")
|
|
1468
|
+
|
|
1469
|
+
for repo_name, info in status["repos"].items():
|
|
1470
|
+
pr = info.get("pr")
|
|
1471
|
+
if pr:
|
|
1472
|
+
console.print(
|
|
1473
|
+
f" [repo]{repo_name}[/] "
|
|
1474
|
+
f"[linear]{SYM_LINK} #{pr['number']}[/] {pr['title']}"
|
|
1475
|
+
)
|
|
1476
|
+
console.print(f" [path]{pr.get('url', '')}[/]")
|
|
1477
|
+
elif "error" in info:
|
|
1478
|
+
console.print(f" [repo]{repo_name}[/] [error]{info['error']}[/]")
|
|
1479
|
+
else:
|
|
1480
|
+
console.print(f" [repo]{repo_name}[/] [muted]no PR[/]")
|
|
1481
|
+
|
|
1482
|
+
# ── Display comments ──
|
|
1483
|
+
separator()
|
|
1484
|
+
total = comments_data.get("total_comments", 0)
|
|
1485
|
+
if total == 0:
|
|
1486
|
+
print_success("No unresolved review comments")
|
|
1487
|
+
else:
|
|
1488
|
+
console.print(f" [warning]{total} unresolved comment{'s' if total != 1 else ''}[/]")
|
|
1489
|
+
console.print()
|
|
1490
|
+
|
|
1491
|
+
for repo_name, repo_data in comments_data.get("repos", {}).items():
|
|
1492
|
+
comments = repo_data.get("comments", [])
|
|
1493
|
+
if not comments:
|
|
1494
|
+
continue
|
|
1495
|
+
|
|
1496
|
+
console.print(f" [repo]{repo_name}[/] [muted]#{repo_data.get('pr_number', '?')}[/]")
|
|
1497
|
+
|
|
1498
|
+
# Group by file
|
|
1499
|
+
by_file: dict[str, list] = {}
|
|
1500
|
+
for c in comments:
|
|
1501
|
+
path = c.get("path") or "(general)"
|
|
1502
|
+
by_file.setdefault(path, []).append(c)
|
|
1503
|
+
|
|
1504
|
+
for filepath, file_comments in by_file.items():
|
|
1505
|
+
console.print(f" [path]{filepath}[/]")
|
|
1506
|
+
for c in file_comments:
|
|
1507
|
+
line = c.get("line")
|
|
1508
|
+
line_str = f"L{line}" if line else ""
|
|
1509
|
+
author = c.get("author", "")
|
|
1510
|
+
body = c.get("body", "").split("\n")[0][:120]
|
|
1511
|
+
console.print(
|
|
1512
|
+
f" [muted]{line_str}[/] "
|
|
1513
|
+
f"[info]{author}[/]: {body}"
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
# ── Display prep results ──
|
|
1517
|
+
if prep_data:
|
|
1518
|
+
separator()
|
|
1519
|
+
if prep_data["all_passed"]:
|
|
1520
|
+
print_success("Pre-commit hooks passed")
|
|
1521
|
+
else:
|
|
1522
|
+
print_warning("Pre-commit hooks failed in some repos")
|
|
1523
|
+
|
|
1524
|
+
for repo_name, info in prep_data["repos"].items():
|
|
1525
|
+
pc = info.get("precommit", {})
|
|
1526
|
+
pc_type = pc.get("type", "none")
|
|
1527
|
+
passed = pc.get("passed", True)
|
|
1528
|
+
staged = info.get("staged", False)
|
|
1529
|
+
dirty = info.get("dirty_count", 0)
|
|
1530
|
+
|
|
1531
|
+
status_parts = []
|
|
1532
|
+
if pc_type != "none":
|
|
1533
|
+
icon = SYM_CHECK if passed else SYM_CROSS
|
|
1534
|
+
style = "success" if passed else "error"
|
|
1535
|
+
status_parts.append(f"[{style}]{icon} hooks[/]")
|
|
1536
|
+
if staged:
|
|
1537
|
+
status_parts.append(f"[ahead]{dirty} staged[/]")
|
|
1538
|
+
elif dirty == 0:
|
|
1539
|
+
status_parts.append("[muted]clean[/]")
|
|
1540
|
+
|
|
1541
|
+
console.print(
|
|
1542
|
+
f" [repo]{repo_name}[/] {' '.join(status_parts)}"
|
|
1543
|
+
)
|
|
1544
|
+
|
|
1545
|
+
if not passed and pc.get("output"):
|
|
1546
|
+
# Show first few lines of hook output
|
|
1547
|
+
for line in pc["output"].split("\n")[:5]:
|
|
1548
|
+
console.print(f" [muted]{line}[/]")
|
|
1549
|
+
|
|
1550
|
+
console.print()
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def cmd_list(args: argparse.Namespace) -> None:
|
|
1554
|
+
"""List all feature lanes — quick overview of what exists."""
|
|
1555
|
+
from .ui import console, separator, SYM_BRANCH, SYM_LINK, SYM_ARROW
|
|
1556
|
+
|
|
1557
|
+
workspace = _load_workspace()
|
|
1558
|
+
from ..features.coordinator import FeatureCoordinator
|
|
1559
|
+
|
|
1560
|
+
coordinator = FeatureCoordinator(workspace)
|
|
1561
|
+
lanes = coordinator.list_active()
|
|
1562
|
+
|
|
1563
|
+
if args.json:
|
|
1564
|
+
_print_json([lane.to_dict() for lane in lanes])
|
|
1565
|
+
return
|
|
1566
|
+
|
|
1567
|
+
if not lanes:
|
|
1568
|
+
console.print()
|
|
1569
|
+
console.print(" [muted]No features.[/] Create one with [info]canopy worktree <name>[/]")
|
|
1570
|
+
console.print()
|
|
1571
|
+
return
|
|
1572
|
+
|
|
1573
|
+
console.print()
|
|
1574
|
+
for lane in lanes:
|
|
1575
|
+
# Feature name + Linear link on one line
|
|
1576
|
+
linear_str = ""
|
|
1577
|
+
if lane.linear_issue:
|
|
1578
|
+
title_bit = f" {lane.linear_title}" if lane.linear_title else ""
|
|
1579
|
+
linear_str = f" [linear]{SYM_LINK} {lane.linear_issue}{title_bit}[/]"
|
|
1580
|
+
|
|
1581
|
+
console.print(f" [feature]{lane.name}[/]{linear_str}")
|
|
1582
|
+
|
|
1583
|
+
# Per-repo: branch context line
|
|
1584
|
+
for repo_name, state in lane.repo_states.items():
|
|
1585
|
+
if "error" in state or not state.get("has_branch"):
|
|
1586
|
+
console.print(f" [muted]{repo_name}[/] [muted]no branch[/]")
|
|
1587
|
+
continue
|
|
1588
|
+
|
|
1589
|
+
parts = [f" [repo]{repo_name}[/]"]
|
|
1590
|
+
# Dirty count
|
|
1591
|
+
dirty_count = state.get("changed_file_count", 0)
|
|
1592
|
+
if state.get("dirty") and dirty_count:
|
|
1593
|
+
parts.append(f"[dirty]{dirty_count} dirty[/]")
|
|
1594
|
+
elif state.get("dirty"):
|
|
1595
|
+
parts.append("[dirty]*[/]")
|
|
1596
|
+
# Ahead/behind
|
|
1597
|
+
if state.get("ahead"):
|
|
1598
|
+
parts.append(f"[ahead]↑{state['ahead']}[/]")
|
|
1599
|
+
if state.get("behind"):
|
|
1600
|
+
parts.append(f"[behind]↓{state['behind']}[/]")
|
|
1601
|
+
# Worktree path
|
|
1602
|
+
wt_path = state.get("worktree_path")
|
|
1603
|
+
if wt_path:
|
|
1604
|
+
parts.append(f"[path]{wt_path}[/]")
|
|
1605
|
+
|
|
1606
|
+
console.print(" ".join(parts))
|
|
1607
|
+
|
|
1608
|
+
console.print()
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def cmd_done(args: argparse.Namespace) -> None:
|
|
1612
|
+
"""Clean up a completed feature — remove worktrees, branches, archive."""
|
|
1613
|
+
from .ui import console, spinner, print_success, print_error, print_warning, separator, SYM_CHECK, SYM_CROSS, SYM_ARROW
|
|
1614
|
+
|
|
1615
|
+
workspace = _load_workspace()
|
|
1616
|
+
from ..features.coordinator import FeatureCoordinator
|
|
1617
|
+
|
|
1618
|
+
coordinator = FeatureCoordinator(workspace)
|
|
1619
|
+
name = args.name
|
|
1620
|
+
|
|
1621
|
+
# Resolve alias for display
|
|
1622
|
+
resolved = coordinator._resolve_name(name)
|
|
1623
|
+
|
|
1624
|
+
try:
|
|
1625
|
+
with spinner(f"Cleaning up {resolved}..."):
|
|
1626
|
+
result = coordinator.done(name, force=args.force)
|
|
1627
|
+
except ValueError as e:
|
|
1628
|
+
print_error(str(e))
|
|
1629
|
+
sys.exit(1)
|
|
1630
|
+
|
|
1631
|
+
if args.json:
|
|
1632
|
+
_print_json(result)
|
|
1633
|
+
return
|
|
1634
|
+
|
|
1635
|
+
feature = result["feature"]
|
|
1636
|
+
console.print()
|
|
1637
|
+
|
|
1638
|
+
# Show alias resolution
|
|
1639
|
+
if name != feature:
|
|
1640
|
+
console.print(f" [muted]{name} {SYM_ARROW}[/] [feature]{feature}[/]")
|
|
1641
|
+
console.print()
|
|
1642
|
+
|
|
1643
|
+
console.print(f" [header]Done: {feature}[/]")
|
|
1644
|
+
|
|
1645
|
+
wt = result.get("worktrees_removed", {})
|
|
1646
|
+
if wt:
|
|
1647
|
+
separator()
|
|
1648
|
+
console.print(f" [muted]Worktrees removed:[/]")
|
|
1649
|
+
for repo, path in wt.items():
|
|
1650
|
+
if "error" in str(path):
|
|
1651
|
+
console.print(f" [repo]{repo}[/] [error]{path}[/]")
|
|
1652
|
+
else:
|
|
1653
|
+
print_success(f"[repo]{repo}[/] [muted]{path}[/]")
|
|
1654
|
+
|
|
1655
|
+
br = result.get("branches_deleted", {})
|
|
1656
|
+
if br:
|
|
1657
|
+
separator()
|
|
1658
|
+
console.print(f" [muted]Branches deleted:[/]")
|
|
1659
|
+
for repo, status in br.items():
|
|
1660
|
+
if status == "ok":
|
|
1661
|
+
print_success(f"[repo]{repo}[/] [branch]{feature}[/] [muted]deleted[/]")
|
|
1662
|
+
elif status == "no branch":
|
|
1663
|
+
console.print(f" [repo]{repo}[/] [muted]no branch[/]")
|
|
1664
|
+
else:
|
|
1665
|
+
print_warning(f"[repo]{repo}[/] {status}")
|
|
1666
|
+
|
|
1667
|
+
if result.get("archived"):
|
|
1668
|
+
separator()
|
|
1669
|
+
print_success("Archived in features.json")
|
|
1670
|
+
|
|
1671
|
+
console.print()
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def cmd_config(args: argparse.Namespace) -> None:
|
|
1675
|
+
"""Read or write workspace settings in canopy.toml."""
|
|
1676
|
+
from .ui import console, print_success, print_error
|
|
1677
|
+
from ..workspace.config import (
|
|
1678
|
+
get_config_value, set_config_value, get_all_config,
|
|
1679
|
+
ConfigNotFoundError, ConfigError, WORKSPACE_SETTINGS,
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
# Find workspace root
|
|
1683
|
+
from ..workspace.config import _find_config
|
|
1684
|
+
try:
|
|
1685
|
+
toml_path = _find_config()
|
|
1686
|
+
root = toml_path.parent
|
|
1687
|
+
except ConfigNotFoundError as e:
|
|
1688
|
+
print_error(str(e))
|
|
1689
|
+
sys.exit(1)
|
|
1690
|
+
|
|
1691
|
+
key = args.key
|
|
1692
|
+
value = args.value
|
|
1693
|
+
|
|
1694
|
+
try:
|
|
1695
|
+
if key is None:
|
|
1696
|
+
# Show all settings
|
|
1697
|
+
settings = get_all_config(root)
|
|
1698
|
+
if args.json:
|
|
1699
|
+
_print_json(settings)
|
|
1700
|
+
return
|
|
1701
|
+
console.print()
|
|
1702
|
+
for k, v in settings.items():
|
|
1703
|
+
display = v if v is not None else "[muted]not set[/]"
|
|
1704
|
+
console.print(f" [info]{k}[/] = {display}")
|
|
1705
|
+
console.print()
|
|
1706
|
+
|
|
1707
|
+
elif value is None:
|
|
1708
|
+
# Get a single setting
|
|
1709
|
+
v = get_config_value(root, key)
|
|
1710
|
+
if args.json:
|
|
1711
|
+
_print_json({"key": key, "value": v})
|
|
1712
|
+
return
|
|
1713
|
+
if v is not None:
|
|
1714
|
+
console.print(f" {v}")
|
|
1715
|
+
else:
|
|
1716
|
+
console.print(f" [muted]not set[/]")
|
|
1717
|
+
|
|
1718
|
+
else:
|
|
1719
|
+
# Set a value
|
|
1720
|
+
coerced = set_config_value(root, key, value)
|
|
1721
|
+
if args.json:
|
|
1722
|
+
_print_json({"key": key, "value": coerced})
|
|
1723
|
+
return
|
|
1724
|
+
print_success(f"[info]{key}[/] = {coerced}")
|
|
1725
|
+
|
|
1726
|
+
except (ConfigNotFoundError, ConfigError) as e:
|
|
1727
|
+
print_error(str(e))
|
|
1728
|
+
sys.exit(1)
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
def _install_hooks_for_repos(root: Path, repos) -> list[dict]:
|
|
1732
|
+
"""Install canopy post-checkout hooks in each non-worktree repo.
|
|
1733
|
+
|
|
1734
|
+
Worktrees share their main repo's hooks dir, so they're skipped here.
|
|
1735
|
+
Returns a list of {repo, action, path} dicts (one per non-worktree repo).
|
|
1736
|
+
"""
|
|
1737
|
+
from ..git.hooks import install_hook
|
|
1738
|
+
|
|
1739
|
+
results = []
|
|
1740
|
+
for r in repos:
|
|
1741
|
+
if r.is_worktree:
|
|
1742
|
+
continue
|
|
1743
|
+
repo_abs = (root / r.path).resolve() if not Path(r.path).is_absolute() else Path(r.path)
|
|
1744
|
+
try:
|
|
1745
|
+
res = install_hook(repo_abs, r.name, root)
|
|
1746
|
+
results.append({"repo": res.repo, "action": res.action, "path": res.path})
|
|
1747
|
+
except Exception as e:
|
|
1748
|
+
results.append({"repo": r.name, "action": "failed", "error": str(e)})
|
|
1749
|
+
return results
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def cmd_hooks(args: argparse.Namespace) -> None:
|
|
1753
|
+
"""Manage drift-tracking post-checkout hooks across the workspace."""
|
|
1754
|
+
from ..git.hooks import install_hook, uninstall_hook, hook_status, read_heads_state
|
|
1755
|
+
from .ui import console, print_success, print_error, print_warning
|
|
1756
|
+
|
|
1757
|
+
workspace = _load_workspace()
|
|
1758
|
+
root = workspace.config.root
|
|
1759
|
+
|
|
1760
|
+
sub = getattr(args, "hooks_command", None) or "status"
|
|
1761
|
+
results: list[dict] = []
|
|
1762
|
+
|
|
1763
|
+
for state in workspace.repos:
|
|
1764
|
+
if state.config.is_worktree:
|
|
1765
|
+
continue
|
|
1766
|
+
repo_abs = state.abs_path
|
|
1767
|
+
try:
|
|
1768
|
+
if sub == "install":
|
|
1769
|
+
r = install_hook(repo_abs, state.config.name, root)
|
|
1770
|
+
results.append({"repo": r.repo, "action": r.action, "path": r.path})
|
|
1771
|
+
elif sub == "uninstall":
|
|
1772
|
+
r = uninstall_hook(repo_abs, state.config.name)
|
|
1773
|
+
results.append({
|
|
1774
|
+
"repo": r.repo, "action": r.action, "reason": r.reason,
|
|
1775
|
+
})
|
|
1776
|
+
elif sub == "status":
|
|
1777
|
+
s = hook_status(repo_abs)
|
|
1778
|
+
results.append({"repo": state.config.name, **s})
|
|
1779
|
+
else:
|
|
1780
|
+
print_error(f"Unknown hooks subcommand: {sub}")
|
|
1781
|
+
sys.exit(2)
|
|
1782
|
+
except Exception as e:
|
|
1783
|
+
results.append({"repo": state.config.name, "action": "failed", "error": str(e)})
|
|
1784
|
+
|
|
1785
|
+
if args.json:
|
|
1786
|
+
payload = {"command": sub, "repos": results}
|
|
1787
|
+
if sub == "status":
|
|
1788
|
+
payload["heads_state"] = read_heads_state(root)
|
|
1789
|
+
_print_json(payload)
|
|
1790
|
+
return
|
|
1791
|
+
|
|
1792
|
+
console.print()
|
|
1793
|
+
if sub == "status":
|
|
1794
|
+
heads = read_heads_state(root)
|
|
1795
|
+
for r in results:
|
|
1796
|
+
mark = "[green]✓[/]" if r.get("installed") else (
|
|
1797
|
+
"[yellow]foreign[/]" if r.get("foreign_hook") else "[red]✗[/]"
|
|
1798
|
+
)
|
|
1799
|
+
head = heads.get(r["repo"], {})
|
|
1800
|
+
head_note = f" [muted]→ {head['branch']} @ {head['sha'][:8]}[/]" if head else ""
|
|
1801
|
+
chained = " [muted](chained user hook present)[/]" if r.get("chained_present") else ""
|
|
1802
|
+
console.print(f" {mark} [repo]{r['repo']}[/]{head_note}{chained}")
|
|
1803
|
+
else:
|
|
1804
|
+
for r in results:
|
|
1805
|
+
action = r.get("action", "unknown")
|
|
1806
|
+
extra = ""
|
|
1807
|
+
if action == "failed":
|
|
1808
|
+
extra = f" [red]{r.get('error', '')}[/]"
|
|
1809
|
+
elif r.get("reason"):
|
|
1810
|
+
extra = f" [muted]({r['reason']})[/]"
|
|
1811
|
+
console.print(f" [repo]{r['repo']}[/] [muted]→[/] {action}{extra}")
|
|
1812
|
+
console.print()
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
def cmd_run(args: argparse.Namespace) -> None:
|
|
1816
|
+
"""Run a shell command in a canopy-managed repo, with directory resolution."""
|
|
1817
|
+
from ..agent.runner import run_in_repo
|
|
1818
|
+
from ..actions.errors import ActionError
|
|
1819
|
+
from .render import render_blocker
|
|
1820
|
+
from .ui import console
|
|
1821
|
+
|
|
1822
|
+
workspace = _load_workspace()
|
|
1823
|
+
try:
|
|
1824
|
+
result = run_in_repo(
|
|
1825
|
+
workspace,
|
|
1826
|
+
repo=args.repo,
|
|
1827
|
+
command=args.cmd,
|
|
1828
|
+
feature=getattr(args, "feature", None),
|
|
1829
|
+
timeout_seconds=getattr(args, "timeout", 60),
|
|
1830
|
+
)
|
|
1831
|
+
except ActionError as err:
|
|
1832
|
+
if args.json:
|
|
1833
|
+
_print_json(err.to_dict())
|
|
1834
|
+
else:
|
|
1835
|
+
render_blocker(err, action="run")
|
|
1836
|
+
sys.exit(1)
|
|
1837
|
+
|
|
1838
|
+
if args.json:
|
|
1839
|
+
_print_json(result)
|
|
1840
|
+
return
|
|
1841
|
+
|
|
1842
|
+
if result["stdout"]:
|
|
1843
|
+
sys.stdout.write(result["stdout"])
|
|
1844
|
+
if not result["stdout"].endswith("\n"):
|
|
1845
|
+
sys.stdout.write("\n")
|
|
1846
|
+
if result["stderr"]:
|
|
1847
|
+
sys.stderr.write(result["stderr"])
|
|
1848
|
+
if not result["stderr"].endswith("\n"):
|
|
1849
|
+
sys.stderr.write("\n")
|
|
1850
|
+
sys.exit(result["exit_code"])
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
def _read_command(impl, args, action_label: str, *extra_kwargs_keys):
|
|
1854
|
+
"""Run a read primitive with structured error rendering. Returns the result dict
|
|
1855
|
+
(or None on failure — caller should sys.exit after this)."""
|
|
1856
|
+
from ..actions.errors import ActionError
|
|
1857
|
+
from .render import render_blocker
|
|
1858
|
+
|
|
1859
|
+
workspace = _load_workspace()
|
|
1860
|
+
kwargs = {k: getattr(args, k) for k in extra_kwargs_keys if hasattr(args, k)}
|
|
1861
|
+
try:
|
|
1862
|
+
return impl(workspace, args.alias, **kwargs)
|
|
1863
|
+
except ActionError as err:
|
|
1864
|
+
if args.json:
|
|
1865
|
+
_print_json(err.to_dict())
|
|
1866
|
+
else:
|
|
1867
|
+
render_blocker(err, action=action_label)
|
|
1868
|
+
sys.exit(1)
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def cmd_issue(args: argparse.Namespace) -> None:
|
|
1872
|
+
"""Fetch an issue from the workspace's configured provider.
|
|
1873
|
+
|
|
1874
|
+
Uses the M5 ``issue_get`` action so the CLI surface matches the
|
|
1875
|
+
``mcp__canopy__issue_get`` MCP tool — canonical state mapping
|
|
1876
|
+
(``todo`` / ``in_progress`` / ``done`` / ``cancelled``) and the
|
|
1877
|
+
full ``Issue`` shape (id, identifier, title, description, state,
|
|
1878
|
+
url, assignee, labels, priority, raw).
|
|
1879
|
+
"""
|
|
1880
|
+
from ..actions.reads import issue_get
|
|
1881
|
+
from .ui import console
|
|
1882
|
+
|
|
1883
|
+
result = _read_command(issue_get, args, "issue")
|
|
1884
|
+
if args.json:
|
|
1885
|
+
_print_json(result)
|
|
1886
|
+
return
|
|
1887
|
+
console.print()
|
|
1888
|
+
identifier = result.get("identifier") or result.get("id") or ""
|
|
1889
|
+
state = result.get("state") or ""
|
|
1890
|
+
console.print(f" [feature]{identifier}[/] [muted]{state}[/]")
|
|
1891
|
+
if result.get("title"):
|
|
1892
|
+
console.print(f" {result['title']}")
|
|
1893
|
+
if result.get("url"):
|
|
1894
|
+
console.print(f" [muted]{result['url']}[/]")
|
|
1895
|
+
if result.get("assignee"):
|
|
1896
|
+
console.print(f" [muted]assignee:[/] {result['assignee']}")
|
|
1897
|
+
labels = result.get("labels") or []
|
|
1898
|
+
if labels:
|
|
1899
|
+
console.print(f" [muted]labels:[/] {', '.join(labels)}")
|
|
1900
|
+
if result.get("description"):
|
|
1901
|
+
desc = result["description"].strip()
|
|
1902
|
+
if len(desc) > 400:
|
|
1903
|
+
desc = desc[:400] + "…"
|
|
1904
|
+
console.print()
|
|
1905
|
+
console.print(f" [muted]{desc}[/]")
|
|
1906
|
+
console.print()
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
def cmd_issues(args: argparse.Namespace) -> None:
|
|
1910
|
+
"""List the current user's open issues from the configured provider (F-5).
|
|
1911
|
+
|
|
1912
|
+
Mirrors ``mcp__canopy__issue_list_my_issues``. Empty list when the
|
|
1913
|
+
provider isn't configured (no autocomplete signal). Each entry is
|
|
1914
|
+
the canonical ``Issue.to_dict()`` shape.
|
|
1915
|
+
"""
|
|
1916
|
+
from ..providers import (
|
|
1917
|
+
IssueProviderError, ProviderNotConfigured, get_issue_provider,
|
|
1918
|
+
)
|
|
1919
|
+
from ..actions.errors import BlockerError, FixAction
|
|
1920
|
+
from .render import render_blocker
|
|
1921
|
+
from .ui import console
|
|
1922
|
+
|
|
1923
|
+
workspace = _load_workspace()
|
|
1924
|
+
try:
|
|
1925
|
+
provider = get_issue_provider(workspace)
|
|
1926
|
+
issues = provider.list_my_issues(limit=args.limit)
|
|
1927
|
+
except ProviderNotConfigured:
|
|
1928
|
+
if args.json:
|
|
1929
|
+
_print_json([])
|
|
1930
|
+
else:
|
|
1931
|
+
console.print(" [muted]no issue provider configured[/]")
|
|
1932
|
+
return
|
|
1933
|
+
except IssueProviderError as e:
|
|
1934
|
+
err = BlockerError(
|
|
1935
|
+
code="issue_provider_failed",
|
|
1936
|
+
what="issue provider call failed",
|
|
1937
|
+
details={"error": str(e)},
|
|
1938
|
+
fix_actions=[
|
|
1939
|
+
FixAction(action="doctor", args={}, safe=True,
|
|
1940
|
+
preview="canopy doctor surfaces provider config drift"),
|
|
1941
|
+
],
|
|
1942
|
+
)
|
|
1943
|
+
if args.json:
|
|
1944
|
+
_print_json(err.to_dict())
|
|
1945
|
+
else:
|
|
1946
|
+
render_blocker(err, action="issues")
|
|
1947
|
+
sys.exit(1)
|
|
1948
|
+
|
|
1949
|
+
items = [i.to_dict() for i in issues]
|
|
1950
|
+
if args.json:
|
|
1951
|
+
_print_json(items)
|
|
1952
|
+
return
|
|
1953
|
+
console.print()
|
|
1954
|
+
if not items:
|
|
1955
|
+
console.print(" [muted]no open issues[/]")
|
|
1956
|
+
console.print()
|
|
1957
|
+
return
|
|
1958
|
+
for it in items:
|
|
1959
|
+
identifier = it.get("identifier") or it.get("id") or ""
|
|
1960
|
+
state = it.get("state") or ""
|
|
1961
|
+
title = it.get("title") or ""
|
|
1962
|
+
console.print(f" [feature]{identifier}[/] [muted]{state}[/] {title}")
|
|
1963
|
+
console.print()
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
def cmd_pr(args: argparse.Namespace) -> None:
|
|
1967
|
+
"""Fetch PR data per repo for an alias."""
|
|
1968
|
+
from ..actions.reads import github_get_pr
|
|
1969
|
+
from .ui import console
|
|
1970
|
+
|
|
1971
|
+
result = _read_command(github_get_pr, args, "pr")
|
|
1972
|
+
if args.json:
|
|
1973
|
+
_print_json(result)
|
|
1974
|
+
return
|
|
1975
|
+
console.print()
|
|
1976
|
+
for repo, info in result["repos"].items():
|
|
1977
|
+
if not info.get("found"):
|
|
1978
|
+
console.print(f" [repo]{repo}[/] [muted]PR #{info['pr_number']} not found[/]")
|
|
1979
|
+
continue
|
|
1980
|
+
decision = info.get("review_decision") or "—"
|
|
1981
|
+
draft = " [muted](draft)[/]" if info.get("draft") else ""
|
|
1982
|
+
console.print(f" [repo]{repo}[/] PR #{info['pr_number']} [muted]{decision}[/]{draft}")
|
|
1983
|
+
if info.get("title"):
|
|
1984
|
+
console.print(f" {info['title']}")
|
|
1985
|
+
if info.get("url"):
|
|
1986
|
+
console.print(f" [muted]{info['url']}[/]")
|
|
1987
|
+
console.print()
|
|
1988
|
+
|
|
1989
|
+
|
|
1990
|
+
def cmd_branch(args: argparse.Namespace) -> None:
|
|
1991
|
+
"""Fetch branch info (HEAD, divergence, upstream) per repo."""
|
|
1992
|
+
from ..actions.reads import github_get_branch
|
|
1993
|
+
from .ui import console
|
|
1994
|
+
|
|
1995
|
+
workspace = _load_workspace()
|
|
1996
|
+
from ..actions.errors import ActionError
|
|
1997
|
+
from .render import render_blocker
|
|
1998
|
+
try:
|
|
1999
|
+
result = github_get_branch(workspace, args.alias, repo=args.repo)
|
|
2000
|
+
except ActionError as err:
|
|
2001
|
+
if args.json:
|
|
2002
|
+
_print_json(err.to_dict())
|
|
2003
|
+
else:
|
|
2004
|
+
render_blocker(err, action="branch")
|
|
2005
|
+
sys.exit(1)
|
|
2006
|
+
|
|
2007
|
+
if args.json:
|
|
2008
|
+
_print_json(result)
|
|
2009
|
+
return
|
|
2010
|
+
console.print()
|
|
2011
|
+
for repo, info in result["repos"].items():
|
|
2012
|
+
if not info.get("exists_locally"):
|
|
2013
|
+
console.print(f" [repo]{repo}[/] [muted]{info['branch']} (not present locally)[/]")
|
|
2014
|
+
continue
|
|
2015
|
+
sha = info.get("head_sha", "")
|
|
2016
|
+
ahead = info.get("ahead", 0)
|
|
2017
|
+
behind = info.get("behind", 0)
|
|
2018
|
+
upstream = "↑" if info.get("has_upstream") else "no upstream"
|
|
2019
|
+
suffix = f" [muted]{upstream}"
|
|
2020
|
+
if info.get("has_upstream"):
|
|
2021
|
+
suffix += f" ↑{ahead} ↓{behind}"
|
|
2022
|
+
suffix += "[/]"
|
|
2023
|
+
console.print(f" [repo]{repo}[/] {info['branch']} [muted]@ {sha[:8]}[/]{suffix}")
|
|
2024
|
+
console.print()
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
def cmd_comments(args: argparse.Namespace) -> None:
|
|
2028
|
+
"""Fetch temporally classified PR review comments per repo."""
|
|
2029
|
+
from ..actions.reads import github_get_pr_comments
|
|
2030
|
+
from .ui import console
|
|
2031
|
+
|
|
2032
|
+
result = _read_command(github_get_pr_comments, args, "comments")
|
|
2033
|
+
if args.json:
|
|
2034
|
+
_print_json(result)
|
|
2035
|
+
return
|
|
2036
|
+
console.print()
|
|
2037
|
+
console.print(
|
|
2038
|
+
f" [header]actionable: {result['actionable_count']}[/] "
|
|
2039
|
+
f"[muted]likely resolved: {result['likely_resolved_count']} "
|
|
2040
|
+
f"resolved: {result['resolved_thread_count']}[/]"
|
|
2041
|
+
)
|
|
2042
|
+
for repo, info in result["repos"].items():
|
|
2043
|
+
console.print()
|
|
2044
|
+
console.print(f" [repo]{repo}[/] PR #{info['pr_number']} [muted]{info.get('pr_url','')}[/]")
|
|
2045
|
+
actionable = info.get("actionable_threads") or []
|
|
2046
|
+
if not actionable:
|
|
2047
|
+
console.print(" [muted]no actionable threads[/]")
|
|
2048
|
+
continue
|
|
2049
|
+
for t in actionable:
|
|
2050
|
+
line = f"{t.get('path','')}:{t.get('line','')}" if t.get("path") else "(general)"
|
|
2051
|
+
console.print(f" [warning]•[/] [muted]{line}[/] {t.get('author','')}")
|
|
2052
|
+
body = (t.get("body") or "").strip().split("\n")[0]
|
|
2053
|
+
if len(body) > 120:
|
|
2054
|
+
body = body[:120] + "…"
|
|
2055
|
+
console.print(f" {body}")
|
|
2056
|
+
console.print()
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
def cmd_setup_agent(args: argparse.Namespace) -> None:
|
|
2060
|
+
"""Install the using-canopy skill + register canopy MCP for the workspace."""
|
|
2061
|
+
from ..agent_setup import setup_agent, check_status
|
|
2062
|
+
from .ui import console, print_success, print_warning
|
|
2063
|
+
|
|
2064
|
+
if args.check:
|
|
2065
|
+
# Best-effort workspace detection, but still works without one.
|
|
2066
|
+
try:
|
|
2067
|
+
workspace = _load_workspace()
|
|
2068
|
+
workspace_root = workspace.config.root
|
|
2069
|
+
except Exception:
|
|
2070
|
+
workspace_root = Path.cwd()
|
|
2071
|
+
status = check_status(workspace_root)
|
|
2072
|
+
if args.json:
|
|
2073
|
+
_print_json(status)
|
|
2074
|
+
return
|
|
2075
|
+
skills_state = status.get("skills") or [status["skill"]]
|
|
2076
|
+
mcp = status["mcp"]
|
|
2077
|
+
console.print()
|
|
2078
|
+
for skill in skills_state:
|
|
2079
|
+
label_name = skill.get("name", "")
|
|
2080
|
+
if skill["installed"] and skill["is_canopy_skill"]:
|
|
2081
|
+
label = "[success]✓ up to date[/]" if skill["up_to_date"] else "[warning]● out of date[/]"
|
|
2082
|
+
console.print(f" skill[{label_name}] {label} [muted]{skill['path']}[/]")
|
|
2083
|
+
elif skill["installed"]:
|
|
2084
|
+
console.print(f" skill[{label_name}] [warning]foreign file present[/] [muted]{skill['path']}[/]")
|
|
2085
|
+
else:
|
|
2086
|
+
console.print(f" skill[{label_name}] [muted]not installed[/] [muted]{skill['path']}[/]")
|
|
2087
|
+
if mcp["configured"]:
|
|
2088
|
+
root = (mcp.get("env") or {}).get("CANOPY_ROOT", "")
|
|
2089
|
+
console.print(f" mcp [success]✓ configured[/] [muted]CANOPY_ROOT={root}[/]")
|
|
2090
|
+
else:
|
|
2091
|
+
console.print(f" mcp [error]✗ not configured[/] [muted]{mcp['path']}[/]")
|
|
2092
|
+
console.print()
|
|
2093
|
+
return
|
|
2094
|
+
|
|
2095
|
+
do_skill = not args.mcp_only
|
|
2096
|
+
do_mcp = not args.skill_only
|
|
2097
|
+
|
|
2098
|
+
from ..agent_setup import DEFAULT_SKILL
|
|
2099
|
+
extra = list(dict.fromkeys(args.skill or [])) # dedupe, preserve order
|
|
2100
|
+
skills: tuple[str, ...] = (
|
|
2101
|
+
tuple([DEFAULT_SKILL] + [s for s in extra if s != DEFAULT_SKILL])
|
|
2102
|
+
if do_skill else ()
|
|
2103
|
+
)
|
|
2104
|
+
|
|
2105
|
+
workspace_root: Path | None = None
|
|
2106
|
+
if do_mcp:
|
|
2107
|
+
try:
|
|
2108
|
+
workspace = _load_workspace()
|
|
2109
|
+
workspace_root = workspace.config.root
|
|
2110
|
+
except Exception:
|
|
2111
|
+
workspace_root = None
|
|
2112
|
+
|
|
2113
|
+
result = setup_agent(
|
|
2114
|
+
workspace_root, skills=skills, do_mcp=do_mcp, reinstall=args.reinstall,
|
|
2115
|
+
)
|
|
2116
|
+
if args.json:
|
|
2117
|
+
_print_json(result)
|
|
2118
|
+
return
|
|
2119
|
+
|
|
2120
|
+
console.print()
|
|
2121
|
+
for s in result.get("skills") or ([result["skill"]] if "skill" in result else []):
|
|
2122
|
+
glyph = {
|
|
2123
|
+
"installed": "[success]✓ installed[/]",
|
|
2124
|
+
"reinstalled": "[success]✓ reinstalled[/]",
|
|
2125
|
+
"skipped": "[muted]· skipped[/]",
|
|
2126
|
+
}.get(s["action"], s["action"])
|
|
2127
|
+
label = f"skill[{s.get('name', DEFAULT_SKILL)}]"
|
|
2128
|
+
console.print(f" {label:<22} {glyph} [muted]{s['path']}[/]")
|
|
2129
|
+
if s.get("reason"):
|
|
2130
|
+
console.print(f" [muted]{s['reason']}[/]")
|
|
2131
|
+
if "mcp" in result:
|
|
2132
|
+
m = result["mcp"]
|
|
2133
|
+
glyph = {
|
|
2134
|
+
"added": "[success]✓ added[/]",
|
|
2135
|
+
"updated": "[success]✓ updated[/]",
|
|
2136
|
+
"created": "[success]✓ created[/]",
|
|
2137
|
+
"skipped": "[muted]· skipped[/]",
|
|
2138
|
+
}.get(m["action"], m["action"])
|
|
2139
|
+
console.print(f" mcp {glyph} [muted]{m['path']}[/]")
|
|
2140
|
+
if m.get("reason"):
|
|
2141
|
+
console.print(f" [muted]{m['reason']}[/]")
|
|
2142
|
+
console.print()
|
|
2143
|
+
console.print(" [muted]Restart Claude Code (or open a new session) to pick up changes.[/]")
|
|
2144
|
+
console.print()
|
|
2145
|
+
|
|
2146
|
+
|
|
2147
|
+
def cmd_state(args: argparse.Namespace) -> None:
|
|
2148
|
+
"""Show the feature state + suggested next actions."""
|
|
2149
|
+
from ..actions.errors import ActionError, BlockerError, FixAction
|
|
2150
|
+
from ..actions import slots as slots_mod
|
|
2151
|
+
from ..actions.feature_state import feature_state as state_impl
|
|
2152
|
+
from .render import render_blocker
|
|
2153
|
+
from .ui import console
|
|
2154
|
+
|
|
2155
|
+
workspace = _load_workspace()
|
|
2156
|
+
feature = args.feature
|
|
2157
|
+
if feature is None:
|
|
2158
|
+
slot_state = slots_mod.read_state(workspace)
|
|
2159
|
+
if slot_state is None or slot_state.canonical is None:
|
|
2160
|
+
err = BlockerError(
|
|
2161
|
+
code="no_active_feature",
|
|
2162
|
+
what="no feature passed and no active feature is set",
|
|
2163
|
+
fix_actions=[
|
|
2164
|
+
FixAction(action="switch", args={"feature": "<name>"},
|
|
2165
|
+
safe=True, preview="set active feature with: canopy switch <feature>"),
|
|
2166
|
+
FixAction(action="state", args={"feature": "<name>"},
|
|
2167
|
+
safe=True, preview="or pass a feature explicitly: canopy state <feature>"),
|
|
2168
|
+
],
|
|
2169
|
+
)
|
|
2170
|
+
if args.json:
|
|
2171
|
+
_print_json(err.to_dict())
|
|
2172
|
+
else:
|
|
2173
|
+
render_blocker(err, action="state")
|
|
2174
|
+
sys.exit(1)
|
|
2175
|
+
feature = slot_state.canonical.feature
|
|
2176
|
+
|
|
2177
|
+
try:
|
|
2178
|
+
result = state_impl(workspace, feature)
|
|
2179
|
+
except ActionError as err:
|
|
2180
|
+
if args.json:
|
|
2181
|
+
_print_json(err.to_dict())
|
|
2182
|
+
else:
|
|
2183
|
+
render_blocker(err, action="state")
|
|
2184
|
+
sys.exit(1)
|
|
2185
|
+
|
|
2186
|
+
if args.json:
|
|
2187
|
+
_print_json(result)
|
|
2188
|
+
return
|
|
2189
|
+
|
|
2190
|
+
state_glyph = {
|
|
2191
|
+
"drifted": "[error]✗[/]",
|
|
2192
|
+
"in_progress": "[warning]●[/]",
|
|
2193
|
+
"ready_to_commit": "[info]●[/]",
|
|
2194
|
+
"ready_to_push": "[info]●[/]",
|
|
2195
|
+
"needs_work": "[error]●[/]",
|
|
2196
|
+
"approved": "[success]●[/]",
|
|
2197
|
+
"awaiting_review": "[muted]●[/]",
|
|
2198
|
+
"no_prs": "[muted]○[/]",
|
|
2199
|
+
}
|
|
2200
|
+
glyph = state_glyph.get(result["state"], "[muted]?[/]")
|
|
2201
|
+
console.print()
|
|
2202
|
+
console.print(f" {glyph} [feature]{result['feature']}[/] [muted]({result['state']})[/]")
|
|
2203
|
+
|
|
2204
|
+
summary = result.get("summary", {})
|
|
2205
|
+
alignment = summary.get("alignment")
|
|
2206
|
+
if alignment and not alignment.get("aligned"):
|
|
2207
|
+
for repo, exp in (alignment.get("expected") or {}).items():
|
|
2208
|
+
actual = (alignment.get("actual") or {}).get(repo) or "(missing)"
|
|
2209
|
+
mark = "[error]✗[/]" if actual != exp else "[success]✓[/]"
|
|
2210
|
+
console.print(f" {mark} [repo]{repo}[/] [muted]→ {actual} (expected {exp})[/]")
|
|
2211
|
+
else:
|
|
2212
|
+
repos = summary.get("repos") or {}
|
|
2213
|
+
for repo, info in repos.items():
|
|
2214
|
+
bits = []
|
|
2215
|
+
if info.get("is_dirty"):
|
|
2216
|
+
bits.append(f"dirty: {info.get('dirty_count', '?')} files")
|
|
2217
|
+
if info.get("ahead", 0) > 0:
|
|
2218
|
+
bits.append(f"↑{info['ahead']}")
|
|
2219
|
+
if info.get("behind", 0) > 0:
|
|
2220
|
+
bits.append(f"↓{info['behind']}")
|
|
2221
|
+
if info.get("actionable_count", 0) > 0:
|
|
2222
|
+
bits.append(f"actionable: {info['actionable_count']}")
|
|
2223
|
+
if info.get("review_decision"):
|
|
2224
|
+
bits.append(info["review_decision"])
|
|
2225
|
+
extra = " [muted](" + ", ".join(bits) + ")[/]" if bits else ""
|
|
2226
|
+
console.print(f" [repo]{repo}[/] [muted]→ {info.get('branch','')}[/]{extra}")
|
|
2227
|
+
|
|
2228
|
+
pf = summary.get("preflight") or {}
|
|
2229
|
+
if pf.get("ran"):
|
|
2230
|
+
status = "passed" if pf.get("passed") else "failed"
|
|
2231
|
+
fresh = "fresh" if pf.get("fresh") else "stale"
|
|
2232
|
+
console.print(f" [muted]preflight: {status} ({fresh}, ran {pf.get('ran_at','')})[/]")
|
|
2233
|
+
|
|
2234
|
+
for w in result.get("warnings", []):
|
|
2235
|
+
console.print(f" [warning]⚠ {w.get('what','')}[/] [muted]({w.get('code','')})[/]")
|
|
2236
|
+
|
|
2237
|
+
next_actions = result.get("next_actions") or []
|
|
2238
|
+
if next_actions:
|
|
2239
|
+
console.print()
|
|
2240
|
+
console.print(" [header]next:[/]")
|
|
2241
|
+
for i, a in enumerate(next_actions):
|
|
2242
|
+
tag = "[info]→[/]" if a.get("primary") else " "
|
|
2243
|
+
label = a.get("label") or a.get("action") or "?"
|
|
2244
|
+
preview = a.get("preview")
|
|
2245
|
+
line = f" {tag} [info]canopy {a['action']} {a.get('args', {}).get('feature','')}[/] [muted]{label}[/]"
|
|
2246
|
+
console.print(line)
|
|
2247
|
+
if preview:
|
|
2248
|
+
console.print(f" [muted]{preview}[/]")
|
|
2249
|
+
console.print()
|
|
2250
|
+
|
|
2251
|
+
|
|
2252
|
+
def cmd_triage(args: argparse.Namespace) -> None:
|
|
2253
|
+
"""Show prioritized list of features needing attention."""
|
|
2254
|
+
from ..actions.errors import ActionError
|
|
2255
|
+
from ..actions.triage import triage as triage_impl
|
|
2256
|
+
from .render import render_blocker
|
|
2257
|
+
from .ui import console
|
|
2258
|
+
|
|
2259
|
+
workspace = _load_workspace()
|
|
2260
|
+
try:
|
|
2261
|
+
result = triage_impl(
|
|
2262
|
+
workspace,
|
|
2263
|
+
author=getattr(args, "author", "@me"),
|
|
2264
|
+
repos=_split_csv(getattr(args, "repos", None)),
|
|
2265
|
+
)
|
|
2266
|
+
except ActionError as err:
|
|
2267
|
+
if args.json:
|
|
2268
|
+
_print_json(err.to_dict())
|
|
2269
|
+
else:
|
|
2270
|
+
render_blocker(err, action="triage")
|
|
2271
|
+
sys.exit(1)
|
|
2272
|
+
|
|
2273
|
+
if args.json:
|
|
2274
|
+
_print_json(result)
|
|
2275
|
+
return
|
|
2276
|
+
|
|
2277
|
+
console.print()
|
|
2278
|
+
console.print(f" [header]triage[/] [muted]author={result['author']} ({len(result['features'])} features)[/]")
|
|
2279
|
+
if not result["features"]:
|
|
2280
|
+
console.print(" [muted]nothing needs attention[/]")
|
|
2281
|
+
console.print()
|
|
2282
|
+
return
|
|
2283
|
+
glyphs = {
|
|
2284
|
+
"changes_requested": "[error]●[/]",
|
|
2285
|
+
"review_required_with_bot_comments": "[warning]●[/]",
|
|
2286
|
+
"review_required": "[muted]●[/]",
|
|
2287
|
+
"approved": "[success]●[/]",
|
|
2288
|
+
"unknown": "[muted]?[/]",
|
|
2289
|
+
}
|
|
2290
|
+
for f in result["features"]:
|
|
2291
|
+
glyph = glyphs.get(f["priority"], "[muted]?[/]")
|
|
2292
|
+
label = f["feature"]
|
|
2293
|
+
linear = f.get("linear_issue") or ""
|
|
2294
|
+
title = f.get("linear_title") or ""
|
|
2295
|
+
suffix = f" [muted]{linear} {title}[/]".rstrip() if linear or title else ""
|
|
2296
|
+
console.print()
|
|
2297
|
+
console.print(f" {glyph} [feature]{label}[/] [muted]({f['priority']})[/]{suffix}")
|
|
2298
|
+
for repo, info in f["repos"].items():
|
|
2299
|
+
decision = info.get("review_decision") or "—"
|
|
2300
|
+
counts = []
|
|
2301
|
+
if info.get("actionable_count"):
|
|
2302
|
+
counts.append(f"actionable: {info['actionable_count']}")
|
|
2303
|
+
if info.get("likely_resolved_count"):
|
|
2304
|
+
counts.append(f"likely_resolved: {info['likely_resolved_count']}")
|
|
2305
|
+
count_str = " [muted](" + ", ".join(counts) + ")[/]" if counts else ""
|
|
2306
|
+
console.print(
|
|
2307
|
+
f" [repo]{repo}[/] PR #{info['pr_number']} "
|
|
2308
|
+
f"[muted]{decision}[/]{count_str}"
|
|
2309
|
+
)
|
|
2310
|
+
console.print()
|
|
2311
|
+
|
|
2312
|
+
|
|
2313
|
+
def cmd_switch(args: argparse.Namespace) -> None:
|
|
2314
|
+
"""Promote a feature to the canonical slot (canonical-slot model, Wave 2.9)."""
|
|
2315
|
+
from ..actions.errors import ActionError
|
|
2316
|
+
from ..actions.switch import switch as switch_impl
|
|
2317
|
+
from .render import render_blocker
|
|
2318
|
+
from .ui import console, spinner
|
|
2319
|
+
|
|
2320
|
+
workspace = _load_workspace()
|
|
2321
|
+
to_slot = getattr(args, "to_slot", None)
|
|
2322
|
+
label = to_slot or args.feature or "feature"
|
|
2323
|
+
try:
|
|
2324
|
+
with spinner(f"Switching to {label}…"):
|
|
2325
|
+
result = switch_impl(
|
|
2326
|
+
workspace, args.feature,
|
|
2327
|
+
release_current=getattr(args, "release_current", False),
|
|
2328
|
+
no_evict=getattr(args, "no_evict", False),
|
|
2329
|
+
evict=getattr(args, "evict", None),
|
|
2330
|
+
evict_to=getattr(args, "evict_to", None),
|
|
2331
|
+
to_slot=to_slot,
|
|
2332
|
+
)
|
|
2333
|
+
except ActionError as err:
|
|
2334
|
+
if args.json:
|
|
2335
|
+
_print_json(err.to_dict())
|
|
2336
|
+
else:
|
|
2337
|
+
render_blocker(err, action="switch")
|
|
2338
|
+
sys.exit(1)
|
|
2339
|
+
|
|
2340
|
+
if args.json:
|
|
2341
|
+
_print_json(result)
|
|
2342
|
+
return
|
|
2343
|
+
|
|
2344
|
+
console.print()
|
|
2345
|
+
feature = result["feature"]
|
|
2346
|
+
mode = result["mode"]
|
|
2347
|
+
glyph = "[success]✓[/]"
|
|
2348
|
+
label = {"active_rotation": "active rotation",
|
|
2349
|
+
"wind_down": "wind down"}.get(mode, mode)
|
|
2350
|
+
console.print(f" {glyph} canonical: [feature]{feature}[/] [muted]({label})[/]")
|
|
2351
|
+
if result.get("previously_canonical"):
|
|
2352
|
+
prev = result["previously_canonical"]
|
|
2353
|
+
if mode == "wind_down":
|
|
2354
|
+
console.print(f" [muted]previous '{prev}' → cold (stashed if dirty)[/]")
|
|
2355
|
+
else:
|
|
2356
|
+
console.print(f" [muted]previous '{prev}' → warm worktree[/]")
|
|
2357
|
+
if result.get("eviction"):
|
|
2358
|
+
ev = result["eviction"]
|
|
2359
|
+
stashed_repos = [r for r in ev["repos"] if r["stashed"]]
|
|
2360
|
+
console.print(
|
|
2361
|
+
f" [warning]evicted '{ev['feature']}'[/] → cold "
|
|
2362
|
+
f"({len(stashed_repos)}/{len(ev['repos'])} repos auto-stashed)"
|
|
2363
|
+
)
|
|
2364
|
+
if result.get("branches_created"):
|
|
2365
|
+
for b in result["branches_created"]:
|
|
2366
|
+
console.print(f" [muted]created branch {b['repo']}/{b['branch']} from {b['base']}[/]")
|
|
2367
|
+
for repo, path in result["per_repo_paths"].items():
|
|
2368
|
+
console.print(f" [repo]{repo}[/] [muted]→ {path}[/]")
|
|
2369
|
+
if result.get("migration"):
|
|
2370
|
+
m = result["migration"]
|
|
2371
|
+
if m.get("ran"):
|
|
2372
|
+
detected = m.get("canonical_detected") or "(none)"
|
|
2373
|
+
console.print(f" [muted]migrated workspace to 2.9 schema; canonical detected: {detected}[/]")
|
|
2374
|
+
console.print()
|
|
2375
|
+
console.print(
|
|
2376
|
+
" [muted]now: 'canopy state' / 'canopy run <repo> <cmd>' default to this feature[/]"
|
|
2377
|
+
)
|
|
2378
|
+
console.print()
|
|
2379
|
+
|
|
2380
|
+
|
|
2381
|
+
def cmd_commit(args: argparse.Namespace) -> None:
|
|
2382
|
+
"""Feature-scoped multi-repo commit (Wave 2.3)."""
|
|
2383
|
+
from ..actions.commit import commit as commit_impl
|
|
2384
|
+
from ..actions.errors import ActionError
|
|
2385
|
+
from .render import render_blocker
|
|
2386
|
+
from .ui import console, spinner
|
|
2387
|
+
|
|
2388
|
+
workspace = _load_workspace()
|
|
2389
|
+
spin_msg = "Committing (running hooks)…" if not args.no_hooks else "Committing…"
|
|
2390
|
+
try:
|
|
2391
|
+
with spinner(spin_msg):
|
|
2392
|
+
result = commit_impl(
|
|
2393
|
+
workspace,
|
|
2394
|
+
args.message or "",
|
|
2395
|
+
feature=args.feature,
|
|
2396
|
+
repos=_split_csv(args.repos),
|
|
2397
|
+
paths=args.paths or None,
|
|
2398
|
+
no_hooks=args.no_hooks,
|
|
2399
|
+
amend=args.amend,
|
|
2400
|
+
address=getattr(args, "address", None),
|
|
2401
|
+
resolve_thread=getattr(args, "resolve_thread", None),
|
|
2402
|
+
)
|
|
2403
|
+
except ActionError as err:
|
|
2404
|
+
if args.json:
|
|
2405
|
+
_print_json(err.to_dict())
|
|
2406
|
+
else:
|
|
2407
|
+
render_blocker(err, action="commit")
|
|
2408
|
+
sys.exit(1)
|
|
2409
|
+
|
|
2410
|
+
if args.json:
|
|
2411
|
+
_print_json(result)
|
|
2412
|
+
return
|
|
2413
|
+
|
|
2414
|
+
console.print()
|
|
2415
|
+
console.print(f" [feature]{result['feature']}[/]")
|
|
2416
|
+
for repo, r in result["results"].items():
|
|
2417
|
+
status = r["status"]
|
|
2418
|
+
if status == "ok":
|
|
2419
|
+
sha = r["sha"][:8]
|
|
2420
|
+
files = r["files_changed"]
|
|
2421
|
+
tag = " (amended)" if r.get("amended") else ""
|
|
2422
|
+
console.print(f" [success]✓[/] [repo]{repo}[/] {sha} ({files} files{tag})")
|
|
2423
|
+
elif status == "nothing":
|
|
2424
|
+
console.print(f" [muted]·[/] [repo]{repo}[/] no changes")
|
|
2425
|
+
elif status == "hooks_failed":
|
|
2426
|
+
console.print(f" [error]✗[/] [repo]{repo}[/] hook failed")
|
|
2427
|
+
for line in (r.get("hook_output") or "").splitlines()[:5]:
|
|
2428
|
+
console.print(f" [muted]{line}[/]")
|
|
2429
|
+
else: # failed
|
|
2430
|
+
console.print(f" [error]✗[/] [repo]{repo}[/] {r.get('reason', 'failed')}")
|
|
2431
|
+
addressed = result.get("addressed")
|
|
2432
|
+
if addressed:
|
|
2433
|
+
cid = addressed["comment_id"]
|
|
2434
|
+
if addressed.get("recorded"):
|
|
2435
|
+
sha = (addressed.get("sha") or "")[:8]
|
|
2436
|
+
console.print(
|
|
2437
|
+
f" [success]✓[/] addressed bot comment [muted]{cid}[/] "
|
|
2438
|
+
f"(recorded against [repo]{addressed['repo']}[/] {sha})",
|
|
2439
|
+
)
|
|
2440
|
+
else:
|
|
2441
|
+
console.print(
|
|
2442
|
+
f" [warning]·[/] bot comment [muted]{cid}[/] not recorded "
|
|
2443
|
+
f"({addressed.get('reason', 'no successful commit in owning repo')})",
|
|
2444
|
+
)
|
|
2445
|
+
tr = addressed.get("thread_resolved")
|
|
2446
|
+
if tr is not None:
|
|
2447
|
+
if tr.get("skipped"):
|
|
2448
|
+
console.print(
|
|
2449
|
+
f" [muted]·[/] thread resolve skipped "
|
|
2450
|
+
f"([muted]{tr['skipped']}[/])",
|
|
2451
|
+
)
|
|
2452
|
+
elif tr.get("is_resolved"):
|
|
2453
|
+
tid = tr.get("thread_id") or tr.get("logged", {}).get("thread_id", "")
|
|
2454
|
+
console.print(
|
|
2455
|
+
f" [success]✓[/] GH review thread resolved "
|
|
2456
|
+
f"([muted]{tid}[/])",
|
|
2457
|
+
)
|
|
2458
|
+
else:
|
|
2459
|
+
console.print(" [warning]·[/] GH thread resolve returned unexpected result")
|
|
2460
|
+
console.print()
|
|
2461
|
+
|
|
2462
|
+
|
|
2463
|
+
def cmd_bot_status(args: argparse.Namespace) -> None:
|
|
2464
|
+
"""Per-feature bot-comment rollup (M3)."""
|
|
2465
|
+
from ..actions.bot_status import bot_comments_status
|
|
2466
|
+
from ..actions.errors import ActionError
|
|
2467
|
+
from .render import render_blocker
|
|
2468
|
+
from .ui import console
|
|
2469
|
+
|
|
2470
|
+
workspace = _load_workspace()
|
|
2471
|
+
try:
|
|
2472
|
+
result = bot_comments_status(workspace, feature=args.feature)
|
|
2473
|
+
except ActionError as err:
|
|
2474
|
+
if args.json:
|
|
2475
|
+
_print_json(err.to_dict())
|
|
2476
|
+
else:
|
|
2477
|
+
render_blocker(err, action="bot-status")
|
|
2478
|
+
sys.exit(1)
|
|
2479
|
+
|
|
2480
|
+
if args.json:
|
|
2481
|
+
_print_json(result)
|
|
2482
|
+
return
|
|
2483
|
+
|
|
2484
|
+
console.print()
|
|
2485
|
+
console.print(f" [feature]{result['feature']}[/]")
|
|
2486
|
+
if not result["any_bot_comments"]:
|
|
2487
|
+
console.print(" [muted]no bot comments tracked[/]")
|
|
2488
|
+
console.print()
|
|
2489
|
+
return
|
|
2490
|
+
|
|
2491
|
+
for repo, info in result["repos"].items():
|
|
2492
|
+
if info["total"] == 0:
|
|
2493
|
+
continue
|
|
2494
|
+
glyph = "[success]✓[/]" if info["unresolved"] == 0 else "[warning]●[/]"
|
|
2495
|
+
pr = f"PR #{info['pr_number']}" if info.get("pr_number") else "no PR"
|
|
2496
|
+
console.print(
|
|
2497
|
+
f" {glyph} [repo]{repo}[/] {pr} "
|
|
2498
|
+
f"resolved {info['resolved']}/{info['total']}",
|
|
2499
|
+
)
|
|
2500
|
+
threads = (
|
|
2501
|
+
[t for t in info["threads"] if not t["resolved"]]
|
|
2502
|
+
if args.unresolved_only
|
|
2503
|
+
else info["threads"]
|
|
2504
|
+
)
|
|
2505
|
+
for t in threads:
|
|
2506
|
+
mark = "[success]✓[/]" if t["resolved"] else "[warning]●[/]"
|
|
2507
|
+
label = f"{t.get('author', '')}".strip() or "(unknown)"
|
|
2508
|
+
preview = t.get("body_preview", "")
|
|
2509
|
+
console.print(f" {mark} [muted]{t.get('id', '')}[/] {label}: {preview}")
|
|
2510
|
+
overall = "[success]all resolved[/]" if result["all_resolved"] else "[warning]unresolved[/]"
|
|
2511
|
+
console.print(f" [muted]→ {overall}[/]")
|
|
2512
|
+
console.print()
|
|
2513
|
+
|
|
2514
|
+
|
|
2515
|
+
def _resolve_historian_feature(workspace, feature: str | None):
|
|
2516
|
+
"""Resolve (workspace_root, feature_name) for a historian CLI call."""
|
|
2517
|
+
from ..actions import slots as slots_mod
|
|
2518
|
+
from ..actions.aliases import resolve_feature
|
|
2519
|
+
from ..actions.errors import BlockerError
|
|
2520
|
+
if feature:
|
|
2521
|
+
return workspace.config.root, resolve_feature(workspace, feature)
|
|
2522
|
+
state = slots_mod.read_state(workspace)
|
|
2523
|
+
if state is None or state.canonical is None:
|
|
2524
|
+
raise BlockerError(
|
|
2525
|
+
code="no_canonical_feature",
|
|
2526
|
+
what="no active feature; pass <feature> or run `canopy switch <name>` first",
|
|
2527
|
+
)
|
|
2528
|
+
return workspace.config.root, state.canonical.feature
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
def cmd_historian(args: argparse.Namespace) -> None:
|
|
2532
|
+
"""Read or compact a feature's historian memory file (M4)."""
|
|
2533
|
+
from ..actions import historian
|
|
2534
|
+
from ..actions.errors import ActionError
|
|
2535
|
+
from .render import render_blocker
|
|
2536
|
+
from .ui import console
|
|
2537
|
+
|
|
2538
|
+
workspace = _load_workspace()
|
|
2539
|
+
try:
|
|
2540
|
+
root, name = _resolve_historian_feature(workspace, args.feature)
|
|
2541
|
+
except ActionError as err:
|
|
2542
|
+
if args.json:
|
|
2543
|
+
_print_json(err.to_dict())
|
|
2544
|
+
else:
|
|
2545
|
+
render_blocker(err, action=f"historian {args.subcommand}")
|
|
2546
|
+
sys.exit(1)
|
|
2547
|
+
|
|
2548
|
+
if args.subcommand == "show":
|
|
2549
|
+
memory = historian.format_for_agent(root, name)
|
|
2550
|
+
if args.json:
|
|
2551
|
+
_print_json({"feature": name, "memory": memory})
|
|
2552
|
+
return
|
|
2553
|
+
if not memory:
|
|
2554
|
+
console.print()
|
|
2555
|
+
console.print(f" [muted]no memory recorded yet for [feature]{name}[/][/]")
|
|
2556
|
+
console.print()
|
|
2557
|
+
return
|
|
2558
|
+
console.print(memory)
|
|
2559
|
+
return
|
|
2560
|
+
|
|
2561
|
+
if args.subcommand == "compact":
|
|
2562
|
+
result = historian.compact(root, name, keep_sessions=args.keep_sessions)
|
|
2563
|
+
if args.json:
|
|
2564
|
+
_print_json({"feature": name, **result})
|
|
2565
|
+
return
|
|
2566
|
+
console.print()
|
|
2567
|
+
console.print(f" [feature]{name}[/] {result.get('action')}: "
|
|
2568
|
+
f"kept {result.get('kept', '?')} entries, "
|
|
2569
|
+
f"dropped {result.get('dropped', 0)}")
|
|
2570
|
+
console.print()
|
|
2571
|
+
return
|
|
2572
|
+
|
|
2573
|
+
|
|
2574
|
+
def cmd_push(args: argparse.Namespace) -> None:
|
|
2575
|
+
"""Feature-scoped multi-repo push (Wave 2.3)."""
|
|
2576
|
+
from ..actions.errors import ActionError
|
|
2577
|
+
from ..actions.push import push as push_impl
|
|
2578
|
+
from .render import render_blocker
|
|
2579
|
+
from .ui import console, spinner
|
|
2580
|
+
|
|
2581
|
+
workspace = _load_workspace()
|
|
2582
|
+
spin_msg = "Dry-run push (no network)…" if args.dry_run else "Pushing…"
|
|
2583
|
+
try:
|
|
2584
|
+
with spinner(spin_msg):
|
|
2585
|
+
result = push_impl(
|
|
2586
|
+
workspace,
|
|
2587
|
+
feature=args.feature,
|
|
2588
|
+
repos=_split_csv(args.repos),
|
|
2589
|
+
set_upstream=args.set_upstream,
|
|
2590
|
+
force_with_lease=args.force_with_lease,
|
|
2591
|
+
dry_run=args.dry_run,
|
|
2592
|
+
)
|
|
2593
|
+
except ActionError as err:
|
|
2594
|
+
if args.json:
|
|
2595
|
+
_print_json(err.to_dict())
|
|
2596
|
+
else:
|
|
2597
|
+
render_blocker(err, action="push")
|
|
2598
|
+
sys.exit(1)
|
|
2599
|
+
|
|
2600
|
+
if args.json:
|
|
2601
|
+
_print_json(result)
|
|
2602
|
+
return
|
|
2603
|
+
|
|
2604
|
+
console.print()
|
|
2605
|
+
console.print(f" [feature]{result['feature']}[/]")
|
|
2606
|
+
for repo, r in result["results"].items():
|
|
2607
|
+
status = r["status"]
|
|
2608
|
+
if status == "ok":
|
|
2609
|
+
count = r.get("pushed_count", 0)
|
|
2610
|
+
ref = r.get("ref", "")
|
|
2611
|
+
extras = []
|
|
2612
|
+
if r.get("set_upstream"):
|
|
2613
|
+
extras.append("upstream set")
|
|
2614
|
+
if r.get("dry_run"):
|
|
2615
|
+
extras.append("dry-run")
|
|
2616
|
+
extra = f" ({', '.join(extras)})" if extras else ""
|
|
2617
|
+
console.print(f" [success]✓[/] [repo]{repo}[/] {ref} +{count}{extra}")
|
|
2618
|
+
elif status == "up_to_date":
|
|
2619
|
+
console.print(f" [muted]·[/] [repo]{repo}[/] up to date")
|
|
2620
|
+
elif status == "rejected":
|
|
2621
|
+
console.print(f" [error]✗[/] [repo]{repo}[/] rejected")
|
|
2622
|
+
console.print(f" [muted]{r.get('reason', '')}[/]")
|
|
2623
|
+
else: # failed
|
|
2624
|
+
console.print(f" [error]✗[/] [repo]{repo}[/] {r.get('reason', 'failed')}")
|
|
2625
|
+
console.print()
|
|
2626
|
+
|
|
2627
|
+
|
|
2628
|
+
def cmd_stash_save_feature(args: argparse.Namespace) -> None:
|
|
2629
|
+
"""Feature-tagged stash save (extends `canopy stash save --feature`)."""
|
|
2630
|
+
from ..actions.errors import ActionError
|
|
2631
|
+
from ..actions.stash import save_for_feature
|
|
2632
|
+
from .render import render_blocker
|
|
2633
|
+
from .ui import console
|
|
2634
|
+
|
|
2635
|
+
workspace = _load_workspace()
|
|
2636
|
+
try:
|
|
2637
|
+
result = save_for_feature(
|
|
2638
|
+
workspace, args.feature, args.message or "",
|
|
2639
|
+
repos=_split_csv(getattr(args, "repos", None)),
|
|
2640
|
+
)
|
|
2641
|
+
except ActionError as err:
|
|
2642
|
+
if args.json:
|
|
2643
|
+
_print_json(err.to_dict())
|
|
2644
|
+
else:
|
|
2645
|
+
render_blocker(err, action="stash save")
|
|
2646
|
+
sys.exit(1)
|
|
2647
|
+
if args.json:
|
|
2648
|
+
_print_json(result)
|
|
2649
|
+
return
|
|
2650
|
+
console.print()
|
|
2651
|
+
console.print(f" [muted]message:[/] {result['message']}")
|
|
2652
|
+
for repo, status in result["repos"].items():
|
|
2653
|
+
glyph = "[success]✓[/]" if status == "stashed" else (
|
|
2654
|
+
"[muted]·[/]" if status == "clean" else "[error]✗[/]"
|
|
2655
|
+
)
|
|
2656
|
+
console.print(f" {glyph} [repo]{repo}[/] [muted]{status}[/]")
|
|
2657
|
+
console.print()
|
|
2658
|
+
|
|
2659
|
+
|
|
2660
|
+
def cmd_stash_list_grouped(args: argparse.Namespace) -> None:
|
|
2661
|
+
"""Grouped stash list (extends `canopy stash list [--feature]`)."""
|
|
2662
|
+
from ..actions.errors import ActionError
|
|
2663
|
+
from ..actions.stash import list_grouped
|
|
2664
|
+
from .render import render_blocker
|
|
2665
|
+
from .ui import console
|
|
2666
|
+
|
|
2667
|
+
workspace = _load_workspace()
|
|
2668
|
+
try:
|
|
2669
|
+
result = list_grouped(workspace, feature=getattr(args, "feature", None))
|
|
2670
|
+
except ActionError as err:
|
|
2671
|
+
if args.json:
|
|
2672
|
+
_print_json(err.to_dict())
|
|
2673
|
+
else:
|
|
2674
|
+
render_blocker(err, action="stash list")
|
|
2675
|
+
sys.exit(1)
|
|
2676
|
+
if args.json:
|
|
2677
|
+
_print_json(result)
|
|
2678
|
+
return
|
|
2679
|
+
console.print()
|
|
2680
|
+
if not result["by_feature"] and not result["untagged"]:
|
|
2681
|
+
console.print(" [muted]no stashes[/]")
|
|
2682
|
+
console.print()
|
|
2683
|
+
return
|
|
2684
|
+
for feature, entries in result["by_feature"].items():
|
|
2685
|
+
console.print(f" [feature]{feature}[/] [muted]({len(entries)})[/]")
|
|
2686
|
+
for e in entries:
|
|
2687
|
+
console.print(f" [repo]{e['repo']}[/] {e['ref']} [muted]{e.get('ts','')}[/] {e.get('user_message','')}")
|
|
2688
|
+
if result["untagged"]:
|
|
2689
|
+
console.print(f" [header]untagged[/] [muted]({len(result['untagged'])})[/]")
|
|
2690
|
+
for e in result["untagged"]:
|
|
2691
|
+
console.print(f" [repo]{e['repo']}[/] {e['ref']} [muted]{e.get('message','')}[/]")
|
|
2692
|
+
console.print()
|
|
2693
|
+
|
|
2694
|
+
|
|
2695
|
+
def cmd_stash_pop_feature(args: argparse.Namespace) -> None:
|
|
2696
|
+
"""Pop most recent feature-tagged stash per repo."""
|
|
2697
|
+
from ..actions.errors import ActionError
|
|
2698
|
+
from ..actions.stash import pop_feature
|
|
2699
|
+
from .render import render_blocker
|
|
2700
|
+
from .ui import console
|
|
2701
|
+
|
|
2702
|
+
workspace = _load_workspace()
|
|
2703
|
+
try:
|
|
2704
|
+
result = pop_feature(
|
|
2705
|
+
workspace, args.feature,
|
|
2706
|
+
repos=_split_csv(getattr(args, "repos", None)),
|
|
2707
|
+
)
|
|
2708
|
+
except ActionError as err:
|
|
2709
|
+
if args.json:
|
|
2710
|
+
_print_json(err.to_dict())
|
|
2711
|
+
else:
|
|
2712
|
+
render_blocker(err, action="stash pop")
|
|
2713
|
+
sys.exit(1)
|
|
2714
|
+
if args.json:
|
|
2715
|
+
_print_json(result)
|
|
2716
|
+
return
|
|
2717
|
+
console.print()
|
|
2718
|
+
for repo, info in result["repos"].items():
|
|
2719
|
+
if info["status"] == "popped":
|
|
2720
|
+
console.print(f" [success]✓[/] [repo]{repo}[/] [muted]{info.get('user_message') or info.get('message','')}[/]")
|
|
2721
|
+
elif info["status"] == "no_match":
|
|
2722
|
+
console.print(f" [muted]·[/] [repo]{repo}[/] [muted]no matching stash[/]")
|
|
2723
|
+
else:
|
|
2724
|
+
console.print(f" [error]✗[/] [repo]{repo}[/] [error]{info.get('error','')}[/]")
|
|
2725
|
+
console.print()
|
|
2726
|
+
|
|
2727
|
+
|
|
2728
|
+
def _split_csv(value: str | None) -> list[str] | None:
|
|
2729
|
+
if value is None or value == "":
|
|
2730
|
+
return None
|
|
2731
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
2732
|
+
|
|
2733
|
+
|
|
2734
|
+
def cmd_drift(args: argparse.Namespace) -> None:
|
|
2735
|
+
"""Compare recorded HEAD state vs feature lane expectations across repos."""
|
|
2736
|
+
from ..actions.drift import detect_drift
|
|
2737
|
+
from .ui import console, print_warning, SYM_CHECK, SYM_CROSS
|
|
2738
|
+
|
|
2739
|
+
workspace = _load_workspace()
|
|
2740
|
+
feature = getattr(args, "feature", None)
|
|
2741
|
+
report = detect_drift(workspace, feature_name=feature)
|
|
2742
|
+
|
|
2743
|
+
if args.json:
|
|
2744
|
+
_print_json(report.to_dict())
|
|
2745
|
+
return
|
|
2746
|
+
|
|
2747
|
+
console.print()
|
|
2748
|
+
if report.note:
|
|
2749
|
+
console.print(f" [muted]{report.note}[/]")
|
|
2750
|
+
console.print()
|
|
2751
|
+
return
|
|
2752
|
+
|
|
2753
|
+
for fd in report.features:
|
|
2754
|
+
glyph = f"[success]{SYM_CHECK}[/]" if fd.aligned else f"[error]{SYM_CROSS}[/]"
|
|
2755
|
+
console.print(f" {glyph} [feature]{fd.feature}[/]")
|
|
2756
|
+
for r in fd.repos:
|
|
2757
|
+
if r.actual is None:
|
|
2758
|
+
line = f" [repo]{r.repo}[/] [muted]→ no recorded state (expected {r.expected})[/]"
|
|
2759
|
+
elif r.aligned:
|
|
2760
|
+
line = f" [repo]{r.repo}[/] [muted]→ {r.actual}[/]"
|
|
2761
|
+
else:
|
|
2762
|
+
line = f" [repo]{r.repo}[/] [warning]→ {r.actual}[/] [muted](expected {r.expected})[/]"
|
|
2763
|
+
console.print(line)
|
|
2764
|
+
if not fd.aligned:
|
|
2765
|
+
console.print(f" [muted]fix:[/] [info]canopy switch {fd.feature}[/]")
|
|
2766
|
+
console.print()
|
|
2767
|
+
|
|
2768
|
+
|
|
2769
|
+
def cmd_pr_checks(args: argparse.Namespace) -> None:
|
|
2770
|
+
"""Fetch CI check runs for a PR alias (M10)."""
|
|
2771
|
+
from ..actions.aliases import resolve_pr_targets
|
|
2772
|
+
from ..integrations import github as gh
|
|
2773
|
+
from .ui import console
|
|
2774
|
+
|
|
2775
|
+
workspace = _load_workspace()
|
|
2776
|
+
targets = resolve_pr_targets(workspace, args.alias)
|
|
2777
|
+
results = []
|
|
2778
|
+
for t in targets:
|
|
2779
|
+
rollup, raw = gh.get_pr_checks(
|
|
2780
|
+
workspace.config.root, t.owner, t.repo_slug, t.pr_number,
|
|
2781
|
+
)
|
|
2782
|
+
results.append({
|
|
2783
|
+
"repo": t.repo,
|
|
2784
|
+
"pr_number": t.pr_number,
|
|
2785
|
+
"ci_status": rollup,
|
|
2786
|
+
"checks": raw,
|
|
2787
|
+
})
|
|
2788
|
+
|
|
2789
|
+
if args.json:
|
|
2790
|
+
_print_json({"alias": args.alias, "results": results})
|
|
2791
|
+
return
|
|
2792
|
+
|
|
2793
|
+
console.print()
|
|
2794
|
+
for r in results:
|
|
2795
|
+
ci = r["ci_status"]
|
|
2796
|
+
glyph = {
|
|
2797
|
+
"passing": "[success]✓[/]",
|
|
2798
|
+
"failing": "[error]✗[/]",
|
|
2799
|
+
"pending": "[warning]·[/]",
|
|
2800
|
+
"no_checks": "[muted]·[/]",
|
|
2801
|
+
}.get(ci.get("status"), "?")
|
|
2802
|
+
console.print(
|
|
2803
|
+
f" [repo]{r['repo']}[/] PR #{r['pr_number']} "
|
|
2804
|
+
f"{glyph} {ci.get('status', '')} "
|
|
2805
|
+
f"[muted]passed: {ci.get('passed', 0)}, failing: {ci.get('failing', 0)}, "
|
|
2806
|
+
f"pending: {ci.get('pending', 0)}[/]"
|
|
2807
|
+
)
|
|
2808
|
+
if ci.get("required_failing"):
|
|
2809
|
+
console.print(
|
|
2810
|
+
f" [error]failing:[/] {', '.join(ci['required_failing'])}"
|
|
2811
|
+
)
|
|
2812
|
+
if ci.get("details_url"):
|
|
2813
|
+
console.print(f" [muted]{ci['details_url']}[/]")
|
|
2814
|
+
console.print()
|
|
2815
|
+
|
|
2816
|
+
|
|
2817
|
+
def cmd_worktree_bootstrap(args: argparse.Namespace) -> None:
|
|
2818
|
+
"""Bootstrap a feature's worktrees: env-files, deps, IDE workspace (M6)."""
|
|
2819
|
+
from ..actions.bootstrap import ALLOWED_STEPS, bootstrap_feature
|
|
2820
|
+
from ..actions.errors import ActionError
|
|
2821
|
+
from .render import render_blocker
|
|
2822
|
+
from .ui import console, spinner
|
|
2823
|
+
|
|
2824
|
+
workspace = _load_workspace()
|
|
2825
|
+
steps_arg = getattr(args, "step", None)
|
|
2826
|
+
steps = [steps_arg] if steps_arg else None
|
|
2827
|
+
|
|
2828
|
+
try:
|
|
2829
|
+
with spinner("Bootstrapping…"):
|
|
2830
|
+
result = bootstrap_feature(
|
|
2831
|
+
workspace, args.feature,
|
|
2832
|
+
force=getattr(args, "force", False),
|
|
2833
|
+
steps=steps,
|
|
2834
|
+
)
|
|
2835
|
+
except ActionError as err:
|
|
2836
|
+
if args.json:
|
|
2837
|
+
_print_json(err.to_dict())
|
|
2838
|
+
else:
|
|
2839
|
+
render_blocker(err, action="worktree-bootstrap")
|
|
2840
|
+
sys.exit(1)
|
|
2841
|
+
|
|
2842
|
+
if args.json:
|
|
2843
|
+
_print_json(result)
|
|
2844
|
+
return
|
|
2845
|
+
|
|
2846
|
+
console.print()
|
|
2847
|
+
console.print(f" [feature]{result['feature']}[/]")
|
|
2848
|
+
for repo, r in result["results"].items():
|
|
2849
|
+
console.print(f"\n [repo]{repo}[/]")
|
|
2850
|
+
env = r["env"]
|
|
2851
|
+
env_glyph = {"ok": "[success]✓[/]", "skipped": "[muted]·[/]",
|
|
2852
|
+
"missing_source": "[warning]·[/]"}.get(env["status"], "?")
|
|
2853
|
+
copied = env.get("files_copied", [])
|
|
2854
|
+
env_summary = f"{len(copied)} file(s)" if copied else env.get("reason", env["status"])
|
|
2855
|
+
console.print(f" env {env_glyph} {env_summary}")
|
|
2856
|
+
deps = r["deps"]
|
|
2857
|
+
deps_glyph = {"ok": "[success]✓[/]", "failed": "[error]✗[/]",
|
|
2858
|
+
"skipped": "[muted]·[/]"}.get(deps["status"], "?")
|
|
2859
|
+
deps_extra = ""
|
|
2860
|
+
if deps["status"] == "ok":
|
|
2861
|
+
deps_extra = f" [muted]({deps.get('duration_ms', 0)}ms)[/]"
|
|
2862
|
+
elif deps["status"] == "failed":
|
|
2863
|
+
deps_extra = f" [error]exit {deps.get('exit_code', '?')}[/]"
|
|
2864
|
+
elif deps.get("reason"):
|
|
2865
|
+
deps_extra = f" [muted]{deps['reason']}[/]"
|
|
2866
|
+
console.print(f" deps {deps_glyph}{deps_extra}")
|
|
2867
|
+
ide = result["ide"]
|
|
2868
|
+
ide_glyph = {"ok": "[success]✓[/]", "skipped": "[muted]·[/]",
|
|
2869
|
+
"no_ide_configured": "[muted]·[/]"}.get(ide["status"], "?")
|
|
2870
|
+
if ide.get("path"):
|
|
2871
|
+
console.print(f"\n ide {ide_glyph} [muted]{ide['path']}[/]")
|
|
2872
|
+
elif ide.get("reason"):
|
|
2873
|
+
console.print(f"\n ide {ide_glyph} [muted]{ide['reason']}[/]")
|
|
2874
|
+
else:
|
|
2875
|
+
console.print(f"\n ide {ide_glyph} [muted]{ide['status']}[/]")
|
|
2876
|
+
console.print()
|
|
2877
|
+
|
|
2878
|
+
|
|
2879
|
+
def cmd_ship(args: argparse.Namespace) -> None:
|
|
2880
|
+
"""Open or update one PR per repo in the canonical feature (M8 / Wave 2.4)."""
|
|
2881
|
+
from ..actions.errors import ActionError
|
|
2882
|
+
from ..actions.ship import ship as ship_impl
|
|
2883
|
+
from .render import render_blocker
|
|
2884
|
+
from .ui import console, spinner
|
|
2885
|
+
|
|
2886
|
+
workspace = _load_workspace()
|
|
2887
|
+
spin_msg = "Dry-run ship…" if args.dry_run else "Shipping…"
|
|
2888
|
+
try:
|
|
2889
|
+
with spinner(spin_msg):
|
|
2890
|
+
result = ship_impl(
|
|
2891
|
+
workspace,
|
|
2892
|
+
feature=args.feature,
|
|
2893
|
+
repos=_split_csv(args.repos),
|
|
2894
|
+
draft=args.draft,
|
|
2895
|
+
reviewers=_split_csv(args.reviewers),
|
|
2896
|
+
dry_run=args.dry_run,
|
|
2897
|
+
base=args.base,
|
|
2898
|
+
)
|
|
2899
|
+
except ActionError as err:
|
|
2900
|
+
if args.json:
|
|
2901
|
+
_print_json(err.to_dict())
|
|
2902
|
+
else:
|
|
2903
|
+
render_blocker(err, action="ship")
|
|
2904
|
+
sys.exit(1)
|
|
2905
|
+
|
|
2906
|
+
if args.json:
|
|
2907
|
+
_print_json(result)
|
|
2908
|
+
return
|
|
2909
|
+
|
|
2910
|
+
console.print()
|
|
2911
|
+
console.print(f" [feature]{result['feature']}[/]")
|
|
2912
|
+
for repo, r in result["results"].items():
|
|
2913
|
+
status = r["status"]
|
|
2914
|
+
if status == "opened":
|
|
2915
|
+
console.print(
|
|
2916
|
+
f" [success]✓[/] [repo]{repo}[/] opened PR #{r['pr_number']} "
|
|
2917
|
+
f"[muted]{r.get('url', '')}[/]"
|
|
2918
|
+
+ (" [muted](draft)[/]" if r.get("draft") else "")
|
|
2919
|
+
)
|
|
2920
|
+
elif status == "up_to_date":
|
|
2921
|
+
console.print(
|
|
2922
|
+
f" [muted]·[/] [repo]{repo}[/] PR #{r['pr_number']} up to date"
|
|
2923
|
+
)
|
|
2924
|
+
elif status == "diverged":
|
|
2925
|
+
console.print(
|
|
2926
|
+
f" [warning]⚠[/] [repo]{repo}[/] PR #{r['pr_number']} diverged "
|
|
2927
|
+
f"[muted]{r.get('warning', '')}[/]"
|
|
2928
|
+
)
|
|
2929
|
+
elif status == "closed":
|
|
2930
|
+
console.print(
|
|
2931
|
+
f" [warning]·[/] [repo]{repo}[/] PR #{r['pr_number']} closed/merged "
|
|
2932
|
+
f"[muted]{r.get('reason', '')}[/]"
|
|
2933
|
+
)
|
|
2934
|
+
elif status == "skipped":
|
|
2935
|
+
console.print(
|
|
2936
|
+
f" [muted]·[/] [repo]{repo}[/] skipped — {r.get('reason', '')}"
|
|
2937
|
+
)
|
|
2938
|
+
elif status.startswith("would_"):
|
|
2939
|
+
console.print(
|
|
2940
|
+
f" [muted]·[/] [repo]{repo}[/] {status} (dry-run)"
|
|
2941
|
+
)
|
|
2942
|
+
else:
|
|
2943
|
+
console.print(f" [error]✗[/] [repo]{repo}[/] {r.get('reason', status)}")
|
|
2944
|
+
if result.get("cross_repo_links_updated"):
|
|
2945
|
+
console.print()
|
|
2946
|
+
console.print(" [muted]cross-repo PR descriptions updated with sibling links[/]")
|
|
2947
|
+
console.print()
|
|
2948
|
+
|
|
2949
|
+
|
|
2950
|
+
def cmd_draft_replies(args: argparse.Namespace) -> None:
|
|
2951
|
+
"""Auto-draft "Done in <sha>" replies for addressed PR comments (M9)."""
|
|
2952
|
+
from ..actions.draft_replies import draft_replies
|
|
2953
|
+
from .ui import console
|
|
2954
|
+
|
|
2955
|
+
workspace = _load_workspace()
|
|
2956
|
+
result = draft_replies(
|
|
2957
|
+
workspace, args.alias,
|
|
2958
|
+
include_likely_resolved=getattr(args, "include_likely_resolved", False),
|
|
2959
|
+
)
|
|
2960
|
+
|
|
2961
|
+
if args.json:
|
|
2962
|
+
_print_json(result)
|
|
2963
|
+
return
|
|
2964
|
+
|
|
2965
|
+
console.print()
|
|
2966
|
+
console.print(
|
|
2967
|
+
f" [header]drafts: {result['addressed_total']} addressed, "
|
|
2968
|
+
f"{result['unaddressed_total']} unaddressed[/]"
|
|
2969
|
+
)
|
|
2970
|
+
for repo, info in result["repos"].items():
|
|
2971
|
+
console.print()
|
|
2972
|
+
console.print(
|
|
2973
|
+
f" [repo]{repo}[/] PR #{info['pr_number']} [muted]{info.get('pr_url', '')}[/]"
|
|
2974
|
+
)
|
|
2975
|
+
for draft in info["addressed"]:
|
|
2976
|
+
conf = draft["confidence"]
|
|
2977
|
+
tag = {"high": "[success]✓[/]", "medium": "[warning]·[/]",
|
|
2978
|
+
"low": "[muted]·[/]"}.get(conf, "·")
|
|
2979
|
+
orig = draft["original_comment"]
|
|
2980
|
+
location = (
|
|
2981
|
+
f"{orig['path']}:{orig['line']}" if orig.get("path") else "(general)"
|
|
2982
|
+
)
|
|
2983
|
+
console.print(
|
|
2984
|
+
f" {tag} [muted]{location}[/] {orig.get('author', '')} "
|
|
2985
|
+
f"[muted]({conf})[/]"
|
|
2986
|
+
)
|
|
2987
|
+
console.print(f" → [info]{draft['draft_reply']}[/]")
|
|
2988
|
+
if not info["addressed"]:
|
|
2989
|
+
console.print(
|
|
2990
|
+
f" [muted]no draftable replies; "
|
|
2991
|
+
f"{len(info['unaddressed'])} unaddressed[/]"
|
|
2992
|
+
)
|
|
2993
|
+
console.print()
|
|
2994
|
+
|
|
2995
|
+
|
|
2996
|
+
def cmd_conflicts(args: argparse.Namespace) -> None:
|
|
2997
|
+
"""Cross-feature file-overlap detection (M12)."""
|
|
2998
|
+
from ..actions.conflicts import find_conflicts
|
|
2999
|
+
from .ui import console
|
|
3000
|
+
|
|
3001
|
+
workspace = _load_workspace()
|
|
3002
|
+
result = find_conflicts(
|
|
3003
|
+
workspace,
|
|
3004
|
+
feature=getattr(args, "feature", None),
|
|
3005
|
+
other=getattr(args, "with_", None),
|
|
3006
|
+
include_cold=getattr(args, "include_cold", False),
|
|
3007
|
+
line_level=getattr(args, "lines", False),
|
|
3008
|
+
)
|
|
3009
|
+
|
|
3010
|
+
if args.json:
|
|
3011
|
+
_print_json(result)
|
|
3012
|
+
return
|
|
3013
|
+
|
|
3014
|
+
pairs = result["pairs"]
|
|
3015
|
+
console.print()
|
|
3016
|
+
console.print(f" [header]Conflicts ({len(pairs)} pair{'' if len(pairs) == 1 else 's'})[/]")
|
|
3017
|
+
console.print(f" {'─' * 60}")
|
|
3018
|
+
if not pairs:
|
|
3019
|
+
console.print(" [muted]no overlaps detected[/]")
|
|
3020
|
+
console.print()
|
|
3021
|
+
return
|
|
3022
|
+
|
|
3023
|
+
severity_glyph = {
|
|
3024
|
+
"high": "[error]⚠[/]",
|
|
3025
|
+
"medium": "[warning]·[/]",
|
|
3026
|
+
"low": "[muted]·[/]",
|
|
3027
|
+
}
|
|
3028
|
+
for pair in pairs:
|
|
3029
|
+
glyph = severity_glyph.get(pair["severity"], "·")
|
|
3030
|
+
console.print(
|
|
3031
|
+
f"\n {glyph} [feature]{pair['feature_a']}[/] ↔ [feature]{pair['feature_b']}[/] "
|
|
3032
|
+
f"[muted]{pair['severity']}[/]"
|
|
3033
|
+
)
|
|
3034
|
+
for repo, entry in pair["overlap"].items():
|
|
3035
|
+
files = entry["files"]
|
|
3036
|
+
file_list = ", ".join(files[:3])
|
|
3037
|
+
if len(files) > 3:
|
|
3038
|
+
file_list += f" (+{len(files) - 3} more)"
|
|
3039
|
+
line_bit = ""
|
|
3040
|
+
if "lines_both" in entry and entry["lines_both"] > 0:
|
|
3041
|
+
line_bit = f", {entry['lines_both']} lines overlapping"
|
|
3042
|
+
console.print(f" [repo]{repo}[/] {file_list}{line_bit}")
|
|
3043
|
+
console.print(f" [muted]suggestion: {pair['suggestion']}[/]")
|
|
3044
|
+
console.print()
|
|
3045
|
+
|
|
3046
|
+
|
|
3047
|
+
def cmd_doctor(args: argparse.Namespace) -> None:
|
|
3048
|
+
"""Diagnose workspace + install integrity; optionally repair."""
|
|
3049
|
+
from ..actions.doctor import doctor
|
|
3050
|
+
from .ui import console
|
|
3051
|
+
|
|
3052
|
+
workspace = _load_workspace()
|
|
3053
|
+
|
|
3054
|
+
fix_categories = None
|
|
3055
|
+
if getattr(args, "fix_category", None):
|
|
3056
|
+
fix_categories = [args.fix_category]
|
|
3057
|
+
fix = bool(getattr(args, "fix", False) or fix_categories)
|
|
3058
|
+
feature = getattr(args, "feature", None)
|
|
3059
|
+
clean_vsix = bool(getattr(args, "clean_vsix", False))
|
|
3060
|
+
|
|
3061
|
+
report = doctor(
|
|
3062
|
+
workspace,
|
|
3063
|
+
fix=fix,
|
|
3064
|
+
fix_categories=fix_categories,
|
|
3065
|
+
feature=feature,
|
|
3066
|
+
clean_vsix=clean_vsix,
|
|
3067
|
+
)
|
|
3068
|
+
|
|
3069
|
+
if args.json:
|
|
3070
|
+
_print_json(report)
|
|
3071
|
+
return
|
|
3072
|
+
|
|
3073
|
+
issues = report["issues"]
|
|
3074
|
+
summary = report["summary"]
|
|
3075
|
+
fixed = report.get("fixed") or []
|
|
3076
|
+
skipped = report.get("skipped") or []
|
|
3077
|
+
|
|
3078
|
+
console.print()
|
|
3079
|
+
if not issues:
|
|
3080
|
+
console.print(" [success]✓[/] doctor: workspace + install look clean")
|
|
3081
|
+
console.print()
|
|
3082
|
+
return
|
|
3083
|
+
|
|
3084
|
+
# Group by severity, errors first.
|
|
3085
|
+
glyphs = {"error": ("[error]✗[/]", "errors"),
|
|
3086
|
+
"warn": ("[warning]![/]", "warnings"),
|
|
3087
|
+
"info": ("[muted]·[/]", "info")}
|
|
3088
|
+
for sev, (glyph, label) in glyphs.items():
|
|
3089
|
+
sev_issues = [i for i in issues if i["severity"] == sev]
|
|
3090
|
+
if not sev_issues:
|
|
3091
|
+
continue
|
|
3092
|
+
console.print(f" {glyph} [header]{label}:[/] {len(sev_issues)}")
|
|
3093
|
+
for issue in sev_issues:
|
|
3094
|
+
scope = ""
|
|
3095
|
+
if issue.get("repo") and issue.get("feature"):
|
|
3096
|
+
scope = f" [muted]({issue['feature']}/{issue['repo']})[/]"
|
|
3097
|
+
elif issue.get("repo"):
|
|
3098
|
+
scope = f" [muted]({issue['repo']})[/]"
|
|
3099
|
+
elif issue.get("feature"):
|
|
3100
|
+
scope = f" [muted]({issue['feature']})[/]"
|
|
3101
|
+
console.print(
|
|
3102
|
+
f" {glyph} {issue['what']}{scope} [muted]({issue['code']})[/]"
|
|
3103
|
+
)
|
|
3104
|
+
if getattr(args, "verbose", False):
|
|
3105
|
+
if issue.get("expected") is not None:
|
|
3106
|
+
console.print(f" [muted]expected:[/] {issue['expected']}")
|
|
3107
|
+
if issue.get("actual") is not None:
|
|
3108
|
+
console.print(f" [muted]actual: [/] {issue['actual']}")
|
|
3109
|
+
if issue.get("fix_action"):
|
|
3110
|
+
tag = "[muted](safe)[/]" if issue.get("auto_fixable") else "[warning](manual)[/]"
|
|
3111
|
+
console.print(f" [muted]fix:[/] {issue['fix_action']} {tag}")
|
|
3112
|
+
console.print()
|
|
3113
|
+
|
|
3114
|
+
if fix:
|
|
3115
|
+
console.print(f" [info]repaired:[/] {len(fixed)}; [muted]skipped:[/] {len(skipped)}")
|
|
3116
|
+
for f in fixed:
|
|
3117
|
+
ok = "[success]✓[/]" if f.get("success") else "[error]✗[/]"
|
|
3118
|
+
console.print(f" {ok} {f['action_taken']} [muted]({f['code']})[/]")
|
|
3119
|
+
if f.get("error"):
|
|
3120
|
+
console.print(f" [error]error:[/] {f['error']}")
|
|
3121
|
+
for s in skipped:
|
|
3122
|
+
console.print(
|
|
3123
|
+
f" [muted]· skipped {s['code']}: {s.get('skip_reason', '')}[/]"
|
|
3124
|
+
)
|
|
3125
|
+
console.print()
|
|
3126
|
+
|
|
3127
|
+
console.print(
|
|
3128
|
+
f" [muted]summary:[/] errors={summary['errors']} "
|
|
3129
|
+
f"warnings={summary['warnings']} info={summary['info']}"
|
|
3130
|
+
)
|
|
3131
|
+
console.print()
|
|
3132
|
+
|
|
3133
|
+
|
|
3134
|
+
def _resolve_thread_feature(workspace, feature: str | None) -> str:
|
|
3135
|
+
"""Return a feature name for thread commands, falling back to canonical."""
|
|
3136
|
+
from ..actions import slots as slots_mod
|
|
3137
|
+
from ..actions.aliases import resolve_feature
|
|
3138
|
+
from ..actions.errors import BlockerError
|
|
3139
|
+
if feature:
|
|
3140
|
+
return resolve_feature(workspace, feature)
|
|
3141
|
+
state = slots_mod.read_state(workspace)
|
|
3142
|
+
if state is None or state.canonical is None:
|
|
3143
|
+
raise BlockerError(
|
|
3144
|
+
code="no_canonical_feature",
|
|
3145
|
+
what="no active feature; pass --feature or run `canopy switch <name>` first",
|
|
3146
|
+
)
|
|
3147
|
+
return state.canonical.feature
|
|
3148
|
+
|
|
3149
|
+
|
|
3150
|
+
def cmd_reply_thread(args: argparse.Namespace) -> None:
|
|
3151
|
+
"""Post a reply to a GitHub PR review thread, optionally resolving it."""
|
|
3152
|
+
from ..actions.thread_actions import reply_to_thread
|
|
3153
|
+
from ..actions.errors import ActionError, BlockerError
|
|
3154
|
+
from .render import render_blocker
|
|
3155
|
+
from .ui import console
|
|
3156
|
+
|
|
3157
|
+
workspace = _load_workspace()
|
|
3158
|
+
try:
|
|
3159
|
+
feature = _resolve_thread_feature(workspace, getattr(args, "feature", None))
|
|
3160
|
+
except ActionError as err:
|
|
3161
|
+
if args.json:
|
|
3162
|
+
_print_json(err.to_dict())
|
|
3163
|
+
else:
|
|
3164
|
+
render_blocker(err, action="reply")
|
|
3165
|
+
sys.exit(1)
|
|
3166
|
+
|
|
3167
|
+
if args.body is not None:
|
|
3168
|
+
body = args.body
|
|
3169
|
+
elif args.body_file is not None:
|
|
3170
|
+
from pathlib import Path
|
|
3171
|
+
body = Path(args.body_file).read_text()
|
|
3172
|
+
else:
|
|
3173
|
+
if sys.stdin.isatty():
|
|
3174
|
+
err = BlockerError(
|
|
3175
|
+
code="missing_reply_body",
|
|
3176
|
+
what="reply body required: pass --body, --body-file, or pipe stdin",
|
|
3177
|
+
)
|
|
3178
|
+
if args.json:
|
|
3179
|
+
_print_json(err.to_dict())
|
|
3180
|
+
else:
|
|
3181
|
+
render_blocker(err, action="reply")
|
|
3182
|
+
sys.exit(1)
|
|
3183
|
+
body = sys.stdin.read()
|
|
3184
|
+
|
|
3185
|
+
if not body.strip():
|
|
3186
|
+
err = BlockerError(code="empty_reply_body", what="reply body is empty")
|
|
3187
|
+
if args.json:
|
|
3188
|
+
_print_json(err.to_dict())
|
|
3189
|
+
else:
|
|
3190
|
+
render_blocker(err, action="reply")
|
|
3191
|
+
sys.exit(1)
|
|
3192
|
+
|
|
3193
|
+
try:
|
|
3194
|
+
result = reply_to_thread(workspace, args.thread_id, body,
|
|
3195
|
+
feature=feature, resolve_after=args.resolve)
|
|
3196
|
+
except ActionError as err:
|
|
3197
|
+
if args.json:
|
|
3198
|
+
_print_json(err.to_dict())
|
|
3199
|
+
else:
|
|
3200
|
+
render_blocker(err, action="reply")
|
|
3201
|
+
sys.exit(1)
|
|
3202
|
+
|
|
3203
|
+
if args.json:
|
|
3204
|
+
_print_json(result)
|
|
3205
|
+
return
|
|
3206
|
+
|
|
3207
|
+
console.print()
|
|
3208
|
+
console.print(f" Posted reply: [info]{result['posted']['url']}[/]")
|
|
3209
|
+
if args.resolve and "resolved" in result:
|
|
3210
|
+
console.print(f" Resolved [info]{args.thread_id}[/]")
|
|
3211
|
+
console.print()
|
|
3212
|
+
|
|
3213
|
+
|
|
3214
|
+
def cmd_resolve_thread(args: argparse.Namespace) -> None:
|
|
3215
|
+
"""Resolve a GitHub PR review thread and record the resolution locally."""
|
|
3216
|
+
from ..actions.thread_actions import resolve_thread
|
|
3217
|
+
from ..actions.errors import ActionError
|
|
3218
|
+
from .render import render_blocker
|
|
3219
|
+
from .ui import console
|
|
3220
|
+
|
|
3221
|
+
workspace = _load_workspace()
|
|
3222
|
+
try:
|
|
3223
|
+
feature = _resolve_thread_feature(workspace, getattr(args, "feature", None))
|
|
3224
|
+
except ActionError as err:
|
|
3225
|
+
if args.json:
|
|
3226
|
+
_print_json(err.to_dict())
|
|
3227
|
+
else:
|
|
3228
|
+
render_blocker(err, action="resolve")
|
|
3229
|
+
sys.exit(1)
|
|
3230
|
+
|
|
3231
|
+
try:
|
|
3232
|
+
result = resolve_thread(workspace, args.thread_id, feature=feature)
|
|
3233
|
+
except ActionError as err:
|
|
3234
|
+
if args.json:
|
|
3235
|
+
_print_json(err.to_dict())
|
|
3236
|
+
else:
|
|
3237
|
+
render_blocker(err, action="resolve")
|
|
3238
|
+
sys.exit(1)
|
|
3239
|
+
|
|
3240
|
+
if args.json:
|
|
3241
|
+
_print_json(result)
|
|
3242
|
+
return
|
|
3243
|
+
|
|
3244
|
+
console.print()
|
|
3245
|
+
console.print(f" Resolved [info]{args.thread_id}[/]")
|
|
3246
|
+
console.print()
|
|
3247
|
+
|
|
3248
|
+
|
|
3249
|
+
def _render_resume_human(brief: dict, console) -> None:
|
|
3250
|
+
"""Paint the resume brief in a human-readable layout."""
|
|
3251
|
+
from .ui import SYM_ARROW, separator
|
|
3252
|
+
|
|
3253
|
+
feature = brief.get("feature", "")
|
|
3254
|
+
fstate = (brief.get("current_state") or {}).get("feature_state") or "unknown"
|
|
3255
|
+
last_visit = brief.get("last_visit")
|
|
3256
|
+
window_hours = brief.get("window_hours")
|
|
3257
|
+
first_visit = brief.get("first_visit", False)
|
|
3258
|
+
switch_performed = brief.get("switch_performed", False)
|
|
3259
|
+
switch_summary = brief.get("switch_summary") or {}
|
|
3260
|
+
since = brief.get("since_last_visit") or {}
|
|
3261
|
+
current = brief.get("current_state") or {}
|
|
3262
|
+
hints = brief.get("intent_hints") or []
|
|
3263
|
+
|
|
3264
|
+
console.print()
|
|
3265
|
+
console.print(
|
|
3266
|
+
f" [feature]{feature}[/] [muted]state: {fstate}[/]"
|
|
3267
|
+
)
|
|
3268
|
+
|
|
3269
|
+
# Last visit line
|
|
3270
|
+
if first_visit:
|
|
3271
|
+
console.print(f" [muted]first visit[/]")
|
|
3272
|
+
else:
|
|
3273
|
+
ago = f" [muted]({window_hours:.0f}h ago)[/]" if window_hours is not None else ""
|
|
3274
|
+
console.print(f" [muted]last visit: {last_visit}{ago}[/]")
|
|
3275
|
+
|
|
3276
|
+
if switch_performed:
|
|
3277
|
+
prev = switch_summary.get("previously_canonical") or ""
|
|
3278
|
+
mode = switch_summary.get("mode", "active_rotation")
|
|
3279
|
+
mode_label = {"active_rotation": "active_rotation", "wind_down": "wind_down"}.get(mode, mode)
|
|
3280
|
+
arrow_str = f"{prev} {SYM_ARROW} {feature}" if prev else feature
|
|
3281
|
+
console.print(f" [muted]switch: {arrow_str} [{mode_label}][/]")
|
|
3282
|
+
|
|
3283
|
+
# ── Since last visit ──────────────────────────────────────────────────
|
|
3284
|
+
separator()
|
|
3285
|
+
console.print(f" [header]Since last visit[/]")
|
|
3286
|
+
|
|
3287
|
+
commits = since.get("commits") or {}
|
|
3288
|
+
if commits:
|
|
3289
|
+
parts = " ".join(
|
|
3290
|
+
f"[repo]{r}[/] {len(cs)}" for r, cs in commits.items() if cs
|
|
3291
|
+
)
|
|
3292
|
+
if parts:
|
|
3293
|
+
console.print(f" Commits {parts}")
|
|
3294
|
+
|
|
3295
|
+
new_threads = since.get("threads_new") or []
|
|
3296
|
+
resolved_gh = since.get("threads_resolved_on_github") or []
|
|
3297
|
+
resolved_by_canopy = since.get("threads_resolved_by_canopy") or []
|
|
3298
|
+
if new_threads:
|
|
3299
|
+
repos_hit = {t.get("repo") for t in new_threads if t.get("repo")}
|
|
3300
|
+
suffix = f" [muted]({', '.join(sorted(repos_hit))})[/]" if repos_hit else ""
|
|
3301
|
+
console.print(f" New threads {len(new_threads)}{suffix}")
|
|
3302
|
+
if resolved_gh:
|
|
3303
|
+
by_canopy = sum(1 for t in resolved_gh if t.get("by_canopy"))
|
|
3304
|
+
canon_str = f" [muted](by canopy: {by_canopy})[/]" if by_canopy else ""
|
|
3305
|
+
console.print(f" Resolved threads {len(resolved_gh)}{canon_str}")
|
|
3306
|
+
elif resolved_by_canopy:
|
|
3307
|
+
console.print(f" Resolved by canopy {len(resolved_by_canopy)}")
|
|
3308
|
+
|
|
3309
|
+
drafts_pending = since.get("draft_replies_pending", 0)
|
|
3310
|
+
if drafts_pending:
|
|
3311
|
+
console.print(f" Draft replies {drafts_pending} pending")
|
|
3312
|
+
|
|
3313
|
+
historian_excerpt = since.get("historian_excerpt") or ""
|
|
3314
|
+
if historian_excerpt:
|
|
3315
|
+
first_line = historian_excerpt.splitlines()[0]
|
|
3316
|
+
console.print(f" Memory [muted]{first_line}[/]")
|
|
3317
|
+
|
|
3318
|
+
if not any([commits, new_threads, resolved_gh, resolved_by_canopy, drafts_pending, historian_excerpt]):
|
|
3319
|
+
console.print(f" [muted]nothing new[/]")
|
|
3320
|
+
|
|
3321
|
+
# ── Current ───────────────────────────────────────────────────────────
|
|
3322
|
+
separator()
|
|
3323
|
+
console.print(f" [header]Current[/]")
|
|
3324
|
+
|
|
3325
|
+
linear_issue = current.get("linear_issue")
|
|
3326
|
+
linear_url = current.get("linear_url")
|
|
3327
|
+
if linear_issue:
|
|
3328
|
+
url_str = f" [muted]{linear_url}[/]" if linear_url else ""
|
|
3329
|
+
console.print(f" Linear [linear]{linear_issue}[/]{url_str}")
|
|
3330
|
+
|
|
3331
|
+
ci = current.get("ci_summary_per_repo") or {}
|
|
3332
|
+
if ci:
|
|
3333
|
+
ci_parts = []
|
|
3334
|
+
for r, status in ci.items():
|
|
3335
|
+
if status in ("passing", "success"):
|
|
3336
|
+
ci_parts.append(f"[repo]{r}[/] [success]{status}[/]")
|
|
3337
|
+
elif status in ("failing", "failure"):
|
|
3338
|
+
ci_parts.append(f"[repo]{r}[/] [error]{status}[/]")
|
|
3339
|
+
else:
|
|
3340
|
+
ci_parts.append(f"[repo]{r}[/] [muted]{status}[/]")
|
|
3341
|
+
console.print(f" CI {' '.join(ci_parts)}")
|
|
3342
|
+
|
|
3343
|
+
open_threads = current.get("open_thread_count", 0)
|
|
3344
|
+
if open_threads:
|
|
3345
|
+
console.print(f" Open threads {open_threads}")
|
|
3346
|
+
|
|
3347
|
+
bot_unresolved = current.get("bot_unresolved_total", 0)
|
|
3348
|
+
if bot_unresolved:
|
|
3349
|
+
console.print(f" Bot unresolved {bot_unresolved}")
|
|
3350
|
+
|
|
3351
|
+
branch_pos = current.get("branch_position_per_repo") or {}
|
|
3352
|
+
if branch_pos:
|
|
3353
|
+
for repo_name, pos in branch_pos.items():
|
|
3354
|
+
ahead = pos.get("ahead", 0)
|
|
3355
|
+
behind = pos.get("behind", 0)
|
|
3356
|
+
default = pos.get("default_branch", "main")
|
|
3357
|
+
parts = []
|
|
3358
|
+
if ahead:
|
|
3359
|
+
parts.append(f"[ahead]+{ahead}[/]")
|
|
3360
|
+
if behind:
|
|
3361
|
+
parts.append(f"[behind]-{behind}[/]")
|
|
3362
|
+
div_str = " ".join(parts) if parts else "[muted]up to date[/]"
|
|
3363
|
+
console.print(
|
|
3364
|
+
f" Branch [repo]{repo_name}[/] {div_str} [muted]from {default}[/]"
|
|
3365
|
+
)
|
|
3366
|
+
|
|
3367
|
+
# ── Next actions ──────────────────────────────────────────────────────
|
|
3368
|
+
if hints:
|
|
3369
|
+
separator()
|
|
3370
|
+
console.print(f" [header]Next actions[/]")
|
|
3371
|
+
for i, hint in enumerate(hints, 1):
|
|
3372
|
+
kind = hint.get("kind", "")
|
|
3373
|
+
summary = hint.get("summary", "")
|
|
3374
|
+
tool = hint.get("suggested_tool", "")
|
|
3375
|
+
tool_args = hint.get("suggested_args") or {}
|
|
3376
|
+
args_str = ", ".join(f'{k}="{v}"' for k, v in tool_args.items() if v)
|
|
3377
|
+
call_str = f"{tool}({args_str})" if tool else ""
|
|
3378
|
+
console.print(f" {i}. [info]{kind}[/] [muted]{summary}[/]")
|
|
3379
|
+
if call_str:
|
|
3380
|
+
console.print(f" [muted]{SYM_ARROW} {call_str}[/]")
|
|
3381
|
+
|
|
3382
|
+
console.print()
|
|
3383
|
+
|
|
3384
|
+
|
|
3385
|
+
def cmd_resume(args: argparse.Namespace) -> None:
|
|
3386
|
+
"""Show a fresh resume brief: what changed since last visit."""
|
|
3387
|
+
from ..actions.resume import feature_resume
|
|
3388
|
+
from ..actions.last_visit import reset_anchor
|
|
3389
|
+
from ..actions.errors import ActionError
|
|
3390
|
+
from .render import render_blocker
|
|
3391
|
+
from .ui import console
|
|
3392
|
+
|
|
3393
|
+
workspace = _load_workspace()
|
|
3394
|
+
|
|
3395
|
+
if args.reset_anchor:
|
|
3396
|
+
from ..actions.aliases import resolve_feature
|
|
3397
|
+
try:
|
|
3398
|
+
feature = resolve_feature(workspace, args.alias)
|
|
3399
|
+
except ActionError as err:
|
|
3400
|
+
if args.json:
|
|
3401
|
+
_print_json(err.to_dict())
|
|
3402
|
+
else:
|
|
3403
|
+
render_blocker(err, action="resume")
|
|
3404
|
+
sys.exit(1)
|
|
3405
|
+
cleared = reset_anchor(workspace, feature)
|
|
3406
|
+
if args.json:
|
|
3407
|
+
_print_json({"feature": feature, "cleared": cleared})
|
|
3408
|
+
return
|
|
3409
|
+
console.print(f" [success]Cleared last_visit for {feature}[/]")
|
|
3410
|
+
return
|
|
3411
|
+
|
|
3412
|
+
try:
|
|
3413
|
+
brief = feature_resume(workspace, args.alias)
|
|
3414
|
+
except ActionError as err:
|
|
3415
|
+
if args.json:
|
|
3416
|
+
_print_json(err.to_dict())
|
|
3417
|
+
else:
|
|
3418
|
+
render_blocker(err, action="resume")
|
|
3419
|
+
sys.exit(1)
|
|
3420
|
+
|
|
3421
|
+
if args.json:
|
|
3422
|
+
_print_json(brief)
|
|
3423
|
+
return
|
|
3424
|
+
|
|
3425
|
+
_render_resume_human(brief, console)
|
|
3426
|
+
|
|
3427
|
+
|
|
3428
|
+
def cmd_context(args: argparse.Namespace) -> None:
|
|
3429
|
+
"""Show detected canopy context for current directory (debug)."""
|
|
3430
|
+
from ..workspace.context import detect_context
|
|
3431
|
+
|
|
3432
|
+
ctx = detect_context()
|
|
3433
|
+
|
|
3434
|
+
if args.json:
|
|
3435
|
+
_print_json(ctx.to_dict())
|
|
3436
|
+
return
|
|
3437
|
+
|
|
3438
|
+
print(f"\n Context type: {ctx.context_type}")
|
|
3439
|
+
print(f" Working dir: {ctx.cwd}")
|
|
3440
|
+
if ctx.workspace_root:
|
|
3441
|
+
print(f" Workspace: {ctx.workspace_root}")
|
|
3442
|
+
if ctx.feature:
|
|
3443
|
+
print(f" Feature: {ctx.feature}")
|
|
3444
|
+
if ctx.branch:
|
|
3445
|
+
print(f" Branch: {ctx.branch}")
|
|
3446
|
+
if ctx.repo_names:
|
|
3447
|
+
print(f" Repos: {', '.join(ctx.repo_names)}")
|
|
3448
|
+
for name, path in zip(ctx.repo_names, ctx.repo_paths):
|
|
3449
|
+
print(f" {name}: {path}")
|
|
3450
|
+
print()
|
|
3451
|
+
|
|
3452
|
+
|
|
3453
|
+
# ── Entry point ───────────────────────────────────────────────────────────
|
|
3454
|
+
|
|
3455
|
+
def main() -> None:
|
|
3456
|
+
from .. import __version__
|
|
3457
|
+
|
|
3458
|
+
parser = argparse.ArgumentParser(
|
|
3459
|
+
prog="canopy",
|
|
3460
|
+
description="Workspace-first development orchestrator.",
|
|
3461
|
+
)
|
|
3462
|
+
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3463
|
+
parser.add_argument(
|
|
3464
|
+
"--version", action="version", version=f"canopy {__version__}",
|
|
3465
|
+
)
|
|
3466
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
3467
|
+
|
|
3468
|
+
# init
|
|
3469
|
+
init_p = subparsers.add_parser("init", help="Initialize a workspace")
|
|
3470
|
+
init_p.add_argument("path", nargs="?", default=None, help="Workspace root path")
|
|
3471
|
+
init_p.add_argument("--name", default=None, help="Workspace name")
|
|
3472
|
+
init_p.add_argument("--force", action="store_true", help="Overwrite existing canopy.toml")
|
|
3473
|
+
init_p.add_argument("--dry-run", action="store_true", help="Print toml without writing")
|
|
3474
|
+
init_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3475
|
+
init_p.add_argument("--no-agent", action="store_true",
|
|
3476
|
+
help="Skip Claude Code agent setup (skill + MCP config)")
|
|
3477
|
+
|
|
3478
|
+
# status
|
|
3479
|
+
status_p = subparsers.add_parser("status", help="Workspace status")
|
|
3480
|
+
status_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3481
|
+
|
|
3482
|
+
# feature (with subcommands)
|
|
3483
|
+
feature_p = subparsers.add_parser("feature", help="Feature lane operations")
|
|
3484
|
+
feature_sub = feature_p.add_subparsers(dest="feature_command")
|
|
3485
|
+
|
|
3486
|
+
# feature create
|
|
3487
|
+
fc = feature_sub.add_parser("create", help="Create a feature lane")
|
|
3488
|
+
fc.add_argument("name", help="Feature/branch name")
|
|
3489
|
+
fc.add_argument("--repos", default=None, help="Comma-separated repo names (default: all)")
|
|
3490
|
+
fc.add_argument("--worktree", action="store_true",
|
|
3491
|
+
help="Create linked worktrees (each repo gets its own directory)")
|
|
3492
|
+
fc.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3493
|
+
|
|
3494
|
+
# feature list
|
|
3495
|
+
fl = feature_sub.add_parser("list", help="List feature lanes")
|
|
3496
|
+
fl.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3497
|
+
|
|
3498
|
+
# feature diff
|
|
3499
|
+
fd = feature_sub.add_parser("diff", help="Feature lane diff")
|
|
3500
|
+
fd.add_argument("name", help="Feature name")
|
|
3501
|
+
fd.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3502
|
+
|
|
3503
|
+
# feature status
|
|
3504
|
+
fst = feature_sub.add_parser("status", help="Feature lane status")
|
|
3505
|
+
fst.add_argument("name", help="Feature name")
|
|
3506
|
+
fst.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3507
|
+
|
|
3508
|
+
# feature changes
|
|
3509
|
+
fch = feature_sub.add_parser("changes", help="Per-file change status across repos")
|
|
3510
|
+
fch.add_argument("name", help="Feature name")
|
|
3511
|
+
fch.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3512
|
+
|
|
3513
|
+
# sync
|
|
3514
|
+
sync_p = subparsers.add_parser("sync", help="Pull + rebase across repos")
|
|
3515
|
+
sync_p.add_argument("--strategy", choices=["rebase", "merge"], default="rebase")
|
|
3516
|
+
sync_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3517
|
+
|
|
3518
|
+
# checkout
|
|
3519
|
+
co_p = subparsers.add_parser("checkout", help="Checkout branch across repos")
|
|
3520
|
+
co_p.add_argument("branch", help="Branch to checkout")
|
|
3521
|
+
co_p.add_argument("--repos", default=None, help="Comma-separated repo names")
|
|
3522
|
+
co_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3523
|
+
|
|
3524
|
+
# log
|
|
3525
|
+
log_p = subparsers.add_parser("log", help="Interleaved log across repos")
|
|
3526
|
+
log_p.add_argument("-n", "--count", type=int, default=20, help="Max entries")
|
|
3527
|
+
log_p.add_argument("--feature", default=None, help="Show log for feature branch")
|
|
3528
|
+
log_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3529
|
+
|
|
3530
|
+
# branch (with subcommands)
|
|
3531
|
+
branch_p = subparsers.add_parser("branch", help="Branch operations across repos")
|
|
3532
|
+
branch_sub = branch_p.add_subparsers(dest="branch_command")
|
|
3533
|
+
|
|
3534
|
+
bl = branch_sub.add_parser("list", help="List branches")
|
|
3535
|
+
bl.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3536
|
+
|
|
3537
|
+
bd = branch_sub.add_parser("delete", help="Delete a branch")
|
|
3538
|
+
bd.add_argument("name", help="Branch to delete")
|
|
3539
|
+
bd.add_argument("--force", action="store_true", help="Force delete")
|
|
3540
|
+
bd.add_argument("--repos", default=None, help="Comma-separated repo names")
|
|
3541
|
+
bd.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3542
|
+
|
|
3543
|
+
br = branch_sub.add_parser("rename", help="Rename a branch")
|
|
3544
|
+
br.add_argument("old", help="Current branch name")
|
|
3545
|
+
br.add_argument("new", help="New branch name")
|
|
3546
|
+
br.add_argument("--repos", default=None, help="Comma-separated repo names")
|
|
3547
|
+
br.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3548
|
+
|
|
3549
|
+
binfo = branch_sub.add_parser(
|
|
3550
|
+
"info",
|
|
3551
|
+
help="Branch info per repo (alias = feature or <repo>:<branch>)",
|
|
3552
|
+
)
|
|
3553
|
+
binfo.add_argument("alias", help="Feature alias or <repo>:<branch>")
|
|
3554
|
+
binfo.add_argument("--repo", default=None,
|
|
3555
|
+
help="Filter feature-alias result to one repo")
|
|
3556
|
+
binfo.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3557
|
+
|
|
3558
|
+
# stash (with subcommands)
|
|
3559
|
+
stash_p = subparsers.add_parser("stash", help="Stash operations across repos")
|
|
3560
|
+
stash_sub = stash_p.add_subparsers(dest="stash_command")
|
|
3561
|
+
|
|
3562
|
+
ss = stash_sub.add_parser("save", help="Stash changes")
|
|
3563
|
+
ss.add_argument("-m", "--message", default="", help="Stash message")
|
|
3564
|
+
ss.add_argument("--repos", default=None, help="Comma-separated repo names")
|
|
3565
|
+
ss.add_argument("--feature", default=None,
|
|
3566
|
+
help="Tag stash with this feature (canopy:<f>:...) and "
|
|
3567
|
+
"include untracked files")
|
|
3568
|
+
ss.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3569
|
+
|
|
3570
|
+
sp = stash_sub.add_parser("pop", help="Pop stash")
|
|
3571
|
+
sp.add_argument("--index", type=int, default=0, help="Stash index")
|
|
3572
|
+
sp.add_argument("--repos", default=None, help="Comma-separated repo names")
|
|
3573
|
+
sp.add_argument("--feature", default=None,
|
|
3574
|
+
help="Pop the most recent stash tagged with this feature")
|
|
3575
|
+
sp.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3576
|
+
|
|
3577
|
+
sl = stash_sub.add_parser("list", help="List stashes")
|
|
3578
|
+
sl.add_argument("--feature", default=None,
|
|
3579
|
+
help="Group/filter by feature tag")
|
|
3580
|
+
sl.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3581
|
+
|
|
3582
|
+
sd = stash_sub.add_parser("drop", help="Drop stash")
|
|
3583
|
+
sd.add_argument("--index", type=int, default=0, help="Stash index")
|
|
3584
|
+
sd.add_argument("--repos", default=None, help="Comma-separated repo names")
|
|
3585
|
+
sd.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3586
|
+
|
|
3587
|
+
# worktree
|
|
3588
|
+
wt_p = subparsers.add_parser(
|
|
3589
|
+
"worktree",
|
|
3590
|
+
help="Create or list worktrees (canopy worktree <name> [issue])",
|
|
3591
|
+
)
|
|
3592
|
+
wt_p.add_argument(
|
|
3593
|
+
"name", nargs="?", default=None,
|
|
3594
|
+
help="Feature name to create. Omit to list existing worktrees.",
|
|
3595
|
+
)
|
|
3596
|
+
wt_p.add_argument(
|
|
3597
|
+
"issue", nargs="?", default=None,
|
|
3598
|
+
help="Linear issue ID (e.g. ENG-123). Fetches via Linear MCP if configured.",
|
|
3599
|
+
)
|
|
3600
|
+
wt_p.add_argument(
|
|
3601
|
+
"--repos", nargs="+",
|
|
3602
|
+
help="Subset of repos (default: all)",
|
|
3603
|
+
)
|
|
3604
|
+
wt_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3605
|
+
|
|
3606
|
+
# code (IDE launcher)
|
|
3607
|
+
code_p = subparsers.add_parser("code", help="Open VS Code for feature or workspace")
|
|
3608
|
+
code_p.add_argument("target", help="Feature name, or '.' for whole workspace")
|
|
3609
|
+
code_p.add_argument("--json", action="store_true", help="Output paths as JSON")
|
|
3610
|
+
|
|
3611
|
+
# cursor (IDE launcher)
|
|
3612
|
+
cursor_p = subparsers.add_parser("cursor", help="Open Cursor for feature or workspace")
|
|
3613
|
+
cursor_p.add_argument("target", help="Feature name, or '.' for whole workspace")
|
|
3614
|
+
cursor_p.add_argument("--json", action="store_true", help="Output paths as JSON")
|
|
3615
|
+
|
|
3616
|
+
# fork (IDE launcher)
|
|
3617
|
+
fork_p = subparsers.add_parser("fork", help="Open Fork.app for feature or workspace")
|
|
3618
|
+
fork_p.add_argument("target", help="Feature name, or '.' for whole workspace")
|
|
3619
|
+
fork_p.add_argument("--json", action="store_true", help="Output paths as JSON")
|
|
3620
|
+
|
|
3621
|
+
# preflight (context-aware add + hooks)
|
|
3622
|
+
preflight_p = subparsers.add_parser("preflight", help="Stage + run hooks (does not commit)")
|
|
3623
|
+
preflight_p.add_argument("feature", nargs="?", default=None,
|
|
3624
|
+
help="Feature alias — when set, runs against the lane's repos and records the result")
|
|
3625
|
+
preflight_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3626
|
+
|
|
3627
|
+
# list (top-level shortcut)
|
|
3628
|
+
list_p = subparsers.add_parser("list", help="List all feature lanes")
|
|
3629
|
+
list_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3630
|
+
|
|
3631
|
+
# review
|
|
3632
|
+
review_p = subparsers.add_parser(
|
|
3633
|
+
"review",
|
|
3634
|
+
help="Fetch PR review comments and prep for commit",
|
|
3635
|
+
)
|
|
3636
|
+
review_p.add_argument("name", help="Feature lane name")
|
|
3637
|
+
review_p.add_argument(
|
|
3638
|
+
"-m", "--message", default="",
|
|
3639
|
+
help="Placeholder commit message (staged but not committed)",
|
|
3640
|
+
)
|
|
3641
|
+
review_p.add_argument(
|
|
3642
|
+
"--comments-only", action="store_true",
|
|
3643
|
+
help="Only fetch comments — skip pre-commit and staging",
|
|
3644
|
+
)
|
|
3645
|
+
review_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3646
|
+
|
|
3647
|
+
# done
|
|
3648
|
+
done_p = subparsers.add_parser(
|
|
3649
|
+
"done",
|
|
3650
|
+
help="Clean up a feature — remove worktrees, branches, archive",
|
|
3651
|
+
)
|
|
3652
|
+
done_p.add_argument("name", help="Feature lane name")
|
|
3653
|
+
done_p.add_argument("--force", action="store_true", help="Remove even with dirty worktrees")
|
|
3654
|
+
done_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3655
|
+
|
|
3656
|
+
# config
|
|
3657
|
+
config_p = subparsers.add_parser(
|
|
3658
|
+
"config",
|
|
3659
|
+
help="Read or write workspace settings (canopy config [key] [value])",
|
|
3660
|
+
)
|
|
3661
|
+
config_p.add_argument("key", nargs="?", default=None, help="Setting name")
|
|
3662
|
+
config_p.add_argument("value", nargs="?", default=None, help="New value")
|
|
3663
|
+
config_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3664
|
+
|
|
3665
|
+
# context (debug)
|
|
3666
|
+
ctx_p = subparsers.add_parser("context", help="Show detected canopy context (debug)")
|
|
3667
|
+
ctx_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3668
|
+
|
|
3669
|
+
# commit (feature-scoped multi-repo commit — Wave 2.3)
|
|
3670
|
+
commit_p = subparsers.add_parser(
|
|
3671
|
+
"commit",
|
|
3672
|
+
help="Commit across every repo in the canonical (or named) feature",
|
|
3673
|
+
)
|
|
3674
|
+
commit_p.add_argument("-m", "--message", required=False, default="",
|
|
3675
|
+
help="Commit message (single message across all repos)")
|
|
3676
|
+
commit_p.add_argument("--feature", default=None,
|
|
3677
|
+
help="Feature alias; defaults to canonical feature")
|
|
3678
|
+
commit_p.add_argument("--repo", "--repos", dest="repos", default=None,
|
|
3679
|
+
help="Comma-separated subset of repos within the feature")
|
|
3680
|
+
commit_p.add_argument("--paths", nargs="*", default=None,
|
|
3681
|
+
help="Filter staging to these paths (relative to each repo root)")
|
|
3682
|
+
commit_p.add_argument("--no-hooks", action="store_true",
|
|
3683
|
+
help="Pass --no-verify to skip pre-commit / commit-msg hooks")
|
|
3684
|
+
commit_p.add_argument("--amend", action="store_true",
|
|
3685
|
+
help="Amend HEAD in each repo instead of creating new commits")
|
|
3686
|
+
commit_p.add_argument("--address", default=None, metavar="COMMENT-ID",
|
|
3687
|
+
help="Address a bot review comment (numeric id or GitHub URL); "
|
|
3688
|
+
"auto-suffixes the message with the comment title + URL "
|
|
3689
|
+
"and records the resolution in .canopy/state/bot_resolutions.json")
|
|
3690
|
+
_resolve_grp = commit_p.add_mutually_exclusive_group()
|
|
3691
|
+
_resolve_grp.add_argument("--resolve-thread", action="store_true", default=None,
|
|
3692
|
+
dest="resolve_thread",
|
|
3693
|
+
help="After --address commit succeeds, resolve the GH review "
|
|
3694
|
+
"thread (overrides augment auto_resolve_threads_on_address)")
|
|
3695
|
+
_resolve_grp.add_argument("--no-resolve-thread", action="store_false",
|
|
3696
|
+
dest="resolve_thread",
|
|
3697
|
+
help="Do NOT resolve the GH review thread even if the augment "
|
|
3698
|
+
"auto_resolve_threads_on_address is true")
|
|
3699
|
+
commit_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3700
|
+
|
|
3701
|
+
# bot-status — per-feature bot-comment rollup (M3)
|
|
3702
|
+
bot_status_p = subparsers.add_parser(
|
|
3703
|
+
"bot-status",
|
|
3704
|
+
help="Show bot review comments for the canonical (or named) feature",
|
|
3705
|
+
)
|
|
3706
|
+
bot_status_p.add_argument("--feature", default=None,
|
|
3707
|
+
help="Feature alias; defaults to canonical feature")
|
|
3708
|
+
bot_status_p.add_argument("--unresolved-only", action="store_true",
|
|
3709
|
+
help="Only list unresolved threads")
|
|
3710
|
+
bot_status_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3711
|
+
|
|
3712
|
+
# historian — cross-session feature memory (M4)
|
|
3713
|
+
historian_p = subparsers.add_parser(
|
|
3714
|
+
"historian",
|
|
3715
|
+
help="Read or compact a feature's persistent memory file (M4)",
|
|
3716
|
+
)
|
|
3717
|
+
historian_sub = historian_p.add_subparsers(dest="subcommand", required=True)
|
|
3718
|
+
|
|
3719
|
+
historian_show = historian_sub.add_parser(
|
|
3720
|
+
"show", help="Print the rendered memory file for the feature",
|
|
3721
|
+
)
|
|
3722
|
+
historian_show.add_argument("feature", nargs="?", default=None,
|
|
3723
|
+
help="Feature alias; defaults to canonical feature")
|
|
3724
|
+
historian_show.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3725
|
+
|
|
3726
|
+
historian_compact = historian_sub.add_parser(
|
|
3727
|
+
"compact",
|
|
3728
|
+
help="Trim the Sessions section to the most recent N entries",
|
|
3729
|
+
)
|
|
3730
|
+
historian_compact.add_argument("feature", nargs="?", default=None,
|
|
3731
|
+
help="Feature alias; defaults to canonical feature")
|
|
3732
|
+
historian_compact.add_argument("--keep-sessions", type=int, default=5,
|
|
3733
|
+
dest="keep_sessions",
|
|
3734
|
+
help="Number of most-recent sessions to keep (default 5)")
|
|
3735
|
+
historian_compact.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3736
|
+
|
|
3737
|
+
# push (feature-scoped multi-repo push — Wave 2.3)
|
|
3738
|
+
push_p = subparsers.add_parser(
|
|
3739
|
+
"push",
|
|
3740
|
+
help="Push the feature branch in every repo (canonical by default)",
|
|
3741
|
+
)
|
|
3742
|
+
push_p.add_argument("--feature", default=None,
|
|
3743
|
+
help="Feature alias; defaults to canonical feature")
|
|
3744
|
+
push_p.add_argument("--repo", "--repos", dest="repos", default=None,
|
|
3745
|
+
help="Comma-separated subset of repos within the feature")
|
|
3746
|
+
push_p.add_argument("--set-upstream", action="store_true",
|
|
3747
|
+
help="Pass --set-upstream for repos that lack an upstream")
|
|
3748
|
+
push_p.add_argument("--force-with-lease", action="store_true",
|
|
3749
|
+
help="Pass --force-with-lease to allow safe non-fast-forward pushes")
|
|
3750
|
+
push_p.add_argument("--dry-run", action="store_true",
|
|
3751
|
+
help="Enumerate what would happen without firing pushes")
|
|
3752
|
+
push_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3753
|
+
|
|
3754
|
+
# switch (canonical-slot focus primitive — Wave 2.9)
|
|
3755
|
+
switch_p = subparsers.add_parser(
|
|
3756
|
+
"switch",
|
|
3757
|
+
help="Promote a feature to the canonical slot (canonical-slot model)",
|
|
3758
|
+
)
|
|
3759
|
+
switch_p.add_argument("feature", nargs="?", default=None,
|
|
3760
|
+
help="Feature alias (name or Linear ID)")
|
|
3761
|
+
switch_p.add_argument("--release-current", action="store_true",
|
|
3762
|
+
help="Wind-down mode: previous canonical goes cold (no warm worktree)")
|
|
3763
|
+
switch_p.add_argument("--no-evict", action="store_true",
|
|
3764
|
+
help="Refuse to auto-evict an LRU warm worktree if cap would fire")
|
|
3765
|
+
switch_p.add_argument("--evict", default=None,
|
|
3766
|
+
help="Explicit feature name to evict to cold (overrides LRU pick)")
|
|
3767
|
+
switch_p.add_argument("--evict-to", default=None,
|
|
3768
|
+
help="Pin which slot the previous canonical evacuates to")
|
|
3769
|
+
switch_p.add_argument("--to-slot", default=None,
|
|
3770
|
+
help="Promote the feature currently in this slot to canonical")
|
|
3771
|
+
switch_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3772
|
+
|
|
3773
|
+
# slots
|
|
3774
|
+
slots_p = subparsers.add_parser(
|
|
3775
|
+
"slots",
|
|
3776
|
+
help="Show slot occupancy: canonical + warm slots + last_touched LRU",
|
|
3777
|
+
)
|
|
3778
|
+
slots_p.add_argument("--json", action="store_true", help="Output as JSON (always rich)")
|
|
3779
|
+
slots_p.add_argument("--rich", action="store_true",
|
|
3780
|
+
help="Include per-slot PR/CI/bots/linear (implied by --json)")
|
|
3781
|
+
|
|
3782
|
+
# slot (sub-command group for slot operations)
|
|
3783
|
+
slot_p = subparsers.add_parser("slot", help="Slot-targeted operations")
|
|
3784
|
+
slot_sub = slot_p.add_subparsers(dest="slot_cmd", required=True)
|
|
3785
|
+
slot_load_p = slot_sub.add_parser("load", help="Warm a cold feature into a slot")
|
|
3786
|
+
slot_load_p.add_argument("feature")
|
|
3787
|
+
slot_load_p.add_argument("slot_id", nargs="?", default=None,
|
|
3788
|
+
help="Target slot id (e.g. worktree-1); defaults to lowest free")
|
|
3789
|
+
slot_load_p.add_argument("--replace", action="store_true",
|
|
3790
|
+
help="Evict slot's current occupant first")
|
|
3791
|
+
slot_load_p.add_argument("--bootstrap", action="store_true",
|
|
3792
|
+
help="Run env/install bootstrap after load")
|
|
3793
|
+
slot_load_p.add_argument("--json", action="store_true")
|
|
3794
|
+
|
|
3795
|
+
slot_clear_p = slot_sub.add_parser("clear", help="Evict a slot's occupant to cold")
|
|
3796
|
+
slot_clear_p.add_argument("slot_id")
|
|
3797
|
+
slot_clear_p.add_argument("--json", action="store_true")
|
|
3798
|
+
|
|
3799
|
+
slot_swap_p = slot_sub.add_parser("swap", help="Exchange occupants of two slots")
|
|
3800
|
+
slot_swap_p.add_argument("slot_a")
|
|
3801
|
+
slot_swap_p.add_argument("slot_b")
|
|
3802
|
+
slot_swap_p.add_argument("--json", action="store_true")
|
|
3803
|
+
|
|
3804
|
+
# migrate-slots
|
|
3805
|
+
migrate_slots_p = subparsers.add_parser(
|
|
3806
|
+
"migrate-slots",
|
|
3807
|
+
help="One-shot migration from pre-3.0 feature-named worktrees to the 3.0 slot model",
|
|
3808
|
+
)
|
|
3809
|
+
migrate_slots_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3810
|
+
|
|
3811
|
+
# setup-agent
|
|
3812
|
+
setup_p = subparsers.add_parser(
|
|
3813
|
+
"setup-agent",
|
|
3814
|
+
help="Install the using-canopy skill + add canopy MCP to .mcp.json",
|
|
3815
|
+
)
|
|
3816
|
+
setup_p.add_argument("--skill-only", action="store_true",
|
|
3817
|
+
help="Install only the skill(s) (skip MCP config)")
|
|
3818
|
+
setup_p.add_argument("--mcp-only", action="store_true",
|
|
3819
|
+
help="Install only the MCP config (skip skills)")
|
|
3820
|
+
setup_p.add_argument("--skill", action="append", default=None, metavar="NAME",
|
|
3821
|
+
help="Install an extra bundled skill by name (e.g. 'augment-canopy'). "
|
|
3822
|
+
"Repeatable. The default 'using-canopy' skill is always installed.")
|
|
3823
|
+
setup_p.add_argument("--reinstall", action="store_true",
|
|
3824
|
+
help="Overwrite existing files even if foreign or current")
|
|
3825
|
+
setup_p.add_argument("--check", action="store_true",
|
|
3826
|
+
help="Report status without changing anything")
|
|
3827
|
+
setup_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3828
|
+
|
|
3829
|
+
# state
|
|
3830
|
+
state_p = subparsers.add_parser(
|
|
3831
|
+
"state",
|
|
3832
|
+
help="Feature state + suggested next actions (dashboard backend)",
|
|
3833
|
+
)
|
|
3834
|
+
state_p.add_argument("feature", nargs="?", default=None,
|
|
3835
|
+
help="Feature alias (name or Linear ID). Defaults to active feature if any.")
|
|
3836
|
+
state_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3837
|
+
|
|
3838
|
+
# triage
|
|
3839
|
+
triage_p = subparsers.add_parser(
|
|
3840
|
+
"triage",
|
|
3841
|
+
help="Prioritized list of features needing attention",
|
|
3842
|
+
)
|
|
3843
|
+
triage_p.add_argument("--author", default="@me",
|
|
3844
|
+
help="Filter PRs to this author (default: @me)")
|
|
3845
|
+
triage_p.add_argument("--repos", default=None,
|
|
3846
|
+
help="Comma-separated subset of repos to scan")
|
|
3847
|
+
triage_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3848
|
+
|
|
3849
|
+
# drift
|
|
3850
|
+
pr_checks_p = subparsers.add_parser(
|
|
3851
|
+
"pr-checks",
|
|
3852
|
+
help="CI check rollup for a PR alias (M10)",
|
|
3853
|
+
)
|
|
3854
|
+
pr_checks_p.add_argument("alias", help="Feature alias, <repo>#<n>, or PR URL")
|
|
3855
|
+
pr_checks_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3856
|
+
|
|
3857
|
+
bootstrap_p = subparsers.add_parser(
|
|
3858
|
+
"worktree-bootstrap",
|
|
3859
|
+
help="Bootstrap a feature's worktrees (env files, deps, IDE workspace) — M6",
|
|
3860
|
+
)
|
|
3861
|
+
bootstrap_p.add_argument("feature", help="Feature alias to bootstrap")
|
|
3862
|
+
bootstrap_p.add_argument("--force", action="store_true",
|
|
3863
|
+
help="Overwrite existing destination env files")
|
|
3864
|
+
bootstrap_p.add_argument("--step", choices=["env", "deps", "ide"], default=None,
|
|
3865
|
+
help="Run only one step instead of all three")
|
|
3866
|
+
bootstrap_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3867
|
+
|
|
3868
|
+
ship_p = subparsers.add_parser(
|
|
3869
|
+
"ship",
|
|
3870
|
+
help="Open or update one PR per repo in the canonical feature (M8 / Wave 2.4)",
|
|
3871
|
+
)
|
|
3872
|
+
ship_p.add_argument("--feature", default=None,
|
|
3873
|
+
help="Feature alias (defaults to canonical)")
|
|
3874
|
+
ship_p.add_argument("--repo", "--repos", dest="repos", default=None,
|
|
3875
|
+
help="Comma-separated repo names to scope the ship")
|
|
3876
|
+
ship_p.add_argument("--draft", action="store_true",
|
|
3877
|
+
help="Open PRs as drafts (initial open only)")
|
|
3878
|
+
ship_p.add_argument("--reviewers", default=None,
|
|
3879
|
+
help="Comma-separated GitHub handles to request review from")
|
|
3880
|
+
ship_p.add_argument("--base", default=None,
|
|
3881
|
+
help="Override base branch (default: each repo's default_branch)")
|
|
3882
|
+
ship_p.add_argument("--dry-run", action="store_true",
|
|
3883
|
+
help="Enumerate without firing pushes or opening PRs")
|
|
3884
|
+
ship_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3885
|
+
|
|
3886
|
+
draft_replies_p = subparsers.add_parser(
|
|
3887
|
+
"draft-replies",
|
|
3888
|
+
help="Auto-draft replies for addressed PR review comments (M9)",
|
|
3889
|
+
)
|
|
3890
|
+
draft_replies_p.add_argument(
|
|
3891
|
+
"alias", help="Feature alias, <repo>#<n>, or PR URL",
|
|
3892
|
+
)
|
|
3893
|
+
draft_replies_p.add_argument(
|
|
3894
|
+
"--include-likely-resolved", action="store_true",
|
|
3895
|
+
help="Also draft for the temporal classifier's likely_resolved set (low confidence)",
|
|
3896
|
+
)
|
|
3897
|
+
draft_replies_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3898
|
+
|
|
3899
|
+
conflicts_p = subparsers.add_parser(
|
|
3900
|
+
"conflicts",
|
|
3901
|
+
help="Cross-feature file-overlap detection (M12)",
|
|
3902
|
+
)
|
|
3903
|
+
conflicts_p.add_argument("--feature", default=None,
|
|
3904
|
+
help="Scope to pairs involving this feature")
|
|
3905
|
+
conflicts_p.add_argument("--with", dest="with_", default=None,
|
|
3906
|
+
help="Further scope to <feature> vs <other>")
|
|
3907
|
+
conflicts_p.add_argument("--include-cold", action="store_true",
|
|
3908
|
+
help="Also scan cold features (default: active only)")
|
|
3909
|
+
conflicts_p.add_argument("--lines", action="store_true",
|
|
3910
|
+
help="Compute line-range overlap (slower; downgrades to medium when files overlap but lines don't)")
|
|
3911
|
+
conflicts_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3912
|
+
|
|
3913
|
+
drift_p = subparsers.add_parser(
|
|
3914
|
+
"drift",
|
|
3915
|
+
help="Show alignment between recorded HEADs and active feature lanes",
|
|
3916
|
+
)
|
|
3917
|
+
drift_p.add_argument("feature", nargs="?", default=None,
|
|
3918
|
+
help="Limit to a specific feature lane")
|
|
3919
|
+
drift_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3920
|
+
|
|
3921
|
+
# issue — fetch one issue from the configured provider (M5+)
|
|
3922
|
+
issue_p = subparsers.add_parser(
|
|
3923
|
+
"issue",
|
|
3924
|
+
help="Fetch an issue from the workspace provider (Linear / GitHub Issues)",
|
|
3925
|
+
)
|
|
3926
|
+
issue_p.add_argument("alias",
|
|
3927
|
+
help="Provider-native id (SIN-412 / 5 / #5 / owner/repo#5 / URL) or feature alias")
|
|
3928
|
+
issue_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3929
|
+
|
|
3930
|
+
# issues — list current user's open issues from the configured provider (F-5)
|
|
3931
|
+
issues_p = subparsers.add_parser(
|
|
3932
|
+
"issues",
|
|
3933
|
+
help="List the current user's open issues from the workspace provider",
|
|
3934
|
+
)
|
|
3935
|
+
issues_p.add_argument("--limit", type=int, default=25,
|
|
3936
|
+
help="Max issues to return (default 25)")
|
|
3937
|
+
issues_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3938
|
+
|
|
3939
|
+
# pr (GitHub)
|
|
3940
|
+
pr_p = subparsers.add_parser(
|
|
3941
|
+
"pr",
|
|
3942
|
+
help="Fetch PR data per repo (alias = feature, <repo>#<n>, or PR URL)",
|
|
3943
|
+
)
|
|
3944
|
+
pr_p.add_argument("alias", help="Feature alias, <repo>#<n>, or PR URL")
|
|
3945
|
+
pr_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3946
|
+
|
|
3947
|
+
# branch info (read tool — sits alongside branch list/delete/rename)
|
|
3948
|
+
|
|
3949
|
+
# comments (PR review comments)
|
|
3950
|
+
comments_p = subparsers.add_parser(
|
|
3951
|
+
"comments",
|
|
3952
|
+
help="Temporally classified PR review comments (alias = feature, <repo>#<n>, URL)",
|
|
3953
|
+
)
|
|
3954
|
+
comments_p.add_argument("alias", help="Feature alias, <repo>#<n>, or PR URL")
|
|
3955
|
+
comments_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3956
|
+
|
|
3957
|
+
# run
|
|
3958
|
+
run_p = subparsers.add_parser(
|
|
3959
|
+
"run",
|
|
3960
|
+
help="Run a shell command in a canopy-managed repo (resolves cwd safely)",
|
|
3961
|
+
)
|
|
3962
|
+
run_p.add_argument("repo", help="Repo name (from canopy.toml)")
|
|
3963
|
+
# NB: positional named "cmd" not "command" — the top-level subparser
|
|
3964
|
+
# dispatch uses dest="command" and would clobber it.
|
|
3965
|
+
run_p.add_argument("cmd", help="Shell command to run")
|
|
3966
|
+
run_p.add_argument("--feature", default=None,
|
|
3967
|
+
help="Feature lane (selects worktree path if applicable)")
|
|
3968
|
+
run_p.add_argument("--timeout", type=int, default=60,
|
|
3969
|
+
help="Kill the process after N seconds (default 60)")
|
|
3970
|
+
run_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3971
|
+
|
|
3972
|
+
# hooks
|
|
3973
|
+
hooks_p = subparsers.add_parser(
|
|
3974
|
+
"hooks",
|
|
3975
|
+
help="Manage drift-tracking post-checkout hooks (install/uninstall/status)",
|
|
3976
|
+
)
|
|
3977
|
+
hooks_sub = hooks_p.add_subparsers(dest="hooks_command")
|
|
3978
|
+
hooks_install_p = hooks_sub.add_parser("install", help="Install hooks in all managed repos")
|
|
3979
|
+
hooks_install_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3980
|
+
hooks_uninstall_p = hooks_sub.add_parser("uninstall", help="Remove canopy hooks; restore chained user hooks")
|
|
3981
|
+
hooks_uninstall_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3982
|
+
hooks_status_p = hooks_sub.add_parser("status", help="Show hook + heads state per repo")
|
|
3983
|
+
hooks_status_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3984
|
+
hooks_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
3985
|
+
|
|
3986
|
+
# doctor
|
|
3987
|
+
doctor_p = subparsers.add_parser(
|
|
3988
|
+
"doctor",
|
|
3989
|
+
help="Diagnose workspace + install integrity; --fix to repair",
|
|
3990
|
+
)
|
|
3991
|
+
doctor_p.add_argument(
|
|
3992
|
+
"--fix", action="store_true",
|
|
3993
|
+
help="Repair every auto-fixable issue",
|
|
3994
|
+
)
|
|
3995
|
+
doctor_p.add_argument(
|
|
3996
|
+
"--fix-category",
|
|
3997
|
+
choices=sorted(["heads", "active_feature", "worktrees", "hooks",
|
|
3998
|
+
"preflight", "features", "branches",
|
|
3999
|
+
"cli", "mcp", "skill", "vsix"]),
|
|
4000
|
+
default=None,
|
|
4001
|
+
help="Repair only one category (implies --fix)",
|
|
4002
|
+
)
|
|
4003
|
+
doctor_p.add_argument(
|
|
4004
|
+
"--feature", default=None,
|
|
4005
|
+
help="Scope feature-bearing checks to one feature",
|
|
4006
|
+
)
|
|
4007
|
+
doctor_p.add_argument(
|
|
4008
|
+
"--clean-vsix", action="store_true",
|
|
4009
|
+
help="Remove duplicate vsix install dirs (gates the vsix repair)",
|
|
4010
|
+
)
|
|
4011
|
+
doctor_p.add_argument(
|
|
4012
|
+
"-v", "--verbose", action="store_true",
|
|
4013
|
+
help="Show expected/actual values per issue",
|
|
4014
|
+
)
|
|
4015
|
+
doctor_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
4016
|
+
|
|
4017
|
+
reply_p = subparsers.add_parser(
|
|
4018
|
+
"reply",
|
|
4019
|
+
help="Post a reply to a GH review thread",
|
|
4020
|
+
)
|
|
4021
|
+
reply_p.add_argument("thread_id", help="Thread node ID (starts with PRRT_)")
|
|
4022
|
+
_reply_body_g = reply_p.add_mutually_exclusive_group()
|
|
4023
|
+
_reply_body_g.add_argument("--body", default=None,
|
|
4024
|
+
help="Reply body text")
|
|
4025
|
+
_reply_body_g.add_argument("--body-file", default=None,
|
|
4026
|
+
help="Path to file containing the reply body")
|
|
4027
|
+
reply_p.add_argument("--resolve", action="store_true",
|
|
4028
|
+
help="Resolve the thread after posting")
|
|
4029
|
+
reply_p.add_argument("--feature", default=None,
|
|
4030
|
+
help="Feature alias; defaults to canonical feature")
|
|
4031
|
+
reply_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
4032
|
+
|
|
4033
|
+
resolve_p = subparsers.add_parser(
|
|
4034
|
+
"resolve",
|
|
4035
|
+
help="Resolve a GitHub PR review thread and record it locally",
|
|
4036
|
+
)
|
|
4037
|
+
resolve_p.add_argument("thread_id", help="Thread node ID (starts with PRRT_)")
|
|
4038
|
+
resolve_p.add_argument("--feature", default=None,
|
|
4039
|
+
help="Feature alias; defaults to canonical feature")
|
|
4040
|
+
resolve_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
4041
|
+
|
|
4042
|
+
# resume
|
|
4043
|
+
resume_p = subparsers.add_parser(
|
|
4044
|
+
"resume",
|
|
4045
|
+
help="Fresh brief: what changed since last visit",
|
|
4046
|
+
)
|
|
4047
|
+
resume_p.add_argument("alias", help="Feature alias (name, Linear ID, slot id, etc.)")
|
|
4048
|
+
resume_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
4049
|
+
resume_p.add_argument(
|
|
4050
|
+
"--reset-anchor", action="store_true",
|
|
4051
|
+
help="Clear last_visit so the next call is treated as a first visit",
|
|
4052
|
+
)
|
|
4053
|
+
|
|
4054
|
+
args = parser.parse_args()
|
|
4055
|
+
|
|
4056
|
+
if not args.command:
|
|
4057
|
+
parser.print_help()
|
|
4058
|
+
sys.exit(0)
|
|
4059
|
+
|
|
4060
|
+
commands = {
|
|
4061
|
+
"init": cmd_init,
|
|
4062
|
+
"status": cmd_status,
|
|
4063
|
+
"sync": cmd_sync,
|
|
4064
|
+
"checkout": cmd_checkout,
|
|
4065
|
+
"log": cmd_log,
|
|
4066
|
+
"worktree": cmd_worktree,
|
|
4067
|
+
"code": cmd_code,
|
|
4068
|
+
"cursor": cmd_cursor,
|
|
4069
|
+
"fork": cmd_fork,
|
|
4070
|
+
"preflight": cmd_preflight,
|
|
4071
|
+
"list": cmd_list,
|
|
4072
|
+
"review": cmd_review,
|
|
4073
|
+
"done": cmd_done,
|
|
4074
|
+
"config": cmd_config,
|
|
4075
|
+
"context": cmd_context,
|
|
4076
|
+
"hooks": cmd_hooks,
|
|
4077
|
+
"drift": cmd_drift,
|
|
4078
|
+
"run": cmd_run,
|
|
4079
|
+
"issue": cmd_issue,
|
|
4080
|
+
"issues": cmd_issues,
|
|
4081
|
+
"pr": cmd_pr,
|
|
4082
|
+
"comments": cmd_comments,
|
|
4083
|
+
"switch": cmd_switch,
|
|
4084
|
+
"slots": cmd_slots,
|
|
4085
|
+
"migrate-slots": cmd_migrate_slots,
|
|
4086
|
+
"commit": cmd_commit,
|
|
4087
|
+
"bot-status": cmd_bot_status,
|
|
4088
|
+
"historian": cmd_historian,
|
|
4089
|
+
"push": cmd_push,
|
|
4090
|
+
"triage": cmd_triage,
|
|
4091
|
+
"state": cmd_state,
|
|
4092
|
+
"setup-agent": cmd_setup_agent,
|
|
4093
|
+
"doctor": cmd_doctor,
|
|
4094
|
+
"conflicts": cmd_conflicts,
|
|
4095
|
+
"draft-replies": cmd_draft_replies,
|
|
4096
|
+
"ship": cmd_ship,
|
|
4097
|
+
"worktree-bootstrap": cmd_worktree_bootstrap,
|
|
4098
|
+
"pr-checks": cmd_pr_checks,
|
|
4099
|
+
"resolve": cmd_resolve_thread,
|
|
4100
|
+
"reply": cmd_reply_thread,
|
|
4101
|
+
"resume": cmd_resume,
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
if args.command == "feature":
|
|
4105
|
+
if not args.feature_command:
|
|
4106
|
+
feature_p.print_help()
|
|
4107
|
+
sys.exit(0)
|
|
4108
|
+
feature_commands = {
|
|
4109
|
+
"create": cmd_feature_create,
|
|
4110
|
+
"list": cmd_feature_list,
|
|
4111
|
+
"diff": cmd_feature_diff,
|
|
4112
|
+
"status": cmd_feature_status,
|
|
4113
|
+
"changes": cmd_feature_changes,
|
|
4114
|
+
}
|
|
4115
|
+
feature_commands[args.feature_command](args)
|
|
4116
|
+
elif args.command == "branch":
|
|
4117
|
+
if not args.branch_command:
|
|
4118
|
+
branch_p.print_help()
|
|
4119
|
+
sys.exit(0)
|
|
4120
|
+
branch_commands = {
|
|
4121
|
+
"list": cmd_branch_list,
|
|
4122
|
+
"delete": cmd_branch_delete,
|
|
4123
|
+
"rename": cmd_branch_rename,
|
|
4124
|
+
"info": cmd_branch,
|
|
4125
|
+
}
|
|
4126
|
+
branch_commands[args.branch_command](args)
|
|
4127
|
+
elif args.command == "stash":
|
|
4128
|
+
if not args.stash_command:
|
|
4129
|
+
stash_p.print_help()
|
|
4130
|
+
sys.exit(0)
|
|
4131
|
+
stash_commands = {
|
|
4132
|
+
"save": cmd_stash_save,
|
|
4133
|
+
"pop": cmd_stash_pop,
|
|
4134
|
+
"list": cmd_stash_list,
|
|
4135
|
+
"drop": cmd_stash_drop,
|
|
4136
|
+
}
|
|
4137
|
+
stash_commands[args.stash_command](args)
|
|
4138
|
+
elif args.command == "slot":
|
|
4139
|
+
slot_commands = {
|
|
4140
|
+
"load": cmd_slot_load,
|
|
4141
|
+
"clear": cmd_slot_clear,
|
|
4142
|
+
"swap": cmd_slot_swap,
|
|
4143
|
+
}
|
|
4144
|
+
slot_commands[args.slot_cmd](args)
|
|
4145
|
+
elif args.command in commands:
|
|
4146
|
+
commands[args.command](args)
|
|
4147
|
+
else:
|
|
4148
|
+
parser.print_help()
|
|
4149
|
+
|
|
4150
|
+
|
|
4151
|
+
if __name__ == "__main__":
|
|
4152
|
+
main()
|