@polymorphism-tech/morph-spec 7.9.4 → 8.0.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 +2 -2
- package/README.md +31 -2
- package/bin/morph-spec.js +50 -2
- package/bin/validate.js +379 -379
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +38 -1
- package/docs/QUICKSTART.md +60 -1
- package/framework/CLAUDE.md +13 -2
- package/framework/MORPH.md +38 -2
- package/framework/agents.json +22 -3
- package/framework/commands/commit.md +166 -166
- package/framework/commands/morph-hotfix.md +39 -0
- package/framework/commands/morph-infra.md +215 -215
- package/framework/commands/morph-preflight.md +205 -205
- package/framework/commands/morph-troubleshoot.md +124 -124
- package/framework/hooks/README.md +25 -10
- package/framework/hooks/claude-code/core/gate-guard.js +4 -2
- package/framework/hooks/claude-code/core/loop-detect.js +2 -1
- package/framework/hooks/claude-code/core/post-edit-typecheck.js +14 -5
- package/framework/hooks/claude-code/core/tasks-json-guard.js +4 -2
- package/framework/hooks/claude-code/core/validate-completion.js +1 -1
- package/framework/hooks/claude-code/core/worktree-context.js +160 -0
- package/framework/hooks/claude-code/statusline.py +679 -679
- package/framework/hooks/claude-code/statusline.sh +11 -11
- package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
- package/framework/hooks/git/pre-commit/agents.sh +25 -25
- package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
- package/framework/hooks/git/pre-commit/specs.sh +50 -50
- package/framework/hooks/git/pre-push/run-tests.sh +44 -44
- package/framework/hooks/shared/activity-logger.js +160 -160
- package/framework/hooks/shared/build-check.js +2 -2
- package/framework/hooks/shared/hook-response.js +17 -5
- package/framework/hooks/shared/payload-utils.js +42 -0
- package/framework/hooks/shared/state-reader.js +22 -2
- package/framework/hooks/shared/stdin-reader.js +26 -26
- package/framework/rubrics/backend.json +41 -41
- package/framework/rules/ai-agents-standards.md +45 -45
- package/framework/rules/dotnet-templates.md +86 -86
- package/framework/rules/frontend-standards.md +13 -13
- package/framework/rules/infrastructure-standards.md +27 -27
- package/framework/rules/neon-standards.md +25 -25
- package/framework/rules/nextjs-standards.md +20 -20
- package/framework/rules/nextjs-templates.md +40 -40
- package/framework/schemas/feature.schema.json +95 -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 +39 -8
- package/framework/skills/core/morph-brainstorming/SKILL.md +9 -1
- package/framework/skills/core/morph-eval/SKILL.md +6 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +136 -0
- package/framework/skills/core/morph-implement/SKILL.md +8 -4
- package/framework/skills/core/morph-plan/SKILL.md +5 -1
- package/framework/skills/core/morph-proposal/SKILL.md +24 -1
- package/framework/skills/core/morph-review/SKILL.md +3 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/standards/ai-agents/agent-session.md +160 -160
- package/framework/standards/ai-agents/batching.md +193 -193
- package/framework/standards/ai-agents/context-providers.md +188 -188
- package/framework/standards/ai-agents/mcp-server.md +250 -250
- package/framework/standards/ai-agents/mcp-tools.md +197 -197
- package/framework/standards/ai-agents/middleware-patterns.md +207 -207
- package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
- package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
- package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
- package/framework/standards/ai-agents/modalities-vision.md +201 -201
- package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
- package/framework/standards/ai-agents/production.md +275 -275
- package/framework/standards/ai-agents/prompts-in-db.md +214 -214
- package/framework/standards/ai-agents/providers/model-registry.md +158 -158
- package/framework/standards/ai-agents/service-tier-flex.md +135 -135
- package/framework/standards/ai-agents/structured-output.md +249 -249
- package/framework/standards/ai-agents/sweet-spot.md +109 -109
- package/framework/templates/.idea/morph-templates.xml +92 -0
- package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/REGISTRY.json +0 -62
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
- package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
- package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
- package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
- package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
- package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
- package/framework/templates/code/dotnet/database/migration.cs +83 -83
- package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
- 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/dotnet/feature/validator.cs.hbs +15 -15
- package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/code/typescript/contracts.ts +167 -167
- package/framework/templates/docs/clarifications.md +253 -253
- package/framework/templates/docs/schema-analysis.md +119 -119
- package/framework/templates/docs/ui-components.md +52 -52
- package/framework/templates/docs/ui-design-system.md +76 -76
- package/framework/templates/docs/ui-flows.md +167 -167
- package/framework/templates/docs/ui-mockups.md +98 -98
- package/framework/templates/feature/decisions.md +122 -122
- package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
- package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
- package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +50 -50
- 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/README.md +56 -56
- 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/integrations/asaas-client.cs +387 -387
- package/framework/templates/integrations/asaas-webhook.cs +351 -351
- 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/meta-prompts/validators/pre-commit-validator.md +95 -95
- package/framework/templates/project/validate.js +120 -120
- 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/dashboard/dashboard.js +53 -53
- package/src/commands/index.js +13 -13
- package/src/commands/mcp/mcp-setup.js +519 -519
- package/src/commands/project/checkpoint.js +209 -209
- package/src/commands/project/doctor.js +4 -2
- package/src/commands/project/init.js +46 -2
- package/src/commands/project/update.js +5 -3
- package/src/commands/project/worktree.js +1005 -46
- package/src/commands/state/advance.js +17 -2
- package/src/commands/state/approve.js +18 -1
- package/src/commands/state/create.js +158 -14
- package/src/commands/state/retype.js +126 -0
- package/src/commands/state/score.js +4 -0
- package/src/commands/tasks/dag.js +32 -0
- package/src/commands/validation/index.js +1 -0
- package/src/commands/validation/validate-feature.js +33 -5
- package/src/commands/validation/verify.js +190 -0
- package/src/core/state/phase-state-machine.js +197 -197
- package/src/core/state/state-manager.js +35 -10
- package/src/core/templates/template-renderer.js +477 -477
- package/src/core/work-types.js +79 -0
- package/src/dashboard/app/components/DetailPanel.jsx +11 -11
- package/src/dashboard/app/components/FlowGraph.jsx +17 -17
- package/src/dashboard/app/components/SearchFilter.jsx +22 -22
- package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/app/pages/AgentMap.jsx +370 -370
- package/src/dashboard/app/pages/HookInspector.jsx +418 -418
- package/src/dashboard/app/pages/LiveState.jsx +11 -1
- package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
- package/src/dashboard/app/pages/RulesMap.jsx +273 -273
- package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
- package/src/dashboard/app/pages/Standards.jsx +332 -332
- package/src/dashboard/app/styles/index.css +47 -47
- package/src/dashboard/index.html +12 -12
- package/src/dashboard/package.json +24 -24
- package/src/dashboard/scanner/hooks-scanner.js +41 -41
- package/src/dashboard/scanner/phases-scanner.js +15 -15
- package/src/dashboard/scanner/rubrics-scanner.js +12 -12
- package/src/dashboard/scanner/rules-scanner.js +37 -37
- package/src/dashboard/scanner/skills-scanner.js +43 -43
- package/src/dashboard/scanner/squads-scanner.js +13 -13
- package/src/dashboard/scanner/standards-scanner.js +26 -26
- package/src/dashboard/scanner/state-scanner.js +42 -0
- package/src/dashboard/vite-plugin-morph-data.js +17 -17
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/agents/metrics-aggregator.js +56 -56
- package/src/lib/agents/metrics-calculator.js +125 -125
- package/src/lib/agents/output-collector.js +73 -73
- package/src/lib/detectors/claude-config-detector.js +152 -152
- package/src/lib/detectors/design-system-detector.js +231 -231
- package/src/lib/detectors/index.js +217 -217
- package/src/lib/generators/architecture-scanner.js +639 -639
- package/src/lib/generators/design-system-generator.js +508 -508
- package/src/lib/generators/index.js +13 -13
- package/src/lib/generators/settings-generator.js +209 -209
- package/src/lib/hooks/hook-executor.js +167 -167
- package/src/lib/installers/mcp-installer.js +719 -719
- package/src/lib/installers/memory-installer.js +1 -1
- package/src/lib/risk-detector.js +16 -0
- package/src/lib/tasks/dispatch-planner.js +122 -4
- package/src/lib/tasks/mermaid-renderer.js +132 -0
- package/src/lib/tasks/task-classifier.js +30 -30
- package/src/lib/tasks/task-parser.js +209 -209
- package/src/lib/tasks/test-runner.js +27 -6
- package/src/lib/validators/architecture/architecture-validator.js +259 -259
- package/src/lib/validators/checkpoint-persistence.js +39 -39
- package/src/lib/validators/contracts/contract-compliance-validator.js +274 -274
- package/src/lib/validators/css/css-validator.js +340 -340
- package/src/lib/validators/packages/package-validator.js +347 -347
- package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
- package/src/lib/validators/shared/issue-counter.js +14 -14
- package/src/lib/validators/validation-runner.js +349 -349
- package/src/lib/validators/verify-runner.js +322 -0
- package/src/lib/work-type-detector.js +143 -0
- package/src/scripts/setup-infra.js +32 -5
- package/src/utils/agent-id-mapper.js +11 -11
- package/src/utils/agents-installer.js +10 -6
- package/src/utils/claude-settings-manager.js +300 -300
- package/src/utils/file-copier.js +43 -0
- package/src/utils/hooks-installer.js +41 -11
- package/src/utils/skills-installer.js +174 -174
- package/framework/templates/meta-prompts/parallel-workers/parallel-coordinator.md +0 -113
- package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +0 -80
- package/src/lib/tasks/wave-resolver.js +0 -353
|
@@ -1,21 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MORPH-SPEC Worktree
|
|
2
|
+
* MORPH-SPEC Worktree Command Suite
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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).
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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).
|
|
10
13
|
*
|
|
11
|
-
*
|
|
14
|
+
* Subcommands:
|
|
15
|
+
* morph-spec worktree setup <feature> [--fresh] — create + materialize state
|
|
16
|
+
* morph-spec worktree link [--target <path>] [--all] — (re)link shared infra
|
|
17
|
+
* morph-spec worktree finish <feature> [--keep] [--force] — merge-back + cleanup
|
|
18
|
+
* morph-spec worktree list [--json] — list active feature worktrees
|
|
19
|
+
*
|
|
20
|
+
* Exit codes (setup):
|
|
12
21
|
* 0 — success (created) or fail-open (not a git repo, git error)
|
|
13
|
-
*
|
|
22
|
+
* 1 — refused: feature name is not a plain kebab slug
|
|
23
|
+
* 2 — worktree already exists at this exact path (signals resume to caller)
|
|
24
|
+
* 3 — a live worktree for this feature already exists at a DIFFERENT path
|
|
25
|
+
* (anti-dupe guard — refuses to fork a second live instance)
|
|
26
|
+
*
|
|
27
|
+
* Exit codes (finish):
|
|
28
|
+
* 0 — merged successfully (or fail-open in a non-git dir is not applicable here)
|
|
29
|
+
* 1 — refused: invalid feature name, no worktree found, the worktree is on a
|
|
30
|
+
* different branch, Gate 3 not approved, worktree/root dirty, or merge
|
|
31
|
+
* conflict. Every refusal is fail-CLOSED — no merge lands and no worktree
|
|
32
|
+
* is removed. The shed runs before the root's clean check, so a refusal
|
|
33
|
+
* from that point on has moved this feature's root-side scope to the
|
|
34
|
+
* backup it reports; see worktreeFinishCommand.
|
|
14
35
|
*/
|
|
15
36
|
|
|
16
|
-
import { execSync } from 'child_process';
|
|
17
|
-
import { existsSync, mkdirSync, symlinkSync,
|
|
18
|
-
import { join, resolve, dirname } from 'path';
|
|
37
|
+
import { execSync, execFileSync } from 'child_process';
|
|
38
|
+
import { existsSync, mkdirSync, symlinkSync, readFileSync, lstatSync, unlinkSync, rmdirSync, readdirSync, cpSync } from 'fs';
|
|
39
|
+
import { join, resolve, dirname, relative } from 'path';
|
|
40
|
+
import chalk from 'chalk';
|
|
19
41
|
|
|
20
42
|
// ── Pure helpers (exported for tests) ──────────────────────────────────────
|
|
21
43
|
|
|
@@ -24,6 +46,24 @@ import { join, resolve, dirname } from 'path';
|
|
|
24
46
|
* @param {string} cwd
|
|
25
47
|
* @returns {boolean}
|
|
26
48
|
*/
|
|
49
|
+
/**
|
|
50
|
+
* Is `name` a plain kebab-case feature name?
|
|
51
|
+
*
|
|
52
|
+
* Mirrors the validation `morph-spec create` applies, and every worktree
|
|
53
|
+
* subcommand enforces it for the same reason plus a sharper one: a feature name
|
|
54
|
+
* flows into `.worktrees/{name}`, the branch `morph/{name}`, and from there into
|
|
55
|
+
* git invocations. Characters like `& | ; > ^` are legal in a git refname but
|
|
56
|
+
* are cmd.exe metacharacters — on Windows an unvalidated name turns any
|
|
57
|
+
* string-interpolated git call into command injection, and makes a `finish`
|
|
58
|
+
* merge a DIFFERENT branch than the one it validated and reports.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} name
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function isValidFeatureName(name) {
|
|
64
|
+
return typeof name === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
27
67
|
export function isGitRepo(cwd = process.cwd()) {
|
|
28
68
|
try {
|
|
29
69
|
execSync('git rev-parse --git-dir', { cwd, stdio: 'pipe' });
|
|
@@ -50,6 +90,17 @@ export function branchExists(branch, cwd = process.cwd()) {
|
|
|
50
90
|
|
|
51
91
|
/**
|
|
52
92
|
* Check if a worktree path is already registered with git.
|
|
93
|
+
*
|
|
94
|
+
* BUGFIX: the old `normalizedPath.startsWith('/')` absolute-path check only
|
|
95
|
+
* recognized POSIX-style absolute paths. On Windows, `buildWorktreePath`
|
|
96
|
+
* already returns an absolute `C:/...` path, which does NOT start with `/` —
|
|
97
|
+
* so this used to re-prepend `cwd` onto an already-absolute path, producing
|
|
98
|
+
* a bogus double-rooted string that never matched `git worktree list`'s
|
|
99
|
+
* output. That silently broke the Gate 2 "already exists → resume" check on
|
|
100
|
+
* Windows (it always fell through to a failing `git worktree add`, caught by
|
|
101
|
+
* the outer fail-open handler). `path.resolve(cwd, worktreePath)` handles
|
|
102
|
+
* both relative and platform-absolute paths correctly.
|
|
103
|
+
*
|
|
53
104
|
* @param {string} worktreePath Relative or absolute path
|
|
54
105
|
* @param {string} cwd
|
|
55
106
|
* @returns {boolean}
|
|
@@ -57,8 +108,7 @@ export function branchExists(branch, cwd = process.cwd()) {
|
|
|
57
108
|
export function worktreeExists(worktreePath, cwd = process.cwd()) {
|
|
58
109
|
try {
|
|
59
110
|
const output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
|
|
60
|
-
const
|
|
61
|
-
const absPath = normalizedPath.startsWith('/') ? normalizedPath : `${cwd.replace(/\\/g, '/')}/${normalizedPath}`;
|
|
111
|
+
const absPath = resolve(cwd, worktreePath).replace(/\\/g, '/');
|
|
62
112
|
return output.replace(/\\/g, '/').includes(absPath);
|
|
63
113
|
} catch {
|
|
64
114
|
return false;
|
|
@@ -96,26 +146,38 @@ function getDateSuffix() {
|
|
|
96
146
|
// ── Infrastructure linking ───────────────────────────────────────────────────
|
|
97
147
|
|
|
98
148
|
/**
|
|
99
|
-
* Create
|
|
100
|
-
*
|
|
149
|
+
* Create junctions in the worktree for shared infrastructure directories
|
|
150
|
+
* (node_modules/, .claude/, .morph/framework/).
|
|
151
|
+
*
|
|
152
|
+
* `.morph/state.json` is deliberately NEVER linked here — a hard link breaks
|
|
153
|
+
* silently the moment either side writes atomically (tmp file + rename creates
|
|
154
|
+
* a fresh inode, severing the link), so worktree and main state can silently
|
|
155
|
+
* diverge. The worktree's local state.json is MATERIALIZED instead, see
|
|
156
|
+
* `materializeState()`. `memory/` and `logs/` are also never linked — each
|
|
157
|
+
* worktree instance owns its own debounce/session snapshots.
|
|
101
158
|
*
|
|
102
159
|
* Uses junctions for directories (no admin required on Windows).
|
|
103
|
-
* File symlinks fall back to copy if symlink fails.
|
|
104
160
|
* Fail-open: any error is silently ignored.
|
|
105
161
|
*
|
|
106
162
|
* @param {string} worktreePath - Absolute path to the worktree
|
|
107
163
|
* @param {string} mainCwd - Absolute path to the main project root
|
|
164
|
+
* @param {{ dirs?: string[] }} [opts] - When `dirs` is provided, restricts
|
|
165
|
+
* which junctions are (re)created to exactly that list. Default: node_modules,
|
|
166
|
+
* .claude, and .morph/framework (the last only if it exists in the main root).
|
|
108
167
|
*/
|
|
109
|
-
export function setupInfraLinks(worktreePath, mainCwd) {
|
|
168
|
+
export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
|
|
110
169
|
const mainAbs = resolve(mainCwd);
|
|
111
170
|
const wtAbs = resolve(worktreePath);
|
|
112
171
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
172
|
+
let dirLinks;
|
|
173
|
+
if (Array.isArray(opts.dirs) && opts.dirs.length) {
|
|
174
|
+
dirLinks = opts.dirs;
|
|
175
|
+
} else {
|
|
176
|
+
dirLinks = ['node_modules', '.claude'];
|
|
177
|
+
// .morph/framework/ — client projects (in framework repo, framework/ is tracked)
|
|
178
|
+
if (existsSync(join(mainAbs, '.morph', 'framework'))) {
|
|
179
|
+
dirLinks.push(join('.morph', 'framework'));
|
|
180
|
+
}
|
|
119
181
|
}
|
|
120
182
|
|
|
121
183
|
for (const rel of dirLinks) {
|
|
@@ -128,38 +190,102 @@ export function setupInfraLinks(worktreePath, mainCwd) {
|
|
|
128
190
|
symlinkSync(target, linkPath, 'junction');
|
|
129
191
|
} catch { /* fail-open */ }
|
|
130
192
|
}
|
|
193
|
+
}
|
|
131
194
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the absolute path (forward slashes) of the primary/main git worktree,
|
|
197
|
+
* from anywhere inside the repo (including from within a secondary worktree).
|
|
198
|
+
*
|
|
199
|
+
* Fail-open: if `cwd` is not a git repo (or the git call errors), returns
|
|
200
|
+
* `cwd` itself, resolved and normalized.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} cwd
|
|
203
|
+
* @returns {string} Absolute, forward-slash path to the primary worktree
|
|
204
|
+
*/
|
|
205
|
+
export function resolvePrimaryRoot(cwd = process.cwd()) {
|
|
206
|
+
try {
|
|
207
|
+
const output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
|
|
208
|
+
const line = output.split('\n').find((l) => l.startsWith('worktree '));
|
|
209
|
+
if (line) {
|
|
210
|
+
return line.slice('worktree '.length).trim().replace(/\\/g, '/');
|
|
140
211
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
212
|
+
} catch { /* fall through to fail-open */ }
|
|
213
|
+
return resolve(cwd).replace(/\\/g, '/');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Materialize a LOCAL, real `.morph/state.json` inside a worktree, reconstructed
|
|
218
|
+
* from the committed per-feature `feature.json` files under `.morph/features/`
|
|
219
|
+
* — not linked to the main copy (see `setupInfraLinks` doc for why hard links
|
|
220
|
+
* are unsafe here).
|
|
221
|
+
*
|
|
222
|
+
* Mechanism: temporarily chdir into the worktree and call `loadState(false)`,
|
|
223
|
+
* whose self-heal path (state-manager.js) scaffolds + hydrates + writes a fresh
|
|
224
|
+
* root index when `.morph/state.json` is missing but per-feature files exist.
|
|
225
|
+
* `process.chdir` is process-global, so the original cwd is ALWAYS restored via
|
|
226
|
+
* finally, even on error. Fail-open: never throws.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
229
|
+
* @returns {Promise<{ materialized: boolean, reason?: string }>}
|
|
230
|
+
*/
|
|
231
|
+
export async function materializeState(worktreePath) {
|
|
232
|
+
const featuresDir = join(worktreePath, '.morph', 'features');
|
|
233
|
+
if (!existsSync(featuresDir)) {
|
|
234
|
+
return { materialized: false, reason: 'no-features' };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const prevCwd = process.cwd();
|
|
238
|
+
try {
|
|
239
|
+
process.chdir(resolve(worktreePath));
|
|
240
|
+
const { loadState } = await import('../../core/state/state-manager.js');
|
|
241
|
+
loadState(false); // self-heal: writes a fresh local .morph/state.json
|
|
242
|
+
// loadState(false) only writes state.json when it actually found something
|
|
243
|
+
// to hydrate (a non-empty .morph/features/). A present-but-empty features
|
|
244
|
+
// dir (e.g. right after `archive` moved the last feature out) makes
|
|
245
|
+
// loadState return null and write nothing — don't report success in that
|
|
246
|
+
// case, or callers believe a local state.json exists when it doesn't.
|
|
247
|
+
if (existsSync(join(worktreePath, '.morph', 'state.json'))) {
|
|
248
|
+
return { materialized: true };
|
|
145
249
|
}
|
|
250
|
+
return { materialized: false, reason: 'no-features-to-hydrate' };
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return { materialized: false, reason: (err && err.message) || 'unknown' };
|
|
253
|
+
} finally {
|
|
254
|
+
process.chdir(prevCwd);
|
|
146
255
|
}
|
|
147
256
|
}
|
|
148
257
|
|
|
149
258
|
// ── Command handler ─────────────────────────────────────────────────────────
|
|
150
259
|
|
|
151
260
|
/**
|
|
152
|
-
*
|
|
261
|
+
* Non-exiting core of `worktree setup` — does all the real work (git checks,
|
|
262
|
+
* guards, branch creation, `git worktree add`, infra linking, state
|
|
263
|
+
* materialization, metadata persistence) and returns a result object instead
|
|
264
|
+
* of calling `process.exit`/`console.log`, so callers (e.g. a future
|
|
265
|
+
* `create --worktree` that needs to chain further steps) can reuse it in
|
|
266
|
+
* process. `worktreeSetupCommand` below is a thin CLI wrapper around this.
|
|
267
|
+
*
|
|
153
268
|
* @param {string} feature
|
|
154
|
-
* @param {{ fresh?: boolean, cwd?: string }} options
|
|
269
|
+
* @param {{ fresh?: boolean, cwd?: string }} [options]
|
|
270
|
+
* @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 }>}
|
|
271
|
+
* exitCode 1 = refused, `error: 'invalid-feature-name'`. This function is the
|
|
272
|
+
* non-exiting core precisely so callers can reuse it in-process — a caller
|
|
273
|
+
* trusting a `0|2|3` union would mishandle that refusal.
|
|
155
274
|
*/
|
|
156
|
-
export async function
|
|
275
|
+
export async function runWorktreeSetup(feature, options = {}) {
|
|
157
276
|
const cwd = options.cwd || process.cwd();
|
|
158
277
|
|
|
278
|
+
// Gate 0: the name must be a plain kebab slug before it reaches a path, a
|
|
279
|
+
// refname, or any git call. See isValidFeatureName — `create` already refuses
|
|
280
|
+
// these names, so accepting them here would only ever create a worktree no
|
|
281
|
+
// other morph-spec command can address.
|
|
282
|
+
if (!isValidFeatureName(feature)) {
|
|
283
|
+
return { ok: false, exitCode: 1, created: false, error: 'invalid-feature-name', feature };
|
|
284
|
+
}
|
|
285
|
+
|
|
159
286
|
// Gate 1: Must be a git repo
|
|
160
287
|
if (!isGitRepo(cwd)) {
|
|
161
|
-
|
|
162
|
-
process.exit(0); // Fail-open
|
|
288
|
+
return { ok: true, exitCode: 0, created: false, error: 'not-a-git-repo', feature };
|
|
163
289
|
}
|
|
164
290
|
|
|
165
291
|
const branch = buildBranchName(feature, options);
|
|
@@ -167,10 +293,29 @@ export async function worktreeSetupCommand(feature, options = {}) {
|
|
|
167
293
|
|
|
168
294
|
// Gate 2: If worktree already registered and not --fresh, signal resume
|
|
169
295
|
if (!options.fresh && worktreeExists(worktreePath, cwd)) {
|
|
170
|
-
|
|
171
|
-
process.exit(2); // Convention: 2 = already exists
|
|
296
|
+
return { ok: false, exitCode: 2, created: false, alreadyExists: true, path: worktreePath, branch, feature };
|
|
172
297
|
}
|
|
173
298
|
|
|
299
|
+
// Guard 3: anti-dupe — refuse to fork a second LIVE worktree for the same
|
|
300
|
+
// feature. If feature.json already records a worktree path that still exists
|
|
301
|
+
// on disk and differs from the one this call would create, stop (exit 3).
|
|
302
|
+
// (A recorded path that's gone from disk — e.g. removed via `git worktree
|
|
303
|
+
// remove` — does not block: existsSync() is false, so this falls through.)
|
|
304
|
+
try {
|
|
305
|
+
const featureJsonPath = join(cwd, '.morph', 'features', feature, 'feature.json');
|
|
306
|
+
if (existsSync(featureJsonPath)) {
|
|
307
|
+
const fj = JSON.parse(readFileSync(featureJsonPath, 'utf8'));
|
|
308
|
+
const existingPath = fj?.worktree?.path;
|
|
309
|
+
if (existingPath) {
|
|
310
|
+
const normalizedExisting = existingPath.replace(/\\/g, '/');
|
|
311
|
+
const normalizedNew = worktreePath.replace(/\\/g, '/');
|
|
312
|
+
if (normalizedExisting !== normalizedNew && existsSync(existingPath)) {
|
|
313
|
+
return { ok: false, exitCode: 3, created: false, error: 'worktree-exists-elsewhere', existingPath, feature };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch { /* fail-open — malformed feature.json must never block setup */ }
|
|
318
|
+
|
|
174
319
|
try {
|
|
175
320
|
// Create branch if needed (based off current HEAD)
|
|
176
321
|
if (!branchExists(branch, cwd)) {
|
|
@@ -186,9 +331,13 @@ export async function worktreeSetupCommand(feature, options = {}) {
|
|
|
186
331
|
// Add worktree
|
|
187
332
|
execSync(`git worktree add "${worktreePath}" ${branch}`, { cwd, stdio: 'pipe' });
|
|
188
333
|
|
|
189
|
-
// Set up infrastructure
|
|
334
|
+
// Set up infrastructure junctions (node_modules, .claude, .morph/framework).
|
|
190
335
|
setupInfraLinks(worktreePath, cwd);
|
|
191
336
|
|
|
337
|
+
// Materialize a local, real state.json in the worktree (self-heal from the
|
|
338
|
+
// committed feature.json files) so hooks work from the very first session.
|
|
339
|
+
const materialized = await materializeState(worktreePath);
|
|
340
|
+
|
|
192
341
|
// Persist in state (non-blocking — feature may not be in state yet).
|
|
193
342
|
// NOTE: imports from core/ (the lib/ path was a latent bug that silently
|
|
194
343
|
// no-op'd this whole block); loadState/saveState operate on the cwd's state.
|
|
@@ -203,10 +352,820 @@ export async function worktreeSetupCommand(feature, options = {}) {
|
|
|
203
352
|
// Non-blocking
|
|
204
353
|
}
|
|
205
354
|
|
|
206
|
-
|
|
207
|
-
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
exitCode: 0,
|
|
358
|
+
created: true,
|
|
359
|
+
path: worktreePath,
|
|
360
|
+
branch,
|
|
361
|
+
feature,
|
|
362
|
+
materialized: materialized.materialized,
|
|
363
|
+
nextStep: `Abra o Claude Code em ${worktreePath} para trabalhar nesta feature`,
|
|
364
|
+
};
|
|
208
365
|
} catch (err) {
|
|
209
|
-
|
|
366
|
+
return { ok: true, exitCode: 0, created: false, error: (err.message || 'unknown').slice(0, 200), feature };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Main command handler for `morph-spec worktree setup <feature>` — thin
|
|
372
|
+
* wrapper around `runWorktreeSetup` that prints its JSON result and exits
|
|
373
|
+
* with its exit code. Preserves the exact JSON shape and exit codes (0/2/3)
|
|
374
|
+
* this command has always used.
|
|
375
|
+
* @param {string} feature
|
|
376
|
+
* @param {{ fresh?: boolean, cwd?: string }} options
|
|
377
|
+
*/
|
|
378
|
+
export async function worktreeSetupCommand(feature, options = {}) {
|
|
379
|
+
const { ok, exitCode, ...result } = await runWorktreeSetup(feature, options);
|
|
380
|
+
console.log(JSON.stringify(result));
|
|
381
|
+
process.exit(exitCode);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Command handler for `morph-spec worktree link [--target <path>] [--all]`.
|
|
386
|
+
*
|
|
387
|
+
* (Re)links shared infrastructure (node_modules by default; also .claude and
|
|
388
|
+
* .morph/framework with --all) from the primary root into a worktree. Useful
|
|
389
|
+
* to repair/refresh junctions without recreating the whole worktree.
|
|
390
|
+
* Idempotent — already-present junctions are reported as skipped, not re-created.
|
|
391
|
+
*
|
|
392
|
+
* @param {{ target?: string, all?: boolean, cwd?: string }} [options]
|
|
393
|
+
*/
|
|
394
|
+
export function worktreeLinkCommand(options = {}) {
|
|
395
|
+
const cwdArg = options.cwd || process.cwd();
|
|
396
|
+
const root = resolvePrimaryRoot(cwdArg);
|
|
397
|
+
const target = resolve(options.target || options.cwd || process.cwd()).replace(/\\/g, '/');
|
|
398
|
+
|
|
399
|
+
// No-op: linking the primary root into itself makes no sense.
|
|
400
|
+
if (target === root) {
|
|
401
|
+
console.log(JSON.stringify({ linked: [], skipped: ['is-primary-root'], root, target }));
|
|
402
|
+
process.exit(0);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (!isGitRepo(cwdArg)) {
|
|
406
|
+
console.log(JSON.stringify({ error: 'not-a-git-repo' }));
|
|
210
407
|
process.exit(0); // Fail-open
|
|
211
408
|
}
|
|
409
|
+
|
|
410
|
+
const dirs = options.all
|
|
411
|
+
? ['node_modules', '.claude', join('.morph', 'framework')]
|
|
412
|
+
: ['node_modules'];
|
|
413
|
+
|
|
414
|
+
const alreadyPresent = {};
|
|
415
|
+
for (const rel of dirs) {
|
|
416
|
+
alreadyPresent[rel] = existsSync(join(target, rel));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
setupInfraLinks(target, root, { dirs });
|
|
420
|
+
|
|
421
|
+
const linked = [];
|
|
422
|
+
const skipped = [];
|
|
423
|
+
for (const rel of dirs) {
|
|
424
|
+
if (alreadyPresent[rel]) {
|
|
425
|
+
skipped.push(rel); // already there before this call
|
|
426
|
+
} else if (existsSync(join(target, rel))) {
|
|
427
|
+
linked.push(rel); // newly created by setupInfraLinks
|
|
428
|
+
} else {
|
|
429
|
+
skipped.push(rel); // nothing to link (source missing in root, or link failed)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
console.log(JSON.stringify({ linked, skipped, root, target }));
|
|
434
|
+
process.exit(0);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── finish / list shared helpers ────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Parse `git worktree list --porcelain` into structured entries.
|
|
441
|
+
* Fail-open: returns `[]` on any git error (e.g. not a git repo).
|
|
442
|
+
* @param {string} cwd
|
|
443
|
+
* @returns {Array<{ path: string, branch: string|null, head: string|null }>}
|
|
444
|
+
*/
|
|
445
|
+
function parseWorktreeList(cwd) {
|
|
446
|
+
let output;
|
|
447
|
+
try {
|
|
448
|
+
output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
|
|
449
|
+
} catch {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const entries = [];
|
|
454
|
+
let current = null;
|
|
455
|
+
for (const line of output.split('\n')) {
|
|
456
|
+
if (line.startsWith('worktree ')) {
|
|
457
|
+
if (current) entries.push(current);
|
|
458
|
+
current = { path: line.slice('worktree '.length).trim().replace(/\\/g, '/'), branch: null, head: null };
|
|
459
|
+
} else if (line.startsWith('branch ') && current) {
|
|
460
|
+
current.branch = line.slice('branch '.length).trim();
|
|
461
|
+
} else if (line.startsWith('HEAD ') && current) {
|
|
462
|
+
current.head = line.slice('HEAD '.length).trim();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (current) entries.push(current);
|
|
466
|
+
return entries;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Locate the registered worktree for a feature's `morph/{feature}` branch.
|
|
471
|
+
* Primary strategy: match the `branch refs/heads/morph/{feature}` entry from
|
|
472
|
+
* `git worktree list --porcelain`. Fallback: the conventional
|
|
473
|
+
* `buildWorktreePath` location, but only if it is itself a registered
|
|
474
|
+
* worktree entry (so a stray directory never gets mistaken for a live one).
|
|
475
|
+
*
|
|
476
|
+
* Returns the branch the worktree is ACTUALLY on, never the computed one. Via
|
|
477
|
+
* the path fallback the two can diverge (the user checked out something else in
|
|
478
|
+
* there), and reporting the computed name would make `finish` merge
|
|
479
|
+
* `morph/{feature}` while removing a worktree holding unrelated work — merging a
|
|
480
|
+
* branch it never validated, which is the whole failure this command guards
|
|
481
|
+
* against. The caller compares the two and refuses on mismatch.
|
|
482
|
+
*
|
|
483
|
+
* @param {string} feature
|
|
484
|
+
* @param {string} root - Primary root path
|
|
485
|
+
* @returns {{ path: string, branch: string, expectedBranch: string }|null}
|
|
486
|
+
*/
|
|
487
|
+
function findWorktreeForFeature(feature, root) {
|
|
488
|
+
const expectedBranch = buildBranchName(feature);
|
|
489
|
+
const refName = `refs/heads/${expectedBranch}`;
|
|
490
|
+
const entries = parseWorktreeList(root);
|
|
491
|
+
|
|
492
|
+
let match = entries.find((e) => e.branch === refName);
|
|
493
|
+
if (!match) {
|
|
494
|
+
const guess = buildWorktreePath(feature, root).replace(/\\/g, '/');
|
|
495
|
+
match = entries.find((e) => e.path === guess);
|
|
496
|
+
}
|
|
497
|
+
if (!match) return null;
|
|
498
|
+
|
|
499
|
+
// A detached HEAD has no branch at all — report it as such rather than
|
|
500
|
+
// inventing the computed name for it.
|
|
501
|
+
const actual = match.branch ? match.branch.replace(/^refs\/heads\//, '') : null;
|
|
502
|
+
return { path: match.path, branch: actual, expectedBranch };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The infra paths `setupInfraLinks` junctions into a worktree. */
|
|
506
|
+
const INFRA_LINK_RELS = ['node_modules', '.claude', join('.morph', 'framework')];
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Drop the infra junctions a worktree carries (`node_modules`, `.claude`,
|
|
510
|
+
* `.morph/framework`) WITHOUT following them.
|
|
511
|
+
*
|
|
512
|
+
* CRITICAL — this is what keeps `worktree finish` from destroying the project.
|
|
513
|
+
* `git worktree remove` refuses a worktree still holding these ("contains
|
|
514
|
+
* modified or untracked files"), and the `--force` fallback FOLLOWS the
|
|
515
|
+
* junctions and deletes the PRIMARY ROOT's real `.claude/` and
|
|
516
|
+
* `.morph/framework/` — wiping the morph-spec installation (MORPH.md, agents,
|
|
517
|
+
* skills, settings) while still reporting `finished: true`. On a short path the
|
|
518
|
+
* wipe is total and silent; on a long one MAX_PATH aborts it midway, leaving
|
|
519
|
+
* partial damage plus an orphan directory. Removing the reparse points first
|
|
520
|
+
* lets the plain (non-force) removal succeed and leaves `--force` nothing to
|
|
521
|
+
* traverse.
|
|
522
|
+
*
|
|
523
|
+
* Only links are touched: `unlinkSync`/`rmdirSync` act on the LINK, never on the
|
|
524
|
+
* target's contents, and `rmdirSync` fails on a real non-empty directory — the
|
|
525
|
+
* safety that stops this from ever deleting genuine files.
|
|
526
|
+
*
|
|
527
|
+
* @param {string} worktreePath
|
|
528
|
+
* @returns {string[]} relative paths actually unlinked
|
|
529
|
+
*/
|
|
530
|
+
export function unlinkWorktreeInfraLinks(worktreePath) {
|
|
531
|
+
const removed = [];
|
|
532
|
+
for (const rel of INFRA_LINK_RELS) {
|
|
533
|
+
const p = join(worktreePath, rel);
|
|
534
|
+
let st;
|
|
535
|
+
try {
|
|
536
|
+
st = lstatSync(p);
|
|
537
|
+
} catch {
|
|
538
|
+
continue; // absent — nothing to drop
|
|
539
|
+
}
|
|
540
|
+
if (!st.isSymbolicLink() && !st.isDirectory()) continue;
|
|
541
|
+
try {
|
|
542
|
+
// symlink → unlink; Windows junction → rmdir. Both act on the link itself.
|
|
543
|
+
try {
|
|
544
|
+
unlinkSync(p);
|
|
545
|
+
} catch {
|
|
546
|
+
rmdirSync(p);
|
|
547
|
+
}
|
|
548
|
+
removed.push(rel);
|
|
549
|
+
} catch { /* a real, non-empty directory — leave it alone */ }
|
|
550
|
+
}
|
|
551
|
+
return removed;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Every reparse point anywhere under `worktreePath`, as relative paths.
|
|
556
|
+
*
|
|
557
|
+
* CRITICAL — this is the gate that keeps `worktree finish` from destroying the
|
|
558
|
+
* project, and it must be exhaustive. BOTH `git worktree remove` and its
|
|
559
|
+
* `--force` fallback FOLLOW reparse points out of the worktree and delete the
|
|
560
|
+
* PRIMARY ROOT's real files. The plain removal is not the safe one: it refuses
|
|
561
|
+
* only when it sees modified/untracked *tracked-scope* content, so a GITIGNORED
|
|
562
|
+
* junction (`node_modules`, `.claude`) leaves `git status` empty, the removal
|
|
563
|
+
* proceeds, exits 0 — and the target is gone.
|
|
564
|
+
*
|
|
565
|
+
* So the check cannot live in the `catch` of the plain removal (unreachable on
|
|
566
|
+
* exactly the destructive path), and it cannot be a fixed list of the links
|
|
567
|
+
* `setup` creates: a nested link inside a real `node_modules` (npm link, npm
|
|
568
|
+
* workspaces) is invisible to any top-level inventory. We scan the whole tree
|
|
569
|
+
* and NEVER recurse INTO a reparse point — descending one would walk the target
|
|
570
|
+
* we are trying to protect (and could not terminate on a cycle).
|
|
571
|
+
*
|
|
572
|
+
* An empty result is NOT the same as a safe one: a directory we could not read
|
|
573
|
+
* or an entry we could not lstat might BE (or contain) a reparse point. Those
|
|
574
|
+
* answer `unprovable: true`, and the caller must treat that exactly like a found
|
|
575
|
+
* link — decline. "I saw nothing" and "I proved there is nothing" are different
|
|
576
|
+
* claims, and only the second one may authorize a removal.
|
|
577
|
+
*
|
|
578
|
+
* @param {string} dir
|
|
579
|
+
* @param {string} [base] - root of the scan, for relative reporting
|
|
580
|
+
* @param {{points: string[], unprovable: string[]}} [acc]
|
|
581
|
+
* @returns {{points: string[], unprovable: string[]}} both empty = provably clean
|
|
582
|
+
*/
|
|
583
|
+
function findReparsePoints(dir, base = dir, acc = { points: [], unprovable: [] }) {
|
|
584
|
+
let entries;
|
|
585
|
+
try {
|
|
586
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
587
|
+
} catch {
|
|
588
|
+
// Unreadable (ACL, lock, EMFILE): we cannot prove what is in here.
|
|
589
|
+
acc.unprovable.push((relative(base, dir) || '.').replace(/\\/g, '/'));
|
|
590
|
+
return acc;
|
|
591
|
+
}
|
|
592
|
+
for (const entry of entries) {
|
|
593
|
+
const p = join(dir, entry.name);
|
|
594
|
+
let st;
|
|
595
|
+
try {
|
|
596
|
+
st = lstatSync(p);
|
|
597
|
+
} catch {
|
|
598
|
+
acc.unprovable.push(relative(base, p).replace(/\\/g, '/'));
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (st.isSymbolicLink()) {
|
|
602
|
+
// Forward slashes: every other path this command reports is normalized,
|
|
603
|
+
// and these end up in JSON the caller may compare or display.
|
|
604
|
+
acc.points.push(relative(base, p).replace(/\\/g, '/'));
|
|
605
|
+
continue; // do NOT descend — that is the target, not the worktree
|
|
606
|
+
}
|
|
607
|
+
if (st.isDirectory() && entry.name !== '.git') findReparsePoints(p, base, acc);
|
|
608
|
+
}
|
|
609
|
+
return acc;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Gitignored paths a morph worktree owns and that are safe to remove — but ONLY
|
|
614
|
+
* once backed up (see backupWorktreeIgnored). `.morph/state.json` is provably
|
|
615
|
+
* derivable (loadState self-heals it from the committed feature.json files) and
|
|
616
|
+
* the rest is morph's own diagnostic output, which `setupInfraLinks` documents
|
|
617
|
+
* as worktree-local. They are small, so backing them up costs nothing and buys
|
|
618
|
+
* the guarantee that this list does not have to be RIGHT.
|
|
619
|
+
*
|
|
620
|
+
* `node_modules` is deliberately NOT here: it is regenerable from the lockfile,
|
|
621
|
+
* far too large to copy, and `unlinkWorktreeInfraLinks` already dropped it when
|
|
622
|
+
* it is the junction `setup` creates. A real one left behind blocks removal —
|
|
623
|
+
* safe, and the user can clear it themselves.
|
|
624
|
+
*/
|
|
625
|
+
const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Split the worktree's GITIGNORED leftovers into what this command may remove
|
|
629
|
+
* (morph's own, after a backup) and what it must refuse to destroy.
|
|
630
|
+
*
|
|
631
|
+
* Why this exists: `git worktree remove` deletes gitignored files outright, and
|
|
632
|
+
* the clean-check that gates it uses `git status --porcelain`, which omits them
|
|
633
|
+
* BY DESIGN. So a user's `.env` or a local override is invisible to that gate
|
|
634
|
+
* and dies silently while the command reports success. Git treats gitignored as
|
|
635
|
+
* disposable; morph must not, because "not in git" is exactly the property that
|
|
636
|
+
* makes a file unrecoverable.
|
|
637
|
+
*
|
|
638
|
+
* Fail-CLOSED: if the status call fails we cannot enumerate what is at stake, so
|
|
639
|
+
* everything is reported as blocking.
|
|
640
|
+
*
|
|
641
|
+
* @param {string} worktreePath
|
|
642
|
+
* @param {{ discardIgnored?: boolean }} options
|
|
643
|
+
* @returns {{ shieldable: string[], blocking: string[] }}
|
|
644
|
+
*/
|
|
645
|
+
function classifyIgnoredLeftovers(worktreePath, options = {}) {
|
|
646
|
+
let out;
|
|
647
|
+
try {
|
|
648
|
+
// `-z` + execFile: NUL-delimited, no shell, no C-style quoting to undo —
|
|
649
|
+
// paths with spaces or non-ASCII (a pt-BR codebase has both) stay intact.
|
|
650
|
+
out = execFileSync('git', ['status', '--porcelain', '--ignored', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
|
|
651
|
+
} catch {
|
|
652
|
+
return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'] };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const shieldable = [];
|
|
656
|
+
const blocking = [];
|
|
657
|
+
for (const record of out.split('\0')) {
|
|
658
|
+
if (!record || record.length < 4) continue;
|
|
659
|
+
if (record.slice(0, 2) !== '!!') continue; // only ignored entries; the rest is the clean-check's job
|
|
660
|
+
const p = record.slice(3).replace(/\\/g, '/');
|
|
661
|
+
if (IGNORED_SHIELDABLE_PREFIXES.some((prefix) => p.startsWith(prefix))) shieldable.push(p);
|
|
662
|
+
else blocking.push(p);
|
|
663
|
+
}
|
|
664
|
+
// Explicit consent clears the block — but the list is NOT thrown away. These
|
|
665
|
+
// are the files that exist nowhere else, and they are the ones about to die;
|
|
666
|
+
// reporting them is the least this can do. (Backing them up instead is not an
|
|
667
|
+
// option: this bucket is where `node_modules`/`dist`/`.next` land, so a copy
|
|
668
|
+
// is unbounded. The user declared them disposable — they still get told.)
|
|
669
|
+
if (options.discardIgnored) return { shieldable, blocking: [], discarded: blocking };
|
|
670
|
+
return { shieldable, blocking, discarded: [] };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Back up the worktree's morph-owned gitignored leftovers before the removal
|
|
675
|
+
* deletes them. Same destination and rationale as backupFeatureScope.
|
|
676
|
+
*
|
|
677
|
+
* @param {string} worktreePath
|
|
678
|
+
* @param {string} root
|
|
679
|
+
* @param {string} feature
|
|
680
|
+
* @param {string[]} rels - worktree-relative paths
|
|
681
|
+
* @param {string} stamp
|
|
682
|
+
* @returns {string|null} backup path, or null if ANY copy failed (caller must
|
|
683
|
+
* then decline the removal — a partial backup authorizes nothing)
|
|
684
|
+
*/
|
|
685
|
+
function backupWorktreeIgnored(worktreePath, root, feature, rels, stamp) {
|
|
686
|
+
const dest = join(root, '.git', 'morph-spec', 'finish-backup', `${feature}-${stamp}-worktree-ignored`);
|
|
687
|
+
try {
|
|
688
|
+
for (const rel of rels) {
|
|
689
|
+
const src = join(worktreePath, rel);
|
|
690
|
+
if (!existsSync(src)) continue; // a directory entry already covered by its children
|
|
691
|
+
const target = join(dest, rel);
|
|
692
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
693
|
+
cpSync(src, target, { recursive: true });
|
|
694
|
+
}
|
|
695
|
+
return existsSync(dest) ? dest : null;
|
|
696
|
+
} catch {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Copy the feature's root-side scope to a backup OUTSIDE the working tree, so
|
|
703
|
+
* the shed below can never be the reason something is lost.
|
|
704
|
+
*
|
|
705
|
+
* Lives under `.git/` on purpose: it is not a working-tree path, so it can never
|
|
706
|
+
* dirty `git status` (which the very next validation reads) and is never merged,
|
|
707
|
+
* committed, or cleaned by `git clean`.
|
|
708
|
+
*
|
|
709
|
+
* @param {string} root
|
|
710
|
+
* @param {string} feature
|
|
711
|
+
* @param {string} stamp - caller-supplied timestamp (keeps this pure/testable)
|
|
712
|
+
* @returns {string|null} absolute backup path, or null if the copy failed
|
|
713
|
+
*/
|
|
714
|
+
function backupFeatureScope(root, feature, stamp) {
|
|
715
|
+
const src = join(root, '.morph', 'features', feature);
|
|
716
|
+
if (!existsSync(src)) return null;
|
|
717
|
+
const dest = join(root, '.git', 'morph-spec', 'finish-backup', `${feature}-${stamp}`);
|
|
718
|
+
try {
|
|
719
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
720
|
+
cpSync(src, dest, { recursive: true });
|
|
721
|
+
return dest;
|
|
722
|
+
} catch {
|
|
723
|
+
return null; // caller MUST treat this as "do not shed"
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ── finish ───────────────────────────────────────────────────────────────
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Command handler for `morph-spec worktree finish <feature> [--keep] [--force]`.
|
|
731
|
+
*
|
|
732
|
+
* Deterministic merge-back: merges the feature's `morph/{feature}` branch into
|
|
733
|
+
* the primary root and (unless `--keep`) removes the worktree + branch. This
|
|
734
|
+
* is the closing half of the worktree lifecycle that `setup` opens.
|
|
735
|
+
*
|
|
736
|
+
* All three preconditions below are fail-CLOSED — no merge is attempted and
|
|
737
|
+
* nothing is removed. They are NOT no-ops, though: the shed runs before the
|
|
738
|
+
* last one, so a refusal there (or on a merge conflict) has already moved this
|
|
739
|
+
* feature's root-side scope to the backup. Recoverable, and every such refusal
|
|
740
|
+
* reports the path — but "nothing was touched" would be false. Preconditions:
|
|
741
|
+
* 1. Gate 3 (review) approved on the worktree's own feature.json, unless `--force`.
|
|
742
|
+
* 2. The worktree itself has no uncommitted changes (work must be committed
|
|
743
|
+
* to travel with the merge).
|
|
744
|
+
* 3. The primary root has no uncommitted changes (so a merge commit lands cleanly).
|
|
745
|
+
*
|
|
746
|
+
* NOTE: precondition 3 runs AFTER the shed of this feature's root-side scope, so
|
|
747
|
+
* a refusal there is not a no-op — but it is RECOVERABLE: the shed copies that
|
|
748
|
+
* scope to `.git/morph-spec/finish-backup/` first and every refusal after it
|
|
749
|
+
* reports the path.
|
|
750
|
+
*
|
|
751
|
+
* That guarantee covers what the SHED discards (the root-side scope) and, at
|
|
752
|
+
* cleanup, morph's own gitignored files in the worktree. It does NOT cover
|
|
753
|
+
* `--discard-ignored`, which destroys the worktree's non-morph gitignored files
|
|
754
|
+
* outright — that is the flag's stated purpose, the JSON names each file it
|
|
755
|
+
* destroyed, and no default path reaches it.
|
|
756
|
+
*
|
|
757
|
+
* A merge conflict aborts the merge (`git merge --abort`) and refuses —
|
|
758
|
+
* nothing is left half-merged.
|
|
759
|
+
*
|
|
760
|
+
* Success is reported as two INDEPENDENT signals: `finished` is the merge (the
|
|
761
|
+
* actual value), `worktreeRemoved` is the best-effort cleanup. Cleanup declines
|
|
762
|
+
* itself whenever it cannot prove it is safe, so `finished: true` +
|
|
763
|
+
* `worktreeRemoved: false` is a normal, honest outcome — not a failure.
|
|
764
|
+
*
|
|
765
|
+
* @param {string} feature
|
|
766
|
+
* @param {{ keep?: boolean, force?: boolean, discardIgnored?: boolean, cwd?: string }} [options]
|
|
767
|
+
* `force` waives the Gate 3 precondition ONLY — never the branch check.
|
|
768
|
+
* `discardIgnored` is consent to destroy the worktree's non-morph gitignored
|
|
769
|
+
* files (see the NOTE above); it does not waive the reparse-point gate.
|
|
770
|
+
*/
|
|
771
|
+
export async function worktreeFinishCommand(feature, options = {}) {
|
|
772
|
+
const cwd = options.cwd || process.cwd();
|
|
773
|
+
const root = resolvePrimaryRoot(cwd);
|
|
774
|
+
|
|
775
|
+
// Refuse anything that is not a plain kebab feature name. `& | ; > ^` are all
|
|
776
|
+
// legal in a git refname but are cmd.exe metacharacters, and every git call
|
|
777
|
+
// below interpolates the name (or the branch built from it). Validating here —
|
|
778
|
+
// not just around the shed — is what keeps a crafted name from steering the
|
|
779
|
+
// `merge`/`branch -d` at the bottom onto a DIFFERENT branch than the one this
|
|
780
|
+
// command validated and reported.
|
|
781
|
+
if (!isValidFeatureName(feature)) {
|
|
782
|
+
console.log(JSON.stringify({ finished: false, error: 'invalid-feature-name', feature }));
|
|
783
|
+
process.exit(1);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const found = findWorktreeForFeature(feature, root);
|
|
787
|
+
if (!found) {
|
|
788
|
+
console.log(JSON.stringify({ finished: false, error: 'no-worktree', feature }));
|
|
789
|
+
process.exit(1);
|
|
790
|
+
}
|
|
791
|
+
const { path: worktreePath, branch, expectedBranch } = found;
|
|
792
|
+
|
|
793
|
+
// Validation 0: the worktree must actually be ON the branch we are about to
|
|
794
|
+
// merge. The path fallback in findWorktreeForFeature matches by location, so
|
|
795
|
+
// a worktree the user re-pointed at another branch (or detached) still
|
|
796
|
+
// matches — and merging the computed `morph/{feature}` then would land a
|
|
797
|
+
// branch we never validated while destroying a worktree holding unrelated
|
|
798
|
+
// work. `--force` does NOT waive this: it waives the review gate, not the
|
|
799
|
+
// question of WHICH branch this command is acting on.
|
|
800
|
+
if (branch !== expectedBranch) {
|
|
801
|
+
console.log(JSON.stringify({
|
|
802
|
+
finished: false,
|
|
803
|
+
error: 'worktree-branch-mismatch',
|
|
804
|
+
feature,
|
|
805
|
+
expectedBranch,
|
|
806
|
+
actualBranch: branch, // null = detached HEAD
|
|
807
|
+
path: worktreePath,
|
|
808
|
+
hint: `o worktree em ${worktreePath} está em ${branch || 'HEAD destacado'}, não em ${expectedBranch} — faça checkout da branch da feature ou finalize essa branch manualmente`,
|
|
809
|
+
}));
|
|
810
|
+
process.exit(1);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Validation 1: Gate 3 (review) approved, unless --force.
|
|
814
|
+
if (!options.force) {
|
|
815
|
+
let reviewApproved = false;
|
|
816
|
+
try {
|
|
817
|
+
const fjPath = join(worktreePath, '.morph', 'features', feature, 'feature.json');
|
|
818
|
+
if (existsSync(fjPath)) {
|
|
819
|
+
const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
|
|
820
|
+
const gates = fj?.approvalGates || fj?.approvals || {};
|
|
821
|
+
reviewApproved = gates?.review?.approved === true;
|
|
822
|
+
}
|
|
823
|
+
} catch { /* reviewApproved stays false — refuse below */ }
|
|
824
|
+
|
|
825
|
+
if (!reviewApproved) {
|
|
826
|
+
console.log(JSON.stringify({ finished: false, error: 'review-not-approved', feature }));
|
|
827
|
+
process.exit(1);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Validation 2: worktree must be clean (work must be committed to travel with the merge).
|
|
832
|
+
let worktreeStatus;
|
|
833
|
+
try {
|
|
834
|
+
worktreeStatus = execSync('git status --porcelain', { cwd: worktreePath, stdio: 'pipe' }).toString();
|
|
835
|
+
} catch (err) {
|
|
836
|
+
console.log(JSON.stringify({ finished: false, error: 'worktree-status-failed', detail: (err.message || '').slice(0, 200), feature }));
|
|
837
|
+
process.exit(1);
|
|
838
|
+
}
|
|
839
|
+
if (worktreeStatus.trim() !== '') {
|
|
840
|
+
console.log(JSON.stringify({
|
|
841
|
+
finished: false,
|
|
842
|
+
error: 'worktree-dirty',
|
|
843
|
+
hint: 'commit seu trabalho na branch antes do finish',
|
|
844
|
+
feature,
|
|
845
|
+
}));
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Shed the feature's OWN transient root-side bookkeeping BEFORE the clean
|
|
850
|
+
// check. `.morph/features/{feature}/` in the root holds artifacts the merge is
|
|
851
|
+
// about to replace wholesale: the factory flow (`create --worktree`) scaffolds
|
|
852
|
+
// it untracked, and `setup` stamps the worktree registration onto a committed
|
|
853
|
+
// feature.json (a ` M`). Shedding them lets the merge own this feature's state
|
|
854
|
+
// while the whole-root clean-check keeps guarding UNRELATED work.
|
|
855
|
+
//
|
|
856
|
+
// SAFETY — back up FIRST, then shed. An earlier design classified each dirty
|
|
857
|
+
// path to decide what was safe to discard; every version of that classifier
|
|
858
|
+
// was wrong in some real flow (an untracked file the branch has at a different
|
|
859
|
+
// CONTENT; a `??` feature.json that a `M`-gated check never inspected), and a
|
|
860
|
+
// wrong classifier here destroys unversioned work while reporting success.
|
|
861
|
+
// Copying the whole scope out of the working tree first makes the shed safe
|
|
862
|
+
// WITHOUT having to be right about what is in it: if the classification the
|
|
863
|
+
// merge implies turns out wrong, the bytes are still on disk.
|
|
864
|
+
const featPathspec = `.morph/features/${feature}`;
|
|
865
|
+
let shedBackup = null;
|
|
866
|
+
|
|
867
|
+
let dirtyInScope = '';
|
|
868
|
+
try {
|
|
869
|
+
dirtyInScope = execFileSync('git', ['status', '--porcelain', '-uall', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
|
|
870
|
+
} catch { /* nothing in scope */ }
|
|
871
|
+
|
|
872
|
+
if (dirtyInScope.trim() !== '') {
|
|
873
|
+
shedBackup = backupFeatureScope(root, feature, new Date().toISOString().replace(/[:.]/g, '-'));
|
|
874
|
+
// No backup, no shed. Falling through leaves the root dirty, so the
|
|
875
|
+
// clean-check below refuses — the exact fail-closed behaviour that existed
|
|
876
|
+
// before any shed did.
|
|
877
|
+
if (shedBackup) {
|
|
878
|
+
// Discard the registration stamped onto a committed feature.json by `setup`.
|
|
879
|
+
try { execFileSync('git', ['checkout', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing tracked to restore */ }
|
|
880
|
+
// Remove the untracked scaffold left in the root by `create --worktree`.
|
|
881
|
+
// Scoped pathspec + no `-x` keep this away from gitignored `.morph/logs/`.
|
|
882
|
+
try { execFileSync('git', ['clean', '-fdq', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing untracked to clean */ }
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Every refusal from here on must tell the user where the bytes went.
|
|
887
|
+
// Distinct key from `hint`: callers below carry their own hint and this must
|
|
888
|
+
// never overwrite it.
|
|
889
|
+
const backupNote = shedBackup
|
|
890
|
+
? {
|
|
891
|
+
shedBackup,
|
|
892
|
+
shedBackupNote: `o conteúdo root-side de ${featPathspec} foi copiado para ${shedBackup} antes de ser descartado — nada foi perdido`,
|
|
893
|
+
}
|
|
894
|
+
: {};
|
|
895
|
+
|
|
896
|
+
// Validation 3: primary root must be clean.
|
|
897
|
+
let rootStatus;
|
|
898
|
+
try {
|
|
899
|
+
rootStatus = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
|
|
900
|
+
} catch (err) {
|
|
901
|
+
console.log(JSON.stringify({ finished: false, error: 'primary-root-status-failed', detail: (err.message || '').slice(0, 200), feature, ...backupNote }));
|
|
902
|
+
process.exit(1);
|
|
903
|
+
}
|
|
904
|
+
if (rootStatus.trim() !== '') {
|
|
905
|
+
console.log(JSON.stringify({
|
|
906
|
+
finished: false,
|
|
907
|
+
error: 'primary-root-dirty',
|
|
908
|
+
feature,
|
|
909
|
+
...backupNote,
|
|
910
|
+
}));
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Merge — fail-closed on conflict. Args go through execFile: the branch name
|
|
915
|
+
// reaches git as one argv entry, never as a cmd.exe string to re-split.
|
|
916
|
+
try {
|
|
917
|
+
execFileSync('git', ['merge', '--no-ff', branch, '-m', `morph-spec worktree finish: merge ${branch}`], { cwd: root, stdio: 'pipe' });
|
|
918
|
+
} catch (err) {
|
|
919
|
+
try {
|
|
920
|
+
execFileSync('git', ['merge', '--abort'], { cwd: root, stdio: 'pipe' });
|
|
921
|
+
} catch { /* best-effort abort — merge may not have entered a mergeable state */ }
|
|
922
|
+
const output = (((err.stdout && err.stdout.toString()) || '') + ((err.stderr && err.stderr.toString()) || '')).slice(0, 2000) || (err.message || '').slice(0, 500);
|
|
923
|
+
console.log(JSON.stringify({
|
|
924
|
+
finished: false,
|
|
925
|
+
error: 'merge-conflict',
|
|
926
|
+
output,
|
|
927
|
+
hint: 'resolva manualmente ou re-execute a feature sequencialmente',
|
|
928
|
+
feature,
|
|
929
|
+
...backupNote,
|
|
930
|
+
}));
|
|
931
|
+
process.exit(1);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (options.keep) {
|
|
935
|
+
console.log(JSON.stringify({ finished: true, kept: true, feature, merged: branch, path: worktreePath, branch, ...backupNote }));
|
|
936
|
+
process.exit(0);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// ── Cleanup: PROVE it is safe, or decline it ──────────────────────────────
|
|
940
|
+
//
|
|
941
|
+
// The merge (the actual value of this command) has landed. Removing the
|
|
942
|
+
// worktree is convenience, and it is the single most dangerous thing here.
|
|
943
|
+
// Two independent ways it destroys data, both of which end in a removal that
|
|
944
|
+
// exits 0 while doing it:
|
|
945
|
+
//
|
|
946
|
+
// 1. `git worktree remove` — plain AND `--force` — follows reparse points
|
|
947
|
+
// out of the worktree and deletes what they point at in the primary root.
|
|
948
|
+
// 2. It deletes GITIGNORED files outright. Git considers them disposable;
|
|
949
|
+
// a user's `.env` is not. They are invisible to the clean-check above
|
|
950
|
+
// (`git status --porcelain` omits them by design), so that gate cannot
|
|
951
|
+
// speak for them at all.
|
|
952
|
+
//
|
|
953
|
+
// Both gates below therefore PROVE-or-DECLINE. Declining costs a stale
|
|
954
|
+
// directory the user can delete in one command; being wrong costs data that
|
|
955
|
+
// exists nowhere else.
|
|
956
|
+
unlinkWorktreeInfraLinks(worktreePath);
|
|
957
|
+
|
|
958
|
+
const scan = findReparsePoints(worktreePath);
|
|
959
|
+
const ignored = classifyIgnoredLeftovers(worktreePath, options);
|
|
960
|
+
const blockers = [...scan.points, ...scan.unprovable];
|
|
961
|
+
|
|
962
|
+
// Back up ONLY once the removal is otherwise cleared. Two reasons, and the
|
|
963
|
+
// ordering buys both for free:
|
|
964
|
+
// - Nothing is about to be destroyed on a declined path, so copying there
|
|
965
|
+
// is pure waste — and worse, it would duplicate the user's content into
|
|
966
|
+
// `.git/` for a removal that never happens.
|
|
967
|
+
// - `blockers.length === 0` is a PROOF that no reparse point exists anywhere
|
|
968
|
+
// under the worktree. cpSync follows junctions on Windows, so running it
|
|
969
|
+
// before that proof could copy an out-of-repo tree of unbounded size in
|
|
970
|
+
// under an allowlisted `.morph/` path.
|
|
971
|
+
let ignoredBackup = null;
|
|
972
|
+
const clearedToRemove = blockers.length === 0 && ignored.blocking.length === 0;
|
|
973
|
+
if (clearedToRemove && ignored.shieldable.length) {
|
|
974
|
+
ignoredBackup = backupWorktreeIgnored(worktreePath, root, feature, ignored.shieldable, new Date().toISOString().replace(/[:.]/g, '-'));
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const canRemove = clearedToRemove
|
|
978
|
+
&& (!ignored.shieldable.length || ignoredBackup !== null);
|
|
979
|
+
|
|
980
|
+
let worktreeRemoved = false;
|
|
981
|
+
if (canRemove) {
|
|
982
|
+
try {
|
|
983
|
+
execFileSync('git', ['worktree', 'remove', worktreePath], { cwd: root, stdio: 'pipe' });
|
|
984
|
+
worktreeRemoved = true;
|
|
985
|
+
} catch {
|
|
986
|
+
// Nothing is left to traverse (proven above), so --force can only act
|
|
987
|
+
// inside the worktree.
|
|
988
|
+
try {
|
|
989
|
+
execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: root, stdio: 'pipe' });
|
|
990
|
+
worktreeRemoved = true;
|
|
991
|
+
} catch { /* fail-open — cleanup is best-effort once the merge already landed */ }
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// The branch is still checked out by a worktree we did not remove — deleting
|
|
996
|
+
// it would fail anyway, and `--worktree prune` has nothing to reclaim.
|
|
997
|
+
if (worktreeRemoved) {
|
|
998
|
+
try {
|
|
999
|
+
execFileSync('git', ['branch', '-d', branch], { cwd: root, stdio: 'pipe' });
|
|
1000
|
+
} catch { /* fail-open */ }
|
|
1001
|
+
|
|
1002
|
+
try {
|
|
1003
|
+
execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
|
|
1004
|
+
} catch { /* fail-open */ }
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// The feature is now merged into the primary root — clear the `worktree`
|
|
1008
|
+
// bookkeeping field from its (root-side) feature.json. Fail-open: this is
|
|
1009
|
+
// metadata cleanup, never a reason to report the finish itself as failed.
|
|
1010
|
+
//
|
|
1011
|
+
// Written DIRECTLY, not via loadState/saveState: saveState re-persists EVERY
|
|
1012
|
+
// feature's feature.json, so clearing one field on THIS feature rewrites the
|
|
1013
|
+
// siblings too. Any sibling whose committed file differs only in formatting (a
|
|
1014
|
+
// trailing newline — what an editor or prettier leaves) gets rewritten into a
|
|
1015
|
+
// ` M`, dirtying the root outside this command's own pathspec, where the
|
|
1016
|
+
// post-merge commit below cannot see it. That stray modification then blocks
|
|
1017
|
+
// the NEXT feature's finish — the exact failure this command exists to avoid,
|
|
1018
|
+
// and it bites hardest in the parallel multi-worktree flow it was built for.
|
|
1019
|
+
// The thin root index carries only {status, updatedAt}, never `worktree`, so
|
|
1020
|
+
// there is nothing else to persist here.
|
|
1021
|
+
// atomicWriteJson (not a plain writeFileSync): this file is the feature's
|
|
1022
|
+
// AUTHORITATIVE, committed state. A torn write from a crash — or an EPERM
|
|
1023
|
+
// from a Windows AV/indexer holding the handle — would corrupt or fail on the
|
|
1024
|
+
// one copy that matters. tmp+rename with the EPERM retry is what the rest of
|
|
1025
|
+
// the state layer uses; bypassing the sweep must not also bypass that.
|
|
1026
|
+
try {
|
|
1027
|
+
const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
|
|
1028
|
+
if (existsSync(rootFjPath)) {
|
|
1029
|
+
const fj = JSON.parse(readFileSync(rootFjPath, 'utf8'));
|
|
1030
|
+
if (fj.worktree) {
|
|
1031
|
+
delete fj.worktree;
|
|
1032
|
+
const { atomicWriteJson } = await import('../../core/state/state-manager.js');
|
|
1033
|
+
atomicWriteJson(rootFjPath, fj);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
} catch { /* fail-open */ }
|
|
1037
|
+
|
|
1038
|
+
// Clearing that field can itself dirty the root: when `setup`'s registration
|
|
1039
|
+
// was COMMITTED, it lives in HEAD, so the shed's `git checkout` cannot undo it
|
|
1040
|
+
// and it survives the merge — then the rewrite above leaves a ` M`. Left
|
|
1041
|
+
// behind, that stray modification blocks the NEXT feature's finish (the shed
|
|
1042
|
+
// is scoped to its own feature), which is the very bug this command was fixed
|
|
1043
|
+
// to stop hitting. Committing it is in contract: `finish` already writes a
|
|
1044
|
+
// merge commit. Scoped to this feature's path — never sweeps up other work.
|
|
1045
|
+
try {
|
|
1046
|
+
const dirtyAfter = execFileSync('git', ['status', '--porcelain', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
|
|
1047
|
+
if (dirtyAfter.trim() !== '') {
|
|
1048
|
+
execFileSync('git', ['add', '--', featPathspec], { cwd: root, stdio: 'pipe' });
|
|
1049
|
+
execFileSync('git', ['commit', '-m', `morph-spec worktree finish: clear ${feature} worktree registration`, '--', featPathspec], { cwd: root, stdio: 'pipe' });
|
|
1050
|
+
}
|
|
1051
|
+
} catch { /* fail-open — a dirty root is a nuisance, not a reason to fail a landed merge */ }
|
|
1052
|
+
|
|
1053
|
+
const declineReason = ignored.blocking.length
|
|
1054
|
+
? `merge OK. O worktree NÃO foi removido: ele contém arquivo(s) gitignored que existem só ali e o \`git worktree remove\` apagaria (${ignored.blocking.slice(0, 5).join(', ')}${ignored.blocking.length > 5 ? `, +${ignored.blocking.length - 5}` : ''}). Salve o que importa e rode \`git worktree remove --force "${worktreePath}"\` + \`git branch -d ${branch}\`, ou re-execute o finish com \`--discard-ignored\` para descartá-los deliberadamente`
|
|
1055
|
+
: blockers.length
|
|
1056
|
+
? `merge OK. O worktree NÃO foi removido: ele ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que o \`git worktree remove\` seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`git worktree remove "${worktreePath}"\` + \`git branch -d ${branch}\``
|
|
1057
|
+
: ignored.shieldable.length && ignoredBackup === null
|
|
1058
|
+
? `merge OK. O worktree NÃO foi removido: o backup de ${featPathspec} (gitignored do worktree) falhou, e sem ele a remoção seria destrutiva. Rode \`git worktree remove --force "${worktreePath}"\` manualmente se não precisar desse conteúdo`
|
|
1059
|
+
: `merge OK, mas o \`git worktree remove\` falhou. Rode \`git worktree remove "${worktreePath}"\` + \`git branch -d ${branch}\` manualmente`;
|
|
1060
|
+
|
|
1061
|
+
console.log(JSON.stringify({
|
|
1062
|
+
finished: true,
|
|
1063
|
+
feature,
|
|
1064
|
+
merged: branch,
|
|
1065
|
+
worktreeRemoved,
|
|
1066
|
+
...(shedBackup ? { shedBackup } : {}),
|
|
1067
|
+
...(ignoredBackup ? { worktreeIgnoredBackup: ignoredBackup } : {}),
|
|
1068
|
+
// Only when the removal actually ran did these die — a decline for any other
|
|
1069
|
+
// reason leaves them on disk. Naming them keeps `--discard-ignored` honest:
|
|
1070
|
+
// `worktreeIgnoredBackup` alone would imply everything was preserved.
|
|
1071
|
+
...(worktreeRemoved && ignored.discarded.length
|
|
1072
|
+
? {
|
|
1073
|
+
discardedIgnored: ignored.discarded,
|
|
1074
|
+
discardedNote: `--discard-ignored: os caminhos acima eram gitignored, existiam só no worktree e foram DESTRUÍDOS sem backup (não são copiados por poderem ser arbitrariamente grandes)`,
|
|
1075
|
+
}
|
|
1076
|
+
: {}),
|
|
1077
|
+
...(worktreeRemoved
|
|
1078
|
+
? {}
|
|
1079
|
+
: {
|
|
1080
|
+
path: worktreePath,
|
|
1081
|
+
branch,
|
|
1082
|
+
...(blockers.length ? { reparsePoints: blockers } : {}),
|
|
1083
|
+
...(ignored.blocking.length ? { ignoredFiles: ignored.blocking } : {}),
|
|
1084
|
+
cleanupHint: declineReason,
|
|
1085
|
+
// The infra junctions were dropped before this decline (that step runs
|
|
1086
|
+
// unconditionally, since it is what makes a removal survivable). A kept
|
|
1087
|
+
// worktree therefore has no node_modules/.claude until they are relinked
|
|
1088
|
+
// — say so, instead of leaving the user to discover a broken worktree.
|
|
1089
|
+
relinkHint: `as junctions de infra (node_modules, .claude, .morph/framework) foram soltas antes desta recusa — se for continuar trabalhando nesse worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``,
|
|
1090
|
+
}),
|
|
1091
|
+
nextStep: `morph-spec archive ${feature}`,
|
|
1092
|
+
}));
|
|
1093
|
+
process.exit(0);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// ── list ─────────────────────────────────────────────────────────────────
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Command handler for `morph-spec worktree list [--json]`.
|
|
1100
|
+
*
|
|
1101
|
+
* Lists active `morph/{feature}` worktrees with their derived phase, status,
|
|
1102
|
+
* and any pending approval gates — visibility across multi-instance sessions.
|
|
1103
|
+
* Fail-open: outside a git repo (or on any git error), reports an empty list.
|
|
1104
|
+
*
|
|
1105
|
+
* @param {{ json?: boolean, cwd?: string }} [options]
|
|
1106
|
+
*/
|
|
1107
|
+
export async function worktreeListCommand(options = {}) {
|
|
1108
|
+
const cwd = options.cwd || process.cwd();
|
|
1109
|
+
|
|
1110
|
+
if (!isGitRepo(cwd)) {
|
|
1111
|
+
printWorktreeList([], options);
|
|
1112
|
+
process.exit(0);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const root = resolvePrimaryRoot(cwd);
|
|
1116
|
+
const entries = parseWorktreeList(root);
|
|
1117
|
+
|
|
1118
|
+
const worktrees = [];
|
|
1119
|
+
if (entries.length) {
|
|
1120
|
+
const { derivePhaseForFeature, getPendingGates, getFeature } =
|
|
1121
|
+
await import('../../../framework/hooks/shared/state-reader.js');
|
|
1122
|
+
|
|
1123
|
+
for (const entry of entries) {
|
|
1124
|
+
const m = entry.branch && entry.branch.match(/^refs\/heads\/morph\/(.+)$/);
|
|
1125
|
+
if (!m) continue; // skip the primary root entry and any non-morph worktree
|
|
1126
|
+
const feature = m[1];
|
|
1127
|
+
|
|
1128
|
+
let phase = 'proposal';
|
|
1129
|
+
let status = 'unknown';
|
|
1130
|
+
let pendingGates = [];
|
|
1131
|
+
try {
|
|
1132
|
+
const featureObj = getFeature(feature, entry.path);
|
|
1133
|
+
status = featureObj?.status || 'unknown';
|
|
1134
|
+
phase = derivePhaseForFeature(feature, entry.path);
|
|
1135
|
+
pendingGates = getPendingGates(feature, entry.path);
|
|
1136
|
+
} catch { /* leave defaults — a corrupt feature.json must never crash list */ }
|
|
1137
|
+
|
|
1138
|
+
worktrees.push({
|
|
1139
|
+
feature,
|
|
1140
|
+
branch: entry.branch.replace(/^refs\/heads\//, ''),
|
|
1141
|
+
phase,
|
|
1142
|
+
status,
|
|
1143
|
+
pendingGates,
|
|
1144
|
+
path: entry.path,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
printWorktreeList(worktrees, options);
|
|
1150
|
+
process.exit(0);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** Render `worktree list` output — JSON or a human table (chalk). */
|
|
1154
|
+
function printWorktreeList(worktrees, options) {
|
|
1155
|
+
if (options.json) {
|
|
1156
|
+
console.log(JSON.stringify({ worktrees }));
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (worktrees.length === 0) {
|
|
1161
|
+
console.log(chalk.gray('No active feature worktrees.'));
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const col = (s, w) => String(s).padEnd(w);
|
|
1166
|
+
console.log(chalk.white.bold(col('FEATURE', 22) + col('BRANCH', 26) + col('PHASE', 12) + col('STATUS', 14) + col('PENDING GATES', 24) + 'PATH'));
|
|
1167
|
+
for (const w of worktrees) {
|
|
1168
|
+
const gates = w.pendingGates.length ? w.pendingGates.join(',') : '-';
|
|
1169
|
+
console.log(col(w.feature, 22) + col(w.branch, 26) + col(w.phase, 12) + col(w.status, 14) + col(gates, 24) + w.path);
|
|
1170
|
+
}
|
|
212
1171
|
}
|