@polymorphism-tech/morph-spec 8.31.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 +5 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +19 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +2 -2
- package/framework/docs/CLI.md +68 -63
- 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 +3 -2
- 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 +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +2 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/branch.js +170 -0
- package/src/commands/project/doctor.js +137 -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/state/lifecycle-audit.js +127 -0
- 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/worktree/fleet-scanner.js +51 -21
- package/src/lib/worktree/ignored-triage.js +197 -0
- package/src/lib/worktree/worktree-audit.js +200 -35
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pending-prs.js — as features que existem SÓ como PR aberto.
|
|
3
|
+
*
|
|
4
|
+
* `finish --pr` deixa o root primário sem vestígio da feature: o
|
|
5
|
+
* `shedRootFeatureScope` apaga `.morph/features/{f}` e o archive foi commitado
|
|
6
|
+
* na branch `morph/{f}`, chegando na default apenas no merge. Entre abrir e
|
|
7
|
+
* mergear, portanto, nenhuma superfície local sabe que aquela branch tem dono —
|
|
8
|
+
* foi assim que uma branch com PR #51 aberto quase foi varrida.
|
|
9
|
+
*
|
|
10
|
+
* Este módulo devolve o dono. Não há estado persistido a consultar, e é de
|
|
11
|
+
* propósito: o PR aberto no GitHub É o registro, a branch é sempre
|
|
12
|
+
* `morph/{feature}`, e o `pr-index.js` já é o leitor único desse fato. Lembrar
|
|
13
|
+
* o número numa segunda cópia só criaria como divergir dele.
|
|
14
|
+
*
|
|
15
|
+
* `null` (do índice ou daqui) significa DESCONHECIDO e jamais "não há
|
|
16
|
+
* pendência" — a mesma regra que torna seguros os caminhos destrutivos que
|
|
17
|
+
* leem o índice.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync } from 'fs';
|
|
21
|
+
import { join } from 'path';
|
|
22
|
+
import { buildPrIndex } from './pr-index.js';
|
|
23
|
+
|
|
24
|
+
/** As branches que o framework cria por feature. */
|
|
25
|
+
export const MORPH_BRANCH_RE = /^morph\/(.+)$/;
|
|
26
|
+
|
|
27
|
+
const DAY_MS = 86400000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* O PR da branch canônica de uma feature.
|
|
31
|
+
*
|
|
32
|
+
* @param {Map<string,object>|null} index - de buildPrIndex
|
|
33
|
+
* @param {string} feature
|
|
34
|
+
* @returns {{number:number,state:string,mergedAt:string|null,url:string|null,createdAt:string|null}|null}
|
|
35
|
+
* null = desconhecido OU sem PR; quem precisa distinguir os dois olha o índice.
|
|
36
|
+
*/
|
|
37
|
+
export function prForFeature(index, feature) {
|
|
38
|
+
if (!index) return null;
|
|
39
|
+
return index.get(`morph/${feature}`) || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A feature tem pasta (ativa ou arquivada) nesta árvore? */
|
|
43
|
+
function hasLocalFolder(root, feature) {
|
|
44
|
+
return existsSync(join(root, '.morph', 'features', feature))
|
|
45
|
+
|| existsSync(join(root, '.morph', 'archive', feature));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* As features aguardando merge: PR OPEN numa branch `morph/*` cujo slug não
|
|
50
|
+
* tem pasta nesta árvore.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} root - a raiz a inspecionar (e de onde o `gh` roda)
|
|
53
|
+
* @param {{ index?: Map<string,object>|null, now?: Date }} [opts]
|
|
54
|
+
* `index` injeta um índice já construído (o `list`/`fleet` constroem um por
|
|
55
|
+
* processo e reaproveitam; os testes passam um sintético). Ausente = constrói.
|
|
56
|
+
* @returns {Array<{feature:string,branch:string,number:number,state:string,url:string|null,createdAt:string|null,ageDays:number|null}>|null}
|
|
57
|
+
* null = DESCONHECIDO. Nunca lista vazia por falta de informação.
|
|
58
|
+
*/
|
|
59
|
+
export function pendingPrFeatures(root, opts = {}) {
|
|
60
|
+
const index = opts.index !== undefined ? opts.index : buildPrIndex(root);
|
|
61
|
+
if (!index) return null;
|
|
62
|
+
|
|
63
|
+
const now = opts.now instanceof Date ? opts.now : new Date();
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const [branch, pr] of index) {
|
|
66
|
+
const m = branch.match(MORPH_BRANCH_RE);
|
|
67
|
+
if (!m) continue;
|
|
68
|
+
if (pr.state !== 'OPEN') continue;
|
|
69
|
+
const feature = m[1];
|
|
70
|
+
if (hasLocalFolder(root, feature)) continue;
|
|
71
|
+
|
|
72
|
+
let ageDays = null;
|
|
73
|
+
if (pr.createdAt) {
|
|
74
|
+
const t = Date.parse(pr.createdAt);
|
|
75
|
+
if (!Number.isNaN(t)) ageDays = Math.floor((now.getTime() - t) / DAY_MS);
|
|
76
|
+
}
|
|
77
|
+
out.push({
|
|
78
|
+
feature,
|
|
79
|
+
branch,
|
|
80
|
+
number: pr.number,
|
|
81
|
+
state: pr.state,
|
|
82
|
+
url: pr.url ?? null,
|
|
83
|
+
createdAt: pr.createdAt ?? null,
|
|
84
|
+
ageDays,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
out.sort((a, b) => a.feature.localeCompare(b.feature));
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pr-index.js — one `gh pr list` per process, shared by every caller that needs
|
|
3
|
+
* to answer "does a branch have a PR, and in what state".
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `fleet-scanner.js` because three independent leaders now ask
|
|
6
|
+
* the SAME question and must get the SAME answer:
|
|
7
|
+
*
|
|
8
|
+
* • `morph-spec fleet` — the PR column
|
|
9
|
+
* • `morph-spec branch prune` — a branch with an OPEN PR is never deleted,
|
|
10
|
+
* and a MERGED one is deletable even when git
|
|
11
|
+
* cannot see the merge (squash-merge)
|
|
12
|
+
* • `morph-spec doctor --worktrees` — a worktree whose branch carries an open
|
|
13
|
+
* PR must NOT be offered up for removal
|
|
14
|
+
*
|
|
15
|
+
* Fail-open by construction: no `gh`, not authenticated, not GitHub, or a
|
|
16
|
+
* timeout all yield `null`. `null` means "unknown", NEVER "no PR" — every
|
|
17
|
+
* caller must treat the two differently, because the destructive paths
|
|
18
|
+
* (prune, remove) are only safe when the answer is a positive fact.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from 'child_process';
|
|
22
|
+
import { resolveGh } from './gh.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the branch → PR index for a repository.
|
|
26
|
+
*
|
|
27
|
+
* Gated on the origin URL actually pointing at github.com: `gh` against any
|
|
28
|
+
* other host is a guaranteed error, and paying its start-up cost to learn that
|
|
29
|
+
* on every call is pure latency.
|
|
30
|
+
*
|
|
31
|
+
* `--state all` on purpose: a MERGED PR is exactly the evidence a squash-merge
|
|
32
|
+
* erases from git history, and it is the whole reason `branch prune` exists.
|
|
33
|
+
* First wins — `gh` lists newest-first, so an OPEN PR shadows an older closed
|
|
34
|
+
* one on the same branch.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} root - repository root to run `gh` from
|
|
37
|
+
* @param {{ limit?: number, timeout?: number }} [opts]
|
|
38
|
+
* @returns {Map<string,{number:number,state:string,mergedAt:string|null,url:string|null,createdAt:string|null}>|null}
|
|
39
|
+
* null = DESCONHECIDO (sem gh / não é GitHub / falhou), não "sem PRs"
|
|
40
|
+
*/
|
|
41
|
+
export function buildPrIndex(root, opts = {}) {
|
|
42
|
+
const limit = String(opts.limit ?? 200);
|
|
43
|
+
const timeout = opts.timeout ?? 8000;
|
|
44
|
+
try {
|
|
45
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: root, stdio: 'pipe' }).toString();
|
|
46
|
+
if (!/github\.com/i.test(url)) return null;
|
|
47
|
+
const { bin, prefix } = resolveGh();
|
|
48
|
+
const out = execFileSync(
|
|
49
|
+
bin,
|
|
50
|
+
[...prefix, 'pr', 'list', '--state', 'all', '--limit', limit,
|
|
51
|
+
'--json', 'number,state,headRefName,mergedAt,url,createdAt'],
|
|
52
|
+
{ cwd: root, stdio: 'pipe', timeout },
|
|
53
|
+
).toString();
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const pr of JSON.parse(out)) {
|
|
56
|
+
if (pr?.headRefName && !map.has(pr.headRefName)) {
|
|
57
|
+
map.set(pr.headRefName, {
|
|
58
|
+
number: pr.number,
|
|
59
|
+
state: pr.state,
|
|
60
|
+
mergedAt: pr.mergedAt ?? null,
|
|
61
|
+
url: pr.url ?? null,
|
|
62
|
+
createdAt: pr.createdAt ?? null,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return map;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -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
|
+
}
|