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