canopy-cli 3.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. canopy/__init__.py +2 -0
  2. canopy/actions/__init__.py +32 -0
  3. canopy/actions/aliases.py +421 -0
  4. canopy/actions/augments.py +55 -0
  5. canopy/actions/bootstrap.py +249 -0
  6. canopy/actions/bot_resolutions.py +123 -0
  7. canopy/actions/bot_status.py +133 -0
  8. canopy/actions/commit.py +511 -0
  9. canopy/actions/conflicts.py +314 -0
  10. canopy/actions/doctor.py +1459 -0
  11. canopy/actions/draft_replies.py +185 -0
  12. canopy/actions/drift.py +241 -0
  13. canopy/actions/errors.py +115 -0
  14. canopy/actions/evacuate.py +192 -0
  15. canopy/actions/feature_state.py +607 -0
  16. canopy/actions/historian.py +612 -0
  17. canopy/actions/ide_workspace.py +49 -0
  18. canopy/actions/last_visit.py +83 -0
  19. canopy/actions/migrate_slots.py +313 -0
  20. canopy/actions/preflight_state.py +97 -0
  21. canopy/actions/push.py +199 -0
  22. canopy/actions/reads.py +304 -0
  23. canopy/actions/resume.py +582 -0
  24. canopy/actions/review_filter.py +135 -0
  25. canopy/actions/ship.py +399 -0
  26. canopy/actions/slot_details.py +208 -0
  27. canopy/actions/slot_load.py +383 -0
  28. canopy/actions/slots.py +221 -0
  29. canopy/actions/stash.py +230 -0
  30. canopy/actions/switch.py +775 -0
  31. canopy/actions/switch_preflight.py +192 -0
  32. canopy/actions/thread_actions.py +88 -0
  33. canopy/actions/thread_resolutions.py +101 -0
  34. canopy/actions/triage.py +286 -0
  35. canopy/agent/__init__.py +5 -0
  36. canopy/agent/runner.py +129 -0
  37. canopy/agent_setup/__init__.py +264 -0
  38. canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
  39. canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
  40. canopy/cli/__init__.py +0 -0
  41. canopy/cli/main.py +4152 -0
  42. canopy/cli/render.py +98 -0
  43. canopy/cli/ui.py +150 -0
  44. canopy/features/__init__.py +2 -0
  45. canopy/features/coordinator.py +1256 -0
  46. canopy/git/__init__.py +0 -0
  47. canopy/git/hooks.py +173 -0
  48. canopy/git/multi.py +435 -0
  49. canopy/git/repo.py +859 -0
  50. canopy/git/templates/post-checkout.py +67 -0
  51. canopy/graph/__init__.py +0 -0
  52. canopy/integrations/__init__.py +0 -0
  53. canopy/integrations/github.py +983 -0
  54. canopy/integrations/linear.py +307 -0
  55. canopy/integrations/precommit.py +239 -0
  56. canopy/mcp/__init__.py +0 -0
  57. canopy/mcp/client.py +329 -0
  58. canopy/mcp/server.py +1797 -0
  59. canopy/providers/__init__.py +105 -0
  60. canopy/providers/github_issues.py +289 -0
  61. canopy/providers/linear.py +341 -0
  62. canopy/providers/types.py +149 -0
  63. canopy/workspace/__init__.py +4 -0
  64. canopy/workspace/config.py +378 -0
  65. canopy/workspace/context.py +224 -0
  66. canopy/workspace/discovery.py +197 -0
  67. canopy/workspace/workspace.py +173 -0
  68. canopy_cli-3.1.0.dist-info/METADATA +282 -0
  69. canopy_cli-3.1.0.dist-info/RECORD +71 -0
  70. canopy_cli-3.1.0.dist-info/WHEEL +4 -0
  71. canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,208 @@
