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/slots.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Slot state — single source of truth for canopy's canonical + warm features.
|
|
2
|
+
|
|
3
|
+
State file: .canopy/state/slots.json (atomic temp+rename writes).
|
|
4
|
+
|
|
5
|
+
Schema::
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"version": 1,
|
|
9
|
+
"slot_count": 2,
|
|
10
|
+
"canonical": {feature, activated_at, per_repo_paths},
|
|
11
|
+
"previous_canonical": str | null,
|
|
12
|
+
"slots": {"worktree-1": {feature, occupied_at}, ...},
|
|
13
|
+
"last_touched": {feature: iso, ...},
|
|
14
|
+
"in_flight": {feature_being_promoted, previously_canonical,
|
|
15
|
+
started_at, per_repo_completed, failed_repo,
|
|
16
|
+
error_what} | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Validation on read: a missing canonical path clears ``canonical`` only —
|
|
20
|
+
the slots/last_touched maps are independent of the canonical pointer and
|
|
21
|
+
must NOT be discarded when the canonical entry is stale. Catastrophic
|
|
22
|
+
cases (file missing, JSON unparseable, top-level not a dict) still
|
|
23
|
+
return None.
|
|
24
|
+
|
|
25
|
+
Missing slot dirs → silently drop from the returned state.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from ..workspace.workspace import Workspace
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
SLOTS_DIR = ".canopy/worktrees"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class CanonicalEntry:
|
|
43
|
+
feature: str
|
|
44
|
+
activated_at: str
|
|
45
|
+
per_repo_paths: dict[str, str]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class SlotEntry:
|
|
50
|
+
feature: str
|
|
51
|
+
occupied_at: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SlotState:
|
|
56
|
+
slot_count: int = 2
|
|
57
|
+
canonical: CanonicalEntry | None = None
|
|
58
|
+
previous_canonical: str | None = None
|
|
59
|
+
slots: dict[str, SlotEntry] = field(default_factory=dict)
|
|
60
|
+
last_touched: dict[str, str] = field(default_factory=dict)
|
|
61
|
+
in_flight: dict | None = None
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict:
|
|
64
|
+
d: dict[str, Any] = {
|
|
65
|
+
"version": 1,
|
|
66
|
+
"slot_count": self.slot_count,
|
|
67
|
+
"previous_canonical": self.previous_canonical,
|
|
68
|
+
"slots": {
|
|
69
|
+
sid: {"feature": e.feature, "occupied_at": e.occupied_at}
|
|
70
|
+
for sid, e in self.slots.items()
|
|
71
|
+
},
|
|
72
|
+
"last_touched": dict(self.last_touched),
|
|
73
|
+
"in_flight": dict(self.in_flight) if self.in_flight else None,
|
|
74
|
+
}
|
|
75
|
+
if self.canonical is not None:
|
|
76
|
+
d["canonical"] = {
|
|
77
|
+
"feature": self.canonical.feature,
|
|
78
|
+
"activated_at": self.canonical.activated_at,
|
|
79
|
+
"per_repo_paths": dict(self.canonical.per_repo_paths),
|
|
80
|
+
}
|
|
81
|
+
else:
|
|
82
|
+
d["canonical"] = None
|
|
83
|
+
return d
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _state_path(workspace: Workspace) -> Path:
|
|
87
|
+
return workspace.config.root / ".canopy" / "state" / "slots.json"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _slots_root(workspace: Workspace) -> Path:
|
|
91
|
+
return workspace.config.root / SLOTS_DIR
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def now_iso() -> str:
|
|
95
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_state(workspace: Workspace) -> SlotState | None:
|
|
99
|
+
path = _state_path(workspace)
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
data = json.loads(path.read_text())
|
|
104
|
+
except (OSError, json.JSONDecodeError):
|
|
105
|
+
return None
|
|
106
|
+
if not isinstance(data, dict):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
# Canonical staleness check — stale canonical is NOT fatal to the
|
|
110
|
+
# rest of the state. Slots and last_touched are independent of the
|
|
111
|
+
# canonical pointer; clear canonical only and preserve the rest.
|
|
112
|
+
canonical_raw = data.get("canonical")
|
|
113
|
+
canonical: CanonicalEntry | None = None
|
|
114
|
+
if isinstance(canonical_raw, dict) and canonical_raw.get("feature"):
|
|
115
|
+
per_repo = canonical_raw.get("per_repo_paths") or {}
|
|
116
|
+
if not isinstance(per_repo, dict):
|
|
117
|
+
# Malformed canonical block — treat as no canonical, keep rest.
|
|
118
|
+
canonical = None
|
|
119
|
+
else:
|
|
120
|
+
stale = any(not Path(p).exists() for p in per_repo.values())
|
|
121
|
+
if stale:
|
|
122
|
+
canonical = None
|
|
123
|
+
else:
|
|
124
|
+
canonical = CanonicalEntry(
|
|
125
|
+
feature=canonical_raw["feature"],
|
|
126
|
+
activated_at=canonical_raw.get("activated_at", ""),
|
|
127
|
+
per_repo_paths=dict(per_repo),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
slots_raw = data.get("slots") or {}
|
|
131
|
+
slots_root = _slots_root(workspace)
|
|
132
|
+
slots_out: dict[str, SlotEntry] = {}
|
|
133
|
+
for sid, entry in slots_raw.items():
|
|
134
|
+
if not isinstance(entry, dict):
|
|
135
|
+
continue
|
|
136
|
+
# Drop slots whose dir is gone (stale on filesystem)
|
|
137
|
+
if not (slots_root / sid).exists():
|
|
138
|
+
continue
|
|
139
|
+
slots_out[sid] = SlotEntry(
|
|
140
|
+
feature=entry.get("feature", ""),
|
|
141
|
+
occupied_at=entry.get("occupied_at", ""),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
in_flight_raw = data.get("in_flight")
|
|
145
|
+
in_flight = (
|
|
146
|
+
dict(in_flight_raw) if isinstance(in_flight_raw, dict) else None
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return SlotState(
|
|
150
|
+
slot_count=int(data.get("slot_count", 2)),
|
|
151
|
+
canonical=canonical,
|
|
152
|
+
previous_canonical=data.get("previous_canonical"),
|
|
153
|
+
slots=slots_out,
|
|
154
|
+
last_touched={
|
|
155
|
+
str(k): str(v) for k, v in (data.get("last_touched") or {}).items()
|
|
156
|
+
},
|
|
157
|
+
in_flight=in_flight,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def write_state(workspace: Workspace, state: SlotState) -> None:
|
|
162
|
+
path = _state_path(workspace)
|
|
163
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
tmp = path.with_suffix(".json.tmp")
|
|
165
|
+
tmp.write_text(json.dumps(state.to_dict(), indent=2))
|
|
166
|
+
tmp.replace(path)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def slot_worktree_path(workspace: Workspace, slot_id: str, repo: str) -> Path:
|
|
170
|
+
"""Filesystem location of a slot's repo subdir."""
|
|
171
|
+
return _slots_root(workspace) / slot_id / repo
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def slot_for_feature(workspace: Workspace, feature: str) -> str | None:
|
|
175
|
+
"""Return the slot id currently holding ``feature``, or None."""
|
|
176
|
+
state = read_state(workspace)
|
|
177
|
+
if state is None:
|
|
178
|
+
return None
|
|
179
|
+
for sid, entry in state.slots.items():
|
|
180
|
+
if entry.feature == feature:
|
|
181
|
+
return sid
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def feature_for_slot(workspace: Workspace, slot_id: str) -> str | None:
|
|
186
|
+
"""Return the feature currently in ``slot_id``, or None."""
|
|
187
|
+
state = read_state(workspace)
|
|
188
|
+
if state is None or slot_id not in state.slots:
|
|
189
|
+
return None
|
|
190
|
+
return state.slots[slot_id].feature
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def allocate_slot(state: SlotState) -> str | None:
|
|
194
|
+
"""Return the lowest-index free slot id, or None if all are full."""
|
|
195
|
+
occupied = set(state.slots.keys())
|
|
196
|
+
for i in range(1, state.slot_count + 1):
|
|
197
|
+
sid = f"worktree-{i}"
|
|
198
|
+
if sid not in occupied:
|
|
199
|
+
return sid
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def lru_evictee(
|
|
204
|
+
state: SlotState, *, exclude: set[str] | None = None,
|
|
205
|
+
) -> str | None:
|
|
206
|
+
"""Pick the LRU-coldest occupant feature from the warm slots.
|
|
207
|
+
|
|
208
|
+
Returns None when no eligible candidate. Sorting is deterministic:
|
|
209
|
+
(last_touched ASC, feature name ASC) — features with no timestamp
|
|
210
|
+
sort as oldest.
|
|
211
|
+
"""
|
|
212
|
+
exclude = exclude or set()
|
|
213
|
+
candidates = [
|
|
214
|
+
e.feature for e in state.slots.values() if e.feature not in exclude
|
|
215
|
+
]
|
|
216
|
+
if not candidates:
|
|
217
|
+
return None
|
|
218
|
+
return sorted(
|
|
219
|
+
candidates,
|
|
220
|
+
key=lambda f: (state.last_touched.get(f, ""), f),
|
|
221
|
+
)[0]
|
canopy/actions/stash.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Feature-aware stash tagging.
|
|
2
|
+
|
|
3
|
+
Wraps the existing per-repo stash primitives so a stash saved in a
|
|
4
|
+
feature context carries a structured prefix in its message. The prefix
|
|
5
|
+
shape — ``[canopy <feature> @ <iso_ts>] <user_message>`` — is readable
|
|
6
|
+
in ``git stash list`` output and easy to parse back.
|
|
7
|
+
|
|
8
|
+
Untagged stashes still work end-to-end. ``list_grouped`` groups stashes
|
|
9
|
+
by feature using the tag; entries without the prefix go in
|
|
10
|
+
``untagged``.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..git import multi as git_multi
|
|
20
|
+
from ..git import repo as git
|
|
21
|
+
from ..workspace.workspace import Workspace
|
|
22
|
+
from .aliases import resolve_feature
|
|
23
|
+
from .errors import BlockerError, FailedError, FixAction
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Git stash auto-prefixes the stored message with "On <branch>: " (or
|
|
27
|
+
# "WIP on <branch>: ..." when no -m given). Match the canopy tag anywhere
|
|
28
|
+
# after that optional prefix; user message is whatever follows the ``]``.
|
|
29
|
+
_TAG = re.compile(r"\[canopy (\S+) @ (\S+)\] ?(.*)$", re.DOTALL)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class StashEntry:
|
|
34
|
+
repo: str
|
|
35
|
+
index: int
|
|
36
|
+
ref: str # 'stash@{N}'
|
|
37
|
+
message: str # raw message as git stored it
|
|
38
|
+
feature: str | None # parsed from tag, None if untagged
|
|
39
|
+
ts: str | None # parsed from tag
|
|
40
|
+
user_message: str # message text after the tag, OR raw if untagged
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"repo": self.repo,
|
|
45
|
+
"index": self.index,
|
|
46
|
+
"ref": self.ref,
|
|
47
|
+
"message": self.message,
|
|
48
|
+
"feature": self.feature,
|
|
49
|
+
"ts": self.ts,
|
|
50
|
+
"user_message": self.user_message,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _format_tag(feature: str, message: str) -> str:
|
|
55
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
56
|
+
msg = message.strip()
|
|
57
|
+
return f"[canopy {feature} @ {ts}]" + (f" {msg}" if msg else "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_message(raw: str) -> tuple[str | None, str | None, str]:
|
|
61
|
+
"""Extract ``(feature, ts, user_message)`` from a stored stash message.
|
|
62
|
+
|
|
63
|
+
Returns ``(None, None, raw)`` if the message isn't a canopy tag.
|
|
64
|
+
"""
|
|
65
|
+
m = _TAG.search(raw)
|
|
66
|
+
if not m:
|
|
67
|
+
return None, None, raw
|
|
68
|
+
return m.group(1), m.group(2), m.group(3)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_entry(repo: str, raw: dict) -> StashEntry:
|
|
72
|
+
feature, ts, user_message = parse_message(raw.get("message", ""))
|
|
73
|
+
return StashEntry(
|
|
74
|
+
repo=repo,
|
|
75
|
+
index=raw.get("index", 0),
|
|
76
|
+
ref=raw.get("ref", f"stash@{{{raw.get('index', 0)}}}"),
|
|
77
|
+
message=raw.get("message", ""),
|
|
78
|
+
feature=feature,
|
|
79
|
+
ts=ts,
|
|
80
|
+
user_message=user_message,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_for_feature(
|
|
85
|
+
workspace: Workspace,
|
|
86
|
+
feature: str,
|
|
87
|
+
message: str,
|
|
88
|
+
repos: list[str] | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""Stash dirty changes in feature.repos with a feature-tagged message.
|
|
91
|
+
|
|
92
|
+
``repos`` overrides which repos to stash (default: all repos in the
|
|
93
|
+
feature lane). Repos not in the feature lane are silently skipped
|
|
94
|
+
when expanding from the lane; if ``repos`` is passed explicitly,
|
|
95
|
+
unknown names raise.
|
|
96
|
+
"""
|
|
97
|
+
feature_name = resolve_feature(workspace, feature)
|
|
98
|
+
target_repos = _select_repos(workspace, feature_name, repos)
|
|
99
|
+
tagged_message = _format_tag(feature_name, message)
|
|
100
|
+
|
|
101
|
+
raw = git_multi.stash_save_all(
|
|
102
|
+
workspace, tagged_message, target_repos, include_untracked=True,
|
|
103
|
+
)
|
|
104
|
+
return {
|
|
105
|
+
"feature": feature_name,
|
|
106
|
+
"message": tagged_message,
|
|
107
|
+
"repos": raw, # {repo: "stashed" | "clean" | error_str}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def list_grouped(
|
|
112
|
+
workspace: Workspace, feature: str | None = None,
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""List stashes across repos, grouped by feature tag.
|
|
115
|
+
|
|
116
|
+
If ``feature`` is passed, returns only entries matching that feature
|
|
117
|
+
(in ``by_feature[<feature>]``). Otherwise groups all tagged entries
|
|
118
|
+
by feature; unmatched go to ``untagged``.
|
|
119
|
+
"""
|
|
120
|
+
target_feature = resolve_feature(workspace, feature) if feature else None
|
|
121
|
+
raw_per_repo = git_multi.stash_list_all(workspace)
|
|
122
|
+
|
|
123
|
+
by_feature: dict[str, list[dict]] = {}
|
|
124
|
+
untagged: list[dict] = []
|
|
125
|
+
|
|
126
|
+
for repo_name, entries in raw_per_repo.items():
|
|
127
|
+
for raw in entries:
|
|
128
|
+
entry = _parse_entry(repo_name, raw)
|
|
129
|
+
if entry.feature is None:
|
|
130
|
+
if target_feature is not None:
|
|
131
|
+
continue # feature filter applied — skip untagged
|
|
132
|
+
untagged.append(entry.to_dict())
|
|
133
|
+
continue
|
|
134
|
+
if target_feature is not None and entry.feature != target_feature:
|
|
135
|
+
continue
|
|
136
|
+
by_feature.setdefault(entry.feature, []).append(entry.to_dict())
|
|
137
|
+
|
|
138
|
+
return {"by_feature": by_feature, "untagged": untagged}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def pop_feature(
|
|
142
|
+
workspace: Workspace,
|
|
143
|
+
feature: str,
|
|
144
|
+
repos: list[str] | None = None,
|
|
145
|
+
) -> dict[str, Any]:
|
|
146
|
+
"""Pop the most recent tagged stash for a feature in each target repo.
|
|
147
|
+
|
|
148
|
+
Returns per-repo ``{status: 'popped' | 'no_match' | 'failed', ref, message}``.
|
|
149
|
+
Raises ``BlockerError`` if no matching stash exists in any target repo.
|
|
150
|
+
"""
|
|
151
|
+
feature_name = resolve_feature(workspace, feature)
|
|
152
|
+
target_repos = _select_repos(workspace, feature_name, repos)
|
|
153
|
+
|
|
154
|
+
raw_per_repo = git_multi.stash_list_all(workspace)
|
|
155
|
+
results: dict[str, dict] = {}
|
|
156
|
+
any_popped = False
|
|
157
|
+
|
|
158
|
+
for repo_name in target_repos:
|
|
159
|
+
entries_raw = raw_per_repo.get(repo_name, [])
|
|
160
|
+
# Most recent matching = lowest index where feature matches.
|
|
161
|
+
match: StashEntry | None = None
|
|
162
|
+
for raw in entries_raw:
|
|
163
|
+
parsed = _parse_entry(repo_name, raw)
|
|
164
|
+
if parsed.feature == feature_name:
|
|
165
|
+
if match is None or parsed.index < match.index:
|
|
166
|
+
match = parsed
|
|
167
|
+
if match is None:
|
|
168
|
+
results[repo_name] = {"status": "no_match"}
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
state = workspace.get_repo(repo_name)
|
|
172
|
+
git.stash_pop(state.abs_path, match.index)
|
|
173
|
+
results[repo_name] = {
|
|
174
|
+
"status": "popped",
|
|
175
|
+
"ref": match.ref,
|
|
176
|
+
"message": match.user_message,
|
|
177
|
+
"ts": match.ts,
|
|
178
|
+
}
|
|
179
|
+
any_popped = True
|
|
180
|
+
except git.GitError as e:
|
|
181
|
+
results[repo_name] = {
|
|
182
|
+
"status": "failed",
|
|
183
|
+
"ref": match.ref,
|
|
184
|
+
"error": str(e),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if not any_popped:
|
|
188
|
+
raise BlockerError(
|
|
189
|
+
code="no_tagged_stash",
|
|
190
|
+
what=f"no stash tagged with feature '{feature_name}' in any target repo",
|
|
191
|
+
expected={"feature": feature_name, "target_repos": list(target_repos)},
|
|
192
|
+
actual={"per_repo": results},
|
|
193
|
+
fix_actions=[
|
|
194
|
+
FixAction(action="stash list", args={"feature": feature_name},
|
|
195
|
+
safe=True, preview="see what tagged stashes exist"),
|
|
196
|
+
],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return {"feature": feature_name, "repos": results}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _select_repos(
|
|
203
|
+
workspace: Workspace,
|
|
204
|
+
feature_name: str,
|
|
205
|
+
requested: list[str] | None,
|
|
206
|
+
) -> list[str]:
|
|
207
|
+
"""Pick the repo set for a stash op.
|
|
208
|
+
|
|
209
|
+
If ``requested`` is given, validate names. Otherwise default to the
|
|
210
|
+
feature lane's ``repos`` (from features.json) or all workspace repos
|
|
211
|
+
when the feature is implicit / has no explicit lane.
|
|
212
|
+
"""
|
|
213
|
+
all_names = {r.config.name for r in workspace.repos}
|
|
214
|
+
|
|
215
|
+
if requested is not None:
|
|
216
|
+
unknown = [r for r in requested if r not in all_names]
|
|
217
|
+
if unknown:
|
|
218
|
+
raise BlockerError(
|
|
219
|
+
code="unknown_repo",
|
|
220
|
+
what=f"unknown repos: {', '.join(unknown)}",
|
|
221
|
+
expected={"available_repos": sorted(all_names)},
|
|
222
|
+
details={"requested": list(requested)},
|
|
223
|
+
)
|
|
224
|
+
return list(requested)
|
|
225
|
+
|
|
226
|
+
from ..features.coordinator import FeatureCoordinator
|
|
227
|
+
features = FeatureCoordinator(workspace)._load_features()
|
|
228
|
+
feature_data = features.get(feature_name) or {}
|
|
229
|
+
declared = feature_data.get("repos") or sorted(all_names)
|
|
230
|
+
return [r for r in declared if r in all_names]
|