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,192 @@
|
|
|
1
|
+
"""Per-repo evacuate primitive — moves the currently-canonical feature
|
|
2
|
+
into a warm slot worktree so a different feature can take its slot in main.
|
|
3
|
+
|
|
4
|
+
This is the engine ``switch`` calls in active-rotation mode. Per repo, the
|
|
5
|
+
recipe is:
|
|
6
|
+
|
|
7
|
+
1. ``git stash push --include-untracked`` (no-op if clean)
|
|
8
|
+
2. ``git checkout target_branch`` in main (caller passes target)
|
|
9
|
+
3. ``git worktree add slot_path feature``
|
|
10
|
+
4. ``git stash pop`` inside the new slot
|
|
11
|
+
|
|
12
|
+
``fastpath_swap_repo`` is the 5-op active-rotation fast path: Y is already
|
|
13
|
+
warm in a slot; X is canonical in main → swap them without a full cold
|
|
14
|
+
round-trip.
|
|
15
|
+
|
|
16
|
+
1. stash X dirty
|
|
17
|
+
2. checkout default_branch in main (frees X)
|
|
18
|
+
3. checkout x_feature in slot_id worktree (slot adopts X)
|
|
19
|
+
4. checkout y_feature in main (main adopts Y)
|
|
20
|
+
5. pop stash inside slot_id (X's dirty work)
|
|
21
|
+
|
|
22
|
+
Wind-down mode does NOT call this module; it uses the simpler
|
|
23
|
+
``stash + checkout`` path with no worktree-add (X goes cold).
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from ..git import repo as git
|
|
32
|
+
from ..workspace.workspace import Workspace
|
|
33
|
+
from .errors import BlockerError
|
|
34
|
+
from . import slots as slots_mod
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def stash_for_evacuation(
|
|
38
|
+
workspace: Workspace, feature: str, repo: str, repo_path: Path,
|
|
39
|
+
) -> str | None:
|
|
40
|
+
"""Stash the repo's dirty state with the canopy feature tag.
|
|
41
|
+
|
|
42
|
+
Returns the stash ref if a stash was created, None if the tree was
|
|
43
|
+
already clean. Tag format matches P12: ``[canopy <feature> @ <ts>] <msg>``
|
|
44
|
+
so the stash can be popped automatically when the feature is warmed
|
|
45
|
+
again later.
|
|
46
|
+
"""
|
|
47
|
+
if not git.is_dirty(repo_path):
|
|
48
|
+
return None
|
|
49
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
50
|
+
msg = f"[canopy {feature} @ {ts}] auto-evacuated from main"
|
|
51
|
+
git.stash_save(repo_path, msg, include_untracked=True)
|
|
52
|
+
return _latest_stash_ref(repo_path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def pop_into_worktree(
|
|
56
|
+
worktree_path: Path, has_stash: bool,
|
|
57
|
+
) -> bool:
|
|
58
|
+
"""Pop the latest stash (index 0) inside the new worktree.
|
|
59
|
+
|
|
60
|
+
No-op when ``has_stash=False``. Index 0 is correct here because
|
|
61
|
+
nothing between ``stash_for_evacuation`` and this call creates a
|
|
62
|
+
new stash, so the one we just pushed is still on top.
|
|
63
|
+
"""
|
|
64
|
+
if not has_stash:
|
|
65
|
+
return False
|
|
66
|
+
git.stash_pop(worktree_path, 0)
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def evacuate_repo(
|
|
71
|
+
workspace: Workspace,
|
|
72
|
+
feature_being_evacuated: str,
|
|
73
|
+
repo_name: str,
|
|
74
|
+
repo_path: Path,
|
|
75
|
+
*,
|
|
76
|
+
slot_id: str,
|
|
77
|
+
target_branch: str,
|
|
78
|
+
target_branch_checkout: bool = True,
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
"""Move ``feature_being_evacuated`` (currently in main) into ``slot_id``.
|
|
81
|
+
|
|
82
|
+
Per-repo recipe (cold-Y / first-time evacuation):
|
|
83
|
+
1. stash X dirty
|
|
84
|
+
2. checkout target_branch in main
|
|
85
|
+
3. ``git worktree add slot_path X``
|
|
86
|
+
4. pop into the new slot
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
feature_being_evacuated: the feature name losing its canonical slot
|
|
90
|
+
(its branch will be moved into slot_id).
|
|
91
|
+
repo_name: repo identifier in canopy config.
|
|
92
|
+
repo_path: absolute path of the repo's main checkout.
|
|
93
|
+
slot_id: the slot identifier to place the evacuated feature in
|
|
94
|
+
(e.g. ``"worktree-1"``).
|
|
95
|
+
target_branch: branch to leave checked out in main after evacuation.
|
|
96
|
+
target_branch_checkout: if False, skip the ``git checkout target_branch``
|
|
97
|
+
step (caller has already done it). Default True.
|
|
98
|
+
|
|
99
|
+
Returns ``{repo, status, stashed, stash_ref, worktree_path, slot_id,
|
|
100
|
+
target_branch, popped}``. Raises ``BlockerError`` on failure.
|
|
101
|
+
"""
|
|
102
|
+
stash_ref = stash_for_evacuation(
|
|
103
|
+
workspace, feature_being_evacuated, repo_name, repo_path,
|
|
104
|
+
)
|
|
105
|
+
if target_branch_checkout:
|
|
106
|
+
git.checkout(repo_path, target_branch)
|
|
107
|
+
dest = slots_mod.slot_worktree_path(workspace, slot_id, repo_name)
|
|
108
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
if dest.exists():
|
|
110
|
+
raise BlockerError(
|
|
111
|
+
code="slot_worktree_path_occupied",
|
|
112
|
+
what=f"path already exists: {dest}",
|
|
113
|
+
details={
|
|
114
|
+
"feature": feature_being_evacuated,
|
|
115
|
+
"repo": repo_name,
|
|
116
|
+
"slot": slot_id,
|
|
117
|
+
"path": str(dest),
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
git.worktree_add(repo_path, dest, feature_being_evacuated, create_branch=False)
|
|
121
|
+
popped = pop_into_worktree(dest.resolve(), has_stash=stash_ref is not None)
|
|
122
|
+
return {
|
|
123
|
+
"repo": repo_name,
|
|
124
|
+
"status": "evacuated",
|
|
125
|
+
"stashed": stash_ref is not None,
|
|
126
|
+
"stash_ref": stash_ref,
|
|
127
|
+
"worktree_path": str(dest.resolve()),
|
|
128
|
+
"slot_id": slot_id,
|
|
129
|
+
"target_branch": target_branch,
|
|
130
|
+
"popped": popped,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def fastpath_swap_repo(
|
|
135
|
+
workspace: Workspace,
|
|
136
|
+
*,
|
|
137
|
+
x_feature: str,
|
|
138
|
+
y_feature: str,
|
|
139
|
+
repo_name: str,
|
|
140
|
+
repo_path: Path,
|
|
141
|
+
slot_id: str,
|
|
142
|
+
default_branch: str,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""5-op fast-path: Y warm in slot_id, X canonical → swap them.
|
|
145
|
+
|
|
146
|
+
Steps:
|
|
147
|
+
1. stash X dirty (tagged with X)
|
|
148
|
+
2. checkout default_branch in main (frees X)
|
|
149
|
+
3. checkout x_feature in slot_id wt (slot adopts X)
|
|
150
|
+
4. checkout y_feature in main (main adopts Y)
|
|
151
|
+
5. pop stash inside slot_id (X's dirty work)
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
x_feature: the feature currently canonical in main (being evacuated).
|
|
155
|
+
y_feature: the feature currently warm in slot_id (being promoted).
|
|
156
|
+
repo_name: repo identifier in canopy config.
|
|
157
|
+
repo_path: absolute path of the repo's main checkout.
|
|
158
|
+
slot_id: slot currently holding Y; after swap, holds X.
|
|
159
|
+
default_branch: neutral branch used as a stepping stone to free X.
|
|
160
|
+
|
|
161
|
+
Returns ``{repo, status, stashed, stash_ref, worktree_path, slot_id,
|
|
162
|
+
swapped_in, swapped_out, popped}``.
|
|
163
|
+
"""
|
|
164
|
+
slot_path = slots_mod.slot_worktree_path(workspace, slot_id, repo_name)
|
|
165
|
+
if not (slot_path / ".git").exists():
|
|
166
|
+
raise BlockerError(
|
|
167
|
+
code="fastpath_slot_missing",
|
|
168
|
+
what=f"slot {slot_id} has no worktree at {slot_path}",
|
|
169
|
+
details={"slot": slot_id, "expected_path": str(slot_path)},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
stash_ref = stash_for_evacuation(workspace, x_feature, repo_name, repo_path)
|
|
173
|
+
git.checkout(repo_path, default_branch)
|
|
174
|
+
git.checkout(slot_path, x_feature)
|
|
175
|
+
git.checkout(repo_path, y_feature)
|
|
176
|
+
popped = pop_into_worktree(slot_path, has_stash=stash_ref is not None)
|
|
177
|
+
return {
|
|
178
|
+
"repo": repo_name,
|
|
179
|
+
"status": "fastpath_swapped",
|
|
180
|
+
"stashed": stash_ref is not None,
|
|
181
|
+
"stash_ref": stash_ref,
|
|
182
|
+
"worktree_path": str(slot_path.resolve()),
|
|
183
|
+
"slot_id": slot_id,
|
|
184
|
+
"swapped_in": y_feature,
|
|
185
|
+
"swapped_out": x_feature,
|
|
186
|
+
"popped": popped,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _latest_stash_ref(repo_path: Path) -> str:
|
|
191
|
+
"""Return ``stash@{0}`` after a fresh stash push (always position 0)."""
|
|
192
|
+
return "stash@{0}"
|