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,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}