diff --git a/CHANGELOG.md b/CHANGELOG.md index dccd429..f5da9d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ Tracks the Python side (CLI + MCP server). The VSCode extension has its own [vsc Versions follow semver. Pre-1.0 — minor bumps may add features or break behavior; the README is the source-of-truth contract. +## 3.1.2 — 2026-07-04 + +Slot-model consistency fixes from canopy-test dogfooding. + +### Fixed +- `switch`: on the cold-Y fall-through (Y has a slots.json entry but this + repo's slot dir is missing), the vacating feature's slot is reclaimed for + the outgoing feature instead of allocating a fresh one — previously the + about-to-be-freed slot counted against the cap and raised a bogus + `no_free_slot`. +- `switch`: precondition failures raised before any git mutation + (`no_free_slot`, `unknown_slot`, `evict_to_occupied`, + `warm_worktree_dirty_on_promote`) no longer stamp the `in_flight` marker + when no repo has been touched — a clean no-op failure used to brick every + subsequent switch via `slot_state_inconsistent`. +- `doctor`: new `slot_repo_worktree_missing` check (+ auto-repair via + `git worktree prune` + `worktree add`) catches half-materialized slots + where a slot holds a feature but one repo's worktree is gone. +- `doctor`: the pre-3.0 `worktree_orphan` check now skips `worktree-N` slot + dirs — `doctor --fix` no longer deletes warm slots. +- `worktree_bootstrap`: resolves worktree paths via slots.json (the 3.0 + source of truth) instead of the legacy `features.json` cache, which is + empty in 3.0 — bootstrap raised `no_worktrees` for every warm feature. + Falls back to the legacy cache for pre-3.0 workspaces. +- `canopy init` / `workspace_reinit`: existing worktrees are reported by + occupant feature (resolved via slots.json) instead of listing slot ids + (`worktree-N`) as if they were feature names. +- `coordinator.status()` / `feature_changes`: honor the per-repo `branches` + map (`lane.branch_for`) instead of assuming branch == feature name — + mismatched-naming features no longer mis-report as having no branch or no + changes. + ## 3.1.1 — 2026-05-31 ### Fixed diff --git a/src/canopy/__init__.py b/src/canopy/__init__.py index 5764033..92f39ab 100644 --- a/src/canopy/__init__.py +++ b/src/canopy/__init__.py @@ -1,2 +1,2 @@ """Canopy — workspace-first development orchestrator.""" -__version__ = "3.1.1" +__version__ = "3.1.2" diff --git a/src/canopy/actions/bootstrap.py b/src/canopy/actions/bootstrap.py index d5c903e..8c3bc09 100644 --- a/src/canopy/actions/bootstrap.py +++ b/src/canopy/actions/bootstrap.py @@ -63,7 +63,10 @@ def bootstrap_feature( if not worktree_paths: raise BlockerError( code="no_worktrees", - what=f"feature '{feature_name}' has no worktree paths recorded", + what=( + f"feature '{feature_name}' is not warm in any slot " + f"(nothing to bootstrap) — `canopy switch {feature_name}` first" + ), ) results: dict[str, dict[str, Any]] = {} @@ -235,7 +238,28 @@ def _validate_steps(steps: Iterable[str] | None) -> set[str]: def _resolve_worktree_paths( workspace: Workspace, feature_name: str, ) -> dict[str, Path]: - """Pull recorded worktree paths from features.json.""" + """Resolve each repo's warm worktree dir for ``feature_name``. + + Wave 3.0: the authoritative source is slots.json — a warm feature's + per-repo worktrees live under its slot at + ``.canopy/worktrees/worktree-N/``. Falls back to the legacy + ``features.json`` ``worktree_paths`` cache for pre-3.0 workspaces (no + slots.json). The old code read only the legacy cache, which is empty in + 3.0 — so bootstrap raised ``no_worktrees`` for every warm feature. + """ + from . import slots as slots_mod + from .aliases import repos_for_feature + + slot_id = slots_mod.slot_for_feature(workspace, feature_name) + if slot_id is not None: + out: dict[str, Path] = {} + for repo_name in repos_for_feature(workspace, feature_name): + p = slots_mod.slot_worktree_path(workspace, slot_id, repo_name) + if (p / ".git").exists(): + out[repo_name] = p + return out + + # Legacy pre-3.0 fallback: features.json worktree_paths cache. import json path = workspace.config.root / ".canopy" / "features.json" if not path.exists(): diff --git a/src/canopy/actions/doctor.py b/src/canopy/actions/doctor.py index 005f8cc..f298bdf 100644 --- a/src/canopy/actions/doctor.py +++ b/src/canopy/actions/doctor.py @@ -222,7 +222,14 @@ def check_active_feature_path_missing(workspace: Workspace) -> list[Issue]: def check_worktree_orphan(workspace: Workspace) -> list[Issue]: - """Worktree directories under .canopy/worktrees/ not referenced by any feature.""" + """Worktree directories under .canopy/worktrees/ not referenced by any feature. + + Pre-3.0 layout only (``/``). The Wave-3.0 slot layout + (``worktree-N/``) is owned by the ``slot_*`` checks — skip those + dirs here, or this check would flag every warm slot as an orphan and + ``--fix`` would delete it. + """ + import re wt_root = workspace.config.root / ".canopy" / "worktrees" if not wt_root.exists(): return [] @@ -231,6 +238,8 @@ def check_worktree_orphan(workspace: Workspace) -> list[Issue]: for feat_dir in sorted(wt_root.iterdir()): if not feat_dir.is_dir(): continue + if re.fullmatch(r"worktree-\d+", feat_dir.name): + continue # slot dir — handled by check_slot_* functions feature_name = feat_dir.name feature_data = features.get(feature_name) feature_repos = (feature_data or {}).get("repos") or [] @@ -604,6 +613,66 @@ def check_slot_branch_mismatches(workspace: Workspace) -> list[Issue]: return issues +def check_slot_repo_worktree_missing(workspace: Workspace) -> list[Issue]: + """A slot holds feature F, but one of F's repos has no worktree on disk. + + This is the per-repo divergence the other slot checks can't see: + ``slot_entry_orphan`` only inspects the ``worktree-N/`` top dir (which + survives as long as ANY repo's subdir remains), and + ``slot_branch_mismatch`` ``continue``s past a non-existent per-repo path. + A half-materialized slot bricked canopy-test (``switch`` then tried to + allocate a fresh slot for an already-occupied feature → ``no_free_slot``). + + Auto-fixable by recreating the worktree from the feature's branch — + unless the branch itself is gone, in which case ``branches_missing`` + owns the deeper problem and this is advice-only. + """ + from . import slots as slots_mod + from .aliases import repos_for_feature + + state = slots_mod.read_state(workspace) + if state is None: + return [] + issues: list[Issue] = [] + for sid, entry in state.slots.items(): + repo_branches = repos_for_feature(workspace, entry.feature) or {} + for repo_name, expected_branch in repo_branches.items(): + slot_path = slots_mod.slot_worktree_path(workspace, sid, repo_name) + if (slot_path / ".git").exists(): + continue + try: + rs = workspace.get_repo(repo_name) + except KeyError: + continue # features_unknown_repo owns this + branch_ok = rs.abs_path.exists() and git.branch_exists( + rs.abs_path, expected_branch, + ) + issues.append(Issue( + code="slot_repo_worktree_missing", + severity="error", + what=( + f"slot '{sid}' is missing its '{repo_name}' worktree" + f" (feature '{entry.feature}', branch '{expected_branch}')" + ), + expected=str(slot_path), + actual="(no worktree on disk)", + repo=repo_name, + feature=entry.feature, + fix_action=( + f"recreate: git worktree add {slot_path} {expected_branch}" + if branch_ok else + f"branch '{expected_branch}' is gone in {repo_name} —" + f" restore it (see branches_missing) before recreating" + ), + auto_fixable=branch_ok, + details={ + "slot": sid, "feature": entry.feature, "repo": repo_name, + "branch": expected_branch, "slot_path": str(slot_path), + }, + )) + return issues + + # ── Install-staleness checks ───────────────────────────────────────────── @@ -895,6 +964,7 @@ def check_vsix_duplicates(workspace: Workspace) -> list[Issue]: "vsix_duplicates": ("vsix", check_vsix_duplicates), "slot_dir_orphan": ("slots", check_slot_dir_orphans), "slot_entry_orphan": ("slots", check_slot_entry_orphans), + "slot_repo_worktree_missing": ("slots", check_slot_repo_worktree_missing), "slot_branch_mismatch": ("slots", check_slot_branch_mismatches), # slot_detached_head shares its check function with slot_branch_mismatch # (one walker emits both codes). The registry entry uses a sentinel @@ -1276,6 +1346,43 @@ def repair_slot_entry_orphan(workspace: Workspace, issue: Issue) -> RepairResult action_taken=f"dropped slots.json entry for '{sid}'") +def repair_slot_repo_worktree_missing(workspace: Workspace, issue: Issue) -> RepairResult: + """Recreate the missing per-repo worktree from the feature's branch. + + Restores the slot invariant rather than dropping the slot entry — + dropping it would orphan the slot's surviving repos. Idempotent: a no-op + if the worktree reappeared. + """ + d = issue.details or {} + repo_name, branch, slot_path_s = d.get("repo"), d.get("branch"), d.get("slot_path") + if not (repo_name and branch and slot_path_s): + return RepairResult(code=issue.code, success=False, action_taken="", + error="missing repo/branch/slot_path on issue") + slot_path = Path(slot_path_s) + if (slot_path / ".git").exists(): + return RepairResult(code=issue.code, success=True, repo=repo_name, + feature=issue.feature, + action_taken="worktree already present") + try: + rs = workspace.get_repo(repo_name) + except KeyError as e: + return RepairResult(code=issue.code, success=False, action_taken="", + error=str(e), repo=repo_name) + repo_root = git.worktree_main_path(rs.abs_path) or rs.abs_path + slot_path.parent.mkdir(parents=True, exist_ok=True) + try: + # Prune first so a stale registration for this path doesn't block add. + git.worktree_prune(repo_root) + git.worktree_add(repo_root, slot_path, branch, create_branch=False) + except git.GitError as e: + return RepairResult(code=issue.code, success=False, repo=repo_name, + feature=issue.feature, action_taken="", + error=str(e)) + return RepairResult(code=issue.code, success=True, repo=repo_name, + feature=issue.feature, + action_taken=f"git worktree add {slot_path} {branch}") + + _REPAIRS: dict[str, Any] = { "heads_stale": repair_heads_stale, "active_feature_orphan": repair_active_feature_orphan, @@ -1291,6 +1398,7 @@ def repair_slot_entry_orphan(workspace: Workspace, issue: Issue) -> RepairResult "mcp_orphans": repair_mcp_orphans, "vsix_duplicates": repair_vsix_duplicates, "slot_entry_orphan": repair_slot_entry_orphan, + "slot_repo_worktree_missing": repair_slot_repo_worktree_missing, # cli_stale, mcp_stale, features_unknown_repo, branches_missing, # slot_dir_orphan, slot_branch_mismatch have no auto-fix — # repair returns surfaced advice via the issue's `fix_action` instead. diff --git a/src/canopy/actions/switch.py b/src/canopy/actions/switch.py index e76061c..eda8c08 100644 --- a/src/canopy/actions/switch.py +++ b/src/canopy/actions/switch.py @@ -34,6 +34,17 @@ from .errors import BlockerError, FixAction +# Precondition BlockerError codes raised by _do_repo_switch BEFORE any git +# mutation in the failing repo. When one fires with no prior repo completed, +# the workspace is NOT partially flipped, so no in_flight marker is warranted. +_PRE_MUTATION_CODES = frozenset({ + "no_free_slot", + "unknown_slot", + "evict_to_occupied", + "warm_worktree_dirty_on_promote", +}) + + def switch( workspace: Workspace, feature: str | None = None, @@ -158,15 +169,20 @@ def switch( evict_to=evict_to, ) except BlockerError as e: - # Even a structured precondition failure (e.g. dirty warm - # worktree on the second repo) can leave disk partially - # mutated by earlier repos. Persist an in_flight marker so - # the next switch refuses to operate on a lie. - _persist_in_flight( - workspace, feature_name, previously_canonical, - failed_repo=repo_name, error_what=e.what or str(e), - completed_results=per_repo_results, - ) + # A structured precondition failure raised BEFORE any git + # mutation (no_free_slot, dirty warm worktree, bad --evict-to) + # leaves disk untouched in this repo. Only stamp in_flight when + # something is actually half-flipped: an earlier repo already + # completed (per_repo_results non-empty), OR this was a mid-op + # failure (not one of the pre-mutation precondition codes). + # Otherwise a clean no-op failure would brick every future + # switch via slot_state_inconsistent. + if per_repo_results or e.code not in _PRE_MUTATION_CODES: + _persist_in_flight( + workspace, feature_name, previously_canonical, + failed_repo=repo_name, error_what=e.what or str(e), + completed_results=per_repo_results, + ) raise except Exception as e: # Mid-op failure with no rollback walker (yet). Surface enough @@ -281,9 +297,14 @@ def _do_repo_switch( per_repo_results.append(result) return # Fall through: Y's slot entry exists but this repo's slot - # dir is missing (partial-scope drift). Treat as cold-Y. - - # Cold-Y path: allocate a fresh slot for X + # dir is missing (orphaned worktree / partial-scope drift). + # Y is still being promoted to canonical, so it vacates its + # slot regardless — X reclaims y_slot below instead of needing + # a brand-new allocation. Allocating blindly here would count + # Y's own about-to-be-freed slot against the cap and raise a + # bogus no_free_slot (the canopy-test billing-export lock-out). + + # Cold-Y path: allocate (or reclaim) the slot X will occupy. state = slots_mod.read_state(workspace) or slots_mod.SlotState( slot_count=workspace.config.slots, ) @@ -304,6 +325,10 @@ def _do_repo_switch( details={"slot": evict_to, "occupant": existing}, ) x_slot = evict_to + elif y_slot is not None: + # Y already owns y_slot and is leaving it (becoming canonical); + # X reclaims it. _post_switch_persist re-stamps the slot to X. + x_slot = y_slot else: x_slot = slots_mod.allocate_slot(state) if x_slot is None: diff --git a/src/canopy/cli/main.py b/src/canopy/cli/main.py index a528221..75c3876 100644 --- a/src/canopy/cli/main.py +++ b/src/canopy/cli/main.py @@ -118,15 +118,10 @@ def cmd_init(args: argparse.Namespace) -> None: if args.json: all_dirs = [d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")] skipped = [d.name for d in all_dirs if not (d / ".git").exists()] - # Detect existing feature worktrees - worktrees_dir = root / ".canopy" / "worktrees" - active_worktrees = {} - if worktrees_dir.is_dir(): - for feat_dir in worktrees_dir.iterdir(): - if feat_dir.is_dir(): - active_worktrees[feat_dir.name] = sorted( - d.name for d in feat_dir.iterdir() if d.is_dir() - ) + # Detect existing worktrees, keyed by FEATURE (slot dirs resolve their + # occupant via slots.json — not reported as if the slot id were a feature). + from ..workspace.discovery import summarize_worktree_dirs + active_worktrees = summarize_worktree_dirs(root) _print_json({ "root": str(root), "repos": [{ @@ -203,22 +198,15 @@ def cmd_init(args: argparse.Namespace) -> None: console.print(f" mcp [muted]· {note}[/]") console.print(f" [muted]Restart Claude Code to pick up the skill + MCP. Skip with --no-agent.[/]") - # Report existing feature worktrees under .canopy/ - canopy_dir = root / ".canopy" - worktrees_dir = canopy_dir / "worktrees" - if worktrees_dir.is_dir(): - features_with_wt = sorted( - d.name for d in worktrees_dir.iterdir() if d.is_dir() - ) - if features_with_wt: - console.print() - console.print(f" [header]Active worktrees ({len(features_with_wt)})[/]") - for feat in features_with_wt: - feat_dir = worktrees_dir / feat - wt_repos = sorted( - d.name for d in feat_dir.iterdir() if d.is_dir() - ) - console.print(f" [feature]{feat}[/] [muted]{SYM_ARROW}[/] {', '.join(wt_repos)}") + # Report existing worktrees under .canopy/, keyed by feature (slot dirs + # resolve their occupant via slots.json — see summarize_worktree_dirs). + from ..workspace.discovery import summarize_worktree_dirs + worktrees = summarize_worktree_dirs(root) + if worktrees: + console.print() + console.print(f" [header]Active worktrees ({len(worktrees)})[/]") + for feat, wt_repos in sorted(worktrees.items()): + console.print(f" [feature]{feat}[/] [muted]{SYM_ARROW}[/] {', '.join(wt_repos)}") console.print() diff --git a/src/canopy/features/coordinator.py b/src/canopy/features/coordinator.py index a97a629..d5a93b7 100644 --- a/src/canopy/features/coordinator.py +++ b/src/canopy/features/coordinator.py @@ -314,6 +314,7 @@ def status(self, name: str) -> FeatureLane: linear_issue=data.get("linear_issue", ""), linear_title=data.get("linear_title", ""), linear_url=data.get("linear_url", ""), + branches=dict(data.get("branches") or {}), ) else: # Implicit feature @@ -438,7 +439,10 @@ def feature_changes(self, name: str) -> dict: continue try: - changes = git.changed_files_with_status(scan_path, name, base) + # Per-repo branch override: scan the lane's actual branch + # for this repo, not the bare feature name. + branch = lane.branch_for(repo_name) + changes = git.changed_files_with_status(scan_path, branch, base) result[repo_name] = { "has_branch": True, "path": str(scan_path), @@ -550,7 +554,12 @@ def _enrich_lane(self, lane: FeatureLane) -> None: continue base = state.config.default_branch - has_branch = git.branch_exists(state.abs_path, lane.name) + # Honor per-repo branch overrides — the branch may differ from + # the feature name (FeatureLane.branches map). Using lane.name + # here would mis-report mismatched-naming features as having no + # branch / no changes. + branch = lane.branch_for(repo_name) + has_branch = git.branch_exists(state.abs_path, branch) if not has_branch: lane.repo_states[repo_name] = { @@ -564,10 +573,10 @@ def _enrich_lane(self, lane: FeatureLane) -> None: try: ahead, behind = git.divergence( - state.abs_path, lane.name, base + state.abs_path, branch, base ) - files = git.changed_files(state.abs_path, lane.name, base) - dirty = state.is_dirty if state.current_branch == lane.name else False + files = git.changed_files(state.abs_path, branch, base) + dirty = state.is_dirty if state.current_branch == branch else False repo_state: dict = { "has_branch": True, @@ -580,7 +589,7 @@ def _enrich_lane(self, lane: FeatureLane) -> None: } # Check if branch is checked out in a worktree - wt_path = git.worktree_for_branch(state.abs_path, lane.name) + wt_path = git.worktree_for_branch(state.abs_path, branch) if wt_path: repo_state["worktree_path"] = wt_path @@ -1220,8 +1229,10 @@ def _find_stale_worktrees(self) -> list[dict]: try: repo_name = repo_dir.name state = self.workspace.get_repo(repo_name) + # Per-repo branch override (else the feature name). + branch = (meta.get("branches") or {}).get(repo_name, feat_name) ahead, _ = git.divergence( - repo_dir, feat_name, state.config.default_branch, + repo_dir, branch, state.config.default_branch, ) if ahead > 0: all_merged = False diff --git a/src/canopy/git/repo.py b/src/canopy/git/repo.py index 8b7f1b7..be2c3be 100644 --- a/src/canopy/git/repo.py +++ b/src/canopy/git/repo.py @@ -732,6 +732,15 @@ def worktree_remove(repo_path: Path, worktree_path: Path, force: bool = False) - return _run(args, cwd=repo_path) +def worktree_prune(repo_path: Path) -> str: + """Prune worktree registrations whose directories are gone. + + Clears stale ``.git/worktrees/`` entries so a subsequent + ``worktree add`` at the same path isn't rejected as already-registered. + """ + return _run(["worktree", "prune"], cwd=repo_path) + + def worktree_move(main_repo: Path, old_path: Path, new_path: Path) -> None: """Run `git worktree move ` from main_repo. diff --git a/src/canopy/mcp/server.py b/src/canopy/mcp/server.py index c9b6bf1..86f33c8 100644 --- a/src/canopy/mcp/server.py +++ b/src/canopy/mcp/server.py @@ -1534,14 +1534,10 @@ def workspace_reinit(name: str | None = None, dry_run: bool = False) -> dict: d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".") ] skipped = [d.name for d in all_dirs if not (d / ".git").exists()] - worktrees_dir = root / ".canopy" / "worktrees" - active_worktrees: dict[str, list[str]] = {} - if worktrees_dir.is_dir(): - for feat_dir in worktrees_dir.iterdir(): - if feat_dir.is_dir(): - active_worktrees[feat_dir.name] = sorted( - d.name for d in feat_dir.iterdir() if d.is_dir() - ) + # Keyed by FEATURE — slot dirs resolve their occupant via slots.json + # rather than being reported as if the slot id were a feature name. + from ..workspace.discovery import summarize_worktree_dirs + active_worktrees: dict[str, list[str]] = summarize_worktree_dirs(root) return { "root": str(root), diff --git a/src/canopy/workspace/discovery.py b/src/canopy/workspace/discovery.py index cf597c3..e3d6785 100644 --- a/src/canopy/workspace/discovery.py +++ b/src/canopy/workspace/discovery.py @@ -195,3 +195,44 @@ def _guess_role(name: str, lang: str) -> str: return "frontend" return "" + + +def summarize_worktree_dirs(root: Path) -> dict[str, list[str]]: + """Map feature name → repo subdirs present in its worktree slot. + + Used by ``canopy init`` / ``workspace_reinit`` to report existing + worktrees. Wave 3.0 worktree dirs are generic numbered SLOTS + (``worktree-N``) whose occupant feature lives in slots.json — so a slot + id must be resolved to its feature, not reported AS the feature. Pre-3.0 + dirs are feature-named and map directly. An orphan slot (dir present, no + occupant in slots.json) falls back to the slot id as the key. + """ + import json + import re + + wt_root = root / ".canopy" / "worktrees" + if not wt_root.is_dir(): + return {} + + slot_feature: dict[str, str | None] = {} + state_path = root / ".canopy" / "state" / "slots.json" + if state_path.exists(): + try: + data = json.loads(state_path.read_text()) + for sid, entry in (data.get("slots") or {}).items(): + if isinstance(entry, dict): + slot_feature[sid] = entry.get("feature") + except (OSError, ValueError): + pass + + out: dict[str, list[str]] = {} + for d in sorted(wt_root.iterdir()): + if not d.is_dir(): + continue + repos = sorted(r.name for r in d.iterdir() if r.is_dir()) + if re.fullmatch(r"worktree-\d+", d.name): + key = slot_feature.get(d.name) or d.name # feature, else slot id + else: + key = d.name # pre-3.0 feature-named dir + out[key] = repos + return out diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 0bf7704..9adfcab 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -208,3 +208,20 @@ def test_bootstrap_feature_blocks_when_no_worktrees(workspace_with_bootstrap_con with pytest.raises(BlockerError) as e: bootstrap_feature(workspace_with_bootstrap_config, "auth-flow") assert e.value.code == "no_worktrees" + + +def test_resolve_worktree_paths_uses_slots_json_for_warm_feature(workspace_with_slots): + """Wave 3.0: a warm feature's worktree paths come from its SLOT, not the + legacy features.json worktree_paths cache (which is empty in 3.0). Before + this fix, bootstrap raised no_worktrees for every warm 3.0 feature.""" + from canopy.actions.bootstrap import _resolve_worktree_paths + from canopy.actions import slots as sm + + ws = workspace_with_slots # Y warm in worktree-1 (repo-a + repo-b) + sid = sm.slot_for_feature(ws, "Y") + assert sid is not None + + paths = _resolve_worktree_paths(ws, "Y") + assert set(paths) == {"repo-a", "repo-b"} + assert paths["repo-a"] == sm.slot_worktree_path(ws, sid, "repo-a") + assert (paths["repo-a"] / ".git").exists() diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 613abe9..0c2896e 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -86,6 +86,53 @@ def test_feature_status(canopy_toml, workspace_with_feature): assert lane.repo_states["repo-b"]["ahead"] >= 1 +def test_status_respects_per_repo_branch_override(canopy_toml): + """A feature whose branch differs from its name in one repo (per-repo + `branches` override) must be enriched against branch_for(repo), not the + bare feature name. Regression for the branch==feature-name coupling that + survived in coordinator internals after the alias layer was fixed. + """ + import subprocess + root = canopy_toml + api, ui = root / "repo-a", root / "repo-b" + + # repo-a uses a MISMATCHED branch name; repo-b matches the feature name. + subprocess.run(["git", "checkout", "-b", "auth-flow-v2"], cwd=api, check=True) + (api / "x.py").write_text("a\n") + subprocess.run(["git", "add", "."], cwd=api, check=True) + subprocess.run(["git", "commit", "-qm", "wip"], cwd=api, check=True) + subprocess.run(["git", "checkout", "-b", "auth-flow"], cwd=ui, check=True) + (ui / "y.ts").write_text("b\n") + subprocess.run(["git", "add", "."], cwd=ui, check=True) + subprocess.run(["git", "commit", "-qm", "wip"], cwd=ui, check=True) + # Park both repos back on main. + subprocess.run(["git", "checkout", "main"], cwd=api, check=True) + subprocess.run(["git", "checkout", "main"], cwd=ui, check=True) + + (root / ".canopy").mkdir(exist_ok=True) + (root / ".canopy" / "features.json").write_text(json.dumps({ + "auth-flow": { + "repos": ["repo-a", "repo-b"], "status": "active", + "branches": {"repo-a": "auth-flow-v2"}, + }, + })) + + coord = FeatureCoordinator(Workspace(load_config(root))) + lane = coord.status("auth-flow") + + # repo-a's real branch is auth-flow-v2 — detected via branch_for, not "auth-flow". + assert lane.repo_states["repo-a"]["has_branch"] is True + assert lane.repo_states["repo-a"]["ahead"] >= 1 + assert lane.repo_states["repo-b"]["has_branch"] is True + + # feature_changes must scan repo-a's override branch, not the feature name. + changes = coord.feature_changes("auth-flow") + assert changes["repos"]["repo-a"]["has_branch"] is True + assert any( + c["path"] == "x.py" for c in changes["repos"]["repo-a"]["changes"] + ) + + def test_feature_diff(canopy_toml, workspace_with_feature): config = load_config(workspace_with_feature) ws = Workspace(config) diff --git a/tests/test_discovery_worktrees.py b/tests/test_discovery_worktrees.py new file mode 100644 index 0000000..d39c7e1 --- /dev/null +++ b/tests/test_discovery_worktrees.py @@ -0,0 +1,50 @@ +"""summarize_worktree_dirs — slot-aware worktree reporting for init/reinit. + +Wave 3.0 worktree dirs are generic slots (worktree-N); their occupant +feature lives in slots.json. The summary must resolve slot → feature, not +report the slot id as if it were a feature name (the Axis-1 bug). +""" +from __future__ import annotations + +import json + +from canopy.workspace.discovery import summarize_worktree_dirs + + +def test_summarize_resolves_slot_id_to_feature(workspace_with_slots): + """A 3.0 slot dir is keyed by its occupant feature, not the slot id.""" + root = workspace_with_slots.config.root + summary = summarize_worktree_dirs(root) + # Y occupies worktree-1 → keyed by "Y", NOT "worktree-1". + assert "Y" in summary + assert "worktree-1" not in summary + assert set(summary["Y"]) == {"repo-a", "repo-b"} + + +def test_summarize_orphan_slot_falls_back_to_slot_id(workspace_with_slots): + """A slot dir with no occupant in slots.json keys by the slot id.""" + import shutil + from canopy.actions import slots as sm + + root = workspace_with_slots.config.root + # Drop Y's slot entry from slots.json but leave the dir → orphan slot. + state = sm.read_state(workspace_with_slots) + state.slots.clear() + sm.write_state(workspace_with_slots, state) + + summary = summarize_worktree_dirs(root) + assert "worktree-1" in summary # no feature to resolve → slot id key + assert "Y" not in summary + + +def test_summarize_legacy_feature_named_dirs(tmp_path): + """Pre-3.0 feature-named dirs (no slots.json) map directly.""" + wt = tmp_path / ".canopy" / "worktrees" / "auth-flow" + (wt / "repo-a").mkdir(parents=True) + (wt / "repo-b").mkdir(parents=True) + summary = summarize_worktree_dirs(tmp_path) + assert summary == {"auth-flow": ["repo-a", "repo-b"]} + + +def test_summarize_empty_when_no_worktrees(tmp_path): + assert summarize_worktree_dirs(tmp_path) == {} diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 7822f46..6d975ab 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -894,3 +894,81 @@ def test_doctor_flags_detached_head_separately(workspace_with_slots): and f.get("details", {}).get("repo") == "repo-a" ] assert not mismatches, "detached HEAD should not double-fire as mismatch" + + +def _orphan_one_repo_worktree(root, slot, repo): + """Delete ONE repo's worktree from a slot (leaving the slot's other + repos + top dir), prune git's registration. Reproduces the per-repo + divergence that bricked canopy-test: worktree-N/ exists, but + worktree-N//.git is gone.""" + import shutil, subprocess + wt_repo = root / ".canopy/worktrees" / slot / repo + shutil.rmtree(wt_repo) + subprocess.run(["git", "worktree", "prune"], cwd=root / repo, check=True) + + +def test_doctor_finds_slot_repo_worktree_missing(workspace_with_slots): + """A slot entry whose per-repo worktree dir is gone — while the slot's + OTHER repos survive — fires slot_repo_worktree_missing. + + This is the divergence the slot-level checks miss: slot_entry_orphan + only inspects the worktree-N/ top dir (still present here), and + slot_branch_mismatch skips repos whose path doesn't exist. + """ + root = workspace_with_slots.config.root + _orphan_one_repo_worktree(root, "worktree-1", "repo-a") + + result = doctor(workspace_with_slots) + findings = [f for f in result["issues"] if f["code"] == "slot_repo_worktree_missing"] + assert findings, "expected slot_repo_worktree_missing finding" + f = findings[0] + assert f["repo"] == "repo-a" + assert f["feature"] == "Y" + assert f["severity"] == "error" + assert f["auto_fixable"] is True + # The top-dir-based check must NOT fire — worktree-1/ still exists. + assert not [i for i in result["issues"] if i["code"] == "slot_entry_orphan"] + + +def test_doctor_repairs_slot_repo_worktree_missing(workspace_with_slots): + """--fix recreates the missing per-repo worktree on the feature's branch.""" + from canopy.git import repo as git + root = workspace_with_slots.config.root + _orphan_one_repo_worktree(root, "worktree-1", "repo-a") + + result = doctor(workspace_with_slots, fix=True) + + fixed = [r for r in result["fixed"] if r["code"] == "slot_repo_worktree_missing"] + assert fixed and fixed[0]["success"] + wt_repo_a = root / ".canopy/worktrees/worktree-1/repo-a" + assert (wt_repo_a / ".git").exists() + assert git.current_branch(wt_repo_a) == "Y" + # Re-running doctor is clean. + again = doctor(workspace_with_slots) + assert not [i for i in again["issues"] if i["code"] == "slot_repo_worktree_missing"] + + +def test_doctor_slot_repo_worktree_missing_not_autofixable_when_branch_gone( + workspace_with_slots, +): + """If the feature's branch is also gone, the worktree can't be recreated — + flag it but defer to branches_missing rather than offering a bad auto-fix.""" + import json as _json + import subprocess + root = workspace_with_slots.config.root + # Declare Y in features.json so repos_for_feature keeps listing repo-a + # even after its branch is deleted (real features aren't branch-derived). + (root / ".canopy" / "features.json").write_text(_json.dumps({ + "Y": {"repos": ["repo-a", "repo-b"], "status": "active"}, + })) + _orphan_one_repo_worktree(root, "worktree-1", "repo-a") + # Delete branch Y in repo-a so recreation is impossible. + subprocess.run(["git", "branch", "-D", "Y"], cwd=root / "repo-a", check=True) + + result = doctor(workspace_with_slots) + findings = [ + f for f in result["issues"] + if f["code"] == "slot_repo_worktree_missing" and f["repo"] == "repo-a" + ] + assert findings + assert findings[0]["auto_fixable"] is False diff --git a/tests/test_switch.py b/tests/test_switch.py index 6e843df..e1cee0c 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -331,3 +331,95 @@ def boom(*a, **k): assert s["new_thread_count"] == 0 # Switch itself must not have failed. assert result["feature"] == "Y" + + +# ── orphaned-warm-worktree regression (canopy-test billing-export lock-out) ── + + +def _orphan_repo_worktree(ws, feature, repo): + """Simulate a slot whose per-repo worktree dir vanished while the slot + entry survives in slots.json — the divergence that bricked canopy-test. + + Deletes the repo subdir of ``feature``'s slot and prunes git's worktree + registration (matching the real state: `git worktree list` showed none), + but leaves the slot's top dir + other repo subdirs so read_state keeps + the slot entry. + """ + import shutil + from canopy.actions import slots as sm + + sid = sm.slot_for_feature(ws, feature) + assert sid is not None, f"{feature!r} must be warm to orphan it" + wt_repo = sm.slot_worktree_path(ws, sid, repo) + assert wt_repo.exists() + shutil.rmtree(wt_repo) + subprocess.run( + ["git", "worktree", "prune"], cwd=ws.config.root / repo, check=True, + ) + return sid + + +def test_switch_to_warm_feature_with_orphaned_repo_worktree_reclaims_slot( + workspace_with_full_slots, +): + """Regression: Y is warm but one repo's worktree dir is missing. + + switch(Y) must reclaim Y's vacated slot for the outgoing canonical X + rather than raising no_free_slot. Exactly the canopy-test failure where + `billing-export` was warm in worktree-1 but worktree-1/canopy-test-api + had no .git, so cold-Y allocation found both slots full. + """ + ws = workspace_with_full_slots # X canonical; A in wt-1, B in wt-2 + from canopy.actions import slots as sm + from canopy.actions.switch import switch + + sid = _orphan_repo_worktree(ws, "A", "repo-a") + + # Must NOT raise no_free_slot. + result = switch(ws, "A") + + assert result["feature"] == "A" + state = sm.read_state(ws) + assert state is not None + assert state.canonical is not None + assert state.canonical.feature == "A" + # X reclaimed A's vacated slot; both repo worktrees exist there again. + assert state.slots[sid].feature == "X" + assert (sm.slot_worktree_path(ws, sid, "repo-a") / ".git").exists() + assert (sm.slot_worktree_path(ws, sid, "repo-b") / ".git").exists() + # B is still warm in its (other) slot — untouched by the A↔X rotation. + warm = {e.feature: s for s, e in state.slots.items()} + assert "B" in warm and warm["B"] != sid + # Clean completion clears any in_flight. + assert state.in_flight is None + + +def test_no_free_slot_on_first_repo_does_not_stamp_in_flight( + workspace_with_canonical_only, monkeypatch, +): + """A pre-mutation no_free_slot on the first repo must NOT brick the + workspace with a false in_flight marker. + + The failure happens before any git mutation (allocate_slot is pure), so + per_repo_completed would be empty — nothing is partially flipped. Stamping + in_flight there permanently locks out switching via slot_state_inconsistent. + """ + ws = workspace_with_canonical_only # X canonical, Y cold + from canopy.actions import slots as sm + from canopy.actions.errors import BlockerError + from canopy.actions.switch import switch + + # Force the cold-Y allocator to fail on the first repo. + monkeypatch.setattr( + "canopy.actions.switch.slots_mod.allocate_slot", lambda state: None, + ) + + with pytest.raises(BlockerError) as e: + switch(ws, "Y") + assert e.value.code == "no_free_slot" + + state = sm.read_state(ws) + assert state is not None + assert state.in_flight is None, ( + "pre-mutation failure on the first repo must not stamp in_flight" + )