@polymorphism-tech/morph-spec 9.0.0 → 9.2.0
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.
- package/CLAUDE.md +8 -9
- package/README.md +38 -36
- package/bin/morph-spec.js +11 -63
- package/bin/validate.js +1 -39
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +3 -3
- package/framework/agents.json +19 -11
- package/framework/commands/morph-apply.md +1 -1
- package/framework/commands/morph-status.md +1 -1
- package/framework/docs/CLI.md +22 -31
- package/framework/evals/review-checklist.md +12 -2
- package/framework/hooks/README.md +8 -44
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +6 -49
- package/framework/hooks/claude-code/core/block-raw-worktree-add.js +2 -35
- package/framework/hooks/claude-code/core/chain.js +4 -65
- package/framework/hooks/claude-code/core/comment-guard.js +2 -16
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +6 -50
- package/framework/hooks/claude-code/core/gate-guard.js +7 -67
- package/framework/hooks/claude-code/core/graph-refresh-kick.js +3 -24
- package/framework/hooks/claude-code/core/heavy-gate.js +3 -21
- package/framework/hooks/claude-code/core/heavy-release.js +2 -13
- package/framework/hooks/claude-code/core/loop-detect.js +2 -31
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +0 -4
- package/framework/hooks/claude-code/core/protect-spec-files.js +7 -67
- package/framework/hooks/claude-code/core/state-sync.js +12 -68
- package/framework/hooks/claude-code/core/telemetry-log.js +12 -93
- package/framework/hooks/claude-code/core/validate-completion.js +11 -57
- package/framework/hooks/claude-code/core/warn-bash-source-write.js +1 -9
- package/framework/hooks/claude-code/core/worktree-context.js +10 -110
- package/framework/hooks/claude-code/core/worktree-create.js +6 -70
- package/framework/hooks/claude-code/core/worktree-env-inject.js +5 -56
- package/framework/hooks/claude-code/core/worktree-remove.js +5 -31
- package/framework/hooks/claude-code/statusline.sh +11 -11
- package/framework/hooks/shared/activity-logger.js +9 -89
- package/framework/hooks/shared/agent-reap.js +2 -17
- package/framework/hooks/shared/build-check.js +6 -75
- package/framework/hooks/shared/claim-registry.js +9 -119
- package/framework/hooks/shared/command-tokens.js +6 -51
- package/framework/hooks/shared/comment-rules.js +5 -20
- package/framework/hooks/shared/comment-scanner.js +6 -25
- package/framework/hooks/shared/comment-stripper.js +11 -85
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/errata.js +2 -15
- package/framework/hooks/shared/feature-locator.js +11 -95
- package/framework/hooks/shared/frontend-locations.js +1 -3
- package/framework/hooks/shared/gate-question.js +1 -7
- package/framework/hooks/shared/git-exclude.js +3 -20
- package/framework/hooks/shared/git-paths.js +8 -104
- package/framework/hooks/shared/graph-freshness.js +22 -166
- package/framework/hooks/shared/graph-refresh.js +21 -149
- package/framework/hooks/shared/heavy-command.js +4 -12
- package/framework/hooks/shared/hook-response.js +8 -83
- package/framework/hooks/shared/morph-cli.js +8 -52
- package/framework/hooks/shared/payload-utils.js +10 -132
- package/framework/hooks/shared/phase-utils.js +1 -42
- package/framework/hooks/shared/redact-secrets.js +4 -15
- package/framework/hooks/shared/reparse-scan.js +6 -66
- package/framework/hooks/shared/slice-progress.js +4 -23
- package/framework/hooks/shared/source-files.js +4 -17
- package/framework/hooks/shared/state-reader.js +13 -183
- package/framework/hooks/shared/state-writer.js +8 -52
- package/framework/hooks/shared/stdin-reader.js +1 -11
- package/framework/hooks/shared/telemetry-logger.js +7 -67
- package/framework/hooks/shared/transcript-usage.js +7 -29
- package/framework/hooks/shared/worktree-root.js +7 -29
- package/framework/rules/csharp-standards.md +1 -1
- package/framework/rules/frontend-standards.md +1 -0
- package/framework/rules/hangfire-standards.md +1 -1
- package/framework/rules/neon-standards.md +1 -1
- package/framework/rules/nextjs-standards.md +1 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +4 -4
- package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
- package/framework/skills/core/morph-machine/SKILL.md +1 -1
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +9 -7
- package/framework/skills/core/morph-review/SKILL.md +9 -6
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +29 -12
- package/framework/standards/STANDARDS.json +36 -14
- package/framework/standards/STANDARDS.meta.json +16 -2
- package/framework/standards/ai-agents/agent-archetypes.md +2 -2
- package/framework/standards/ai-agents/agent-session.md +1 -1
- package/framework/standards/ai-agents/conversational-agent-with-phases.md +4 -4
- package/framework/standards/ai-agents/durable-workflows-hitl.md +3 -3
- package/framework/standards/ai-agents/guardrails.md +1 -1
- package/framework/standards/ai-agents/multi-agent-patterns.md +2 -2
- package/framework/standards/ai-agents/observability-patterns.md +1 -1
- package/framework/standards/ai-agents/production.md +1 -1
- package/framework/standards/ai-agents/prompt-sources.md +2 -2
- package/framework/standards/ai-agents/providers/model-registry.md +1 -1
- package/framework/standards/ai-agents/rag-custom-pgvector.md +2 -2
- package/framework/standards/ai-agents/setup.md +2 -2
- package/framework/standards/ai-agents/sweet-spot.md +2 -2
- package/framework/standards/ai-agents/tool-adapters.md +0 -2
- package/framework/standards/ai-agents/vector-data-extensions.md +4 -4
- package/framework/standards/ai-agents/workflows.md +1 -1
- package/framework/standards/architecture/code-comments.md +1 -1
- package/framework/standards/architecture/vertical-slice/vertical-slice.md +1 -1
- package/framework/standards/backend/api/validation.md +1 -1
- package/framework/standards/backend/dotnet/testing.md +10 -4
- package/framework/standards/backend/dotnet/vsa-handler-patterns.md +5 -5
- package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +1 -1
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -2
- package/framework/standards/data/nosql/cache/redis.md +1 -1
- package/framework/standards/data/nosql/cosmos-db.md +3 -3
- package/framework/standards/frontend/admin-app/forms.md +1 -1
- package/framework/standards/frontend/design-system/art-direction.md +88 -0
- package/framework/standards/frontend/design-system/premium-finish.md +77 -2
- package/framework/standards/frontend/nextjs/testing.md +10 -6
- package/framework/standards/infrastructure/neon/neon-pgvector.md +5 -5
- package/framework/templates/.idea/morph-templates.xml +92 -0
- package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
- package/framework/templates/CLAUDE.md.core.template +3 -3
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/REGISTRY.json +9 -9
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/docs/plan.md +3 -1
- package/framework/templates/docs/ui-mockups.md +4 -0
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
- package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/context/context-refresh.js +0 -16
- package/src/commands/dashboard/dashboard.js +2 -8
- package/src/commands/e2e/e2e.js +17 -104
- package/src/commands/graph/affected.js +0 -14
- package/src/commands/graph/build.js +11 -50
- package/src/commands/graph/explain.js +0 -16
- package/src/commands/graph/status.js +1 -12
- package/src/commands/mcp/mcp-setup.js +4 -62
- package/src/commands/observability/cost.js +5 -57
- package/src/commands/observability/telemetry.js +1 -14
- package/src/commands/project/branch.js +4 -45
- package/src/commands/project/claim.js +4 -48
- package/src/commands/project/doctor.js +48 -302
- package/src/commands/project/env.js +1 -27
- package/src/commands/project/fleet.js +3 -32
- package/src/commands/project/init.js +4 -68
- package/src/commands/project/install-plugin-cmd.js +0 -1
- package/src/commands/project/standards.js +1 -62
- package/src/commands/project/status.js +0 -53
- package/src/commands/project/update.js +31 -239
- package/src/commands/project/worktree.js +108 -882
- package/src/commands/scaffold/scaffold.js +0 -25
- package/src/commands/state/advance.js +14 -85
- package/src/commands/state/approve.js +15 -97
- package/src/commands/state/archive.js +12 -64
- package/src/commands/state/auto-approval.js +2 -5
- package/src/commands/state/create.js +22 -109
- package/src/commands/state/delete.js +11 -63
- package/src/commands/state/finish.js +98 -475
- package/src/commands/state/gate-check.js +22 -132
- package/src/commands/state/gate-decision.js +3 -39
- package/src/commands/state/handoff.js +3 -30
- package/src/commands/state/list.js +14 -84
- package/src/commands/state/report-check.js +8 -58
- package/src/commands/state/retype.js +4 -26
- package/src/commands/validation/index.js +0 -3
- package/src/commands/validation/mutate.js +11 -11
- package/src/commands/validation/plan-check.js +4 -2
- package/src/commands/validation/tests-audit.js +7 -0
- package/src/commands/validation/validate-feature.js +10 -97
- package/src/commands/validation/verify.js +30 -113
- package/src/core/paths/output-schema.js +2 -101
- package/src/core/state/feature-identity.js +1 -25
- package/src/core/state/gate-order.js +0 -27
- package/src/core/state/lifecycle-audit.js +8 -100
- package/src/core/state/state-manager.js +22 -304
- package/src/core/templates/template-registry.js +2 -97
- package/src/core/templates/template-renderer.js +13 -93
- package/src/core/templates/template-seed.js +0 -1
- package/src/core/work-types.js +1 -36
- package/src/dashboard/app/App.jsx +1 -8
- package/src/dashboard/app/components/ActivityFeed.jsx +0 -5
- package/src/dashboard/app/components/CommandPalette.jsx +0 -7
- package/src/dashboard/app/components/DetailPanel.jsx +1 -5
- package/src/dashboard/app/components/EntityLink.jsx +0 -12
- package/src/dashboard/app/components/ErrorBoundary.jsx +1 -8
- package/src/dashboard/app/components/KnowledgeMarkdown.jsx +0 -11
- package/src/dashboard/app/components/Markdown.jsx +0 -9
- package/src/dashboard/app/components/PhaseRail.jsx +0 -11
- package/src/dashboard/app/components/SearchFilter.jsx +0 -4
- package/src/dashboard/app/components/Sidebar.jsx +0 -5
- package/src/dashboard/app/hooks/useMorphQuery.js +1 -11
- package/src/dashboard/app/hooks/usePref.js +1 -5
- package/src/dashboard/app/lib/format.js +0 -2
- package/src/dashboard/app/lib/phases.js +1 -5
- package/src/dashboard/app/lib/project.js +0 -12
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/app/pages/AgentMap.jsx +1 -8
- package/src/dashboard/app/pages/FeatureDetail.jsx +1 -5
- package/src/dashboard/app/pages/Features.jsx +1 -8
- package/src/dashboard/app/pages/Guide.jsx +0 -1
- package/src/dashboard/app/pages/HookInspector.jsx +2 -23
- package/src/dashboard/app/pages/PhasePipeline.jsx +0 -3
- package/src/dashboard/app/pages/Portfolio.jsx +0 -1
- package/src/dashboard/app/pages/RulesMap.jsx +1 -16
- package/src/dashboard/app/pages/SkillsLibrary.jsx +1 -16
- package/src/dashboard/app/pages/Standards.jsx +2 -17
- package/src/dashboard/scanner/activity-scanner.js +10 -69
- package/src/dashboard/scanner/agents-scanner.js +1 -23
- package/src/dashboard/scanner/docs-scanner.js +0 -6
- package/src/dashboard/scanner/feature-status.js +2 -5
- package/src/dashboard/scanner/features-scanner.js +10 -66
- package/src/dashboard/scanner/hooks-scanner.js +5 -28
- package/src/dashboard/scanner/index.js +0 -4
- package/src/dashboard/scanner/portfolio.js +5 -29
- package/src/dashboard/scanner/project-api.js +8 -54
- package/src/dashboard/scanner/rules-scanner.js +1 -2
- package/src/dashboard/scanner/skills-scanner.js +2 -12
- package/src/dashboard/scanner/standards-scanner.js +2 -6
- package/src/dashboard/vite-plugin-morph-data.js +5 -19
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/ai/ai-checks.js +7 -52
- package/src/lib/ai/ai-project-scan.js +14 -108
- package/src/lib/ai/code-audit.js +7 -63
- package/src/lib/ai/pin-drift.js +8 -51
- package/src/lib/ai/registry-audit.js +11 -90
- package/src/lib/ci/ci-verify.js +4 -19
- package/src/lib/ci/github-ci.js +9 -37
- package/src/lib/ci/workflow-scaffold.js +14 -10
- package/src/lib/detectors/claude-config-detector.js +13 -74
- package/src/lib/detectors/index.js +4 -50
- package/src/lib/e2e/applicability.js +37 -0
- package/src/lib/e2e/compose-env.js +5 -66
- package/src/lib/e2e/stack-teardown.js +6 -40
- package/src/lib/generators/architecture-scanner.js +18 -156
- package/src/lib/generators/handoff-generator.js +9 -53
- package/src/lib/generators/scaffold-generator.js +5 -101
- package/src/lib/generators/settings-generator.js +4 -37
- package/src/lib/git/branch-classifier.js +11 -84
- package/src/lib/git/default-branch.js +14 -169
- package/src/lib/git/evidence-staleness.js +1 -0
- package/src/lib/git/gh.js +2 -17
- package/src/lib/git/pending-prs.js +6 -37
- package/src/lib/git/pr-files.js +3 -28
- package/src/lib/git/pr-index.js +3 -34
- package/src/lib/git/tree-fingerprint.js +3 -15
- package/src/lib/git/tree-state.js +6 -33
- package/src/lib/graph/god-nodes.js +2 -22
- package/src/lib/graph/graph-health.js +3 -24
- package/src/lib/graph/graph-loader.js +5 -56
- package/src/lib/graph/graphify-runtime.js +7 -37
- package/src/lib/graph/graphifyignore.js +8 -74
- package/src/lib/graph/sanitize.js +16 -136
- package/src/lib/heavy/cleanup.js +1 -1
- package/src/lib/installers/effects.js +8 -93
- package/src/lib/installers/mcp-installer.js +9 -169
- package/src/lib/installers/memory-installer.js +4 -27
- package/src/lib/installers/templates-reconcile.js +5 -60
- package/src/lib/mutation/adapters.js +6 -17
- package/src/lib/mutation/dotnet-introspection.js +20 -106
- package/src/lib/mutation/runner.js +23 -75
- package/src/lib/mutation/subset.js +1 -4
- package/src/lib/mutation/trx.js +0 -1
- package/src/lib/plan/artifact-vocabulary.js +5 -25
- package/src/lib/plan/checks.js +34 -33
- package/src/lib/plan/plan-parser.js +7 -8
- package/src/lib/projects/registry.js +7 -44
- package/src/lib/review-reports.js +11 -26
- package/src/lib/risk-detector.js +41 -173
- package/src/lib/specs/adr-index.js +6 -34
- package/src/lib/specs/frontmatter.js +2 -15
- package/src/lib/specs/gfm-table.js +1 -7
- package/src/lib/specs/revocations.js +1 -14
- package/src/lib/specs/screen-checklist.js +47 -124
- package/src/lib/stack/dotnet-build-ignores.js +2 -6
- package/src/lib/stack/stack-profile.js +1 -28
- package/src/lib/stack/stack-scan.js +1 -8
- package/src/lib/stack-filter.js +4 -27
- package/src/lib/standards/registry.js +1 -7
- package/src/lib/standards/sections.js +2 -13
- package/src/lib/standards/verified-against.js +19 -191
- package/src/lib/tasks/eval-node.js +21 -169
- package/src/lib/tasks/linux-runner.js +18 -142
- package/src/lib/tasks/test-nodes.js +43 -240
- package/src/lib/tasks/test-runner.js +43 -341
- package/src/lib/telemetry/metrics.js +7 -36
- package/src/lib/telemetry/telemetry-reader.js +13 -125
- package/src/lib/telemetry/verify-event.js +2 -30
- package/src/lib/tests-audit/audit.js +96 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +4 -44
- package/src/lib/validators/code-budget/index.js +0 -7
- package/src/lib/validators/contracts/contract-compliance-validator.js +2 -24
- package/src/lib/validators/contracts/index.js +0 -6
- package/src/lib/validators/nextjs/index.js +0 -5
- package/src/lib/validators/nextjs/next-component-validator.js +4 -81
- package/src/lib/validators/packages/index.js +0 -6
- package/src/lib/validators/packages/package-validator.js +6 -56
- package/src/lib/validators/reuse/index.js +0 -8
- package/src/lib/validators/reuse/reuse-validator.js +11 -101
- package/src/lib/validators/reuse/symbol-scanner.js +6 -62
- package/src/lib/validators/traceability-check.js +5 -63
- package/src/lib/validators/validation-runner.js +21 -158
- package/src/lib/validators/verify-runner.js +85 -376
- package/src/lib/work-type-detector.js +5 -67
- package/src/lib/worktree/docker-audit.js +17 -136
- package/src/lib/worktree/env-allocator.js +10 -98
- package/src/lib/worktree/feature-truth.js +12 -115
- package/src/lib/worktree/finish-backup.js +32 -242
- package/src/lib/worktree/fleet-scanner.js +13 -95
- package/src/lib/worktree/ignored-triage.js +15 -91
- package/src/lib/worktree/vscode-settings.js +2 -3
- package/src/lib/worktree/worktree-audit.js +21 -213
- package/src/lib/worktree/worktree-include.js +3 -5
- package/src/scripts/global-install.js +3 -9
- package/src/scripts/install-plugin.js +1 -13
- package/src/scripts/setup-infra.js +17 -147
- package/src/utils/agents-installer.js +29 -276
- package/src/utils/banner.js +4 -25
- package/src/utils/claude-md-injector.js +1 -37
- package/src/utils/claude-settings-manager.js +1 -52
- package/src/utils/file-copier.js +11 -95
- package/src/utils/hooks-installer.js +21 -298
- package/src/utils/skills-installer.js +4 -101
- package/src/utils/version-checker.js +4 -83
- package/framework/commands/morph-proposal.md +0 -9
- package/framework/hooks/git/commit-msg/conventional-commits.sh +0 -33
- package/framework/hooks/git/pre-commit/agents.sh +0 -25
- package/framework/hooks/git/pre-commit/orchestrator.sh +0 -64
- package/framework/hooks/git/pre-push/run-tests.sh +0 -44
- package/src/commands/utils/index.js +0 -3
- package/src/lib/detectors/design-system-detector.js +0 -188
- package/src/lib/generators/design-system-generator.js +0 -508
- package/src/lib/specs/old-format.js +0 -68
- package/src/utils/agent-id-mapper.js +0 -11
|
@@ -1,39 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MORPH-SPEC Worktree Command Suite
|
|
3
|
-
*
|
|
4
|
-
* Isolates feature work into per-feature git worktrees at worktrees/{feature}/
|
|
5
|
-
* on branch morph/{feature}, so each feature can run its own Claude Code
|
|
6
|
-
* instance (v8 multi-instance, parallel-feature-development flow).
|
|
7
|
-
*
|
|
8
|
-
* `.morph/features/{f}/feature.json` is the authoritative, committed state — it
|
|
9
|
-
* travels with the worktree's branch. `.morph/state.json` is a thin, gitignored,
|
|
10
|
-
* derivable index; `worktree setup` MATERIALIZES a local one via loadState()'s
|
|
11
|
-
* self-heal rather than linking to the main copy (a hard link breaks silently on
|
|
12
|
-
* the first atomic write, since tmp+rename creates a new inode).
|
|
13
|
-
*
|
|
14
|
-
* Subcommands:
|
|
15
|
-
* morph-spec worktree setup <feature> [--fresh] — create + materialize state
|
|
16
|
-
* morph-spec worktree provision <name> [--base <ref>] — task-isolation tree
|
|
17
|
-
* (branch morph-task/{name}, same infra, NO feature identity) — what the
|
|
18
|
-
* WorktreeCreate hook calls for `claude -w` / EnterWorktree / Agent isolation
|
|
19
|
-
* morph-spec worktree link [--target <path>] [--all] — (re)link shared infra
|
|
20
|
-
* morph-spec worktree remove [<feature>|--target <path>] [--if-clean] — safe teardown
|
|
21
|
-
* morph-spec worktree list [--json] — list active feature worktrees
|
|
22
|
-
*
|
|
23
|
-
* Closing the lifecycle (integrate + teardown) lives in `morph-spec finish`
|
|
24
|
-
* (src/commands/state/finish.js), which orchestrates archive → merge|PR →
|
|
25
|
-
* teardown for BOTH the worktree and the common flow. This file exports the
|
|
26
|
-
* hardened primitives it uses: `shedRootFeatureScope`, `primaryRootUnexplainedDirt`,
|
|
27
|
-
* and `teardownWorktree` (PROVE-or-DECLINE removal).
|
|
28
|
-
*
|
|
29
|
-
* Exit codes (setup):
|
|
30
|
-
* 0 — success (created) or fail-open (not a git repo, git error)
|
|
31
|
-
* 1 — refused: feature name is not a plain kebab slug
|
|
32
|
-
* 2 — worktree already exists at this exact path (signals resume to caller)
|
|
33
|
-
* 3 — a live worktree for this feature already exists at a DIFFERENT path
|
|
34
|
-
* (anti-dupe guard — refuses to fork a second live instance)
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
1
|
import { execSync, execFileSync } from 'child_process';
|
|
38
2
|
import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, lstatSync, unlinkSync, rmdirSync, cpSync, copyFileSync, readdirSync } from 'fs';
|
|
39
3
|
import { join, resolve, dirname, basename } from 'path';
|
|
@@ -47,16 +11,12 @@ import {
|
|
|
47
11
|
enforceBackupRetention,
|
|
48
12
|
newBackupStamp,
|
|
49
13
|
} from '../../lib/worktree/finish-backup.js';
|
|
50
|
-
//
|
|
51
|
-
// the SAME answer this file does, and hooks can never import from `src/`.
|
|
14
|
+
// Lives in hooks/shared: the PreToolUse guardrail needs the same answer and hooks cannot import `src/`.
|
|
52
15
|
import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
|
|
53
16
|
import { ensureCommonExclude } from '../../../framework/hooks/shared/git-exclude.js';
|
|
54
17
|
import { seedGraphFromRoot } from '../../../framework/hooks/shared/graph-refresh.js';
|
|
55
18
|
import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
|
|
56
19
|
|
|
57
|
-
// The container name lives in lib/worktree/dirs.js (single source, C1
|
|
58
|
-
// rationale documented there); re-exported here so existing importers and the
|
|
59
|
-
// dependency-matrix row keep pointing at this file.
|
|
60
20
|
import { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES } from '../../lib/worktree/dirs.js';
|
|
61
21
|
import { applyEditorSettings } from '../../lib/worktree/vscode-settings.js';
|
|
62
22
|
import { copyWorktreeIncludes } from '../../lib/worktree/worktree-include.js';
|
|
@@ -75,27 +35,7 @@ function rootAndEditorReport(root) {
|
|
|
75
35
|
}
|
|
76
36
|
export { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES };
|
|
77
37
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Check if a directory is inside a git repository.
|
|
82
|
-
* @param {string} cwd
|
|
83
|
-
* @returns {boolean}
|
|
84
|
-
*/
|
|
85
|
-
/**
|
|
86
|
-
* Is `name` a plain kebab-case feature name?
|
|
87
|
-
*
|
|
88
|
-
* Mirrors the validation `morph-spec create` applies, and every worktree
|
|
89
|
-
* subcommand enforces it for the same reason plus a sharper one: a feature name
|
|
90
|
-
* flows into `worktrees/{name}`, the branch `morph/{name}`, and from there into
|
|
91
|
-
* git invocations. Characters like `& | ; > ^` are legal in a git refname but
|
|
92
|
-
* are cmd.exe metacharacters — on Windows an unvalidated name turns any
|
|
93
|
-
* string-interpolated git call into command injection, and makes a `finish`
|
|
94
|
-
* merge a DIFFERENT branch than the one it validated and reports.
|
|
95
|
-
*
|
|
96
|
-
* @param {string} name
|
|
97
|
-
* @returns {boolean}
|
|
98
|
-
*/
|
|
38
|
+
/** Also guards git calls: `& | ; > ^` are legal in a refname but cmd.exe metacharacters on Windows. */
|
|
99
39
|
export function isValidFeatureName(name) {
|
|
100
40
|
return typeof name === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(name);
|
|
101
41
|
}
|
|
@@ -109,12 +49,6 @@ export function isGitRepo(cwd = process.cwd()) {
|
|
|
109
49
|
}
|
|
110
50
|
}
|
|
111
51
|
|
|
112
|
-
/**
|
|
113
|
-
* Check if a branch exists locally.
|
|
114
|
-
* @param {string} branch
|
|
115
|
-
* @param {string} cwd
|
|
116
|
-
* @returns {boolean}
|
|
117
|
-
*/
|
|
118
52
|
export function branchExists(branch, cwd = process.cwd()) {
|
|
119
53
|
try {
|
|
120
54
|
execSync(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd, stdio: 'pipe' });
|
|
@@ -124,23 +58,6 @@ export function branchExists(branch, cwd = process.cwd()) {
|
|
|
124
58
|
}
|
|
125
59
|
}
|
|
126
60
|
|
|
127
|
-
/**
|
|
128
|
-
* Check if a worktree path is already registered with git.
|
|
129
|
-
*
|
|
130
|
-
* BUGFIX: the old `normalizedPath.startsWith('/')` absolute-path check only
|
|
131
|
-
* recognized POSIX-style absolute paths. On Windows, `buildWorktreePath`
|
|
132
|
-
* already returns an absolute `C:/...` path, which does NOT start with `/` —
|
|
133
|
-
* so this used to re-prepend `cwd` onto an already-absolute path, producing
|
|
134
|
-
* a bogus double-rooted string that never matched `git worktree list`'s
|
|
135
|
-
* output. That silently broke the Gate 2 "already exists → resume" check on
|
|
136
|
-
* Windows (it always fell through to a failing `git worktree add`, caught by
|
|
137
|
-
* the outer fail-open handler). `path.resolve(cwd, worktreePath)` handles
|
|
138
|
-
* both relative and platform-absolute paths correctly.
|
|
139
|
-
*
|
|
140
|
-
* @param {string} worktreePath Relative or absolute path
|
|
141
|
-
* @param {string} cwd
|
|
142
|
-
* @returns {boolean}
|
|
143
|
-
*/
|
|
144
61
|
export function worktreeExists(worktreePath, cwd = process.cwd()) {
|
|
145
62
|
try {
|
|
146
63
|
const output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
|
|
@@ -151,13 +68,7 @@ export function worktreeExists(worktreePath, cwd = process.cwd()) {
|
|
|
151
68
|
}
|
|
152
69
|
}
|
|
153
70
|
|
|
154
|
-
/**
|
|
155
|
-
* Build the worktree directory path (always forward slashes).
|
|
156
|
-
* @param {string} feature
|
|
157
|
-
* @param {string} cwd
|
|
158
|
-
* @param {{ fresh?: boolean }} options
|
|
159
|
-
* @returns {string}
|
|
160
|
-
*/
|
|
71
|
+
/** Always forward slashes. */
|
|
161
72
|
export function buildWorktreePath(feature, cwd = process.cwd(), options = {}) {
|
|
162
73
|
const suffix = options.fresh ? `-${getDateSuffix()}` : '';
|
|
163
74
|
const normalizedCwd = cwd.replace(/\\/g, '/');
|
|
@@ -165,12 +76,6 @@ export function buildWorktreePath(feature, cwd = process.cwd(), options = {}) {
|
|
|
165
76
|
return `${container}/${feature}${suffix}`;
|
|
166
77
|
}
|
|
167
78
|
|
|
168
|
-
/**
|
|
169
|
-
* Build the branch name for a feature worktree.
|
|
170
|
-
* @param {string} feature
|
|
171
|
-
* @param {{ fresh?: boolean }} options
|
|
172
|
-
* @returns {string}
|
|
173
|
-
*/
|
|
174
79
|
export function buildBranchName(feature, options = {}) {
|
|
175
80
|
const suffix = options.fresh ? `-${getDateSuffix()}` : '';
|
|
176
81
|
return `morph/${feature}${suffix}`;
|
|
@@ -180,24 +85,8 @@ function getDateSuffix() {
|
|
|
180
85
|
return new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
181
86
|
}
|
|
182
87
|
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* The infra directories morph itself links into a worktree — the ONLY ones
|
|
187
|
-
* `setupInfraLinks`/`worktreeLinkCommand --all`/`unlinkWorktreeInfraLinks` treat
|
|
188
|
-
* as KNOWN, and therefore the only ones `finish` may auto-remove via the
|
|
189
|
-
* already-proven-safe unlink mechanism (never by widening the reparse-point
|
|
190
|
-
* scan — see `findReparsePoints`).
|
|
191
|
-
*
|
|
192
|
-
* Always: `node_modules`, `.claude`. Conditionally: `.morph/framework` (client
|
|
193
|
-
* projects only) and `{project.frontendPath}/node_modules` — a monorepo
|
|
194
|
-
* frontend (e.g. a Next.js app under `apps/catalog-admin`) commonly has its OWN
|
|
195
|
-
* node_modules, a separate `npm install` from the root's, invisible to a static
|
|
196
|
-
* list. Fail-open: a missing/unreadable config.json just means no frontendPath.
|
|
197
|
-
*
|
|
198
|
-
* @param {string} mainCwd - Absolute path to the main project root
|
|
199
|
-
* @returns {string[]} relative paths, root-first
|
|
200
|
-
*/
|
|
88
|
+
// The ONLY links `finish` may auto-remove (never widened to any reparse point found), plus
|
|
89
|
+
// `{project.frontendPath}/node_modules`: a monorepo frontend has its own install.
|
|
201
90
|
function resolveInfraLinkDirs(mainCwd) {
|
|
202
91
|
const dirs = ['node_modules', '.claude'];
|
|
203
92
|
if (existsSync(join(mainCwd, '.morph', 'framework'))) {
|
|
@@ -210,29 +99,13 @@ function resolveInfraLinkDirs(mainCwd) {
|
|
|
210
99
|
const frontendPath = config?.project?.frontendPath;
|
|
211
100
|
if (frontendPath) dirs.push(join(frontendPath, 'node_modules'));
|
|
212
101
|
}
|
|
213
|
-
} catch {
|
|
102
|
+
} catch {}
|
|
214
103
|
return dirs;
|
|
215
104
|
}
|
|
216
105
|
|
|
217
106
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* `.morph/state.json` is deliberately NEVER linked here — a hard link breaks
|
|
222
|
-
* silently the moment either side writes atomically (tmp file + rename creates
|
|
223
|
-
* a fresh inode, severing the link), so worktree and main state can silently
|
|
224
|
-
* diverge. The worktree's local state.json is MATERIALIZED instead, see
|
|
225
|
-
* `materializeState()`. `memory/` and `logs/` are also never linked — each
|
|
226
|
-
* worktree instance owns its own debounce/session snapshots.
|
|
227
|
-
*
|
|
228
|
-
* Uses junctions for directories (no admin required on Windows).
|
|
229
|
-
* Fail-open: any error is silently ignored.
|
|
230
|
-
*
|
|
231
|
-
* @param {string} worktreePath - Absolute path to the worktree
|
|
232
|
-
* @param {string} mainCwd - Absolute path to the main project root
|
|
233
|
-
* @param {{ dirs?: string[] }} [opts] - When `dirs` is provided, restricts
|
|
234
|
-
* which junctions are (re)created to exactly that list. Default: node_modules,
|
|
235
|
-
* .claude, and .morph/framework (the last only if it exists in the main root).
|
|
107
|
+
* `.morph/state.json` is never linked: an atomic write (tmp + rename) makes a new inode and silently
|
|
108
|
+
* severs a hard link, so the worktree materializes its own (`materializeState`).
|
|
236
109
|
*/
|
|
237
110
|
export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
|
|
238
111
|
const mainAbs = resolve(mainCwd);
|
|
@@ -250,14 +123,11 @@ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
|
|
|
250
123
|
const parent = dirname(linkPath);
|
|
251
124
|
if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
|
|
252
125
|
symlinkSync(target, linkPath, 'junction');
|
|
253
|
-
} catch {
|
|
126
|
+
} catch {}
|
|
254
127
|
}
|
|
255
128
|
|
|
256
|
-
// Only
|
|
257
|
-
//
|
|
258
|
-
// already speak for it. `lstat` reports a POSIX symlink and a Windows
|
|
259
|
-
// junction alike as a symbolic link, so the exclude lands identically on
|
|
260
|
-
// both platforms — as does the bug it prevents (see excludeInfraLinks).
|
|
129
|
+
// Only reparse points: `lstat` reports a POSIX symlink and a Windows junction alike, so the
|
|
130
|
+
// exclude lands identically on both platforms.
|
|
261
131
|
excludeWorktreePaths(mainAbs, dirLinks.filter((rel) => {
|
|
262
132
|
try {
|
|
263
133
|
return lstatSync(join(wtAbs, rel)).isSymbolicLink();
|
|
@@ -271,47 +141,9 @@ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
|
|
|
271
141
|
const EXCLUDE_MARKER = '# morph-spec: infra links inside worktrees (managed — see setupInfraLinks)';
|
|
272
142
|
|
|
273
143
|
/**
|
|
274
|
-
*
|
|
275
|
-
* the
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* CRITICAL — without this, `morph-spec finish` (worktree teardown) is unusable on
|
|
279
|
-
* POSIX and destructive at worst, because git never sees a link as a link: it sees what
|
|
280
|
-
* the OS reports. A Windows junction IS a directory, so a `.morph/framework/`
|
|
281
|
-
* rule (trailing slash = directories only) matches it and git ignores it. A
|
|
282
|
-
* POSIX symlink is a FILE (mode 120000), so the very same rule does NOT match
|
|
283
|
-
* and the link surfaces as untracked (`??`). Both endings are bad:
|
|
284
|
-
* - `finish` refuses on its clean-worktree gate (`worktree-dirty`) — the
|
|
285
|
-
* careful user who commits only real work can never finish at all; or
|
|
286
|
-
* - a `git add -A` COMMITS the links onto the feature branch, and the merge
|
|
287
|
-
* then replaces the primary root's real `.claude/` and `.morph/framework/`
|
|
288
|
-
* with symlinks pointing at themselves. The installation is gone, and
|
|
289
|
-
* `finished: true` is reported.
|
|
290
|
-
*
|
|
291
|
-
* The user's `.gitignore` cannot be the fix: it is a TRACKED file (rewriting it
|
|
292
|
-
* dirties the very tree these gates read), morph deliberately ignores only the
|
|
293
|
-
* morph-OWNED subpaths of `.claude/` so a user's own settings stay trackable,
|
|
294
|
-
* and existing installs would only pick up a new rule on a re-init.
|
|
295
|
-
* `.git/info/exclude` is git's own answer to "ignore this locally": never
|
|
296
|
-
* committed, additive (it does not override the user's global excludes, and an
|
|
297
|
-
* exclude NEVER applies to a tracked file), and — unlike the per-worktree
|
|
298
|
-
* `.git/worktrees/<id>/info/exclude`, which git does not read for a linked
|
|
299
|
-
* worktree — honored from the COMMON git dir by every worktree of the repo.
|
|
300
|
-
*
|
|
301
|
-
* TRADEOFF, deliberate: patterns are matched against each working tree's own
|
|
302
|
-
* top and the file is shared, so `/.claude` also hides an UNTRACKED `.claude/`
|
|
303
|
-
* in the primary root. gitignore cannot express "only when it is a symlink",
|
|
304
|
-
* and no negation can re-include a path under an excluded directory. The cost
|
|
305
|
-
* is narrow — tracked files are untouched, and a team that versions
|
|
306
|
-
* `.claude/settings.json` has it tracked — and it buys the links being
|
|
307
|
-
* invisible to `git add -A`, which is what keeps the merge from eating the
|
|
308
|
-
* installation.
|
|
309
|
-
*
|
|
310
|
-
* Idempotent (never duplicates a pattern) and fail-open: an exclude we could
|
|
311
|
-
* not write costs a refused finish, never a wrong one.
|
|
312
|
-
*
|
|
313
|
-
* @param {string} mainCwd - Absolute path to the main project root
|
|
314
|
-
* @param {string[]} rels - worktree-relative paths to exclude
|
|
144
|
+
* Git sees a POSIX symlink as a FILE, so a dirs-only rule misses it and `git add -A` commits the links
|
|
145
|
+
* (the merge then eats the root's installation). The COMMON dir's `info/exclude` is honored by every
|
|
146
|
+
* worktree; the accepted cost is that it also hides an untracked `.claude/` in the root.
|
|
315
147
|
*/
|
|
316
148
|
function excludeWorktreePaths(mainCwd, rels) {
|
|
317
149
|
// Anchored (`/x`): these live at the top of a worktree, and an unanchored
|
|
@@ -319,16 +151,7 @@ function excludeWorktreePaths(mainCwd, rels) {
|
|
|
319
151
|
ensureCommonExclude(mainCwd, rels.map((rel) => `/${rel.replace(/\\/g, '/')}`), EXCLUDE_MARKER);
|
|
320
152
|
}
|
|
321
153
|
|
|
322
|
-
/**
|
|
323
|
-
* Resolve the absolute path (forward slashes) of the primary/main git worktree,
|
|
324
|
-
* from anywhere inside the repo (including from within a secondary worktree).
|
|
325
|
-
*
|
|
326
|
-
* Fail-open: if `cwd` is not a git repo (or the git call errors), returns
|
|
327
|
-
* `cwd` itself, resolved and normalized.
|
|
328
|
-
*
|
|
329
|
-
* @param {string} cwd
|
|
330
|
-
* @returns {string} Absolute, forward-slash path to the primary worktree
|
|
331
|
-
*/
|
|
154
|
+
/** Fail-open: outside a git repo (or on a git error) returns `cwd` itself, normalized. */
|
|
332
155
|
export function resolvePrimaryRoot(cwd = process.cwd()) {
|
|
333
156
|
try {
|
|
334
157
|
const output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
|
|
@@ -336,39 +159,13 @@ export function resolvePrimaryRoot(cwd = process.cwd()) {
|
|
|
336
159
|
if (line) {
|
|
337
160
|
return line.slice('worktree '.length).trim().replace(/\\/g, '/');
|
|
338
161
|
}
|
|
339
|
-
} catch {
|
|
162
|
+
} catch {}
|
|
340
163
|
return resolve(cwd).replace(/\\/g, '/');
|
|
341
164
|
}
|
|
342
165
|
|
|
343
166
|
/**
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
* — not linked to the main copy (see `setupInfraLinks` doc for why hard links
|
|
347
|
-
* are unsafe here).
|
|
348
|
-
*
|
|
349
|
-
* Mechanism: temporarily chdir into the worktree and call `loadState(false)`,
|
|
350
|
-
* whose self-heal path (state-manager.js) scaffolds + hydrates + writes a fresh
|
|
351
|
-
* root index when `.morph/state.json` is missing but per-feature files exist.
|
|
352
|
-
* `process.chdir` is process-global, so the original cwd is ALWAYS restored via
|
|
353
|
-
* finally, even on error. Fail-open: never throws.
|
|
354
|
-
*
|
|
355
|
-
* @param {string} worktreePath - Absolute path to the worktree
|
|
356
|
-
* @returns {Promise<{ materialized: boolean, reason?: string }>}
|
|
357
|
-
*/
|
|
358
|
-
/**
|
|
359
|
-
* Guarantee the worktree owns a local `.morph/state.json`, hydrated from its
|
|
360
|
-
* committed features when there are any, and an EMPTY index otherwise.
|
|
361
|
-
*
|
|
362
|
-
* Why the empty index matters: every core hook fail-opens on `stateExists()`,
|
|
363
|
-
* which is true only with a `state.json` or a feature dir in the tree. A
|
|
364
|
-
* task-isolation worktree of a project with no committed features (measured
|
|
365
|
-
* in the sandbox e2e) had neither — so the env-inject hook, the removal guard
|
|
366
|
-
* and every other guardrail were silently dark inside it. `state.json` is
|
|
367
|
-
* gitignored and derivable, so an empty one costs nothing and can never dirty
|
|
368
|
-
* the tree the finish gates read.
|
|
369
|
-
*
|
|
370
|
-
* @param {string} worktreePath
|
|
371
|
-
* @returns {Promise<{ materialized: boolean, reason?: string, seeded?: boolean }>}
|
|
167
|
+
* Writes an EMPTY index when there is nothing to hydrate: every hook fail-opens on `stateExists()`,
|
|
168
|
+
* and a tree with neither state.json nor features left them all silently dark.
|
|
372
169
|
*/
|
|
373
170
|
export async function ensureLocalState(worktreePath) {
|
|
374
171
|
const result = await materializeState(worktreePath);
|
|
@@ -402,12 +199,8 @@ export async function materializeState(worktreePath) {
|
|
|
402
199
|
try {
|
|
403
200
|
process.chdir(resolve(worktreePath));
|
|
404
201
|
const { loadState } = await import('../../core/state/state-manager.js');
|
|
405
|
-
loadState(false);
|
|
406
|
-
// loadState(false)
|
|
407
|
-
// to hydrate (a non-empty .morph/features/). A present-but-empty features
|
|
408
|
-
// dir (e.g. right after `archive` moved the last feature out) makes
|
|
409
|
-
// loadState return null and write nothing — don't report success in that
|
|
410
|
-
// case, or callers believe a local state.json exists when it doesn't.
|
|
202
|
+
loadState(false);
|
|
203
|
+
// loadState(false) writes nothing for a present-but-empty features dir: don't report success then.
|
|
411
204
|
if (existsSync(join(worktreePath, '.morph', 'state.json'))) {
|
|
412
205
|
return { materialized: true };
|
|
413
206
|
}
|
|
@@ -419,20 +212,8 @@ export async function materializeState(worktreePath) {
|
|
|
419
212
|
}
|
|
420
213
|
}
|
|
421
214
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
* the root's graph is either this tree's HEAD graph already or one
|
|
425
|
-
* `morph-spec graph refresh` away — and the `reuse` validator reads the graph
|
|
426
|
-
* of the tree `verify` runs in, so a worktree without one skips reuse checking
|
|
427
|
-
* exactly where the new code is written. Nothing is launched in the
|
|
428
|
-
* background: the automatic refresh only ever touches the root (a detached
|
|
429
|
-
* process holding a worktree directory made `finish` fail on win32).
|
|
430
|
-
* Fail-open: no root graph, no copy.
|
|
431
|
-
*
|
|
432
|
-
* @param {string} worktreePath
|
|
433
|
-
* @param {string} root
|
|
434
|
-
* @returns {{seeded: boolean, reason?: string}}
|
|
435
|
-
*/
|
|
215
|
+
// Nothing launched in the background: a detached process holding a worktree directory made
|
|
216
|
+
// `finish` fail on win32. Fail-open: no root graph, no copy.
|
|
436
217
|
function seedWorktreeGraph(worktreePath, root) {
|
|
437
218
|
try {
|
|
438
219
|
return seedGraphFromRoot(worktreePath, root);
|
|
@@ -441,35 +222,18 @@ function seedWorktreeGraph(worktreePath, root) {
|
|
|
441
222
|
}
|
|
442
223
|
}
|
|
443
224
|
|
|
444
|
-
// ── Command handler ─────────────────────────────────────────────────────────
|
|
445
|
-
|
|
446
225
|
/**
|
|
447
|
-
* Non-exiting core of `worktree setup
|
|
448
|
-
*
|
|
449
|
-
* materialization, metadata persistence) and returns a result object instead
|
|
450
|
-
* of calling `process.exit`/`console.log`, so callers (e.g. a future
|
|
451
|
-
* `create --worktree` that needs to chain further steps) can reuse it in
|
|
452
|
-
* process. `worktreeSetupCommand` below is a thin CLI wrapper around this.
|
|
453
|
-
*
|
|
454
|
-
* @param {string} feature
|
|
455
|
-
* @param {{ fresh?: boolean, cwd?: string }} [options]
|
|
456
|
-
* @returns {Promise<{ ok: boolean, exitCode: 0|1|2|3, created?: boolean, alreadyExists?: boolean, path?: string, branch?: string, feature: string, materialized?: boolean, nextStep?: string, error?: string, existingPath?: string }>}
|
|
457
|
-
* exitCode 1 = refused, `error: 'invalid-feature-name'`. This function is the
|
|
458
|
-
* non-exiting core precisely so callers can reuse it in-process — a caller
|
|
459
|
-
* trusting a `0|2|3` union would mishandle that refusal.
|
|
226
|
+
* Non-exiting core of `worktree setup`. exitCode 1 = refused (`invalid-feature-name`),
|
|
227
|
+
* 2 = already exists here, 3 = a live worktree for the feature exists elsewhere.
|
|
460
228
|
*/
|
|
461
229
|
export async function runWorktreeSetup(feature, options = {}) {
|
|
462
230
|
const cwd = options.cwd || process.cwd();
|
|
463
231
|
|
|
464
|
-
//
|
|
465
|
-
// refname, or any git call. See isValidFeatureName — `create` already refuses
|
|
466
|
-
// these names, so accepting them here would only ever create a worktree no
|
|
467
|
-
// other morph-spec command can address.
|
|
232
|
+
// A plain kebab slug before it reaches a path, a refname or any git call (see isValidFeatureName).
|
|
468
233
|
if (!isValidFeatureName(feature)) {
|
|
469
234
|
return { ok: false, exitCode: 1, created: false, error: 'invalid-feature-name', feature };
|
|
470
235
|
}
|
|
471
236
|
|
|
472
|
-
// Gate 1: Must be a git repo
|
|
473
237
|
if (!isGitRepo(cwd)) {
|
|
474
238
|
return { ok: true, exitCode: 0, created: false, error: 'not-a-git-repo', feature };
|
|
475
239
|
}
|
|
@@ -477,16 +241,11 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
477
241
|
const branch = buildBranchName(feature, options);
|
|
478
242
|
const worktreePath = buildWorktreePath(feature, cwd, options);
|
|
479
243
|
|
|
480
|
-
// Gate 2: If worktree already registered and not --fresh, signal resume
|
|
481
244
|
if (!options.fresh && worktreeExists(worktreePath, cwd)) {
|
|
482
245
|
return { ok: false, exitCode: 2, created: false, alreadyExists: true, path: worktreePath, branch, feature };
|
|
483
246
|
}
|
|
484
247
|
|
|
485
|
-
//
|
|
486
|
-
// feature. If feature.json already records a worktree path that still exists
|
|
487
|
-
// on disk and differs from the one this call would create, stop (exit 3).
|
|
488
|
-
// (A recorded path that's gone from disk — e.g. removed via `git worktree
|
|
489
|
-
// remove` — does not block: existsSync() is false, so this falls through.)
|
|
248
|
+
// Anti-dupe: refuse a second LIVE worktree for this feature; a recorded path gone from disk does not block.
|
|
490
249
|
try {
|
|
491
250
|
const featureJsonPath = join(cwd, '.morph', 'features', feature, 'feature.json');
|
|
492
251
|
if (existsSync(featureJsonPath)) {
|
|
@@ -510,15 +269,8 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
510
269
|
|
|
511
270
|
let branchedFrom = null;
|
|
512
271
|
try {
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
// gave the new feature that feature's commits, and every PR opened from
|
|
516
|
-
// this worktree carried the sibling's diff (A1). Symmetric with the common
|
|
517
|
-
// flow in create.js → branchCommonFlow.
|
|
518
|
-
//
|
|
519
|
-
// Fail-open, same as create: with no resolvable default (a repo whose
|
|
520
|
-
// default is neither `main`/`master` nor declared via origin/HEAD) the
|
|
521
|
-
// branch is still created — off HEAD — and `branchedFrom: null` reports it.
|
|
272
|
+
// Off the DEFAULT branch, never HEAD: a root parked on another `morph/*` would hand over its commits.
|
|
273
|
+
// No resolvable default → off HEAD, reported as `branchedFrom: null`.
|
|
522
274
|
if (!branchExists(branch, cwd)) {
|
|
523
275
|
const defaultBranch = detectDefaultBranch(cwd);
|
|
524
276
|
if (defaultBranch) {
|
|
@@ -529,48 +281,32 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
529
281
|
}
|
|
530
282
|
}
|
|
531
283
|
|
|
532
|
-
// Create worktree directory parent if needed
|
|
533
284
|
const parentDir = newWorktreesDir(cwd);
|
|
534
285
|
if (!existsSync(parentDir)) {
|
|
535
286
|
mkdirSync(parentDir, { recursive: true });
|
|
536
287
|
}
|
|
537
288
|
|
|
538
|
-
// Add worktree
|
|
539
289
|
execSync(`git worktree add "${worktreePath}" ${branch}`, { cwd, stdio: 'pipe' });
|
|
540
290
|
|
|
541
|
-
// Set up infrastructure junctions (node_modules, .claude, .morph/framework).
|
|
542
291
|
setupInfraLinks(worktreePath, cwd);
|
|
543
292
|
const included = copyWorktreeIncludes(worktreePath, cwd);
|
|
544
293
|
|
|
545
|
-
//
|
|
546
|
-
// worktree-local manifest (.morph/worktree.env). Fail-open — a worktree
|
|
547
|
-
// without ports is degraded, never broken.
|
|
294
|
+
// Fail-open: a worktree without ports is degraded, never broken.
|
|
548
295
|
const envBlock = await allocateWorktreeEnv(worktreePath, cwd, feature);
|
|
549
296
|
|
|
550
|
-
// Materialize a local, real state.json in the worktree (self-heal from the
|
|
551
|
-
// committed feature.json files) so hooks work from the very first session.
|
|
552
297
|
const materialized = await ensureLocalState(worktreePath);
|
|
553
298
|
|
|
554
299
|
const graph = seedWorktreeGraph(worktreePath, cwd);
|
|
555
300
|
|
|
556
|
-
//
|
|
557
|
-
// handoff.md before anything else (SessionStart hook points at it). The doc
|
|
558
|
-
// is derived and git-invisible (see handoff-generator). Fail-open — and a
|
|
559
|
-
// no-op in the create --worktree flow, where the feature dir only lands in
|
|
560
|
-
// the worktree after create's copy (create regenerates it then).
|
|
301
|
+
// Fail-open; a no-op under `create --worktree`, which regenerates it after its copy.
|
|
561
302
|
try {
|
|
562
303
|
if (existsSync(join(worktreePath, '.morph', 'features', feature, 'feature.json'))) {
|
|
563
304
|
const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
|
|
564
|
-
// `worktree` is
|
|
565
|
-
// copy by contract — pass it explicitly so the doc built FROM INSIDE
|
|
566
|
-
// the worktree still says "Local: worktree", not "raiz primária".
|
|
305
|
+
// `worktree` is root-owned and absent from this branch copy: pass it so the doc says "Local: worktree".
|
|
567
306
|
writeHandoff(feature, worktreePath, { worktree: { path: worktreePath, branch } });
|
|
568
307
|
}
|
|
569
|
-
} catch {
|
|
308
|
+
} catch {}
|
|
570
309
|
|
|
571
|
-
// Persist in state (non-blocking — feature may not be in state yet).
|
|
572
|
-
// NOTE: imports from core/ (the lib/ path was a latent bug that silently
|
|
573
|
-
// no-op'd this whole block); loadState/saveState operate on the cwd's state.
|
|
574
310
|
try {
|
|
575
311
|
const { loadState, saveState } = await import('../../core/state/state-manager.js');
|
|
576
312
|
const state = loadState(false);
|
|
@@ -579,7 +315,6 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
579
315
|
saveState(state);
|
|
580
316
|
}
|
|
581
317
|
} catch {
|
|
582
|
-
// Non-blocking
|
|
583
318
|
}
|
|
584
319
|
|
|
585
320
|
return {
|
|
@@ -602,31 +337,13 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
602
337
|
}
|
|
603
338
|
}
|
|
604
339
|
|
|
605
|
-
/**
|
|
606
|
-
* Main command handler for `morph-spec worktree setup <feature>` — thin
|
|
607
|
-
* wrapper around `runWorktreeSetup` that prints its JSON result and exits
|
|
608
|
-
* with its exit code. Preserves the exact JSON shape and exit codes (0/2/3)
|
|
609
|
-
* this command has always used.
|
|
610
|
-
* @param {string} feature
|
|
611
|
-
* @param {{ fresh?: boolean, cwd?: string }} options
|
|
612
|
-
*/
|
|
613
340
|
export async function worktreeSetupCommand(feature, options = {}) {
|
|
614
341
|
const { ok, exitCode, ...result } = await runWorktreeSetup(feature, options);
|
|
615
342
|
console.log(JSON.stringify(result));
|
|
616
343
|
process.exit(exitCode);
|
|
617
344
|
}
|
|
618
345
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* Branch name for a task-isolation worktree: `morph-task/<name>`. A separate
|
|
623
|
-
* namespace from `morph/<feature>` on purpose — `worktree list`, `fleet`,
|
|
624
|
-
* `finish` and the SessionStart hook all read `morph/<f>` as "this IS feature
|
|
625
|
-
* f", and a scratch tree named after a feature must never impersonate it.
|
|
626
|
-
*
|
|
627
|
-
* @param {string} name
|
|
628
|
-
* @returns {string}
|
|
629
|
-
*/
|
|
346
|
+
/** A separate namespace on purpose: a scratch tree must never impersonate `morph/<feature>`. */
|
|
630
347
|
export function buildTaskBranchName(name) {
|
|
631
348
|
return `morph-task/${name}`;
|
|
632
349
|
}
|
|
@@ -634,18 +351,8 @@ export function buildTaskBranchName(name) {
|
|
|
634
351
|
const ROOT_ENV_FILE_RE = /^\.env(\..+)?$/;
|
|
635
352
|
|
|
636
353
|
/**
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
* a REAL file (never a link — nothing in the tree may reach the root through
|
|
640
|
-
* one), only when the tree lacks it (never overwrite), and only when git
|
|
641
|
-
* IGNORES it there: an untracked-but-visible copy would dirty the tree, and
|
|
642
|
-
* `finish`/`worktree remove --if-clean` refuse a dirty tree. At teardown the
|
|
643
|
-
* copy is byte-identical to the root's, which `triageIgnoredPaths` classifies
|
|
644
|
-
* as REPLICABLE — deleted with nothing lost.
|
|
645
|
-
*
|
|
646
|
-
* @param {string} worktreePath
|
|
647
|
-
* @param {string} root
|
|
648
|
-
* @returns {string[]} names copied
|
|
354
|
+
* Only a REAL file, never overwriting, and only when git IGNORES it in the tree: a visible copy
|
|
355
|
+
* would dirty it and `finish` refuses a dirty tree. At teardown the copy is a replica of the root's.
|
|
649
356
|
*/
|
|
650
357
|
export function copyRootEnvFiles(worktreePath, root) {
|
|
651
358
|
let names;
|
|
@@ -659,32 +366,16 @@ export function copyRootEnvFiles(worktreePath, root) {
|
|
|
659
366
|
const dst = join(worktreePath, name);
|
|
660
367
|
try {
|
|
661
368
|
if (!lstatSync(join(root, name)).isFile()) continue;
|
|
662
|
-
try { lstatSync(dst); continue; } catch {
|
|
369
|
+
try { lstatSync(dst); continue; } catch {}
|
|
663
370
|
execFileSync('git', ['check-ignore', '-q', '--', name], { cwd: worktreePath, stdio: 'pipe' });
|
|
664
371
|
copyFileSync(join(root, name), dst);
|
|
665
372
|
copied.push(name);
|
|
666
|
-
} catch {
|
|
373
|
+
} catch {}
|
|
667
374
|
}
|
|
668
375
|
return copied;
|
|
669
376
|
}
|
|
670
377
|
|
|
671
|
-
/**
|
|
672
|
-
* Non-exiting core of `worktree provision` — the tree `claude -w`,
|
|
673
|
-
* `EnterWorktree` and `Agent isolation:"worktree"` ask for when the name is
|
|
674
|
-
* not a feature. Everything `setup` gives a feature worktree EXCEPT the
|
|
675
|
-
* feature identity: it lands under `<primary root>/worktrees/<name>` (never
|
|
676
|
-
* nested under a secondary worktree, whatever the cwd), on `morph-task/<name>`
|
|
677
|
-
* off the default branch (or `--base`), with the infra junctions, a git-
|
|
678
|
-
* invisible `.morph/worktree.env` keyed `task/<name>`, and a materialized
|
|
679
|
-
* local state.json. No feature registration, no handoff.
|
|
680
|
-
*
|
|
681
|
-
* Exit codes mirror `setup`: 0 created (or fail-open), 1 refused (bad name),
|
|
682
|
-
* 2 already exists at this exact path.
|
|
683
|
-
*
|
|
684
|
-
* @param {string} name
|
|
685
|
-
* @param {{ base?: string, cwd?: string }} [options]
|
|
686
|
-
* @returns {Promise<{ ok: boolean, exitCode: 0|1|2, created: boolean, alreadyExists?: boolean, path?: string, branch?: string, branchedFrom?: string|null, kind?: 'task-isolation', name: string, env?: object, error?: string }>}
|
|
687
|
-
*/
|
|
378
|
+
/** Non-exiting core of `worktree provision`: `setup` without the feature identity. Exit codes mirror `setup`. */
|
|
688
379
|
export async function runWorktreeProvision(name, options = {}) {
|
|
689
380
|
const cwd = options.cwd || process.cwd();
|
|
690
381
|
|
|
@@ -695,9 +386,7 @@ export async function runWorktreeProvision(name, options = {}) {
|
|
|
695
386
|
return { ok: true, exitCode: 0, created: false, error: 'not-a-git-repo', name };
|
|
696
387
|
}
|
|
697
388
|
|
|
698
|
-
// Always the PRIMARY root: a
|
|
699
|
-
// must not nest a worktree under it — that tree would be invisible to the
|
|
700
|
-
// fleet and to doctor, which enumerate `<root>/worktrees/`.
|
|
389
|
+
// Always the PRIMARY root: a tree nested under a feature worktree is invisible to fleet and doctor.
|
|
701
390
|
const root = resolvePrimaryRoot(cwd);
|
|
702
391
|
const branch = buildTaskBranchName(name);
|
|
703
392
|
const worktreePath = buildWorktreePath(name, root);
|
|
@@ -755,34 +444,17 @@ export async function runWorktreeProvision(name, options = {}) {
|
|
|
755
444
|
}
|
|
756
445
|
}
|
|
757
446
|
|
|
758
|
-
/**
|
|
759
|
-
* `morph-spec worktree provision <name> [--base <ref>]` — thin wrapper around
|
|
760
|
-
* `runWorktreeProvision`, same JSON-and-exit contract as `setup`.
|
|
761
|
-
* @param {string} name
|
|
762
|
-
* @param {{ base?: string, cwd?: string }} [options]
|
|
763
|
-
*/
|
|
764
447
|
export async function worktreeProvisionCommand(name, options = {}) {
|
|
765
448
|
const { ok, exitCode, ...result } = await runWorktreeProvision(name, options);
|
|
766
449
|
console.log(JSON.stringify(result));
|
|
767
450
|
process.exit(exitCode);
|
|
768
451
|
}
|
|
769
452
|
|
|
770
|
-
/**
|
|
771
|
-
* Command handler for `morph-spec worktree link [--target <path>] [--all]`.
|
|
772
|
-
*
|
|
773
|
-
* (Re)links shared infrastructure (node_modules by default; also .claude and
|
|
774
|
-
* .morph/framework with --all) from the primary root into a worktree. Useful
|
|
775
|
-
* to repair/refresh junctions without recreating the whole worktree.
|
|
776
|
-
* Idempotent — already-present junctions are reported as skipped, not re-created.
|
|
777
|
-
*
|
|
778
|
-
* @param {{ target?: string, all?: boolean, cwd?: string }} [options]
|
|
779
|
-
*/
|
|
780
453
|
export async function worktreeLinkCommand(options = {}) {
|
|
781
454
|
const cwdArg = options.cwd || process.cwd();
|
|
782
455
|
const root = resolvePrimaryRoot(cwdArg);
|
|
783
456
|
const target = resolve(options.target || options.cwd || process.cwd()).replace(/\\/g, '/');
|
|
784
457
|
|
|
785
|
-
// No-op: linking the primary root into itself makes no sense.
|
|
786
458
|
if (target === root) {
|
|
787
459
|
console.log(JSON.stringify({ linked: [], skipped: ['is-primary-root'], root, target }));
|
|
788
460
|
process.exit(0);
|
|
@@ -790,7 +462,7 @@ export async function worktreeLinkCommand(options = {}) {
|
|
|
790
462
|
|
|
791
463
|
if (!isGitRepo(cwdArg)) {
|
|
792
464
|
console.log(JSON.stringify({ error: 'not-a-git-repo' }));
|
|
793
|
-
process.exit(0);
|
|
465
|
+
process.exit(0);
|
|
794
466
|
}
|
|
795
467
|
|
|
796
468
|
const dirs = options.all
|
|
@@ -808,18 +480,15 @@ export async function worktreeLinkCommand(options = {}) {
|
|
|
808
480
|
const skipped = [];
|
|
809
481
|
for (const rel of dirs) {
|
|
810
482
|
if (alreadyPresent[rel]) {
|
|
811
|
-
skipped.push(rel);
|
|
483
|
+
skipped.push(rel);
|
|
812
484
|
} else if (existsSync(join(target, rel))) {
|
|
813
|
-
linked.push(rel);
|
|
485
|
+
linked.push(rel);
|
|
814
486
|
} else {
|
|
815
|
-
skipped.push(rel);
|
|
487
|
+
skipped.push(rel);
|
|
816
488
|
}
|
|
817
489
|
}
|
|
818
490
|
|
|
819
|
-
//
|
|
820
|
-
// that includes a `.morph/worktree.env` written by an older layout. The
|
|
821
|
-
// allocation is idempotent (same key → same block), so regenerating the
|
|
822
|
-
// manifest never moves a worktree's ports; it only adds the keys it lacks.
|
|
491
|
+
// Allocation is idempotent (same key → same block): regenerating never moves a worktree's ports.
|
|
823
492
|
let env = null;
|
|
824
493
|
if (options.all) {
|
|
825
494
|
const key = envKeyForWorktree(target, root);
|
|
@@ -831,15 +500,7 @@ export async function worktreeLinkCommand(options = {}) {
|
|
|
831
500
|
process.exit(0);
|
|
832
501
|
}
|
|
833
502
|
|
|
834
|
-
/**
|
|
835
|
-
* The env-registry key of a worktree, derived from its branch: `morph/<f>` →
|
|
836
|
-
* `<f>` (feature), `morph-task/<n>` → `task/<n>` (task isolation). `null` for
|
|
837
|
-
* a branch morph does not own — no block is allocated for those.
|
|
838
|
-
*
|
|
839
|
-
* @param {string} worktreePath
|
|
840
|
-
* @param {string} root
|
|
841
|
-
* @returns {string|null}
|
|
842
|
-
*/
|
|
503
|
+
/** `morph/<f>` → `<f>`, `morph-task/<n>` → `task/<n>`, `null` for a branch morph does not own. */
|
|
843
504
|
export function envKeyForWorktree(worktreePath, root) {
|
|
844
505
|
const norm = resolve(worktreePath).replace(/\\/g, '/');
|
|
845
506
|
const entry = parseWorktreeList(root).find((e) => e.path === norm);
|
|
@@ -852,26 +513,8 @@ export function envKeyForWorktree(worktreePath, root) {
|
|
|
852
513
|
}
|
|
853
514
|
|
|
854
515
|
/**
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
* the infra links (it must be invisible to the finish clean-checks). Under
|
|
858
|
-
* `.morph/` on purpose: that prefix is already shieldable at finish (backed up,
|
|
859
|
-
* never a removal blocker). Fail-open: returns `null` when no block could be
|
|
860
|
-
* written — a worktree without ports is degraded, never broken.
|
|
861
|
-
*
|
|
862
|
-
* Shared by `setup` (feature key), `provision` (task key) and `link --all`
|
|
863
|
-
* (regeneration), so the manifest has exactly one writer.
|
|
864
|
-
*
|
|
865
|
-
* `morphFeature` (task trees born from `morph/<f>`) only renames the
|
|
866
|
-
* `MORPH_FEATURE` line. Left undefined — the `link --all` regeneration, which
|
|
867
|
-
* derives the key from the branch and cannot see the base — the value the
|
|
868
|
-
* file already carries is kept, so a repair never forgets the feature.
|
|
869
|
-
*
|
|
870
|
-
* @param {string} worktreePath
|
|
871
|
-
* @param {string} root - primary root
|
|
872
|
-
* @param {string} envKey - `<feature>` or `task/<name>`
|
|
873
|
-
* @param {{ morphFeature?: string|null }} [opts]
|
|
874
|
-
* @returns {Promise<{ index: number, base: number, range: [number, number] }|null>}
|
|
516
|
+
* The manifest's single writer. Without `morphFeature` (the `link --all` repair) the MORPH_FEATURE
|
|
517
|
+
* already in the file is kept. `null` when no block could be written.
|
|
875
518
|
*/
|
|
876
519
|
export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFeature } = {}) {
|
|
877
520
|
const envPath = join(worktreePath, '.morph', 'worktree.env');
|
|
@@ -894,7 +537,7 @@ export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFea
|
|
|
894
537
|
const cfg = JSON.parse(readFileSync(join(root, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, ''));
|
|
895
538
|
stack = cfg?.project?.stack || '';
|
|
896
539
|
aliases = resolvePortAliases(cfg).aliases;
|
|
897
|
-
} catch {
|
|
540
|
+
} catch {}
|
|
898
541
|
mkdirSync(join(worktreePath, '.morph'), { recursive: true });
|
|
899
542
|
writeFileSync(envPath, buildEnvFileContent(envKey, block, stack, { morphFeature, aliases }), 'utf8');
|
|
900
543
|
excludeWorktreePaths(root, [join('.morph', 'worktree.env')]);
|
|
@@ -904,14 +547,7 @@ export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFea
|
|
|
904
547
|
}
|
|
905
548
|
}
|
|
906
549
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
/**
|
|
910
|
-
* Parse `git worktree list --porcelain` into structured entries.
|
|
911
|
-
* Fail-open: returns `[]` on any git error (e.g. not a git repo).
|
|
912
|
-
* @param {string} cwd
|
|
913
|
-
* @returns {Array<{ path: string, branch: string|null, head: string|null, locked: boolean }>}
|
|
914
|
-
*/
|
|
550
|
+
/** Fail-open: `[]` on any git error. */
|
|
915
551
|
export function parseWorktreeList(cwd) {
|
|
916
552
|
let output;
|
|
917
553
|
try {
|
|
@@ -939,22 +575,8 @@ export function parseWorktreeList(cwd) {
|
|
|
939
575
|
}
|
|
940
576
|
|
|
941
577
|
/**
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
* `git worktree list --porcelain`. Fallback: the conventional
|
|
945
|
-
* `buildWorktreePath` location, but only if it is itself a registered
|
|
946
|
-
* worktree entry (so a stray directory never gets mistaken for a live one).
|
|
947
|
-
*
|
|
948
|
-
* Returns the branch the worktree is ACTUALLY on, never the computed one. Via
|
|
949
|
-
* the path fallback the two can diverge (the user checked out something else in
|
|
950
|
-
* there), and reporting the computed name would make `finish` merge
|
|
951
|
-
* `morph/{feature}` while removing a worktree holding unrelated work — merging a
|
|
952
|
-
* branch it never validated, which is the whole failure this command guards
|
|
953
|
-
* against. The caller compares the two and refuses on mismatch.
|
|
954
|
-
*
|
|
955
|
-
* @param {string} feature
|
|
956
|
-
* @param {string} root - Primary root path
|
|
957
|
-
* @returns {{ path: string, branch: string, expectedBranch: string }|null}
|
|
578
|
+
* Returns the branch the worktree is ACTUALLY on, never the computed one: via the path fallback they
|
|
579
|
+
* can diverge, and the caller refuses on mismatch rather than merge a branch it never validated.
|
|
958
580
|
*/
|
|
959
581
|
export function findWorktreeForFeature(feature, root) {
|
|
960
582
|
const expectedBranch = buildBranchName(feature);
|
|
@@ -968,41 +590,13 @@ export function findWorktreeForFeature(feature, root) {
|
|
|
968
590
|
}
|
|
969
591
|
if (!match) return null;
|
|
970
592
|
|
|
971
|
-
// A detached HEAD has no branch at all — report it as such rather than
|
|
972
|
-
// inventing the computed name for it.
|
|
973
593
|
const actual = match.branch ? match.branch.replace(/^refs\/heads\//, '') : null;
|
|
974
594
|
return { path: match.path, branch: actual, expectedBranch };
|
|
975
595
|
}
|
|
976
596
|
|
|
977
597
|
/**
|
|
978
|
-
*
|
|
979
|
-
* `.morph/framework
|
|
980
|
-
* configured) WITHOUT following them.
|
|
981
|
-
*
|
|
982
|
-
* CRITICAL — this is what keeps `morph-spec finish` (worktree teardown) from
|
|
983
|
-
* destroying the project. `git worktree remove` refuses a worktree still holding these ("contains
|
|
984
|
-
* modified or untracked files"), and the `--force` fallback FOLLOWS the
|
|
985
|
-
* junctions and deletes the PRIMARY ROOT's real `.claude/` and
|
|
986
|
-
* `.morph/framework/` — wiping the morph-spec installation (MORPH.md, agents,
|
|
987
|
-
* skills, settings) while still reporting `finished: true`. On a short path the
|
|
988
|
-
* wipe is total and silent; on a long one MAX_PATH aborts it midway, leaving
|
|
989
|
-
* partial damage plus an orphan directory. Removing the reparse points first
|
|
990
|
-
* lets the plain (non-force) removal succeed and leaves `--force` nothing to
|
|
991
|
-
* traverse.
|
|
992
|
-
*
|
|
993
|
-
* Only links are touched: `unlinkSync`/`rmdirSync` act on the LINK, never on the
|
|
994
|
-
* target's contents, and `rmdirSync` fails on a real non-empty directory — the
|
|
995
|
-
* safety that stops this from ever deleting genuine files.
|
|
996
|
-
*
|
|
997
|
-
* This is deliberately narrow to the KNOWN inventory from `resolveInfraLinkDirs`
|
|
998
|
-
* — never widened to auto-remove any reparse point `findReparsePoints` might
|
|
999
|
-
* discover elsewhere. An unknown/nested link (npm link, npm workspaces) must
|
|
1000
|
-
* still decline the removal; see the CANARY tests pinning that in worktree.test.js.
|
|
1001
|
-
*
|
|
1002
|
-
* @param {string} worktreePath
|
|
1003
|
-
* @param {string} mainCwd - Absolute path to the main project root, to resolve
|
|
1004
|
-
* `project.frontendPath` from config.json
|
|
1005
|
-
* @returns {string[]} relative paths actually unlinked
|
|
598
|
+
* Must run before `git worktree remove`: its `--force` fallback FOLLOWS junctions and deletes the root's
|
|
599
|
+
* real `.claude/` and `.morph/framework/`. Only the known inventory, never any reparse point found.
|
|
1006
600
|
*/
|
|
1007
601
|
export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
|
|
1008
602
|
const removed = [];
|
|
@@ -1012,7 +606,7 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
|
|
|
1012
606
|
try {
|
|
1013
607
|
st = lstatSync(p);
|
|
1014
608
|
} catch {
|
|
1015
|
-
continue;
|
|
609
|
+
continue;
|
|
1016
610
|
}
|
|
1017
611
|
if (!st.isSymbolicLink() && !st.isDirectory()) continue;
|
|
1018
612
|
try {
|
|
@@ -1028,36 +622,10 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
|
|
|
1028
622
|
return removed;
|
|
1029
623
|
}
|
|
1030
624
|
|
|
1031
|
-
|
|
1032
|
-
* Gitignored paths a morph worktree owns and that are safe to remove — but ONLY
|
|
1033
|
-
* once backed up (see writeWorktreeIgnoredBackup). `.morph/state.json` is provably
|
|
1034
|
-
* derivable (loadState self-heals it from the committed feature.json files) and
|
|
1035
|
-
* the rest is morph's own diagnostic output, which `setupInfraLinks` documents
|
|
1036
|
-
* as worktree-local. They are small, so backing them up costs nothing and buys
|
|
1037
|
-
* the guarantee that this list does not have to be RIGHT.
|
|
1038
|
-
*
|
|
1039
|
-
* `node_modules` is deliberately NOT here: it is regenerable from the lockfile,
|
|
1040
|
-
* far too large to copy, and `unlinkWorktreeInfraLinks` already dropped it when
|
|
1041
|
-
* it is the junction `setup` creates. A real one left behind blocks removal —
|
|
1042
|
-
* safe, and the user can clear it themselves.
|
|
1043
|
-
*/
|
|
625
|
+
// Safe to remove only once backed up. `node_modules` is not here: regenerable and too large to copy.
|
|
1044
626
|
const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
|
|
1045
627
|
|
|
1046
|
-
/**
|
|
1047
|
-
* Whether a dirty root-side path is EXACTLY a sibling feature's live worktree
|
|
1048
|
-
* registration — see the call site (primaryRootUnexplainedDirt, the root-clean
|
|
1049
|
-
* gate `morph-spec finish` uses) for why this is safe to exempt from the
|
|
1050
|
-
* whole-root clean gate rather than a widened hole.
|
|
1051
|
-
*
|
|
1052
|
-
* PROVES it rather than assuming: the path must be exactly that sibling's
|
|
1053
|
-
* `feature.json`, must parse, and its `worktree.path` must point at a
|
|
1054
|
-
* currently-existing directory. A parse failure, a missing registration, or a
|
|
1055
|
-
* STALE pointer (worktree already removed) is NOT exempted.
|
|
1056
|
-
*
|
|
1057
|
-
* @param {string} relPath - forward-slash path relative to `root`
|
|
1058
|
-
* @param {string} root - absolute path to the primary root
|
|
1059
|
-
* @param {string} feature - the feature THIS finish is acting on (never exempted — its own scope is the shed's job)
|
|
1060
|
-
*/
|
|
628
|
+
/** PROVES it: exactly the sibling's feature.json, parseable, and `worktree.path` an existing dir. */
|
|
1061
629
|
function isLiveSiblingWorktreeRegistration(relPath, root, feature) {
|
|
1062
630
|
const m = relPath.match(/^\.morph\/features\/([^/]+)\/feature\.json$/);
|
|
1063
631
|
if (!m || m[1] === feature) return false;
|
|
@@ -1071,61 +639,14 @@ function isLiveSiblingWorktreeRegistration(relPath, root, feature) {
|
|
|
1071
639
|
}
|
|
1072
640
|
|
|
1073
641
|
/**
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
* Why this exists: `git worktree remove` deletes gitignored files outright, and
|
|
1078
|
-
* the clean-check that gates it uses `git status --porcelain`, which omits them
|
|
1079
|
-
* BY DESIGN. So a user's `.env` or a local override is invisible to that gate
|
|
1080
|
-
* and dies silently while the command reports success. Git treats gitignored as
|
|
1081
|
-
* disposable; morph must not, because "not in git" is exactly the property that
|
|
1082
|
-
* makes a file unrecoverable.
|
|
1083
|
-
*
|
|
1084
|
-
* The buckets, and why refusing is no longer one of them: the earlier version
|
|
1085
|
-
* blocked on ANY unrecognised gitignored path, which in practice meant blocking
|
|
1086
|
-
* on `.env` files that were byte-identical to the primary root's — nothing was
|
|
1087
|
-
* at stake, and the only escape offered (`--discard-ignored`) also destroyed the
|
|
1088
|
-
* genuinely unique `keys/` next to them. `triageIgnoredPaths` answers the
|
|
1089
|
-
* question that actually decides whether bytes are lost (see ignored-triage.js),
|
|
1090
|
-
* so the command can PROCEED in every case, backing up only what exists nowhere
|
|
1091
|
-
* else. A refusal survives for exactly one reason now: the backup FAILED.
|
|
1092
|
-
*
|
|
1093
|
-
* shieldable — morph's own bookkeeping + anything EXCLUSIVE to this worktree.
|
|
1094
|
-
* Copied to `finish-backup` before the removal; a failed copy is
|
|
1095
|
-
* what declines the removal.
|
|
1096
|
-
* discarded — build artifacts (regenerable) and REPLICABLE files (the root
|
|
1097
|
-
* holds the same path with the same SHA-256). Deleted without a
|
|
1098
|
-
* backup because neither loses bytes.
|
|
1099
|
-
* blocking — the fail-CLOSED sentinel only: `git status --ignored` itself
|
|
1100
|
-
* failed, so we cannot enumerate what is at stake.
|
|
1101
|
-
*
|
|
1102
|
-
* `--discard-ignored` no longer means "destroy the unique ones too, blind"; it
|
|
1103
|
-
* means SKIP THE BACKUP — the same removal, without the copy.
|
|
1104
|
-
*
|
|
1105
|
-
* @param {string} worktreePath
|
|
1106
|
-
* @param {string} rootPath - the primary root, for the replica comparison
|
|
1107
|
-
* @param {{ discardIgnored?: boolean }} options
|
|
1108
|
-
* @returns {{ shieldable: string[], blocking: string[], discarded: string[], replicable: string[] }}
|
|
1109
|
-
*
|
|
1110
|
-
* EXPORTADA para que o `finish --dry-run` (pré-voo) responda EXATAMENTE a
|
|
1111
|
-
* mesma pergunta que o teardown real vai responder. Uma segunda enumeração de
|
|
1112
|
-
* `git status --ignored` no finish.js — com a sua própria cópia de
|
|
1113
|
-
* IGNORED_SHIELDABLE_PREFIXES — faria o pré-voo prometer uma coisa e o
|
|
1114
|
-
* comando fazer outra, que é a única maneira de um dry-run ser pior que
|
|
1115
|
-
* nenhum. Consumida read-only: o pré-voo não copia, não apaga, não decide.
|
|
642
|
+
* `git worktree remove` deletes gitignored files and `git status --porcelain` omits them. shieldable:
|
|
643
|
+
* backed up first; discarded: build artifacts and replicas of the root; blocking: enumeration failed.
|
|
1116
644
|
*/
|
|
1117
645
|
export function classifyIgnoredLeftovers(worktreePath, rootPath, options = {}) {
|
|
1118
646
|
let out;
|
|
1119
647
|
try {
|
|
1120
|
-
// `-z` + execFile: NUL-delimited, no shell
|
|
1121
|
-
//
|
|
1122
|
-
// `=matching` (not the traditional default): an ignored directory whose own
|
|
1123
|
-
// name matches no pattern — e.g. `MyProj/`, ignored only because EVERYTHING
|
|
1124
|
-
// inside it (`bin/`, `obj/`) does — otherwise collapses into one opaque
|
|
1125
|
-
// `MyProj/` entry, hiding the actual disposable-artifact names from
|
|
1126
|
-
// isDisposableBuildArtifact. A directory that IS itself the matched pattern
|
|
1127
|
-
// (`node_modules/`) still reports as one entry either way — verified
|
|
1128
|
-
// empirically, not assumed.
|
|
648
|
+
// `-z` + execFile: NUL-delimited, no shell quoting. `=matching`: otherwise a dir ignored only through
|
|
649
|
+
// its children (`MyProj/` via `bin/`, `obj/`) collapses into one entry, hiding the artifact names.
|
|
1129
650
|
out = execFileSync('git', ['status', '--porcelain', '--ignored=matching', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
|
|
1130
651
|
} catch {
|
|
1131
652
|
return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'], discarded: [], replicable: [] };
|
|
@@ -1144,45 +665,20 @@ export function classifyIgnoredLeftovers(worktreePath, rootPath, options = {}) {
|
|
|
1144
665
|
const { replicable, disposable, exclusive } = triageIgnoredPaths({ worktreePath, rootPath, paths: rest });
|
|
1145
666
|
const shieldable = [...morphOwned, ...exclusive];
|
|
1146
667
|
|
|
1147
|
-
//
|
|
1148
|
-
// are removed either way; the flag only decides whether a copy is made first.
|
|
1149
|
-
// The list is never thrown away — these are the bytes that exist nowhere
|
|
1150
|
-
// else, and reporting them is the least this can do.
|
|
668
|
+
// Consent = SKIP THE BACKUP, not widen what dies; the list is still reported.
|
|
1151
669
|
if (options.discardIgnored) {
|
|
1152
670
|
return { shieldable: [], blocking: [], discarded: [...disposable, ...replicable, ...shieldable], replicable };
|
|
1153
671
|
}
|
|
1154
672
|
return { shieldable, blocking: [], discarded: [...disposable, ...replicable], replicable };
|
|
1155
673
|
}
|
|
1156
674
|
|
|
1157
|
-
|
|
1158
|
-
//
|
|
1159
|
-
// The old `worktree finish` command dissolved here: `morph-spec finish` (see
|
|
1160
|
-
// src/commands/state/finish.js) now orchestrates archive → integrate (merge OR
|
|
1161
|
-
// PR) → teardown for BOTH the worktree and the common flow. These exported
|
|
1162
|
-
// primitives keep the load-bearing safety (shed with backup, prove-or-decline
|
|
1163
|
-
// worktree removal) in one hardened place, shared by every integration path.
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* Shed the feature's OWN transient root-side bookkeeping so the merge can own
|
|
1167
|
-
* this feature's state while the whole-root clean-check keeps guarding UNRELATED
|
|
1168
|
-
* work. Backs up FIRST (see writeFeatureScopeBackup), then discards — the shed
|
|
1169
|
-
* is safe WITHOUT having to be right about what is in scope: if the
|
|
1170
|
-
* classification the merge implies turns out wrong, the bytes are still on
|
|
1171
|
-
* disk. Worktree-flow only; the common flow committed the archive on the
|
|
1172
|
-
* branch, so there is no untracked root-side scaffold to shed.
|
|
1173
|
-
*
|
|
1174
|
-
* @param {string} root
|
|
1175
|
-
* @param {string} feature
|
|
1176
|
-
* @param {{ stamp?: string }} [opts] - the finish's one stamp, shared with the
|
|
1177
|
-
* teardown's backup so the two folders count as one backup
|
|
1178
|
-
* @returns {{ shedBackup: string|null }} backup path if anything was shed
|
|
1179
|
-
*/
|
|
675
|
+
/** Backs up FIRST, then discards: safe without being right about what is in scope. */
|
|
1180
676
|
export function shedRootFeatureScope(root, feature, { stamp } = {}) {
|
|
1181
677
|
const featPathspec = `.morph/features/${feature}`;
|
|
1182
678
|
let dirtyInScope = '';
|
|
1183
679
|
try {
|
|
1184
680
|
dirtyInScope = execFileSync('git', ['status', '--porcelain', '-uall', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
|
|
1185
|
-
} catch {
|
|
681
|
+
} catch {}
|
|
1186
682
|
|
|
1187
683
|
if (dirtyInScope.trim() === '') return { shedBackup: null };
|
|
1188
684
|
|
|
@@ -1190,41 +686,21 @@ export function shedRootFeatureScope(root, feature, { stamp } = {}) {
|
|
|
1190
686
|
// No backup, no shed. Falling through leaves the root dirty, so the caller's
|
|
1191
687
|
// clean-check refuses — the exact fail-closed behaviour from before any shed.
|
|
1192
688
|
if (shedBackup) {
|
|
1193
|
-
try { execFileSync('git', ['checkout', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch {
|
|
1194
|
-
try { execFileSync('git', ['clean', '-fdq', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch {
|
|
689
|
+
try { execFileSync('git', ['checkout', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch {}
|
|
690
|
+
try { execFileSync('git', ['clean', '-fdq', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch {}
|
|
1195
691
|
}
|
|
1196
692
|
return { shedBackup };
|
|
1197
693
|
}
|
|
1198
694
|
|
|
1199
695
|
/**
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1202
|
-
* isLiveSiblingWorktreeRegistration, the exemption that keeps one open sibling
|
|
1203
|
-
* from blocking every other feature's finish). `ok:false` means the status call
|
|
1204
|
-
* itself failed; an empty `dirt` means clean enough to merge into.
|
|
1205
|
-
*
|
|
1206
|
-
* `exemptOwnScope` additionally forgives everything under
|
|
1207
|
-
* `.morph/features/{feature}/` — the EXACT pathspec `shedRootFeatureScope`
|
|
1208
|
-
* backs up and discards. It exists so `finish` can ask "is the root clean
|
|
1209
|
-
* enough to merge into?" BEFORE archiving anything, where the shed has not run
|
|
1210
|
-
* yet and the feature's own scaffold is still on disk. Without it that early
|
|
1211
|
-
* question answers "dirty" for a root that the shed would have cleaned, and a
|
|
1212
|
-
* finish that works today would start being refused.
|
|
1213
|
-
*
|
|
1214
|
-
* Callers must keep the post-shed check: this one is an early refusal so the
|
|
1215
|
-
* archive never happens, not the authoritative gate.
|
|
1216
|
-
*
|
|
1217
|
-
* @param {string} root
|
|
1218
|
-
* @param {string} feature - the feature THIS finish acts on (its own scope is the shed's job)
|
|
1219
|
-
* @param {{ exemptOwnScope?: boolean }} [opts]
|
|
1220
|
-
* @returns {{ ok: boolean, dirt: string[] }}
|
|
696
|
+
* `exemptOwnScope` forgives `.morph/features/{feature}/` so `finish` can ask before the shed runs;
|
|
697
|
+
* callers must keep the post-shed check, the authoritative gate. `ok:false` = the status call failed.
|
|
1221
698
|
*/
|
|
1222
699
|
export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
|
|
1223
700
|
let rootStatusRaw;
|
|
1224
701
|
try {
|
|
1225
|
-
// `--untracked-files=all
|
|
1226
|
-
//
|
|
1227
|
-
// exact `.../feature.json` path isLiveSiblingWorktreeRegistration expects.
|
|
702
|
+
// `--untracked-files=all`: the default collapses an untracked dir into one entry that never
|
|
703
|
+
// matches the exact `.../feature.json` path.
|
|
1228
704
|
rootStatusRaw = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '-z'], { cwd: root, stdio: 'pipe' }).toString();
|
|
1229
705
|
} catch {
|
|
1230
706
|
return { ok: false, dirt: [] };
|
|
@@ -1240,27 +716,8 @@ export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
|
|
|
1240
716
|
}
|
|
1241
717
|
|
|
1242
718
|
/**
|
|
1243
|
-
*
|
|
1244
|
-
*
|
|
1245
|
-
* so a stale registration never trips the anti-dupe guard or the next finish's
|
|
1246
|
-
* root-clean.
|
|
1247
|
-
*
|
|
1248
|
-
* A registration that was committed on the default branch is cleared by a commit
|
|
1249
|
-
* scoped to the feature's own pathspec. An untracked scaffold (what
|
|
1250
|
-
* `create --worktree` leaves at the root) is never committed: the branch owns
|
|
1251
|
-
* the feature, and committing the skeleton would put it on the default next to
|
|
1252
|
-
* the archived copy. When the feature is already archived the skeleton is shed,
|
|
1253
|
-
* backup first, by the same primitive `finish` uses.
|
|
1254
|
-
*
|
|
1255
|
-
* Written DIRECTLY (never saveState, which re-persists every sibling's
|
|
1256
|
-
* feature.json and can dirty the root outside this command's pathspec). The thin
|
|
1257
|
-
* root index carries only {status, updatedAt}, never `worktree`. Fail-open:
|
|
1258
|
-
* metadata cleanup is never a reason to fail an integration that already landed.
|
|
1259
|
-
*
|
|
1260
|
-
* @param {string} root
|
|
1261
|
-
* @param {string} feature
|
|
1262
|
-
* @param {{ stamp?: string }} [opts] - the caller's backup stamp, for the shed
|
|
1263
|
-
* @returns {Promise<{ shedBackup: string|null }>}
|
|
719
|
+
* Written DIRECTLY, never via saveState (which re-persists every sibling and can dirty the root
|
|
720
|
+
* outside this pathspec). An untracked scaffold is never committed. Fail-open.
|
|
1264
721
|
*/
|
|
1265
722
|
export async function clearRootWorktreeRegistration(root, feature, { stamp } = {}) {
|
|
1266
723
|
const featPathspec = `.morph/features/${feature}`;
|
|
@@ -1275,7 +732,7 @@ export async function clearRootWorktreeRegistration(root, feature, { stamp } = {
|
|
|
1275
732
|
atomicWriteJson(rootFjPath, fj);
|
|
1276
733
|
}
|
|
1277
734
|
}
|
|
1278
|
-
} catch {
|
|
735
|
+
} catch {}
|
|
1279
736
|
|
|
1280
737
|
try {
|
|
1281
738
|
const tracked = execFileSync('git', ['ls-files', '--', featPathspec], gitOpts).toString().trim() !== '';
|
|
@@ -1292,25 +749,7 @@ export async function clearRootWorktreeRegistration(root, feature, { stamp } = {
|
|
|
1292
749
|
return { shedBackup: null };
|
|
1293
750
|
}
|
|
1294
751
|
|
|
1295
|
-
/**
|
|
1296
|
-
* Clear a feature's `worktree` registration ONLY when it's dangling — the
|
|
1297
|
-
* path it names no longer exists on disk (the directory was removed outside
|
|
1298
|
-
* `morph-spec finish`/`worktree remove`: by hand, by `git worktree prune`, or
|
|
1299
|
-
* by any tool that doesn't know about this pointer).
|
|
1300
|
-
*
|
|
1301
|
-
* Deliberately narrower than `clearRootWorktreeRegistration`: a registration
|
|
1302
|
-
* naming a worktree that's still ALIVE is not a bug to repair — it's the
|
|
1303
|
-
* anti-dupe guard's only signal (`runWorktreeSetup` Guard 3) and `verify`'s
|
|
1304
|
-
* only signal for which cwd may persist (`decidePersistence`). Clearing a
|
|
1305
|
-
* live registration would trade a true pointer for a false negative on both.
|
|
1306
|
-
*
|
|
1307
|
-
* Reuses `clearRootWorktreeRegistration` for the actual mutation (same
|
|
1308
|
-
* atomic-write + scoped-commit contract) once the dangling predicate holds.
|
|
1309
|
-
*
|
|
1310
|
-
* @param {string} root
|
|
1311
|
-
* @param {string} feature
|
|
1312
|
-
* @returns {Promise<{ cleared: boolean }>}
|
|
1313
|
-
*/
|
|
752
|
+
/** Only when the named path is gone: a LIVE registration is the anti-dupe guard's and `verify`'s only signal. */
|
|
1314
753
|
export async function clearDanglingWorktreeRegistration(root, feature) {
|
|
1315
754
|
try {
|
|
1316
755
|
const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
|
|
@@ -1321,24 +760,11 @@ export async function clearDanglingWorktreeRegistration(root, feature) {
|
|
|
1321
760
|
await clearRootWorktreeRegistration(root, feature);
|
|
1322
761
|
return { cleared: true };
|
|
1323
762
|
} catch {
|
|
1324
|
-
return { cleared: false };
|
|
763
|
+
return { cleared: false };
|
|
1325
764
|
}
|
|
1326
765
|
}
|
|
1327
766
|
|
|
1328
|
-
/**
|
|
1329
|
-
* Why `git worktree remove` failed, from its stderr.
|
|
1330
|
-
*
|
|
1331
|
-
* The distinction that matters is `in-use` — on Windows a process whose cwd is
|
|
1332
|
-
* inside the worktree (another Claude Code instance, VS Code, a dev server, a
|
|
1333
|
-
* stray terminal) pins the directory and the removal cannot proceed. That is a
|
|
1334
|
-
* human-actionable cause with a specific fix ("close it"), and printing it is
|
|
1335
|
-
* the difference between a message the user can act on and one that just says
|
|
1336
|
-
* "failed" — which is what made a raw `git worktree remove --force` look like
|
|
1337
|
-
* the only way forward.
|
|
1338
|
-
*
|
|
1339
|
-
* @param {string} stderr
|
|
1340
|
-
* @returns {{ kind: 'in-use'|'unknown', detail: string }}
|
|
1341
|
-
*/
|
|
767
|
+
/** `in-use` (a process holding the dir on Windows) needs a human, so it is named apart. */
|
|
1342
768
|
function classifyRemovalError(stderr) {
|
|
1343
769
|
const text = String(stderr || '').trim();
|
|
1344
770
|
const inUse = /permission denied|EPERM|EBUSY|being used by another process|resource busy|access is denied|is locked/i.test(text);
|
|
@@ -1349,19 +775,8 @@ function classifyRemovalError(stderr) {
|
|
|
1349
775
|
}
|
|
1350
776
|
|
|
1351
777
|
/**
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1354
|
-
* The retry exists for the transient Windows handle (antivirus/indexer touching
|
|
1355
|
-
* a file mid-removal) — the same class the repo already retries in
|
|
1356
|
-
* `atomicWriteJson`. It deliberately does NOT help the `in-use` case: a held
|
|
1357
|
-
* directory stays held, and that one needs the human, not another attempt.
|
|
1358
|
-
*
|
|
1359
|
-
* `--force` is safe HERE and only here: the caller proved `blockers.length ===
|
|
1360
|
-
* 0` before calling, so there is nothing left for it to traverse out of.
|
|
1361
|
-
*
|
|
1362
|
-
* @param {string} worktreePath
|
|
1363
|
-
* @param {string} root
|
|
1364
|
-
* @returns {Promise<{ removed: boolean, failure: {kind: string, detail: string}|null }>}
|
|
778
|
+
* One retry for transient Windows handles (antivirus/indexer); it does not help `in-use`.
|
|
779
|
+
* `--force` is safe only here: the caller proved there are no blockers.
|
|
1365
780
|
*/
|
|
1366
781
|
async function attemptWorktreeRemoval(worktreePath, root) {
|
|
1367
782
|
const stderrs = [];
|
|
@@ -1379,19 +794,7 @@ async function attemptWorktreeRemoval(worktreePath, root) {
|
|
|
1379
794
|
return { removed: false, failure: pickRemovalFailure(stderrs) };
|
|
1380
795
|
}
|
|
1381
796
|
|
|
1382
|
-
/**
|
|
1383
|
-
* Which of the attempts' errors the user should read.
|
|
1384
|
-
*
|
|
1385
|
-
* The LAST one used to win, and it was the worst choice: when the first
|
|
1386
|
-
* `git worktree remove` deletes the content and de-registers the tree but the
|
|
1387
|
-
* directory itself is held (a session with its cwd there), every later attempt
|
|
1388
|
-
* answers only `'…' is not a working tree` — an echo that hid the real cause
|
|
1389
|
-
* (the lock) and made the hint send the user to a command that can no longer
|
|
1390
|
-
* reach the tree. A lock anywhere wins; otherwise the first REAL error wins.
|
|
1391
|
-
*
|
|
1392
|
-
* @param {string[]} stderrs - one entry per failed attempt, in order
|
|
1393
|
-
* @returns {{ kind: string, detail: string, alreadyDeregistered: boolean }}
|
|
1394
|
-
*/
|
|
797
|
+
/** A lock anywhere wins, else the first REAL error: later attempts only echo "is not a working tree". */
|
|
1395
798
|
export function pickRemovalFailure(stderrs) {
|
|
1396
799
|
const list = (stderrs || []).map((s) => String(s || '')).filter((s) => s.trim());
|
|
1397
800
|
const notAWorktree = (s) => /is not a working tree/i.test(s);
|
|
@@ -1407,58 +810,20 @@ export function pickRemovalFailure(stderrs) {
|
|
|
1407
810
|
}
|
|
1408
811
|
|
|
1409
812
|
/**
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1412
|
-
* cannot prove removal is safe. Two independent ways `git worktree remove`
|
|
1413
|
-
* (plain AND `--force`) destroys data, both ending in an exit-0 removal:
|
|
1414
|
-
* 1. it FOLLOWS reparse points out of the worktree and deletes what they point
|
|
1415
|
-
* at in the primary root (the real `.claude/`, `.morph/framework/`);
|
|
1416
|
-
* 2. it deletes GITIGNORED files outright — a user's `.env` is not disposable,
|
|
1417
|
-
* and the clean-check cannot see them.
|
|
1418
|
-
*
|
|
1419
|
-
* A non-removal comes in two flavours the caller must not conflate — see the
|
|
1420
|
-
* rollback comment below. `refused` (we proved it unsafe) restores the infra
|
|
1421
|
-
* junctions; a FAILURE (git errored) deliberately leaves them down, reported in
|
|
1422
|
-
* `infraLeftUnlinked`.
|
|
1423
|
-
*
|
|
1424
|
-
* `deleteBranch` decouples `git branch -d` from removal: true for `--merge` (the
|
|
1425
|
-
* branch is integrated), false for `--pr` (the open PR still needs the branch).
|
|
1426
|
-
*
|
|
1427
|
-
* `stamp` names the backup; `finish` passes the one it gave the shed, so both
|
|
1428
|
-
* folders of that finish count as one backup under the retention.
|
|
1429
|
-
*
|
|
1430
|
-
* `relinkOnFailure` (the agent reaper) also restores the junctions when git
|
|
1431
|
-
* FAILED, not only when we refused: the reaper removes trees on a schedule, and
|
|
1432
|
-
* a lock that leaves a possibly-live agent without `.claude` kills its hooks.
|
|
1433
|
-
* Off by default — a person retrying a failed removal by hand is what the
|
|
1434
|
-
* unlinked state protects. A tree git already de-registered has nothing to link.
|
|
1435
|
-
*
|
|
1436
|
-
* @param {string} worktreePath
|
|
1437
|
-
* @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean, stamp?: string }} ctx
|
|
1438
|
-
* @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
|
|
1439
|
-
* blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null,
|
|
1440
|
-
* relinked: boolean, refused: boolean, infraLeftUnlinked: string[],
|
|
1441
|
-
* removalFailure: {kind: string, detail: string}|null, rootShedBackup: string|null }>}
|
|
813
|
+
* PROVE-or-DECLINE. `refused` (proved unsafe) restores the junctions; a git FAILURE leaves them down
|
|
814
|
+
* (`infraLeftUnlinked`) unless `relinkOnFailure` (the reaper, whose agent may still be live).
|
|
1442
815
|
*/
|
|
1443
816
|
export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false, relinkOnFailure = false, stamp }) {
|
|
1444
|
-
//
|
|
1445
|
-
// a
|
|
1446
|
-
//
|
|
1447
|
-
// The ORDER IS LOAD-BEARING and must not be flipped: `git worktree remove`
|
|
1448
|
-
// (and its --force fallback) follows reparse points OUT of the worktree and
|
|
1449
|
-
// deletes the primary root's real `.claude/` and `.morph/framework/`.
|
|
1450
|
-
// Whatever this unlink costs on a declined path is repaired by the rollback
|
|
1451
|
-
// below — the reverse ordering has no repair, it destroys the installation.
|
|
817
|
+
// ORDER IS LOAD-BEARING: unlink the junctions BEFORE git touches the dir. `git worktree remove`
|
|
818
|
+
// follows them and deletes the root's installation; a declined path is repaired by the rollback below.
|
|
1452
819
|
const unlinked = unlinkWorktreeInfraLinks(worktreePath, root);
|
|
1453
820
|
|
|
1454
821
|
const scan = findReparsePoints(worktreePath);
|
|
1455
822
|
const ignored = classifyIgnoredLeftovers(worktreePath, root, options);
|
|
1456
823
|
const blockers = [...scan.points, ...scan.unprovable];
|
|
1457
824
|
|
|
1458
|
-
// Back up
|
|
1459
|
-
//
|
|
1460
|
-
// no reparse point exists — cpSync follows junctions, so backing up before
|
|
1461
|
-
// that proof could copy an out-of-repo tree of unbounded size.
|
|
825
|
+
// Back up only once removal is cleared: `blockers.length === 0` proves no reparse point, and
|
|
826
|
+
// cpSync follows junctions (it could copy an unbounded out-of-repo tree).
|
|
1462
827
|
let ignoredBackup = null;
|
|
1463
828
|
const clearedToRemove = blockers.length === 0 && ignored.blocking.length === 0;
|
|
1464
829
|
if (clearedToRemove && ignored.shieldable.length) {
|
|
@@ -1472,41 +837,25 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1472
837
|
if (canRemove) {
|
|
1473
838
|
const attempt = await attemptWorktreeRemoval(worktreePath, root);
|
|
1474
839
|
worktreeRemoved = attempt.removed;
|
|
1475
|
-
removalFailure = attempt.failure;
|
|
840
|
+
removalFailure = attempt.failure;
|
|
1476
841
|
}
|
|
1477
842
|
|
|
1478
843
|
if (worktreeRemoved) {
|
|
1479
|
-
//
|
|
1480
|
-
// release-env (worktree existed and is gone). `--pr` removes the worktree
|
|
1481
|
-
// but keeps the branch the open PR needs.
|
|
844
|
+
// `--pr` removes the worktree but keeps the branch the open PR needs.
|
|
1482
845
|
if (deleteBranch) {
|
|
1483
|
-
try { execFileSync('git', ['branch', '-d', branch], { cwd: root, stdio: 'pipe' }); } catch {
|
|
846
|
+
try { execFileSync('git', ['branch', '-d', branch], { cwd: root, stdio: 'pipe' }); } catch {}
|
|
1484
847
|
}
|
|
1485
|
-
try { execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' }); } catch {
|
|
1486
|
-
// Fleet env: the worktree is gone, so no server can be running on its block.
|
|
848
|
+
try { execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' }); } catch {}
|
|
1487
849
|
try {
|
|
1488
850
|
const { releaseEnvBlock } = await import('../../lib/worktree/env-allocator.js');
|
|
1489
851
|
const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
1490
852
|
if (common) releaseEnvBlock(feature, { gitCommonDir: resolve(root, common) });
|
|
1491
|
-
} catch {
|
|
853
|
+
} catch {}
|
|
1492
854
|
({ shedBackup: rootShedBackup } = await clearRootWorktreeRegistration(root, feature, { stamp }));
|
|
1493
855
|
}
|
|
1494
856
|
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
// REFUSED (`!canRemove`): we proved removal unsafe, so the user keeps
|
|
1499
|
-
// working in this worktree. Leaving the junctions down is "um estado pior
|
|
1500
|
-
// que não ter tentado" — put them back.
|
|
1501
|
-
// FAILED (`canRemove` but git errored): removal WAS the user's intent and
|
|
1502
|
-
// the only obstacle is a lock. Re-linking here would restore the exact
|
|
1503
|
-
// hazard that destroyed installations — the retry (ours, theirs, or a raw
|
|
1504
|
-
// `git worktree remove --force` pasted from an old transcript) would then
|
|
1505
|
-
// follow the junctions out. Leaving them down makes EVERY subsequent
|
|
1506
|
-
// attempt safe, because there is nothing left to traverse.
|
|
1507
|
-
//
|
|
1508
|
-
// Fail-open on the refusal path: a failed re-link reports `relinked: false`
|
|
1509
|
-
// and the caller falls back to telling the user to run `worktree link --all`.
|
|
857
|
+
// Relink ONLY on a REFUSAL: on a FAILURE removal was intended and only a lock remains; relinking
|
|
858
|
+
// would make the next retry (or a raw `--force`) follow the junctions out.
|
|
1510
859
|
const refused = !canRemove;
|
|
1511
860
|
let relinked = false;
|
|
1512
861
|
const relinkAfterFailure = relinkOnFailure && !refused && !worktreeRemoved && !removalFailure?.alreadyDeregistered;
|
|
@@ -1516,21 +865,10 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1516
865
|
relinked = true;
|
|
1517
866
|
} catch { relinked = false; }
|
|
1518
867
|
}
|
|
1519
|
-
// What the caller must tell the user about on the FAILURE path: the worktree
|
|
1520
|
-
// survived without its shared infra, on purpose.
|
|
1521
868
|
const infraLeftUnlinked = (!worktreeRemoved && !refused && !relinked) ? unlinked : [];
|
|
1522
869
|
|
|
1523
|
-
// Every
|
|
1524
|
-
//
|
|
1525
|
-
// decline is exactly the moment a frustrated reader copies whatever command
|
|
1526
|
-
// the tool just printed — that copy is what emptied a primary root's
|
|
1527
|
-
// `.claude/` and `.morph/framework/`. `worktree remove` re-runs this same
|
|
1528
|
-
// PROVE-or-DECLINE path and accepts the same `--discard-ignored` consent, so
|
|
1529
|
-
// there is no decline left where the raw command would be the right answer.
|
|
1530
|
-
// A worktree with no feature identity (the ones `isolation: "worktree"`
|
|
1531
|
-
// creates) can only be re-addressed by path — naming a feature that does not
|
|
1532
|
-
// resolve would hand the reader a command that FAILS, and a failing safe
|
|
1533
|
-
// command is exactly how people end up back at the raw one.
|
|
870
|
+
// Every hint names `morph-spec worktree remove`, NEVER `git worktree remove`: a frustrated reader
|
|
871
|
+
// copies what was printed. A tree with no feature identity is addressed by `--target` path.
|
|
1534
872
|
const removeCmd = (addressByTarget || !isValidFeatureName(feature))
|
|
1535
873
|
? `morph-spec worktree remove --target "${worktreePath}"`
|
|
1536
874
|
: `morph-spec worktree remove ${feature}`;
|
|
@@ -1543,10 +881,6 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1543
881
|
: blockers.length
|
|
1544
882
|
? `worktree NÃO removido: ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que a remoção seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`${removeCmd}\``
|
|
1545
883
|
: ignored.shieldable.length && ignoredBackup === null
|
|
1546
|
-
// The ONLY gitignored refusal left. The old hint was circular — it
|
|
1547
|
-
// said "salve o que importa e rode <the command that just failed>".
|
|
1548
|
-
// This one names WHAT is exclusive, what was replicable (so the
|
|
1549
|
-
// reader stops worrying about those), and WHERE the copy was going.
|
|
1550
884
|
? `worktree NÃO removido: o backup falhou. Estes arquivos existem só neste worktree e a remoção os apagaria: ${sample(ignored.shieldable)}. `
|
|
1551
885
|
+ (ignored.replicable.length ? `(${sample(ignored.replicable)} são idênticos aos da raiz — esses não são o problema.) ` : '')
|
|
1552
886
|
+ `O backup iria para \`${backupDir}\`; libere espaço/permissão ali e rode \`${removeCmd}\`, ou copie os arquivos você mesmo e rode \`${removeCmd} --discard-ignored\` para pular o backup`
|
|
@@ -1569,12 +903,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1569
903
|
ignoredBackup,
|
|
1570
904
|
discarded: ignored.discarded,
|
|
1571
905
|
replicable: ignored.replicable,
|
|
1572
|
-
//
|
|
1573
|
-
// the directory. Reported because it is the load-bearing safety step of the
|
|
1574
|
-
// whole operation and was previously invisible: the user had no way to see
|
|
1575
|
-
// that `.claude` was a junction into the primary root, nor that it was
|
|
1576
|
-
// unlinked rather than followed. On a removal it is the proof the root's
|
|
1577
|
-
// real `.claude/` was never at risk.
|
|
906
|
+
// Reported: the proof the root's real `.claude/` was unlinked, never followed.
|
|
1578
907
|
unlinked,
|
|
1579
908
|
blockers,
|
|
1580
909
|
ignoredBlocking: ignored.blocking,
|
|
@@ -1588,19 +917,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1588
917
|
};
|
|
1589
918
|
}
|
|
1590
919
|
|
|
1591
|
-
/**
|
|
1592
|
-
* Is `cwd` the worktree we are about to remove, or inside it?
|
|
1593
|
-
*
|
|
1594
|
-
* Hoisted out of `finish` so BOTH callers share one answer. Windows holds a
|
|
1595
|
-
* lock on a process's working directory, so a removal issued from inside the
|
|
1596
|
-
* target fails — and in `finish`'s case it fails AFTER the archive is committed
|
|
1597
|
-
* and the branch merged, leaving an orphan directory and a de-registered
|
|
1598
|
-
* worktree. Refusing up front costs one `cd`.
|
|
1599
|
-
*
|
|
1600
|
-
* @param {string} cwd
|
|
1601
|
-
* @param {string} worktreePath
|
|
1602
|
-
* @returns {boolean}
|
|
1603
|
-
*/
|
|
920
|
+
/** Windows locks a process's cwd: removing from inside fails after the merge, so refuse up front. */
|
|
1604
921
|
export function cwdIsInsideWorktree(cwd, worktreePath) {
|
|
1605
922
|
const cwdNorm = resolve(cwd).replace(/\\/g, '/');
|
|
1606
923
|
const wtNorm = resolve(worktreePath).replace(/\\/g, '/');
|
|
@@ -1608,56 +925,15 @@ export function cwdIsInsideWorktree(cwd, worktreePath) {
|
|
|
1608
925
|
}
|
|
1609
926
|
|
|
1610
927
|
/**
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
* Not the same question as `cwdIsInsideWorktree`. That one sees the PROCESS
|
|
1614
|
-
* cwd, and `cd <root> && morph-spec finish` answers it "no" — while the session
|
|
1615
|
-
* stays anchored in the worktree: its CLAUDE_PROJECT_DIR is the worktree, and
|
|
1616
|
-
* every project hook runs `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/…"`.
|
|
1617
|
-
* Tearing that worktree down unlinks the junction those hooks resolve through,
|
|
1618
|
-
* and the directory stays held by the session — so the removal fails half-way
|
|
1619
|
-
* and every hook of every later tool call dies with MODULE_NOT_FOUND.
|
|
1620
|
-
*
|
|
1621
|
-
* The anchor arrives as MORPH_SESSION_PROJECT_DIR, exported by the
|
|
1622
|
-
* `worktree-env-inject` PreToolUse hook (the Bash tool does not export
|
|
1623
|
-
* CLAUDE_PROJECT_DIR itself); CLAUDE_PROJECT_DIR is honoured when present.
|
|
1624
|
-
*
|
|
1625
|
-
* @param {string} worktreePath
|
|
1626
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
1627
|
-
* @returns {boolean}
|
|
928
|
+
* The SESSION anchor, not the process cwd: tearing down the session's worktree kills every later hook
|
|
929
|
+
* (MODULE_NOT_FOUND). Read from MORPH_SESSION_PROJECT_DIR (set by worktree-env-inject) or CLAUDE_PROJECT_DIR.
|
|
1628
930
|
*/
|
|
1629
931
|
export function sessionAnchoredInWorktree(worktreePath, env = process.env) {
|
|
1630
932
|
const anchor = env.MORPH_SESSION_PROJECT_DIR || env.CLAUDE_PROJECT_DIR || '';
|
|
1631
933
|
return anchor ? cwdIsInsideWorktree(anchor, worktreePath) : false;
|
|
1632
934
|
}
|
|
1633
935
|
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
/**
|
|
1637
|
-
* Non-exiting core of `morph-spec worktree remove <feature>` /
|
|
1638
|
-
* `morph-spec worktree remove --target <path>` — returns `{ out, code }` instead
|
|
1639
|
-
* of printing and exiting, so the agent reaper can call it in a loop.
|
|
1640
|
-
*
|
|
1641
|
-
* WHY THIS EXISTS: `teardownWorktree` — the only removal that drops the infra
|
|
1642
|
-
* junctions before letting git near the directory — used to be reachable ONLY
|
|
1643
|
-
* through `morph-spec finish`. So every message that had to tell a user how to
|
|
1644
|
-
* remove a worktree pointed at raw `git worktree remove`, which follows those
|
|
1645
|
-
* junctions out and empties the primary root's `.claude/` and
|
|
1646
|
-
* `.morph/framework/`. This command is the safe answer those messages now give.
|
|
1647
|
-
*
|
|
1648
|
-
* Two ways to address the target, because there are two kinds of worktree:
|
|
1649
|
-
* <feature> — a morph worktree, resolved through `findWorktreeForFeature`
|
|
1650
|
-
* --target <path> — any registered worktree, including the ones the Agent
|
|
1651
|
-
* tool's `isolation: "worktree"` creates, which have no
|
|
1652
|
-
* feature registration for the lookup to find.
|
|
1653
|
-
*
|
|
1654
|
-
* It removes ONLY. No archive, no merge, and the branch survives by default —
|
|
1655
|
-
* closing a feature is still `morph-spec finish`.
|
|
1656
|
-
*
|
|
1657
|
-
* @param {string|undefined} featureArg
|
|
1658
|
-
* @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, ifClean?: boolean, relinkOnFailure?: boolean, cwd?: string }} [options]
|
|
1659
|
-
* @returns {Promise<{ out: object, code: 0|1 }>}
|
|
1660
|
-
*/
|
|
936
|
+
/** Non-exiting core of `worktree remove`: removes ONLY, the branch survives by default. */
|
|
1661
937
|
export async function runWorktreeRemove(featureArg, options = {}) {
|
|
1662
938
|
const cwdArg = options.cwd || process.cwd();
|
|
1663
939
|
const outcome = (out, code) => ({ out, code });
|
|
@@ -1668,15 +944,11 @@ export async function runWorktreeRemove(featureArg, options = {}) {
|
|
|
1668
944
|
let worktreePath = null;
|
|
1669
945
|
let feature = featureArg || null;
|
|
1670
946
|
let branch = null;
|
|
1671
|
-
// Can a re-run be addressed as `remove <feature>`? Only when the feature name
|
|
1672
|
-
// is what resolved the worktree in the first place.
|
|
1673
947
|
let featureAddressable = !options.target;
|
|
1674
948
|
|
|
1675
949
|
if (options.target) {
|
|
1676
950
|
worktreePath = resolve(options.target).replace(/\\/g, '/');
|
|
1677
|
-
//
|
|
1678
|
-
// hand an arbitrary path to `git worktree remove` — and `--target ../..`
|
|
1679
|
-
// is the same path-traversal class already fixed in delete/archive.
|
|
951
|
+
// Must be a registered worktree: `--target ../..` is a path traversal otherwise.
|
|
1680
952
|
const entry = parseWorktreeList(root).find((e) => e.path === worktreePath);
|
|
1681
953
|
if (!entry) {
|
|
1682
954
|
return outcome({ removed: false, error: 'not-a-registered-worktree', target: worktreePath, root,
|
|
@@ -1687,9 +959,6 @@ export async function runWorktreeRemove(featureArg, options = {}) {
|
|
|
1687
959
|
hint: 'a raiz primária nunca é removível por este comando' }, 1);
|
|
1688
960
|
}
|
|
1689
961
|
branch = entry.branch ? entry.branch.replace(/^refs\/heads\//, '') : null;
|
|
1690
|
-
// Best-effort feature identity — used for the backup/env-release bookkeeping
|
|
1691
|
-
// inside the teardown. A `morph/{f}` branch means `remove <feature>` would
|
|
1692
|
-
// resolve too, so re-run advice may name it; anything else stays --target.
|
|
1693
962
|
if (!feature && branch?.startsWith('morph/')) {
|
|
1694
963
|
feature = branch.slice('morph/'.length);
|
|
1695
964
|
featureAddressable = isValidFeatureName(feature);
|
|
@@ -1708,30 +977,20 @@ export async function runWorktreeRemove(featureArg, options = {}) {
|
|
|
1708
977
|
branch = found.branch;
|
|
1709
978
|
}
|
|
1710
979
|
|
|
1711
|
-
// Same guard finish applies, for the same Windows reason — and this command
|
|
1712
|
-
// is MORE likely to hit it: the natural moment to run it is right after
|
|
1713
|
-
// working inside the worktree.
|
|
1714
980
|
if (cwdIsInsideWorktree(cwdArg, worktreePath)) {
|
|
1715
981
|
return outcome({ removed: false, error: 'cwd-inside-worktree', path: worktreePath, root,
|
|
1716
982
|
hint: `saia do worktree antes de removê-lo — o processo trava o próprio diretório de trabalho. Rode de novo a partir de "${root}"` }, 1);
|
|
1717
983
|
}
|
|
1718
984
|
|
|
1719
|
-
// The SESSION's anchor
|
|
1720
|
-
// refusing here, before the junctions are touched, leaves the session's hooks
|
|
1721
|
-
// intact. Unlike finish there is nothing else to do in this command, so the
|
|
1722
|
-
// refusal IS the deferral.
|
|
985
|
+
// The SESSION's anchor: refusing before the junctions are touched leaves its hooks intact.
|
|
1723
986
|
if (sessionAnchoredInWorktree(worktreePath)) {
|
|
1724
987
|
const again = featureAddressable && feature ? `morph-spec worktree remove ${feature}` : `morph-spec worktree remove --target "${worktreePath}"`;
|
|
1725
988
|
return outcome({ removed: false, error: 'session-anchored-in-worktree', path: worktreePath, root,
|
|
1726
989
|
hint: `a sessão do Claude Code que rodou este comando está ancorada neste worktree — os hooks dela resolvem \`.morph/framework\` por ele, e desmontá-lo quebraria todo tool call seguinte (e o diretório seguiria travado pela sessão). Feche esta sessão e rode \`${again}\` de uma sessão aberta em "${root}"` }, 1);
|
|
1727
990
|
}
|
|
1728
991
|
|
|
1729
|
-
// `--if-clean`
|
|
1730
|
-
//
|
|
1731
|
-
// call, not the harness's. Any uncommitted or untracked file refuses the
|
|
1732
|
-
// removal BEFORE the junctions are touched, so a refusal leaves the tree
|
|
1733
|
-
// exactly as it was (no rollback needed, nothing to relink). The infra
|
|
1734
|
-
// links and the env manifest are excluded from git, so they never count.
|
|
992
|
+
// `--if-clean` (WorktreeRemove hook): any uncommitted file refuses BEFORE the junctions are touched.
|
|
993
|
+
// Infra links and the env manifest are excluded from git, so they never count.
|
|
1735
994
|
if (options.ifClean) {
|
|
1736
995
|
let status;
|
|
1737
996
|
try {
|
|
@@ -1747,27 +1006,18 @@ export async function runWorktreeRemove(featureArg, options = {}) {
|
|
|
1747
1006
|
}
|
|
1748
1007
|
}
|
|
1749
1008
|
|
|
1750
|
-
//
|
|
1751
|
-
// copy of the root's rules: not a file to back up. Deleted only now, after
|
|
1752
|
-
// every refusal, so a refused removal leaves the tree as it was.
|
|
1009
|
+
// Leftover temp .graphifyignore: deleted only after every refusal, so a refused removal leaves the tree as it was.
|
|
1753
1010
|
dropLeftoverIgnore(worktreePath);
|
|
1754
1011
|
|
|
1755
|
-
//
|
|
1756
|
-
// `task/<n>` for morph-task/<n>. Read BEFORE the teardown (the entry is gone
|
|
1757
|
-
// from `git worktree list` afterwards). The old `'worktree'` placeholder
|
|
1758
|
-
// meant every --target removal released a slot nobody held and leaked the
|
|
1759
|
-
// one the tree actually used.
|
|
1012
|
+
// Read BEFORE the teardown: the entry is gone from `git worktree list` afterwards.
|
|
1760
1013
|
const envKey = envKeyForWorktree(worktreePath, root);
|
|
1761
1014
|
|
|
1762
|
-
//
|
|
1763
|
-
// container holds the directory (Windows) and turns a clean removal into a
|
|
1764
|
-
// removalFailure. Volumes survive — removing a worktree is not closing the
|
|
1765
|
-
// feature, and a re-`setup` should find its seeded database intact.
|
|
1015
|
+
// Compose stack down BEFORE the tree: a bind-mounted container holds the dir on Windows. Volumes survive.
|
|
1766
1016
|
let e2e = null;
|
|
1767
1017
|
try {
|
|
1768
1018
|
const { tearDownFeatureStack } = await import('../../lib/e2e/stack-teardown.js');
|
|
1769
1019
|
e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes: false });
|
|
1770
|
-
} catch { e2e = null;
|
|
1020
|
+
} catch { e2e = null; }
|
|
1771
1021
|
|
|
1772
1022
|
// The stamp this removal's backup is written under — protected from the
|
|
1773
1023
|
// retention that runs after it.
|
|
@@ -1778,8 +1028,7 @@ export async function runWorktreeRemove(featureArg, options = {}) {
|
|
|
1778
1028
|
stamp,
|
|
1779
1029
|
branch,
|
|
1780
1030
|
options: { discardIgnored: options.discardIgnored === true },
|
|
1781
|
-
//
|
|
1782
|
-
// deleting it here would discard unintegrated commits. Opt-in only.
|
|
1031
|
+
// The branch is the work: deleting it would discard unintegrated commits. Opt-in only.
|
|
1783
1032
|
deleteBranch: options.deleteBranch === true,
|
|
1784
1033
|
relinkOnFailure: options.relinkOnFailure === true,
|
|
1785
1034
|
// Re-run advice must address the worktree the way it was addressable here:
|
|
@@ -1793,16 +1042,10 @@ export async function runWorktreeRemove(featureArg, options = {}) {
|
|
|
1793
1042
|
...(branch ? { branch } : {}),
|
|
1794
1043
|
...(feature ? { feature } : {}),
|
|
1795
1044
|
};
|
|
1796
|
-
// Reported on BOTH paths: on a removal it is the proof the primary root's
|
|
1797
|
-
// real `.claude/`/`.morph/framework/` were unlinked rather than followed; on
|
|
1798
|
-
// a refusal it is what `relinked` did or did not put back.
|
|
1799
1045
|
if (teardown.unlinked.length) out.unlinked = teardown.unlinked;
|
|
1800
1046
|
if (e2e?.attempted) out.e2e = { status: e2e.status, cwd: e2e.cwd, volumes: false };
|
|
1801
1047
|
if (teardown.worktreeRemoved) {
|
|
1802
1048
|
if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
|
|
1803
|
-
// Broken out of `discardedIgnored` on purpose: "deleted" and "deleted
|
|
1804
|
-
// because the identical bytes are still in the root" are not the same
|
|
1805
|
-
// report, and the second is the one that needs no follow-up from the user.
|
|
1806
1049
|
if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
|
|
1807
1050
|
if (teardown.ignoredBackup) out.ignoredBackup = teardown.ignoredBackup;
|
|
1808
1051
|
if (teardown.shieldable.length) out.backedUp = teardown.shieldable;
|
|
@@ -1824,28 +1067,12 @@ export async function runWorktreeRemove(featureArg, options = {}) {
|
|
|
1824
1067
|
return outcome(out, teardown.worktreeRemoved ? 0 : 1);
|
|
1825
1068
|
}
|
|
1826
1069
|
|
|
1827
|
-
/**
|
|
1828
|
-
* `morph-spec worktree remove` — prints the JSON verdict and exits with its code.
|
|
1829
|
-
* @param {string|undefined} featureArg
|
|
1830
|
-
* @param {object} [options] - see `runWorktreeRemove`
|
|
1831
|
-
*/
|
|
1832
1070
|
export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
1833
1071
|
const { out, code } = await runWorktreeRemove(featureArg, options);
|
|
1834
1072
|
console.log(JSON.stringify(out));
|
|
1835
1073
|
process.exit(code);
|
|
1836
1074
|
}
|
|
1837
1075
|
|
|
1838
|
-
// ── list ─────────────────────────────────────────────────────────────────
|
|
1839
|
-
|
|
1840
|
-
/**
|
|
1841
|
-
* Command handler for `morph-spec worktree list [--json]`.
|
|
1842
|
-
*
|
|
1843
|
-
* Lists active `morph/{feature}` worktrees with their derived phase, status,
|
|
1844
|
-
* and any pending approval gates — visibility across multi-instance sessions.
|
|
1845
|
-
* Fail-open: outside a git repo (or on any git error), reports an empty list.
|
|
1846
|
-
*
|
|
1847
|
-
* @param {{ json?: boolean, cwd?: string }} [options]
|
|
1848
|
-
*/
|
|
1849
1076
|
export async function worktreeListCommand(options = {}) {
|
|
1850
1077
|
const cwd = options.cwd || process.cwd();
|
|
1851
1078
|
|
|
@@ -1864,7 +1091,7 @@ export async function worktreeListCommand(options = {}) {
|
|
|
1864
1091
|
|
|
1865
1092
|
for (const entry of entries) {
|
|
1866
1093
|
const m = entry.branch && entry.branch.match(/^refs\/heads\/morph\/(.+)$/);
|
|
1867
|
-
if (!m) continue;
|
|
1094
|
+
if (!m) continue;
|
|
1868
1095
|
const feature = m[1];
|
|
1869
1096
|
|
|
1870
1097
|
let phase = 'plan';
|
|
@@ -1892,7 +1119,6 @@ export async function worktreeListCommand(options = {}) {
|
|
|
1892
1119
|
process.exit(0);
|
|
1893
1120
|
}
|
|
1894
1121
|
|
|
1895
|
-
/** Render `worktree list` output — JSON or a human table (chalk). */
|
|
1896
1122
|
function printWorktreeList(worktrees, options) {
|
|
1897
1123
|
if (options.json) {
|
|
1898
1124
|
console.log(JSON.stringify({ worktrees }));
|