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,314 @@
|
|
|
1
|
+
"""``canopy conflicts`` — cross-feature file-overlap detection.
|
|
2
|
+
|
|
3
|
+
Pairwise intersects each active feature's changed-file set per repo. Flags
|
|
4
|
+
pairs that touch the same file (and, when we can read it cheaply, the
|
|
5
|
+
same line ranges) so the user can rebase proactively instead of discovering
|
|
6
|
+
the conflict at PR-merge time.
|
|
7
|
+
|
|
8
|
+
V1 ships file-level severity by default — same file in both features ==
|
|
9
|
+
``high`` because the rebase will produce a textual conflict marker even if
|
|
10
|
+
the diffs would have auto-merged. The ``--lines`` flag opts into the more
|
|
11
|
+
expensive line-range comparison and downgrades to ``medium`` when the
|
|
12
|
+
files overlap but the actual line ranges don't intersect.
|
|
13
|
+
|
|
14
|
+
Read-only — no canopy state files are touched.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from itertools import combinations
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Iterable
|
|
22
|
+
|
|
23
|
+
from ..git import repo as git
|
|
24
|
+
from ..git.multi import cross_repo_diff
|
|
25
|
+
from ..workspace.workspace import Workspace
|
|
26
|
+
from . import slots as slots_mod
|
|
27
|
+
from .aliases import resolve_feature
|
|
28
|
+
|
|
29
|
+
_GENERATED_HINT = re.compile(
|
|
30
|
+
r"(?:^|/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|"
|
|
31
|
+
r"poetry\.lock|uv\.lock|Pipfile\.lock|composer\.lock|Gemfile\.lock)$"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_conflicts(
|
|
36
|
+
workspace: Workspace,
|
|
37
|
+
*,
|
|
38
|
+
feature: str | None = None,
|
|
39
|
+
other: str | None = None,
|
|
40
|
+
include_cold: bool = False,
|
|
41
|
+
line_level: bool = False,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
"""Compute pairwise file-overlap across active features.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
workspace: loaded workspace.
|
|
47
|
+
feature: scope to "what overlaps with this feature." When set,
|
|
48
|
+
the result only includes pairs where ``feature`` is one side.
|
|
49
|
+
other: further scope to "specifically <feature> vs <other>."
|
|
50
|
+
Requires ``feature``.
|
|
51
|
+
include_cold: also consider cold features (no worktree). Default
|
|
52
|
+
keeps the focus on active rotation since cold features are
|
|
53
|
+
less likely to merge soon.
|
|
54
|
+
line_level: when True, compute per-file line ranges to differentiate
|
|
55
|
+
``high`` (lines overlap) from ``medium`` (same file, disjoint
|
|
56
|
+
lines). When False (default), any shared file is ``high``.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
``{features: [<scanned>], pairs: [<ConflictPair>], generated_at}``.
|
|
60
|
+
Pairs are sorted high → low severity.
|
|
61
|
+
"""
|
|
62
|
+
feature_names = _enumerate_features(workspace, include_cold=include_cold)
|
|
63
|
+
if feature is not None:
|
|
64
|
+
scoped = resolve_feature(workspace, feature)
|
|
65
|
+
if scoped not in feature_names:
|
|
66
|
+
feature_names.append(scoped)
|
|
67
|
+
feature_names = [scoped] + [f for f in feature_names if f != scoped]
|
|
68
|
+
if other is not None:
|
|
69
|
+
other_resolved = resolve_feature(workspace, other)
|
|
70
|
+
feature_names = [scoped, other_resolved]
|
|
71
|
+
|
|
72
|
+
diffs: dict[str, dict[str, dict[str, Any]]] = {}
|
|
73
|
+
for name in feature_names:
|
|
74
|
+
diffs[name] = cross_repo_diff(workspace, name)
|
|
75
|
+
|
|
76
|
+
pairs: list[dict[str, Any]] = []
|
|
77
|
+
iterator: Iterable[tuple[str, str]]
|
|
78
|
+
if feature is not None:
|
|
79
|
+
scoped = feature_names[0]
|
|
80
|
+
iterator = ((scoped, b) for b in feature_names if b != scoped)
|
|
81
|
+
else:
|
|
82
|
+
iterator = combinations(feature_names, 2)
|
|
83
|
+
|
|
84
|
+
for a, b in iterator:
|
|
85
|
+
overlap = compute_overlap(diffs[a], diffs[b], workspace=workspace,
|
|
86
|
+
feature_a=a, feature_b=b,
|
|
87
|
+
line_level=line_level)
|
|
88
|
+
if not _has_overlap(overlap):
|
|
89
|
+
continue
|
|
90
|
+
severity, suggestion = classify(overlap, a, b)
|
|
91
|
+
pairs.append({
|
|
92
|
+
"feature_a": a,
|
|
93
|
+
"feature_b": b,
|
|
94
|
+
"overlap": overlap,
|
|
95
|
+
"severity": severity,
|
|
96
|
+
"suggestion": suggestion,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
pairs.sort(key=lambda p: _SEVERITY_ORDER[p["severity"]])
|
|
100
|
+
return {
|
|
101
|
+
"features": feature_names,
|
|
102
|
+
"pairs": pairs,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _enumerate_features(workspace: Workspace, *, include_cold: bool) -> list[str]:
|
|
110
|
+
"""Return canonical + warm features, plus cold ones when requested."""
|
|
111
|
+
state = slots_mod.read_state(workspace)
|
|
112
|
+
canonical_feature: str | None = (
|
|
113
|
+
state.canonical.feature if state and state.canonical else None
|
|
114
|
+
)
|
|
115
|
+
warm_features: set[str] = {
|
|
116
|
+
e.feature for e in (state.slots.values() if state else [])
|
|
117
|
+
}
|
|
118
|
+
names: list[str] = []
|
|
119
|
+
if canonical_feature:
|
|
120
|
+
names.append(canonical_feature)
|
|
121
|
+
|
|
122
|
+
# Walk features.json directly for the full roster — keeps the
|
|
123
|
+
# implementation independent of whichever helper is in vogue.
|
|
124
|
+
features_path = workspace.config.root / ".canopy" / "features.json"
|
|
125
|
+
if features_path.exists():
|
|
126
|
+
import json
|
|
127
|
+
try:
|
|
128
|
+
data = json.loads(features_path.read_text("utf-8"))
|
|
129
|
+
except (OSError, ValueError):
|
|
130
|
+
data = {}
|
|
131
|
+
else:
|
|
132
|
+
data = {}
|
|
133
|
+
|
|
134
|
+
for name, entry in data.items():
|
|
135
|
+
if name in names:
|
|
136
|
+
continue
|
|
137
|
+
if entry.get("status") and entry["status"] != "active":
|
|
138
|
+
continue
|
|
139
|
+
# A feature is "warm" if it occupies a slot OR if features.json
|
|
140
|
+
# records worktree_paths for it (the slot map may be stale, and
|
|
141
|
+
# explicit worktree_paths still mean the feature has a working tree).
|
|
142
|
+
has_worktree = name in warm_features or bool(entry.get("worktree_paths"))
|
|
143
|
+
if not include_cold and not has_worktree and name != canonical_feature:
|
|
144
|
+
continue
|
|
145
|
+
names.append(name)
|
|
146
|
+
return names
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def compute_overlap(
|
|
150
|
+
diff_a: dict[str, dict[str, Any]],
|
|
151
|
+
diff_b: dict[str, dict[str, Any]],
|
|
152
|
+
*,
|
|
153
|
+
workspace: Workspace | None = None,
|
|
154
|
+
feature_a: str | None = None,
|
|
155
|
+
feature_b: str | None = None,
|
|
156
|
+
line_level: bool = False,
|
|
157
|
+
) -> dict[str, dict[str, Any]]:
|
|
158
|
+
"""Pairwise file intersection grouped by repo.
|
|
159
|
+
|
|
160
|
+
Returns ``{<repo>: {files: [<path>], lines_a_only?, lines_b_only?,
|
|
161
|
+
lines_both?, generated_files?}}``. Repos with no overlap are omitted.
|
|
162
|
+
"""
|
|
163
|
+
out: dict[str, dict[str, Any]] = {}
|
|
164
|
+
repos = set(diff_a.keys()) & set(diff_b.keys())
|
|
165
|
+
for repo in sorted(repos):
|
|
166
|
+
files_a = set((diff_a.get(repo) or {}).get("changed_files") or [])
|
|
167
|
+
files_b = set((diff_b.get(repo) or {}).get("changed_files") or [])
|
|
168
|
+
shared = sorted(files_a & files_b)
|
|
169
|
+
if not shared:
|
|
170
|
+
continue
|
|
171
|
+
entry: dict[str, Any] = {
|
|
172
|
+
"files": shared,
|
|
173
|
+
"generated_files": [f for f in shared if _GENERATED_HINT.search(f)],
|
|
174
|
+
}
|
|
175
|
+
if line_level and workspace is not None and feature_a and feature_b:
|
|
176
|
+
entry.update(_line_overlap(workspace, repo, feature_a, feature_b, shared))
|
|
177
|
+
out[repo] = entry
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _has_overlap(overlap: dict[str, dict[str, Any]]) -> bool:
|
|
182
|
+
return any(entry.get("files") for entry in overlap.values())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def classify(
|
|
186
|
+
overlap: dict[str, dict[str, Any]],
|
|
187
|
+
feature_a: str,
|
|
188
|
+
feature_b: str,
|
|
189
|
+
) -> tuple[str, str]:
|
|
190
|
+
"""Return ``(severity, suggestion)``.
|
|
191
|
+
|
|
192
|
+
Heuristic:
|
|
193
|
+
- ``high`` when any repo has line-level overlap, OR when line
|
|
194
|
+
data isn't available and there's ≥1 shared real file
|
|
195
|
+
(non-generated).
|
|
196
|
+
- ``medium`` when only generated/lockfile-style files overlap, OR
|
|
197
|
+
when line data shows zero-line overlap on shared files.
|
|
198
|
+
- ``low`` fallback (currently unused — kept for the suggestion
|
|
199
|
+
layer).
|
|
200
|
+
"""
|
|
201
|
+
has_line_data = any("lines_both" in entry for entry in overlap.values())
|
|
202
|
+
line_overlap = any(entry.get("lines_both", 0) > 0 for entry in overlap.values())
|
|
203
|
+
|
|
204
|
+
only_generated = all(
|
|
205
|
+
entry["files"] and entry["files"] == entry.get("generated_files", [])
|
|
206
|
+
for entry in overlap.values()
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if line_overlap or (not has_line_data and not only_generated):
|
|
210
|
+
severity = "high"
|
|
211
|
+
elif only_generated:
|
|
212
|
+
severity = "medium"
|
|
213
|
+
else:
|
|
214
|
+
severity = "medium"
|
|
215
|
+
|
|
216
|
+
if severity == "high":
|
|
217
|
+
suggestion = (
|
|
218
|
+
f"Rebase {feature_b} onto {feature_a} (or vice versa) before "
|
|
219
|
+
f"opening a PR — they touch the same file(s)."
|
|
220
|
+
)
|
|
221
|
+
elif only_generated:
|
|
222
|
+
suggestion = (
|
|
223
|
+
"Both features touch generated/lockfile-style files. Likely "
|
|
224
|
+
"auto-mergeable; re-run the dep installer after rebasing."
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
suggestion = (
|
|
228
|
+
"Same files modified but disjoint lines. Should auto-merge but "
|
|
229
|
+
"worth a glance before opening a PR."
|
|
230
|
+
)
|
|
231
|
+
return severity, suggestion
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── line-level helper ────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
def _line_overlap(
|
|
237
|
+
workspace: Workspace,
|
|
238
|
+
repo_name: str,
|
|
239
|
+
feature_a: str,
|
|
240
|
+
feature_b: str,
|
|
241
|
+
files: list[str],
|
|
242
|
+
) -> dict[str, int]:
|
|
243
|
+
"""Aggregate per-file line-range intersections into single counters.
|
|
244
|
+
|
|
245
|
+
Reads ``git diff --unified=0`` for each (feature → base) pair and
|
|
246
|
+
parses the ``@@`` hunk headers to extract the changed line ranges in
|
|
247
|
+
the *new* file. Intersects per file, then sums.
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
state = workspace.get_repo(repo_name)
|
|
251
|
+
except KeyError:
|
|
252
|
+
return {"lines_a_only": 0, "lines_b_only": 0, "lines_both": 0}
|
|
253
|
+
base = state.config.default_branch
|
|
254
|
+
repo_path = state.abs_path
|
|
255
|
+
|
|
256
|
+
ranges_a = _file_line_ranges(repo_path, base, feature_a, files)
|
|
257
|
+
ranges_b = _file_line_ranges(repo_path, base, feature_b, files)
|
|
258
|
+
a_only = b_only = both = 0
|
|
259
|
+
for f in files:
|
|
260
|
+
ra = ranges_a.get(f, [])
|
|
261
|
+
rb = ranges_b.get(f, [])
|
|
262
|
+
a_lines = _lines_in_ranges(ra)
|
|
263
|
+
b_lines = _lines_in_ranges(rb)
|
|
264
|
+
a_only += len(a_lines - b_lines)
|
|
265
|
+
b_only += len(b_lines - a_lines)
|
|
266
|
+
both += len(a_lines & b_lines)
|
|
267
|
+
return {"lines_a_only": a_only, "lines_b_only": b_only, "lines_both": both}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _file_line_ranges(
|
|
274
|
+
repo_path: Path,
|
|
275
|
+
base: str,
|
|
276
|
+
branch: str,
|
|
277
|
+
files: list[str],
|
|
278
|
+
) -> dict[str, list[tuple[int, int]]]:
|
|
279
|
+
"""Read ``git diff --unified=0`` and return per-file ``[(start, len), …]``."""
|
|
280
|
+
if not files:
|
|
281
|
+
return {}
|
|
282
|
+
out: dict[str, list[tuple[int, int]]] = {f: [] for f in files}
|
|
283
|
+
try:
|
|
284
|
+
diff_out = git._run_ok(
|
|
285
|
+
["diff", "--unified=0", "--no-color",
|
|
286
|
+
f"{base}...{branch}", "--", *files],
|
|
287
|
+
cwd=repo_path,
|
|
288
|
+
)
|
|
289
|
+
except git.GitError:
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
current_file: str | None = None
|
|
293
|
+
for line in diff_out.split("\n"):
|
|
294
|
+
if line.startswith("diff --git "):
|
|
295
|
+
current_file = None
|
|
296
|
+
elif line.startswith("+++ "):
|
|
297
|
+
# +++ b/<path>
|
|
298
|
+
current_file = line[6:].strip() if line.startswith("+++ b/") else None
|
|
299
|
+
elif line.startswith("@@") and current_file in out:
|
|
300
|
+
m = _HUNK_HEADER.match(line)
|
|
301
|
+
if not m:
|
|
302
|
+
continue
|
|
303
|
+
start = int(m.group(1))
|
|
304
|
+
length = int(m.group(2)) if m.group(2) else 1
|
|
305
|
+
if length > 0:
|
|
306
|
+
out[current_file].append((start, length))
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _lines_in_ranges(ranges: list[tuple[int, int]]) -> set[int]:
|
|
311
|
+
s: set[int] = set()
|
|
312
|
+
for start, length in ranges:
|
|
313
|
+
s.update(range(start, start + length))
|
|
314
|
+
return s
|