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/actions/switch.py
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
"""switch — the canonical-slot focus primitive.
|
|
2
|
+
|
|
3
|
+
`switch(Y)` promotes Y to the canonical slot (main checkout). Whatever was
|
|
4
|
+
canonical before either:
|
|
5
|
+
|
|
6
|
+
- **Active rotation (default)**: evacuates to a warm worktree at
|
|
7
|
+
``.canopy/worktrees/<previous>/<repo>/`` so it stays close at hand.
|
|
8
|
+
- **Wind-down (``release_current=True``)**: goes cold (just the branch +
|
|
9
|
+
a feature-tagged stash if there were dirty changes). Use when the
|
|
10
|
+
previous focus is parked / finished and Y is the new focus.
|
|
11
|
+
|
|
12
|
+
Per-repo recipe per mode is in ``evacuate.py`` (active-rotation) and
|
|
13
|
+
inline below (wind-down). Cap-reached failures surface via
|
|
14
|
+
``switch_preflight.py`` as a structured ``BlockerError`` with explicit
|
|
15
|
+
fix actions — no silent eviction.
|
|
16
|
+
|
|
17
|
+
PR1 scope: the canonical-slot behavior end-to-end with preflight as the
|
|
18
|
+
primary safety net. PR2 adds journal + rollback walker for the residual
|
|
19
|
+
mid-op failures. PR3 adds the fast-path 3-checkout swap when both X and
|
|
20
|
+
Y already have homes.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..git import repo as git
|
|
29
|
+
from ..workspace.workspace import Workspace
|
|
30
|
+
from . import evacuate as evac
|
|
31
|
+
from . import slots as slots_mod
|
|
32
|
+
from . import switch_preflight as preflight
|
|
33
|
+
from .aliases import resolve_feature, repos_for_feature
|
|
34
|
+
from .errors import BlockerError, FixAction
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def switch(
|
|
38
|
+
workspace: Workspace,
|
|
39
|
+
feature: str | None = None,
|
|
40
|
+
*,
|
|
41
|
+
release_current: bool = False,
|
|
42
|
+
no_evict: bool = False,
|
|
43
|
+
evict: str | None = None,
|
|
44
|
+
evict_to: str | None = None,
|
|
45
|
+
to_slot: str | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Promote ``feature`` to the canonical slot.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
feature: feature alias (resolved via the alias layer). Accepts a
|
|
51
|
+
fresh name too — branches are created from default if missing.
|
|
52
|
+
release_current: wind-down mode. Previously-canonical feature goes
|
|
53
|
+
cold (just stashed if dirty), no warm worktree created.
|
|
54
|
+
no_evict: in active-rotation mode, refuse to evict an LRU warm
|
|
55
|
+
worktree when the cap would fire. Returns a cap-reached
|
|
56
|
+
BlockerError instead. Default False (canopy auto-picks LRU).
|
|
57
|
+
evict: explicit feature name to evict from warm to cold instead of
|
|
58
|
+
the LRU pick. Used when the user wants control after a
|
|
59
|
+
cap-reached blocker surfaced an LRU candidate.
|
|
60
|
+
evict_to: pin which slot the previously-canonical feature evacuates
|
|
61
|
+
to (overrides the LRU-coldest-free pick).
|
|
62
|
+
to_slot: promote the feature currently in this slot to canonical.
|
|
63
|
+
Sugar over ``switch(<feature-in-slot>)``.
|
|
64
|
+
|
|
65
|
+
Returns ``{feature, mode, per_repo_paths, previously_canonical?,
|
|
66
|
+
evacuation?, eviction?, branches_created?, migration?}``.
|
|
67
|
+
"""
|
|
68
|
+
# to_slot: resolve the occupant and forward
|
|
69
|
+
if to_slot is not None:
|
|
70
|
+
if feature is not None:
|
|
71
|
+
raise BlockerError(
|
|
72
|
+
code="ambiguous_switch_args",
|
|
73
|
+
what="pass `feature` OR `--to-slot`, not both",
|
|
74
|
+
)
|
|
75
|
+
occupant = slots_mod.feature_for_slot(workspace, to_slot)
|
|
76
|
+
if occupant is None:
|
|
77
|
+
raise BlockerError(
|
|
78
|
+
code="slot_empty",
|
|
79
|
+
what=f"slot '{to_slot}' is empty — nothing to promote",
|
|
80
|
+
details={"slot": to_slot},
|
|
81
|
+
)
|
|
82
|
+
feature = occupant
|
|
83
|
+
|
|
84
|
+
if feature is None:
|
|
85
|
+
raise BlockerError(
|
|
86
|
+
code="missing_feature",
|
|
87
|
+
what="switch needs a feature name or --to-slot",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
_ensure_post_migration(workspace)
|
|
91
|
+
_ensure_consistent(workspace)
|
|
92
|
+
feature_name = resolve_feature_safely(workspace, feature)
|
|
93
|
+
|
|
94
|
+
repo_branches = repos_for_feature(workspace, feature_name)
|
|
95
|
+
if not repo_branches:
|
|
96
|
+
# Permit fresh feature names (will create branches from default)
|
|
97
|
+
repo_branches = {r.config.name: feature_name for r in workspace.repos}
|
|
98
|
+
|
|
99
|
+
pre = preflight.preflight(
|
|
100
|
+
workspace, feature_name, repo_branches,
|
|
101
|
+
release_current=release_current,
|
|
102
|
+
no_evict=no_evict and (evict is None),
|
|
103
|
+
evict_to=evict_to,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
out: dict[str, Any] = {"feature": feature_name}
|
|
107
|
+
previously_canonical = pre["previously_canonical"]
|
|
108
|
+
if previously_canonical:
|
|
109
|
+
out["previously_canonical"] = previously_canonical
|
|
110
|
+
|
|
111
|
+
# Step A: optional eviction (active-rotation cap fire) —
|
|
112
|
+
# explicit ``evict=<feature>`` overrides preflight's LRU pick.
|
|
113
|
+
# When ``evict_to`` is pinned to an occupied slot, evict that slot's
|
|
114
|
+
# occupant first so X can land there.
|
|
115
|
+
eviction_info: dict[str, Any] | None = None
|
|
116
|
+
eviction_target: str | None = None
|
|
117
|
+
if not release_current:
|
|
118
|
+
if evict:
|
|
119
|
+
eviction_target = evict
|
|
120
|
+
elif evict_to is not None:
|
|
121
|
+
# Pinned destination — if it holds an occupant other than Y, evict it.
|
|
122
|
+
cur_state = slots_mod.read_state(workspace)
|
|
123
|
+
if cur_state is not None and evict_to in cur_state.slots:
|
|
124
|
+
occ = cur_state.slots[evict_to].feature
|
|
125
|
+
if occ and occ != feature_name:
|
|
126
|
+
eviction_target = occ
|
|
127
|
+
elif pre["cap_will_fire"] and pre["lru_eviction_candidate"]:
|
|
128
|
+
eviction_target = pre["lru_eviction_candidate"]
|
|
129
|
+
if eviction_target:
|
|
130
|
+
eviction_info = _evict_warm_to_cold(workspace, eviction_target)
|
|
131
|
+
out["eviction"] = eviction_info
|
|
132
|
+
|
|
133
|
+
# Step B: branches that need creating from default
|
|
134
|
+
if pre["branches_to_create"]:
|
|
135
|
+
out["branches_created"] = _create_missing_branches(
|
|
136
|
+
workspace, pre["branches_to_create"],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Step C: per-repo per-mode work
|
|
140
|
+
per_repo_results: list[dict[str, Any]] = []
|
|
141
|
+
new_canonical_paths: dict[str, str] = {}
|
|
142
|
+
|
|
143
|
+
for repo_name, target_branch in repo_branches.items():
|
|
144
|
+
try:
|
|
145
|
+
state = workspace.get_repo(repo_name)
|
|
146
|
+
except KeyError:
|
|
147
|
+
continue
|
|
148
|
+
repo_path = state.abs_path
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
_do_repo_switch(
|
|
152
|
+
workspace, feature_name, repo_name, target_branch,
|
|
153
|
+
repo_path=repo_path,
|
|
154
|
+
release_current=release_current,
|
|
155
|
+
previously_canonical=previously_canonical,
|
|
156
|
+
per_repo_results=per_repo_results,
|
|
157
|
+
new_canonical_paths=new_canonical_paths,
|
|
158
|
+
evict_to=evict_to,
|
|
159
|
+
)
|
|
160
|
+
except BlockerError as e:
|
|
161
|
+
# Even a structured precondition failure (e.g. dirty warm
|
|
162
|
+
# worktree on the second repo) can leave disk partially
|
|
163
|
+
# mutated by earlier repos. Persist an in_flight marker so
|
|
164
|
+
# the next switch refuses to operate on a lie.
|
|
165
|
+
_persist_in_flight(
|
|
166
|
+
workspace, feature_name, previously_canonical,
|
|
167
|
+
failed_repo=repo_name, error_what=e.what or str(e),
|
|
168
|
+
completed_results=per_repo_results,
|
|
169
|
+
)
|
|
170
|
+
raise
|
|
171
|
+
except Exception as e:
|
|
172
|
+
# Mid-op failure with no rollback walker (yet). Surface enough
|
|
173
|
+
# state for the user to recover manually instead of leaving
|
|
174
|
+
# them with a generic exception. See GitHub issue #2.
|
|
175
|
+
_persist_in_flight(
|
|
176
|
+
workspace, feature_name, previously_canonical,
|
|
177
|
+
failed_repo=repo_name, error_what=str(e),
|
|
178
|
+
completed_results=per_repo_results,
|
|
179
|
+
)
|
|
180
|
+
raise _build_mid_op_error(
|
|
181
|
+
workspace, feature_name, repo_name, target_branch,
|
|
182
|
+
previously_canonical, e, per_repo_results,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
_post_switch_persist(
|
|
186
|
+
workspace, feature_name, new_canonical_paths, previously_canonical,
|
|
187
|
+
out, release_current=release_current, per_repo_results=per_repo_results,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# M4: include the new feature's persistent memory so the agent picks
|
|
191
|
+
# up cross-session context immediately. Empty string when no memory
|
|
192
|
+
# has been recorded yet — caller can ignore.
|
|
193
|
+
from . import historian
|
|
194
|
+
out["memory"] = historian.format_for_agent(
|
|
195
|
+
workspace.config.root, feature_name,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _do_repo_switch(
|
|
202
|
+
workspace: Workspace,
|
|
203
|
+
feature_name: str,
|
|
204
|
+
repo_name: str,
|
|
205
|
+
target_branch: str,
|
|
206
|
+
*,
|
|
207
|
+
repo_path: Path,
|
|
208
|
+
release_current: bool,
|
|
209
|
+
previously_canonical: str | None,
|
|
210
|
+
per_repo_results: list[dict[str, Any]],
|
|
211
|
+
new_canonical_paths: dict[str, str],
|
|
212
|
+
evict_to: str | None = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Per-repo switch body — extracted so the caller can wrap it in a
|
|
215
|
+
structured mid-op error handler. Mutates the lists/dicts in place."""
|
|
216
|
+
|
|
217
|
+
# If main is already on the target branch, nothing to do for this
|
|
218
|
+
# repo aside from recording its path.
|
|
219
|
+
try:
|
|
220
|
+
current = git.current_branch(repo_path)
|
|
221
|
+
except git.GitError:
|
|
222
|
+
current = None
|
|
223
|
+
new_canonical_paths[repo_name] = str(repo_path.resolve())
|
|
224
|
+
if current == target_branch:
|
|
225
|
+
per_repo_results.append({
|
|
226
|
+
"repo": repo_name, "status": "noop",
|
|
227
|
+
"reason": "already on target branch",
|
|
228
|
+
})
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
# Mode A: wind-down — stash X dirty into a feature-tagged stash on
|
|
232
|
+
# X's branch, then plain checkout Y in main. No worktree-add for X.
|
|
233
|
+
if release_current and previously_canonical and current == _branch_for_in_repo(
|
|
234
|
+
workspace, previously_canonical, repo_name,
|
|
235
|
+
):
|
|
236
|
+
# If Y is warm in a slot, must free it from the slot before main
|
|
237
|
+
# can adopt it (git one-checkout-per-branch rule).
|
|
238
|
+
_free_warm_slot_if_holding(workspace, feature_name, repo_name)
|
|
239
|
+
stash_ref = _stash_for_winddown(
|
|
240
|
+
workspace, previously_canonical, repo_path,
|
|
241
|
+
)
|
|
242
|
+
git.checkout(repo_path, target_branch)
|
|
243
|
+
per_repo_results.append({
|
|
244
|
+
"repo": repo_name, "status": "wind_down_then_checkout",
|
|
245
|
+
"previous_branch": _branch_for_in_repo(
|
|
246
|
+
workspace, previously_canonical, repo_name,
|
|
247
|
+
),
|
|
248
|
+
"target_branch": target_branch,
|
|
249
|
+
"stashed": stash_ref is not None,
|
|
250
|
+
"stash_ref": stash_ref,
|
|
251
|
+
})
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
# Mode B: active rotation
|
|
255
|
+
if (
|
|
256
|
+
previously_canonical
|
|
257
|
+
and not release_current
|
|
258
|
+
and current == _branch_for_in_repo(
|
|
259
|
+
workspace, previously_canonical, repo_name,
|
|
260
|
+
)
|
|
261
|
+
):
|
|
262
|
+
# Fast-path: Y is already warm in some slot → 5-op swap
|
|
263
|
+
y_slot = slots_mod.slot_for_feature(workspace, feature_name)
|
|
264
|
+
if y_slot is not None:
|
|
265
|
+
slot_dir = slots_mod.slot_worktree_path(
|
|
266
|
+
workspace, y_slot, repo_name,
|
|
267
|
+
)
|
|
268
|
+
if (slot_dir / ".git").exists():
|
|
269
|
+
default_branch = workspace.get_repo(
|
|
270
|
+
repo_name,
|
|
271
|
+
).config.default_branch
|
|
272
|
+
result = evac.fastpath_swap_repo(
|
|
273
|
+
workspace,
|
|
274
|
+
x_feature=previously_canonical,
|
|
275
|
+
y_feature=target_branch,
|
|
276
|
+
repo_name=repo_name,
|
|
277
|
+
repo_path=repo_path,
|
|
278
|
+
slot_id=y_slot,
|
|
279
|
+
default_branch=default_branch,
|
|
280
|
+
)
|
|
281
|
+
per_repo_results.append(result)
|
|
282
|
+
return
|
|
283
|
+
# Fall through: Y's slot entry exists but this repo's slot
|
|
284
|
+
# dir is missing (partial-scope drift). Treat as cold-Y.
|
|
285
|
+
|
|
286
|
+
# Cold-Y path: allocate a fresh slot for X
|
|
287
|
+
state = slots_mod.read_state(workspace) or slots_mod.SlotState(
|
|
288
|
+
slot_count=workspace.config.slots,
|
|
289
|
+
)
|
|
290
|
+
if evict_to is not None:
|
|
291
|
+
# Validate the slot id is in range
|
|
292
|
+
valid_slots = {f"worktree-{i}" for i in range(1, workspace.config.slots + 1)}
|
|
293
|
+
if evict_to not in valid_slots:
|
|
294
|
+
raise BlockerError(
|
|
295
|
+
code="unknown_slot",
|
|
296
|
+
what=f"--evict-to {evict_to} is out of range (cap={workspace.config.slots})",
|
|
297
|
+
)
|
|
298
|
+
if evict_to in state.slots:
|
|
299
|
+
existing = state.slots[evict_to].feature
|
|
300
|
+
if existing != feature_name:
|
|
301
|
+
raise BlockerError(
|
|
302
|
+
code="evict_to_occupied",
|
|
303
|
+
what=f"slot '{evict_to}' is already occupied by '{existing}'",
|
|
304
|
+
details={"slot": evict_to, "occupant": existing},
|
|
305
|
+
)
|
|
306
|
+
x_slot = evict_to
|
|
307
|
+
else:
|
|
308
|
+
x_slot = slots_mod.allocate_slot(state)
|
|
309
|
+
if x_slot is None:
|
|
310
|
+
# Preflight should have caught this; defensive
|
|
311
|
+
raise BlockerError(
|
|
312
|
+
code="no_free_slot",
|
|
313
|
+
what="no free slot for evacuation (preflight should have raised)",
|
|
314
|
+
)
|
|
315
|
+
result = evac.evacuate_repo(
|
|
316
|
+
workspace, previously_canonical, repo_name, repo_path,
|
|
317
|
+
slot_id=x_slot,
|
|
318
|
+
target_branch=target_branch,
|
|
319
|
+
)
|
|
320
|
+
per_repo_results.append(result)
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
# Fallback: main is on something else (or not on previous_canonical).
|
|
324
|
+
# Just stash + checkout. If Y happens to be warm somewhere, free it
|
|
325
|
+
# first.
|
|
326
|
+
_free_warm_slot_if_holding(workspace, feature_name, repo_name)
|
|
327
|
+
if git.is_dirty(repo_path):
|
|
328
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
329
|
+
current_label = current or "(detached)"
|
|
330
|
+
git.stash_save(
|
|
331
|
+
repo_path,
|
|
332
|
+
f"[canopy {current_label} @ {ts}] auto-stash on switch",
|
|
333
|
+
include_untracked=True,
|
|
334
|
+
)
|
|
335
|
+
stashed = True
|
|
336
|
+
else:
|
|
337
|
+
stashed = False
|
|
338
|
+
git.checkout(repo_path, target_branch)
|
|
339
|
+
per_repo_results.append({
|
|
340
|
+
"repo": repo_name, "status": "checkout",
|
|
341
|
+
"previous_branch": current,
|
|
342
|
+
"target_branch": target_branch,
|
|
343
|
+
"stashed": stashed,
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _build_mid_op_error(
|
|
348
|
+
workspace: Workspace,
|
|
349
|
+
feature_name: str,
|
|
350
|
+
failed_repo: str,
|
|
351
|
+
target_branch: str,
|
|
352
|
+
previously_canonical: str | None,
|
|
353
|
+
underlying_error: Exception,
|
|
354
|
+
completed_results: list[dict[str, Any]],
|
|
355
|
+
) -> BlockerError:
|
|
356
|
+
"""Build a structured ``BlockerError`` for a mid-op failure.
|
|
357
|
+
|
|
358
|
+
Goal: tell the user exactly which repo failed at which step, what
|
|
359
|
+
state the workspace is in NOW, and the precise commands to recover.
|
|
360
|
+
Without this they get a generic git error and a half-flipped workspace.
|
|
361
|
+
|
|
362
|
+
A real rollback walker is in GitHub issue #2; this is the interim.
|
|
363
|
+
"""
|
|
364
|
+
completed_repos = [r["repo"] for r in completed_results]
|
|
365
|
+
# Per-repo recovery hints for completed repos
|
|
366
|
+
recovery_hints: list[str] = []
|
|
367
|
+
for r in completed_results:
|
|
368
|
+
if r.get("stashed"):
|
|
369
|
+
recovery_hints.append(
|
|
370
|
+
f" {r['repo']}: stash exists ({r.get('stash_ref','stash@{0}')}) — "
|
|
371
|
+
f"`git -C <{r['repo']}-path> stash list` to inspect"
|
|
372
|
+
)
|
|
373
|
+
if r.get("status") == "evacuated" and r.get("worktree_path"):
|
|
374
|
+
recovery_hints.append(
|
|
375
|
+
f" {r['repo']}: warm worktree at {r['worktree_path']} (X={previously_canonical})"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return BlockerError(
|
|
379
|
+
code="switch_mid_op_failed",
|
|
380
|
+
what=(
|
|
381
|
+
f"switch to '{feature_name}' failed in repo '{failed_repo}' — "
|
|
382
|
+
f"workspace is partially flipped"
|
|
383
|
+
),
|
|
384
|
+
expected={"feature": feature_name, "target_branch": target_branch},
|
|
385
|
+
actual={
|
|
386
|
+
"failed_repo": failed_repo,
|
|
387
|
+
"completed_repos": completed_repos,
|
|
388
|
+
"underlying_error": str(underlying_error),
|
|
389
|
+
"underlying_error_type": type(underlying_error).__name__,
|
|
390
|
+
},
|
|
391
|
+
details={
|
|
392
|
+
"previously_canonical": previously_canonical,
|
|
393
|
+
"completed_results": completed_results,
|
|
394
|
+
"recovery_hints": recovery_hints,
|
|
395
|
+
},
|
|
396
|
+
fix_actions=[
|
|
397
|
+
FixAction(
|
|
398
|
+
action="manual",
|
|
399
|
+
args={"see": "details.recovery_hints"},
|
|
400
|
+
safe=False,
|
|
401
|
+
preview=(
|
|
402
|
+
"auto-rollback isn't implemented yet (GH #2). "
|
|
403
|
+
"Inspect per-repo state via `canopy state` + `git stash list` "
|
|
404
|
+
"in each repo, then re-run `canopy switch <feature>` once "
|
|
405
|
+
f"the underlying error ({type(underlying_error).__name__}) is resolved."
|
|
406
|
+
),
|
|
407
|
+
),
|
|
408
|
+
FixAction(
|
|
409
|
+
action="switch",
|
|
410
|
+
args={"feature": previously_canonical} if previously_canonical else {"feature": feature_name},
|
|
411
|
+
safe=False,
|
|
412
|
+
preview=(
|
|
413
|
+
f"switch back to '{previously_canonical}' may un-flip"
|
|
414
|
+
f" some repos (depends on which step failed)"
|
|
415
|
+
if previously_canonical else "retry the switch"
|
|
416
|
+
),
|
|
417
|
+
),
|
|
418
|
+
],
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _post_switch_persist(
|
|
423
|
+
workspace: Workspace,
|
|
424
|
+
feature_name: str,
|
|
425
|
+
new_canonical_paths: dict[str, str],
|
|
426
|
+
previously_canonical: str | None,
|
|
427
|
+
out: dict[str, Any],
|
|
428
|
+
*,
|
|
429
|
+
release_current: bool,
|
|
430
|
+
per_repo_results: list[dict[str, Any]],
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Finalize the switch result: write ``slots.json`` + populate summary
|
|
433
|
+
fields. Mutates ``out`` in place."""
|
|
434
|
+
out["mode"] = "wind_down" if release_current else "active_rotation"
|
|
435
|
+
out["per_repo"] = per_repo_results
|
|
436
|
+
out["per_repo_paths"] = new_canonical_paths
|
|
437
|
+
|
|
438
|
+
state = slots_mod.read_state(workspace) or slots_mod.SlotState(
|
|
439
|
+
slot_count=workspace.config.slots,
|
|
440
|
+
)
|
|
441
|
+
now = slots_mod.now_iso()
|
|
442
|
+
|
|
443
|
+
state.previous_canonical = (
|
|
444
|
+
state.canonical.feature if state.canonical else None
|
|
445
|
+
)
|
|
446
|
+
state.canonical = slots_mod.CanonicalEntry(
|
|
447
|
+
feature=feature_name,
|
|
448
|
+
activated_at=now,
|
|
449
|
+
per_repo_paths={k: str(v) for k, v in new_canonical_paths.items()},
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# Apply per-repo slot mutations. fastpath swaps update the existing
|
|
453
|
+
# slot entry; cold-Y evacuations occupy a freshly allocated slot.
|
|
454
|
+
for r in per_repo_results:
|
|
455
|
+
if r.get("status") == "fastpath_swapped":
|
|
456
|
+
sid = r["slot_id"]
|
|
457
|
+
state.slots[sid] = slots_mod.SlotEntry(
|
|
458
|
+
feature=r["swapped_out"], occupied_at=now,
|
|
459
|
+
)
|
|
460
|
+
elif r.get("status") == "evacuated":
|
|
461
|
+
sid = r["slot_id"]
|
|
462
|
+
state.slots[sid] = slots_mod.SlotEntry(
|
|
463
|
+
feature=previously_canonical or "",
|
|
464
|
+
occupied_at=now,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
state.last_touched[feature_name] = now
|
|
468
|
+
if previously_canonical:
|
|
469
|
+
state.last_touched[previously_canonical] = now
|
|
470
|
+
|
|
471
|
+
# Drop any slot entries that still claim Y — Y is now canonical and
|
|
472
|
+
# its slot dir (if it had one) was emptied by fastpath_swap_repo.
|
|
473
|
+
for sid, entry in list(state.slots.items()):
|
|
474
|
+
if entry.feature == feature_name:
|
|
475
|
+
del state.slots[sid]
|
|
476
|
+
|
|
477
|
+
# Clear any in_flight marker — this switch completed cleanly.
|
|
478
|
+
state.in_flight = None
|
|
479
|
+
|
|
480
|
+
# T14: capture prior anchor BEFORE the T13 bump so the summary's
|
|
481
|
+
# "since when" window reflects what the user last saw, not this visit.
|
|
482
|
+
from . import last_visit as lv
|
|
483
|
+
_prior = lv.get_last_visit(workspace, feature_name)
|
|
484
|
+
prior_iso: str | None = _prior["last_visit"] if _prior else None
|
|
485
|
+
|
|
486
|
+
slots_mod.write_state(workspace, state)
|
|
487
|
+
|
|
488
|
+
# T13: bump last_visit after slots.json is committed — every successful
|
|
489
|
+
# switch into a feature counts as a "conscious look" per the plan.
|
|
490
|
+
lv.mark_visited(workspace, feature_name)
|
|
491
|
+
|
|
492
|
+
# T14: embed since-last-visit summary using the PRIOR anchor.
|
|
493
|
+
try:
|
|
494
|
+
from . import resume
|
|
495
|
+
out["since_last_visit_summary"] = resume.resume_summary(
|
|
496
|
+
workspace, feature_name, prior_iso=prior_iso,
|
|
497
|
+
)
|
|
498
|
+
except Exception:
|
|
499
|
+
out["since_last_visit_summary"] = {
|
|
500
|
+
"last_visit": prior_iso,
|
|
501
|
+
"first_visit": prior_iso is None,
|
|
502
|
+
"new_commit_count": 0,
|
|
503
|
+
"new_thread_count": 0,
|
|
504
|
+
"github_resolved_count": 0,
|
|
505
|
+
"ci_changed": False,
|
|
506
|
+
"draft_replies_pending": 0,
|
|
507
|
+
"memory_present": False,
|
|
508
|
+
"degraded": True,
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
out["activated_at"] = now
|
|
512
|
+
if state.previous_canonical:
|
|
513
|
+
out["previous_feature_in_state"] = state.previous_canonical
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def resolve_feature_safely(workspace: Workspace, feature: str) -> str:
|
|
517
|
+
"""Like ``resolve_feature`` but accepts a fresh feature name as a
|
|
518
|
+
fallback. Switch is allowed to invent new feature lanes if the user
|
|
519
|
+
types a name that doesn't exist yet."""
|
|
520
|
+
try:
|
|
521
|
+
return resolve_feature(workspace, feature)
|
|
522
|
+
except BlockerError as e:
|
|
523
|
+
if e.code in ("unknown_alias", "ambiguous_alias"):
|
|
524
|
+
return feature
|
|
525
|
+
raise
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ── eviction (warm → cold) ──────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
def _evict_warm_to_cold(
|
|
531
|
+
workspace: Workspace, feature: str,
|
|
532
|
+
) -> dict[str, Any]:
|
|
533
|
+
"""Park a warm feature back to cold. Auto-stash any dirty work first.
|
|
534
|
+
|
|
535
|
+
Slot-aware: finds the slot currently holding ``feature`` and clears
|
|
536
|
+
every repo subdir of that slot. The branch stays — feature is now
|
|
537
|
+
cold. After clearing, removes the slot entry from ``slots.json``.
|
|
538
|
+
|
|
539
|
+
Returns ``{feature, slot_id, repos: [{repo, stashed, stash_ref?,
|
|
540
|
+
removed}]}``. Empty repos list if the feature wasn't actually warm.
|
|
541
|
+
"""
|
|
542
|
+
slot_id = slots_mod.slot_for_feature(workspace, feature)
|
|
543
|
+
if slot_id is None:
|
|
544
|
+
return {"feature": feature, "slot_id": None, "repos": []}
|
|
545
|
+
|
|
546
|
+
repo_results: list[dict[str, Any]] = []
|
|
547
|
+
for state in workspace.repos:
|
|
548
|
+
repo_name = state.config.name
|
|
549
|
+
wt_path = slots_mod.slot_worktree_path(workspace, slot_id, repo_name)
|
|
550
|
+
if not (wt_path.exists() and (wt_path / ".git").exists()):
|
|
551
|
+
continue
|
|
552
|
+
stash_ref: str | None = None
|
|
553
|
+
if git.is_dirty(wt_path):
|
|
554
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
555
|
+
git.stash_save(
|
|
556
|
+
wt_path,
|
|
557
|
+
f"[canopy {feature} @ {ts}] auto-evicted",
|
|
558
|
+
include_untracked=True,
|
|
559
|
+
)
|
|
560
|
+
stash_ref = "stash@{0}"
|
|
561
|
+
git.worktree_remove(state.abs_path, wt_path)
|
|
562
|
+
repo_results.append({
|
|
563
|
+
"repo": repo_name,
|
|
564
|
+
"stashed": stash_ref is not None,
|
|
565
|
+
"stash_ref": stash_ref,
|
|
566
|
+
"removed": True,
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
# Drop the slot entry from state so the slot becomes available.
|
|
570
|
+
st = slots_mod.read_state(workspace)
|
|
571
|
+
if st is not None and slot_id in st.slots:
|
|
572
|
+
del st.slots[slot_id]
|
|
573
|
+
slots_mod.write_state(workspace, st)
|
|
574
|
+
return {"feature": feature, "slot_id": slot_id, "repos": repo_results}
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _free_warm_slot_if_holding(
|
|
578
|
+
workspace: Workspace, feature: str, repo_name: str,
|
|
579
|
+
) -> None:
|
|
580
|
+
"""If ``feature`` is warm in some slot for ``repo_name``, remove that
|
|
581
|
+
slot's worktree for this repo so main can adopt the branch.
|
|
582
|
+
|
|
583
|
+
Raises ``BlockerError(warm_worktree_dirty_on_promote)`` if the slot
|
|
584
|
+
is dirty — losing the user's work is never silent. Mirrors the
|
|
585
|
+
pre-3.0 reverse-evacuation safety check, just keyed by slot id.
|
|
586
|
+
"""
|
|
587
|
+
slot_id = slots_mod.slot_for_feature(workspace, feature)
|
|
588
|
+
if slot_id is None:
|
|
589
|
+
return
|
|
590
|
+
wt_path = slots_mod.slot_worktree_path(workspace, slot_id, repo_name)
|
|
591
|
+
if not (wt_path / ".git").exists():
|
|
592
|
+
return
|
|
593
|
+
if git.is_dirty(wt_path):
|
|
594
|
+
raise BlockerError(
|
|
595
|
+
code="warm_worktree_dirty_on_promote",
|
|
596
|
+
what=(
|
|
597
|
+
f"warm worktree {wt_path} has uncommitted changes;"
|
|
598
|
+
f" can't promote {feature} to canonical without losing them"
|
|
599
|
+
),
|
|
600
|
+
details={"feature": feature, "repo": repo_name,
|
|
601
|
+
"worktree_path": str(wt_path), "slot_id": slot_id},
|
|
602
|
+
fix_actions=[
|
|
603
|
+
FixAction(
|
|
604
|
+
action="commit",
|
|
605
|
+
args={"feature": feature},
|
|
606
|
+
safe=False,
|
|
607
|
+
preview=f"commit dirty changes in {wt_path}",
|
|
608
|
+
),
|
|
609
|
+
FixAction(
|
|
610
|
+
action="stash_save_feature",
|
|
611
|
+
args={"feature": feature},
|
|
612
|
+
safe=True,
|
|
613
|
+
preview=f"stash dirty changes in {wt_path}",
|
|
614
|
+
),
|
|
615
|
+
],
|
|
616
|
+
)
|
|
617
|
+
repo_state = workspace.get_repo(repo_name)
|
|
618
|
+
git.worktree_remove(repo_state.abs_path, wt_path)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# ── wind-down stash helper ──────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
def _stash_for_winddown(
|
|
624
|
+
workspace: Workspace, feature: str, repo_path: Path,
|
|
625
|
+
) -> str | None:
|
|
626
|
+
"""Stash dirty work in main for a feature being wound down (cold).
|
|
627
|
+
|
|
628
|
+
Tag matches P12 so future ``switch(feature)`` (warming) auto-finds it.
|
|
629
|
+
"""
|
|
630
|
+
if not git.is_dirty(repo_path):
|
|
631
|
+
return None
|
|
632
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
633
|
+
git.stash_save(
|
|
634
|
+
repo_path,
|
|
635
|
+
f"[canopy {feature} @ {ts}] released to cold",
|
|
636
|
+
include_untracked=True,
|
|
637
|
+
)
|
|
638
|
+
return "stash@{0}"
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# ── helpers ─────────────────────────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
def _branch_for_in_repo(
|
|
644
|
+
workspace: Workspace, feature: str, repo_name: str,
|
|
645
|
+
) -> str:
|
|
646
|
+
"""Return the branch name for ``feature`` in ``repo_name``.
|
|
647
|
+
|
|
648
|
+
Honors the lane's ``branches`` map for per-repo branch overrides
|
|
649
|
+
(e.g. auth-flow in api vs auth-flow-v2 in ui)."""
|
|
650
|
+
from ..features.coordinator import FeatureCoordinator
|
|
651
|
+
coord = FeatureCoordinator(workspace)
|
|
652
|
+
try:
|
|
653
|
+
lane = coord.status(feature)
|
|
654
|
+
except Exception:
|
|
655
|
+
return feature
|
|
656
|
+
return lane.branch_for(repo_name)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _create_missing_branches(
|
|
660
|
+
workspace: Workspace, items: list[tuple[str, str]],
|
|
661
|
+
) -> list[dict[str, Any]]:
|
|
662
|
+
"""Create each missing branch from the repo's default branch.
|
|
663
|
+
|
|
664
|
+
Returns per-repo ``[{repo, branch, base, created_from_sha}]``.
|
|
665
|
+
"""
|
|
666
|
+
out = []
|
|
667
|
+
for repo_name, branch in items:
|
|
668
|
+
try:
|
|
669
|
+
state = workspace.get_repo(repo_name)
|
|
670
|
+
except KeyError:
|
|
671
|
+
continue
|
|
672
|
+
base = state.config.default_branch
|
|
673
|
+
base_sha = git.sha_of(state.abs_path, base) or ""
|
|
674
|
+
# --no-track is the right call here (see git/repo.py:create_branch).
|
|
675
|
+
git.create_branch(state.abs_path, branch, start_point=base)
|
|
676
|
+
out.append({
|
|
677
|
+
"repo": repo_name, "branch": branch,
|
|
678
|
+
"base": base, "created_from_sha": base_sha,
|
|
679
|
+
})
|
|
680
|
+
return out
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# ── partial-failure marker ──────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
def _persist_in_flight(
|
|
686
|
+
workspace: Workspace,
|
|
687
|
+
feature_being_promoted: str,
|
|
688
|
+
previously_canonical: str | None,
|
|
689
|
+
*,
|
|
690
|
+
failed_repo: str,
|
|
691
|
+
error_what: str,
|
|
692
|
+
completed_results: list[dict[str, Any]],
|
|
693
|
+
) -> None:
|
|
694
|
+
"""Stamp ``slots.json`` with an in_flight marker so the next switch
|
|
695
|
+
refuses to run on a half-flipped workspace.
|
|
696
|
+
|
|
697
|
+
Captures: what we were trying to do, what completed before the crash,
|
|
698
|
+
which repo blew up, and the underlying error message. Cleared on the
|
|
699
|
+
next successful switch via ``_post_switch_persist``.
|
|
700
|
+
"""
|
|
701
|
+
state = slots_mod.read_state(workspace) or slots_mod.SlotState(
|
|
702
|
+
slot_count=workspace.config.slots,
|
|
703
|
+
)
|
|
704
|
+
state.in_flight = {
|
|
705
|
+
"feature_being_promoted": feature_being_promoted,
|
|
706
|
+
"previously_canonical": previously_canonical,
|
|
707
|
+
"started_at": slots_mod.now_iso(),
|
|
708
|
+
"per_repo_completed": [
|
|
709
|
+
{k: v for k, v in r.items()} for r in completed_results
|
|
710
|
+
],
|
|
711
|
+
"failed_repo": failed_repo,
|
|
712
|
+
"error_what": error_what,
|
|
713
|
+
}
|
|
714
|
+
slots_mod.write_state(workspace, state)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _ensure_consistent(workspace: Workspace) -> None:
|
|
718
|
+
"""Refuse to switch when an in_flight marker is set.
|
|
719
|
+
|
|
720
|
+
A prior switch left the workspace in a partial state (some repos
|
|
721
|
+
flipped to Y, others still on X). Continuing would compound the
|
|
722
|
+
inconsistency. Surface a structured blocker; T19 will extend doctor
|
|
723
|
+
to actually repair this.
|
|
724
|
+
"""
|
|
725
|
+
state = slots_mod.read_state(workspace)
|
|
726
|
+
if state is None or state.in_flight is None:
|
|
727
|
+
return
|
|
728
|
+
inf = state.in_flight
|
|
729
|
+
raise BlockerError(
|
|
730
|
+
code="slot_state_inconsistent",
|
|
731
|
+
what=(
|
|
732
|
+
f"a prior switch to '{inf.get('feature_being_promoted')}' failed in "
|
|
733
|
+
f"repo '{inf.get('failed_repo')}' — workspace is partially flipped"
|
|
734
|
+
),
|
|
735
|
+
details={"in_flight": dict(inf)},
|
|
736
|
+
fix_actions=[
|
|
737
|
+
FixAction(
|
|
738
|
+
action="doctor",
|
|
739
|
+
args={},
|
|
740
|
+
safe=True,
|
|
741
|
+
preview=(
|
|
742
|
+
"run `canopy doctor` to inspect slots.json and the "
|
|
743
|
+
"completed-vs-failed per-repo work; resolve manually, "
|
|
744
|
+
"then clear the in_flight marker"
|
|
745
|
+
),
|
|
746
|
+
),
|
|
747
|
+
],
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# ── pre-3.0 migration gate ──────────────────────────────────────────────
|
|
752
|
+
|
|
753
|
+
def _ensure_post_migration(workspace: Workspace) -> None:
|
|
754
|
+
"""Refuse to switch on a workspace still on the pre-3.0 layout.
|
|
755
|
+
|
|
756
|
+
If ``.canopy/state/active_feature.json`` exists, the workspace hasn't
|
|
757
|
+
been migrated to the slot model yet. Surface a structured blocker
|
|
758
|
+
pointing at ``canopy migrate-slots`` instead of silently writing the
|
|
759
|
+
new ``slots.json`` alongside (which would leave two sources of truth).
|
|
760
|
+
"""
|
|
761
|
+
old = workspace.config.root / ".canopy/state/active_feature.json"
|
|
762
|
+
if old.exists():
|
|
763
|
+
raise BlockerError(
|
|
764
|
+
code="pre_migration",
|
|
765
|
+
what="this workspace is on the pre-3.0 layout — run `canopy migrate-slots`",
|
|
766
|
+
details={"old_state_file": str(old)},
|
|
767
|
+
fix_actions=[
|
|
768
|
+
FixAction(
|
|
769
|
+
action="migrate_slots",
|
|
770
|
+
args={},
|
|
771
|
+
safe=True,
|
|
772
|
+
preview="canopy migrate-slots — one-shot rewrite to slot layout",
|
|
773
|
+
),
|
|
774
|
+
],
|
|
775
|
+
)
|