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,83 @@
|
|
|
1
|
+
"""Per-feature last-visit anchor for the feature-resume brief.
|
|
2
|
+
|
|
3
|
+
State file: .canopy/state/visits.json
|
|
4
|
+
Schema: {"<feature>": {"last_visit": "ISO", "previous_visit": "ISO|null"}}
|
|
5
|
+
|
|
6
|
+
Bumped by switch (T13). Read by resume (T6+). Atomic temp+replace writes.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..workspace.workspace import Workspace
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_STATE = ".canopy/state/visits.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _path(workspace: Workspace) -> Path:
|
|
24
|
+
return workspace.config.root / _STATE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load(workspace: Workspace) -> dict[str, dict[str, Any]]:
|
|
28
|
+
p = _path(workspace)
|
|
29
|
+
if not p.exists():
|
|
30
|
+
return {}
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(p.read_text())
|
|
33
|
+
except (OSError, json.JSONDecodeError):
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _save(workspace: Workspace, data: dict) -> None:
|
|
38
|
+
p = _path(workspace)
|
|
39
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
fd, tmp = tempfile.mkstemp(
|
|
41
|
+
prefix=f".{p.name}.", suffix=".tmp", dir=str(p.parent)
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
with os.fdopen(fd, "w") as f:
|
|
45
|
+
json.dump(data, f, indent=2, sort_keys=True)
|
|
46
|
+
os.replace(tmp, p)
|
|
47
|
+
except Exception:
|
|
48
|
+
try:
|
|
49
|
+
os.unlink(tmp)
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
pass
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_last_visit(workspace: Workspace, feature: str) -> dict[str, Any] | None:
|
|
56
|
+
"""Return the visit entry for a feature, or None if not visited."""
|
|
57
|
+
return _load(workspace).get(feature)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def mark_visited(workspace: Workspace, feature: str) -> str:
|
|
61
|
+
"""Bump last_visit to now; carry old value to previous_visit.
|
|
62
|
+
|
|
63
|
+
Returns the new timestamp (ISO 8601 Z format).
|
|
64
|
+
"""
|
|
65
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
66
|
+
data = _load(workspace)
|
|
67
|
+
prev = data.get(feature, {}).get("last_visit")
|
|
68
|
+
data[feature] = {"last_visit": now, "previous_visit": prev}
|
|
69
|
+
_save(workspace, data)
|
|
70
|
+
return now
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def reset_anchor(workspace: Workspace, feature: str) -> bool:
|
|
74
|
+
"""Drop the feature entry from the visits log.
|
|
75
|
+
|
|
76
|
+
Returns True if the entry existed and was removed, False otherwise.
|
|
77
|
+
"""
|
|
78
|
+
data = _load(workspace)
|
|
79
|
+
if feature not in data:
|
|
80
|
+
return False
|
|
81
|
+
del data[feature]
|
|
82
|
+
_save(workspace, data)
|
|
83
|
+
return True
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""One-shot migration: pre-3.0 feature-named worktrees → 3.0 generic slots.
|
|
2
|
+
|
|
3
|
+
Refuses to run if slots.json already exists. Idempotent only in the
|
|
4
|
+
"nothing to do" sense — once migrated, calling again raises.
|
|
5
|
+
|
|
6
|
+
Steps:
|
|
7
|
+
1. Read old active_feature.json (preserve last_touched + canonical).
|
|
8
|
+
2. Scan .canopy/worktrees/<feature>/<repo>/ on disk.
|
|
9
|
+
3. Allocate sequential slot ids (worktree-1, worktree-2, ...).
|
|
10
|
+
4. `git worktree move` each repo dir into its slot.
|
|
11
|
+
5. Rewrite canopy.toml: max_worktrees → slots.
|
|
12
|
+
6. Write slots.json.
|
|
13
|
+
7. Delete active_feature.json.
|
|
14
|
+
8. rmdir the now-empty feature parent dirs.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
if sys.version_info >= (3, 11):
|
|
25
|
+
import tomllib
|
|
26
|
+
else:
|
|
27
|
+
import tomli as tomllib # type: ignore[import-not-found]
|
|
28
|
+
|
|
29
|
+
from ..git import repo as git
|
|
30
|
+
from . import slots as slots_mod
|
|
31
|
+
from .errors import BlockerError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AlreadyMigratedError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotLegacyError(Exception):
|
|
39
|
+
"""No old state and no feature-named worktrees — nothing to migrate."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def migrate(workspace_root: Path) -> dict[str, Any]:
|
|
43
|
+
"""Migrate a pre-3.0 canopy workspace to the 3.0 slot layout.
|
|
44
|
+
|
|
45
|
+
Takes a path (not a Workspace) because the legacy canopy.toml has
|
|
46
|
+
max_worktrees which load_config rejects after T3.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
{moved: [{from, to}, ...], slots: {slot_id: feature}, canonical, slot_count}
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
AlreadyMigratedError: slots.json already exists.
|
|
53
|
+
NotLegacyError: nothing to migrate (no active_feature.json and no feature dirs).
|
|
54
|
+
"""
|
|
55
|
+
root = Path(workspace_root)
|
|
56
|
+
slots_json = root / ".canopy/state/slots.json"
|
|
57
|
+
if slots_json.exists():
|
|
58
|
+
raise AlreadyMigratedError(f"slots.json already exists at {slots_json}")
|
|
59
|
+
|
|
60
|
+
toml_path = root / "canopy.toml"
|
|
61
|
+
if not toml_path.exists():
|
|
62
|
+
raise NotLegacyError(f"no canopy.toml at {toml_path}")
|
|
63
|
+
|
|
64
|
+
# Parse the toml directly (load_config rejects max_worktrees per T3).
|
|
65
|
+
with open(toml_path, "rb") as f:
|
|
66
|
+
toml_data = tomllib.load(f)
|
|
67
|
+
repos_cfg = toml_data.get("repos", [])
|
|
68
|
+
repo_paths_by_name: dict[str, Path] = {}
|
|
69
|
+
for r in repos_cfg:
|
|
70
|
+
name = r.get("name")
|
|
71
|
+
rel_path = r.get("path", name)
|
|
72
|
+
if name:
|
|
73
|
+
repo_paths_by_name[name] = root / rel_path
|
|
74
|
+
|
|
75
|
+
old_active = root / ".canopy/state/active_feature.json"
|
|
76
|
+
wt_base = root / ".canopy/worktrees"
|
|
77
|
+
|
|
78
|
+
if not old_active.exists() and not wt_base.is_dir():
|
|
79
|
+
raise NotLegacyError("no active_feature.json and no .canopy/worktrees/")
|
|
80
|
+
|
|
81
|
+
old: dict[str, Any] = {}
|
|
82
|
+
if old_active.exists():
|
|
83
|
+
try:
|
|
84
|
+
old = json.loads(old_active.read_text())
|
|
85
|
+
except (OSError, json.JSONDecodeError):
|
|
86
|
+
old = {}
|
|
87
|
+
|
|
88
|
+
# 1. Inventory feature-named dirs (skip any already-named worktree-N dirs)
|
|
89
|
+
legacy: dict[str, list[str]] = {} # feature → list of repos
|
|
90
|
+
if wt_base.is_dir():
|
|
91
|
+
for feat_dir in sorted(wt_base.iterdir()):
|
|
92
|
+
if not feat_dir.is_dir():
|
|
93
|
+
continue
|
|
94
|
+
if re.fullmatch(r"worktree-\d+", feat_dir.name):
|
|
95
|
+
raise AlreadyMigratedError(
|
|
96
|
+
f"found slot dir {feat_dir.name} without slots.json"
|
|
97
|
+
)
|
|
98
|
+
repos = sorted(d.name for d in feat_dir.iterdir()
|
|
99
|
+
if d.is_dir() and (d / ".git").exists())
|
|
100
|
+
if repos:
|
|
101
|
+
legacy[feat_dir.name] = repos
|
|
102
|
+
|
|
103
|
+
# 2. Allocate slot ids
|
|
104
|
+
slot_assignment: dict[str, str] = {}
|
|
105
|
+
for i, feature in enumerate(sorted(legacy.keys()), start=1):
|
|
106
|
+
slot_assignment[feature] = f"worktree-{i}"
|
|
107
|
+
|
|
108
|
+
# 3a. Dry-run preflight: validate every move target BEFORE touching disk.
|
|
109
|
+
# Avoids the half-migrated state that wedges the user (some dirs at the
|
|
110
|
+
# new slot path, others at the old feature path, no slots.json yet).
|
|
111
|
+
preflight_issues: list[dict[str, Any]] = []
|
|
112
|
+
for feature, repos in legacy.items():
|
|
113
|
+
slot_id = slot_assignment[feature]
|
|
114
|
+
for repo_name in repos:
|
|
115
|
+
old_path = wt_base / feature / repo_name
|
|
116
|
+
new_path = wt_base / slot_id / repo_name
|
|
117
|
+
main_repo = repo_paths_by_name.get(repo_name)
|
|
118
|
+
if main_repo is None or not main_repo.exists():
|
|
119
|
+
preflight_issues.append({
|
|
120
|
+
"kind": "main_repo_missing", "repo": repo_name,
|
|
121
|
+
"feature": feature, "main_repo": str(main_repo) if main_repo else None,
|
|
122
|
+
})
|
|
123
|
+
continue
|
|
124
|
+
if not old_path.exists():
|
|
125
|
+
preflight_issues.append({
|
|
126
|
+
"kind": "source_missing", "repo": repo_name,
|
|
127
|
+
"feature": feature, "path": str(old_path),
|
|
128
|
+
})
|
|
129
|
+
continue
|
|
130
|
+
if not (old_path / ".git").exists():
|
|
131
|
+
preflight_issues.append({
|
|
132
|
+
"kind": "source_not_a_worktree", "repo": repo_name,
|
|
133
|
+
"feature": feature, "path": str(old_path),
|
|
134
|
+
})
|
|
135
|
+
continue
|
|
136
|
+
if new_path.exists():
|
|
137
|
+
preflight_issues.append({
|
|
138
|
+
"kind": "destination_exists", "repo": repo_name,
|
|
139
|
+
"feature": feature, "path": str(new_path),
|
|
140
|
+
})
|
|
141
|
+
continue
|
|
142
|
+
# Validate the worktree is registered with git (catches locked worktrees).
|
|
143
|
+
try:
|
|
144
|
+
listed = git.worktree_list(main_repo)
|
|
145
|
+
listed_paths = {Path(w.get("path", "")).resolve() for w in listed}
|
|
146
|
+
if old_path.resolve() not in listed_paths:
|
|
147
|
+
preflight_issues.append({
|
|
148
|
+
"kind": "worktree_unregistered", "repo": repo_name,
|
|
149
|
+
"feature": feature, "path": str(old_path),
|
|
150
|
+
})
|
|
151
|
+
except Exception as e: # noqa: BLE001 — surface as a single issue
|
|
152
|
+
preflight_issues.append({
|
|
153
|
+
"kind": "worktree_list_failed", "repo": repo_name,
|
|
154
|
+
"feature": feature, "error": str(e),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
if preflight_issues:
|
|
158
|
+
raise BlockerError(
|
|
159
|
+
code="migration_preflight_failed",
|
|
160
|
+
what=(
|
|
161
|
+
f"{len(preflight_issues)} issue(s) detected before migration could begin — "
|
|
162
|
+
f"refusing to start so the workspace stays in the pre-3.0 layout"
|
|
163
|
+
),
|
|
164
|
+
details={"issues": preflight_issues},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# 3b. Move each repo dir via `git worktree move`. If any move fails
|
|
168
|
+
# mid-loop, attempt to undo the completed ones so the user lands back
|
|
169
|
+
# on the pre-3.0 layout rather than a half-migrated wedge.
|
|
170
|
+
moved: list[dict[str, str]] = []
|
|
171
|
+
try:
|
|
172
|
+
for feature, repos in legacy.items():
|
|
173
|
+
slot_id = slot_assignment[feature]
|
|
174
|
+
(wt_base / slot_id).mkdir(parents=True, exist_ok=True)
|
|
175
|
+
for repo_name in repos:
|
|
176
|
+
old_path = wt_base / feature / repo_name
|
|
177
|
+
new_path = wt_base / slot_id / repo_name
|
|
178
|
+
main_repo = repo_paths_by_name.get(repo_name)
|
|
179
|
+
if main_repo is None or not main_repo.exists():
|
|
180
|
+
continue
|
|
181
|
+
git.worktree_move(main_repo, old_path, new_path)
|
|
182
|
+
moved.append({"from": str(old_path), "to": str(new_path)})
|
|
183
|
+
except Exception as move_err: # noqa: BLE001
|
|
184
|
+
# Best-effort rollback: move each completed entry back to its old path.
|
|
185
|
+
unrolled: list[dict[str, str]] = []
|
|
186
|
+
rollback_failures: list[dict[str, str]] = []
|
|
187
|
+
for m in reversed(moved):
|
|
188
|
+
new_path = Path(m["to"])
|
|
189
|
+
old_path = Path(m["from"])
|
|
190
|
+
# Figure out which repo this was so we can address the main repo.
|
|
191
|
+
repo_name = new_path.name
|
|
192
|
+
main_repo = repo_paths_by_name.get(repo_name)
|
|
193
|
+
if main_repo is None:
|
|
194
|
+
rollback_failures.append({**m, "error": "no main repo"})
|
|
195
|
+
continue
|
|
196
|
+
try:
|
|
197
|
+
old_path.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
git.worktree_move(main_repo, new_path, old_path)
|
|
199
|
+
unrolled.append({"from": str(new_path), "to": str(old_path)})
|
|
200
|
+
except Exception as e: # noqa: BLE001
|
|
201
|
+
rollback_failures.append({**m, "error": str(e)})
|
|
202
|
+
# Best-effort cleanup of empty slot dirs we created during the failed pass.
|
|
203
|
+
for feature in legacy:
|
|
204
|
+
slot_id = slot_assignment[feature]
|
|
205
|
+
slot_dir = wt_base / slot_id
|
|
206
|
+
try:
|
|
207
|
+
if slot_dir.exists() and not any(slot_dir.iterdir()):
|
|
208
|
+
slot_dir.rmdir()
|
|
209
|
+
except OSError:
|
|
210
|
+
pass
|
|
211
|
+
if rollback_failures:
|
|
212
|
+
raise BlockerError(
|
|
213
|
+
code="migration_partial",
|
|
214
|
+
what=(
|
|
215
|
+
"migration failed mid-loop AND rollback could not return"
|
|
216
|
+
" every dir to its pre-3.0 location — manual cleanup required"
|
|
217
|
+
),
|
|
218
|
+
details={
|
|
219
|
+
"underlying_error": str(move_err),
|
|
220
|
+
"moved_dirs": moved,
|
|
221
|
+
"unrolled_dirs": unrolled,
|
|
222
|
+
"rollback_failures": rollback_failures,
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
raise BlockerError(
|
|
226
|
+
code="migration_aborted",
|
|
227
|
+
what=(
|
|
228
|
+
"migration failed mid-loop; reverted to pre-3.0 layout — "
|
|
229
|
+
"re-run after resolving the underlying error"
|
|
230
|
+
),
|
|
231
|
+
details={
|
|
232
|
+
"underlying_error": str(move_err),
|
|
233
|
+
"rolled_back_dirs": unrolled,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# 4. Rewrite canopy.toml: max_worktrees → slots
|
|
238
|
+
text = toml_path.read_text()
|
|
239
|
+
new_text, n = re.subn(
|
|
240
|
+
r"(?m)^(\s*)max_worktrees(\s*=\s*\d+)\s*$",
|
|
241
|
+
r"\1slots\2",
|
|
242
|
+
text,
|
|
243
|
+
)
|
|
244
|
+
if n == 0 and not re.search(r"(?m)^\s*slots\s*=", text):
|
|
245
|
+
# Insert default `slots = 2` under [workspace]
|
|
246
|
+
new_text = re.sub(
|
|
247
|
+
r"(?m)^(\[workspace\][^\n]*\n(?:[^\[\n][^\n]*\n)*)",
|
|
248
|
+
r"\1slots = 2\n",
|
|
249
|
+
text, count=1,
|
|
250
|
+
)
|
|
251
|
+
toml_path.write_text(new_text)
|
|
252
|
+
|
|
253
|
+
# 5. Build slots.json
|
|
254
|
+
canonical_feature = old.get("feature")
|
|
255
|
+
canonical: slots_mod.CanonicalEntry | None = None
|
|
256
|
+
if canonical_feature:
|
|
257
|
+
per_repo = old.get("per_repo_paths") or {}
|
|
258
|
+
if isinstance(per_repo, dict) and all(Path(p).exists() for p in per_repo.values()):
|
|
259
|
+
canonical = slots_mod.CanonicalEntry(
|
|
260
|
+
feature=canonical_feature,
|
|
261
|
+
activated_at=old.get("activated_at", slots_mod.now_iso()),
|
|
262
|
+
per_repo_paths=dict(per_repo),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
slot_entries = {
|
|
266
|
+
slot_assignment[feat]: slots_mod.SlotEntry(
|
|
267
|
+
feature=feat, occupied_at=slots_mod.now_iso(),
|
|
268
|
+
)
|
|
269
|
+
for feat in legacy
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
last_touched = {
|
|
273
|
+
str(k): str(v) for k, v in (old.get("last_touched") or {}).items()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
# Re-parse the rewritten toml for slot_count
|
|
277
|
+
with open(toml_path, "rb") as f:
|
|
278
|
+
new_toml_data = tomllib.load(f)
|
|
279
|
+
slot_count = int(new_toml_data.get("workspace", {}).get("slots", 2))
|
|
280
|
+
|
|
281
|
+
state = slots_mod.SlotState(
|
|
282
|
+
slot_count=slot_count,
|
|
283
|
+
canonical=canonical,
|
|
284
|
+
previous_canonical=old.get("previous_feature"),
|
|
285
|
+
slots=slot_entries,
|
|
286
|
+
last_touched=last_touched,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Write slots.json directly (write_state requires a Workspace object)
|
|
290
|
+
state_path = root / ".canopy/state/slots.json"
|
|
291
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
tmp = state_path.with_suffix(".json.tmp")
|
|
293
|
+
tmp.write_text(json.dumps(state.to_dict(), indent=2))
|
|
294
|
+
tmp.replace(state_path)
|
|
295
|
+
|
|
296
|
+
# 6. Delete active_feature.json
|
|
297
|
+
if old_active.exists():
|
|
298
|
+
old_active.unlink()
|
|
299
|
+
|
|
300
|
+
# 7. rmdir the now-empty feature parent dirs
|
|
301
|
+
for feature in legacy:
|
|
302
|
+
old_dir = wt_base / feature
|
|
303
|
+
try:
|
|
304
|
+
old_dir.rmdir()
|
|
305
|
+
except OSError:
|
|
306
|
+
pass # not empty — leave for the user to clean up
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
"moved": moved,
|
|
310
|
+
"slots": {sid: e.feature for sid, e in slot_entries.items()},
|
|
311
|
+
"canonical": canonical_feature,
|
|
312
|
+
"slot_count": slot_count,
|
|
313
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Persist preflight results so feature_state can tell IN_PROGRESS from READY_TO_COMMIT.
|
|
2
|
+
|
|
3
|
+
State file: ``<workspace_root>/.canopy/state/preflight.json``
|
|
4
|
+
|
|
5
|
+
Schema (one entry per feature)::
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"<feature_name>": {
|
|
9
|
+
"passed": bool,
|
|
10
|
+
"ran_at": "ISO 8601",
|
|
11
|
+
"head_sha_per_repo": {"<repo>": "sha"},
|
|
12
|
+
"all_passed": bool,
|
|
13
|
+
"summary": "..."
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
A preflight result is "fresh" for a repo when the recorded HEAD sha
|
|
18
|
+
equals the repo's current HEAD. If any repo's HEAD has moved since the
|
|
19
|
+
recorded run, the preflight is stale (state machine treats it as
|
|
20
|
+
"not run").
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ..git import repo as git
|
|
30
|
+
from ..workspace.workspace import Workspace
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _state_file(workspace_root: Path) -> Path:
|
|
34
|
+
return workspace_root / ".canopy" / "state" / "preflight.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_state(workspace_root: Path) -> dict[str, Any]:
|
|
38
|
+
"""Return ``{<feature>: {passed, ran_at, head_sha_per_repo, ...}}``."""
|
|
39
|
+
path = _state_file(workspace_root)
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return {}
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(path.read_text())
|
|
44
|
+
except (OSError, json.JSONDecodeError):
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def record_result(
|
|
49
|
+
workspace_root: Path,
|
|
50
|
+
feature: str,
|
|
51
|
+
*,
|
|
52
|
+
passed: bool,
|
|
53
|
+
head_sha_per_repo: dict[str, str],
|
|
54
|
+
summary: str = "",
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Persist a preflight outcome for a feature."""
|
|
57
|
+
path = _state_file(workspace_root)
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
state = read_state(workspace_root)
|
|
60
|
+
state[feature] = {
|
|
61
|
+
"passed": passed,
|
|
62
|
+
"ran_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
63
|
+
"head_sha_per_repo": dict(head_sha_per_repo),
|
|
64
|
+
"summary": summary,
|
|
65
|
+
}
|
|
66
|
+
tmp = path.with_suffix(".json.tmp")
|
|
67
|
+
tmp.write_text(json.dumps(state, indent=2))
|
|
68
|
+
tmp.replace(path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_fresh(
|
|
72
|
+
workspace: Workspace,
|
|
73
|
+
feature: str,
|
|
74
|
+
repo_branches: dict[str, str],
|
|
75
|
+
) -> tuple[bool, dict[str, Any] | None]:
|
|
76
|
+
"""True if the recorded preflight is still valid for the current HEAD per repo.
|
|
77
|
+
|
|
78
|
+
Returns ``(fresh, entry)``:
|
|
79
|
+
- fresh=True only when the entry exists AND each repo's recorded
|
|
80
|
+
sha equals current HEAD sha for the expected branch.
|
|
81
|
+
- entry is the recorded dict (None if no entry).
|
|
82
|
+
"""
|
|
83
|
+
state = read_state(workspace.config.root)
|
|
84
|
+
entry = state.get(feature)
|
|
85
|
+
if not entry:
|
|
86
|
+
return False, None
|
|
87
|
+
|
|
88
|
+
recorded = entry.get("head_sha_per_repo") or {}
|
|
89
|
+
for repo_name, branch in repo_branches.items():
|
|
90
|
+
try:
|
|
91
|
+
state_obj = workspace.get_repo(repo_name)
|
|
92
|
+
except KeyError:
|
|
93
|
+
return False, entry
|
|
94
|
+
current = git.sha_of(state_obj.abs_path, branch)
|
|
95
|
+
if not current or recorded.get(repo_name) != current:
|
|
96
|
+
return False, entry
|
|
97
|
+
return True, entry
|
canopy/actions/push.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""push — feature-scoped multi-repo push.
|
|
2
|
+
|
|
3
|
+
Pushes the feature's branch in each in-scope repo to ``origin``. Like
|
|
4
|
+
``commit``, defaults to the canonical feature when no ``--feature`` is
|
|
5
|
+
given. ``--dry-run`` enumerates what would happen without firing pushes.
|
|
6
|
+
|
|
7
|
+
Pre-flight (raises ``BlockerError`` before any push fires):
|
|
8
|
+
- ``no_canonical_feature`` — no active feature and no explicit one.
|
|
9
|
+
- ``empty_feature`` — feature has no associated repos.
|
|
10
|
+
- ``no_upstream`` — at least one in-scope repo lacks a configured
|
|
11
|
+
upstream and ``set_upstream`` was not passed. The fix-action
|
|
12
|
+
carries the same call arguments + ``set_upstream=True`` so the
|
|
13
|
+
agent can retry mechanically.
|
|
14
|
+
|
|
15
|
+
Per-repo recipe::
|
|
16
|
+
|
|
17
|
+
1. read upstream + unpushed_count for the feature branch
|
|
18
|
+
2. branch ahead of upstream → `git push` → ok / rejected / failed
|
|
19
|
+
3. branch up-to-date → status: "up_to_date"
|
|
20
|
+
4. branch lacks upstream + set_upstream → push --set-upstream
|
|
21
|
+
5. rejected (non-fast-forward) without force_with_lease → status:
|
|
22
|
+
"rejected" + reason; do NOT auto-force.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ..git import repo as git
|
|
30
|
+
from ..workspace.workspace import Workspace
|
|
31
|
+
from . import slots as slots_mod
|
|
32
|
+
from .aliases import repos_for_feature, resolve_feature
|
|
33
|
+
from .errors import BlockerError, FixAction
|
|
34
|
+
from .feature_state import resolve_repo_paths
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_feature_name(
|
|
38
|
+
workspace: Workspace, feature: str | None,
|
|
39
|
+
) -> str:
|
|
40
|
+
if feature:
|
|
41
|
+
return resolve_feature(workspace, feature)
|
|
42
|
+
state = slots_mod.read_state(workspace)
|
|
43
|
+
if state is None or state.canonical is None:
|
|
44
|
+
raise BlockerError(
|
|
45
|
+
code="no_canonical_feature",
|
|
46
|
+
what="no active feature; pass --feature or run `canopy switch <name>` first",
|
|
47
|
+
fix_actions=[
|
|
48
|
+
FixAction(action="switch", args={}, safe=False,
|
|
49
|
+
preview="canopy switch <feature> sets the canonical slot"),
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
return state.canonical.feature
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _check_upstream(
|
|
56
|
+
repo_paths: dict[str, Path],
|
|
57
|
+
repo_branches: dict[str, str],
|
|
58
|
+
set_upstream: bool,
|
|
59
|
+
feature_name: str,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Raise no_upstream BlockerError if any repo lacks upstream + set_upstream not given."""
|
|
62
|
+
if set_upstream:
|
|
63
|
+
return
|
|
64
|
+
missing: dict[str, str] = {}
|
|
65
|
+
for repo_name, branch in repo_branches.items():
|
|
66
|
+
path = repo_paths.get(repo_name)
|
|
67
|
+
if path is None:
|
|
68
|
+
continue
|
|
69
|
+
if not git.has_upstream(path, branch):
|
|
70
|
+
missing[repo_name] = branch
|
|
71
|
+
if not missing:
|
|
72
|
+
return
|
|
73
|
+
raise BlockerError(
|
|
74
|
+
code="no_upstream",
|
|
75
|
+
what=(
|
|
76
|
+
f"{len(missing)} repo(s) have no upstream for the feature branch — "
|
|
77
|
+
"rerun with --set-upstream to publish"
|
|
78
|
+
),
|
|
79
|
+
details={"per_repo": missing},
|
|
80
|
+
fix_actions=[
|
|
81
|
+
FixAction(
|
|
82
|
+
action="push",
|
|
83
|
+
args={"feature": feature_name, "set_upstream": True},
|
|
84
|
+
safe=False,
|
|
85
|
+
preview="canopy push --set-upstream publishes the branches",
|
|
86
|
+
),
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _push_one(
|
|
92
|
+
repo_path: Path,
|
|
93
|
+
branch: str,
|
|
94
|
+
*,
|
|
95
|
+
set_upstream: bool,
|
|
96
|
+
force_with_lease: bool,
|
|
97
|
+
dry_run: bool,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
"""Push one repo. Returns a per-repo result dict."""
|
|
100
|
+
has_up = git.has_upstream(repo_path, branch)
|
|
101
|
+
|
|
102
|
+
# Up-to-date short-circuit only meaningful when upstream exists.
|
|
103
|
+
if has_up and not set_upstream:
|
|
104
|
+
unpushed = git.unpushed_count(repo_path, branch)
|
|
105
|
+
if unpushed == 0:
|
|
106
|
+
ref = git.upstream_ref(repo_path, branch)
|
|
107
|
+
return {
|
|
108
|
+
"status": "up_to_date",
|
|
109
|
+
"ref": ref,
|
|
110
|
+
"pushed_count": 0,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if dry_run:
|
|
114
|
+
# dry-run still calls git push --dry-run; trust the primitive.
|
|
115
|
+
return git.push(
|
|
116
|
+
repo_path,
|
|
117
|
+
branch=branch,
|
|
118
|
+
set_upstream=set_upstream and not has_up,
|
|
119
|
+
force_with_lease=force_with_lease,
|
|
120
|
+
dry_run=True,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return git.push(
|
|
124
|
+
repo_path,
|
|
125
|
+
branch=branch,
|
|
126
|
+
set_upstream=set_upstream and not has_up,
|
|
127
|
+
force_with_lease=force_with_lease,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def push(
|
|
132
|
+
workspace: Workspace,
|
|
133
|
+
*,
|
|
134
|
+
feature: str | None = None,
|
|
135
|
+
repos: list[str] | None = None,
|
|
136
|
+
set_upstream: bool = False,
|
|
137
|
+
force_with_lease: bool = False,
|
|
138
|
+
dry_run: bool = False,
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""Push the feature branch across every repo in the lane.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
workspace: the workspace.
|
|
144
|
+
feature: feature alias. If None, falls back to the canonical
|
|
145
|
+
feature in ``slots.json``.
|
|
146
|
+
repos: optional filter — only push these repos within the
|
|
147
|
+
feature scope.
|
|
148
|
+
set_upstream: pass ``--set-upstream`` for repos that lack an
|
|
149
|
+
upstream; without this, missing-upstream raises a
|
|
150
|
+
``BlockerError(code='no_upstream')`` pre-flight.
|
|
151
|
+
force_with_lease: pass ``--force-with-lease`` so non-fast-forward
|
|
152
|
+
pushes succeed when the local upstream cache matches the
|
|
153
|
+
remote (preserves "did anyone push behind my back?" check).
|
|
154
|
+
dry_run: enumerate what would happen without firing pushes.
|
|
155
|
+
|
|
156
|
+
Returns ``{feature, results: {<repo>: {...}}}``. Per-repo dict shape::
|
|
157
|
+
|
|
158
|
+
{status, pushed_count?, ref?, set_upstream?, reason?, dry_run?}
|
|
159
|
+
|
|
160
|
+
where ``status`` is one of
|
|
161
|
+
``ok | up_to_date | rejected | failed``.
|
|
162
|
+
"""
|
|
163
|
+
feature_name = _resolve_feature_name(workspace, feature)
|
|
164
|
+
repo_branches = repos_for_feature(workspace, feature_name)
|
|
165
|
+
if not repo_branches:
|
|
166
|
+
raise BlockerError(
|
|
167
|
+
code="empty_feature",
|
|
168
|
+
what=f"feature '{feature_name}' has no associated repos",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if repos:
|
|
172
|
+
repo_branches = {
|
|
173
|
+
r: b for r, b in repo_branches.items() if r in set(repos)
|
|
174
|
+
}
|
|
175
|
+
if not repo_branches:
|
|
176
|
+
raise BlockerError(
|
|
177
|
+
code="repos_filter_empty",
|
|
178
|
+
what=f"none of {sorted(repos)} are in feature '{feature_name}'",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
repo_paths, _has_wt = resolve_repo_paths(workspace, feature_name, repo_branches)
|
|
182
|
+
|
|
183
|
+
_check_upstream(repo_paths, repo_branches, set_upstream, feature_name)
|
|
184
|
+
|
|
185
|
+
results: dict[str, dict[str, Any]] = {}
|
|
186
|
+
for repo_name, branch in repo_branches.items():
|
|
187
|
+
path = repo_paths.get(repo_name)
|
|
188
|
+
if path is None:
|
|
189
|
+
results[repo_name] = {"status": "failed", "reason": "repo path unresolved"}
|
|
190
|
+
continue
|
|
191
|
+
results[repo_name] = _push_one(
|
|
192
|
+
path,
|
|
193
|
+
branch,
|
|
194
|
+
set_upstream=set_upstream,
|
|
195
|
+
force_with_lease=force_with_lease,
|
|
196
|
+
dry_run=dry_run,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return {"feature": feature_name, "results": results}
|