@polymorphism-tech/morph-spec 8.29.0 → 8.32.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 +11 -1
- package/README.md +13 -2
- package/bin/morph-spec.js +67 -1
- package/bin/validate.js +379 -379
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +4 -1
- package/framework/MORPH.md +4 -3
- package/framework/agents.json +6 -6
- package/framework/commands/morph-status.md +1 -1
- package/framework/docs/CLI.md +68 -45
- package/framework/evals/rubrics/code-quality.md +17 -0
- package/framework/evals/rubrics/condensed.md +3 -1
- 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/phase-utils.js +2 -0
- package/framework/hooks/shared/stdin-reader.js +26 -26
- package/framework/mcp/registry.json +39 -4
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/schemas/tasks.schema.json +25 -0
- 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 +14 -4
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +12 -2
- package/framework/skills/core/morph-plan/SKILL.md +45 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +12 -4
- package/framework/skills/core/morph-stack-scan/SKILL.md +13 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/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 +231 -231
- 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/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/context/ARCHITECTURE.md +20 -0
- 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/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/graph/affected.js +89 -0
- package/src/commands/graph/build.js +94 -0
- package/src/commands/graph/explain.js +96 -0
- package/src/commands/graph/status.js +93 -0
- package/src/commands/index.js +13 -13
- package/src/commands/project/branch.js +170 -0
- package/src/commands/project/checkpoint.js +209 -209
- package/src/commands/project/doctor.js +148 -9
- package/src/commands/project/fleet.js +10 -3
- package/src/commands/project/worktree.js +73 -50
- package/src/commands/state/finish.js +41 -26
- package/src/commands/state/list.js +79 -6
- package/src/core/paths/output-schema.js +7 -0
- package/src/core/state/lifecycle-audit.js +127 -0
- package/src/core/state/phase-state-machine.js +197 -197
- package/src/core/templates/template-renderer.js +477 -477
- 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/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/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/vite-plugin-morph-data.js +17 -17
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/detectors/design-system-detector.js +188 -188
- package/src/lib/detectors/index.js +217 -217
- package/src/lib/generators/architecture-scanner.js +45 -4
- package/src/lib/generators/design-system-generator.js +508 -508
- package/src/lib/generators/settings-generator.js +209 -209
- package/src/lib/git/branch-classifier.js +177 -0
- package/src/lib/git/gh.js +30 -0
- package/src/lib/git/pending-prs.js +89 -0
- package/src/lib/git/pr-index.js +70 -0
- package/src/lib/graph/god-nodes.js +75 -0
- package/src/lib/graph/graph-health.js +61 -0
- package/src/lib/graph/graph-loader.js +86 -0
- package/src/lib/graph/graphify-runtime.js +100 -0
- package/src/lib/graph/graphifyignore.js +86 -0
- package/src/lib/graph/sanitize.js +280 -0
- package/src/lib/hooks/hook-executor.js +167 -167
- package/src/lib/installers/mcp-installer.js +719 -719
- package/src/lib/tasks/task-classifier.js +30 -30
- package/src/lib/tasks/task-parser.js +209 -209
- package/src/lib/tasks/tasks-json-parser.js +26 -0
- package/src/lib/validators/checkpoint-persistence.js +39 -39
- package/src/lib/validators/css/css-validator.js +340 -340
- package/src/lib/validators/reuse/index.js +10 -0
- package/src/lib/validators/reuse/reuse-validator.js +324 -0
- package/src/lib/validators/reuse/symbol-scanner.js +144 -0
- 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 +40 -1
- package/src/lib/worktree/fleet-scanner.js +51 -21
- package/src/lib/worktree/ignored-triage.js +197 -0
- package/src/lib/worktree/worktree-audit.js +200 -35
- package/src/scripts/setup-infra.js +60 -1
- package/src/utils/agent-id-mapper.js +11 -11
- package/src/utils/claude-settings-manager.js +300 -300
- package/src/utils/file-copier.js +40 -0
- package/src/utils/skills-installer.js +187 -187
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
pendingGatesFrom,
|
|
29
29
|
} from '../../../framework/hooks/shared/state-reader.js';
|
|
30
30
|
import { listClaims } from '../../../framework/hooks/shared/claim-registry.js';
|
|
31
|
+
import { buildPrIndex } from '../git/pr-index.js';
|
|
32
|
+
import { pendingPrFeatures } from '../git/pending-prs.js';
|
|
31
33
|
|
|
32
34
|
function countFrom(raw) {
|
|
33
35
|
const data = JSON.parse(raw);
|
|
@@ -133,32 +135,18 @@ function remoteHeads(root) {
|
|
|
133
135
|
/**
|
|
134
136
|
* One `gh pr list` for the whole scan → Map<headRefName, {number, state}>.
|
|
135
137
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
138
|
+
* Delegates to the shared `buildPrIndex` so `fleet`, `branch prune` and
|
|
139
|
+
* `doctor --worktrees` cannot answer "does this branch have a PR" differently
|
|
140
|
+
* — the destructive two read it to decide what NOT to delete.
|
|
141
|
+
*
|
|
142
|
+
* Fail-open: `null` (no `gh`, not authenticated, not GitHub, timeout) just
|
|
143
|
+
* leaves the PR half of the column empty while `pushed` still works.
|
|
141
144
|
*
|
|
142
145
|
* @param {string} root
|
|
143
146
|
* @returns {Map<string,{number:number,state:string}>|null}
|
|
144
147
|
*/
|
|
145
148
|
function remotePrs(root) {
|
|
146
|
-
|
|
147
|
-
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: root, stdio: 'pipe' }).toString();
|
|
148
|
-
if (!/github\.com/i.test(url)) return null;
|
|
149
|
-
const out = execFileSync('gh', ['pr', 'list', '--state', 'all', '--limit', '100', '--json', 'number,state,headRefName'], {
|
|
150
|
-
cwd: root, stdio: 'pipe', timeout: 8000,
|
|
151
|
-
}).toString();
|
|
152
|
-
const map = new Map();
|
|
153
|
-
for (const pr of JSON.parse(out)) {
|
|
154
|
-
// First wins: `gh` lists newest-first, so an OPEN PR shadows an older
|
|
155
|
-
// closed one on the same branch.
|
|
156
|
-
if (pr?.headRefName && !map.has(pr.headRefName)) map.set(pr.headRefName, { number: pr.number, state: pr.state });
|
|
157
|
-
}
|
|
158
|
-
return map;
|
|
159
|
-
} catch {
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
149
|
+
return buildPrIndex(root, { limit: 100 });
|
|
162
150
|
}
|
|
163
151
|
|
|
164
152
|
/**
|
|
@@ -297,5 +285,47 @@ export function scanFleet(cwd = process.cwd()) {
|
|
|
297
285
|
}
|
|
298
286
|
} catch { /* fail-open */ }
|
|
299
287
|
|
|
288
|
+
// Features that exist ONLY as an open PR: no registered worktree, no
|
|
289
|
+
// `.morph/features/*` folder in this tree. This is exactly what
|
|
290
|
+
// `finish --pr` leaves behind — and without this pass `fleet` would say
|
|
291
|
+
// "nothing in flight" about work that is under review on GitHub.
|
|
292
|
+
//
|
|
293
|
+
// `prs` was attempted above whenever `heads` is truthy (`remotePrs` runs —
|
|
294
|
+
// see `const prs = heads ? remotePrs(root) : null;` — regardless of
|
|
295
|
+
// whether that `gh` call itself succeeded). Gate on the SAME condition
|
|
296
|
+
// `remotePrs` used, not on `prs !== null`: `remotePrs`/`buildPrIndex` can
|
|
297
|
+
// fail (timeout, not authenticated, malformed JSON) and legitimately
|
|
298
|
+
// return `null` even after really invoking `gh`. Passing that `null`
|
|
299
|
+
// through lets `pendingPrFeatures` short-circuit on its own documented
|
|
300
|
+
// contract (explicit `null` index = known unknown, never a fallback build)
|
|
301
|
+
// instead of retrying `gh` a second time in the same scan. Only when
|
|
302
|
+
// `remotePrs` was never attempted at all (`heads` falsy) does
|
|
303
|
+
// `pendingPrFeatures` get no index, so it makes the first (and only) `gh`
|
|
304
|
+
// call itself.
|
|
305
|
+
try {
|
|
306
|
+
const seen = new Set(entries.map((e) => e.feature));
|
|
307
|
+
const pendOpts = heads ? { index: prs } : {};
|
|
308
|
+
for (const p of pendingPrFeatures(root, pendOpts) || []) {
|
|
309
|
+
if (seen.has(p.feature)) continue;
|
|
310
|
+
entries.push({
|
|
311
|
+
feature: p.feature,
|
|
312
|
+
workType: null,
|
|
313
|
+
phase: 'aguardando-merge',
|
|
314
|
+
status: 'in_progress',
|
|
315
|
+
pendingGates: [],
|
|
316
|
+
location: 'pr-pending',
|
|
317
|
+
path: null,
|
|
318
|
+
branch: p.branch,
|
|
319
|
+
tasks: null,
|
|
320
|
+
env: null,
|
|
321
|
+
handoff: { exists: false, generatedAt: null },
|
|
322
|
+
lastCommit: null,
|
|
323
|
+
dirty: null,
|
|
324
|
+
remote: { branch: p.branch, pushed: true, sha: null, pr: { number: p.number, state: p.state } },
|
|
325
|
+
claim: null,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
} catch { /* fail-open — a pendency not listed must never take down the scan */ }
|
|
329
|
+
|
|
300
330
|
return { root, entries };
|
|
301
331
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ignored-triage.js — decide, per gitignored leftover, whether removing a
|
|
3
|
+
* worktree would DESTROY something or merely delete a copy.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS: `git worktree remove` deletes gitignored files outright and
|
|
6
|
+
* the clean-check that gates it (`git status --porcelain`) omits them by
|
|
7
|
+
* design, so a user's `.env` dies silently while the command reports success.
|
|
8
|
+
* The first fix for that refused the removal whenever ANY unrecognised
|
|
9
|
+
* gitignored path was present — correct, but blind: in the observed session a
|
|
10
|
+
* worktree carried `.env` and `.env.e2e` that were BYTE-IDENTICAL to the
|
|
11
|
+
* primary root's, alongside a `keys/key-*.xml` that existed nowhere else. The
|
|
12
|
+
* refusal treated them alike, and the only escape it offered
|
|
13
|
+
* (`--discard-ignored`) destroyed the unique one together with the replicas.
|
|
14
|
+
*
|
|
15
|
+
* The triage below replaces "recognised vs not" with the question that actually
|
|
16
|
+
* decides whether bytes are lost:
|
|
17
|
+
*
|
|
18
|
+
* REPLICABLE — the primary root has the same relative path with identical
|
|
19
|
+
* content. Deleting the worktree's copy loses nothing; the
|
|
20
|
+
* original stays where it always was.
|
|
21
|
+
* DISPOSABLE — a well-known build artifact (`bin/`, `obj/`, `node_modules/`,
|
|
22
|
+
* `__pycache__/`, `test-results/`, `.auth/`, `*.tsbuildinfo`).
|
|
23
|
+
* Reproducible from source, which is a STRONGER guarantee than
|
|
24
|
+
* backed up.
|
|
25
|
+
* EXCLUSIVE — everything else: no counterpart in the root, or a counterpart
|
|
26
|
+
* whose content differs. These are the only bytes at stake, and
|
|
27
|
+
* the caller copies them to `finish-backup` before proceeding.
|
|
28
|
+
*
|
|
29
|
+
* Fail-CLOSED on every uncertainty: an unreadable file, a hash that cannot be
|
|
30
|
+
* computed, a directory too large to compare, a counterpart that is a link
|
|
31
|
+
* rather than a real file — all classify as EXCLUSIVE. Being wrong in that
|
|
32
|
+
* direction costs a backup copy; being wrong the other way costs the file.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createHash } from 'crypto';
|
|
36
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from 'fs';
|
|
37
|
+
import { join } from 'path';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Well-known, regenerable build artifacts. Deliberately narrow — do NOT add
|
|
41
|
+
* speculative patterns (`dist/`, `.next/`) that a project might legitimately
|
|
42
|
+
* hand-edit; anything unlisted still reaches the EXCLUSIVE bucket, where it is
|
|
43
|
+
* backed up rather than assumed disposable.
|
|
44
|
+
*
|
|
45
|
+
* `.auth/` (Playwright storage state) belongs here for a reason worth stating:
|
|
46
|
+
* it holds a captured session, so it LOOKS like precious local state — but it
|
|
47
|
+
* is regenerated by the auth setup project on the next run, and it expires on
|
|
48
|
+
* its own. Keeping it out of the backup avoids copying credentials around.
|
|
49
|
+
*/
|
|
50
|
+
const DISPOSABLE_DIR_NAMES = new Set([
|
|
51
|
+
'bin', 'obj', 'node_modules', '__pycache__', 'test-results', '.auth',
|
|
52
|
+
]);
|
|
53
|
+
const DISPOSABLE_FILE_RE = /\.tsbuildinfo$/;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Comparing a directory recursively is only worth doing while it stays small:
|
|
57
|
+
* the point is to spare `.env`-sized replicas from a needless refusal, not to
|
|
58
|
+
* checksum a data set. Past either cap the answer becomes EXCLUSIVE (the safe
|
|
59
|
+
* direction), and the caller backs it up.
|
|
60
|
+
*/
|
|
61
|
+
const REPLICABLE_MAX_FILES = 500;
|
|
62
|
+
const REPLICABLE_MAX_BYTES = 8 * 1024 * 1024;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} p - forward-slash relative path
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
export function isDisposableBuildArtifact(p) {
|
|
69
|
+
if (DISPOSABLE_FILE_RE.test(p)) return true;
|
|
70
|
+
return p.split('/').filter(Boolean).some((seg) => DISPOSABLE_DIR_NAMES.has(seg));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sha256OfFile(path) {
|
|
74
|
+
try {
|
|
75
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Real file (not a link, not a directory)? A reparse point must never be read
|
|
83
|
+
* THROUGH — following one is the exact failure mode the whole teardown path
|
|
84
|
+
* exists to prevent — so a link on either side makes the pair incomparable.
|
|
85
|
+
*/
|
|
86
|
+
function isRealFile(path) {
|
|
87
|
+
try {
|
|
88
|
+
return lstatSync(path).isFile();
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isRealDir(path) {
|
|
95
|
+
try {
|
|
96
|
+
return lstatSync(path).isDirectory();
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Every real file under `dir`, relative to it. Returns null when the walk hits
|
|
104
|
+
* a link, an unreadable entry, or either cap — "cannot enumerate" and "too big
|
|
105
|
+
* to compare" both mean "do not claim this is replicable".
|
|
106
|
+
*/
|
|
107
|
+
function walkFiles(dir, budget) {
|
|
108
|
+
const out = [];
|
|
109
|
+
const stack = [''];
|
|
110
|
+
while (stack.length) {
|
|
111
|
+
const rel = stack.pop();
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = readdirSync(join(dir, rel), { withFileTypes: true });
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
for (const e of entries) {
|
|
119
|
+
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
120
|
+
const childAbs = join(dir, childRel);
|
|
121
|
+
if (e.isSymbolicLink()) return null; // never compare through a link
|
|
122
|
+
if (e.isDirectory()) { stack.push(childRel); continue; }
|
|
123
|
+
if (!e.isFile()) return null; // socket/fifo/unknown — incomparable
|
|
124
|
+
let size;
|
|
125
|
+
try { size = lstatSync(childAbs).size; } catch { return null; }
|
|
126
|
+
budget.files += 1;
|
|
127
|
+
budget.bytes += size;
|
|
128
|
+
if (budget.files > REPLICABLE_MAX_FILES || budget.bytes > REPLICABLE_MAX_BYTES) return null;
|
|
129
|
+
out.push(childRel);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Does `root/rel` hold exactly the same content as `worktree/rel`?
|
|
137
|
+
*
|
|
138
|
+
* Files compare by SHA-256; directories compare by having the SAME set of
|
|
139
|
+
* files, each with an identical hash — a root copy that is merely a SUPERSET
|
|
140
|
+
* is not a replica of the worktree's, because the worktree's own extra files
|
|
141
|
+
* would still be lost.
|
|
142
|
+
*
|
|
143
|
+
* @returns {boolean} false on ANY uncertainty
|
|
144
|
+
*/
|
|
145
|
+
export function isReplicatedInRoot(worktreePath, rootPath, rel) {
|
|
146
|
+
const src = join(worktreePath, rel);
|
|
147
|
+
const dst = join(rootPath, rel);
|
|
148
|
+
if (!existsSync(src) || !existsSync(dst)) return false;
|
|
149
|
+
|
|
150
|
+
if (isRealFile(src)) {
|
|
151
|
+
if (!isRealFile(dst)) return false;
|
|
152
|
+
const a = sha256OfFile(src);
|
|
153
|
+
const b = sha256OfFile(dst);
|
|
154
|
+
return a !== null && a === b;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (isRealDir(src)) {
|
|
158
|
+
if (!isRealDir(dst)) return false;
|
|
159
|
+
const srcFiles = walkFiles(src, { files: 0, bytes: 0 });
|
|
160
|
+
if (srcFiles === null) return false;
|
|
161
|
+
const dstFiles = walkFiles(dst, { files: 0, bytes: 0 });
|
|
162
|
+
if (dstFiles === null) return false;
|
|
163
|
+
if (srcFiles.length !== dstFiles.length) return false;
|
|
164
|
+
const dstSet = new Set(dstFiles);
|
|
165
|
+
for (const f of srcFiles) {
|
|
166
|
+
if (!dstSet.has(f)) return false;
|
|
167
|
+
const a = sha256OfFile(join(src, f));
|
|
168
|
+
const b = sha256OfFile(join(dst, f));
|
|
169
|
+
if (a === null || a !== b) return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return false; // a link, or something that is neither — incomparable
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Split gitignored worktree paths into the three buckets above.
|
|
179
|
+
*
|
|
180
|
+
* ORDER IS DELIBERATE: disposable is checked FIRST, so a `bin/` that happens to
|
|
181
|
+
* be byte-identical to the root's is discarded rather than hashed, and a
|
|
182
|
+
* `node_modules` never enters the comparison budget at all.
|
|
183
|
+
*
|
|
184
|
+
* @param {{ worktreePath: string, rootPath: string, paths: string[] }} args
|
|
185
|
+
* @returns {{ replicable: string[], disposable: string[], exclusive: string[] }}
|
|
186
|
+
*/
|
|
187
|
+
export function triageIgnoredPaths({ worktreePath, rootPath, paths }) {
|
|
188
|
+
const replicable = [];
|
|
189
|
+
const disposable = [];
|
|
190
|
+
const exclusive = [];
|
|
191
|
+
for (const rel of paths) {
|
|
192
|
+
if (isDisposableBuildArtifact(rel)) { disposable.push(rel); continue; }
|
|
193
|
+
if (isReplicatedInRoot(worktreePath, rootPath, rel)) { replicable.push(rel); continue; }
|
|
194
|
+
exclusive.push(rel);
|
|
195
|
+
}
|
|
196
|
+
return { replicable, disposable, exclusive };
|
|
197
|
+
}
|
|
@@ -15,14 +15,16 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Read-only and fail-open: anything unresolvable yields no issue rather than a
|
|
17
17
|
* throw. A diagnostic that invents problems is worse than one that misses some.
|
|
18
|
-
* The
|
|
19
|
-
* possible: `git worktree prune` drops ADMIN RECORDS for directories
|
|
20
|
-
* already gone
|
|
21
|
-
*
|
|
18
|
+
* The mutations all live in `pruneWorktrees`, and each is deliberately the
|
|
19
|
+
* safest possible: `git worktree prune` drops ADMIN RECORDS for directories
|
|
20
|
+
* that are already gone, and the empty-residue sweep removes only directories
|
|
21
|
+
* with zero entries inside — nothing that can hold work. Deleting a directory
|
|
22
|
+
* that still HOLDS something stays a human decision, the same PROVE-or-DECLINE
|
|
23
|
+
* discipline `teardownWorktree` follows.
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
import { execFileSync } from 'child_process';
|
|
25
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
27
|
+
import { existsSync, readdirSync, readFileSync, rmdirSync, statSync } from 'fs';
|
|
26
28
|
import { join, resolve } from 'path';
|
|
27
29
|
import {
|
|
28
30
|
isGitRepo,
|
|
@@ -31,6 +33,9 @@ import {
|
|
|
31
33
|
WORKTREE_DIR_NAMES,
|
|
32
34
|
clearDanglingWorktreeRegistration,
|
|
33
35
|
} from '../../commands/project/worktree.js';
|
|
36
|
+
import { detectDefaultBranch } from '../git/default-branch.js';
|
|
37
|
+
import { buildPrIndex } from '../git/pr-index.js';
|
|
38
|
+
import { classifyBranch, commitsAhead, resolveBaseRef } from '../git/branch-classifier.js';
|
|
34
39
|
|
|
35
40
|
const norm = (p) => resolve(p).replace(/\\/g, '/');
|
|
36
41
|
|
|
@@ -66,6 +71,54 @@ function featureCommittedOnBranch(root, feature) {
|
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Does this worktree's branch still hold work that is NOT in the default
|
|
76
|
+
* branch, or an OPEN PR?
|
|
77
|
+
*
|
|
78
|
+
* This is the predicate that separates "an orphan directory to clean up" from
|
|
79
|
+
* "the only copy of somebody's work". Two independent pieces of evidence, and
|
|
80
|
+
* ANY of them is enough to protect the worktree:
|
|
81
|
+
*
|
|
82
|
+
* 1. commits the default branch does not have (`git rev-list base..branch`)
|
|
83
|
+
* 2. an OPEN pull request on that branch
|
|
84
|
+
*
|
|
85
|
+
* Fail-CLOSED, and that asymmetry is the whole design: whenever the answer
|
|
86
|
+
* cannot be established — no default branch, no origin, git errored — the
|
|
87
|
+
* worktree counts as UNINTEGRATED. The cost of being wrong that way is a
|
|
88
|
+
* diagnostic that stays quiet; the cost of the opposite is a suggestion to
|
|
89
|
+
* delete work that exists nowhere else.
|
|
90
|
+
*
|
|
91
|
+
* A squash-merged branch legitimately reports commits ahead, so the tree
|
|
92
|
+
* comparison (`branch-classifier.js`) is consulted for the same reason
|
|
93
|
+
* `branch prune` needs it — the two must not disagree about "is this in".
|
|
94
|
+
*
|
|
95
|
+
* @returns {{ unintegrated: boolean, evidence: string, pr: number|null }}
|
|
96
|
+
*/
|
|
97
|
+
function unintegratedWork(root, branchRef, prIndex) {
|
|
98
|
+
const branch = (branchRef || '').replace(/^refs\/heads\//, '');
|
|
99
|
+
if (!branch) return { unintegrated: true, evidence: 'não foi possível resolver a branch do worktree', pr: null };
|
|
100
|
+
|
|
101
|
+
const pr = prIndex?.get(branch) ?? null;
|
|
102
|
+
if (pr?.state === 'OPEN') return { unintegrated: true, evidence: `PR #${pr.number} OPEN`, pr: pr.number };
|
|
103
|
+
|
|
104
|
+
const defaultBranch = detectDefaultBranch(root);
|
|
105
|
+
const baseRef = resolveBaseRef(defaultBranch, root);
|
|
106
|
+
if (!baseRef) return { unintegrated: true, evidence: 'sem branch default resolvível para comparar', pr: null };
|
|
107
|
+
|
|
108
|
+
const ahead = commitsAhead(baseRef, branch, root);
|
|
109
|
+
if (ahead === null) return { unintegrated: true, evidence: `não foi possível comparar com ${baseRef}`, pr: null };
|
|
110
|
+
if (ahead === 0) return { unintegrated: false, evidence: `nenhum commit fora de ${baseRef}`, pr: pr?.number ?? null };
|
|
111
|
+
|
|
112
|
+
// Commits ahead, but the work may still be IN: a squash-merge leaves exactly
|
|
113
|
+
// that shape. Reuse the classifier so `doctor` and `branch prune` cannot
|
|
114
|
+
// reach opposite conclusions about the same branch.
|
|
115
|
+
const verdict = classifyBranch({ name: branch, sha: '', current: false, worktreePath: null }, { baseRef, cwd: root, pr });
|
|
116
|
+
if (verdict.kind === 'merged-ancestral' || verdict.kind === 'merged-squash') {
|
|
117
|
+
return { unintegrated: false, evidence: verdict.reason, pr: pr?.number ?? null };
|
|
118
|
+
}
|
|
119
|
+
return { unintegrated: true, evidence: `${ahead} commit(s) fora de ${baseRef}`, pr: pr?.number ?? null };
|
|
120
|
+
}
|
|
121
|
+
|
|
69
122
|
/**
|
|
70
123
|
* Every feature's `worktree` registration in the primary root — the OTHER
|
|
71
124
|
* direction from `knownFeatures`/`dirsOnDisk`: not "does git/disk know about
|
|
@@ -96,6 +149,22 @@ function featureRegistrations(root) {
|
|
|
96
149
|
return out;
|
|
97
150
|
}
|
|
98
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Is this directory literally empty — zero entries, dotfiles included?
|
|
154
|
+
*
|
|
155
|
+
* The whole point of the distinction: an empty residue holds nothing that can
|
|
156
|
+
* be lost, so it can be removed without a human looking at it. Anything else —
|
|
157
|
+
* including a directory we cannot read — is reported as NON-empty, because the
|
|
158
|
+
* safe answer to "might there be work in here?" is yes.
|
|
159
|
+
*/
|
|
160
|
+
function isEmptyDir(path) {
|
|
161
|
+
try {
|
|
162
|
+
return readdirSync(path).length === 0;
|
|
163
|
+
} catch {
|
|
164
|
+
return false; // unreadable → treat as occupied
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
99
168
|
/** Directories physically present under either worktree root. */
|
|
100
169
|
function dirsOnDisk(root) {
|
|
101
170
|
const found = [];
|
|
@@ -106,7 +175,9 @@ function dirsOnDisk(root) {
|
|
|
106
175
|
for (const name of readdirSync(base)) {
|
|
107
176
|
const full = join(base, name);
|
|
108
177
|
try {
|
|
109
|
-
if (statSync(full).isDirectory())
|
|
178
|
+
if (statSync(full).isDirectory()) {
|
|
179
|
+
found.push({ name, path: norm(full), container: dirName, empty: isEmptyDir(full) });
|
|
180
|
+
}
|
|
110
181
|
} catch { /* unreadable entry — skip */ }
|
|
111
182
|
}
|
|
112
183
|
} catch { /* fail-open */ }
|
|
@@ -125,7 +196,13 @@ function dirsOnDisk(root) {
|
|
|
125
196
|
* • unregistered-dir — a directory under worktrees/ git never
|
|
126
197
|
* registered
|
|
127
198
|
* • no-feature — a `morph/{f}` worktree for a feature morph
|
|
128
|
-
* does not know
|
|
199
|
+
* does not know AND whose branch carries
|
|
200
|
+
* nothing that is not already integrated
|
|
201
|
+
* • unintegrated-work (info) — the same shape, except the branch still holds
|
|
202
|
+
* commits the default does not, or an OPEN PR.
|
|
203
|
+
* Reported so the reader knows why no removal
|
|
204
|
+
* is suggested — this worktree may be the ONLY
|
|
205
|
+
* copy of the work
|
|
129
206
|
* • dangling-registration — a feature's ROOT feature.json still points
|
|
130
207
|
* at a `worktree.path` that no longer exists
|
|
131
208
|
* (the symmetric OTHER direction from
|
|
@@ -144,6 +221,11 @@ export function auditWorktrees(cwd = process.cwd()) {
|
|
|
144
221
|
const rootNorm = norm(root);
|
|
145
222
|
const issues = [];
|
|
146
223
|
|
|
224
|
+
// ONE `gh pr list` for the whole audit — null means UNKNOWN (no gh, not
|
|
225
|
+
// GitHub, not authenticated), never "no PRs"; `unintegratedWork` treats the
|
|
226
|
+
// two differently on purpose.
|
|
227
|
+
const prIndex = buildPrIndex(root);
|
|
228
|
+
|
|
147
229
|
const registered = parseWorktreeList(root);
|
|
148
230
|
const registeredPaths = new Set();
|
|
149
231
|
const features = knownFeatures(root);
|
|
@@ -169,27 +251,65 @@ export function auditWorktrees(cwd = process.cwd()) {
|
|
|
169
251
|
// a worktree the user keeps elsewhere is none of morph's business.
|
|
170
252
|
const inContainer = WORKTREE_DIR_NAMES.some((d) => path.startsWith(`${rootNorm}/${d}/`));
|
|
171
253
|
if (inContainer) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
254
|
+
// Same hazard as `no-feature` below, and it needs the same guard:
|
|
255
|
+
// "invisible to fleet" says nothing about whether the WORK is safe
|
|
256
|
+
// elsewhere. A worktree on a `feat/*` branch with an open PR is the
|
|
257
|
+
// most normal thing in the world, and suggesting its removal is a
|
|
258
|
+
// suggestion to destroy the only copy.
|
|
259
|
+
const work = unintegratedWork(root, entry.branch, prIndex);
|
|
260
|
+
issues.push(work.unintegrated
|
|
261
|
+
? {
|
|
262
|
+
type: 'unintegrated-work',
|
|
263
|
+
severity: 'info',
|
|
264
|
+
path,
|
|
265
|
+
detail: `${path} está em ${entry.branch || 'HEAD destacado'} (o \`fleet\` não o enxerga) e carrega trabalho NÃO integrado (${work.evidence})`,
|
|
266
|
+
fix: work.pr
|
|
267
|
+
? `nada a fazer enquanto o PR #${work.pr} estiver aberto`
|
|
268
|
+
: `integre a branch antes de remover o worktree — ele é o único exemplar`,
|
|
269
|
+
}
|
|
270
|
+
: {
|
|
271
|
+
type: 'non-morph-branch',
|
|
272
|
+
path,
|
|
273
|
+
detail: `${path} está em ${entry.branch || 'HEAD destacado'}, não numa branch morph/* — o \`fleet\` não o enxerga (e não há trabalho pendente: ${work.evidence})`,
|
|
274
|
+
// NEVER raw `git worktree remove` here: it follows the infra junctions
|
|
275
|
+
// out and empties the primary root's .claude/ and .morph/framework/.
|
|
276
|
+
// `worktree remove` drops them first (PROVE-or-DECLINE) and takes
|
|
277
|
+
// --target precisely for worktrees like this one, with no feature.
|
|
278
|
+
fix: `morph-spec worktree remove --target "${path}" (se já não serve)`,
|
|
279
|
+
});
|
|
182
280
|
}
|
|
183
281
|
continue;
|
|
184
282
|
}
|
|
185
283
|
|
|
186
284
|
if (!features.has(feature) && !featureCommittedOnBranch(root, feature)) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
285
|
+
// "sem feature conhecida" is a bookkeeping fact, NOT a verdict on the
|
|
286
|
+
// work: a `finish` that archived the feature leaves exactly this shape,
|
|
287
|
+
// and so does a worktree whose PR is still open. In the observed session
|
|
288
|
+
// this branch recommended `worktree remove --target` for a worktree
|
|
289
|
+
// holding the ONLY copy of an open PR with 12 tasks and a 9.5 at Gate 3.
|
|
290
|
+
// So the diagnosis now depends on whether the WORK is integrated, and the
|
|
291
|
+
// suggestion to destroy appears only when it provably is.
|
|
292
|
+
const work = unintegratedWork(root, entry.branch, prIndex);
|
|
293
|
+
if (work.unintegrated) {
|
|
294
|
+
issues.push({
|
|
295
|
+
type: 'unintegrated-work',
|
|
296
|
+
severity: 'info',
|
|
297
|
+
path,
|
|
298
|
+
feature,
|
|
299
|
+
detail: `worktree com trabalho NÃO integrado (${work.evidence}) — a feature foi arquivada, o trabalho não`,
|
|
300
|
+
fix: work.pr
|
|
301
|
+
? `nada a fazer enquanto o PR #${work.pr} estiver aberto; depois de mergeado, \`morph-spec worktree remove --target "${path}"\``
|
|
302
|
+
: `integre a branch (\`morph-spec finish\` ou um PR) antes de remover o worktree — ele é o único exemplar`,
|
|
303
|
+
});
|
|
304
|
+
} else {
|
|
305
|
+
issues.push({
|
|
306
|
+
type: 'no-feature',
|
|
307
|
+
path,
|
|
308
|
+
feature,
|
|
309
|
+
detail: `worktree de "${feature}" sem feature conhecida (nem em .morph/features/, nem na branch) e sem trabalho pendente (${work.evidence})`,
|
|
310
|
+
fix: `morph-spec worktree remove --target "${path}"`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
193
313
|
}
|
|
194
314
|
}
|
|
195
315
|
|
|
@@ -198,8 +318,16 @@ export function auditWorktrees(cwd = process.cwd()) {
|
|
|
198
318
|
issues.push({
|
|
199
319
|
type: 'unregistered-dir',
|
|
200
320
|
path: d.path,
|
|
201
|
-
|
|
202
|
-
|
|
321
|
+
empty: d.empty,
|
|
322
|
+
detail: d.empty
|
|
323
|
+
? `${d.container}/${d.name} existe no disco, está VAZIO e o git não o registra — resíduo de um finish anterior`
|
|
324
|
+
: `${d.container}/${d.name} existe no disco mas o git não o registra — resíduo de um finish anterior`,
|
|
325
|
+
// An empty folder has nothing to lose, so it gets the automatic repair.
|
|
326
|
+
// One with content keeps the manual chore: there may be work in there,
|
|
327
|
+
// and the whole PROVE-or-DECLINE discipline exists for exactly that.
|
|
328
|
+
fix: d.empty
|
|
329
|
+
? `morph-spec doctor --worktrees --prune`
|
|
330
|
+
: `apague o diretório à mão depois de conferir que nada importante ficou nele`,
|
|
203
331
|
});
|
|
204
332
|
}
|
|
205
333
|
|
|
@@ -232,24 +360,59 @@ export function auditWorktrees(cwd = process.cwd()) {
|
|
|
232
360
|
}
|
|
233
361
|
|
|
234
362
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
363
|
+
* Remove the EMPTY directories under `worktrees/`/`.worktrees/` that git does
|
|
364
|
+
* not register — the residue a finish leaves behind.
|
|
365
|
+
*
|
|
366
|
+
* "Empty" is the whole licence to act: a folder with zero entries cannot be
|
|
367
|
+
* hiding uncommitted work, so asking a human about it is pure ceremony, and
|
|
368
|
+
* the audit that only warns leaves it there forever. A directory with anything
|
|
369
|
+
* in it keeps the manual path.
|
|
370
|
+
*
|
|
371
|
+
* Two guards beyond the emptiness check:
|
|
372
|
+
* • only directories directly inside morph's own worktree containers, never
|
|
373
|
+
* the containers themselves and never a path git registers;
|
|
374
|
+
* • `rmdirSync`, not `rm -rf` — it REFUSES a non-empty directory, so a file
|
|
375
|
+
* that appears between the check and the call is a failure, not a loss.
|
|
376
|
+
*
|
|
377
|
+
* @param {string} root
|
|
378
|
+
* @returns {string[]} paths actually removed
|
|
379
|
+
*/
|
|
380
|
+
function removeEmptyUnregisteredDirs(root) {
|
|
381
|
+
const registeredPaths = new Set(parseWorktreeList(root).map((e) => norm(e.path)));
|
|
382
|
+
const removed = [];
|
|
383
|
+
for (const d of dirsOnDisk(root)) {
|
|
384
|
+
if (!d.empty || registeredPaths.has(d.path)) continue;
|
|
385
|
+
try {
|
|
386
|
+
rmdirSync(d.path);
|
|
387
|
+
removed.push(d.path);
|
|
388
|
+
} catch { /* raced, locked or no longer empty — leave it to the next audit */ }
|
|
389
|
+
}
|
|
390
|
+
return removed;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Drop git's admin records for worktrees whose directory is already gone,
|
|
395
|
+
* remove the EMPTY unregistered residue directories, AND clear any feature's
|
|
396
|
+
* `worktree` registration that names a directory that is now gone.
|
|
397
|
+
*
|
|
398
|
+
* These three are the ONLY prune offered, on purpose: none can destroy
|
|
399
|
+
* anything, because everything they touch is either already absent from disk
|
|
400
|
+
* or provably empty. Removing a directory that still HOLDS something is
|
|
401
|
+
* exactly the operation that needs a human looking at it.
|
|
238
402
|
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
* a human looking at it.
|
|
403
|
+
* Order is load-bearing: the empty directories go first, so a root pointer
|
|
404
|
+
* naming one of them becomes dangling in time for the registration sweep below
|
|
405
|
+
* to clear it — one command, not two runs.
|
|
243
406
|
*
|
|
244
407
|
* `clearDanglingWorktreeRegistration` re-checks the dangling predicate itself
|
|
245
408
|
* (path unset, or still exists → no-op) rather than trusting a pre-computed
|
|
246
409
|
* list, so this stays correct even called against a stale audit snapshot.
|
|
247
410
|
*
|
|
248
411
|
* @param {string} [cwd]
|
|
249
|
-
* @returns {Promise<{ pruned: boolean, error?: string, clearedRegistrations: Array<{feature: string, cleared: boolean}> }>}
|
|
412
|
+
* @returns {Promise<{ pruned: boolean, error?: string, removedEmptyDirs: string[], clearedRegistrations: Array<{feature: string, cleared: boolean}> }>}
|
|
250
413
|
*/
|
|
251
414
|
export async function pruneWorktrees(cwd = process.cwd()) {
|
|
252
|
-
if (!isGitRepo(cwd)) return { pruned: false, error: 'not-a-git-repo', clearedRegistrations: [] };
|
|
415
|
+
if (!isGitRepo(cwd)) return { pruned: false, error: 'not-a-git-repo', removedEmptyDirs: [], clearedRegistrations: [] };
|
|
253
416
|
const root = resolvePrimaryRoot(cwd);
|
|
254
417
|
|
|
255
418
|
let pruned = false;
|
|
@@ -261,11 +424,13 @@ export async function pruneWorktrees(cwd = process.cwd()) {
|
|
|
261
424
|
error = (err.message || 'unknown').slice(0, 200);
|
|
262
425
|
}
|
|
263
426
|
|
|
427
|
+
const removedEmptyDirs = removeEmptyUnregisteredDirs(root);
|
|
428
|
+
|
|
264
429
|
const clearedRegistrations = [];
|
|
265
430
|
for (const reg of featureRegistrations(root)) {
|
|
266
431
|
const { cleared } = await clearDanglingWorktreeRegistration(root, reg.feature);
|
|
267
432
|
if (cleared) clearedRegistrations.push({ feature: reg.feature, cleared: true });
|
|
268
433
|
}
|
|
269
434
|
|
|
270
|
-
return { pruned, ...(error ? { error } : {}), clearedRegistrations };
|
|
435
|
+
return { pruned, ...(error ? { error } : {}), removedEmptyDirs, clearedRegistrations };
|
|
271
436
|
}
|