1
+ """Rich per-slot enrichment for the slots MCP shape (T15).
2
+
3
+ Composes ``feature_state`` + ``bot_status`` + ``FeatureCoordinator.status``
4
+ per slot occupant + canonical. Single function so the CLI / MCP / dashboard
5
+ layers stay thin — and so the agent and the human read the same payload.
6
+
7
+ The shape mirrors the dashboard's grid: one slot block per occupied slot
8
+ (``None`` when empty, never ``{}``), and per-repo facts inside each block
9
+ limited to the repos the feature actually touches (partial-scope features
10
+ stay partial here).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from ..features.coordinator import FeatureCoordinator
18
+ from ..git import repo as git
19
+ from ..workspace.workspace import Workspace
20
+ from . import bot_status
21
+ from . import feature_state as fs
22
+ from . import slots as slots_mod
23
+ from .aliases import repos_for_feature
24
+
25
+
26
+ def rich_slots(workspace: Workspace) -> dict[str, Any]:
27
+ """Return the full dashboard payload for every slot + canonical.
28
+
29
+ Empty slots are explicit ``None`` (the dashboard renders these as
30
+ placeholders). When ``slots.json`` is absent we still return the
31
+ skeleton — ``slot_count`` from canopy.toml, every slot ``None``,
32
+ ``canonical`` ``None`` — so the consumer never has to special-case
33
+ "no state yet."
34
+ """
35
+ state = slots_mod.read_state(workspace) or slots_mod.SlotState(
36
+ slot_count=workspace.config.slots,
37
+ )
38
+ out: dict[str, Any] = {
39
+ "version": 1,
40
+ "slot_count": state.slot_count,
41
+ "canonical": _enrich_canonical(workspace, state),
42
+ "slots": {},
43
+ "last_touched": dict(state.last_touched),
44
+ }
45
+ for i in range(1, state.slot_count + 1):
46
+ sid = f"worktree-{i}"
47
+ entry = state.slots.get(sid)
48
+ out["slots"][sid] = (
49
+ _enrich_slot(workspace, sid, entry) if entry else None
50
+ )
51
+ return out
52
+
53
+
54
+ def _enrich_canonical(
55
+ workspace: Workspace, state: slots_mod.SlotState,
56
+ ) -> dict[str, Any] | None:
57
+ if state.canonical is None:
58
+ return None
59
+ return {
60
+ "slot_id": "canonical",
61
+ "feature": state.canonical.feature,
62
+ "activated_at": state.canonical.activated_at,
63
+ **_enrich_feature_payload(workspace, state.canonical.feature),
64
+ }
65
+
66
+
67
+ def _enrich_slot(
68
+ workspace: Workspace, slot_id: str, entry: slots_mod.SlotEntry,
69
+ ) -> dict[str, Any]:
70
+ return {
71
+ "slot_id": slot_id,
72
+ "feature": entry.feature,
73
+ "occupied_at": entry.occupied_at,
74
+ **_enrich_feature_payload(workspace, entry.feature),
75
+ }
76
+
77
+
78
+ def _enrich_feature_payload(
79
+ workspace: Workspace, feature: str,
80
+ ) -> dict[str, Any]:
81
+ """Per-feature payload shared by canonical + slot blocks.
82
+
83
+ Delegates to ``feature_state`` for the heavy lifting (dirty / diverge
84
+ / PR / CI) and to ``bot_status`` for the unresolved-bot rollup. We
85
+ only translate field names + fill the few extras the dashboard wants
86
+ (short sha, commit subject + date, default branch). No new git or
87
+ GitHub calls beyond what those two paths already make.
88
+ """
89
+ repo_branches = repos_for_feature(workspace, feature)
90
+ errors: list[dict] = []
91
+ try:
92
+ st = fs.feature_state(workspace, feature)
93
+ except Exception as e:
94
+ st = {"state": "unknown", "summary": {"repos": {}, "prs": {},
95
+ "ci_per_repo": {}}}
96
+ errors.append({"source": "feature_state", "what": str(e)})
97
+ summary = st.get("summary") or {}
98
+ facts_by_repo: dict[str, dict] = summary.get("repos") or {}
99
+ prs_by_repo: dict[str, dict] = summary.get("prs") or {}
100
+ ci_by_repo: dict[str, dict] = summary.get("ci_per_repo") or {}
101
+
102
+ # Per-repo path resolution mirrors feature_state's: worktree path for
103
+ # worktree-backed features, main repo otherwise. We need the path so
104
+ # we can run a few cheap extra git reads (short sha, subject, default
105
+ # branch) without re-doing the worktree resolution dance.
106
+ repo_paths, _has_wt = fs.resolve_repo_paths(workspace, feature, repo_branches)
107
+
108
+ try:
109
+ bot_roll = bot_status.bot_comments_status(workspace, feature)
110
+ except Exception as e:
111
+ bot_roll = {"repos": {}}
112
+ errors.append({"source": "bot_status", "what": str(e)})
113
+ bot_repos = bot_roll.get("repos") or {}
114
+
115
+ try:
116
+ lane = FeatureCoordinator(workspace).status(feature)
117
+ linear_issue = getattr(lane, "linear_issue", "") or None
118
+ linear_url = getattr(lane, "linear_url", "") or None
119
+ except Exception as e:
120
+ linear_issue = None
121
+ linear_url = None
122
+ errors.append({"source": "coordinator.status", "what": str(e)})
123
+
124
+ repos_out: dict[str, dict] = {}
125
+ for repo_name, expected_branch in repo_branches.items():
126
+ repo_facts = facts_by_repo.get(repo_name) or {}
127
+ repo_path = repo_paths.get(repo_name)
128
+ pr = prs_by_repo.get(repo_name)
129
+ pr_block = _pr_block(pr, ci_by_repo.get(repo_name))
130
+
131
+ repos_out[repo_name] = {
132
+ "branch": repo_facts.get("branch", expected_branch),
133
+ "path": str(repo_path) if repo_path else "",
134
+ "dirty": bool(repo_facts.get("is_dirty", False)),
135
+ "dirty_file_count": int(repo_facts.get("dirty_count", 0)),
136
+ "ahead": int(repo_facts.get("ahead", 0)),
137
+ "behind": int(repo_facts.get("behind", 0)),
138
+ "default_branch": _default_branch(repo_path),
139
+ "last_commit": _last_commit(repo_path, repo_facts.get("head_sha", "")),
140
+ "pr": pr_block,
141
+ "bot_unresolved": int((bot_repos.get(repo_name) or {}).get("unresolved", 0)),
142
+ # Feature-tagged stash count: skipped in T15 (would need an
143
+ # extra `git stash list` per repo). The shape includes the
144
+ # field so the dashboard never KeyErrors; populated by a later
145
+ # plan if it earns its keep.
146
+ "feature_tagged_stash_count": 0,
147
+ }
148
+
149
+ return {
150
+ "repos": repos_out,
151
+ "feature_state": st.get("state", "unknown"),
152
+ "linear_issue": linear_issue,
153
+ "linear_url": linear_url,
154
+ # last_visit lands with the feature-resume plan; reserved here so
155
+ # the shape is stable when that plan ships.
156
+ "last_visit": None,
157
+ # Empty list when all enrichment sources succeeded. Populated with
158
+ # ``{source, what}`` dicts when a source raised — surfaces real
159
+ # bugs that previously vanished into bare ``except Exception``.
160
+ "errors": errors,
161
+ }
162
+
163
+
164
+ def _pr_block(pr: dict | None, ci_status: dict | None) -> dict | None:
165
+ if not pr:
166
+ return None
167
+ return {
168
+ "number": pr.get("number"),
169
+ "url": pr.get("url", ""),
170
+ "state": pr.get("state", ""),
171
+ "review_decision": pr.get("review_decision", ""),
172
+ "ci_status": ci_status or {"status": "no_checks"},
173
+ }
174
+
175
+
176
+ def _default_branch(repo_path: Path | None) -> str:
177
+ if repo_path is None:
178
+ return "main"
179
+ try:
180
+ return git.default_branch(repo_path) or "main"
181
+ except Exception:
182
+ return "main"
183
+
184
+
185
+ def _last_commit(repo_path: Path | None, head_sha: str) -> dict | None:
186
+ """Last-commit detail block. Returns None when the branch has no commits.
187
+
188
+ Re-uses the already-resolved ``head_sha`` from feature_state to avoid a
189
+ second rev-parse. Subject + ISO date are one cheap git log each — same
190
+ cost feature_state would pay anyway if it asked.
191
+ """
192
+ if not head_sha or repo_path is None:
193
+ return None
194
+ short = head_sha[:8]
195
+ subject = ""
196
+ try:
197
+ lines = git.log_oneline(repo_path, head_sha, max_count=1)
198
+ if lines:
199
+ # `<short_sha> <subject>` — drop the hash prefix.
200
+ parts = lines[0].split(" ", 1)
201
+ subject = parts[1] if len(parts) > 1 else ""
202
+ except Exception:
203
+ pass
204
+ try:
205
+ at = git.commit_iso_date(repo_path, head_sha)
206
+ except Exception:
207
+ at = ""
208
+ return {"sha": head_sha, "short": short, "subject": subject, "at": at}
@@ -0,0 +1,383 @@
1
+ """slot_load / slot_clear / slot_swap — slot-targeted operations (T16, T17).
2
+
3
+ These complement `switch` by letting the caller manipulate warm slots
4
+ without changing canonical. Useful for the dashboard's load/clear/swap
5
+ buttons and for pre-warming.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from ..git import repo as git
12
+ from ..workspace.workspace import Workspace
13
+ from .aliases import resolve_feature, repos_for_feature
14
+ from .errors import BlockerError, FixAction
15
+ from . import slots as slots_mod
16
+
17
+
18
+ def _ensure_consistent_slot_state(workspace: Workspace) -> None:
19
+ """Refuse slot operations when a prior op left an in_flight marker.
20
+
21
+ Mirrors switch._ensure_consistent — a prior slot op partially failed
22
+ and the workspace is in a half-flipped state. Continuing would
23
+ compound the inconsistency. Surface a structured blocker and let the
24
+ user run `canopy doctor` to inspect.
25
+ """
26
+ state = slots_mod.read_state(workspace)
27
+ if state is None or state.in_flight is None:
28
+ return
29
+ inf = state.in_flight
30
+ raise BlockerError(
31
+ code="slot_state_inconsistent",
32
+ what=(
33
+ f"a prior {inf.get('operation', 'slot op')} failed in "
34
+ f"repo '{inf.get('failed_repo')}' — workspace is partially flipped"
35
+ ),
36
+ details={"in_flight": dict(inf)},
37
+ fix_actions=[
38
+ FixAction(
39
+ action="doctor",
40
+ args={},
41
+ safe=True,
42
+ preview=(
43
+ "run `canopy doctor` to inspect slots.json and the "
44
+ "in_flight marker; resolve manually, then clear it"
45
+ ),
46
+ ),
47
+ ],
48
+ )
49
+
50
+
51
+ def slot_load(
52
+ workspace: Workspace,
53
+ feature: str,
54
+ *,
55
+ slot_id: str | None = None,
56
+ replace: bool = False,
57
+ bootstrap: bool = False,
58
+ ) -> dict[str, Any]:
59
+ """Warm a cold feature into a slot without changing canonical.
60
+
61
+ Raises BlockerError for:
62
+ - feature_is_canonical: feature is already canonical
63
+ - feature_already_warm: feature is already in a warm slot
64
+ - slot_occupied: target slot is occupied and replace=False
65
+ - unknown_slot: slot_id is out of range
66
+ - worktree_cap_reached: all slots full and slot_id was not given
67
+ """
68
+ _ensure_consistent_slot_state(workspace)
69
+ feature_name = resolve_feature(workspace, feature)
70
+ state = slots_mod.read_state(workspace) or slots_mod.SlotState(
71
+ slot_count=workspace.config.slots,
72
+ )
73
+
74
+ # Refuse if already canonical — it's loaded more strongly than warm.
75
+ if state.canonical and state.canonical.feature == feature_name:
76
+ raise BlockerError(
77
+ code="feature_is_canonical",
78
+ what=f"feature '{feature_name}' is already canonical — use `switch` to move it",
79
+ )
80
+
81
+ # Refuse if already warm in any slot.
82
+ existing_slot = slots_mod.slot_for_feature(workspace, feature_name)
83
+ if existing_slot is not None:
84
+ raise BlockerError(
85
+ code="feature_already_warm",
86
+ what=f"feature '{feature_name}' is already warm in {existing_slot}",
87
+ details={"current_slot": existing_slot, "requested_slot": slot_id},
88
+ fix_actions=[
89
+ FixAction(
90
+ action="slot_swap",
91
+ args={"slot_a": existing_slot, "slot_b": slot_id or "?"},
92
+ safe=False,
93
+ preview="use `slot swap` to move between slots",
94
+ ),
95
+ ],
96
+ )
97
+
98
+ # Resolve slot id — pick lowest free, or use caller's choice.
99
+ if slot_id is None:
100
+ chosen = slots_mod.allocate_slot(state)
101
+ if chosen is None:
102
+ raise BlockerError(
103
+ code="worktree_cap_reached",
104
+ what=f"all {state.slot_count} slots are occupied",
105
+ fix_actions=[
106
+ FixAction(
107
+ action="slot_clear",
108
+ args={"slot_id": "<LRU>"},
109
+ safe=False,
110
+ preview="clear an LRU slot first",
111
+ ),
112
+ ],
113
+ )
114
+ slot_id = chosen
115
+
116
+ # Validate slot id range.
117
+ valid_slots = {f"worktree-{i}" for i in range(1, state.slot_count + 1)}
118
+ if slot_id not in valid_slots:
119
+ raise BlockerError(
120
+ code="unknown_slot",
121
+ what=f"slot '{slot_id}' out of range (cap={state.slot_count})",
122
+ )
123
+
124
+ # If occupied: evict with replace=True, else refuse.
125
+ evicted: dict | None = None
126
+ if slot_id in state.slots:
127
+ if not replace:
128
+ raise BlockerError(
129
+ code="slot_occupied",
130
+ what=f"{slot_id} is occupied by '{state.slots[slot_id].feature}'",
131
+ details={"slot": slot_id, "occupant": state.slots[slot_id].feature},
132
+ fix_actions=[
133
+ FixAction(
134
+ action="slot_load",
135
+ args={"feature": feature_name, "slot_id": slot_id, "replace": True},
136
+ safe=False,
137
+ preview="evict occupant to cold and load this feature",
138
+ ),
139
+ ],
140
+ )
141
+ evicted = slot_clear(workspace, slot_id)
142
+
143
+ # Re-read state after potential eviction.
144
+ state = slots_mod.read_state(workspace) or slots_mod.SlotState(
145
+ slot_count=workspace.config.slots,
146
+ )
147
+
148
+ # Add worktrees per repo — iterate repos_for_feature (respects partial scope).
149
+ # Refuse to auto-allocate "all repos" for unregistered features — that
150
+ # silently over-scopes partial-scope work. Force the user to declare
151
+ # intent via `canopy feature create <name> --repos <list>` first.
152
+ repo_branches = repos_for_feature(workspace, feature_name)
153
+ if not repo_branches:
154
+ raise BlockerError(
155
+ code="ambiguous_feature_scope",
156
+ what=(
157
+ f"feature '{feature_name}' is not yet registered — "
158
+ f"run `canopy feature create <name> --repos <list>` first"
159
+ ),
160
+ details={"feature": feature_name},
161
+ )
162
+ per_repo: list[dict] = []
163
+ for repo_name, branch in repo_branches.items():
164
+ try:
165
+ repo = workspace.get_repo(repo_name)
166
+ except KeyError:
167
+ continue
168
+ if not git.branch_exists(repo.abs_path, branch):
169
+ git.create_branch(repo.abs_path, branch,
170
+ start_point=repo.config.default_branch)
171
+ dest = slots_mod.slot_worktree_path(workspace, slot_id, repo_name)
172
+ dest.parent.mkdir(parents=True, exist_ok=True)
173
+ git.worktree_add(repo.abs_path, dest, branch, create_branch=False)
174
+ per_repo.append({
175
+ "repo": repo_name,
176
+ "branch": branch,
177
+ "worktree_path": str(dest.resolve()),
178
+ })
179
+
180
+ # Persist slot entry + bump last_touched.
181
+ now = slots_mod.now_iso()
182
+ state.slots[slot_id] = slots_mod.SlotEntry(feature=feature_name, occupied_at=now)
183
+ state.last_touched[feature_name] = now
184
+ slots_mod.write_state(workspace, state)
185
+
186
+ # Optional bootstrap.
187
+ bootstrap_result = None
188
+ if bootstrap or getattr(workspace.config, "bootstrap_default", False):
189
+ try:
190
+ from . import bootstrap as bs
191
+ bootstrap_result = bs.bootstrap_feature(workspace, feature_name)
192
+ except (ImportError, AttributeError) as e:
193
+ bootstrap_result = {"skipped": f"bootstrap module not available: {e}"}
194
+ except Exception as e:
195
+ bootstrap_result = {"error": str(e)}
196
+
197
+ return {
198
+ "feature": feature_name,
199
+ "slot_id": slot_id,
200
+ "per_repo": per_repo,
201
+ "evicted": evicted,
202
+ "bootstrap": bootstrap_result,
203
+ }
204
+
205
+
206
+ def slot_clear(workspace: Workspace, slot_id: str) -> dict[str, Any]:
207
+ """Evict a feature from a slot to cold.
208
+
209
+ Creates a feature-tagged stash for any dirty work before removing the
210
+ worktree (best-effort — stash failure does not block removal). The branch
211
+ is kept; only the warm worktree is removed.
212
+ """
213
+ _ensure_consistent_slot_state(workspace)
214
+ state = slots_mod.read_state(workspace)
215
+ if state is None or slot_id not in state.slots:
216
+ raise BlockerError(
217
+ code="empty_slot",
218
+ what=f"slot '{slot_id}' is empty — nothing to clear",
219
+ )
220
+ feature = state.slots[slot_id].feature
221
+ repo_branches = repos_for_feature(workspace, feature) or {
222
+ r.config.name: feature for r in workspace.repos
223
+ }
224
+ cleared: list[dict] = []
225
+ for repo_name in repo_branches:
226
+ try:
227
+ repo = workspace.get_repo(repo_name)
228
+ except KeyError:
229
+ continue
230
+ slot_path = slots_mod.slot_worktree_path(workspace, slot_id, repo_name)
231
+ if not slot_path.exists():
232
+ cleared.append({"repo": repo_name, "status": "missing", "slot_path": str(slot_path)})
233
+ continue
234
+ # Tag any dirty work with a feature-tagged stash before removing the worktree.
235
+ # Critical: if the slot is dirty AND stash fails, refuse to remove the
236
+ # worktree — silent data loss is never acceptable.
237
+ stash_ref = None
238
+ stash_failed = False
239
+ try:
240
+ from . import evacuate as evac
241
+ stash_ref = evac.stash_for_evacuation(workspace, feature, repo_name, slot_path)
242
+ except Exception:
243
+ stash_failed = True
244
+ if stash_failed:
245
+ try:
246
+ dirty = git.is_dirty(slot_path)
247
+ except Exception:
248
+ dirty = True # conservative: assume dirty when we can't tell
249
+ if dirty:
250
+ raise BlockerError(
251
+ code="evict_stash_failed",
252
+ what=(
253
+ f"slot '{slot_id}' repo '{repo_name}' is dirty but stash failed; "
254
+ f"refusing to remove worktree"
255
+ ),
256
+ details={"slot": slot_id, "repo": repo_name,
257
+ "slot_path": str(slot_path)},
258
+ )
259
+ try:
260
+ git.worktree_remove(repo.abs_path, slot_path, force=True)
261
+ except Exception as e:
262
+ cleared.append({"repo": repo_name, "status": "remove_failed",
263
+ "slot_path": str(slot_path), "error": str(e)})
264
+ continue
265
+ cleared.append({
266
+ "repo": repo_name, "status": "cleared",
267
+ "slot_path": str(slot_path), "stash_ref": stash_ref,
268
+ })
269
+ # Re-read to get latest state, then remove slot entry.
270
+ state = slots_mod.read_state(workspace) or state
271
+ if slot_id in state.slots:
272
+ del state.slots[slot_id]
273
+ slots_mod.write_state(workspace, state)
274
+ return {"slot_id": slot_id, "feature": feature, "repos": cleared}
275
+
276
+
277
+ def slot_swap(workspace: Workspace, slot_a: str, slot_b: str) -> dict[str, Any]:
278
+ """Exchange the occupants of two slots.
279
+
280
+ Performs two parallel branch checkouts inside the slot worktrees and
281
+ updates slots.json — no worktree_add or worktree_remove involved.
282
+ Raises swap_scope_mismatch when the two features touch different repo sets.
283
+
284
+ On a phase-2 checkout failure, attempts to re-checkout each slot's
285
+ original branch (best-effort rollback) and persists an ``in_flight``
286
+ marker so the next slot op refuses to operate on a half-swapped state.
287
+ """
288
+ _ensure_consistent_slot_state(workspace)
289
+ state = slots_mod.read_state(workspace)
290
+ if state is None:
291
+ raise BlockerError(code="no_slot_state", what="no slots.json")
292
+ if slot_a not in state.slots:
293
+ raise BlockerError(code="empty_slot",
294
+ what=f"slot '{slot_a}' is empty — cannot swap")
295
+ if slot_b not in state.slots:
296
+ raise BlockerError(code="empty_slot",
297
+ what=f"slot '{slot_b}' is empty — cannot swap")
298
+
299
+ feat_a = state.slots[slot_a].feature
300
+ feat_b = state.slots[slot_b].feature
301
+
302
+ branches_a = repos_for_feature(workspace, feat_a) or {}
303
+ branches_b = repos_for_feature(workspace, feat_b) or {}
304
+
305
+ # v1 swap requires identical repo scope on both features.
306
+ if set(branches_a.keys()) != set(branches_b.keys()):
307
+ raise BlockerError(
308
+ code="swap_scope_mismatch",
309
+ what=(f"features '{feat_a}' and '{feat_b}' touch different repo sets — "
310
+ "v1 swap requires identical scope"),
311
+ details={
312
+ "feat_a": feat_a, "feat_a_repos": sorted(branches_a.keys()),
313
+ "feat_b": feat_b, "feat_b_repos": sorted(branches_b.keys()),
314
+ },
315
+ )
316
+
317
+ # Per repo, swap the checked-out branches inside each slot's worktree.
318
+ # Git won't allow a branch to be checked out in two worktrees simultaneously,
319
+ # so we detach both slots first to free the branch locks, then do the checkouts.
320
+ per_repo: list[dict] = []
321
+ repo_names = sorted(branches_a.keys())
322
+ # Phase 1: detach every slot's repo HEAD so the branches are free.
323
+ for repo_name in repo_names:
324
+ slot_a_path = slots_mod.slot_worktree_path(workspace, slot_a, repo_name)
325
+ slot_b_path = slots_mod.slot_worktree_path(workspace, slot_b, repo_name)
326
+ git.checkout_detach(slot_a_path)
327
+ git.checkout_detach(slot_b_path)
328
+
329
+ # Phase 2: adopt the swapped branches. On failure, attempt to re-checkout
330
+ # each slot's ORIGINAL branch (rollback) and persist an in_flight marker
331
+ # so the next slot op refuses to run on a half-flipped state.
332
+ try:
333
+ for repo_name in repo_names:
334
+ slot_a_path = slots_mod.slot_worktree_path(workspace, slot_a, repo_name)
335
+ slot_b_path = slots_mod.slot_worktree_path(workspace, slot_b, repo_name)
336
+ # slot A's worktree adopts feat_b's branch; slot B's worktree adopts feat_a's branch.
337
+ git.checkout(slot_a_path, branches_b[repo_name])
338
+ git.checkout(slot_b_path, branches_a[repo_name])
339
+ per_repo.append({"repo": repo_name,
340
+ "slot_a_now": branches_b[repo_name],
341
+ "slot_b_now": branches_a[repo_name]})
342
+ except Exception as e:
343
+ failed_repo = repo_name # last iterated value
344
+ # Best-effort rollback: put every slot back on its ORIGINAL branch.
345
+ for rn in repo_names:
346
+ slot_a_path = slots_mod.slot_worktree_path(workspace, slot_a, rn)
347
+ slot_b_path = slots_mod.slot_worktree_path(workspace, slot_b, rn)
348
+ try:
349
+ git.checkout(slot_a_path, branches_a[rn])
350
+ except Exception:
351
+ pass
352
+ try:
353
+ git.checkout(slot_b_path, branches_b[rn])
354
+ except Exception:
355
+ pass
356
+ # Persist in_flight marker — slots.json otherwise unchanged.
357
+ cur = slots_mod.read_state(workspace) or state
358
+ cur.in_flight = {
359
+ "operation": "slot_swap",
360
+ "slot_a": slot_a, "slot_b": slot_b,
361
+ "feat_a": feat_a, "feat_b": feat_b,
362
+ "started_at": slots_mod.now_iso(),
363
+ "failed_repo": failed_repo,
364
+ "error_what": str(e),
365
+ }
366
+ slots_mod.write_state(workspace, cur)
367
+ raise
368
+
369
+ now = slots_mod.now_iso()
370
+ state = slots_mod.read_state(workspace) or state
371
+ state.slots[slot_a] = slots_mod.SlotEntry(feature=feat_b, occupied_at=now)
372
+ state.slots[slot_b] = slots_mod.SlotEntry(feature=feat_a, occupied_at=now)
373
+ state.last_touched[feat_a] = now
374
+ state.last_touched[feat_b] = now
375
+ # Clear any in_flight marker — this swap completed cleanly.
376
+ state.in_flight = None
377
+ slots_mod.write_state(workspace, state)
378
+
379
+ return {
380
+ "swapped": [f"{feat_a}↔{feat_b}"],
381
+ "slot_a": slot_a, "slot_b": slot_b,
382
+ "per_repo": per_repo,
383
+ }