canopy-cli 3.1.1__tar.gz → 3.1.2__tar.gz
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_cli-3.1.1 → canopy_cli-3.1.2}/.github/workflows/ci.yml +0 -35
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/CHANGELOG.md +32 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/PKG-INFO +2 -2
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/README.md +1 -1
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/__init__.py +1 -1
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/bootstrap.py +26 -2
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/doctor.py +109 -1
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/switch.py +37 -12
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/cli/main.py +13 -25
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/features/coordinator.py +18 -7
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/git/repo.py +9 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/mcp/server.py +4 -8
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/workspace/discovery.py +41 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_bootstrap.py +17 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_coordinator.py +47 -0
- canopy_cli-3.1.2/tests/test_discovery_worktrees.py +50 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_doctor.py +78 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_switch.py +92 -0
- canopy_cli-3.1.1/vscode-extension/.gitignore +0 -5
- canopy_cli-3.1.1/vscode-extension/.vscodeignore +0 -14
- canopy_cli-3.1.1/vscode-extension/CHANGELOG.md +0 -128
- canopy_cli-3.1.1/vscode-extension/LICENSE +0 -21
- canopy_cli-3.1.1/vscode-extension/README.md +0 -82
- canopy_cli-3.1.1/vscode-extension/esbuild.config.mjs +0 -71
- canopy_cli-3.1.1/vscode-extension/jest.config.ts +0 -22
- canopy_cli-3.1.1/vscode-extension/media/canopy-icon.png +0 -0
- canopy_cli-3.1.1/vscode-extension/media/canopy-icon.svg +0 -588
- canopy_cli-3.1.1/vscode-extension/media/screenshots/dashboard-feature.png +0 -0
- canopy_cli-3.1.1/vscode-extension/media/screenshots/dashboard-global.png +0 -0
- canopy_cli-3.1.1/vscode-extension/package-lock.json +0 -9054
- canopy_cli-3.1.1/vscode-extension/package.json +0 -297
- canopy_cli-3.1.1/vscode-extension/src/__mocks__/vscode.ts +0 -98
- canopy_cli-3.1.1/vscode-extension/src/canopyCli.test.ts +0 -196
- canopy_cli-3.1.1/vscode-extension/src/canopyCli.ts +0 -862
- canopy_cli-3.1.1/vscode-extension/src/canopyClient.ts +0 -704
- canopy_cli-3.1.1/vscode-extension/src/cliResolver.test.ts +0 -122
- canopy_cli-3.1.1/vscode-extension/src/cliResolver.ts +0 -134
- canopy_cli-3.1.1/vscode-extension/src/commands/createFeature.ts +0 -161
- canopy_cli-3.1.1/vscode-extension/src/commands/createFeatureFromIssue.ts +0 -85
- canopy_cli-3.1.1/vscode-extension/src/commands/installBackend.ts +0 -218
- canopy_cli-3.1.1/vscode-extension/src/commands/setupWizard.ts +0 -103
- canopy_cli-3.1.1/vscode-extension/src/extension.ts +0 -828
- canopy_cli-3.1.1/vscode-extension/src/mcpResolver.ts +0 -130
- canopy_cli-3.1.1/vscode-extension/src/stateReader.test.ts +0 -273
- canopy_cli-3.1.1/vscode-extension/src/stateReader.ts +0 -287
- canopy_cli-3.1.1/vscode-extension/src/statusBar.ts +0 -96
- canopy_cli-3.1.1/vscode-extension/src/types.ts +0 -192
- canopy_cli-3.1.1/vscode-extension/src/views/GlobalDashboardPanel.ts +0 -1020
- canopy_cli-3.1.1/vscode-extension/src/views/canopyTreeProvider.ts +0 -269
- canopy_cli-3.1.1/vscode-extension/src/views/themeShim.ts +0 -113
- canopy_cli-3.1.1/vscode-extension/src/watchers.ts +0 -79
- canopy_cli-3.1.1/vscode-extension/src/webview/cockpitPanel.ts +0 -395
- canopy_cli-3.1.1/vscode-extension/src/webview/components/branchLedger.ts +0 -72
- canopy_cli-3.1.1/vscode-extension/src/webview/components/bridge.ts +0 -72
- canopy_cli-3.1.1/vscode-extension/src/webview/components/capReachedModal.ts +0 -112
- canopy_cli-3.1.1/vscode-extension/src/webview/components/focusTile.ts +0 -139
- canopy_cli-3.1.1/vscode-extension/src/webview/components/newFeatureForm.ts +0 -174
- canopy_cli-3.1.1/vscode-extension/src/webview/components/styles.ts +0 -691
- canopy_cli-3.1.1/vscode-extension/src/webview/components/triageFeed.ts +0 -91
- canopy_cli-3.1.1/vscode-extension/src/webview/components/util.ts +0 -40
- canopy_cli-3.1.1/vscode-extension/src/webview/components/worktreeRow.ts +0 -71
- canopy_cli-3.1.1/vscode-extension/src/webview/dashboardPanel.ts +0 -904
- canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/Dashboard.tsx +0 -137
- canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/FeatureView.tsx +0 -972
- canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/GlobalView.tsx +0 -649
- canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/Skeletons.tsx +0 -38
- canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/diff.ts +0 -118
- canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/index.tsx +0 -17
- canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/protocol.ts +0 -129
- canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/vscode.ts +0 -30
- canopy_cli-3.1.1/vscode-extension/src/webview/newFeaturePanel.ts +0 -322
- canopy_cli-3.1.1/vscode-extension/src/webview/shared/pastel.css +0 -801
- canopy_cli-3.1.1/vscode-extension/src/webview/themes/index.ts +0 -42
- canopy_cli-3.1.1/vscode-extension/src/webview/themes/minimal.ts +0 -62
- canopy_cli-3.1.1/vscode-extension/src/webview/themes/navy.ts +0 -60
- canopy_cli-3.1.1/vscode-extension/src/webview/themes/render.ts +0 -96
- canopy_cli-3.1.1/vscode-extension/src/webview/themes/types.ts +0 -104
- canopy_cli-3.1.1/vscode-extension/tsconfig.json +0 -20
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/.github/workflows/release.yml +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/.gitignore +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/AGENTS.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/CLAUDE.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/agents.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/architecture/providers.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/architecture.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/archive/test-plan.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/canopy-banner.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-commit.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-config.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-done.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-drift.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-init.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-list.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-push.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-review.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-setup-agent.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-stage.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-state.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-status.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-switch.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-triage.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-worktree.svg +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/commands.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/concepts.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/mcp.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/workspace.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/gen_svgs.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/mockups/canopy-vscode.html +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/pyproject.toml +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/aliases.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/augments.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/bot_resolutions.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/bot_status.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/commit.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/conflicts.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/draft_replies.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/drift.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/errors.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/evacuate.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/feature_state.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/historian.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/ide_workspace.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/last_visit.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/migrate_slots.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/preflight_state.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/push.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/reads.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/resume.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/review_filter.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/ship.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/slot_details.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/slot_load.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/slots.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/stash.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/switch_preflight.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/thread_actions.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/thread_resolutions.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/triage.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/agent/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/agent/runner.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/agent_setup/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/agent_setup/skills/augment-canopy/SKILL.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/agent_setup/skills/using-canopy/SKILL.md +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/cli/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/cli/render.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/cli/ui.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/features/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/git/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/git/hooks.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/git/multi.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/git/templates/post-checkout.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/graph/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/integrations/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/integrations/github.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/integrations/linear.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/integrations/precommit.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/mcp/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/mcp/client.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/providers/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/providers/github_issues.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/providers/linear.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/providers/types.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/workspace/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/workspace/config.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/workspace/context.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/workspace/workspace.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/__init__.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/conftest.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_action_errors.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_agent_setup.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_aliases.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_augments.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_bot_resolutions.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_bot_status.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_ci_status.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_commit.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_commit_push_integration.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_config.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_config_done.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_conflicts.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_context_stage.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_draft_replies.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_drift.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_evacuate.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_feature_state.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_feature_state_bot.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_historian.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_hooks.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_last_visit.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_linear_integration.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_mcp_server_worktree_create.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_migrate_slots.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_new_commands.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_precommit.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_providers_github_issues.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_providers_linear.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_providers_registry.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_providers_types.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_push.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_reads.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_repo.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_resume.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_review.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_review_filter.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_runner.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_ship.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_slot_details.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_slot_load.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_slots.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_stash_features.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_switch_action.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_switch_preflight.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_thread_actions.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_thread_graphql.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_triage.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_workspace.py +0 -0
- {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_worktree_features.py +0 -0
|
@@ -39,38 +39,3 @@ jobs:
|
|
|
39
39
|
|
|
40
40
|
- name: Run pytest
|
|
41
41
|
run: pytest tests/ -v --maxfail=5
|
|
42
|
-
|
|
43
|
-
extension-build:
|
|
44
|
-
name: VSCode extension build
|
|
45
|
-
runs-on: ubuntu-latest
|
|
46
|
-
defaults:
|
|
47
|
-
run:
|
|
48
|
-
working-directory: vscode-extension
|
|
49
|
-
steps:
|
|
50
|
-
- uses: actions/checkout@v4
|
|
51
|
-
|
|
52
|
-
- uses: actions/setup-node@v4
|
|
53
|
-
with:
|
|
54
|
-
node-version: "20"
|
|
55
|
-
cache: npm
|
|
56
|
-
cache-dependency-path: vscode-extension/package-lock.json
|
|
57
|
-
|
|
58
|
-
- name: Install dependencies
|
|
59
|
-
run: npm ci
|
|
60
|
-
|
|
61
|
-
- name: Type-check
|
|
62
|
-
run: npx tsc --noEmit
|
|
63
|
-
|
|
64
|
-
- name: Bundle with esbuild
|
|
65
|
-
run: npm run build
|
|
66
|
-
|
|
67
|
-
- name: Package VSIX (sanity check)
|
|
68
|
-
run: npx vsce package --allow-missing-repository
|
|
69
|
-
|
|
70
|
-
- name: Upload VSIX artifact
|
|
71
|
-
uses: actions/upload-artifact@v4
|
|
72
|
-
with:
|
|
73
|
-
name: canopy-vsix
|
|
74
|
-
path: vscode-extension/*.vsix
|
|
75
|
-
if-no-files-found: error
|
|
76
|
-
retention-days: 14
|
|
@@ -4,6 +4,38 @@ Tracks the Python side (CLI + MCP server). The VSCode extension has its own [vsc
|
|
|
4
4
|
|
|
5
5
|
Versions follow semver. Pre-1.0 — minor bumps may add features or break behavior; the README is the source-of-truth contract.
|
|
6
6
|
|
|
7
|
+
## 3.1.2 — 2026-07-04
|
|
8
|
+
|
|
9
|
+
Slot-model consistency fixes from canopy-test dogfooding.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `switch`: on the cold-Y fall-through (Y has a slots.json entry but this
|
|
13
|
+
repo's slot dir is missing), the vacating feature's slot is reclaimed for
|
|
14
|
+
the outgoing feature instead of allocating a fresh one — previously the
|
|
15
|
+
about-to-be-freed slot counted against the cap and raised a bogus
|
|
16
|
+
`no_free_slot`.
|
|
17
|
+
- `switch`: precondition failures raised before any git mutation
|
|
18
|
+
(`no_free_slot`, `unknown_slot`, `evict_to_occupied`,
|
|
19
|
+
`warm_worktree_dirty_on_promote`) no longer stamp the `in_flight` marker
|
|
20
|
+
when no repo has been touched — a clean no-op failure used to brick every
|
|
21
|
+
subsequent switch via `slot_state_inconsistent`.
|
|
22
|
+
- `doctor`: new `slot_repo_worktree_missing` check (+ auto-repair via
|
|
23
|
+
`git worktree prune` + `worktree add`) catches half-materialized slots
|
|
24
|
+
where a slot holds a feature but one repo's worktree is gone.
|
|
25
|
+
- `doctor`: the pre-3.0 `worktree_orphan` check now skips `worktree-N` slot
|
|
26
|
+
dirs — `doctor --fix` no longer deletes warm slots.
|
|
27
|
+
- `worktree_bootstrap`: resolves worktree paths via slots.json (the 3.0
|
|
28
|
+
source of truth) instead of the legacy `features.json` cache, which is
|
|
29
|
+
empty in 3.0 — bootstrap raised `no_worktrees` for every warm feature.
|
|
30
|
+
Falls back to the legacy cache for pre-3.0 workspaces.
|
|
31
|
+
- `canopy init` / `workspace_reinit`: existing worktrees are reported by
|
|
32
|
+
occupant feature (resolved via slots.json) instead of listing slot ids
|
|
33
|
+
(`worktree-N`) as if they were feature names.
|
|
34
|
+
- `coordinator.status()` / `feature_changes`: honor the per-repo `branches`
|
|
35
|
+
map (`lane.branch_for`) instead of assuming branch == feature name —
|
|
36
|
+
mismatched-naming features no longer mis-report as having no branch or no
|
|
37
|
+
changes.
|
|
38
|
+
|
|
7
39
|
## 3.1.1 — 2026-05-31
|
|
8
40
|
|
|
9
41
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: canopy-cli
|
|
3
|
-
Version: 3.1.
|
|
3
|
+
Version: 3.1.2
|
|
4
4
|
Summary: Workspace-first development orchestrator for AI coding agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/ashmitb95/canopy
|
|
6
6
|
Project-URL: Source, https://github.com/ashmitb95/canopy
|
|
@@ -254,7 +254,7 @@ canopy doctor # diagnose drift / staleness
|
|
|
254
254
|
<img src="docs/cli-state.svg" alt="canopy state" width="720">
|
|
255
255
|
</p>
|
|
256
256
|
|
|
257
|
-
The CLI and MCP server are thin wrappers over the same actions — `canopy state X` and `mcp__canopy__feature_state(feature='X')` return identical bytes. There's also a [VSCode extension](https://marketplace.visualstudio.com/items?itemName=SingularityInc.canopy) reading the same state the agent reads.
|
|
257
|
+
The CLI and MCP server are thin wrappers over the same actions — `canopy state X` and `mcp__canopy__feature_state(feature='X')` return identical bytes. There's also a [VSCode extension](https://marketplace.visualstudio.com/items?itemName=SingularityInc.canopy) (source at [`ashmitb95/canopy-dashboard`](https://github.com/ashmitb95/canopy-dashboard)) reading the same state the agent reads.
|
|
258
258
|
|
|
259
259
|
Full CLI reference: [docs/commands.md](docs/commands.md).
|
|
260
260
|
|
|
@@ -226,7 +226,7 @@ canopy doctor # diagnose drift / staleness
|
|
|
226
226
|
<img src="docs/cli-state.svg" alt="canopy state" width="720">
|
|
227
227
|
</p>
|
|
228
228
|
|
|
229
|
-
The CLI and MCP server are thin wrappers over the same actions — `canopy state X` and `mcp__canopy__feature_state(feature='X')` return identical bytes. There's also a [VSCode extension](https://marketplace.visualstudio.com/items?itemName=SingularityInc.canopy) reading the same state the agent reads.
|
|
229
|
+
The CLI and MCP server are thin wrappers over the same actions — `canopy state X` and `mcp__canopy__feature_state(feature='X')` return identical bytes. There's also a [VSCode extension](https://marketplace.visualstudio.com/items?itemName=SingularityInc.canopy) (source at [`ashmitb95/canopy-dashboard`](https://github.com/ashmitb95/canopy-dashboard)) reading the same state the agent reads.
|
|
230
230
|
|
|
231
231
|
Full CLI reference: [docs/commands.md](docs/commands.md).
|
|
232
232
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""Canopy — workspace-first development orchestrator."""
|
|
2
|
-
__version__ = "3.1.
|
|
2
|
+
__version__ = "3.1.2"
|
|
@@ -63,7 +63,10 @@ def bootstrap_feature(
|
|
|
63
63
|
if not worktree_paths:
|
|
64
64
|
raise BlockerError(
|
|
65
65
|
code="no_worktrees",
|
|
66
|
-
what=
|
|
66
|
+
what=(
|
|
67
|
+
f"feature '{feature_name}' is not warm in any slot "
|
|
68
|
+
f"(nothing to bootstrap) — `canopy switch {feature_name}` first"
|
|
69
|
+
),
|
|
67
70
|
)
|
|
68
71
|
|
|
69
72
|
results: dict[str, dict[str, Any]] = {}
|
|
@@ -235,7 +238,28 @@ def _validate_steps(steps: Iterable[str] | None) -> set[str]:
|
|
|
235
238
|
def _resolve_worktree_paths(
|
|
236
239
|
workspace: Workspace, feature_name: str,
|
|
237
240
|
) -> dict[str, Path]:
|
|
238
|
-
"""
|
|
241
|
+
"""Resolve each repo's warm worktree dir for ``feature_name``.
|
|
242
|
+
|
|
243
|
+
Wave 3.0: the authoritative source is slots.json — a warm feature's
|
|
244
|
+
per-repo worktrees live under its slot at
|
|
245
|
+
``.canopy/worktrees/worktree-N/<repo>``. Falls back to the legacy
|
|
246
|
+
``features.json`` ``worktree_paths`` cache for pre-3.0 workspaces (no
|
|
247
|
+
slots.json). The old code read only the legacy cache, which is empty in
|
|
248
|
+
3.0 — so bootstrap raised ``no_worktrees`` for every warm feature.
|
|
249
|
+
"""
|
|
250
|
+
from . import slots as slots_mod
|
|
251
|
+
from .aliases import repos_for_feature
|
|
252
|
+
|
|
253
|
+
slot_id = slots_mod.slot_for_feature(workspace, feature_name)
|
|
254
|
+
if slot_id is not None:
|
|
255
|
+
out: dict[str, Path] = {}
|
|
256
|
+
for repo_name in repos_for_feature(workspace, feature_name):
|
|
257
|
+
p = slots_mod.slot_worktree_path(workspace, slot_id, repo_name)
|
|
258
|
+
if (p / ".git").exists():
|
|
259
|
+
out[repo_name] = p
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
# Legacy pre-3.0 fallback: features.json worktree_paths cache.
|
|
239
263
|
import json
|
|
240
264
|
path = workspace.config.root / ".canopy" / "features.json"
|
|
241
265
|
if not path.exists():
|
|
@@ -222,7 +222,14 @@ def check_active_feature_path_missing(workspace: Workspace) -> list[Issue]:
|
|
|
222
222
|
|
|
223
223
|
|
|
224
224
|
def check_worktree_orphan(workspace: Workspace) -> list[Issue]:
|
|
225
|
-
"""Worktree directories under .canopy/worktrees/ not referenced by any feature.
|
|
225
|
+
"""Worktree directories under .canopy/worktrees/ not referenced by any feature.
|
|
226
|
+
|
|
227
|
+
Pre-3.0 layout only (``<feature>/<repo>``). The Wave-3.0 slot layout
|
|
228
|
+
(``worktree-N/<repo>``) is owned by the ``slot_*`` checks — skip those
|
|
229
|
+
dirs here, or this check would flag every warm slot as an orphan and
|
|
230
|
+
``--fix`` would delete it.
|
|
231
|
+
"""
|
|
232
|
+
import re
|
|
226
233
|
wt_root = workspace.config.root / ".canopy" / "worktrees"
|
|
227
234
|
if not wt_root.exists():
|
|
228
235
|
return []
|
|
@@ -231,6 +238,8 @@ def check_worktree_orphan(workspace: Workspace) -> list[Issue]:
|
|
|
231
238
|
for feat_dir in sorted(wt_root.iterdir()):
|
|
232
239
|
if not feat_dir.is_dir():
|
|
233
240
|
continue
|
|
241
|
+
if re.fullmatch(r"worktree-\d+", feat_dir.name):
|
|
242
|
+
continue # slot dir — handled by check_slot_* functions
|
|
234
243
|
feature_name = feat_dir.name
|
|
235
244
|
feature_data = features.get(feature_name)
|
|
236
245
|
feature_repos = (feature_data or {}).get("repos") or []
|
|
@@ -604,6 +613,66 @@ def check_slot_branch_mismatches(workspace: Workspace) -> list[Issue]:
|
|
|
604
613
|
return issues
|
|
605
614
|
|
|
606
615
|
|
|
616
|
+
def check_slot_repo_worktree_missing(workspace: Workspace) -> list[Issue]:
|
|
617
|
+
"""A slot holds feature F, but one of F's repos has no worktree on disk.
|
|
618
|
+
|
|
619
|
+
This is the per-repo divergence the other slot checks can't see:
|
|
620
|
+
``slot_entry_orphan`` only inspects the ``worktree-N/`` top dir (which
|
|
621
|
+
survives as long as ANY repo's subdir remains), and
|
|
622
|
+
``slot_branch_mismatch`` ``continue``s past a non-existent per-repo path.
|
|
623
|
+
A half-materialized slot bricked canopy-test (``switch`` then tried to
|
|
624
|
+
allocate a fresh slot for an already-occupied feature → ``no_free_slot``).
|
|
625
|
+
|
|
626
|
+
Auto-fixable by recreating the worktree from the feature's branch —
|
|
627
|
+
unless the branch itself is gone, in which case ``branches_missing``
|
|
628
|
+
owns the deeper problem and this is advice-only.
|
|
629
|
+
"""
|
|
630
|
+
from . import slots as slots_mod
|
|
631
|
+
from .aliases import repos_for_feature
|
|
632
|
+
|
|
633
|
+
state = slots_mod.read_state(workspace)
|
|
634
|
+
if state is None:
|
|
635
|
+
return []
|
|
636
|
+
issues: list[Issue] = []
|
|
637
|
+
for sid, entry in state.slots.items():
|
|
638
|
+
repo_branches = repos_for_feature(workspace, entry.feature) or {}
|
|
639
|
+
for repo_name, expected_branch in repo_branches.items():
|
|
640
|
+
slot_path = slots_mod.slot_worktree_path(workspace, sid, repo_name)
|
|
641
|
+
if (slot_path / ".git").exists():
|
|
642
|
+
continue
|
|
643
|
+
try:
|
|
644
|
+
rs = workspace.get_repo(repo_name)
|
|
645
|
+
except KeyError:
|
|
646
|
+
continue # features_unknown_repo owns this
|
|
647
|
+
branch_ok = rs.abs_path.exists() and git.branch_exists(
|
|
648
|
+
rs.abs_path, expected_branch,
|
|
649
|
+
)
|
|
650
|
+
issues.append(Issue(
|
|
651
|
+
code="slot_repo_worktree_missing",
|
|
652
|
+
severity="error",
|
|
653
|
+
what=(
|
|
654
|
+
f"slot '{sid}' is missing its '{repo_name}' worktree"
|
|
655
|
+
f" (feature '{entry.feature}', branch '{expected_branch}')"
|
|
656
|
+
),
|
|
657
|
+
expected=str(slot_path),
|
|
658
|
+
actual="(no worktree on disk)",
|
|
659
|
+
repo=repo_name,
|
|
660
|
+
feature=entry.feature,
|
|
661
|
+
fix_action=(
|
|
662
|
+
f"recreate: git worktree add {slot_path} {expected_branch}"
|
|
663
|
+
if branch_ok else
|
|
664
|
+
f"branch '{expected_branch}' is gone in {repo_name} —"
|
|
665
|
+
f" restore it (see branches_missing) before recreating"
|
|
666
|
+
),
|
|
667
|
+
auto_fixable=branch_ok,
|
|
668
|
+
details={
|
|
669
|
+
"slot": sid, "feature": entry.feature, "repo": repo_name,
|
|
670
|
+
"branch": expected_branch, "slot_path": str(slot_path),
|
|
671
|
+
},
|
|
672
|
+
))
|
|
673
|
+
return issues
|
|
674
|
+
|
|
675
|
+
|
|
607
676
|
# ── Install-staleness checks ─────────────────────────────────────────────
|
|
608
677
|
|
|
609
678
|
|
|
@@ -895,6 +964,7 @@ _CHECKS: dict[str, tuple[str, Any]] = {
|
|
|
895
964
|
"vsix_duplicates": ("vsix", check_vsix_duplicates),
|
|
896
965
|
"slot_dir_orphan": ("slots", check_slot_dir_orphans),
|
|
897
966
|
"slot_entry_orphan": ("slots", check_slot_entry_orphans),
|
|
967
|
+
"slot_repo_worktree_missing": ("slots", check_slot_repo_worktree_missing),
|
|
898
968
|
"slot_branch_mismatch": ("slots", check_slot_branch_mismatches),
|
|
899
969
|
# slot_detached_head shares its check function with slot_branch_mismatch
|
|
900
970
|
# (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
|
|
|
1276
1346
|
action_taken=f"dropped slots.json entry for '{sid}'")
|
|
1277
1347
|
|
|
1278
1348
|
|
|
1349
|
+
def repair_slot_repo_worktree_missing(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1350
|
+
"""Recreate the missing per-repo worktree from the feature's branch.
|
|
1351
|
+
|
|
1352
|
+
Restores the slot invariant rather than dropping the slot entry —
|
|
1353
|
+
dropping it would orphan the slot's surviving repos. Idempotent: a no-op
|
|
1354
|
+
if the worktree reappeared.
|
|
1355
|
+
"""
|
|
1356
|
+
d = issue.details or {}
|
|
1357
|
+
repo_name, branch, slot_path_s = d.get("repo"), d.get("branch"), d.get("slot_path")
|
|
1358
|
+
if not (repo_name and branch and slot_path_s):
|
|
1359
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
1360
|
+
error="missing repo/branch/slot_path on issue")
|
|
1361
|
+
slot_path = Path(slot_path_s)
|
|
1362
|
+
if (slot_path / ".git").exists():
|
|
1363
|
+
return RepairResult(code=issue.code, success=True, repo=repo_name,
|
|
1364
|
+
feature=issue.feature,
|
|
1365
|
+
action_taken="worktree already present")
|
|
1366
|
+
try:
|
|
1367
|
+
rs = workspace.get_repo(repo_name)
|
|
1368
|
+
except KeyError as e:
|
|
1369
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
1370
|
+
error=str(e), repo=repo_name)
|
|
1371
|
+
repo_root = git.worktree_main_path(rs.abs_path) or rs.abs_path
|
|
1372
|
+
slot_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1373
|
+
try:
|
|
1374
|
+
# Prune first so a stale registration for this path doesn't block add.
|
|
1375
|
+
git.worktree_prune(repo_root)
|
|
1376
|
+
git.worktree_add(repo_root, slot_path, branch, create_branch=False)
|
|
1377
|
+
except git.GitError as e:
|
|
1378
|
+
return RepairResult(code=issue.code, success=False, repo=repo_name,
|
|
1379
|
+
feature=issue.feature, action_taken="",
|
|
1380
|
+
error=str(e))
|
|
1381
|
+
return RepairResult(code=issue.code, success=True, repo=repo_name,
|
|
1382
|
+
feature=issue.feature,
|
|
1383
|
+
action_taken=f"git worktree add {slot_path} {branch}")
|
|
1384
|
+
|
|
1385
|
+
|
|
1279
1386
|
_REPAIRS: dict[str, Any] = {
|
|
1280
1387
|
"heads_stale": repair_heads_stale,
|
|
1281
1388
|
"active_feature_orphan": repair_active_feature_orphan,
|
|
@@ -1291,6 +1398,7 @@ _REPAIRS: dict[str, Any] = {
|
|
|
1291
1398
|
"mcp_orphans": repair_mcp_orphans,
|
|
1292
1399
|
"vsix_duplicates": repair_vsix_duplicates,
|
|
1293
1400
|
"slot_entry_orphan": repair_slot_entry_orphan,
|
|
1401
|
+
"slot_repo_worktree_missing": repair_slot_repo_worktree_missing,
|
|
1294
1402
|
# cli_stale, mcp_stale, features_unknown_repo, branches_missing,
|
|
1295
1403
|
# slot_dir_orphan, slot_branch_mismatch have no auto-fix —
|
|
1296
1404
|
# repair returns surfaced advice via the issue's `fix_action` instead.
|
|
@@ -34,6 +34,17 @@ from .aliases import resolve_feature, repos_for_feature
|
|
|
34
34
|
from .errors import BlockerError, FixAction
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
# Precondition BlockerError codes raised by _do_repo_switch BEFORE any git
|
|
38
|
+
# mutation in the failing repo. When one fires with no prior repo completed,
|
|
39
|
+
# the workspace is NOT partially flipped, so no in_flight marker is warranted.
|
|
40
|
+
_PRE_MUTATION_CODES = frozenset({
|
|
41
|
+
"no_free_slot",
|
|
42
|
+
"unknown_slot",
|
|
43
|
+
"evict_to_occupied",
|
|
44
|
+
"warm_worktree_dirty_on_promote",
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
|
|
37
48
|
def switch(
|
|
38
49
|
workspace: Workspace,
|
|
39
50
|
feature: str | None = None,
|
|
@@ -158,15 +169,20 @@ def switch(
|
|
|
158
169
|
evict_to=evict_to,
|
|
159
170
|
)
|
|
160
171
|
except BlockerError as e:
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
172
|
+
# A structured precondition failure raised BEFORE any git
|
|
173
|
+
# mutation (no_free_slot, dirty warm worktree, bad --evict-to)
|
|
174
|
+
# leaves disk untouched in this repo. Only stamp in_flight when
|
|
175
|
+
# something is actually half-flipped: an earlier repo already
|
|
176
|
+
# completed (per_repo_results non-empty), OR this was a mid-op
|
|
177
|
+
# failure (not one of the pre-mutation precondition codes).
|
|
178
|
+
# Otherwise a clean no-op failure would brick every future
|
|
179
|
+
# switch via slot_state_inconsistent.
|
|
180
|
+
if per_repo_results or e.code not in _PRE_MUTATION_CODES:
|
|
181
|
+
_persist_in_flight(
|
|
182
|
+
workspace, feature_name, previously_canonical,
|
|
183
|
+
failed_repo=repo_name, error_what=e.what or str(e),
|
|
184
|
+
completed_results=per_repo_results,
|
|
185
|
+
)
|
|
170
186
|
raise
|
|
171
187
|
except Exception as e:
|
|
172
188
|
# Mid-op failure with no rollback walker (yet). Surface enough
|
|
@@ -281,9 +297,14 @@ def _do_repo_switch(
|
|
|
281
297
|
per_repo_results.append(result)
|
|
282
298
|
return
|
|
283
299
|
# Fall through: Y's slot entry exists but this repo's slot
|
|
284
|
-
# dir is missing (partial-scope drift).
|
|
285
|
-
|
|
286
|
-
|
|
300
|
+
# dir is missing (orphaned worktree / partial-scope drift).
|
|
301
|
+
# Y is still being promoted to canonical, so it vacates its
|
|
302
|
+
# slot regardless — X reclaims y_slot below instead of needing
|
|
303
|
+
# a brand-new allocation. Allocating blindly here would count
|
|
304
|
+
# Y's own about-to-be-freed slot against the cap and raise a
|
|
305
|
+
# bogus no_free_slot (the canopy-test billing-export lock-out).
|
|
306
|
+
|
|
307
|
+
# Cold-Y path: allocate (or reclaim) the slot X will occupy.
|
|
287
308
|
state = slots_mod.read_state(workspace) or slots_mod.SlotState(
|
|
288
309
|
slot_count=workspace.config.slots,
|
|
289
310
|
)
|
|
@@ -304,6 +325,10 @@ def _do_repo_switch(
|
|
|
304
325
|
details={"slot": evict_to, "occupant": existing},
|
|
305
326
|
)
|
|
306
327
|
x_slot = evict_to
|
|
328
|
+
elif y_slot is not None:
|
|
329
|
+
# Y already owns y_slot and is leaving it (becoming canonical);
|
|
330
|
+
# X reclaims it. _post_switch_persist re-stamps the slot to X.
|
|
331
|
+
x_slot = y_slot
|
|
307
332
|
else:
|
|
308
333
|
x_slot = slots_mod.allocate_slot(state)
|
|
309
334
|
if x_slot is None:
|
|
@@ -118,15 +118,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
118
118
|
if args.json:
|
|
119
119
|
all_dirs = [d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")]
|
|
120
120
|
skipped = [d.name for d in all_dirs if not (d / ".git").exists()]
|
|
121
|
-
# Detect existing
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
for feat_dir in worktrees_dir.iterdir():
|
|
126
|
-
if feat_dir.is_dir():
|
|
127
|
-
active_worktrees[feat_dir.name] = sorted(
|
|
128
|
-
d.name for d in feat_dir.iterdir() if d.is_dir()
|
|
129
|
-
)
|
|
121
|
+
# Detect existing worktrees, keyed by FEATURE (slot dirs resolve their
|
|
122
|
+
# occupant via slots.json — not reported as if the slot id were a feature).
|
|
123
|
+
from ..workspace.discovery import summarize_worktree_dirs
|
|
124
|
+
active_worktrees = summarize_worktree_dirs(root)
|
|
130
125
|
_print_json({
|
|
131
126
|
"root": str(root),
|
|
132
127
|
"repos": [{
|
|
@@ -203,22 +198,15 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
203
198
|
console.print(f" mcp [muted]· {note}[/]")
|
|
204
199
|
console.print(f" [muted]Restart Claude Code to pick up the skill + MCP. Skip with --no-agent.[/]")
|
|
205
200
|
|
|
206
|
-
# Report existing
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
console.print()
|
|
215
|
-
console.print(f" [header]Active worktrees ({len(features_with_wt)})[/]")
|
|
216
|
-
for feat in features_with_wt:
|
|
217
|
-
feat_dir = worktrees_dir / feat
|
|
218
|
-
wt_repos = sorted(
|
|
219
|
-
d.name for d in feat_dir.iterdir() if d.is_dir()
|
|
220
|
-
)
|
|
221
|
-
console.print(f" [feature]{feat}[/] [muted]{SYM_ARROW}[/] {', '.join(wt_repos)}")
|
|
201
|
+
# Report existing worktrees under .canopy/, keyed by feature (slot dirs
|
|
202
|
+
# resolve their occupant via slots.json — see summarize_worktree_dirs).
|
|
203
|
+
from ..workspace.discovery import summarize_worktree_dirs
|
|
204
|
+
worktrees = summarize_worktree_dirs(root)
|
|
205
|
+
if worktrees:
|
|
206
|
+
console.print()
|
|
207
|
+
console.print(f" [header]Active worktrees ({len(worktrees)})[/]")
|
|
208
|
+
for feat, wt_repos in sorted(worktrees.items()):
|
|
209
|
+
console.print(f" [feature]{feat}[/] [muted]{SYM_ARROW}[/] {', '.join(wt_repos)}")
|
|
222
210
|
console.print()
|
|
223
211
|
|
|
224
212
|
|
|
@@ -314,6 +314,7 @@ class FeatureCoordinator:
|
|
|
314
314
|
linear_issue=data.get("linear_issue", ""),
|
|
315
315
|
linear_title=data.get("linear_title", ""),
|
|
316
316
|
linear_url=data.get("linear_url", ""),
|
|
317
|
+
branches=dict(data.get("branches") or {}),
|
|
317
318
|
)
|
|
318
319
|
else:
|
|
319
320
|
# Implicit feature
|
|
@@ -438,7 +439,10 @@ class FeatureCoordinator:
|
|
|
438
439
|
continue
|
|
439
440
|
|
|
440
441
|
try:
|
|
441
|
-
|
|
442
|
+
# Per-repo branch override: scan the lane's actual branch
|
|
443
|
+
# for this repo, not the bare feature name.
|
|
444
|
+
branch = lane.branch_for(repo_name)
|
|
445
|
+
changes = git.changed_files_with_status(scan_path, branch, base)
|
|
442
446
|
result[repo_name] = {
|
|
443
447
|
"has_branch": True,
|
|
444
448
|
"path": str(scan_path),
|
|
@@ -550,7 +554,12 @@ class FeatureCoordinator:
|
|
|
550
554
|
continue
|
|
551
555
|
|
|
552
556
|
base = state.config.default_branch
|
|
553
|
-
|
|
557
|
+
# Honor per-repo branch overrides — the branch may differ from
|
|
558
|
+
# the feature name (FeatureLane.branches map). Using lane.name
|
|
559
|
+
# here would mis-report mismatched-naming features as having no
|
|
560
|
+
# branch / no changes.
|
|
561
|
+
branch = lane.branch_for(repo_name)
|
|
562
|
+
has_branch = git.branch_exists(state.abs_path, branch)
|
|
554
563
|
|
|
555
564
|
if not has_branch:
|
|
556
565
|
lane.repo_states[repo_name] = {
|
|
@@ -564,10 +573,10 @@ class FeatureCoordinator:
|
|
|
564
573
|
|
|
565
574
|
try:
|
|
566
575
|
ahead, behind = git.divergence(
|
|
567
|
-
state.abs_path,
|
|
576
|
+
state.abs_path, branch, base
|
|
568
577
|
)
|
|
569
|
-
files = git.changed_files(state.abs_path,
|
|
570
|
-
dirty = state.is_dirty if state.current_branch ==
|
|
578
|
+
files = git.changed_files(state.abs_path, branch, base)
|
|
579
|
+
dirty = state.is_dirty if state.current_branch == branch else False
|
|
571
580
|
|
|
572
581
|
repo_state: dict = {
|
|
573
582
|
"has_branch": True,
|
|
@@ -580,7 +589,7 @@ class FeatureCoordinator:
|
|
|
580
589
|
}
|
|
581
590
|
|
|
582
591
|
# Check if branch is checked out in a worktree
|
|
583
|
-
wt_path = git.worktree_for_branch(state.abs_path,
|
|
592
|
+
wt_path = git.worktree_for_branch(state.abs_path, branch)
|
|
584
593
|
if wt_path:
|
|
585
594
|
repo_state["worktree_path"] = wt_path
|
|
586
595
|
|
|
@@ -1220,8 +1229,10 @@ class FeatureCoordinator:
|
|
|
1220
1229
|
try:
|
|
1221
1230
|
repo_name = repo_dir.name
|
|
1222
1231
|
state = self.workspace.get_repo(repo_name)
|
|
1232
|
+
# Per-repo branch override (else the feature name).
|
|
1233
|
+
branch = (meta.get("branches") or {}).get(repo_name, feat_name)
|
|
1223
1234
|
ahead, _ = git.divergence(
|
|
1224
|
-
repo_dir,
|
|
1235
|
+
repo_dir, branch, state.config.default_branch,
|
|
1225
1236
|
)
|
|
1226
1237
|
if ahead > 0:
|
|
1227
1238
|
all_merged = False
|
|
@@ -732,6 +732,15 @@ def worktree_remove(repo_path: Path, worktree_path: Path, force: bool = False) -
|
|
|
732
732
|
return _run(args, cwd=repo_path)
|
|
733
733
|
|
|
734
734
|
|
|
735
|
+
def worktree_prune(repo_path: Path) -> str:
|
|
736
|
+
"""Prune worktree registrations whose directories are gone.
|
|
737
|
+
|
|
738
|
+
Clears stale ``.git/worktrees/<name>`` entries so a subsequent
|
|
739
|
+
``worktree add`` at the same path isn't rejected as already-registered.
|
|
740
|
+
"""
|
|
741
|
+
return _run(["worktree", "prune"], cwd=repo_path)
|
|
742
|
+
|
|
743
|
+
|
|
735
744
|
def worktree_move(main_repo: Path, old_path: Path, new_path: Path) -> None:
|
|
736
745
|
"""Run `git worktree move <old_path> <new_path>` from main_repo.
|
|
737
746
|
|
|
@@ -1534,14 +1534,10 @@ def workspace_reinit(name: str | None = None, dry_run: bool = False) -> dict:
|
|
|
1534
1534
|
d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")
|
|
1535
1535
|
]
|
|
1536
1536
|
skipped = [d.name for d in all_dirs if not (d / ".git").exists()]
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
if feat_dir.is_dir():
|
|
1542
|
-
active_worktrees[feat_dir.name] = sorted(
|
|
1543
|
-
d.name for d in feat_dir.iterdir() if d.is_dir()
|
|
1544
|
-
)
|
|
1537
|
+
# Keyed by FEATURE — slot dirs resolve their occupant via slots.json
|
|
1538
|
+
# rather than being reported as if the slot id were a feature name.
|
|
1539
|
+
from ..workspace.discovery import summarize_worktree_dirs
|
|
1540
|
+
active_worktrees: dict[str, list[str]] = summarize_worktree_dirs(root)
|
|
1545
1541
|
|
|
1546
1542
|
return {
|
|
1547
1543
|
"root": str(root),
|
|
@@ -195,3 +195,44 @@ def _guess_role(name: str, lang: str) -> str:
|
|
|
195
195
|
return "frontend"
|
|
196
196
|
|
|
197
197
|
return ""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def summarize_worktree_dirs(root: Path) -> dict[str, list[str]]:
|
|
201
|
+
"""Map feature name → repo subdirs present in its worktree slot.
|
|
202
|
+
|
|
203
|
+
Used by ``canopy init`` / ``workspace_reinit`` to report existing
|
|
204
|
+
worktrees. Wave 3.0 worktree dirs are generic numbered SLOTS
|
|
205
|
+
(``worktree-N``) whose occupant feature lives in slots.json — so a slot
|
|
206
|
+
id must be resolved to its feature, not reported AS the feature. Pre-3.0
|
|
207
|
+
dirs are feature-named and map directly. An orphan slot (dir present, no
|
|
208
|
+
occupant in slots.json) falls back to the slot id as the key.
|
|
209
|
+
"""
|
|
210
|
+
import json
|
|
211
|
+
import re
|
|
212
|
+
|
|
213
|
+
wt_root = root / ".canopy" / "worktrees"
|
|
214
|
+
if not wt_root.is_dir():
|
|
215
|
+
return {}
|
|
216
|
+
|
|
217
|
+
slot_feature: dict[str, str | None] = {}
|
|
218
|
+
state_path = root / ".canopy" / "state" / "slots.json"
|
|
219
|
+
if state_path.exists():
|
|
220
|
+
try:
|
|
221
|
+
data = json.loads(state_path.read_text())
|
|
222
|
+
for sid, entry in (data.get("slots") or {}).items():
|
|
223
|
+
if isinstance(entry, dict):
|
|
224
|
+
slot_feature[sid] = entry.get("feature")
|
|
225
|
+
except (OSError, ValueError):
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
out: dict[str, list[str]] = {}
|
|
229
|
+
for d in sorted(wt_root.iterdir()):
|
|
230
|
+
if not d.is_dir():
|
|
231
|
+
continue
|
|
232
|
+
repos = sorted(r.name for r in d.iterdir() if r.is_dir())
|
|
233
|
+
if re.fullmatch(r"worktree-\d+", d.name):
|
|
234
|
+
key = slot_feature.get(d.name) or d.name # feature, else slot id
|
|
235
|
+
else:
|
|
236
|
+
key = d.name # pre-3.0 feature-named dir
|
|
237
|
+
out[key] = repos
|
|
238
|
+
return out
|
|
@@ -208,3 +208,20 @@ def test_bootstrap_feature_blocks_when_no_worktrees(workspace_with_bootstrap_con
|
|
|
208
208
|
with pytest.raises(BlockerError) as e:
|
|
209
209
|
bootstrap_feature(workspace_with_bootstrap_config, "auth-flow")
|
|
210
210
|
assert e.value.code == "no_worktrees"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_resolve_worktree_paths_uses_slots_json_for_warm_feature(workspace_with_slots):
|
|
214
|
+
"""Wave 3.0: a warm feature's worktree paths come from its SLOT, not the
|
|
215
|
+
legacy features.json worktree_paths cache (which is empty in 3.0). Before
|
|
216
|
+
this fix, bootstrap raised no_worktrees for every warm 3.0 feature."""
|
|
217
|
+
from canopy.actions.bootstrap import _resolve_worktree_paths
|
|
218
|
+
from canopy.actions import slots as sm
|
|
219
|
+
|
|
220
|
+
ws = workspace_with_slots # Y warm in worktree-1 (repo-a + repo-b)
|
|
221
|
+
sid = sm.slot_for_feature(ws, "Y")
|
|
222
|
+
assert sid is not None
|
|
223
|
+
|
|
224
|
+
paths = _resolve_worktree_paths(ws, "Y")
|
|
225
|
+
assert set(paths) == {"repo-a", "repo-b"}
|
|
226
|
+
assert paths["repo-a"] == sm.slot_worktree_path(ws, sid, "repo-a")
|
|
227
|
+
assert (paths["repo-a"] / ".git").exists()
|