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