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
|
@@ -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
|
+
}
|