Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/canopy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""Canopy — workspace-first development orchestrator."""
__version__ = "3.1.1"
__version__ = "3.1.2"
28 changes: 26 additions & 2 deletions src/canopy/actions/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {}
Expand Down Expand Up @@ -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/<repo>``. 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():
Expand Down
110 changes: 109 additions & 1 deletion src/canopy/actions/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (``<feature>/<repo>``). The Wave-3.0 slot layout
(``worktree-N/<repo>``) 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 []
Expand All @@ -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 []
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
49 changes: 37 additions & 12 deletions src/canopy/actions/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand All @@ -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:
Expand Down
38 changes: 13 additions & 25 deletions src/canopy/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [{
Expand Down Expand Up @@ -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()


Expand Down
Loading
Loading