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,378 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parse and validate canopy.toml workspace configuration.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
if sys.version_info >= (3, 11):
|
|
8
|
+
import tomllib
|
|
9
|
+
else:
|
|
10
|
+
import tomli as tomllib
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigNotFoundError(Exception):
|
|
17
|
+
"""No canopy.toml found in the directory tree."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigError(Exception):
|
|
21
|
+
"""Invalid canopy.toml content."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RepoConfig:
|
|
26
|
+
"""Configuration for a single repository in the workspace."""
|
|
27
|
+
name: str
|
|
28
|
+
path: str # relative path from workspace root
|
|
29
|
+
role: str = "" # optional: backend, frontend, shared, infra
|
|
30
|
+
lang: str = "" # optional: primary language
|
|
31
|
+
default_branch: str = "main"
|
|
32
|
+
is_worktree: bool = False # True if this is a linked worktree
|
|
33
|
+
worktree_main: str | None = None # path to main working tree (if worktree)
|
|
34
|
+
augments: dict[str, Any] = field(default_factory=dict) # per-repo augment overrides (M2)
|
|
35
|
+
# M6 worktree-bootstrap fields. All optional — missing means "skip
|
|
36
|
+
# this step." See docs/plans/worktree-bootstrap.md.
|
|
37
|
+
env_files: list[str] = field(default_factory=list)
|
|
38
|
+
install_cmd: str = ""
|
|
39
|
+
ide_settings: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class IssueProviderConfig:
|
|
44
|
+
"""Per-workspace issue provider selection (M5).
|
|
45
|
+
|
|
46
|
+
Parsed from the ``[issue_provider]`` block in canopy.toml. The
|
|
47
|
+
``options`` dict carries provider-specific settings from the
|
|
48
|
+
``[issue_provider.<name>]`` sub-table.
|
|
49
|
+
|
|
50
|
+
When the block is missing entirely, defaults to Linear with a
|
|
51
|
+
deprecation warning logged once per session — explicit config will
|
|
52
|
+
be required in a future release.
|
|
53
|
+
"""
|
|
54
|
+
name: str = "linear"
|
|
55
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
# Set to True when the parser fell back to the Linear default because
|
|
57
|
+
# no [issue_provider] block was present. The action layer logs a
|
|
58
|
+
# one-time deprecation notice.
|
|
59
|
+
is_default_fallback: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class WorkspaceConfig:
|
|
64
|
+
"""Parsed workspace configuration."""
|
|
65
|
+
name: str
|
|
66
|
+
repos: list[RepoConfig]
|
|
67
|
+
root: Path # absolute path to workspace root
|
|
68
|
+
slots: int = 2 # warm slot count (canonical is separate); default 2
|
|
69
|
+
issue_provider: IssueProviderConfig = field(default_factory=IssueProviderConfig)
|
|
70
|
+
augments: dict[str, Any] = field(default_factory=dict) # workspace-level augment defaults (M2)
|
|
71
|
+
# M6 — IDE workspace template + per-workspace bootstrap default.
|
|
72
|
+
ide: str = "none" # "vscode" | "none" (default)
|
|
73
|
+
bootstrap_default: bool = False # if true, --bootstrap is implicit on create/warm
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_config(path: Path | None = None) -> WorkspaceConfig:
|
|
77
|
+
"""Find and parse canopy.toml.
|
|
78
|
+
|
|
79
|
+
If no path is given, walks up from cwd looking for canopy.toml.
|
|
80
|
+
Raises ConfigNotFoundError if none is found.
|
|
81
|
+
Raises ConfigError if the file is malformed.
|
|
82
|
+
"""
|
|
83
|
+
if path is not None:
|
|
84
|
+
toml_path = path if path.name == "canopy.toml" else path / "canopy.toml"
|
|
85
|
+
else:
|
|
86
|
+
toml_path = _find_config()
|
|
87
|
+
|
|
88
|
+
if not toml_path.exists():
|
|
89
|
+
raise ConfigNotFoundError(f"No canopy.toml found at {toml_path}")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
with open(toml_path, "rb") as f:
|
|
93
|
+
data = tomllib.load(f)
|
|
94
|
+
except tomllib.TOMLDecodeError as e:
|
|
95
|
+
raise ConfigError(f"Invalid TOML in {toml_path}: {e}") from e
|
|
96
|
+
|
|
97
|
+
return _parse_config(data, toml_path.parent.resolve())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _find_config() -> Path:
|
|
101
|
+
"""Walk up from cwd looking for canopy.toml."""
|
|
102
|
+
current = Path.cwd().resolve()
|
|
103
|
+
while True:
|
|
104
|
+
candidate = current / "canopy.toml"
|
|
105
|
+
if candidate.exists():
|
|
106
|
+
return candidate
|
|
107
|
+
parent = current.parent
|
|
108
|
+
if parent == current:
|
|
109
|
+
raise ConfigNotFoundError(
|
|
110
|
+
"No canopy.toml found in current directory or any parent."
|
|
111
|
+
)
|
|
112
|
+
current = parent
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse_config(data: dict[str, Any], root: Path) -> WorkspaceConfig:
|
|
116
|
+
"""Parse raw TOML dict into WorkspaceConfig."""
|
|
117
|
+
workspace = data.get("workspace", {})
|
|
118
|
+
name = workspace.get("name")
|
|
119
|
+
if not name:
|
|
120
|
+
raise ConfigError("Missing [workspace] name in canopy.toml")
|
|
121
|
+
|
|
122
|
+
repos_data = data.get("repos", [])
|
|
123
|
+
if not repos_data:
|
|
124
|
+
raise ConfigError("No [[repos]] entries in canopy.toml")
|
|
125
|
+
|
|
126
|
+
repos = []
|
|
127
|
+
seen_names: set[str] = set()
|
|
128
|
+
for i, entry in enumerate(repos_data):
|
|
129
|
+
repo_name = entry.get("name")
|
|
130
|
+
if not repo_name:
|
|
131
|
+
raise ConfigError(f"[[repos]] entry {i} missing 'name'")
|
|
132
|
+
if not entry.get("path"):
|
|
133
|
+
raise ConfigError(f"[[repos]] entry '{repo_name}' missing 'path'")
|
|
134
|
+
if repo_name in seen_names:
|
|
135
|
+
raise ConfigError(f"Duplicate repo name: '{repo_name}'")
|
|
136
|
+
seen_names.add(repo_name)
|
|
137
|
+
|
|
138
|
+
repo_augments = entry.get("augments")
|
|
139
|
+
if repo_augments is not None and not isinstance(repo_augments, dict):
|
|
140
|
+
raise ConfigError(
|
|
141
|
+
f"[[repos]] entry '{repo_name}' augments must be a table, got: {type(repo_augments).__name__}",
|
|
142
|
+
)
|
|
143
|
+
env_files = entry.get("env_files") or []
|
|
144
|
+
if env_files and not (
|
|
145
|
+
isinstance(env_files, list) and all(isinstance(p, str) for p in env_files)
|
|
146
|
+
):
|
|
147
|
+
raise ConfigError(
|
|
148
|
+
f"[[repos]] entry '{repo_name}' env_files must be a list of strings",
|
|
149
|
+
)
|
|
150
|
+
ide_settings = entry.get("ide_settings")
|
|
151
|
+
if ide_settings is not None and not isinstance(ide_settings, dict):
|
|
152
|
+
raise ConfigError(
|
|
153
|
+
f"[[repos]] entry '{repo_name}' ide_settings must be a table",
|
|
154
|
+
)
|
|
155
|
+
repos.append(RepoConfig(
|
|
156
|
+
name=repo_name,
|
|
157
|
+
path=entry["path"],
|
|
158
|
+
role=entry.get("role", ""),
|
|
159
|
+
lang=entry.get("lang", ""),
|
|
160
|
+
default_branch=entry.get("default_branch", "main"),
|
|
161
|
+
augments=dict(repo_augments) if repo_augments else {},
|
|
162
|
+
env_files=list(env_files),
|
|
163
|
+
install_cmd=entry.get("install_cmd", "") or "",
|
|
164
|
+
ide_settings=dict(ide_settings) if ide_settings else {},
|
|
165
|
+
))
|
|
166
|
+
|
|
167
|
+
if "max_worktrees" in workspace:
|
|
168
|
+
raise ConfigError(
|
|
169
|
+
"max_worktrees was renamed to `slots` in canopy 3.0 — "
|
|
170
|
+
"run `canopy migrate-slots` to update canopy.toml"
|
|
171
|
+
)
|
|
172
|
+
slots_count = workspace.get("slots", 2)
|
|
173
|
+
if not isinstance(slots_count, int) or slots_count < 1:
|
|
174
|
+
raise ConfigError(f"slots must be a positive integer, got: {slots_count!r}")
|
|
175
|
+
ide_choice = workspace.get("ide", "none")
|
|
176
|
+
if not isinstance(ide_choice, str):
|
|
177
|
+
raise ConfigError(f"[workspace] ide must be a string, got {type(ide_choice).__name__}")
|
|
178
|
+
bootstrap_default = bool(workspace.get("bootstrap_default", False))
|
|
179
|
+
issue_provider = _parse_issue_provider(data)
|
|
180
|
+
augments = _parse_augments(data)
|
|
181
|
+
|
|
182
|
+
return WorkspaceConfig(
|
|
183
|
+
name=name,
|
|
184
|
+
repos=repos,
|
|
185
|
+
root=root,
|
|
186
|
+
slots=slots_count,
|
|
187
|
+
issue_provider=issue_provider,
|
|
188
|
+
augments=augments,
|
|
189
|
+
ide=ide_choice,
|
|
190
|
+
bootstrap_default=bootstrap_default,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _parse_augments(data: dict[str, Any]) -> dict[str, Any]:
|
|
195
|
+
"""Parse the ``[augments]`` block from canopy.toml (M2).
|
|
196
|
+
|
|
197
|
+
Schema::
|
|
198
|
+
|
|
199
|
+
[augments]
|
|
200
|
+
preflight_cmd = "make check"
|
|
201
|
+
test_cmd = "pytest"
|
|
202
|
+
review_bots = ["coderabbit", "korbit"]
|
|
203
|
+
|
|
204
|
+
Lenient: missing block returns empty dict; unknown keys preserved
|
|
205
|
+
so future augments don't require parser changes. Validation that
|
|
206
|
+
catches typos is deferred to ``canopy doctor`` (see plan §non-goals).
|
|
207
|
+
"""
|
|
208
|
+
block = data.get("augments")
|
|
209
|
+
if block is None:
|
|
210
|
+
return {}
|
|
211
|
+
if not isinstance(block, dict):
|
|
212
|
+
raise ConfigError(
|
|
213
|
+
f"[augments] must be a table, got: {type(block).__name__}",
|
|
214
|
+
)
|
|
215
|
+
return dict(block)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _parse_issue_provider(data: dict[str, Any]) -> IssueProviderConfig:
|
|
219
|
+
"""Parse the ``[issue_provider]`` block from canopy.toml.
|
|
220
|
+
|
|
221
|
+
Schema::
|
|
222
|
+
|
|
223
|
+
[issue_provider]
|
|
224
|
+
name = "linear" # or "github_issues"
|
|
225
|
+
|
|
226
|
+
[issue_provider.linear] # optional sub-table
|
|
227
|
+
api_key_env = "LINEAR_API_KEY"
|
|
228
|
+
|
|
229
|
+
[issue_provider.github_issues]
|
|
230
|
+
repo = "owner/repo"
|
|
231
|
+
|
|
232
|
+
Returns ``IssueProviderConfig(name="linear", is_default_fallback=True)``
|
|
233
|
+
when the block is missing — preserves backward compatibility with
|
|
234
|
+
pre-M5 canopy.toml files.
|
|
235
|
+
"""
|
|
236
|
+
block = data.get("issue_provider")
|
|
237
|
+
if not isinstance(block, dict):
|
|
238
|
+
return IssueProviderConfig(name="linear", is_default_fallback=True)
|
|
239
|
+
name = block.get("name", "linear")
|
|
240
|
+
if not isinstance(name, str) or not name:
|
|
241
|
+
raise ConfigError(
|
|
242
|
+
f"[issue_provider] name must be a non-empty string, got: {name!r}",
|
|
243
|
+
)
|
|
244
|
+
sub_table = block.get(name)
|
|
245
|
+
options: dict[str, Any] = sub_table if isinstance(sub_table, dict) else {}
|
|
246
|
+
return IssueProviderConfig(name=name, options=options)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ── Workspace settings (keys under [workspace]) ────────────────────────
|
|
250
|
+
|
|
251
|
+
# Settings that can be read/written via `canopy config`
|
|
252
|
+
WORKSPACE_SETTINGS = {
|
|
253
|
+
"name": str,
|
|
254
|
+
"slots": int,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_config_value(root: Path, key: str) -> Any:
|
|
259
|
+
"""Read a single workspace setting from canopy.toml."""
|
|
260
|
+
if key not in WORKSPACE_SETTINGS:
|
|
261
|
+
raise ConfigError(
|
|
262
|
+
f"Unknown setting: '{key}'. "
|
|
263
|
+
f"Available: {', '.join(sorted(WORKSPACE_SETTINGS))}"
|
|
264
|
+
)
|
|
265
|
+
toml_path = root / "canopy.toml"
|
|
266
|
+
if not toml_path.exists():
|
|
267
|
+
raise ConfigNotFoundError(f"No canopy.toml at {root}")
|
|
268
|
+
with open(toml_path, "rb") as f:
|
|
269
|
+
data = tomllib.load(f)
|
|
270
|
+
return data.get("workspace", {}).get(key)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def set_config_value(root: Path, key: str, value: str) -> Any:
|
|
274
|
+
"""Write a single workspace setting to canopy.toml.
|
|
275
|
+
|
|
276
|
+
Handles type coercion based on WORKSPACE_SETTINGS.
|
|
277
|
+
Returns the coerced value.
|
|
278
|
+
"""
|
|
279
|
+
if key not in WORKSPACE_SETTINGS:
|
|
280
|
+
raise ConfigError(
|
|
281
|
+
f"Unknown setting: '{key}'. "
|
|
282
|
+
f"Available: {', '.join(sorted(WORKSPACE_SETTINGS))}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
expected_type = WORKSPACE_SETTINGS[key]
|
|
286
|
+
try:
|
|
287
|
+
if expected_type == int:
|
|
288
|
+
coerced = int(value)
|
|
289
|
+
else:
|
|
290
|
+
coerced = value
|
|
291
|
+
except (ValueError, TypeError):
|
|
292
|
+
raise ConfigError(f"Invalid value for '{key}': expected {expected_type.__name__}")
|
|
293
|
+
|
|
294
|
+
toml_path = root / "canopy.toml"
|
|
295
|
+
if not toml_path.exists():
|
|
296
|
+
raise ConfigNotFoundError(f"No canopy.toml at {root}")
|
|
297
|
+
|
|
298
|
+
content = toml_path.read_text()
|
|
299
|
+
|
|
300
|
+
# Try to update existing key under [workspace]
|
|
301
|
+
import re
|
|
302
|
+
# Match: key = value (with optional quotes for strings)
|
|
303
|
+
pattern = rf'^({re.escape(key)}\s*=\s*).*$'
|
|
304
|
+
|
|
305
|
+
# Find lines within the [workspace] section
|
|
306
|
+
lines = content.split("\n")
|
|
307
|
+
in_workspace = False
|
|
308
|
+
updated = False
|
|
309
|
+
for i, line in enumerate(lines):
|
|
310
|
+
stripped = line.strip()
|
|
311
|
+
if stripped == "[workspace]":
|
|
312
|
+
in_workspace = True
|
|
313
|
+
continue
|
|
314
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
315
|
+
# Hit a new section — if we haven't updated yet, insert before this
|
|
316
|
+
if in_workspace and not updated:
|
|
317
|
+
# Insert the key before this section
|
|
318
|
+
formatted = _format_toml_value(key, coerced)
|
|
319
|
+
lines.insert(i, formatted)
|
|
320
|
+
updated = True
|
|
321
|
+
in_workspace = False
|
|
322
|
+
continue
|
|
323
|
+
if in_workspace and re.match(pattern, stripped):
|
|
324
|
+
lines[i] = _format_toml_value(key, coerced)
|
|
325
|
+
updated = True
|
|
326
|
+
break
|
|
327
|
+
|
|
328
|
+
# If still not updated, append to [workspace] section
|
|
329
|
+
if not updated:
|
|
330
|
+
# Find the [workspace] line and append after it
|
|
331
|
+
for i, line in enumerate(lines):
|
|
332
|
+
if line.strip() == "[workspace]":
|
|
333
|
+
lines.insert(i + 1, _format_toml_value(key, coerced))
|
|
334
|
+
updated = True
|
|
335
|
+
break
|
|
336
|
+
|
|
337
|
+
if not updated:
|
|
338
|
+
raise ConfigError("Could not find [workspace] section in canopy.toml")
|
|
339
|
+
|
|
340
|
+
toml_path.write_text("\n".join(lines))
|
|
341
|
+
return coerced
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def get_all_config(root: Path) -> dict[str, Any]:
|
|
345
|
+
"""Read all workspace settings from canopy.toml."""
|
|
346
|
+
toml_path = root / "canopy.toml"
|
|
347
|
+
if not toml_path.exists():
|
|
348
|
+
raise ConfigNotFoundError(f"No canopy.toml at {root}")
|
|
349
|
+
with open(toml_path, "rb") as f:
|
|
350
|
+
data = tomllib.load(f)
|
|
351
|
+
ws = data.get("workspace", {})
|
|
352
|
+
return {k: ws.get(k) for k in WORKSPACE_SETTINGS}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _format_toml_value(key: str, value: Any) -> str:
|
|
356
|
+
"""Format a key = value line for TOML."""
|
|
357
|
+
if isinstance(value, int):
|
|
358
|
+
return f"{key} = {value}"
|
|
359
|
+
elif isinstance(value, str):
|
|
360
|
+
return f'{key} = "{value}"'
|
|
361
|
+
return f"{key} = {value}"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def validate_config(config: WorkspaceConfig) -> list[str]:
|
|
365
|
+
"""Validate a WorkspaceConfig and return a list of warnings.
|
|
366
|
+
|
|
367
|
+
Returns an empty list if everything is valid.
|
|
368
|
+
"""
|
|
369
|
+
warnings = []
|
|
370
|
+
|
|
371
|
+
for repo in config.repos:
|
|
372
|
+
abs_path = (config.root / repo.path).resolve()
|
|
373
|
+
if not abs_path.exists():
|
|
374
|
+
warnings.append(f"Repo '{repo.name}': path does not exist: {abs_path}")
|
|
375
|
+
elif not (abs_path / ".git").exists():
|
|
376
|
+
warnings.append(f"Repo '{repo.name}': not a git repository: {abs_path}")
|
|
377
|
+
|
|
378
|
+
return warnings
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context detection — figure out where canopy is running from.
|
|
3
|
+
|
|
4
|
+
When you run canopy from inside a worktree directory, this module
|
|
5
|
+
detects which feature lane, which repo(s), and which branch you're
|
|
6
|
+
working in. This powers context-aware commands like `canopy stage`.
|
|
7
|
+
|
|
8
|
+
Context hierarchy (from most to least specific):
|
|
9
|
+
1. Inside a specific repo worktree:
|
|
10
|
+
.canopy/worktrees/auth-flow/api/ → feature=auth-flow, repo=api
|
|
11
|
+
2. Inside a feature directory (parent of repo worktrees):
|
|
12
|
+
.canopy/worktrees/auth-flow/ → feature=auth-flow, repos=all
|
|
13
|
+
3. Inside a normal repo in the workspace:
|
|
14
|
+
workspace/api/ → repo=api, feature=current branch
|
|
15
|
+
4. At the workspace root:
|
|
16
|
+
workspace/ → whole workspace
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from ..git import repo as git
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CanopyContext:
|
|
30
|
+
"""Where canopy is running and what it can see."""
|
|
31
|
+
|
|
32
|
+
# Where we detected context from
|
|
33
|
+
cwd: Path
|
|
34
|
+
|
|
35
|
+
# Workspace root (where canopy.toml lives), if found
|
|
36
|
+
workspace_root: Optional[Path] = None
|
|
37
|
+
|
|
38
|
+
# Feature lane name, if detected
|
|
39
|
+
feature: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
# Repo directories to operate on (worktree or regular repo paths)
|
|
42
|
+
repo_paths: list[Path] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
# Repo names (matching repo_paths)
|
|
45
|
+
repo_names: list[str] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
# The current branch (if in a single repo/worktree)
|
|
48
|
+
branch: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
# How we detected context
|
|
51
|
+
context_type: str = "unknown"
|
|
52
|
+
# "feature_dir" — inside .canopy/worktrees/<feature>/
|
|
53
|
+
# "repo_worktree" — inside .canopy/worktrees/<feature>/<repo>/
|
|
54
|
+
# "repo" — inside a normal workspace repo
|
|
55
|
+
# "workspace_root" — at the workspace root
|
|
56
|
+
# "unknown" — couldn't detect
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict:
|
|
59
|
+
return {
|
|
60
|
+
"cwd": str(self.cwd),
|
|
61
|
+
"workspace_root": str(self.workspace_root) if self.workspace_root else None,
|
|
62
|
+
"feature": self.feature,
|
|
63
|
+
"repo_paths": [str(p) for p in self.repo_paths],
|
|
64
|
+
"repo_names": self.repo_names,
|
|
65
|
+
"branch": self.branch,
|
|
66
|
+
"context_type": self.context_type,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def detect_context(cwd: Path | None = None) -> CanopyContext:
|
|
71
|
+
"""Detect canopy context from the current working directory.
|
|
72
|
+
|
|
73
|
+
Walks up from cwd looking for clues:
|
|
74
|
+
- .canopy/worktrees/<feature>/<repo>/ structure
|
|
75
|
+
- canopy.toml (workspace root)
|
|
76
|
+
- .git directory (regular repo)
|
|
77
|
+
"""
|
|
78
|
+
if cwd is None:
|
|
79
|
+
cwd = Path.cwd().resolve()
|
|
80
|
+
else:
|
|
81
|
+
cwd = cwd.resolve()
|
|
82
|
+
|
|
83
|
+
ctx = CanopyContext(cwd=cwd)
|
|
84
|
+
|
|
85
|
+
# Strategy 1: Are we inside a .canopy/worktrees/<feature>/ structure?
|
|
86
|
+
_detect_worktree_context(ctx)
|
|
87
|
+
if ctx.context_type != "unknown":
|
|
88
|
+
return ctx
|
|
89
|
+
|
|
90
|
+
# Strategy 2: Are we inside a normal repo in a workspace?
|
|
91
|
+
_detect_repo_context(ctx)
|
|
92
|
+
if ctx.context_type != "unknown":
|
|
93
|
+
return ctx
|
|
94
|
+
|
|
95
|
+
# Strategy 3: Are we at a workspace root?
|
|
96
|
+
_detect_workspace_root(ctx)
|
|
97
|
+
|
|
98
|
+
return ctx
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _detect_worktree_context(ctx: CanopyContext) -> None:
|
|
102
|
+
"""Check if cwd is inside .canopy/worktrees/worktree-N/[<repo>/]."""
|
|
103
|
+
path = ctx.cwd
|
|
104
|
+
|
|
105
|
+
# Walk up looking for a path segment that matches .canopy/worktrees/<slot-id>
|
|
106
|
+
parts = path.parts
|
|
107
|
+
for i, part in enumerate(parts):
|
|
108
|
+
if part == ".canopy" and i + 2 < len(parts) and parts[i + 1] == "worktrees":
|
|
109
|
+
# Found .canopy/worktrees/ — next part is the slot id (e.g. worktree-1)
|
|
110
|
+
slot_id = parts[i + 2]
|
|
111
|
+
canopy_dir = Path(*parts[:i]) # workspace root
|
|
112
|
+
slot_dir = Path(*parts[:i + 3]) # .canopy/worktrees/<slot-id>
|
|
113
|
+
|
|
114
|
+
ctx.workspace_root = canopy_dir
|
|
115
|
+
# Resolve feature via slots.json. If we can't load it (e.g. no
|
|
116
|
+
# canopy.toml on this branch), fall back to the slot id so
|
|
117
|
+
# downstream commands have *something*.
|
|
118
|
+
feature = _slot_to_feature(canopy_dir, slot_id) or slot_id
|
|
119
|
+
ctx.feature = feature
|
|
120
|
+
|
|
121
|
+
if i + 3 < len(parts):
|
|
122
|
+
# We're inside a specific repo worktree
|
|
123
|
+
repo_name = parts[i + 3]
|
|
124
|
+
repo_path = Path(*parts[:i + 4])
|
|
125
|
+
|
|
126
|
+
ctx.context_type = "repo_worktree"
|
|
127
|
+
ctx.repo_paths = [repo_path]
|
|
128
|
+
ctx.repo_names = [repo_name]
|
|
129
|
+
ctx.branch = _safe_branch(repo_path)
|
|
130
|
+
else:
|
|
131
|
+
# We're at the slot directory level — find all repo worktrees
|
|
132
|
+
ctx.context_type = "feature_dir"
|
|
133
|
+
if slot_dir.exists():
|
|
134
|
+
for child in sorted(slot_dir.iterdir()):
|
|
135
|
+
if child.is_dir() and (child / ".git").exists():
|
|
136
|
+
ctx.repo_paths.append(child)
|
|
137
|
+
ctx.repo_names.append(child.name)
|
|
138
|
+
if ctx.repo_paths:
|
|
139
|
+
ctx.branch = _safe_branch(ctx.repo_paths[0])
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _slot_to_feature(workspace_root: Path, slot_id: str) -> str | None:
|
|
144
|
+
"""Read slots.json to map a slot id back to its current feature."""
|
|
145
|
+
p = workspace_root / ".canopy/state/slots.json"
|
|
146
|
+
if not p.exists():
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
data = json.loads(p.read_text())
|
|
150
|
+
except (OSError, json.JSONDecodeError):
|
|
151
|
+
return None
|
|
152
|
+
entry = (data.get("slots") or {}).get(slot_id)
|
|
153
|
+
if isinstance(entry, dict):
|
|
154
|
+
return entry.get("feature")
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _detect_repo_context(ctx: CanopyContext) -> None:
|
|
159
|
+
"""Check if cwd is inside a normal repo in a canopy workspace."""
|
|
160
|
+
path = ctx.cwd
|
|
161
|
+
|
|
162
|
+
# Walk up looking for .git
|
|
163
|
+
current = path
|
|
164
|
+
while True:
|
|
165
|
+
if (current / ".git").exists():
|
|
166
|
+
# Found a repo — is it inside a canopy workspace?
|
|
167
|
+
parent = current.parent
|
|
168
|
+
ws_root = _find_workspace_root(parent)
|
|
169
|
+
if ws_root:
|
|
170
|
+
ctx.workspace_root = ws_root
|
|
171
|
+
ctx.context_type = "repo"
|
|
172
|
+
ctx.repo_paths = [current]
|
|
173
|
+
ctx.repo_names = [current.name]
|
|
174
|
+
ctx.branch = _safe_branch(current)
|
|
175
|
+
|
|
176
|
+
# Try to detect feature from branch name
|
|
177
|
+
if ctx.branch and ctx.branch not in ("main", "master", "(detached)"):
|
|
178
|
+
ctx.feature = ctx.branch
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
parent = current.parent
|
|
182
|
+
if parent == current:
|
|
183
|
+
break
|
|
184
|
+
current = parent
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _detect_workspace_root(ctx: CanopyContext) -> None:
|
|
188
|
+
"""Check if cwd is a workspace root (has canopy.toml)."""
|
|
189
|
+
ws_root = _find_workspace_root(ctx.cwd)
|
|
190
|
+
if ws_root and ws_root == ctx.cwd:
|
|
191
|
+
ctx.workspace_root = ws_root
|
|
192
|
+
ctx.context_type = "workspace_root"
|
|
193
|
+
|
|
194
|
+
# Find all repos at the workspace level
|
|
195
|
+
try:
|
|
196
|
+
from .config import load_config
|
|
197
|
+
config = load_config(ws_root)
|
|
198
|
+
for rc in config.repos:
|
|
199
|
+
abs_path = (ws_root / rc.path).resolve()
|
|
200
|
+
if abs_path.exists():
|
|
201
|
+
ctx.repo_paths.append(abs_path)
|
|
202
|
+
ctx.repo_names.append(rc.name)
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _find_workspace_root(start: Path) -> Path | None:
|
|
208
|
+
"""Walk up from start looking for canopy.toml."""
|
|
209
|
+
current = start.resolve()
|
|
210
|
+
while True:
|
|
211
|
+
if (current / "canopy.toml").exists():
|
|
212
|
+
return current
|
|
213
|
+
parent = current.parent
|
|
214
|
+
if parent == current:
|
|
215
|
+
return None
|
|
216
|
+
current = parent
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _safe_branch(repo_path: Path) -> str | None:
|
|
220
|
+
"""Get current branch, returning None on error."""
|
|
221
|
+
try:
|
|
222
|
+
return git.current_branch(repo_path)
|
|
223
|
+
except Exception:
|
|
224
|
+
return None
|