@sabaiway/agent-workflow-kit 3.0.0 → 3.1.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/CHANGELOG.md +49 -1
- package/README.md +1 -0
- package/SKILL.md +5 -1
- package/bin/install.mjs +10 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +20 -7
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +76 -18
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +20 -7
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +65 -17
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/recommendations.md +1 -0
- package/references/modes/worktrees.md +120 -0
- package/references/scripts/archive-decisions.mjs +2 -2
- package/references/scripts/archive-decisions.test.mjs +5 -5
- package/references/scripts/check-docs-size-cli.test.mjs +41 -0
- package/references/scripts/check-docs-size.mjs +46 -29
- package/tools/autonomy-doctor.mjs +1 -1
- package/tools/changed-surface.mjs +8 -8
- package/tools/commands.mjs +9 -0
- package/tools/grounding.mjs +13 -13
- package/tools/inject-methodology.mjs +71 -40
- package/tools/migrate-adr-store.mjs +3 -3
- package/tools/procedures.mjs +1 -1
- package/tools/recipes.mjs +2 -2
- package/tools/recommendations.mjs +34 -7
- package/tools/release-scan.mjs +9 -2
- package/tools/review-state.mjs +3 -3
- package/tools/sandbox-masks.mjs +13 -13
- package/tools/set-recipe.mjs +1 -1
- package/tools/velocity-profile.mjs +7 -7
- package/tools/worktrees.mjs +2292 -0
|
@@ -0,0 +1,2292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// worktrees.mjs — parallel feature worktrees over git: provision | list | land | cleanup (v1).
|
|
3
|
+
// Thin steps over git — every verification datum is recomputed live from git (branches, OIDs,
|
|
4
|
+
// observed status), never read from stored metadata; the handoff file is the one on-disk record
|
|
5
|
+
// (written at provision, refreshed at prepare, read by list/cleanup). Ownership matrix, crash
|
|
6
|
+
// discipline, and the zero-prompt lanes live in references/modes/worktrees.md. Dependency-free,
|
|
7
|
+
// Node >= 22.
|
|
8
|
+
// No side effects on import (the isDirectRun idiom).
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
lstatSync, statSync, readFileSync, mkdirSync, rmdirSync, readdirSync, readlinkSync,
|
|
12
|
+
symlinkSync, realpathSync, unlinkSync, openSync, fstatSync, readSync, writeSync, fchmodSync,
|
|
13
|
+
closeSync, constants as fsC,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { join, dirname, basename, resolve, relative, isAbsolute, sep } from 'node:path';
|
|
16
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import {
|
|
20
|
+
KIT_OWN_PATHS, KNOWN_FOOTPRINT, expandGlob, normalizeSlashes, isDirPattern, isGlobPattern,
|
|
21
|
+
patternToProbe,
|
|
22
|
+
} from './known-footprint.mjs';
|
|
23
|
+
import { isScratchPlanName, plansInFlight, PLANS_REL, shellQuoteArg } from './review-state.mjs';
|
|
24
|
+
import { writeContainedFileAtomic } from './atomic-write.mjs';
|
|
25
|
+
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
26
|
+
import { isFinalCapableDeclaration } from './run-gates.mjs';
|
|
27
|
+
|
|
28
|
+
export const WORKTREES_STOP = 'WORKTREES_STOP';
|
|
29
|
+
export const stop = (message, fields = {}) =>
|
|
30
|
+
Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'WorktreesStop', code: WORKTREES_STOP, ...fields });
|
|
31
|
+
const usageStop = (message) => stop(message, { exitCode: EXIT.usage });
|
|
32
|
+
const errorText = (error) => String(error?.message ?? error).replace(/^\[agent-workflow-kit\] /, '');
|
|
33
|
+
const composeFailure = (primary, secondaryName, secondary) =>
|
|
34
|
+
stop(`${errorText(primary)}; ${secondaryName} failed: ${errorText(secondary)}`);
|
|
35
|
+
|
|
36
|
+
export const EXIT = Object.freeze({ ok: 0, stop: 1, usage: 2 });
|
|
37
|
+
export const CONFIG_REL = 'docs/ai/worktrees.json';
|
|
38
|
+
export const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
39
|
+
export const DEFAULT_BRANCH_PREFIX = 'aw/';
|
|
40
|
+
export const handoffBasename = (slug) => `handoff-${slug}.md`;
|
|
41
|
+
const WORKTREES_TOOL_ABS = fileURLToPath(import.meta.url);
|
|
42
|
+
const WORKTREES_TOOL_DIR = dirname(WORKTREES_TOOL_ABS);
|
|
43
|
+
|
|
44
|
+
// Stores/sidecars a fresh worktree must NOT inherit (session state stays with its session).
|
|
45
|
+
const EXCLUDED_BASENAMES = new Set([
|
|
46
|
+
'.codex-last-session',
|
|
47
|
+
'agent-workflow-review-receipts.jsonl',
|
|
48
|
+
'agent-workflow-core-evidence.jsonl',
|
|
49
|
+
]);
|
|
50
|
+
// Registry entries provision seeds by its own rules instead of copying wholesale.
|
|
51
|
+
const SEEDED_SEPARATELY = new Set([`/${PLANS_REL}/`]);
|
|
52
|
+
// Copied files whose absolute main-root pins are rebased onto the worktree root.
|
|
53
|
+
const REBASE_TARGETS = Object.freeze(['docs/ai/gates.json', '.claude/settings.json', '.claude/settings.local.json']);
|
|
54
|
+
const TRACKED_PIN_DECLARATION =
|
|
55
|
+
'tracked declaration is not worktree-portable — this worktree\'s council/final checks route through the MAIN runner via --cwd';
|
|
56
|
+
const TRANSFER_EXCLUSIONS = Object.freeze([':!docs/ai', ':!docs/plans']);
|
|
57
|
+
const PREPARE_LOCK_BASENAME = 'aw-prepare-lock';
|
|
58
|
+
|
|
59
|
+
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
60
|
+
|
|
61
|
+
const USAGE = [
|
|
62
|
+
'usage: worktrees.mjs <subcommand> [args]',
|
|
63
|
+
'',
|
|
64
|
+
' provision <slug> --plan <path> [--as <name>.md] [--dir <path>] [--branch <name>]',
|
|
65
|
+
' [--include <path>]... [--install] [--resume]',
|
|
66
|
+
' create + populate a feature worktree (sibling dir by default; the parent dir is',
|
|
67
|
+
` the ${CONFIG_REL} "parentDir" setting when present). --install only PRINTS the`,
|
|
68
|
+
' install command. --resume completes a half-done provision (identity-checked).',
|
|
69
|
+
' list show every worktree of this repo: slug, path, branch, base OID, dirty, handoff.',
|
|
70
|
+
' land <slug> --prepare',
|
|
71
|
+
' stage the satellite diff onto a clean main (no commit — the commit stays a',
|
|
72
|
+
' dialogue ask). Refuses divergence, incomplete satellite state, or a dirty main.',
|
|
73
|
+
' cleanup <slug> [--branch <name>] [--abandon]',
|
|
74
|
+
' remove a LANDED worktree (fail-closed verification); --abandon is the one',
|
|
75
|
+
' destructive arm and destroys unlanded work.',
|
|
76
|
+
'',
|
|
77
|
+
'The slug is REQUIRED and positional on provision/land/cleanup: lowercase letters, digits,',
|
|
78
|
+
'hyphens, max 64 chars, letter/digit first. Exit codes: 0 ok / 1 refusal / 2 usage.',
|
|
79
|
+
].join('\n');
|
|
80
|
+
|
|
81
|
+
// ── deps + git plumbing (every seam injectable for hermetic tests) ─────────────────────
|
|
82
|
+
|
|
83
|
+
const fsOf = (deps) => ({
|
|
84
|
+
lstat: deps.lstat ?? lstatSync,
|
|
85
|
+
mkdir: deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true })),
|
|
86
|
+
mkdirPlain: deps.mkdirPlain ?? mkdirSync,
|
|
87
|
+
rmdir: deps.rmdir ?? rmdirSync,
|
|
88
|
+
readdir: deps.readdir ?? readdirSync,
|
|
89
|
+
unlink: deps.unlink ?? unlinkSync,
|
|
90
|
+
readlink: deps.readlink ?? readlinkSync,
|
|
91
|
+
symlink: deps.symlink ?? symlinkSync,
|
|
92
|
+
realpath: deps.realpath ?? realpathSync,
|
|
93
|
+
open: deps.open ?? openSync,
|
|
94
|
+
fstat: deps.fstat ?? fstatSync,
|
|
95
|
+
read: deps.read ?? readSync,
|
|
96
|
+
write: deps.write ?? writeSync,
|
|
97
|
+
fchmod: deps.fchmod ?? fchmodSync,
|
|
98
|
+
close: deps.close ?? closeSync,
|
|
99
|
+
writeFile: deps.writeFile,
|
|
100
|
+
rename: deps.rename,
|
|
101
|
+
rm: deps.rm,
|
|
102
|
+
rand: deps.rand ?? (() => randomBytes(6).toString('hex')),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const spawnGit = (args, cwd) => {
|
|
106
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, maxBuffer: GIT_MAX_BUFFER });
|
|
107
|
+
return {
|
|
108
|
+
status: r.error ? -1 : r.status,
|
|
109
|
+
stdout: r.stdout ?? '',
|
|
110
|
+
stderr: r.error ? String(r.error.message) : (r.stderr ?? ''),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const gitLine = (git, args, cwd) => {
|
|
114
|
+
const r = git(args, cwd);
|
|
115
|
+
return r.status === 0 ? r.stdout.replace(/\r?\n$/, '') : null;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// check-ignore's honest trichotomy: 0 ignored · 1 not-ignored · anything else is a REAL git
|
|
119
|
+
// failure that must never read as "not ignored"
|
|
120
|
+
const checkIgnored = (git, probeRel, cwd) => {
|
|
121
|
+
const r = git(['check-ignore', '--', probeRel], cwd);
|
|
122
|
+
if (r.status === 0) return true;
|
|
123
|
+
if (r.status === 1) return false;
|
|
124
|
+
throw stop(`git check-ignore failed for ${probeRel}: ${(r.stderr || r.stdout).trim()}`);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const lstatNoFollow = (lstat, path) => {
|
|
128
|
+
try {
|
|
129
|
+
return lstat(path);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err && err.code === 'ENOENT') return null;
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const classifyNodeNoFollow = (path, fs) => {
|
|
137
|
+
const node = (() => {
|
|
138
|
+
try {
|
|
139
|
+
return { stat: fs.lstat(path) };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return error?.code === 'ENOENT'
|
|
142
|
+
? { stat: null }
|
|
143
|
+
: { error: error?.code ?? 'fs error' };
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
if (node.error) return { kind: 'error', error: node.error };
|
|
147
|
+
if (node.stat === null) return { kind: 'absent' };
|
|
148
|
+
if (!node.stat.isSymbolicLink()) {
|
|
149
|
+
if (node.stat.isDirectory()) return { kind: 'plain-directory', stat: node.stat };
|
|
150
|
+
if (node.stat.isFile()) return { kind: 'regular-file', stat: node.stat };
|
|
151
|
+
return { kind: 'special', stat: node.stat };
|
|
152
|
+
}
|
|
153
|
+
const realPath = (() => {
|
|
154
|
+
try {
|
|
155
|
+
return { path: fs.realpath(path) };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return { error: error?.code ?? 'fs error' };
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
if (realPath.error) return { kind: 'symlink-unresolvable', error: realPath.error };
|
|
161
|
+
const target = (() => {
|
|
162
|
+
try {
|
|
163
|
+
return { stat: fs.lstat(realPath.path) };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return { error: error?.code ?? 'fs error' };
|
|
166
|
+
}
|
|
167
|
+
})();
|
|
168
|
+
if (target.error) return { kind: 'symlink-unresolvable', error: target.error };
|
|
169
|
+
if (target.stat.isDirectory()) return { kind: 'symlink-to-directory', realPath: realPath.path, stat: node.stat };
|
|
170
|
+
if (target.stat.isFile()) return { kind: 'symlink-to-file', realPath: realPath.path, stat: node.stat };
|
|
171
|
+
return { kind: 'symlink-to-special', realPath: realPath.path, stat: node.stat };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// The ONE content-read door: no-follow lstat, then an O_NOFOLLOW|O_NONBLOCK descriptor with an
|
|
175
|
+
// fstat recheck — a node swapped after the lstat can neither follow a link nor block on a FIFO.
|
|
176
|
+
// Outcomes: { bytes } | { absent } | { unsafe } | { error: code }; callers map them, never read raw.
|
|
177
|
+
const NOFOLLOW_READ = fsC.O_RDONLY | (fsC.O_NOFOLLOW ?? 0) | (fsC.O_NONBLOCK ?? 0);
|
|
178
|
+
const readFileNoFollow = (fs, abs) => {
|
|
179
|
+
let st;
|
|
180
|
+
try {
|
|
181
|
+
st = fs.lstat(abs);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return err?.code === 'ENOENT' ? { absent: true } : { error: err?.code ?? 'fs error' };
|
|
184
|
+
}
|
|
185
|
+
if (st.isSymbolicLink() || !st.isFile()) return { unsafe: true };
|
|
186
|
+
let fd = null;
|
|
187
|
+
try {
|
|
188
|
+
fd = openSync(abs, NOFOLLOW_READ);
|
|
189
|
+
const fdStat = fstatSync(fd);
|
|
190
|
+
// Comparing the injected lstat with the real descriptor fstat is deliberate.
|
|
191
|
+
if (!fdStat.isFile() || st.dev !== fdStat.dev || st.ino !== fdStat.ino) return { unsafe: true };
|
|
192
|
+
return { bytes: readFileSync(fd) };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (err?.code === 'ENOENT') return { absent: true };
|
|
195
|
+
return err?.code === 'ELOOP' ? { unsafe: true } : { error: err?.code ?? 'fs error' };
|
|
196
|
+
} finally {
|
|
197
|
+
if (fd !== null) closeSync(fd);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const NOFOLLOW_WRITE = fsC.O_WRONLY | fsC.O_CREAT | fsC.O_EXCL | (fsC.O_NOFOLLOW ?? 0);
|
|
202
|
+
const COPY_BUFFER_BYTES = 64 * 1024;
|
|
203
|
+
const copyFileNoFollow = ({ srcAbs, dstAbs, sourceStat, rel, fs }) => {
|
|
204
|
+
const handles = { source: null, destination: null };
|
|
205
|
+
const closeErrors = [];
|
|
206
|
+
const outcome = { error: null };
|
|
207
|
+
try {
|
|
208
|
+
try {
|
|
209
|
+
handles.source = fs.open(srcAbs, NOFOLLOW_READ);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error?.code === 'ENOENT' || error?.code === 'ELOOP') {
|
|
212
|
+
throw stop(`copy source changed between lstat and open: ${rel}`);
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
const descriptorStat = fs.fstat(handles.source);
|
|
217
|
+
if (!descriptorStat.isFile() || descriptorStat.dev !== sourceStat.dev || descriptorStat.ino !== sourceStat.ino) {
|
|
218
|
+
throw stop(`copy source changed between lstat and open: ${rel}`);
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
// O_EXCL closes the create race; O_NOFOLLOW is defense-in-depth for nonstandard link handling.
|
|
222
|
+
handles.destination = fs.open(dstAbs, NOFOLLOW_WRITE, sourceStat.mode & 0o666);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error?.code === 'EEXIST' || error?.code === 'ELOOP') {
|
|
225
|
+
throw stop(`copy destination changed between lstat and open: ${rel}`);
|
|
226
|
+
}
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
const buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES);
|
|
230
|
+
let bytesRead = fs.read(handles.source, buffer, 0, buffer.length, null);
|
|
231
|
+
while (bytesRead > 0) {
|
|
232
|
+
let written = 0;
|
|
233
|
+
while (written < bytesRead) {
|
|
234
|
+
const bytesWritten = fs.write(handles.destination, buffer, written, bytesRead - written, null);
|
|
235
|
+
if (bytesWritten <= 0) throw Object.assign(new Error('zero-byte descriptor write'), { code: 'EIO' });
|
|
236
|
+
written += bytesWritten;
|
|
237
|
+
}
|
|
238
|
+
bytesRead = fs.read(handles.source, buffer, 0, buffer.length, null);
|
|
239
|
+
}
|
|
240
|
+
if ((sourceStat.mode & 0o111) !== 0) fs.fchmod(handles.destination, sourceStat.mode & 0o777);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
outcome.error = error;
|
|
243
|
+
}
|
|
244
|
+
for (const key of ['destination', 'source']) {
|
|
245
|
+
if (handles[key] === null) continue;
|
|
246
|
+
try {
|
|
247
|
+
fs.close(handles[key]);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
closeErrors.push(error);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const withDestinationState = (error) => Object.assign(error, {
|
|
253
|
+
copyDoorDestinationCreated: handles.destination !== null,
|
|
254
|
+
});
|
|
255
|
+
if (outcome.error) throw withDestinationState(outcome.error);
|
|
256
|
+
if (closeErrors.length > 0) throw withDestinationState(closeErrors[0]);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const isInside = (root, path) => {
|
|
260
|
+
const rel = relative(root, path);
|
|
261
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// ── slug / config / target resolution ──────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
export const validateSlug = (slug) => {
|
|
267
|
+
if (typeof slug !== 'string' || !SLUG_RE.test(slug)) {
|
|
268
|
+
throw usageStop(
|
|
269
|
+
`invalid slug ${JSON.stringify(slug ?? '')} — lowercase letters, digits, hyphens, max 64 chars, letter/digit first`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return slug;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export const parseStrictJson = (text) => {
|
|
276
|
+
const source = String(text);
|
|
277
|
+
const cursor = { index: 0 };
|
|
278
|
+
const skipWhitespace = () => {
|
|
279
|
+
while (/\s/.test(source[cursor.index] ?? '')) cursor.index += 1;
|
|
280
|
+
};
|
|
281
|
+
const readString = () => {
|
|
282
|
+
const start = cursor.index;
|
|
283
|
+
cursor.index += 1;
|
|
284
|
+
while (cursor.index < source.length) {
|
|
285
|
+
if (source[cursor.index] === '\\') {
|
|
286
|
+
cursor.index += 2;
|
|
287
|
+
} else if (source[cursor.index] === '"') {
|
|
288
|
+
cursor.index += 1;
|
|
289
|
+
return JSON.parse(source.slice(start, cursor.index));
|
|
290
|
+
} else {
|
|
291
|
+
cursor.index += 1;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
};
|
|
296
|
+
const childPath = (path, key) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
|
|
297
|
+
? `${path}.${key}`
|
|
298
|
+
: `${path}[${JSON.stringify(key)}]`;
|
|
299
|
+
const scanValue = (path) => {
|
|
300
|
+
skipWhitespace();
|
|
301
|
+
if (source[cursor.index] === '{') {
|
|
302
|
+
cursor.index += 1;
|
|
303
|
+
skipWhitespace();
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
if (source[cursor.index] === '}') {
|
|
306
|
+
cursor.index += 1;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
while (cursor.index < source.length) {
|
|
310
|
+
const key = readString();
|
|
311
|
+
if (seen.has(key)) throw new SyntaxError(`duplicate JSON key ${JSON.stringify(key)} at ${path}`);
|
|
312
|
+
seen.add(key);
|
|
313
|
+
skipWhitespace();
|
|
314
|
+
cursor.index += 1;
|
|
315
|
+
scanValue(childPath(path, key));
|
|
316
|
+
skipWhitespace();
|
|
317
|
+
if (source[cursor.index] === '}') {
|
|
318
|
+
cursor.index += 1;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
cursor.index += 1;
|
|
322
|
+
skipWhitespace();
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (source[cursor.index] === '[') {
|
|
327
|
+
cursor.index += 1;
|
|
328
|
+
skipWhitespace();
|
|
329
|
+
if (source[cursor.index] === ']') {
|
|
330
|
+
cursor.index += 1;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
let item = 0;
|
|
334
|
+
while (cursor.index < source.length) {
|
|
335
|
+
scanValue(`${path}[${item}]`);
|
|
336
|
+
item += 1;
|
|
337
|
+
skipWhitespace();
|
|
338
|
+
if (source[cursor.index] === ']') {
|
|
339
|
+
cursor.index += 1;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
cursor.index += 1;
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (source[cursor.index] === '"') {
|
|
347
|
+
readString();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
while (cursor.index < source.length && !/[\s,}\]]/.test(source[cursor.index])) cursor.index += 1;
|
|
351
|
+
};
|
|
352
|
+
// the duplicate scan tolerates malformed tails (EOF-safe returns); JSON.parse then rejects them
|
|
353
|
+
scanValue('$');
|
|
354
|
+
return JSON.parse(source);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
export const loadWorktreesConfig = (root, deps = {}) => {
|
|
358
|
+
const fs = fsOf(deps);
|
|
359
|
+
// the ancestor chain is verified even when the leaf is absent — a symlinked docs/ or docs/ai
|
|
360
|
+
// must never read as plain absence
|
|
361
|
+
let dir = root;
|
|
362
|
+
for (const seg of ['docs', 'ai']) {
|
|
363
|
+
dir = join(dir, seg);
|
|
364
|
+
let st;
|
|
365
|
+
try {
|
|
366
|
+
st = fs.lstat(dir);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (err?.code === 'ENOENT') return { parentDir: null, source: 'default' };
|
|
369
|
+
throw stop(`${CONFIG_REL}: cannot stat ${relative(root, dir)} (${err?.code ?? 'fs error'}) — refusing to trust plain absence`);
|
|
370
|
+
}
|
|
371
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
372
|
+
throw stop(`${CONFIG_REL}: ${relative(root, dir)} is not a plain directory — refusing to trust plain absence`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const leaf = readFileNoFollow(fs, join(root, CONFIG_REL));
|
|
376
|
+
if (leaf.absent) return { parentDir: null, source: 'default' };
|
|
377
|
+
if (leaf.unsafe) throw stop(`${CONFIG_REL} is not a regular file — refusing to read it`);
|
|
378
|
+
if (leaf.error) throw stop(`${CONFIG_REL}: unreadable (${leaf.error})`);
|
|
379
|
+
let parsed;
|
|
380
|
+
try {
|
|
381
|
+
parsed = parseStrictJson(String(leaf.bytes));
|
|
382
|
+
} catch (err) {
|
|
383
|
+
throw stop(`${CONFIG_REL}: malformed JSON (${err.message}) — fix it by hand; the tool never guesses`);
|
|
384
|
+
}
|
|
385
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
386
|
+
throw stop(`${CONFIG_REL}: must be a JSON object { "parentDir": "<dir>" }`);
|
|
387
|
+
}
|
|
388
|
+
for (const key of Object.keys(parsed)) {
|
|
389
|
+
if (key !== '_README' && key !== 'parentDir') {
|
|
390
|
+
throw stop(`${CONFIG_REL}: unknown key "${key}" (allowed: _README, parentDir)`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (parsed.parentDir !== undefined && (typeof parsed.parentDir !== 'string' || parsed.parentDir.trim() === '')) {
|
|
394
|
+
throw stop(`${CONFIG_REL}: "parentDir" must be a non-empty string`);
|
|
395
|
+
}
|
|
396
|
+
return { parentDir: parsed.parentDir ?? null, source: parsed.parentDir === undefined ? 'default' : 'setting' };
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
export const resolveTargetDir = ({ root, slug, dirFlag, parentDir }) => {
|
|
400
|
+
if (dirFlag) return resolve(root, dirFlag);
|
|
401
|
+
const parent = parentDir == null ? dirname(root) : resolve(root, parentDir);
|
|
402
|
+
return join(parent, `${basename(root)}--${slug}`);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Realpath through the NEAREST EXISTING ancestor (the target itself may not exist yet), so a
|
|
406
|
+
// symlinked parent can never smuggle the worktree outside the intended placement.
|
|
407
|
+
export const realpathThroughExistingParent = (target, deps = {}) => {
|
|
408
|
+
const { lstat, realpath } = fsOf(deps);
|
|
409
|
+
const tail = [];
|
|
410
|
+
const walkUp = (p) => {
|
|
411
|
+
if (lstatNoFollow(lstat, p) !== null) return p;
|
|
412
|
+
const parent = dirname(p);
|
|
413
|
+
if (parent === p) return p;
|
|
414
|
+
tail.unshift(basename(p));
|
|
415
|
+
return walkUp(parent);
|
|
416
|
+
};
|
|
417
|
+
const existing = walkUp(resolve(target));
|
|
418
|
+
return join(realpath(existing), ...tail);
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// ── roots + worktree registry ──────────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
export const parseWorktreeList = (text) => {
|
|
424
|
+
const entries = [];
|
|
425
|
+
let fields = [];
|
|
426
|
+
const finishEntry = () => {
|
|
427
|
+
if (fields.length === 0) return;
|
|
428
|
+
const entry = { path: null, head: null, branch: null, detached: false, prunable: false, bare: false };
|
|
429
|
+
for (const field of fields) {
|
|
430
|
+
if (field.startsWith('worktree ')) entry.path = field.slice('worktree '.length);
|
|
431
|
+
else if (field.startsWith('HEAD ')) entry.head = field.slice('HEAD '.length);
|
|
432
|
+
else if (field.startsWith('branch ')) entry.branch = field.slice('branch '.length);
|
|
433
|
+
else if (field === 'detached') entry.detached = true;
|
|
434
|
+
else if (field === 'bare') entry.bare = true;
|
|
435
|
+
else if (field === 'prunable' || field.startsWith('prunable ')) entry.prunable = true;
|
|
436
|
+
}
|
|
437
|
+
if (entry.path !== null) entries.push(entry);
|
|
438
|
+
fields = [];
|
|
439
|
+
};
|
|
440
|
+
for (const field of String(text).split('\0')) {
|
|
441
|
+
if (field === '') finishEntry();
|
|
442
|
+
else fields.push(field);
|
|
443
|
+
}
|
|
444
|
+
finishEntry();
|
|
445
|
+
return entries;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const listWorktrees = (git, cwd) => {
|
|
449
|
+
const r = git(['worktree', 'list', '--porcelain', '-z'], cwd);
|
|
450
|
+
if (r.status !== 0) throw stop(`git worktree list failed: ${r.stderr.trim() || r.stdout.trim()}`);
|
|
451
|
+
return parseWorktreeList(r.stdout);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// The MAIN worktree is the first `git worktree list --porcelain -z` entry; provision/land/cleanup
|
|
455
|
+
// refuse to run from inside a linked worktree.
|
|
456
|
+
export const resolveRoots = (cwd, git, { refuseLinked = true } = {}) => {
|
|
457
|
+
const root = gitLine(git, ['rev-parse', '--show-toplevel'], cwd);
|
|
458
|
+
if (root == null) throw stop('not inside a git work tree');
|
|
459
|
+
const gitDir = gitLine(git, ['rev-parse', '--path-format=absolute', '--git-dir'], cwd);
|
|
460
|
+
const commonDir = gitLine(git, ['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
|
|
461
|
+
if (gitDir == null || commonDir == null) throw stop('cannot resolve the git dir');
|
|
462
|
+
if (refuseLinked && gitDir !== commonDir) {
|
|
463
|
+
const main = listWorktrees(git, cwd)[0]?.path ?? '(unknown)';
|
|
464
|
+
throw stop(`run this from the MAIN worktree (${main}) — the cwd is inside a linked worktree`);
|
|
465
|
+
}
|
|
466
|
+
return { root, gitDir, commonDir };
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// ── the writability preflight (the ONE sibling-mutation gate + degrade) ────────────────
|
|
470
|
+
|
|
471
|
+
// git worktree add creates missing leading dirs itself — the probe targets the nearest
|
|
472
|
+
// EXISTING ancestor (probing a not-yet-existing parent would read as a false denial).
|
|
473
|
+
export const nearestExistingDir = (path, deps = {}) => {
|
|
474
|
+
const { lstat } = fsOf(deps);
|
|
475
|
+
const walk = (p) => {
|
|
476
|
+
if (lstatNoFollow(lstat, p) !== null) return p;
|
|
477
|
+
const parent = dirname(p);
|
|
478
|
+
return parent === p ? p : walk(parent);
|
|
479
|
+
};
|
|
480
|
+
return walk(resolve(path));
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// The ONE canonical probe-dir derivation provision AND the recommendations advisor share:
|
|
484
|
+
// resolve through the existing-parent realpath, then walk to the nearest existing dir.
|
|
485
|
+
export const resolveProbeDir = (path, deps = {}) =>
|
|
486
|
+
nearestExistingDir(realpathThroughExistingParent(path, deps), deps);
|
|
487
|
+
|
|
488
|
+
export const probeParentWritable = (parentDir, deps = {}) => {
|
|
489
|
+
const { mkdirPlain, rmdir, rand } = fsOf(deps);
|
|
490
|
+
const probe = join(parentDir, `.aw-write-probe-${rand()}`);
|
|
491
|
+
try {
|
|
492
|
+
mkdirPlain(probe);
|
|
493
|
+
} catch (err) {
|
|
494
|
+
return { writable: false, code: err?.code ?? 'error' };
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
rmdir(probe);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// create-OK/delete-FAIL is its own refusal — never "writable", never silent debris
|
|
500
|
+
return { writable: false, cleanupFailed: { path: probe, code: err?.code ?? 'error' } };
|
|
501
|
+
}
|
|
502
|
+
return { writable: true };
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const probeCleanupStop = ({ path, code }) => stop(
|
|
506
|
+
`the writability probe could not clean up its probe dir (${code}) — remove it by hand: ${path}`,
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const provisionFlagsTail = (flags, q) => {
|
|
510
|
+
const parts = ['--plan', q(flags.plan)];
|
|
511
|
+
if (flags.as) parts.push('--as', q(flags.as));
|
|
512
|
+
if (flags.branch) parts.push('--branch', q(flags.branch));
|
|
513
|
+
if (flags.dir) parts.push('--dir', q(flags.dir));
|
|
514
|
+
for (const inc of flags.include ?? []) parts.push('--include', q(inc));
|
|
515
|
+
if (flags.install) parts.push('--install');
|
|
516
|
+
if (flags.resume) parts.push('--resume');
|
|
517
|
+
return parts;
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// The maintainer-paste fallback must be the COMPLETE original invocation, quoted; the tool
|
|
521
|
+
// path is ALWAYS quoted — the one token whose spaces depend on the install location.
|
|
522
|
+
const quoteOwnTool = () => `'${WORKTREES_TOOL_ABS.replace(/'/g, `'\\''`)}'`;
|
|
523
|
+
const composeOwnToolPrefix = (root) =>
|
|
524
|
+
`cd ${shellQuoteArg(root)} && node ${quoteOwnTool()}`;
|
|
525
|
+
|
|
526
|
+
const landWritabilityStop = ({ parentDir, root, slug }) => stop([
|
|
527
|
+
`the worktrees parent dir is not writable from this session: ${parentDir}`,
|
|
528
|
+
'Arm the ONE-TIME consent (then land runs promptless):',
|
|
529
|
+
` .claude/settings.json → sandbox.filesystem.allowWrite += ${JSON.stringify(parentDir)}`,
|
|
530
|
+
'Or run the full command yourself in a plain terminal:',
|
|
531
|
+
` ${composeOwnToolPrefix(root)} land ${shellQuoteArg(slug)} --prepare`,
|
|
532
|
+
].join('\n'));
|
|
533
|
+
|
|
534
|
+
export const composeProvisionArgv = ({ root, slug, flags }) => {
|
|
535
|
+
const q = shellQuoteArg;
|
|
536
|
+
return [composeOwnToolPrefix(root), 'provision', q(slug), ...provisionFlagsTail(flags, q)].join(' ');
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const writabilityStop = ({ parentDir, root, slug, flags }) => {
|
|
540
|
+
const q = shellQuoteArg;
|
|
541
|
+
return stop(
|
|
542
|
+
[
|
|
543
|
+
`the worktrees parent dir is not writable from this session: ${parentDir}`,
|
|
544
|
+
'Arm the ONE-TIME consent (then every provision/cleanup runs promptless):',
|
|
545
|
+
` .claude/settings.json → sandbox.filesystem.allowWrite += ${JSON.stringify(parentDir)}`,
|
|
546
|
+
'Or run the full command yourself in a plain terminal:',
|
|
547
|
+
` ${composeProvisionArgv({ root, slug, flags })}`,
|
|
548
|
+
`(from the target repo root, when that checkout carries the kit at agent-workflow-kit/: node agent-workflow-kit/tools/worktrees.mjs provision ${q(slug)} ${provisionFlagsTail(flags, q).join(' ')})`,
|
|
549
|
+
].join('\n'),
|
|
550
|
+
);
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
// ── provision: copy set + copy semantics ───────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
const isPresent = (root, pattern, fs) => {
|
|
556
|
+
const rel = patternToProbe(pattern).replace(/\/$/, '');
|
|
557
|
+
const st = lstatNoFollow(fs.lstat, join(root, rel));
|
|
558
|
+
if (st === null) return false;
|
|
559
|
+
return isDirPattern(pattern) ? st.isDirectory() : true;
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
export const provisionCopySet = (root, deps = {}) => {
|
|
563
|
+
const fs = fsOf(deps);
|
|
564
|
+
const out = [];
|
|
565
|
+
for (const pattern of KIT_OWN_PATHS) {
|
|
566
|
+
if (SEEDED_SEPARATELY.has(pattern)) continue;
|
|
567
|
+
if (isPresent(root, pattern, fs)) out.push(pattern);
|
|
568
|
+
}
|
|
569
|
+
for (const entry of KNOWN_FOOTPRINT) {
|
|
570
|
+
if (isGlobPattern(entry.pattern)) {
|
|
571
|
+
out.push(...expandGlob(entry.pattern, { dir: root, readdir: fs.readdir, stat: deps.stat ?? statSync }));
|
|
572
|
+
} else if (isPresent(root, entry.pattern, fs)) {
|
|
573
|
+
out.push(entry.pattern);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return out;
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
// fs-safe's traversal guard, surfaced as this tool's typed STOP — runs before EVERY destination
|
|
580
|
+
// mutation, so a symlinked parent can never leak a write.
|
|
581
|
+
const guardDst = (fs, wtRoot, dstAbs) => {
|
|
582
|
+
try {
|
|
583
|
+
assertContainedRealPath(wtRoot, dstAbs, { lstat: fs.lstat });
|
|
584
|
+
} catch (err) {
|
|
585
|
+
throw stop(String(err?.message ?? err).replace(/^\[agent-workflow-kit\] /, ''));
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// The ONE path-removal door: containment + no-follow classification before a known leaf/tree is
|
|
590
|
+
// removed. Symlinks are unlinked as links; recursion enters plain directories only.
|
|
591
|
+
const removeNodeNoFollow = ({ root, abs, fs, label = abs, emptyOnly = false }) => {
|
|
592
|
+
guardDst(fs, root, dirname(abs));
|
|
593
|
+
const node = classifyNodeNoFollow(abs, fs);
|
|
594
|
+
if (node.kind === 'absent') return true;
|
|
595
|
+
if (node.kind === 'error') throw stop(`cannot inspect removal target ${label} (${node.error})`);
|
|
596
|
+
if (node.kind === 'plain-directory') {
|
|
597
|
+
let names;
|
|
598
|
+
try {
|
|
599
|
+
names = fs.readdir(abs);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
throw stop(`cannot enumerate removal target ${label} (${error?.code ?? 'fs error'})`, { causeCode: error?.code ?? 'fs error' });
|
|
602
|
+
}
|
|
603
|
+
if (emptyOnly && names.length > 0) return false;
|
|
604
|
+
for (const name of names) {
|
|
605
|
+
removeNodeNoFollow({ root, abs: join(abs, name), fs, label: `${label}/${name}` });
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
fs.rmdir(abs);
|
|
609
|
+
} catch (error) {
|
|
610
|
+
throw stop(`cannot remove directory ${label} (${error?.code ?? 'fs error'})`, { causeCode: error?.code ?? 'fs error' });
|
|
611
|
+
}
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
if (emptyOnly) return false;
|
|
615
|
+
if (node.kind === 'special' || node.kind === 'symlink-to-special') {
|
|
616
|
+
throw stop(`refusing to remove special node ${label}`);
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
fs.unlink(abs);
|
|
620
|
+
} catch (error) {
|
|
621
|
+
throw stop(`cannot remove ${label} (${error?.code ?? 'fs error'})`, { causeCode: error?.code ?? 'fs error' });
|
|
622
|
+
}
|
|
623
|
+
return true;
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const removeEmptyParentsNoFollow = ({ root, abs, fs }) => {
|
|
627
|
+
const parent = dirname(abs);
|
|
628
|
+
if (parent === root) return;
|
|
629
|
+
const removed = removeNodeNoFollow({
|
|
630
|
+
root, abs: parent, fs, label: relative(root, parent), emptyOnly: true,
|
|
631
|
+
});
|
|
632
|
+
if (removed) removeEmptyParentsNoFollow({ root, abs: parent, fs });
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const failAfterCopy = ({ cause, dstAbs, wtRoot, fs }) => {
|
|
636
|
+
const primary = cause.message.replace(/^\[agent-workflow-kit\] /, '');
|
|
637
|
+
const throwCleanupFailure = (error) => {
|
|
638
|
+
// a door STOP carries its underlying errno as causeCode — the composed contract stays concise
|
|
639
|
+
const cleanupCode = error?.causeCode ?? error?.code ?? 'fs error';
|
|
640
|
+
const cleanupDetail = cleanupCode === WORKTREES_STOP
|
|
641
|
+
? `: ${error.message.replace(/^\[agent-workflow-kit\] /, '')}`
|
|
642
|
+
: '';
|
|
643
|
+
throw stop(`${primary}; cleanup failed (${cleanupCode}${cleanupDetail}) — untrusted destination remains; remove it by hand: ${dstAbs}`);
|
|
644
|
+
};
|
|
645
|
+
const destination = (() => {
|
|
646
|
+
try {
|
|
647
|
+
return { stat: lstatNoFollow(fs.lstat, dstAbs) };
|
|
648
|
+
} catch (error) {
|
|
649
|
+
return { error };
|
|
650
|
+
}
|
|
651
|
+
})();
|
|
652
|
+
if (destination.error) throwCleanupFailure(destination.error);
|
|
653
|
+
if (destination.stat === null) throw cause;
|
|
654
|
+
try {
|
|
655
|
+
guardDst(fs, wtRoot, dstAbs);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
throwCleanupFailure(error);
|
|
658
|
+
}
|
|
659
|
+
try {
|
|
660
|
+
removeNodeNoFollow({ root: wtRoot, abs: dstAbs, fs, label: dstAbs });
|
|
661
|
+
} catch (error) {
|
|
662
|
+
throwCleanupFailure(error);
|
|
663
|
+
}
|
|
664
|
+
throw stop(`${primary} — partial destination removed; re-run provision`);
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied }) => {
|
|
668
|
+
if (EXCLUDED_BASENAMES.has(basename(srcAbs))) {
|
|
669
|
+
report.push(` skip (session sidecar): ${rel}`);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
let st;
|
|
673
|
+
try {
|
|
674
|
+
st = fs.lstat(srcAbs);
|
|
675
|
+
} catch (err) {
|
|
676
|
+
throw stop(`copy failed (${err?.code ?? 'fs error'}) reading ${rel}`);
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
if (st.isSymbolicLink()) {
|
|
680
|
+
if (lstatNoFollow(fs.lstat, dstAbs) !== null) {
|
|
681
|
+
report.push(` kept (already present): ${rel}`);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const target = fs.readlink(srcAbs);
|
|
685
|
+
if (isAbsolute(target)) throw stop(`refusing to copy an absolute symlink: ${rel} -> ${target}`);
|
|
686
|
+
const resolved = resolve(dirname(dstAbs), target);
|
|
687
|
+
if (!isInside(wtRoot, resolved)) {
|
|
688
|
+
throw stop(`refusing to copy a symlink escaping the worktree: ${rel} -> ${target}`);
|
|
689
|
+
}
|
|
690
|
+
// the lexical check alone can be re-routed through an existing symlinked component —
|
|
691
|
+
// canonicalize through the nearest existing ancestor and re-check
|
|
692
|
+
const canonicalTarget = (() => {
|
|
693
|
+
try {
|
|
694
|
+
return realpathThroughExistingParent(resolved, fs);
|
|
695
|
+
} catch {
|
|
696
|
+
throw stop(`refusing to copy a symlink with an unresolvable target: ${rel} -> ${target}`);
|
|
697
|
+
}
|
|
698
|
+
})();
|
|
699
|
+
const wtReal = (() => {
|
|
700
|
+
try {
|
|
701
|
+
return fs.realpath(wtRoot);
|
|
702
|
+
} catch {
|
|
703
|
+
return wtRoot;
|
|
704
|
+
}
|
|
705
|
+
})();
|
|
706
|
+
if (!isInside(wtReal, canonicalTarget)) {
|
|
707
|
+
throw stop(`refusing to copy a symlink escaping the worktree (canonical): ${rel} -> ${target}`);
|
|
708
|
+
}
|
|
709
|
+
guardDst(fs, wtRoot, dirname(dstAbs));
|
|
710
|
+
fs.mkdir(dirname(dstAbs));
|
|
711
|
+
guardDst(fs, wtRoot, dstAbs);
|
|
712
|
+
fs.symlink(target, dstAbs);
|
|
713
|
+
copied.add(rel);
|
|
714
|
+
report.push(` linked: ${rel} -> ${target}`);
|
|
715
|
+
} else if (st.isDirectory()) {
|
|
716
|
+
if (lstatNoFollow(fs.lstat, dstAbs) === null) {
|
|
717
|
+
guardDst(fs, wtRoot, dstAbs);
|
|
718
|
+
fs.mkdir(dstAbs);
|
|
719
|
+
}
|
|
720
|
+
for (const entry of fs.readdir(srcAbs)) {
|
|
721
|
+
copyNode({ srcAbs: join(srcAbs, entry), dstAbs: join(dstAbs, entry), wtRoot, rel: `${rel}/${entry}`, fs, report, copied });
|
|
722
|
+
}
|
|
723
|
+
} else if (st.isFile()) {
|
|
724
|
+
if (lstatNoFollow(fs.lstat, dstAbs) !== null) {
|
|
725
|
+
report.push(` kept (already present): ${rel}`);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
guardDst(fs, wtRoot, dirname(dstAbs));
|
|
729
|
+
fs.mkdir(dirname(dstAbs));
|
|
730
|
+
guardDst(fs, wtRoot, dstAbs);
|
|
731
|
+
try {
|
|
732
|
+
copyFileNoFollow({ srcAbs, dstAbs, sourceStat: st, rel, fs });
|
|
733
|
+
} catch (err) {
|
|
734
|
+
const cause = err?.code === WORKTREES_STOP ? err : stop(`copy failed (${err?.code ?? 'fs error'}) at ${rel}`);
|
|
735
|
+
if (err?.copyDoorDestinationCreated !== true) throw cause;
|
|
736
|
+
failAfterCopy({ cause, dstAbs, wtRoot, fs });
|
|
737
|
+
}
|
|
738
|
+
copied.add(rel);
|
|
739
|
+
report.push(` copied: ${rel}`);
|
|
740
|
+
} else {
|
|
741
|
+
throw stop(`refusing to copy a special file (device/FIFO/socket): ${rel}`);
|
|
742
|
+
}
|
|
743
|
+
} catch (err) {
|
|
744
|
+
if (err?.code === WORKTREES_STOP) throw err;
|
|
745
|
+
throw stop(`copy failed (${err?.code ?? 'fs error'}) at ${rel}`);
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
export const copyTreeIfMissing = ({ srcAbs, dstAbs, wtRoot, rel, deps = {} }) => {
|
|
750
|
+
const report = [];
|
|
751
|
+
const copied = new Set();
|
|
752
|
+
copyNode({ srcAbs, dstAbs, wtRoot, rel, fs: fsOf(deps), report, copied });
|
|
753
|
+
return { report, copied: [...copied] };
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
// ── absolute-pin rebase (pure; slash-normalized both ways) ─────────────────────────────
|
|
757
|
+
|
|
758
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
759
|
+
|
|
760
|
+
export const rebaseAbsolutePins = (text, mainRoot, wtRoot) => {
|
|
761
|
+
const mainFwd = normalizeSlashes(mainRoot);
|
|
762
|
+
const wtFwd = normalizeSlashes(wtRoot);
|
|
763
|
+
const mainBack = mainFwd.replace(/\//g, '\\');
|
|
764
|
+
const wtBack = wtFwd.replace(/\//g, '\\');
|
|
765
|
+
// three encodings of the same pin: forward, raw backslash, and JSON-escaped doubled
|
|
766
|
+
// backslash (serialized settings/gates content) — the doubled pass runs FIRST
|
|
767
|
+
const mainBack2 = mainBack.replace(/\\/g, '\\\\');
|
|
768
|
+
const wtBack2 = wtBack.replace(/\\/g, '\\\\');
|
|
769
|
+
const boundary = '(?=[/\\\\"\'\\s]|$)';
|
|
770
|
+
const fwdRe = () => new RegExp(escapeRe(mainFwd) + boundary, 'g');
|
|
771
|
+
const backRe = () => new RegExp(escapeRe(mainBack) + boundary, 'g');
|
|
772
|
+
const back2Re = () => new RegExp(escapeRe(mainBack2) + boundary, 'g');
|
|
773
|
+
// function replacements — a worktree path carrying $&/$$/$' must land byte-literal
|
|
774
|
+
const rebased = text.replace(back2Re(), () => wtBack2).replace(fwdRe(), () => wtFwd).replace(backRe(), () => wtBack);
|
|
775
|
+
const changes = [];
|
|
776
|
+
if (rebased !== text) {
|
|
777
|
+
const before = text.split('\n');
|
|
778
|
+
const after = rebased.split('\n');
|
|
779
|
+
before.forEach((line, i) => {
|
|
780
|
+
if (line !== after[i]) {
|
|
781
|
+
const count = (line.match(back2Re()) ?? []).length
|
|
782
|
+
+ (line.match(fwdRe()) ?? []).length
|
|
783
|
+
+ (line.match(backRe()) ?? []).length;
|
|
784
|
+
changes.push({ line: i + 1, count, before: line, after: after[i] });
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
return { text: rebased, changes };
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
// ── pre-add source sweeps (containment + target-collision; nothing mutates before them) ─
|
|
792
|
+
|
|
793
|
+
// Every lstat-PRESENT registry entry (dir-pattern symlinks included — the copy set excludes
|
|
794
|
+
// them, but a symlinked root must still never smuggle outside content) realpath-resolves
|
|
795
|
+
// INSIDE the main repo. Returns the present entries with their realpaths for the collision check.
|
|
796
|
+
const assertProvisionSourcesContained = ({ root, rootReal, fs, statFollow }) => {
|
|
797
|
+
const rels = [];
|
|
798
|
+
for (const pattern of [...KIT_OWN_PATHS, ...KNOWN_FOOTPRINT.map((e) => e.pattern)]) {
|
|
799
|
+
if (SEEDED_SEPARATELY.has(pattern)) continue;
|
|
800
|
+
if (isGlobPattern(pattern)) {
|
|
801
|
+
rels.push(...expandGlob(pattern, { dir: root, readdir: fs.readdir, stat: statFollow }).map((p) => patternToProbe(p)));
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
const rel = patternToProbe(pattern).replace(/\/$/, '');
|
|
805
|
+
if (lstatNoFollow(fs.lstat, join(root, rel)) !== null) rels.push(rel);
|
|
806
|
+
}
|
|
807
|
+
const sources = [];
|
|
808
|
+
for (const rel of rels) {
|
|
809
|
+
const abs = join(root, rel);
|
|
810
|
+
let real;
|
|
811
|
+
try {
|
|
812
|
+
real = fs.realpath(abs);
|
|
813
|
+
} catch (err) {
|
|
814
|
+
// realpath validation stays authoritative for resolvable paths; the ONE lexical
|
|
815
|
+
// acceptance is an inside-root ENOENT-dangling symlink (the copy mirrors it as a link)
|
|
816
|
+
const st = lstatNoFollow(fs.lstat, abs);
|
|
817
|
+
if (st !== null && st.isSymbolicLink() && err?.code === 'ENOENT') {
|
|
818
|
+
const target = fs.readlink(abs);
|
|
819
|
+
const lexical = isAbsolute(target) ? resolve(target) : resolve(dirname(abs), target);
|
|
820
|
+
if (isAbsolute(target) || !isInside(rootReal, lexical)) {
|
|
821
|
+
throw stop(`provision source escapes the main repo via a symlink (dangling): ${rel} -> ${target} — fix or remove it before provisioning`);
|
|
822
|
+
}
|
|
823
|
+
const canonical = (() => {
|
|
824
|
+
try {
|
|
825
|
+
return realpathThroughExistingParent(lexical, fs);
|
|
826
|
+
} catch {
|
|
827
|
+
throw stop(`provision source unresolvable (dangling chain): ${rel} -> ${target} — fix or remove it before provisioning`);
|
|
828
|
+
}
|
|
829
|
+
})();
|
|
830
|
+
if (!isInside(rootReal, canonical)) {
|
|
831
|
+
throw stop(`provision source escapes the main repo via a symlink (dangling): ${rel} -> ${target} — fix or remove it before provisioning`);
|
|
832
|
+
}
|
|
833
|
+
sources.push({ rel, real: canonical });
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
throw stop(`provision source unresolvable (${err?.code ?? 'fs error'}): ${rel} — fix or remove it before provisioning`);
|
|
837
|
+
}
|
|
838
|
+
if (!isInside(rootReal, real)) {
|
|
839
|
+
throw stop(`provision source escapes the main repo via a symlink: ${rel} -> ${real} — fix or remove it before provisioning`);
|
|
840
|
+
}
|
|
841
|
+
sources.push({ rel, real });
|
|
842
|
+
}
|
|
843
|
+
return sources;
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
// A target inside a provision-source subtree would copy ITSELF recursively at copy time.
|
|
847
|
+
const assertTargetOutsideSources = ({ targetReal, sources }) => {
|
|
848
|
+
for (const s of sources) {
|
|
849
|
+
if (targetReal === s.real || isInside(s.real, targetReal)) {
|
|
850
|
+
throw stop(`the target dir is inside a provision source (${s.rel}) — pick a dir outside every provision source`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
// ── the shared plans-chain scanner (resume identity + list ride the SAME no-follow walk) ─
|
|
856
|
+
|
|
857
|
+
// Whole-chain no-follow: the worktree root, docs, and docs/plans must be plain directories;
|
|
858
|
+
// handoff candidates count ONLY as regular files. states: ok | absent | unreadable.
|
|
859
|
+
// ANY stat failure (not just readdir) renders honestly — list must never crash on a bad node.
|
|
860
|
+
const scanPlansDir = ({ wtRoot, fs }) => {
|
|
861
|
+
if (classifyNodeNoFollow(wtRoot, fs).kind !== 'plain-directory') return { state: 'unreadable' };
|
|
862
|
+
const docs = classifyNodeNoFollow(join(wtRoot, 'docs'), fs);
|
|
863
|
+
if (docs.kind === 'absent') return { state: 'absent' };
|
|
864
|
+
if (docs.kind !== 'plain-directory') return { state: 'unreadable' };
|
|
865
|
+
const plans = classifyNodeNoFollow(join(wtRoot, PLANS_REL), fs);
|
|
866
|
+
if (plans.kind === 'absent') return { state: 'absent' };
|
|
867
|
+
if (plans.kind !== 'plain-directory') return { state: 'unreadable' };
|
|
868
|
+
let names;
|
|
869
|
+
try {
|
|
870
|
+
names = fs.readdir(join(wtRoot, PLANS_REL));
|
|
871
|
+
} catch {
|
|
872
|
+
return { state: 'unreadable' };
|
|
873
|
+
}
|
|
874
|
+
const handoffs = [];
|
|
875
|
+
const nonRegular = [];
|
|
876
|
+
for (const n of names) {
|
|
877
|
+
if (!/^handoff-.+\.md$/.test(n)) continue;
|
|
878
|
+
const cand = classifyNodeNoFollow(join(wtRoot, PLANS_REL, n), fs);
|
|
879
|
+
if (cand.kind !== 'regular-file') nonRegular.push(n);
|
|
880
|
+
else handoffs.push(n);
|
|
881
|
+
}
|
|
882
|
+
return { state: 'ok', handoffs, nonRegular };
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// Resume writes NOTHING before this: the existing handoff must be the live identity.
|
|
886
|
+
const assertResumeHandoffIdentity = ({ wtRoot, slug, branch, fs }) => {
|
|
887
|
+
const scan = scanPlansDir({ wtRoot, fs });
|
|
888
|
+
if (scan.state === 'unreadable') {
|
|
889
|
+
throw stop('--resume: the worktree docs/plans chain is not a plain directory tree (a symlink or special node is in the way) — fix it before resuming');
|
|
890
|
+
}
|
|
891
|
+
if (scan.state === 'absent') return;
|
|
892
|
+
if (scan.nonRegular.length > 0) {
|
|
893
|
+
throw stop(`--resume: handoff-named entr${scan.nonRegular.length === 1 ? 'y is' : 'ies are'} not regular file(s): ${scan.nonRegular.join(', ')} — fix before resuming`);
|
|
894
|
+
}
|
|
895
|
+
if (scan.handoffs.length === 0) return;
|
|
896
|
+
if (scan.handoffs.length > 1) {
|
|
897
|
+
throw stop(`--resume: multiple handoff files found (${scan.handoffs.join(', ')}) — exactly one may exist`);
|
|
898
|
+
}
|
|
899
|
+
const name = scan.handoffs[0];
|
|
900
|
+
if (name !== handoffBasename(slug)) {
|
|
901
|
+
throw stop(`--resume identity mismatch: the existing handoff is ${name}, the live slug is ${slug} (${handoffBasename(slug)})`);
|
|
902
|
+
}
|
|
903
|
+
const rf = readFileNoFollow(fs, join(wtRoot, PLANS_REL, name));
|
|
904
|
+
if (!rf.bytes) throw stop(`--resume: the handoff ${name} is not readable as a regular file — fix it before resuming`);
|
|
905
|
+
const record = parseProvisionRecord(String(rf.bytes));
|
|
906
|
+
if (record.slug !== slug) {
|
|
907
|
+
throw stop(`--resume identity mismatch: the handoff record slug is ${record.slug ?? '(missing)'}, the live slug is ${slug}`);
|
|
908
|
+
}
|
|
909
|
+
if (record.branch !== branch) {
|
|
910
|
+
throw stop(`--resume identity mismatch: the handoff record branch is ${record.branch ?? '(missing)'}, the live branch is ${branch}`);
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const assertResumePlanCompatibility = ({ wtRoot, seedName, fs }) => {
|
|
915
|
+
const inFlight = plansInFlight(wtRoot, fs.readdir);
|
|
916
|
+
if (inFlight.length === 0 || (inFlight.length === 1 && inFlight[0] === seedName)) return;
|
|
917
|
+
if (inFlight.length === 1) {
|
|
918
|
+
throw stop(
|
|
919
|
+
`--resume plan mismatch: found [${inFlight[0]}], expected [${seedName}] or no in-flight plan — ` +
|
|
920
|
+
`re-run with --as ${inFlight[0]}, or remove the existing plan by hand`,
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
throw stop(
|
|
924
|
+
`the worktree must hold EXACTLY ONE in-flight plan, found [${inFlight.join(', ')}] — remove the extras (or re-seed) and re-run --resume`,
|
|
925
|
+
);
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
// ── the handoff artifact (the tool's own record inside it; list/cleanup read it) ───────
|
|
929
|
+
|
|
930
|
+
const composeProvisionRecordSection = ({ slug, branch, includes, nodeModules, vscode, prepared = null }) => [
|
|
931
|
+
'## Provision record',
|
|
932
|
+
'',
|
|
933
|
+
`- slug: ${slug}`,
|
|
934
|
+
`- branch: ${branch}`,
|
|
935
|
+
...(includes.length === 0 ? ['- include: (none)'] : includes.map((p) => `- include: ${p}`)),
|
|
936
|
+
`- node_modules: ${nodeModules}`,
|
|
937
|
+
`- vscode-settings: ${vscode}`,
|
|
938
|
+
...(prepared === null ? [] : [`- prepared-tree: ${prepared}`]),
|
|
939
|
+
'',
|
|
940
|
+
].join('\n');
|
|
941
|
+
|
|
942
|
+
export const composeHandoffStub = (fields) => [
|
|
943
|
+
`# Handoff — ${fields.slug}`,
|
|
944
|
+
'',
|
|
945
|
+
'provisioned, nothing done yet',
|
|
946
|
+
'',
|
|
947
|
+
composeProvisionRecordSection(fields),
|
|
948
|
+
].join('\n');
|
|
949
|
+
|
|
950
|
+
const ATX_SECTION_HEADING = /^ {0,3}#{1,2} /;
|
|
951
|
+
|
|
952
|
+
const locateProvisionRecordSection = (text) => {
|
|
953
|
+
const source = String(text);
|
|
954
|
+
const lines = [...source.matchAll(/.*(?:\r?\n|$)/g)].filter((match) => match[0] !== '');
|
|
955
|
+
const headings = lines.filter((match) => match[0].replace(/\r?\n$/, '').trim() === '## Provision record');
|
|
956
|
+
if (headings.length === 0) throw stop('handoff record: missing required "## Provision record" section');
|
|
957
|
+
if (headings.length > 1) throw stop('handoff record: multiple "## Provision record" sections — the record is ambiguous');
|
|
958
|
+
const start = headings[0].index;
|
|
959
|
+
const nextHeading = lines.find((match) => match.index > start && ATX_SECTION_HEADING.test(match[0].replace(/\r?\n$/, '')));
|
|
960
|
+
return { source, start, end: nextHeading?.index ?? source.length };
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
// ONLY the required section is parsed, so decoy fields elsewhere cannot hijack identity.
|
|
964
|
+
// Duplicated single-valued fields are ambiguous identity → typed STOP, never last-wins.
|
|
965
|
+
export const parseProvisionRecord = (text) => {
|
|
966
|
+
const section = locateProvisionRecordSection(text);
|
|
967
|
+
const scan = section.source.slice(section.start, section.end).split('\n').slice(1);
|
|
968
|
+
const record = { slug: null, branch: null, includes: [], nodeModules: null, vscode: null, prepared: null };
|
|
969
|
+
const single = {
|
|
970
|
+
slug: 'slug', branch: 'branch', node_modules: 'nodeModules',
|
|
971
|
+
'vscode-settings': 'vscode', 'prepared-tree': 'prepared',
|
|
972
|
+
};
|
|
973
|
+
const seen = new Set();
|
|
974
|
+
for (const line of scan) {
|
|
975
|
+
const m = line.match(/^- ([a-z_-]+): (.*)$/);
|
|
976
|
+
if (!m) continue;
|
|
977
|
+
const value = m[2].trim();
|
|
978
|
+
if (m[1] === 'include') {
|
|
979
|
+
if (value !== '(none)') record.includes.push(value);
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
const key = single[m[1]];
|
|
983
|
+
if (!key) continue;
|
|
984
|
+
if (seen.has(m[1])) throw stop(`handoff record: duplicate "${m[1]}" field — the record is ambiguous`);
|
|
985
|
+
seen.add(m[1]);
|
|
986
|
+
record[key] = value;
|
|
987
|
+
}
|
|
988
|
+
return record;
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
const pendingHandoffFields = ({ slug, branch }) =>
|
|
992
|
+
({ slug, branch, includes: [], nodeModules: 'pending', vscode: 'pending' });
|
|
993
|
+
|
|
994
|
+
// The stub is written only when ABSENT; the final record surgically replaces the tool section.
|
|
995
|
+
const writeHandoffStubIfAbsent = ({ wtRoot, slug, branch, fs, report }) => {
|
|
996
|
+
const dst = join(wtRoot, PLANS_REL, handoffBasename(slug));
|
|
997
|
+
const cur = readFileNoFollow(fs, dst);
|
|
998
|
+
if (cur.bytes) {
|
|
999
|
+
report.push(' handoff: kept (already present)');
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (!cur.absent) {
|
|
1003
|
+
throw stop(`the handoff at ${PLANS_REL}/${handoffBasename(slug)} is not readable as a regular file — fix or remove it, then re-run`);
|
|
1004
|
+
}
|
|
1005
|
+
guardDst(fs, wtRoot, dirname(dst));
|
|
1006
|
+
fs.mkdir(dirname(dst));
|
|
1007
|
+
writeContainedFileAtomic(wtRoot, dst, composeHandoffStub(pendingHandoffFields({ slug, branch })), fs, { stop: (m) => stop(m) });
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report }) => {
|
|
1011
|
+
const dst = join(wtRoot, PLANS_REL, handoffBasename(slug));
|
|
1012
|
+
const cur = readFileNoFollow(fs, dst);
|
|
1013
|
+
if (!cur.bytes) {
|
|
1014
|
+
throw stop(`the handoff at ${PLANS_REL}/${handoffBasename(slug)} is not readable as a regular file — fix or remove it, then re-run --resume`);
|
|
1015
|
+
}
|
|
1016
|
+
const section = locateProvisionRecordSection(String(cur.bytes));
|
|
1017
|
+
const updated = `${section.source.slice(0, section.start)}${composeProvisionRecordSection(fields)}${section.source.slice(section.end)}`;
|
|
1018
|
+
writeContainedFileAtomic(wtRoot, dst, updated, fs, { stop: (m) => stop(m) });
|
|
1019
|
+
report.push(' handoff: provision record refreshed (user sections preserved)');
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
// ── provision ──────────────────────────────────────────────────────────────────────────
|
|
1023
|
+
|
|
1024
|
+
// Validated BEFORE any git mutation — a bad --plan/--as never leaves a half-made worktree.
|
|
1025
|
+
const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
|
|
1026
|
+
if (asFlag !== null && (asFlag.includes('/') || asFlag.includes('\\') || !asFlag.endsWith('.md'))) {
|
|
1027
|
+
throw usageStop(`--as must be a basename ending in .md, got ${JSON.stringify(asFlag)}`);
|
|
1028
|
+
}
|
|
1029
|
+
const srcAbs = resolve(root, planFlag);
|
|
1030
|
+
const node = classifyNodeNoFollow(srcAbs, fs);
|
|
1031
|
+
if (node.kind === 'absent') throw stop(`--plan: not found: ${planFlag}`);
|
|
1032
|
+
if (node.kind === 'error') throw stop(`--plan: cannot inspect ${planFlag} (${node.error})`);
|
|
1033
|
+
if (node.kind !== 'regular-file') throw stop(`--plan must be a regular non-symlink file: ${planFlag}`);
|
|
1034
|
+
let srcReal;
|
|
1035
|
+
try {
|
|
1036
|
+
srcReal = fs.realpath(srcAbs);
|
|
1037
|
+
} catch {
|
|
1038
|
+
throw stop(`--plan: not found: ${planFlag}`);
|
|
1039
|
+
}
|
|
1040
|
+
if (!isInside(rootReal, srcReal)) throw stop(`--plan must resolve inside the main repo: ${planFlag}`);
|
|
1041
|
+
if (normalizeSlashes(dirname(srcReal)) === normalizeSlashes(join(rootReal, PLANS_REL)) && !isScratchPlanName(basename(srcReal))) {
|
|
1042
|
+
throw stop(
|
|
1043
|
+
`--plan names a bare (in-flight) plan inside MAIN's ${PLANS_REL} — the feature plan must live in the satellite ONLY, ` +
|
|
1044
|
+
'else main keeps a plan in flight and every land trips the review-state gate. ' +
|
|
1045
|
+
'Recovery: rename the main copy to a scratch name (or remove it), then re-run.',
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
const name = asFlag ?? basename(srcAbs);
|
|
1049
|
+
if (!name.endsWith('.md')) throw stop(`the seeded plan name must end in .md: ${name}`);
|
|
1050
|
+
if (isScratchPlanName(name)) {
|
|
1051
|
+
throw stop(
|
|
1052
|
+
`refusing to seed a scratch-class plan name (${name}) — the worktree's review-state would read it as "no plan ` +
|
|
1053
|
+
'in flight" and every council check would pass vacuously. Seed a bare name via --as <name>.md.',
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
return { srcAbs: srcReal, name };
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
|
|
1060
|
+
const dst = join(wtRoot, PLANS_REL, name);
|
|
1061
|
+
if (lstatNoFollow(fs.lstat, dst) !== null) {
|
|
1062
|
+
report.push(` kept (already present): ${PLANS_REL}/${name}`);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const src = readFileNoFollow(fs, srcAbs);
|
|
1066
|
+
if (!src.bytes) throw stop(`--plan: not readable as a regular file: ${srcAbs}`);
|
|
1067
|
+
guardDst(fs, wtRoot, dirname(dst));
|
|
1068
|
+
fs.mkdir(dirname(dst));
|
|
1069
|
+
writeContainedFileAtomic(wtRoot, dst, String(src.bytes), fs, { stop: (m) => stop(m) });
|
|
1070
|
+
report.push(` seeded plan: ${PLANS_REL}/${name}`);
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
const provisionIncludes = ({ root, rootReal, wtRoot, includes, git, fs, report, copied }) => {
|
|
1074
|
+
const recorded = [];
|
|
1075
|
+
for (const inc of includes) {
|
|
1076
|
+
const srcAbs = resolve(root, inc);
|
|
1077
|
+
let srcReal;
|
|
1078
|
+
try {
|
|
1079
|
+
srcReal = fs.realpath(srcAbs);
|
|
1080
|
+
} catch {
|
|
1081
|
+
throw stop(`--include: not found: ${inc}`);
|
|
1082
|
+
}
|
|
1083
|
+
if (!isInside(rootReal, srcReal)) throw stop(`--include must resolve inside the main repo: ${inc}`);
|
|
1084
|
+
const rel = relative(rootReal, srcReal);
|
|
1085
|
+
const probeRel = fs.lstat(srcReal).isDirectory() ? `${rel}/` : rel;
|
|
1086
|
+
if (!checkIgnored(git, probeRel, wtRoot)) {
|
|
1087
|
+
throw stop(
|
|
1088
|
+
`--include destination is not ignored in the worktree: ${rel} — it would become a land-preflight leftover. ` +
|
|
1089
|
+
'Recovery: ignore the path (shared exclude / .gitignore) or drop the --include.',
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
copyNode({ srcAbs: srcReal, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied });
|
|
1093
|
+
recorded.push(rel);
|
|
1094
|
+
}
|
|
1095
|
+
return recorded;
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
const LOCKFILE_MANAGERS = Object.freeze([
|
|
1099
|
+
['package-lock.json', 'npm'],
|
|
1100
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
1101
|
+
['yarn.lock', 'yarn'],
|
|
1102
|
+
['bun.lockb', 'bun'],
|
|
1103
|
+
['bun.lock', 'bun'],
|
|
1104
|
+
]);
|
|
1105
|
+
const NEUTRAL_INSTALL_ADVICE =
|
|
1106
|
+
'install command not printed — package manager is ambiguous or unknown; install dependencies in the worktree by hand';
|
|
1107
|
+
|
|
1108
|
+
const resolveInstallAdvice = ({ root, wtRoot, fs }) => {
|
|
1109
|
+
const pkg = readFileNoFollow(fs, join(root, 'package.json'));
|
|
1110
|
+
let manager = null;
|
|
1111
|
+
let inspectLocks = false;
|
|
1112
|
+
if (pkg.absent) {
|
|
1113
|
+
inspectLocks = true;
|
|
1114
|
+
} else if (pkg.bytes) {
|
|
1115
|
+
try {
|
|
1116
|
+
const parsed = JSON.parse(String(pkg.bytes));
|
|
1117
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1118
|
+
return { command: null, instruction: NEUTRAL_INSTALL_ADVICE };
|
|
1119
|
+
}
|
|
1120
|
+
if (Object.hasOwn(parsed, 'packageManager')) {
|
|
1121
|
+
const match = typeof parsed.packageManager === 'string'
|
|
1122
|
+
? /^(npm|pnpm|yarn|bun)@[^\s]+$/.exec(parsed.packageManager)
|
|
1123
|
+
: null;
|
|
1124
|
+
if (!match) return { command: null, instruction: NEUTRAL_INSTALL_ADVICE };
|
|
1125
|
+
manager = match[1];
|
|
1126
|
+
} else {
|
|
1127
|
+
inspectLocks = true;
|
|
1128
|
+
}
|
|
1129
|
+
} catch {
|
|
1130
|
+
return { command: null, instruction: NEUTRAL_INSTALL_ADVICE };
|
|
1131
|
+
}
|
|
1132
|
+
} else {
|
|
1133
|
+
return { command: null, instruction: NEUTRAL_INSTALL_ADVICE };
|
|
1134
|
+
}
|
|
1135
|
+
if (inspectLocks) {
|
|
1136
|
+
const found = [];
|
|
1137
|
+
for (const [name, candidate] of LOCKFILE_MANAGERS) {
|
|
1138
|
+
let st;
|
|
1139
|
+
try {
|
|
1140
|
+
st = fs.lstat(join(root, name));
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
if (err?.code === 'ENOENT') continue;
|
|
1143
|
+
return { command: null, instruction: NEUTRAL_INSTALL_ADVICE };
|
|
1144
|
+
}
|
|
1145
|
+
if (st.isSymbolicLink() || !st.isFile()) return { command: null, instruction: NEUTRAL_INSTALL_ADVICE };
|
|
1146
|
+
found.push(candidate);
|
|
1147
|
+
}
|
|
1148
|
+
if (found.length > 1) return { command: null, instruction: NEUTRAL_INSTALL_ADVICE };
|
|
1149
|
+
manager = found[0] ?? 'npm';
|
|
1150
|
+
}
|
|
1151
|
+
const command = `cd ${shellQuoteArg(wtRoot)} && ${manager} install`;
|
|
1152
|
+
return { command, instruction: command };
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, git, fs, report }) => {
|
|
1156
|
+
const install = resolveInstallAdvice({ root, wtRoot, fs });
|
|
1157
|
+
if (installFlag) {
|
|
1158
|
+
const dst = join(wtRoot, 'node_modules');
|
|
1159
|
+
const existing = lstatNoFollow(fs.lstat, dst);
|
|
1160
|
+
if (existing !== null && existing.isSymbolicLink()) {
|
|
1161
|
+
// isolation only exists BEFORE the link: an install through it would write into MAIN
|
|
1162
|
+
const separator = install.command === null ? ' — ' : ' && ';
|
|
1163
|
+
report.push(` node_modules: existing symlink kept — for isolation remove it first: rm ${shellQuoteArg(dst)}${separator}${install.instruction}`);
|
|
1164
|
+
return 'install-printed-unlink-first';
|
|
1165
|
+
}
|
|
1166
|
+
report.push(install.command === null
|
|
1167
|
+
? ` node_modules: ${install.instruction}`
|
|
1168
|
+
: ` node_modules: install it yourself (zero spawn): ${install.instruction}`);
|
|
1169
|
+
return 'install-printed';
|
|
1170
|
+
}
|
|
1171
|
+
const mainNm = join(root, 'node_modules');
|
|
1172
|
+
const node = classifyNodeNoFollow(mainNm, fs);
|
|
1173
|
+
if (node.kind === 'absent') {
|
|
1174
|
+
report.push(` node_modules: main has none — after your own install there, re-run --resume, or: ${install.instruction}`);
|
|
1175
|
+
return 'absent';
|
|
1176
|
+
}
|
|
1177
|
+
if (node.kind === 'symlink-unresolvable') {
|
|
1178
|
+
report.push(` node_modules: main's is unresolvable — ${install.instruction}`);
|
|
1179
|
+
return 'unresolvable';
|
|
1180
|
+
}
|
|
1181
|
+
if (node.kind === 'error') throw stop(`cannot inspect main's node_modules (${node.error})`);
|
|
1182
|
+
if (node.kind !== 'plain-directory' && node.kind !== 'symlink-to-directory') {
|
|
1183
|
+
report.push(` node_modules: main's node_modules is neither a plain directory nor a symlink resolving to a directory — not symlinked; ${install.instruction}`);
|
|
1184
|
+
return 'invalid-kind';
|
|
1185
|
+
}
|
|
1186
|
+
// A directory-form ignore pattern (node_modules/) never covers a SYMLINK of that name — the
|
|
1187
|
+
// link would surface as an untracked leftover; the placed-paths-stay-ignored rule applies.
|
|
1188
|
+
// Deliberately slashless: this probes the SYMLINK form.
|
|
1189
|
+
if (!checkIgnored(git, 'node_modules', wtRoot)) {
|
|
1190
|
+
report.push(` node_modules: a symlink would not be ignored here (only the directory form is) — not symlinked; ${install.instruction}`);
|
|
1191
|
+
return 'not-ignored';
|
|
1192
|
+
}
|
|
1193
|
+
let mainNmReal;
|
|
1194
|
+
try {
|
|
1195
|
+
mainNmReal = fs.realpath(mainNm);
|
|
1196
|
+
} catch {
|
|
1197
|
+
report.push(` node_modules: main's is unresolvable — ${install.instruction}`);
|
|
1198
|
+
return 'unresolvable';
|
|
1199
|
+
}
|
|
1200
|
+
if (!isInside(rootReal, mainNmReal)) {
|
|
1201
|
+
report.push(` node_modules: main's resolves outside the repo — not symlinked; ${install.instruction}`);
|
|
1202
|
+
return 'outside-repo';
|
|
1203
|
+
}
|
|
1204
|
+
const dst = join(wtRoot, 'node_modules');
|
|
1205
|
+
if (lstatNoFollow(fs.lstat, dst) !== null) {
|
|
1206
|
+
report.push(' node_modules: already present in the worktree');
|
|
1207
|
+
return 'present';
|
|
1208
|
+
}
|
|
1209
|
+
guardDst(fs, wtRoot, dst);
|
|
1210
|
+
try {
|
|
1211
|
+
fs.symlink(mainNm, dst);
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
report.push(` node_modules: symlink failed (${err?.code ?? 'error'}) — ${install.instruction}`);
|
|
1214
|
+
return 'symlink-failed';
|
|
1215
|
+
}
|
|
1216
|
+
report.push(` node_modules: symlinked -> ${mainNm} (shared MUTABLE cache — writes through it hit MAIN's node_modules; isolation: --install; workspace self-links resolve to MAIN sources)`);
|
|
1217
|
+
return 'symlinked';
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
|
|
1221
|
+
const relPath = '.vscode/settings.json';
|
|
1222
|
+
const vscodeDir = lstatNoFollow(fs.lstat, join(root, '.vscode'));
|
|
1223
|
+
if (vscodeDir === null || !vscodeDir.isDirectory()) {
|
|
1224
|
+
report.push(' .vscode: main has no .vscode/ dir — window title not written');
|
|
1225
|
+
return 'absent';
|
|
1226
|
+
}
|
|
1227
|
+
const tracked = git(['ls-files', '--', relPath], root);
|
|
1228
|
+
if (tracked.status !== 0) {
|
|
1229
|
+
throw stop(`git ls-files failed for ${relPath}: ${(tracked.stderr || tracked.stdout).trim()}`);
|
|
1230
|
+
}
|
|
1231
|
+
if (tracked.stdout.trim() !== '') {
|
|
1232
|
+
report.push(` .vscode: ${relPath} is tracked — left byte-unchanged (set the window title by hand if wanted)`);
|
|
1233
|
+
return 'skipped-tracked';
|
|
1234
|
+
}
|
|
1235
|
+
if (!checkIgnored(git, relPath, wtRoot)) {
|
|
1236
|
+
report.push(` .vscode: ${relPath} is not ignored in the worktree — skipped (it would become a land leftover)`);
|
|
1237
|
+
return 'skipped-not-ignored';
|
|
1238
|
+
}
|
|
1239
|
+
if (lstatNoFollow(fs.lstat, join(wtRoot, relPath)) !== null) {
|
|
1240
|
+
report.push(' .vscode: kept (already present)');
|
|
1241
|
+
return 'kept';
|
|
1242
|
+
}
|
|
1243
|
+
const src = readFileNoFollow(fs, join(root, relPath));
|
|
1244
|
+
if (src.unsafe) {
|
|
1245
|
+
report.push(` .vscode: main's ${relPath} is not a regular file — skipped`);
|
|
1246
|
+
return 'skipped-unsafe';
|
|
1247
|
+
}
|
|
1248
|
+
if (src.error) {
|
|
1249
|
+
report.push(` .vscode: main's ${relPath} is unreadable (${src.error}) — skipped`);
|
|
1250
|
+
return 'skipped-unreadable';
|
|
1251
|
+
}
|
|
1252
|
+
const base = (() => {
|
|
1253
|
+
if (!src.bytes) return {};
|
|
1254
|
+
try {
|
|
1255
|
+
return JSON.parse(String(src.bytes));
|
|
1256
|
+
} catch {
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
})();
|
|
1260
|
+
if (base === null || typeof base !== 'object' || Array.isArray(base)) {
|
|
1261
|
+
report.push(` .vscode: main's ${relPath} is not a JSON object — skipped`);
|
|
1262
|
+
return 'skipped-unparsable';
|
|
1263
|
+
}
|
|
1264
|
+
const body = `${JSON.stringify({ ...base, 'window.title': slug }, null, 2)}\n`;
|
|
1265
|
+
guardDst(fs, wtRoot, join(wtRoot, '.vscode'));
|
|
1266
|
+
fs.mkdir(join(wtRoot, '.vscode'));
|
|
1267
|
+
writeContainedFileAtomic(wtRoot, join(wtRoot, relPath), body, fs, { stop: (m) => stop(m) });
|
|
1268
|
+
report.push(` .vscode: ${relPath} written (window.title = ${slug})`);
|
|
1269
|
+
return 'written';
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
// tracked/untracked is decided by GIT (a run-local copy log lies after a crash-resume); an
|
|
1273
|
+
// untracked pin-carrying file is rewritten ONLY when its bytes equal the MAIN source or its
|
|
1274
|
+
// already-rebased form — anything else is user work and stays byte-untouched (reported).
|
|
1275
|
+
const rebasePins = ({ root, wtRoot, git, fs, report }) => {
|
|
1276
|
+
for (const target of REBASE_TARGETS) {
|
|
1277
|
+
const wtAbs = join(wtRoot, target);
|
|
1278
|
+
const cur = readFileNoFollow(fs, wtAbs);
|
|
1279
|
+
if (cur.absent) continue;
|
|
1280
|
+
if (!cur.bytes) {
|
|
1281
|
+
report.push(` ${target}: ${cur.unsafe ? 'not a regular file' : `unreadable (${cur.error})`} — left untouched`);
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
const tracked = git(['ls-files', '--', target], wtRoot);
|
|
1285
|
+
if (tracked.status !== 0) throw stop(`git ls-files failed for ${target}: ${(tracked.stderr || tracked.stdout).trim()}`);
|
|
1286
|
+
const text = String(cur.bytes);
|
|
1287
|
+
const { text: rebased, changes } = rebaseAbsolutePins(text, root, wtRoot);
|
|
1288
|
+
if (tracked.stdout.trim() !== '') {
|
|
1289
|
+
if (changes.length > 0) report.push(` ${target}: ${TRACKED_PIN_DECLARATION}`);
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
if (changes.length === 0) continue;
|
|
1293
|
+
const main = readFileNoFollow(fs, join(root, target));
|
|
1294
|
+
const mainText = main.bytes ? String(main.bytes) : null;
|
|
1295
|
+
const rebasedMain = mainText === null ? null : rebaseAbsolutePins(mainText, root, wtRoot).text;
|
|
1296
|
+
if (mainText !== null && (text === mainText || text === rebasedMain)) {
|
|
1297
|
+
writeContainedFileAtomic(wtRoot, wtAbs, rebasedMain, fs, { stop: (m) => stop(m) });
|
|
1298
|
+
// file:line + count only — settings lines can carry secrets, so content never hits the report
|
|
1299
|
+
for (const c of changes) report.push(` rebased ${target}:${c.line} (${c.count} replacement${c.count === 1 ? '' : 's'})`);
|
|
1300
|
+
} else {
|
|
1301
|
+
report.push(` ${target}: carries main-root pins but is user-modified — left untouched; rebase it by hand if wanted`);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
const gates = readFileNoFollow(fs, join(wtRoot, 'docs/ai/gates.json'));
|
|
1305
|
+
if (!gates.absent) {
|
|
1306
|
+
const capable = (() => {
|
|
1307
|
+
if (!gates.bytes) return null;
|
|
1308
|
+
try {
|
|
1309
|
+
return isFinalCapableDeclaration(JSON.parse(String(gates.bytes)).gates, wtRoot);
|
|
1310
|
+
} catch {
|
|
1311
|
+
return null;
|
|
1312
|
+
}
|
|
1313
|
+
})();
|
|
1314
|
+
report.push(capable === null
|
|
1315
|
+
? ' gates.json: unreadable at the worktree — final-capability unknown'
|
|
1316
|
+
: ` gates.json: final-capable at the worktree: ${capable ? 'yes' : 'no'}`);
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
1321
|
+
const fs = fsOf(deps);
|
|
1322
|
+
const slug = validateSlug(argvSlug);
|
|
1323
|
+
const branch = flags.branch ?? `${DEFAULT_BRANCH_PREFIX}${slug}`;
|
|
1324
|
+
if (flags.plan == null) throw usageStop('provision requires --plan <path> (the ONE feature plan the worktree starts with)');
|
|
1325
|
+
const { root, commonDir } = resolveRoots(cwd, git);
|
|
1326
|
+
const rootReal = fs.realpath(root);
|
|
1327
|
+
const config = loadWorktreesConfig(root, deps);
|
|
1328
|
+
const targetAbs = resolveTargetDir({ root, slug, dirFlag: flags.dir ?? null, parentDir: config.parentDir });
|
|
1329
|
+
const report = [];
|
|
1330
|
+
|
|
1331
|
+
// dir probes carry the trailing slash — a dir-form ignore pattern never matches an
|
|
1332
|
+
// absent-slashless path, and the probed dir may not exist yet
|
|
1333
|
+
if (!checkIgnored(git, `${PLANS_REL}/`, root)) {
|
|
1334
|
+
throw stop(`${PLANS_REL} is not git-ignored in the main repo — the seeded plan and handoff would land as tracked leftovers. Ignore ${PLANS_REL}/ first.`);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const targetReal = realpathThroughExistingParent(targetAbs, deps);
|
|
1338
|
+
if (targetReal === rootReal) throw stop('the target dir is the main repo itself');
|
|
1339
|
+
if (isInside(rootReal, targetReal)) {
|
|
1340
|
+
const rel = relative(rootReal, targetReal);
|
|
1341
|
+
if (!checkIgnored(git, `${rel}/`, root)) {
|
|
1342
|
+
throw stop(`the target dir is inside the main repo and not ignored: ${rel} — pick an outside dir (--dir) or ignore it`);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const seed = validateSeedPlan({ root, rootReal, planFlag: flags.plan, asFlag: flags.as ?? null, fs });
|
|
1347
|
+
|
|
1348
|
+
const sources = assertProvisionSourcesContained({ root, rootReal, fs, statFollow: deps.stat ?? statSync });
|
|
1349
|
+
const includeSources = [];
|
|
1350
|
+
for (const inc of flags.include) {
|
|
1351
|
+
const incAbs = resolve(root, inc);
|
|
1352
|
+
let incReal;
|
|
1353
|
+
try {
|
|
1354
|
+
incReal = fs.realpath(incAbs);
|
|
1355
|
+
} catch {
|
|
1356
|
+
throw stop(`--include: not found: ${inc}`);
|
|
1357
|
+
}
|
|
1358
|
+
if (!isInside(rootReal, incReal)) throw stop(`--include must resolve inside the main repo: ${inc}`);
|
|
1359
|
+
includeSources.push({ rel: relative(rootReal, incReal), real: incReal });
|
|
1360
|
+
}
|
|
1361
|
+
assertTargetOutsideSources({ targetReal, sources: [...sources, ...includeSources] });
|
|
1362
|
+
|
|
1363
|
+
const probeDir = resolveProbeDir(dirname(targetReal), deps);
|
|
1364
|
+
// the probe itself is a create+delete write — on resume it runs only AFTER every identity check
|
|
1365
|
+
const runWritabilityProbe = () => {
|
|
1366
|
+
const probe = probeParentWritable(probeDir, deps);
|
|
1367
|
+
if (!probe.writable) {
|
|
1368
|
+
if (probe.cleanupFailed) throw probeCleanupStop(probe.cleanupFailed);
|
|
1369
|
+
throw writabilityStop({ parentDir: probeDir, root, slug, flags });
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
|
|
1373
|
+
if (flags.resume) {
|
|
1374
|
+
const entry = listWorktrees(git, root).find((e) => resolve(e.path) === resolve(targetReal));
|
|
1375
|
+
if (!entry) throw stop(`--resume: no registered worktree at ${targetReal} — run provision without --resume`);
|
|
1376
|
+
if (entry.branch !== `refs/heads/${branch}`) {
|
|
1377
|
+
throw stop(`--resume identity mismatch: worktree at ${targetReal} is on ${entry.branch ?? 'detached HEAD'}, expected refs/heads/${branch}`);
|
|
1378
|
+
}
|
|
1379
|
+
const wtCommon = gitLine(git, ['rev-parse', '--path-format=absolute', '--git-common-dir'], targetReal);
|
|
1380
|
+
if (wtCommon !== commonDir) throw stop(`--resume identity mismatch: ${targetReal} does not share this repo's git dir`);
|
|
1381
|
+
assertResumeHandoffIdentity({ wtRoot: targetReal, slug, branch, fs });
|
|
1382
|
+
assertResumePlanCompatibility({ wtRoot: targetReal, seedName: seed.name, fs });
|
|
1383
|
+
runWritabilityProbe();
|
|
1384
|
+
report.push(`resuming provision at ${targetReal} (branch ${branch})`);
|
|
1385
|
+
} else {
|
|
1386
|
+
runWritabilityProbe();
|
|
1387
|
+
const add = git(['worktree', 'add', '-b', branch, targetReal], root);
|
|
1388
|
+
if (add.status !== 0) {
|
|
1389
|
+
throw stop(
|
|
1390
|
+
[
|
|
1391
|
+
`git worktree add refused: ${(add.stderr || add.stdout).trim()}`,
|
|
1392
|
+
'Recoveries: another --dir · another --branch · provision --resume <slug> (finish a half-done provision) · consented cleanup of the stale worktree.',
|
|
1393
|
+
].join('\n'),
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
report.push(`created worktree ${targetReal} (branch ${branch})`);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// any failure past this point leaves a real created worktree — the error must say so and
|
|
1400
|
+
// hand back the exact finish command, never just the local cause
|
|
1401
|
+
try {
|
|
1402
|
+
return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, git, deps, fs, report, log });
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
if (!flags.resume && err?.message) {
|
|
1405
|
+
err.message += `\nNOTE: the worktree at ${targetReal} (branch ${branch}) was created and KEPT — finish with: ${composeProvisionArgv({ root, slug, flags: { ...flags, resume: true } })} (or reclaim it with the consented cleanup).`;
|
|
1406
|
+
}
|
|
1407
|
+
throw err;
|
|
1408
|
+
}
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, git, deps, fs, report, log }) => {
|
|
1412
|
+
writeHandoffStubIfAbsent({ wtRoot: targetPath, slug, branch, fs, report });
|
|
1413
|
+
|
|
1414
|
+
const copied = new Set();
|
|
1415
|
+
report.push('copying the provision set (copy-if-missing; tracked files come from the checkout):');
|
|
1416
|
+
for (const pattern of provisionCopySet(root, deps)) {
|
|
1417
|
+
const rel = patternToProbe(pattern).replace(/\/$/, '');
|
|
1418
|
+
copyNode({ srcAbs: join(root, rel), dstAbs: join(targetPath, rel), wtRoot: targetPath, rel, fs, report, copied });
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
writeSeedPlan({ wtRoot: targetPath, srcAbs: seed.srcAbs, name: seed.name, fs, report });
|
|
1422
|
+
const includesRecorded = provisionIncludes({ root, rootReal, wtRoot: targetPath, includes: flags.include, git, fs, report, copied });
|
|
1423
|
+
const nodeModulesMode = provisionNodeModules({ root, rootReal, wtRoot: targetPath, installFlag: flags.install, git, fs, report });
|
|
1424
|
+
const vscodeMode = provisionVscode({ root, wtRoot: targetPath, slug, git, fs, report });
|
|
1425
|
+
|
|
1426
|
+
rebasePins({ root, wtRoot: targetPath, git, fs, report });
|
|
1427
|
+
|
|
1428
|
+
writeHandoffRecord({
|
|
1429
|
+
wtRoot: targetPath,
|
|
1430
|
+
slug,
|
|
1431
|
+
branch,
|
|
1432
|
+
fields: { slug, branch, includes: includesRecorded, nodeModules: nodeModulesMode, vscode: vscodeMode },
|
|
1433
|
+
fs,
|
|
1434
|
+
report,
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
const inFlight = plansInFlight(targetPath, fs.readdir);
|
|
1438
|
+
if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
|
|
1439
|
+
throw stop(
|
|
1440
|
+
`the worktree must hold EXACTLY ONE in-flight plan, found [${inFlight.join(', ')}] — remove the extras (or re-seed) and re-run --resume`,
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const porcelain = git(['status', '--porcelain'], targetPath);
|
|
1445
|
+
if (porcelain.status !== 0) throw stop(`git status failed in the worktree: ${porcelain.stderr.trim()}`);
|
|
1446
|
+
if (porcelain.stdout.trim() !== '') {
|
|
1447
|
+
throw stop(
|
|
1448
|
+
`post-provision verify failed — the worktree status is not clean (everything provision places must be ignored-or-tracked):\n${porcelain.stdout.trimEnd()}`,
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const base = gitLine(git, ['rev-parse', 'HEAD'], targetPath) ?? '(unknown)';
|
|
1453
|
+
for (const line of report) log(line);
|
|
1454
|
+
log(`[worktrees] provisioned ${slug} at ${targetPath} (branch ${branch}, base ${base})`);
|
|
1455
|
+
log(`open it: code -n ${shellQuoteArg(targetPath)}`);
|
|
1456
|
+
return EXIT.ok;
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
// ── list ───────────────────────────────────────────────────────────────────────────────
|
|
1460
|
+
|
|
1461
|
+
export const runList = ({ cwd, git, deps, log }) => {
|
|
1462
|
+
const fs = fsOf(deps);
|
|
1463
|
+
const entries = listWorktrees(git, cwd);
|
|
1464
|
+
if (entries.length <= 1) {
|
|
1465
|
+
log('[worktrees] no linked worktrees');
|
|
1466
|
+
return EXIT.ok;
|
|
1467
|
+
}
|
|
1468
|
+
const rows = [];
|
|
1469
|
+
for (const entry of entries.slice(1)) {
|
|
1470
|
+
const row = {
|
|
1471
|
+
slug: 'unknown (foreign)',
|
|
1472
|
+
path: entry.path,
|
|
1473
|
+
branch: entry.detached ? '(detached)' : (entry.branch ?? '(none)').replace(/^refs\/heads\//, ''),
|
|
1474
|
+
base: entry.head ? entry.head.slice(0, 12) : '(none)',
|
|
1475
|
+
dirty: '?',
|
|
1476
|
+
handoff: 'no',
|
|
1477
|
+
prunable: entry.prunable,
|
|
1478
|
+
};
|
|
1479
|
+
if (!entry.prunable) {
|
|
1480
|
+
// the SAME no-follow chain scan resume identity uses: only a genuinely absent docs/plans
|
|
1481
|
+
// under a safe chain reads as no-handoff; symlinks, non-files, and read failures render honestly
|
|
1482
|
+
const scan = scanPlansDir({ wtRoot: entry.path, fs });
|
|
1483
|
+
if (scan.state === 'unreadable' || (scan.state === 'ok' && scan.nonRegular.length > 0)) {
|
|
1484
|
+
row.handoff = '(unreadable)';
|
|
1485
|
+
} else if (scan.state === 'ok') {
|
|
1486
|
+
if (scan.handoffs.length === 1) {
|
|
1487
|
+
row.slug = scan.handoffs[0].slice('handoff-'.length, -'.md'.length);
|
|
1488
|
+
row.handoff = 'yes';
|
|
1489
|
+
} else if (scan.handoffs.length > 1) {
|
|
1490
|
+
row.handoff = `ambiguous (${scan.handoffs.length})`;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
const porcelain = git(['--no-optional-locks', 'status', '--porcelain'], entry.path);
|
|
1494
|
+
row.dirty = porcelain.status === 0 ? (porcelain.stdout.trim() === '' ? 'clean' : 'dirty') : '(unreadable)';
|
|
1495
|
+
}
|
|
1496
|
+
rows.push(row);
|
|
1497
|
+
}
|
|
1498
|
+
for (const r of rows) {
|
|
1499
|
+
const state = r.prunable ? 'PRUNABLE (dir gone — `git worktree prune` reclaims the entry)' : `${r.dirty}, handoff: ${r.handoff}`;
|
|
1500
|
+
log(`${r.slug} · ${r.path} · branch ${r.branch} · base ${r.base} · ${state}`);
|
|
1501
|
+
if (!r.prunable) log(` open: code -n ${shellQuoteArg(r.path)}`);
|
|
1502
|
+
}
|
|
1503
|
+
return EXIT.ok;
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
// ── land + cleanup ────────────────────────────────────────────────────────────────────
|
|
1507
|
+
|
|
1508
|
+
const nulFields = (text) => String(text).split('\0').filter((field) => field !== '');
|
|
1509
|
+
|
|
1510
|
+
const gitRead = (git, args, cwd, label) => {
|
|
1511
|
+
const result = git(args, cwd);
|
|
1512
|
+
if (result.status !== 0) {
|
|
1513
|
+
throw stop(`${label}: ${(result.stderr || result.stdout).trim() || `git exited ${result.status}`}`);
|
|
1514
|
+
}
|
|
1515
|
+
return result;
|
|
1516
|
+
};
|
|
1517
|
+
|
|
1518
|
+
// New git mutations cross one status-checking door; callers may add a narrower recovery message.
|
|
1519
|
+
const gitMutation = (git, args, cwd, label) => gitRead(git, args, cwd, label);
|
|
1520
|
+
|
|
1521
|
+
const spawnChild = (deps, command, args, cwd) => {
|
|
1522
|
+
const spawn = deps.spawn ?? ((cmd, childArgs, options) => {
|
|
1523
|
+
const result = spawnSync(cmd, childArgs, {
|
|
1524
|
+
...options, encoding: 'utf8', windowsHide: true, maxBuffer: GIT_MAX_BUFFER,
|
|
1525
|
+
});
|
|
1526
|
+
return {
|
|
1527
|
+
status: result.error ? -1 : result.status,
|
|
1528
|
+
stdout: result.stdout ?? '',
|
|
1529
|
+
stderr: result.error ? String(result.error.message) : (result.stderr ?? ''),
|
|
1530
|
+
};
|
|
1531
|
+
});
|
|
1532
|
+
return spawn(command, args, { cwd, encoding: 'utf8', windowsHide: true, maxBuffer: GIT_MAX_BUFFER });
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
const childWords = (result) => [result.stdout, result.stderr]
|
|
1536
|
+
.map((part) => String(part ?? '').trim())
|
|
1537
|
+
.filter(Boolean)
|
|
1538
|
+
.join('\n');
|
|
1539
|
+
|
|
1540
|
+
const withPrepareLock = ({ commonDir, fs, now }, action) => {
|
|
1541
|
+
const path = join(commonDir, PREPARE_LOCK_BASENAME);
|
|
1542
|
+
try {
|
|
1543
|
+
fs.mkdirPlain(path);
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
if (error?.code !== 'EEXIST') throw stop(`cannot acquire ${PREPARE_LOCK_BASENAME}: ${error?.code ?? 'fs error'}`);
|
|
1546
|
+
let age = 'unknown';
|
|
1547
|
+
try {
|
|
1548
|
+
const stat = fs.lstat(path);
|
|
1549
|
+
age = `${Math.max(0, Math.floor((now() - Number(stat.mtimeMs)) / 1000))}s`;
|
|
1550
|
+
} catch {
|
|
1551
|
+
// The lock still failed atomically; an unreadable age must not weaken the refusal.
|
|
1552
|
+
}
|
|
1553
|
+
throw stop(
|
|
1554
|
+
`${PREPARE_LOCK_BASENAME} is already held at ${path} (age ${age}). ` +
|
|
1555
|
+
`If no land/cleanup process owns it, remove ${path} by hand and retry.`,
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
let result;
|
|
1559
|
+
let actionError = null;
|
|
1560
|
+
try {
|
|
1561
|
+
result = action();
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
actionError = error;
|
|
1564
|
+
}
|
|
1565
|
+
let releaseError = null;
|
|
1566
|
+
try {
|
|
1567
|
+
fs.rmdir(path);
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
releaseError = stop(`could not release ${PREPARE_LOCK_BASENAME} at ${path} (${error?.code ?? 'fs error'}) — remove it by hand`);
|
|
1570
|
+
}
|
|
1571
|
+
if (actionError && releaseError) throw composeFailure(actionError, 'lock release', releaseError);
|
|
1572
|
+
if (actionError) throw actionError;
|
|
1573
|
+
if (releaseError) throw releaseError;
|
|
1574
|
+
return result;
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
const branchNameOf = (entry) => entry.branch?.replace(/^refs\/heads\//, '') ?? null;
|
|
1578
|
+
|
|
1579
|
+
const findSatelliteEntry = ({ root, slug, branch, git, fs }) => {
|
|
1580
|
+
const entries = listWorktrees(git, root).slice(1);
|
|
1581
|
+
const exactHandoff = [];
|
|
1582
|
+
for (const entry of entries) {
|
|
1583
|
+
if (entry.prunable) continue;
|
|
1584
|
+
const scan = scanPlansDir({ wtRoot: entry.path, fs });
|
|
1585
|
+
if (scan.state === 'ok' && scan.handoffs.includes(handoffBasename(slug))) exactHandoff.push(entry);
|
|
1586
|
+
}
|
|
1587
|
+
if (exactHandoff.length > 1) {
|
|
1588
|
+
throw stop(`multiple worktrees carry ${handoffBasename(slug)} — cleanup the duplicate identity before continuing`);
|
|
1589
|
+
}
|
|
1590
|
+
if (branch !== null) {
|
|
1591
|
+
const byBranch = entries.filter((entry) => entry.branch === `refs/heads/${branch}`);
|
|
1592
|
+
if (byBranch.length > 1) throw stop(`multiple worktrees claim branch ${branch}`);
|
|
1593
|
+
if (byBranch.length === 1) return byBranch[0];
|
|
1594
|
+
}
|
|
1595
|
+
if (exactHandoff.length === 1) return exactHandoff[0];
|
|
1596
|
+
const fallback = entries.filter((entry) => entry.branch === `refs/heads/${DEFAULT_BRANCH_PREFIX}${slug}`);
|
|
1597
|
+
if (fallback.length === 1) return fallback[0];
|
|
1598
|
+
throw stop(`no registered satellite worktree for ${slug}`);
|
|
1599
|
+
};
|
|
1600
|
+
|
|
1601
|
+
const readSatelliteIdentity = ({ entry, slug, expectedBranch, fs, abandon = false }) => {
|
|
1602
|
+
const name = handoffBasename(slug);
|
|
1603
|
+
const scan = scanPlansDir({ wtRoot: entry.path, fs });
|
|
1604
|
+
if (scan.state === 'ok' && scan.nonRegular.includes(name)) {
|
|
1605
|
+
throw stop(`handoff identity mismatch: ${name} is not a regular file`);
|
|
1606
|
+
}
|
|
1607
|
+
if (scan.state !== 'ok' || !scan.handoffs.includes(name)) {
|
|
1608
|
+
if (abandon) throw stop(`${name} is absent — force deletion is forbidden without the handoff identity`);
|
|
1609
|
+
throw stop(`handoff identity mismatch: expected ${name} in the satellite`);
|
|
1610
|
+
}
|
|
1611
|
+
if (scan.handoffs.length !== 1) {
|
|
1612
|
+
throw stop(`handoff identity mismatch: expected exactly ${name}, found [${scan.handoffs.join(', ')}]`);
|
|
1613
|
+
}
|
|
1614
|
+
const leaf = readFileNoFollow(fs, join(entry.path, PLANS_REL, name));
|
|
1615
|
+
if (!leaf.bytes) throw stop(`handoff identity mismatch: ${name} is not readable as a regular file`);
|
|
1616
|
+
const record = parseProvisionRecord(String(leaf.bytes));
|
|
1617
|
+
const liveBranch = branchNameOf(entry);
|
|
1618
|
+
const wantedBranch = expectedBranch ?? liveBranch;
|
|
1619
|
+
if (record.slug !== slug || record.branch !== wantedBranch || liveBranch !== wantedBranch) {
|
|
1620
|
+
throw stop(
|
|
1621
|
+
`handoff identity mismatch: expected slug ${slug} and branch ${wantedBranch}; ` +
|
|
1622
|
+
`record has slug ${record.slug ?? '(missing)'} and branch ${record.branch ?? '(missing)'}, live branch ${liveBranch ?? '(detached)'}`,
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
return { record, path: join(entry.path, PLANS_REL, name), branch: wantedBranch };
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
const changedPaths = (git, args, cwd, label) =>
|
|
1629
|
+
nulFields(gitRead(git, [...args, '-z', '--', ...TRANSFER_EXCLUSIONS], cwd, label).stdout);
|
|
1630
|
+
|
|
1631
|
+
const literalPathspec = (path) => `:(literal)${path}`;
|
|
1632
|
+
|
|
1633
|
+
const untrackedPaths = (git, cwd) => nulFields(gitRead(
|
|
1634
|
+
git, ['ls-files', '--others', '--exclude-standard', '-z'], cwd, 'git ls-files failed',
|
|
1635
|
+
).stdout);
|
|
1636
|
+
|
|
1637
|
+
const mainPorcelain = (git, cwd) => gitRead(
|
|
1638
|
+
git, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], cwd, 'git status failed',
|
|
1639
|
+
).stdout;
|
|
1640
|
+
|
|
1641
|
+
const classifyDivergence = ({ git, mainHead, satelliteHead, worktree }) => {
|
|
1642
|
+
if (mainHead === satelliteHead) return 'none';
|
|
1643
|
+
const satelliteBehind = git(['merge-base', '--is-ancestor', satelliteHead, mainHead], worktree);
|
|
1644
|
+
const satelliteAhead = git(['merge-base', '--is-ancestor', mainHead, satelliteHead], worktree);
|
|
1645
|
+
if (![0, 1].includes(satelliteBehind.status) || ![0, 1].includes(satelliteAhead.status)) {
|
|
1646
|
+
throw stop(`git merge-base failed: ${(satelliteBehind.stderr || satelliteAhead.stderr).trim()}`);
|
|
1647
|
+
}
|
|
1648
|
+
if (satelliteBehind.status === 0) return 'behind';
|
|
1649
|
+
if (satelliteAhead.status === 0) return 'local';
|
|
1650
|
+
return 'both';
|
|
1651
|
+
};
|
|
1652
|
+
|
|
1653
|
+
const driftRecipe = ({ slug, satelliteHead, mainHead }) => [
|
|
1654
|
+
`satellite is behind main; old satellite HEAD rollback datum: ${satelliteHead}`,
|
|
1655
|
+
'Recover in the satellite:',
|
|
1656
|
+
' git add -A',
|
|
1657
|
+
` git diff --cached --binary --no-ext-diff --no-textconv --output=${PLANS_REL}/aw-rebase-${slug}.patch`,
|
|
1658
|
+
` git reset --hard ${mainHead}`,
|
|
1659
|
+
` git apply --index ${PLANS_REL}/aw-rebase-${slug}.patch`,
|
|
1660
|
+
'Delete the patch ONLY after apply succeeds; on failure the patch is KEPT, run:',
|
|
1661
|
+
` git reset --hard ${satelliteHead}`,
|
|
1662
|
+
` git apply --index ${PLANS_REL}/aw-rebase-${slug}.patch`,
|
|
1663
|
+
'Resolve conflicts in the satellite, then re-run its council.',
|
|
1664
|
+
].join('\n');
|
|
1665
|
+
|
|
1666
|
+
const assertSatelliteReady = ({ slug, mainHead, satelliteHead, worktree, git }) => {
|
|
1667
|
+
const divergence = classifyDivergence({ git, mainHead, satelliteHead, worktree });
|
|
1668
|
+
if (divergence === 'local') {
|
|
1669
|
+
throw stop('satellite has local commits; cherry-pick the wanted commits at main, then retire or reset the satellite before land');
|
|
1670
|
+
}
|
|
1671
|
+
if (divergence === 'both') {
|
|
1672
|
+
throw stop(
|
|
1673
|
+
'satellite is both behind main and carries local commits. Recover commits first with cherry-pick, then repair the working diff:\n' +
|
|
1674
|
+
driftRecipe({ slug, satelliteHead, mainHead }),
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
if (divergence === 'behind') throw stop(driftRecipe({ slug, satelliteHead, mainHead }));
|
|
1678
|
+
|
|
1679
|
+
const docsAi = nulFields(gitRead(
|
|
1680
|
+
git, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', 'docs/ai'], worktree,
|
|
1681
|
+
'git status failed for docs/ai',
|
|
1682
|
+
).stdout).map((entry) => entry.length > 3 ? entry.slice(3) : entry);
|
|
1683
|
+
if (docsAi.length > 0) {
|
|
1684
|
+
throw stop(
|
|
1685
|
+
`satellite docs/ai must stay byte-equal to HEAD; move durable content to the handoff and reset these paths:\n${docsAi.join('\n')}`,
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const stagedExcluded = nulFields(gitRead(
|
|
1690
|
+
git, ['diff', '--cached', '--name-only', '--no-renames', '-z', '--', 'docs/ai', 'docs/plans'], worktree,
|
|
1691
|
+
'git diff failed for excluded paths',
|
|
1692
|
+
).stdout);
|
|
1693
|
+
if (stagedExcluded.length > 0) {
|
|
1694
|
+
throw stop(
|
|
1695
|
+
`staged excluded path(s): ${stagedExcluded.join(', ')} — unstage them; their durable content belongs in the handoff`,
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
const unstaged = nulFields(gitRead(
|
|
1700
|
+
git, ['diff', '--name-only', '--no-renames', '-z'], worktree, 'git diff failed for satellite leftovers',
|
|
1701
|
+
).stdout);
|
|
1702
|
+
const untracked = untrackedPaths(git, worktree);
|
|
1703
|
+
const leftovers = [...new Set([...unstaged, ...untracked])];
|
|
1704
|
+
if (leftovers.length > 0) {
|
|
1705
|
+
throw stop(
|
|
1706
|
+
`satellite has unstaged or untracked-not-ignored leftovers; stage the complete working-tree diff before land:\n${leftovers.join('\n')}`,
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
const staged = changedPaths(git, ['diff', '--cached', '--name-only', '--no-renames'], worktree, 'git diff failed');
|
|
1711
|
+
if (staged.length === 0) throw stop('satellite has an empty staged diff — there is nothing to prepare');
|
|
1712
|
+
return staged;
|
|
1713
|
+
};
|
|
1714
|
+
|
|
1715
|
+
const runSatelliteReview = ({ worktree, deps }) => {
|
|
1716
|
+
const tool = join(WORKTREES_TOOL_DIR, 'review-state.mjs');
|
|
1717
|
+
const result = spawnChild(deps, process.execPath, [tool, '--check'], worktree);
|
|
1718
|
+
if (result.status !== 0) {
|
|
1719
|
+
throw stop(
|
|
1720
|
+
`satellite review-state is not green; finish the in-flight plan and council in the satellite before land.\n${childWords(result)}`,
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
|
|
1725
|
+
const rollbackMain = ({ root, mainHead, git, fs }) => {
|
|
1726
|
+
const failures = [];
|
|
1727
|
+
let leftovers = [];
|
|
1728
|
+
try {
|
|
1729
|
+
leftovers = untrackedPaths(git, root);
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
failures.push(error);
|
|
1732
|
+
}
|
|
1733
|
+
for (const rel of leftovers) {
|
|
1734
|
+
try {
|
|
1735
|
+
const abs = join(root, rel);
|
|
1736
|
+
removeNodeNoFollow({ root, abs, fs, label: rel });
|
|
1737
|
+
removeEmptyParentsNoFollow({ root, abs, fs });
|
|
1738
|
+
} catch (error) {
|
|
1739
|
+
failures.push(error);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
try {
|
|
1743
|
+
gitMutation(git, ['reset', '--hard', mainHead], root, 'git reset --hard rollback failed');
|
|
1744
|
+
} catch (error) {
|
|
1745
|
+
failures.push(error);
|
|
1746
|
+
}
|
|
1747
|
+
return failures;
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
const withRollbackFailures = (primary, rollback) =>
|
|
1751
|
+
rollback.reduce((error, failure) => composeFailure(error, 'rollback', failure), primary);
|
|
1752
|
+
|
|
1753
|
+
const withRollbackOnFailure = ({ root, mainHead, git, fs }, action) => {
|
|
1754
|
+
try {
|
|
1755
|
+
return action();
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
throw withRollbackFailures(error, rollbackMain({ root, mainHead, git, fs }));
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1760
|
+
|
|
1761
|
+
const applyTransfer = ({ root, commonDir, slug, patch, mainHead, git, fs }) => {
|
|
1762
|
+
const patchPath = join(commonDir, `aw-transfer-${slug}.patch`);
|
|
1763
|
+
writeContainedFileAtomic(commonDir, patchPath, patch, fs, { stop: (message) => stop(message) });
|
|
1764
|
+
let applyError = null;
|
|
1765
|
+
try {
|
|
1766
|
+
gitMutation(git, ['apply', '--index', patchPath], root, 'git apply --index failed');
|
|
1767
|
+
} catch (error) {
|
|
1768
|
+
applyError = error;
|
|
1769
|
+
}
|
|
1770
|
+
let cleanupError = null;
|
|
1771
|
+
try {
|
|
1772
|
+
removeNodeNoFollow({ root: commonDir, abs: patchPath, fs, label: patchPath });
|
|
1773
|
+
} catch (error) {
|
|
1774
|
+
cleanupError = error;
|
|
1775
|
+
}
|
|
1776
|
+
if (!applyError && !cleanupError) return;
|
|
1777
|
+
let primary = applyError ?? cleanupError;
|
|
1778
|
+
if (applyError && cleanupError) primary = composeFailure(applyError, 'patch cleanup', cleanupError);
|
|
1779
|
+
throw withRollbackFailures(primary, rollbackMain({ root, mainHead, git, fs }));
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1782
|
+
const runSyncAdapter = ({ root, mainHead, transferPaths, git, fs, deps, report }) => {
|
|
1783
|
+
const adapter = join(root, 'scripts/sync-mirrors.mjs');
|
|
1784
|
+
const node = classifyNodeNoFollow(adapter, fs);
|
|
1785
|
+
if (node.kind === 'absent') {
|
|
1786
|
+
report.push('sync adapter: absent — skipped');
|
|
1787
|
+
return [];
|
|
1788
|
+
}
|
|
1789
|
+
if (node.kind !== 'regular-file') throw stop('sync adapter is not a regular non-symlink file — refusing to execute it');
|
|
1790
|
+
const indexTreeBefore = gitRead(git, ['write-tree'], root, 'cannot snapshot the main index before sync').stdout.trim();
|
|
1791
|
+
const result = spawnChild(deps, process.execPath, [adapter], root);
|
|
1792
|
+
const { indexDelta, workingDelta } = withRollbackOnFailure({ root, mainHead, git, fs }, () => {
|
|
1793
|
+
const indexTreeAfter = gitRead(git, ['write-tree'], root, 'cannot snapshot the main index after sync').stdout.trim();
|
|
1794
|
+
return {
|
|
1795
|
+
indexDelta: indexTreeBefore === indexTreeAfter
|
|
1796
|
+
? []
|
|
1797
|
+
: nulFields(gitRead(
|
|
1798
|
+
git, ['diff', '--name-only', '--no-renames', '-z', indexTreeBefore, indexTreeAfter], root,
|
|
1799
|
+
'git diff failed for the sync index delta',
|
|
1800
|
+
).stdout),
|
|
1801
|
+
workingDelta: [...new Set([
|
|
1802
|
+
...nulFields(gitRead(git, ['diff', '--name-only', '--no-renames', '-z'], root, 'git diff failed after sync').stdout),
|
|
1803
|
+
...untrackedPaths(git, root),
|
|
1804
|
+
])],
|
|
1805
|
+
};
|
|
1806
|
+
});
|
|
1807
|
+
if (result.status !== 0) {
|
|
1808
|
+
const output = childWords(result);
|
|
1809
|
+
const primary = stop('sync adapter failed');
|
|
1810
|
+
const composed = withRollbackFailures(primary, rollbackMain({ root, mainHead, git, fs }));
|
|
1811
|
+
if (composed !== primary) {
|
|
1812
|
+
if (output) composed.message += `\n${output}`;
|
|
1813
|
+
throw composed;
|
|
1814
|
+
}
|
|
1815
|
+
throw stop(`sync adapter failed; main was rolled back byte-clean.${output ? `\n${output}` : ''}`);
|
|
1816
|
+
}
|
|
1817
|
+
withRollbackOnFailure({ root, mainHead, git, fs }, () => {
|
|
1818
|
+
if (workingDelta.length > 0) {
|
|
1819
|
+
gitMutation(
|
|
1820
|
+
git, ['add', '-A', '--', ...workingDelta.map(literalPathspec)], root,
|
|
1821
|
+
'git add failed for the observed sync delta',
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
const delta = [...new Set([...indexDelta, ...workingDelta])];
|
|
1826
|
+
const overlap = delta.filter((path) => transferPaths.includes(path));
|
|
1827
|
+
if (overlap.length > 0) report.push(`mirror edit overwritten by canon sync: ${overlap.join(', ')}`);
|
|
1828
|
+
const output = childWords(result);
|
|
1829
|
+
if (output) report.push(output);
|
|
1830
|
+
return delta;
|
|
1831
|
+
};
|
|
1832
|
+
|
|
1833
|
+
const recordPreparedTree = ({ identity, slug, entry, prepared, fs }) => {
|
|
1834
|
+
writeHandoffRecord({
|
|
1835
|
+
wtRoot: entry.path,
|
|
1836
|
+
slug,
|
|
1837
|
+
branch: identity.branch,
|
|
1838
|
+
fields: { ...identity.record, prepared },
|
|
1839
|
+
fs,
|
|
1840
|
+
report: [],
|
|
1841
|
+
});
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
const dirtyMainStop = ({ root, git, record, porcelain }) => {
|
|
1845
|
+
const tree = gitRead(git, ['write-tree'], root, 'git write-tree failed').stdout.trim();
|
|
1846
|
+
const leftovers = untrackedPaths(git, root);
|
|
1847
|
+
const treeMatchesRecord = record.prepared !== null && record.prepared === tree;
|
|
1848
|
+
const stagedDelta = treeMatchesRecord ? git(['diff', '--cached', '--quiet'], root) : null;
|
|
1849
|
+
if (stagedDelta !== null && ![0, 1].includes(stagedDelta.status)) {
|
|
1850
|
+
throw stop(`git diff --cached --quiet failed: ${(stagedDelta.stderr || stagedDelta.stdout).trim()}`);
|
|
1851
|
+
}
|
|
1852
|
+
const converged = treeMatchesRecord && (stagedDelta.status === 1 || stagedDelta.stdout !== '');
|
|
1853
|
+
const trackedEntries = parseStatusZ(porcelain).filter((entry) => !['??', '!!'].includes(entry.code));
|
|
1854
|
+
const trackedPaths = [...new Set(trackedEntries.flatMap((entry) => entry.paths))];
|
|
1855
|
+
const hasTrackedUnstaged = trackedEntries.some((entry) => entry.code[1] !== ' ');
|
|
1856
|
+
const mayReset = converged && !hasTrackedUnstaged;
|
|
1857
|
+
const classification = converged
|
|
1858
|
+
? `converged re-run: current staged write-tree matches the previous prepare's recorded OID ${record.prepared}`
|
|
1859
|
+
: record.prepared === null
|
|
1860
|
+
? 'foreign staged work: no previous prepare OID is recorded'
|
|
1861
|
+
: treeMatchesRecord
|
|
1862
|
+
? `foreign staged work: the index has no staged delta against HEAD, although its write-tree matches the recorded OID ${record.prepared}`
|
|
1863
|
+
: `foreign staged work: current staged write-tree differs from the previous prepare's recorded OID ${record.prepared}`;
|
|
1864
|
+
const leftoversReport = leftovers.length === 0
|
|
1865
|
+
? []
|
|
1866
|
+
: mayReset
|
|
1867
|
+
? [
|
|
1868
|
+
'Then remove crash-residue untracked paths:',
|
|
1869
|
+
...leftovers.map((path) => ` cd ${shellQuoteArg(root)} && rm -- ${shellQuoteArg(path)}`),
|
|
1870
|
+
]
|
|
1871
|
+
: [
|
|
1872
|
+
'Untracked paths require manual review; no removal command is offered:',
|
|
1873
|
+
...leftovers.map((path) => ` ${shellQuoteArg(path)}`),
|
|
1874
|
+
];
|
|
1875
|
+
const trackedReport = trackedPaths.length === 0
|
|
1876
|
+
? []
|
|
1877
|
+
: ['Tracked changes:', ...trackedPaths.map((path) => ` ${shellQuoteArg(path)}`)];
|
|
1878
|
+
throw stop([
|
|
1879
|
+
mayReset
|
|
1880
|
+
? 'main is not clean; a second prepare is reset-only.'
|
|
1881
|
+
: 'main is not clean; land --prepare refuses to overwrite existing main changes.',
|
|
1882
|
+
`current staged write-tree: ${tree}`,
|
|
1883
|
+
classification,
|
|
1884
|
+
...trackedReport,
|
|
1885
|
+
...(mayReset ? ['Recover mechanically: git reset --hard'] : []),
|
|
1886
|
+
...leftoversReport,
|
|
1887
|
+
`Re-run land from ${root} with --prepare.`,
|
|
1888
|
+
].join('\n'));
|
|
1889
|
+
};
|
|
1890
|
+
|
|
1891
|
+
export const runLand = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
1892
|
+
const slug = validateSlug(argvSlug);
|
|
1893
|
+
if (!flags.prepare) throw usageStop('land requires --prepare');
|
|
1894
|
+
const fs = fsOf(deps);
|
|
1895
|
+
const { root, commonDir } = resolveRoots(cwd, git);
|
|
1896
|
+
|
|
1897
|
+
return withPrepareLock({ commonDir, fs, now: deps.now ?? Date.now }, () => {
|
|
1898
|
+
const entry = findSatelliteEntry({ root, slug, branch: null, git, fs });
|
|
1899
|
+
if (entry.prunable) throw stop(`satellite ${slug} is prunable because its directory is gone — run cleanup ${slug}`);
|
|
1900
|
+
const identity = readSatelliteIdentity({ entry, slug, expectedBranch: null, fs });
|
|
1901
|
+
const porcelain = mainPorcelain(git, root);
|
|
1902
|
+
if (porcelain !== '') dirtyMainStop({ root, git, record: identity.record, porcelain });
|
|
1903
|
+
const mainHead = gitRead(git, ['rev-parse', 'HEAD'], root, 'cannot resolve main HEAD').stdout.trim();
|
|
1904
|
+
const satelliteHead = gitRead(git, ['rev-parse', 'HEAD'], entry.path, 'cannot resolve satellite HEAD').stdout.trim();
|
|
1905
|
+
const transferPaths = assertSatelliteReady({ slug, mainHead, satelliteHead, worktree: entry.path, git });
|
|
1906
|
+
runSatelliteReview({ worktree: entry.path, deps });
|
|
1907
|
+
|
|
1908
|
+
const gates = classifyNodeNoFollow(join(root, 'docs/ai/gates.json'), fs);
|
|
1909
|
+
if (gates.kind === 'absent') throw stop('docs/ai/gates.json is absent — land cannot attest the prepared main tree');
|
|
1910
|
+
if (gates.kind !== 'regular-file') throw stop('docs/ai/gates.json is not a regular non-symlink file — land fails closed');
|
|
1911
|
+
|
|
1912
|
+
const satelliteParent = dirname(entry.path);
|
|
1913
|
+
const probe = probeParentWritable(satelliteParent, deps);
|
|
1914
|
+
if (!probe.writable) {
|
|
1915
|
+
if (probe.cleanupFailed) throw probeCleanupStop(probe.cleanupFailed);
|
|
1916
|
+
throw landWritabilityStop({ parentDir: satelliteParent, root, slug });
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const transferTree = gitRead(git, ['write-tree'], entry.path, 'cannot write the satellite transfer tree').stdout.trim();
|
|
1920
|
+
const patch = gitRead(
|
|
1921
|
+
git,
|
|
1922
|
+
['diff', '--cached', '--binary', '--no-ext-diff', '--no-textconv', '--full-index', '--', ...TRANSFER_EXCLUSIONS],
|
|
1923
|
+
entry.path,
|
|
1924
|
+
'cannot create the satellite transfer diff',
|
|
1925
|
+
).stdout;
|
|
1926
|
+
applyTransfer({ root, commonDir, slug, patch, mainHead, git, fs });
|
|
1927
|
+
|
|
1928
|
+
const report = [];
|
|
1929
|
+
const syncDelta = runSyncAdapter({ root, mainHead, transferPaths, git, fs, deps, report });
|
|
1930
|
+
const preparedTree = gitRead(git, ['write-tree'], root, 'cannot write the prepared main tree').stdout.trim();
|
|
1931
|
+
try {
|
|
1932
|
+
recordPreparedTree({ identity, slug, entry, prepared: preparedTree, fs });
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
throw withRollbackFailures(error, rollbackMain({ root, mainHead, git, fs }));
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
const beforeGates = { head: mainHead, tree: preparedTree, porcelain: mainPorcelain(git, root) };
|
|
1938
|
+
const gateTool = join(WORKTREES_TOOL_DIR, 'run-gates.mjs');
|
|
1939
|
+
const gateResult = spawnChild(deps, process.execPath, [gateTool, '--cwd', root], root);
|
|
1940
|
+
const afterGates = {
|
|
1941
|
+
head: gitRead(git, ['rev-parse', 'HEAD'], root, 'cannot re-read main HEAD').stdout.trim(),
|
|
1942
|
+
tree: gitRead(git, ['write-tree'], root, 'cannot re-read the prepared tree').stdout.trim(),
|
|
1943
|
+
porcelain: mainPorcelain(git, root),
|
|
1944
|
+
};
|
|
1945
|
+
if (afterGates.head !== beforeGates.head || afterGates.tree !== beforeGates.tree || afterGates.porcelain !== beforeGates.porcelain) {
|
|
1946
|
+
throw stop(
|
|
1947
|
+
`main changed during gates; the post-gates snapshot no longer matches. ` +
|
|
1948
|
+
`Recover with git reset --hard ${mainHead}, fix the gate, and re-run land --prepare.`,
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
if (gateResult.status !== 0) {
|
|
1952
|
+
throw stop([
|
|
1953
|
+
childWords(gateResult),
|
|
1954
|
+
'The prepared tree stays staged. Recover either by: reset main, fix in satellite, and re-land; or apply a maintainer-directed fix at main during primary re-attest.',
|
|
1955
|
+
].filter(Boolean).join('\n'));
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
const gateOutput = childWords(gateResult);
|
|
1959
|
+
if (gateOutput) log(gateOutput);
|
|
1960
|
+
for (const line of report) log(line);
|
|
1961
|
+
log(`transfer paths: ${transferPaths.map(shellQuoteArg).join(', ')}`);
|
|
1962
|
+
log(`main HEAD: ${mainHead}`);
|
|
1963
|
+
log(`transfer: ${transferTree}`);
|
|
1964
|
+
log(`prepared: ${preparedTree}`);
|
|
1965
|
+
log(`sync delta: ${syncDelta.length === 0 ? 'none' : syncDelta.join(', ')}`);
|
|
1966
|
+
log('this tool did not commit — re-attest the landed diff, confirm main HEAD is unchanged, then ask before committing');
|
|
1967
|
+
return EXIT.ok;
|
|
1968
|
+
});
|
|
1969
|
+
};
|
|
1970
|
+
|
|
1971
|
+
const parseStatusZ = (text) => {
|
|
1972
|
+
const fields = String(text).split('\0');
|
|
1973
|
+
const entries = [];
|
|
1974
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
1975
|
+
const field = fields[index];
|
|
1976
|
+
if (!field) continue;
|
|
1977
|
+
const code = field.slice(0, 2);
|
|
1978
|
+
const paths = [field.slice(3)];
|
|
1979
|
+
if (code.includes('R') || code.includes('C')) paths.push(fields[index += 1]);
|
|
1980
|
+
entries.push({ code, paths: paths.filter(Boolean) });
|
|
1981
|
+
}
|
|
1982
|
+
return entries;
|
|
1983
|
+
};
|
|
1984
|
+
|
|
1985
|
+
const indexEntry = (git, cwd, path) => {
|
|
1986
|
+
const fields = nulFields(gitRead(
|
|
1987
|
+
git, ['ls-files', '--stage', '-z', '--', literalPathspec(path)], cwd,
|
|
1988
|
+
`git ls-files failed for ${path}`,
|
|
1989
|
+
).stdout);
|
|
1990
|
+
if (fields.length === 0) return null;
|
|
1991
|
+
if (fields.length !== 1) throw stop(`satellite index is conflicted at ${path}`);
|
|
1992
|
+
const match = fields[0].match(/^(\d+) ([0-9a-f]+) 0\t/);
|
|
1993
|
+
if (!match) throw stop(`cannot parse the satellite index entry for ${path}`);
|
|
1994
|
+
return { mode: match[1], oid: match[2] };
|
|
1995
|
+
};
|
|
1996
|
+
|
|
1997
|
+
const headEntry = (git, cwd, head, path) => {
|
|
1998
|
+
const fields = nulFields(gitRead(
|
|
1999
|
+
git, ['ls-tree', '-z', head, '--', literalPathspec(path)], cwd,
|
|
2000
|
+
`git ls-tree failed for ${path}`,
|
|
2001
|
+
).stdout);
|
|
2002
|
+
if (fields.length === 0) return null;
|
|
2003
|
+
const match = fields[0].match(/^(\d+) \w+ ([0-9a-f]+)\t/);
|
|
2004
|
+
if (!match) throw stop(`cannot parse the main HEAD entry for ${path}`);
|
|
2005
|
+
return { mode: match[1], oid: match[2] };
|
|
2006
|
+
};
|
|
2007
|
+
|
|
2008
|
+
const registryRoots = () => {
|
|
2009
|
+
const roots = [];
|
|
2010
|
+
for (const pattern of [...KIT_OWN_PATHS, ...KNOWN_FOOTPRINT.map((entry) => entry.pattern)]) {
|
|
2011
|
+
const normalized = normalizeSlashes(pattern).replace(/^\//, '').replace(/\/$/, '');
|
|
2012
|
+
if (normalized) roots.push(normalized);
|
|
2013
|
+
}
|
|
2014
|
+
return roots;
|
|
2015
|
+
};
|
|
2016
|
+
|
|
2017
|
+
const safeRecordedPath = (path) => {
|
|
2018
|
+
const normalized = normalizeSlashes(String(path)).replace(/^\.\//, '').replace(/\/$/, '');
|
|
2019
|
+
if (!normalized || isAbsolute(normalized) || normalized.split('/').includes('..')) {
|
|
2020
|
+
throw stop(`handoff record carries an unsafe provision path: ${path}`);
|
|
2021
|
+
}
|
|
2022
|
+
return normalized;
|
|
2023
|
+
};
|
|
2024
|
+
|
|
2025
|
+
const provisionKnownRoots = (identity) => {
|
|
2026
|
+
const roots = [
|
|
2027
|
+
...registryRoots(),
|
|
2028
|
+
...identity.record.includes.map(safeRecordedPath),
|
|
2029
|
+
PLANS_REL,
|
|
2030
|
+
'node_modules',
|
|
2031
|
+
];
|
|
2032
|
+
if (identity.record.vscode === 'written') roots.push('.vscode/settings.json');
|
|
2033
|
+
return [...new Set(roots)];
|
|
2034
|
+
};
|
|
2035
|
+
|
|
2036
|
+
const provisionKnownDirectoryRoots = ({ root, identity, fs }) => {
|
|
2037
|
+
const roots = [
|
|
2038
|
+
...KIT_OWN_PATHS.filter(isDirPattern),
|
|
2039
|
+
...KNOWN_FOOTPRINT.filter((entry) => entry.type === 'dir').map((entry) => entry.pattern),
|
|
2040
|
+
PLANS_REL,
|
|
2041
|
+
'node_modules',
|
|
2042
|
+
].map((path) => normalizeSlashes(path).replace(/^\//, '').replace(/\/$/, ''));
|
|
2043
|
+
for (const include of identity.record.includes.map(safeRecordedPath)) {
|
|
2044
|
+
const kind = classifyNodeNoFollow(join(root, include), fs).kind;
|
|
2045
|
+
if (kind === 'plain-directory' || kind === 'symlink-to-directory') roots.push(include);
|
|
2046
|
+
}
|
|
2047
|
+
return new Set(roots);
|
|
2048
|
+
};
|
|
2049
|
+
|
|
2050
|
+
const rootCoveringPath = (path, roots) => roots.find((root) => {
|
|
2051
|
+
if (root.includes('*')) {
|
|
2052
|
+
const expression = new RegExp(`^${root.split('*').map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')}$`);
|
|
2053
|
+
return expression.test(path);
|
|
2054
|
+
}
|
|
2055
|
+
return path === root || path.startsWith(`${root}/`);
|
|
2056
|
+
}) ?? null;
|
|
2057
|
+
|
|
2058
|
+
const foreignIgnoredPaths = ({ root, git, identity, fs }) => {
|
|
2059
|
+
const result = gitRead(
|
|
2060
|
+
git, ['status', '--porcelain=v1', '-z', '--ignored', '--untracked-files=all'], root,
|
|
2061
|
+
'git status --ignored failed',
|
|
2062
|
+
);
|
|
2063
|
+
const seen = new Map();
|
|
2064
|
+
for (const entry of parseStatusZ(result.stdout)) {
|
|
2065
|
+
if (entry.code !== '!!') continue;
|
|
2066
|
+
for (const rawPath of entry.paths) {
|
|
2067
|
+
const normalized = normalizeSlashes(rawPath);
|
|
2068
|
+
const path = normalized.replace(/\/$/, '');
|
|
2069
|
+
const kind = classifyNodeNoFollow(join(root, path), fs).kind;
|
|
2070
|
+
const rootType = kind === 'plain-directory' || kind === 'symlink-to-directory'
|
|
2071
|
+
? 'directory'
|
|
2072
|
+
: kind === 'regular-file' || kind === 'symlink-to-file'
|
|
2073
|
+
? 'file'
|
|
2074
|
+
: kind === 'absent'
|
|
2075
|
+
? (normalized.endsWith('/') ? 'directory' : 'file')
|
|
2076
|
+
: 'unknown';
|
|
2077
|
+
seen.set(path, rootType);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
const knownRoots = provisionKnownRoots(identity);
|
|
2081
|
+
const directoryRoots = provisionKnownDirectoryRoots({ root, identity, fs });
|
|
2082
|
+
return [...seen].filter(([path, rootType]) => {
|
|
2083
|
+
const known = rootCoveringPath(path, knownRoots);
|
|
2084
|
+
if (known === null) return true;
|
|
2085
|
+
const atRoot = known.includes('*') || path === known;
|
|
2086
|
+
if (atRoot && rootType !== (directoryRoots.has(known) ? 'directory' : 'file')) return true;
|
|
2087
|
+
return !atRoot && !directoryRoots.has(known);
|
|
2088
|
+
}).map(([path]) => path);
|
|
2089
|
+
};
|
|
2090
|
+
|
|
2091
|
+
const composeCleanupCommand = ({ slug, branch, abandon }) =>
|
|
2092
|
+
`cleanup ${shellQuoteArg(slug)}` +
|
|
2093
|
+
`${branch === `${DEFAULT_BRANCH_PREFIX}${slug}` ? '' : ` --branch ${shellQuoteArg(branch)}`}` +
|
|
2094
|
+
`${abandon ? ' --abandon' : ''}`;
|
|
2095
|
+
|
|
2096
|
+
const destructiveRecovery = ({ slug, branch }) =>
|
|
2097
|
+
`Destructive recovery requires: ${composeCleanupCommand({ slug, branch, abandon: true })}`;
|
|
2098
|
+
|
|
2099
|
+
const cleanupWritabilityStop = ({ parentDir, root, slug, branch, abandon }) => stop([
|
|
2100
|
+
`the worktrees parent dir is not writable from this session: ${parentDir}`,
|
|
2101
|
+
'Arm the ONE-TIME consent (then every provision/cleanup runs promptless):',
|
|
2102
|
+
` .claude/settings.json → sandbox.filesystem.allowWrite += ${JSON.stringify(parentDir)}`,
|
|
2103
|
+
'Or run the full command yourself in a plain terminal:',
|
|
2104
|
+
` ${composeOwnToolPrefix(root)} ${composeCleanupCommand({ slug, branch, abandon })}`,
|
|
2105
|
+
].join('\n'));
|
|
2106
|
+
|
|
2107
|
+
const removeWorktree = ({ root, entry, branch, abandon, git }) => {
|
|
2108
|
+
const args = ['worktree', 'remove', ...(abandon ? ['--force'] : []), entry.path];
|
|
2109
|
+
try {
|
|
2110
|
+
gitMutation(git, args, root, 'git worktree remove failed');
|
|
2111
|
+
} catch (error) {
|
|
2112
|
+
if (/EBUSY|Device or resource busy/i.test(error.message)) {
|
|
2113
|
+
throw stop(
|
|
2114
|
+
`${error.message}\nLikely causes: lingering processes or open file descriptors (including a sandbox mount). ` +
|
|
2115
|
+
'Close them and retry cleanup outside the sandbox if needed.',
|
|
2116
|
+
);
|
|
2117
|
+
}
|
|
2118
|
+
throw error;
|
|
2119
|
+
}
|
|
2120
|
+
const deleteFlag = abandon ? '-D' : '-d';
|
|
2121
|
+
// Normal cleanup's divergence STOP makes -d sufficient; --abandon is explicitly destructive.
|
|
2122
|
+
gitMutation(git, ['branch', deleteFlag, branch], root, `git branch ${deleteFlag} failed`);
|
|
2123
|
+
gitMutation(git, ['worktree', 'prune'], root, 'git worktree prune failed');
|
|
2124
|
+
};
|
|
2125
|
+
|
|
2126
|
+
export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
2127
|
+
const slug = validateSlug(argvSlug);
|
|
2128
|
+
const fs = fsOf(deps);
|
|
2129
|
+
const { root, commonDir } = resolveRoots(cwd, git);
|
|
2130
|
+
const branch = flags.branch ?? `${DEFAULT_BRANCH_PREFIX}${slug}`;
|
|
2131
|
+
|
|
2132
|
+
return withPrepareLock({ commonDir, fs, now: deps.now ?? Date.now }, () => {
|
|
2133
|
+
const entry = findSatelliteEntry({ root, slug, branch, git, fs });
|
|
2134
|
+
if (entry.prunable) {
|
|
2135
|
+
gitMutation(git, ['worktree', 'prune'], root, 'git worktree prune failed');
|
|
2136
|
+
log(`[worktrees] pruned the registered deleted worktree for ${slug}`);
|
|
2137
|
+
return EXIT.ok;
|
|
2138
|
+
}
|
|
2139
|
+
const identity = readSatelliteIdentity({ entry, slug, expectedBranch: branch, fs, abandon: flags.abandon });
|
|
2140
|
+
if (flags.abandon) log('WARNING: cleanup --abandon DESTROYS unlanded work');
|
|
2141
|
+
|
|
2142
|
+
const knownRoots = provisionKnownRoots(identity);
|
|
2143
|
+
let removalRoots = [];
|
|
2144
|
+
if (!flags.abandon) {
|
|
2145
|
+
const mainHead = gitRead(git, ['rev-parse', 'HEAD'], root, 'cannot resolve main HEAD').stdout.trim();
|
|
2146
|
+
// Transfer verification excludes docs/ai and docs/plans; their tracked drift is checked separately before reset.
|
|
2147
|
+
const stagedPaths = changedPaths(
|
|
2148
|
+
git, ['diff', '--cached', '--name-only', '--no-renames', 'HEAD'], entry.path,
|
|
2149
|
+
'git diff failed for landed verification',
|
|
2150
|
+
);
|
|
2151
|
+
const unstagedPaths = changedPaths(
|
|
2152
|
+
git, ['diff', '--name-only', '--no-renames'], entry.path,
|
|
2153
|
+
'git diff failed for landed verification',
|
|
2154
|
+
);
|
|
2155
|
+
const excludedStagedPaths = nulFields(gitRead(
|
|
2156
|
+
git, ['diff', '--cached', '--name-only', '--no-renames', '-z', '--', 'docs/ai', 'docs/plans'], entry.path,
|
|
2157
|
+
'git diff failed for excluded tracked paths',
|
|
2158
|
+
).stdout);
|
|
2159
|
+
const excludedUnstagedPaths = nulFields(gitRead(
|
|
2160
|
+
git, ['diff', '--name-only', '--no-renames', '-z', '--', 'docs/ai', 'docs/plans'], entry.path,
|
|
2161
|
+
'git diff failed for excluded tracked paths',
|
|
2162
|
+
).stdout);
|
|
2163
|
+
const mismatches = new Set([...unstagedPaths, ...excludedStagedPaths, ...excludedUnstagedPaths]);
|
|
2164
|
+
for (const path of stagedPaths) {
|
|
2165
|
+
const satellite = indexEntry(git, entry.path, path);
|
|
2166
|
+
const main = headEntry(git, root, mainHead, path);
|
|
2167
|
+
if (satellite?.mode !== main?.mode || satellite?.oid !== main?.oid) mismatches.add(path);
|
|
2168
|
+
}
|
|
2169
|
+
if (mismatches.size > 0) {
|
|
2170
|
+
const lines = [...mismatches].map(
|
|
2171
|
+
(path) => `${path}: differs — canon-overwritten at land OR changed after land; confirm via the handoff`,
|
|
2172
|
+
);
|
|
2173
|
+
throw stop(
|
|
2174
|
+
`unlanded or foreign work prevents cleanup:\n${lines.join('\n')}\n` +
|
|
2175
|
+
destructiveRecovery({ slug, branch }),
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
if (identity.record.prepared === null) {
|
|
2180
|
+
throw stop(
|
|
2181
|
+
`nothing has been landed: the handoff prepared-tree OID is absent — ` +
|
|
2182
|
+
`${composeCleanupCommand({ slug, branch, abandon: true })} is required`,
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
const untracked = untrackedPaths(git, entry.path);
|
|
2187
|
+
const foreign = [];
|
|
2188
|
+
for (const path of untracked) {
|
|
2189
|
+
const known = rootCoveringPath(path, knownRoots);
|
|
2190
|
+
if (known === null) foreign.push(path);
|
|
2191
|
+
else removalRoots.push(known.includes('*') ? path : known);
|
|
2192
|
+
}
|
|
2193
|
+
if (foreign.length > 0) {
|
|
2194
|
+
throw stop(
|
|
2195
|
+
`unlanded or foreign work prevents cleanup:\n${foreign.join('\n')}\n` +
|
|
2196
|
+
destructiveRecovery({ slug, branch }),
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
const foreignIgnored = foreignIgnoredPaths({ root: entry.path, git, identity, fs });
|
|
2200
|
+
if (foreignIgnored.length > 0) {
|
|
2201
|
+
throw stop(`foreign ignored content requires --abandon:\n${foreignIgnored.join('\n')}`);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
const probe = probeParentWritable(dirname(entry.path), deps);
|
|
2206
|
+
if (!probe.writable) {
|
|
2207
|
+
if (probe.cleanupFailed) throw probeCleanupStop(probe.cleanupFailed);
|
|
2208
|
+
throw cleanupWritabilityStop({ parentDir: dirname(entry.path), root, slug, branch, abandon: flags.abandon });
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
if (!flags.abandon) {
|
|
2212
|
+
const satelliteHead = gitRead(git, ['rev-parse', 'HEAD'], entry.path, 'cannot resolve satellite HEAD').stdout.trim();
|
|
2213
|
+
gitMutation(git, ['reset', '--hard', satelliteHead], entry.path, 'git reset --hard failed before cleanup');
|
|
2214
|
+
removalRoots = [...new Set(removalRoots)].sort((a, b) => a.length - b.length)
|
|
2215
|
+
.filter((path, index, all) => !all.slice(0, index).some((parent) => path.startsWith(`${parent}/`)));
|
|
2216
|
+
for (const rel of removalRoots) {
|
|
2217
|
+
removeNodeNoFollow({ root: entry.path, abs: join(entry.path, rel), fs, label: rel });
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
removeWorktree({ root, entry, branch, abandon: flags.abandon, git });
|
|
2222
|
+
log(`[worktrees] cleanup complete for ${slug}`);
|
|
2223
|
+
return EXIT.ok;
|
|
2224
|
+
});
|
|
2225
|
+
};
|
|
2226
|
+
|
|
2227
|
+
// ── CLI ────────────────────────────────────────────────────────────────────────────────
|
|
2228
|
+
|
|
2229
|
+
export const parseArgs = (argv) => {
|
|
2230
|
+
const [sub, ...rest] = argv;
|
|
2231
|
+
if (sub === undefined || sub === '--help' || sub === '-h') return { sub: 'help' };
|
|
2232
|
+
if (!['provision', 'list', 'land', 'cleanup'].includes(sub)) {
|
|
2233
|
+
throw usageStop(`unknown subcommand ${JSON.stringify(sub)}\n${USAGE}`);
|
|
2234
|
+
}
|
|
2235
|
+
const SUB_FLAGS = {
|
|
2236
|
+
provision: ['--plan', '--as', '--dir', '--branch', '--include', '--install', '--resume'],
|
|
2237
|
+
list: [],
|
|
2238
|
+
land: ['--prepare'],
|
|
2239
|
+
cleanup: ['--branch', '--abandon'],
|
|
2240
|
+
};
|
|
2241
|
+
const flags = { plan: null, as: null, dir: null, branch: null, include: [], install: false, resume: false, prepare: false, abandon: false };
|
|
2242
|
+
let slug = null;
|
|
2243
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
2244
|
+
const a = rest[i];
|
|
2245
|
+
if (a.startsWith('--')) {
|
|
2246
|
+
if (!SUB_FLAGS[sub].includes(a)) {
|
|
2247
|
+
throw usageStop(`flag ${JSON.stringify(a)} is not valid for ${sub}\n${USAGE}`);
|
|
2248
|
+
}
|
|
2249
|
+
const takesValue = ['--plan', '--as', '--dir', '--branch', '--include'].includes(a);
|
|
2250
|
+
if (takesValue) {
|
|
2251
|
+
i += 1;
|
|
2252
|
+
if (rest[i] === undefined) throw usageStop(`${a} requires an argument`);
|
|
2253
|
+
if (a === '--include') flags.include.push(rest[i]);
|
|
2254
|
+
else flags[a.slice(2)] = rest[i];
|
|
2255
|
+
} else {
|
|
2256
|
+
flags[a.slice(2)] = true;
|
|
2257
|
+
}
|
|
2258
|
+
} else if (slug === null) slug = a;
|
|
2259
|
+
else throw usageStop(`unexpected argument ${JSON.stringify(a)}`);
|
|
2260
|
+
}
|
|
2261
|
+
if (sub !== 'list' && slug === null) throw usageStop(`${sub} requires the positional <slug>`);
|
|
2262
|
+
if (sub === 'list' && slug !== null) throw usageStop('list takes no positional argument');
|
|
2263
|
+
return { sub, slug, flags };
|
|
2264
|
+
};
|
|
2265
|
+
|
|
2266
|
+
export const runCli = (argv, deps = {}) => {
|
|
2267
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
2268
|
+
const log = deps.log ?? console.log;
|
|
2269
|
+
const logError = deps.logError ?? console.error;
|
|
2270
|
+
const git = deps.git ?? spawnGit;
|
|
2271
|
+
try {
|
|
2272
|
+
const parsed = parseArgs(argv);
|
|
2273
|
+
if (parsed.sub === 'help') {
|
|
2274
|
+
log(USAGE);
|
|
2275
|
+
return EXIT.ok;
|
|
2276
|
+
}
|
|
2277
|
+
if (parsed.sub === 'provision') {
|
|
2278
|
+
return runProvision({ argvSlug: parsed.slug, flags: parsed.flags, cwd, git, deps, log });
|
|
2279
|
+
}
|
|
2280
|
+
if (parsed.sub === 'list') return runList({ cwd, git, deps, log });
|
|
2281
|
+
if (parsed.sub === 'land') {
|
|
2282
|
+
return runLand({ argvSlug: parsed.slug, flags: parsed.flags, cwd, git, deps, log });
|
|
2283
|
+
}
|
|
2284
|
+
return runCleanup({ argvSlug: parsed.slug, flags: parsed.flags, cwd, git, deps, log });
|
|
2285
|
+
} catch (err) {
|
|
2286
|
+
logError(`[worktrees] ${err.message}`);
|
|
2287
|
+
return err.exitCode ?? EXIT.stop;
|
|
2288
|
+
}
|
|
2289
|
+
};
|
|
2290
|
+
|
|
2291
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
2292
|
+
if (isDirectRun) process.exitCode = runCli(process.argv.slice(2));
|