pi-odd-worktree 0.0.0-stage → 0.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/README.md +223 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Creation hooks for the worktree tools (P2-S3).
|
|
3
|
+
//
|
|
4
|
+
// `runCreationHooks({principalRoot, worktreePath, ...})` runs, in order:
|
|
5
|
+
// (a) copy unversioned env files from the principal checkout,
|
|
6
|
+
// (a2) link the principal's git-ignored agent/ODD entries (S3/L4: `.agents`,
|
|
7
|
+
// `.pi`, `.claude`, `odd` — never a copy, never over an existing path),
|
|
8
|
+
// (b) dependency install by lockfile-detected stack,
|
|
9
|
+
// (c) `codegraph init` when the CLI is available.
|
|
10
|
+
//
|
|
11
|
+
// Both `worktree_upsert` (sync runner) and the wizard's gitOnly path (async
|
|
12
|
+
// twin) reach these hooks, so the link policy is applied by ONE shared
|
|
13
|
+
// implementation in `lib/worktree-symlinks.js` and both creation routes
|
|
14
|
+
// behave identically.
|
|
15
|
+
//
|
|
16
|
+
// A worktree reaches a checked-in `odd/` because the repository tracks it: a
|
|
17
|
+
// tracked entry arrives with the checkout, which is why the link step refuses
|
|
18
|
+
// every name git already tracks.
|
|
19
|
+
//
|
|
20
|
+
// Every external process is spawned with a fixed argv array (never a shell
|
|
21
|
+
// string) and a bounded timeout. A missing binary is a `skipped` step with
|
|
22
|
+
// a recorded reason, never a failure. A non-zero exit is a `failed` step
|
|
23
|
+
// plus a warning, and never aborts the remaining steps. Missing sources
|
|
24
|
+
// never fail the hook.
|
|
25
|
+
//
|
|
26
|
+
// The candidate list (`unversionedCandidates`) and the command table
|
|
27
|
+
// (`installTable`) are injectable so tests never touch the network and
|
|
28
|
+
// never need real package managers. Defaults match S8 exactly. The `run`
|
|
29
|
+
// and `hasBinary` hooks are injectable for the same reason.
|
|
30
|
+
|
|
31
|
+
const { spawnSync } = require('node:child_process');
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
const symlinksLib = require('./worktree-symlinks.js');
|
|
35
|
+
|
|
36
|
+
// Shared pure plan helpers (S63): the sync and async hook runners use the
|
|
37
|
+
// SAME detection/format/classification pipeline so the two implementations
|
|
38
|
+
// cannot drift. Detection stays sync lstat (constant-time, millisecond
|
|
39
|
+
// scale, never a 120s subprocess); only subprocesses and file copies go
|
|
40
|
+
// async in the async twin.
|
|
41
|
+
|
|
42
|
+
const DEFAULT_UNVERSIONED_FILES = [
|
|
43
|
+
'.env',
|
|
44
|
+
'.env.local',
|
|
45
|
+
'.env.development',
|
|
46
|
+
'.env.production',
|
|
47
|
+
'.env.test',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Order is the execution order when several markers are present (monorepo:
|
|
51
|
+
// every detected stack runs). `markers` is OR: any present file triggers
|
|
52
|
+
// the entry. `fallbackArgv` runs only when `argv` exits non-zero.
|
|
53
|
+
const DEFAULT_INSTALL_TABLE = [
|
|
54
|
+
{ key: 'bun', markers: ['bun.lockb', 'bun.lock'], argv: ['bun', 'install'] },
|
|
55
|
+
{ key: 'pnpm', markers: ['pnpm-lock.yaml'], argv: ['pnpm', 'install', '--frozen-lockfile'] },
|
|
56
|
+
{ key: 'yarn', markers: ['yarn.lock'], argv: ['yarn', 'install', '--frozen-lockfile'] },
|
|
57
|
+
{ key: 'npm', markers: ['package-lock.json'], argv: ['npm', 'ci'], fallbackArgv: ['npm', 'install'] },
|
|
58
|
+
{ key: 'uv', markers: ['uv.lock', 'pyproject.toml'], argv: ['uv', 'sync'] },
|
|
59
|
+
{ key: 'cargo', markers: ['Cargo.toml'], argv: ['cargo', 'fetch'] },
|
|
60
|
+
{ key: 'go', markers: ['go.mod'], argv: ['go', 'mod', 'download'] },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
64
|
+
|
|
65
|
+
function resolvePrincipal(opts) {
|
|
66
|
+
return opts.principalRoot || opts.root || opts.principal || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveWorktree(opts) {
|
|
70
|
+
return opts.worktreePath || opts.worktreeRoot || opts.path || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// True when the path exists, including a dangling symlink (lstat, not stat).
|
|
74
|
+
function pathExists(p) {
|
|
75
|
+
try {
|
|
76
|
+
fs.lstatSync(p);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Default PATH lookup: search each PATH entry for an executable file.
|
|
84
|
+
// Never spawns anything, never a shell string.
|
|
85
|
+
function defaultHasBinary(bin, env) {
|
|
86
|
+
if (typeof bin !== 'string' || bin === '') return false;
|
|
87
|
+
if (bin.includes(path.sep) || bin.includes('/')) {
|
|
88
|
+
try {
|
|
89
|
+
fs.accessSync(bin, fs.constants.X_OK);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const rawPath = (env || {}).PATH || (env || {}).Path || '';
|
|
96
|
+
const dirs = String(rawPath).split(path.delimiter).filter((d) => d.length > 0);
|
|
97
|
+
for (const dir of dirs) {
|
|
98
|
+
try {
|
|
99
|
+
fs.accessSync(path.join(dir, bin), fs.constants.X_OK);
|
|
100
|
+
return true;
|
|
101
|
+
} catch {
|
|
102
|
+
// Not here; keep searching.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Default runner: fixed argv via spawnSync, bounded timeout, no shell.
|
|
109
|
+
function defaultRun(bin, args, opts = {}) {
|
|
110
|
+
const result = spawnSync(bin, args, {
|
|
111
|
+
cwd: opts.cwd,
|
|
112
|
+
env: opts.env,
|
|
113
|
+
timeout: opts.timeoutMs,
|
|
114
|
+
encoding: 'utf8',
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
});
|
|
117
|
+
if (result.error) {
|
|
118
|
+
return {
|
|
119
|
+
status: result.status ?? null,
|
|
120
|
+
stdout: result.stdout || '',
|
|
121
|
+
stderr: result.stderr || '',
|
|
122
|
+
error: String((result.error && result.error.message) || result.error),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
status: result.status,
|
|
127
|
+
stdout: result.stdout || '',
|
|
128
|
+
stderr: result.stderr || '',
|
|
129
|
+
error: null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// True when <candidate> (relative, e.g. `.env`) is tracked by git in the
|
|
134
|
+
// principal checkout: `git ls-files --error-unmatch` exits 0. Fixed argv,
|
|
135
|
+
// bounded timeout, never a shell string.
|
|
136
|
+
function isTrackedByGit(run, principalRoot, candidate, timeoutMs, env) {
|
|
137
|
+
let res;
|
|
138
|
+
try {
|
|
139
|
+
res = run('git', ['ls-files', '--error-unmatch', candidate], {
|
|
140
|
+
cwd: principalRoot,
|
|
141
|
+
timeoutMs,
|
|
142
|
+
env,
|
|
143
|
+
});
|
|
144
|
+
} catch {
|
|
145
|
+
// A runner failure must never mark a file untracked (fail closed
|
|
146
|
+
// would copy a tracked file); treat as tracked so the copy is refused.
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (!res) return true;
|
|
150
|
+
if (res.status === 0) return true;
|
|
151
|
+
// A spawn-level failure (missing git, timeout) fails closed: refuse
|
|
152
|
+
// the copy rather than risk copying a tracked file.
|
|
153
|
+
if (res.status === null || res.status === undefined || res.error) return true;
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Shared pure pipeline (S63): sync + async runners use these ---
|
|
158
|
+
// Detection: which install entries fire for the worktree (markers OR).
|
|
159
|
+
// Sync lstat only (constant-time); never a subprocess, never the loop blocker.
|
|
160
|
+
function selectInstallEntries(worktreePath, table) {
|
|
161
|
+
const list = Array.isArray(table) ? table : DEFAULT_INSTALL_TABLE;
|
|
162
|
+
return list.filter((entry) => Array.isArray(entry.markers)
|
|
163
|
+
&& entry.markers.some((marker) => pathExists(path.join(worktreePath, marker))));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Split an install entry argv into fixed bin + args (never a shell string).
|
|
167
|
+
// Returns null when the entry carries no command.
|
|
168
|
+
function installEntryArgv(entry) {
|
|
169
|
+
const argv = (entry && entry.argv) || [];
|
|
170
|
+
const bin = argv[0];
|
|
171
|
+
if (typeof bin !== 'string' || bin === '') return null;
|
|
172
|
+
return { bin, args: argv.slice(1) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Classify a finished run: { ok } when status 0, else a short cause.
|
|
176
|
+
function runCause(res) {
|
|
177
|
+
if (res && res.status === 0) return null;
|
|
178
|
+
if (res && (res.stderr || res.error)) return String(res.stderr || res.error).trim();
|
|
179
|
+
const code = res && res.status !== null && res.status !== undefined ? res.status : 'non-zero';
|
|
180
|
+
return `exit ${code}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Copy outcome formatting shared by both runners.
|
|
184
|
+
function formatCopyOutcome(copied, skipped, copyError) {
|
|
185
|
+
if (copyError && copied.length === 0) {
|
|
186
|
+
const message = `copy unversioned files failed: ${(copyError && copyError.message) || copyError}`;
|
|
187
|
+
return { status: 'failed', detail: `copied none; ${skipped.join('; ')}`, warning: message };
|
|
188
|
+
}
|
|
189
|
+
const warning = copyError
|
|
190
|
+
? `copy unversioned files partially failed: ${(copyError && copyError.message) || copyError}`
|
|
191
|
+
: null;
|
|
192
|
+
const detail = copied.length > 0
|
|
193
|
+
? `copied ${copied.join(', ')}${skipped.length > 0 ? `; skipped ${skipped.join('; ')}` : ''}`
|
|
194
|
+
: `copied none; ${skipped.join('; ') || 'nothing to copy'}`;
|
|
195
|
+
return { status: copied.length > 0 ? 'done' : 'skipped', detail, warning };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Link outcome formatting shared by both runners. `created` means "links in
|
|
199
|
+
// place after this call" (the create tool surfaces it), `existing` the ones
|
|
200
|
+
// that were already correct, and `errors` the ones whose symlink failed — a
|
|
201
|
+
// failed link is reported, never silent, and never fails the creation.
|
|
202
|
+
function formatLinkOutcome(applied, error) {
|
|
203
|
+
if (!applied) {
|
|
204
|
+
const message = `link principal entries failed: ${(error && error.message) || error || 'unknown error'}`;
|
|
205
|
+
return { status: 'failed', detail: message, warning: message };
|
|
206
|
+
}
|
|
207
|
+
const created = applied.created || [];
|
|
208
|
+
const existing = applied.existing || [];
|
|
209
|
+
const skipped = applied.skipped || [];
|
|
210
|
+
const errors = applied.errors || [];
|
|
211
|
+
const parts = [];
|
|
212
|
+
if (created.length > 0) parts.push(`linked ${created.join(', ')}`);
|
|
213
|
+
if (existing.length > 0) parts.push(`already linked ${existing.join(', ')}`);
|
|
214
|
+
if (skipped.length > 0) parts.push(`skipped ${skipped.join('; ')}`);
|
|
215
|
+
const detail = parts.join('; ') || 'nothing to link';
|
|
216
|
+
if (errors.length === 0) {
|
|
217
|
+
return {
|
|
218
|
+
status: created.length > 0 ? 'done' : 'skipped',
|
|
219
|
+
detail,
|
|
220
|
+
warning: null,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const message = `link principal entries failed: ${errors.join('; ')}`;
|
|
224
|
+
return { status: 'failed', detail, warning: message };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Default async runner: SHARED with `lib/worktree-git.js` runArgvAsync
|
|
228
|
+
// (the wizard's effective production runner) — one ownership
|
|
229
|
+
// implementation, never two divergent kill paths. Fixed argv, never a
|
|
230
|
+
// shell string, never the main-thread blocker; bounded timeout with
|
|
231
|
+
// SIGTERM-then-SIGKILL escalation and owned-group reaping (POSIX), plus
|
|
232
|
+
// AbortSignal support. Forwards cwd/env/spawnFn/signal/timeoutMs/killGraceMs
|
|
233
|
+
// and resolves the same `{status, stdout, stderr, error}` shape.
|
|
234
|
+
function defaultRunAsync(bin, args, opts = {}) {
|
|
235
|
+
let gitAsync = null;
|
|
236
|
+
try {
|
|
237
|
+
gitAsync = require('./worktree-git.js').runArgvAsync;
|
|
238
|
+
} catch {
|
|
239
|
+
gitAsync = null;
|
|
240
|
+
}
|
|
241
|
+
if (typeof gitAsync === 'function') {
|
|
242
|
+
return gitAsync(bin, args, opts);
|
|
243
|
+
}
|
|
244
|
+
return Promise.resolve({
|
|
245
|
+
status: null,
|
|
246
|
+
stdout: '',
|
|
247
|
+
stderr: '',
|
|
248
|
+
error: 'async runner unavailable: lib/worktree-git.js could not be loaded',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Async existence via lstat (dangling symlink counts as exists, like sync).
|
|
253
|
+
async function pathExistsAsync(p, existsAsync) {
|
|
254
|
+
if (typeof existsAsync === 'function') return !!(await existsAsync(p));
|
|
255
|
+
try {
|
|
256
|
+
await fs.promises.lstat(p);
|
|
257
|
+
return true;
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function copyFileAsyncDefault(src, dest, copyAsync) {
|
|
264
|
+
if (typeof copyAsync === 'function') return copyAsync(src, dest);
|
|
265
|
+
return fs.promises.copyFile(src, dest);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Async tracked check: same fail-closed classification as the sync twin.
|
|
269
|
+
async function isTrackedByGitAsync(runAsync, principalRoot, candidate, timeoutMs, env) {
|
|
270
|
+
let res;
|
|
271
|
+
try {
|
|
272
|
+
res = await runAsync('git', ['ls-files', '--error-unmatch', candidate], { cwd: principalRoot, timeoutMs, env });
|
|
273
|
+
} catch {
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
if (!res) return true;
|
|
277
|
+
if (res.status === 0) return true;
|
|
278
|
+
if (res.status === null || res.status === undefined || res.error) return true;
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Async counterpart of runCreationHooks (S63): same order, same steps,
|
|
283
|
+
// same warnings, same fail-closed gates — but subprocesses run on a
|
|
284
|
+
// spawned child with a killing timeout and copies run on async fs, so
|
|
285
|
+
// Pi input/render/event-loop work advances WHILE hooks are pending.
|
|
286
|
+
// Never detaches orphan jobs: every child is owned until settle (close /
|
|
287
|
+
// error / timeout / abort kills it). Never skips installs/codegraph as
|
|
288
|
+
// a speed fix; no extra retries beyond the npm fallback. Fixed argv only.
|
|
289
|
+
//
|
|
290
|
+
// `deferSetup: true` splits the run for the INTERACTIVE create: the file-level
|
|
291
|
+
// steps (unversioned copies, principal links) still settle before it returns,
|
|
292
|
+
// while the slow half (dependency install, `codegraph init`) travels back as
|
|
293
|
+
// `setup()` — one callable, run once, never rejecting — so the human returns to
|
|
294
|
+
// Warp as soon as the checkout path exists. Nothing is skipped: the deferred
|
|
295
|
+
// phases still run, just after the caller has handed control back.
|
|
296
|
+
async function runCreationHooksAsync(opts = {}) {
|
|
297
|
+
const principalRoot = resolvePrincipal(opts);
|
|
298
|
+
const worktreePath = resolveWorktree(opts);
|
|
299
|
+
if (typeof principalRoot !== 'string' || principalRoot === '') {
|
|
300
|
+
throw new TypeError('runCreationHooksAsync requires principalRoot (or root)');
|
|
301
|
+
}
|
|
302
|
+
if (typeof worktreePath !== 'string' || worktreePath === '') {
|
|
303
|
+
throw new TypeError('runCreationHooksAsync requires worktreePath');
|
|
304
|
+
}
|
|
305
|
+
const env = opts.env || process.env;
|
|
306
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
307
|
+
const signal = opts.signal || null;
|
|
308
|
+
const candidates = Array.isArray(opts.unversionedCandidates)
|
|
309
|
+
? opts.unversionedCandidates
|
|
310
|
+
: DEFAULT_UNVERSIONED_FILES;
|
|
311
|
+
const table = Array.isArray(opts.installTable) ? opts.installTable : DEFAULT_INSTALL_TABLE;
|
|
312
|
+
const spawnFn = opts.spawnFn;
|
|
313
|
+
const killGraceMs = opts.killGraceMs;
|
|
314
|
+
// Preferred async runner; else await a promise-returning `run`; else the
|
|
315
|
+
// owned default that kills on timeout/abort. A purely sync blocking `run`
|
|
316
|
+
// must not be passed here (it would re-block the loop).
|
|
317
|
+
let runAsync;
|
|
318
|
+
if (typeof opts.runAsync === 'function') {
|
|
319
|
+
runAsync = (bin, args, o) => opts.runAsync(bin, args, o);
|
|
320
|
+
} else if (typeof opts.run === 'function') {
|
|
321
|
+
runAsync = (bin, args, o) => {
|
|
322
|
+
const out = opts.run(bin, args, o);
|
|
323
|
+
if (out && typeof out.then === 'function') return out;
|
|
324
|
+
return Promise.resolve(out);
|
|
325
|
+
};
|
|
326
|
+
} else {
|
|
327
|
+
runAsync = (bin, args, o) => defaultRunAsync(bin, args, { ...o, env, spawnFn, signal });
|
|
328
|
+
}
|
|
329
|
+
const defaultedRunAsync = runAsync;
|
|
330
|
+
const runWithDefaults = (bin, args, o) => defaultedRunAsync(bin, args, { ...o, env, spawnFn, signal, killGraceMs });
|
|
331
|
+
const hasBinary = typeof opts.hasBinary === 'function'
|
|
332
|
+
? (bin) => opts.hasBinary(bin, env)
|
|
333
|
+
: (bin) => defaultHasBinary(bin, env);
|
|
334
|
+
const existsAsync = opts.existsAsync;
|
|
335
|
+
const copyAsync = opts.copyAsync;
|
|
336
|
+
|
|
337
|
+
const steps = [];
|
|
338
|
+
const warnings = [];
|
|
339
|
+
let failed = false;
|
|
340
|
+
const addStep = (name, status, detail) => {
|
|
341
|
+
steps.push({ name, status, detail: String(detail || '') });
|
|
342
|
+
if (status === 'failed') failed = true;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// (a) Copy unversioned files on async fs (heartbeat advances mid-copy).
|
|
346
|
+
{
|
|
347
|
+
const copied = [];
|
|
348
|
+
const skipped = [];
|
|
349
|
+
let copyError = null;
|
|
350
|
+
for (const candidate of candidates) {
|
|
351
|
+
const src = path.join(principalRoot, candidate);
|
|
352
|
+
const dest = path.join(worktreePath, candidate);
|
|
353
|
+
if (!(await pathExistsAsync(src, existsAsync))) {
|
|
354
|
+
skipped.push(`${candidate}: missing source`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (await pathExistsAsync(dest, existsAsync)) {
|
|
358
|
+
skipped.push(`${candidate}: target exists`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (await isTrackedByGitAsync(runWithDefaults, principalRoot, candidate, timeoutMs, env)) {
|
|
362
|
+
skipped.push(`${candidate}: tracked`);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
await copyFileAsyncDefault(src, dest, copyAsync);
|
|
367
|
+
copied.push(candidate);
|
|
368
|
+
} catch (err) {
|
|
369
|
+
copyError = err;
|
|
370
|
+
skipped.push(`${candidate}: copy failed`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const outcome = formatCopyOutcome(copied, skipped, copyError);
|
|
374
|
+
if (outcome.warning) warnings.push(outcome.warning);
|
|
375
|
+
addStep('copy-unversioned', outcome.status, outcome.detail);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// (a2) Link git-ignored principal entries: the async twin of the sync
|
|
379
|
+
// step, sharing the one classifier in lib/worktree-symlinks.js so the tool
|
|
380
|
+
// path and the gitOnly/wizard path cannot disagree. The git reads and the
|
|
381
|
+
// link writes are awaited, so the event loop keeps running.
|
|
382
|
+
//
|
|
383
|
+
// Policy (round-3 S3/L4): `.agents`, `.pi`, `.claude` and `odd` become
|
|
384
|
+
// symlinks to the principal ONLY when git ignores them there. A tracked
|
|
385
|
+
// entry arrives with the checkout, an untracked-but-not-ignored one is not
|
|
386
|
+
// eligible, and an occupied target is preserved — never copied, never
|
|
387
|
+
// deleted, never fatal.
|
|
388
|
+
{
|
|
389
|
+
let applied = null;
|
|
390
|
+
let linkError = null;
|
|
391
|
+
try {
|
|
392
|
+
applied = await symlinksLib.applyWorktreeSymlinksAsync({
|
|
393
|
+
principalRoot, worktreePath, env, timeoutMs, runAsync: runWithDefaults,
|
|
394
|
+
});
|
|
395
|
+
} catch (err) {
|
|
396
|
+
linkError = err;
|
|
397
|
+
}
|
|
398
|
+
const outcome = formatLinkOutcome(applied, linkError);
|
|
399
|
+
if (outcome.warning) warnings.push(outcome.warning);
|
|
400
|
+
addStep('link-principal-entries', outcome.status, outcome.detail);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// (b) Dependency install by detected stack (shared detection pipeline) and
|
|
404
|
+
// (c) `codegraph init` when the CLI is available. These two are the SLOW
|
|
405
|
+
// half — a package manager can run for minutes — so they are one unit that
|
|
406
|
+
// the interactive create can defer instead of awaiting.
|
|
407
|
+
const runSetupPhases = async () => {
|
|
408
|
+
const setupSteps = [];
|
|
409
|
+
const setupWarnings = [];
|
|
410
|
+
let setupFailed = false;
|
|
411
|
+
const addSetupStep = (name, status, detail) => {
|
|
412
|
+
setupSteps.push({ name, status, detail: String(detail || '') });
|
|
413
|
+
if (status === 'failed') setupFailed = true;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
{
|
|
417
|
+
const detected = selectInstallEntries(worktreePath, table);
|
|
418
|
+
if (detected.length === 0) {
|
|
419
|
+
addSetupStep('install', 'skipped', 'no lockfile detected');
|
|
420
|
+
}
|
|
421
|
+
for (const entry of detected) {
|
|
422
|
+
const name = `install:${entry.key}`;
|
|
423
|
+
const parsed = installEntryArgv(entry);
|
|
424
|
+
if (!parsed) {
|
|
425
|
+
addSetupStep(name, 'skipped', 'no command configured');
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const { bin, args } = parsed;
|
|
429
|
+
if (!hasBinary(bin)) {
|
|
430
|
+
addSetupStep(name, 'skipped', `${bin} not on PATH`);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
let res;
|
|
434
|
+
try {
|
|
435
|
+
res = await runWithDefaults(bin, args, { cwd: worktreePath, timeoutMs, env });
|
|
436
|
+
} catch (err) {
|
|
437
|
+
res = { status: null, stdout: '', stderr: '', error: String((err && err.message) || err) };
|
|
438
|
+
}
|
|
439
|
+
if (res && res.status === 0) {
|
|
440
|
+
addSetupStep(name, 'done', `${bin} ${args.join(' ')} exited 0`);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (Array.isArray(entry.fallbackArgv) && entry.fallbackArgv.length > 0) {
|
|
444
|
+
const fbBin = entry.fallbackArgv[0];
|
|
445
|
+
const fbArgs = entry.fallbackArgv.slice(1);
|
|
446
|
+
if (!hasBinary(fbBin)) {
|
|
447
|
+
const message = `${name}: ${bin} ${args.join(' ')} failed and fallback ${fbBin} not on PATH`;
|
|
448
|
+
setupWarnings.push(message);
|
|
449
|
+
addSetupStep(name, 'failed', message);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
let fb;
|
|
453
|
+
try {
|
|
454
|
+
fb = await runWithDefaults(fbBin, fbArgs, { cwd: worktreePath, timeoutMs, env });
|
|
455
|
+
} catch (err) {
|
|
456
|
+
fb = { status: null, stdout: '', stderr: '', error: String((err && err.message) || err) };
|
|
457
|
+
}
|
|
458
|
+
if (fb && fb.status === 0) {
|
|
459
|
+
addSetupStep(name, 'done', `${bin} ${args.join(' ')} failed, ${fbBin} ${fbArgs.join(' ')} exited 0`);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const message = `${name}: ${bin} ${args.join(' ')} failed (${((res && (res.stderr || res.error)) || 'exit non-zero').trim()}), `
|
|
463
|
+
+ `fallback ${fbBin} ${fbArgs.join(' ')} failed (${((fb && (fb.stderr || fb.error)) || 'exit non-zero').trim()})`;
|
|
464
|
+
setupWarnings.push(message);
|
|
465
|
+
addSetupStep(name, 'failed', message);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const cause = runCause(res);
|
|
469
|
+
const message = `${name}: ${bin} ${args.join(' ')} failed (${cause})`;
|
|
470
|
+
setupWarnings.push(message);
|
|
471
|
+
addSetupStep(name, 'failed', message);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
{
|
|
476
|
+
if (!hasBinary('codegraph')) {
|
|
477
|
+
addSetupStep('codegraph-init', 'skipped', 'codegraph not on PATH');
|
|
478
|
+
} else {
|
|
479
|
+
let res;
|
|
480
|
+
try {
|
|
481
|
+
res = await runWithDefaults('codegraph', ['init'], { cwd: worktreePath, timeoutMs, env });
|
|
482
|
+
} catch (err) {
|
|
483
|
+
res = { status: null, stdout: '', stderr: '', error: String((err && err.message) || err) };
|
|
484
|
+
}
|
|
485
|
+
if (res && res.status === 0) {
|
|
486
|
+
addSetupStep('codegraph-init', 'done', 'codegraph init exited 0');
|
|
487
|
+
} else {
|
|
488
|
+
const cause = runCause(res);
|
|
489
|
+
const message = `codegraph-init: codegraph init failed (${cause})`;
|
|
490
|
+
setupWarnings.push(message);
|
|
491
|
+
addSetupStep('codegraph-init', 'failed', message);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return { ok: !setupFailed, steps: setupSteps, warnings: setupWarnings };
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
// Deferred setup: the caller wants the checkout usable NOW and the slow
|
|
500
|
+
// phases later. Nothing is skipped — the phases travel as ONE callable that
|
|
501
|
+
// runs them exactly once (a second call returns the same promise, so a
|
|
502
|
+
// retry can never install twice) and never rejects: a thrown phase becomes a
|
|
503
|
+
// failed step, so a deferred caller can always report an outcome.
|
|
504
|
+
let setupPromise = null;
|
|
505
|
+
const runSetupOnce = () => {
|
|
506
|
+
if (setupPromise === null) {
|
|
507
|
+
setupPromise = (async () => {
|
|
508
|
+
try {
|
|
509
|
+
return await runSetupPhases();
|
|
510
|
+
} catch (err) {
|
|
511
|
+
const message = `creation setup failed: ${(err && err.message) || err}`;
|
|
512
|
+
return { ok: false, steps: [{ name: 'setup', status: 'failed', detail: message }], warnings: [message] };
|
|
513
|
+
}
|
|
514
|
+
})();
|
|
515
|
+
}
|
|
516
|
+
return setupPromise;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
if (opts.deferSetup === true) {
|
|
520
|
+
// The steps the deferred half WILL report are named as pending, so the
|
|
521
|
+
// caller's own report is honest about what has not happened yet instead of
|
|
522
|
+
// claiming a skipped or a completed setup.
|
|
523
|
+
addStep('install', 'pending', 'setup runs in the background');
|
|
524
|
+
addStep('codegraph-init', 'pending', 'setup runs in the background');
|
|
525
|
+
return { ok: !failed, steps, warnings, deferred: true, setup: runSetupOnce };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const setupOutcome = await runSetupOnce();
|
|
529
|
+
for (const step of setupOutcome.steps) steps.push(step);
|
|
530
|
+
for (const warning of setupOutcome.warnings) warnings.push(warning);
|
|
531
|
+
if (!setupOutcome.ok) failed = true;
|
|
532
|
+
|
|
533
|
+
return { ok: !failed, steps, warnings };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function runCreationHooks(opts = {}) {
|
|
537
|
+
const principalRoot = resolvePrincipal(opts);
|
|
538
|
+
const worktreePath = resolveWorktree(opts);
|
|
539
|
+
if (typeof principalRoot !== 'string' || principalRoot === '') {
|
|
540
|
+
throw new TypeError('runCreationHooks requires principalRoot (or root)');
|
|
541
|
+
}
|
|
542
|
+
if (typeof worktreePath !== 'string' || worktreePath === '') {
|
|
543
|
+
throw new TypeError('runCreationHooks requires worktreePath');
|
|
544
|
+
}
|
|
545
|
+
const env = opts.env || process.env;
|
|
546
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
547
|
+
const candidates = Array.isArray(opts.unversionedCandidates)
|
|
548
|
+
? opts.unversionedCandidates
|
|
549
|
+
: DEFAULT_UNVERSIONED_FILES;
|
|
550
|
+
const table = Array.isArray(opts.installTable) ? opts.installTable : DEFAULT_INSTALL_TABLE;
|
|
551
|
+
const run = typeof opts.run === 'function'
|
|
552
|
+
? (bin, args, o) => opts.run(bin, args, o)
|
|
553
|
+
: (bin, args, o) => defaultRun(bin, args, { ...o, env });
|
|
554
|
+
const hasBinary = typeof opts.hasBinary === 'function'
|
|
555
|
+
? (bin) => opts.hasBinary(bin, env)
|
|
556
|
+
: (bin) => defaultHasBinary(bin, env);
|
|
557
|
+
|
|
558
|
+
const steps = [];
|
|
559
|
+
const warnings = [];
|
|
560
|
+
let failed = false;
|
|
561
|
+
const addStep = (name, status, detail) => {
|
|
562
|
+
steps.push({ name, status, detail: String(detail || '') });
|
|
563
|
+
if (status === 'failed') failed = true;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// (a) Copy unversioned files. Each candidate is copied from the
|
|
567
|
+
// principal checkout ONLY when it exists there, is NOT tracked by git,
|
|
568
|
+
// and does NOT already exist in the worktree. Never overwrite, never
|
|
569
|
+
// copy a tracked file, never fail for a missing source.
|
|
570
|
+
{
|
|
571
|
+
const copied = [];
|
|
572
|
+
const skipped = [];
|
|
573
|
+
let copyError = null;
|
|
574
|
+
for (const candidate of candidates) {
|
|
575
|
+
const src = path.join(principalRoot, candidate);
|
|
576
|
+
const dest = path.join(worktreePath, candidate);
|
|
577
|
+
if (!pathExists(src)) {
|
|
578
|
+
skipped.push(`${candidate}: missing source`);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (pathExists(dest)) {
|
|
582
|
+
skipped.push(`${candidate}: target exists`);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (isTrackedByGit(run, principalRoot, candidate, timeoutMs, env)) {
|
|
586
|
+
skipped.push(`${candidate}: tracked`);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
fs.copyFileSync(src, dest);
|
|
591
|
+
copied.push(candidate);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
copyError = err;
|
|
594
|
+
skipped.push(`${candidate}: copy failed`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (copyError && copied.length === 0) {
|
|
598
|
+
// A copy error with nothing copied is a failed step plus a warning,
|
|
599
|
+
// but never aborts the remaining hooks.
|
|
600
|
+
const message = `copy unversioned files failed: ${(copyError && copyError.message) || copyError}`;
|
|
601
|
+
warnings.push(message);
|
|
602
|
+
addStep('copy-unversioned', 'failed', `copied none; ${skipped.join('; ')}`);
|
|
603
|
+
} else {
|
|
604
|
+
if (copyError) warnings.push(`copy unversioned files partially failed: ${(copyError && copyError.message) || copyError}`);
|
|
605
|
+
addStep(
|
|
606
|
+
'copy-unversioned',
|
|
607
|
+
copied.length > 0 ? 'done' : 'skipped',
|
|
608
|
+
copied.length > 0
|
|
609
|
+
? `copied ${copied.join(', ')}${skipped.length > 0 ? `; skipped ${skipped.join('; ')}` : ''}`
|
|
610
|
+
: `copied none; ${skipped.join('; ') || 'nothing to copy'}`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// (a2) Link git-ignored principal entries (round-3 S3/L4). One shared
|
|
616
|
+
// policy for both creation routes: `.agents`, `.pi`, `.claude` and `odd`
|
|
617
|
+
// become symlinks to the principal ONLY when git ignores them there. A
|
|
618
|
+
// tracked entry arrives with the checkout, an untracked-but-not-ignored
|
|
619
|
+
// one is not eligible, and an occupied target is preserved — this step
|
|
620
|
+
// never copies, never deletes and never aborts the creation.
|
|
621
|
+
{
|
|
622
|
+
let applied = null;
|
|
623
|
+
let linkError = null;
|
|
624
|
+
try {
|
|
625
|
+
applied = symlinksLib.applyWorktreeSymlinks({
|
|
626
|
+
principalRoot, worktreePath, env, timeoutMs, run,
|
|
627
|
+
});
|
|
628
|
+
} catch (err) {
|
|
629
|
+
linkError = err;
|
|
630
|
+
}
|
|
631
|
+
const outcome = formatLinkOutcome(applied, linkError);
|
|
632
|
+
if (outcome.warning) warnings.push(outcome.warning);
|
|
633
|
+
addStep('link-principal-entries', outcome.status, outcome.detail);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// (b) Dependency install by detected stack. Every detected stack runs in
|
|
637
|
+
// table order. A missing binary is skipped; a non-zero exit is failed
|
|
638
|
+
// plus a warning and never aborts the remaining steps. `npm ci` falls
|
|
639
|
+
// back to `npm install` only when `ci` fails.
|
|
640
|
+
{
|
|
641
|
+
const detected = table.filter((entry) => Array.isArray(entry.markers)
|
|
642
|
+
&& entry.markers.some((marker) => pathExists(path.join(worktreePath, marker))));
|
|
643
|
+
if (detected.length === 0) {
|
|
644
|
+
addStep('install', 'skipped', 'no lockfile detected');
|
|
645
|
+
}
|
|
646
|
+
for (const entry of detected) {
|
|
647
|
+
const name = `install:${entry.key}`;
|
|
648
|
+
const argv = entry.argv || [];
|
|
649
|
+
const bin = argv[0];
|
|
650
|
+
const args = argv.slice(1);
|
|
651
|
+
if (typeof bin !== 'string' || bin === '') {
|
|
652
|
+
addStep(name, 'skipped', 'no command configured');
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (!hasBinary(bin)) {
|
|
656
|
+
addStep(name, 'skipped', `${bin} not on PATH`);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
let res;
|
|
660
|
+
try {
|
|
661
|
+
res = run(bin, args, { cwd: worktreePath, timeoutMs, env });
|
|
662
|
+
} catch (err) {
|
|
663
|
+
res = { status: null, stdout: '', stderr: '', error: String((err && err.message) || err) };
|
|
664
|
+
}
|
|
665
|
+
if (res && res.status === 0) {
|
|
666
|
+
addStep(name, 'done', `${bin} ${args.join(' ')} exited 0`);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
// Non-zero: npm alone gets exactly one fallback to `npm install`.
|
|
670
|
+
if (Array.isArray(entry.fallbackArgv) && entry.fallbackArgv.length > 0) {
|
|
671
|
+
const fbBin = entry.fallbackArgv[0];
|
|
672
|
+
const fbArgs = entry.fallbackArgv.slice(1);
|
|
673
|
+
if (!hasBinary(fbBin)) {
|
|
674
|
+
const message = `${name}: ${bin} ${args.join(' ')} failed and fallback ${fbBin} not on PATH`;
|
|
675
|
+
warnings.push(message);
|
|
676
|
+
addStep(name, 'failed', message);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
let fb;
|
|
680
|
+
try {
|
|
681
|
+
fb = run(fbBin, fbArgs, { cwd: worktreePath, timeoutMs, env });
|
|
682
|
+
} catch (err) {
|
|
683
|
+
fb = { status: null, stdout: '', stderr: '', error: String((err && err.message) || err) };
|
|
684
|
+
}
|
|
685
|
+
if (fb && fb.status === 0) {
|
|
686
|
+
addStep(name, 'done', `${bin} ${args.join(' ')} failed, ${fbBin} ${fbArgs.join(' ')} exited 0`);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const message = `${name}: ${bin} ${args.join(' ')} failed (${((res && (res.stderr || res.error)) || 'exit non-zero').trim()}), `
|
|
690
|
+
+ `fallback ${fbBin} ${fbArgs.join(' ')} failed (${((fb && (fb.stderr || fb.error)) || 'exit non-zero').trim()})`;
|
|
691
|
+
warnings.push(message);
|
|
692
|
+
addStep(name, 'failed', message);
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
const cause = res && (res.stderr || res.error)
|
|
696
|
+
? String(res.stderr || res.error).trim()
|
|
697
|
+
: `exit ${res && res.status !== null && res.status !== undefined ? res.status : 'non-zero'}`;
|
|
698
|
+
const message = `${name}: ${bin} ${args.join(' ')} failed (${cause})`;
|
|
699
|
+
warnings.push(message);
|
|
700
|
+
addStep(name, 'failed', message);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// (c) codegraph init when the CLI is available. Skipped with a reason
|
|
705
|
+
// when absent; a non-zero exit is failed plus a warning. Never fatal.
|
|
706
|
+
{
|
|
707
|
+
if (!hasBinary('codegraph')) {
|
|
708
|
+
addStep('codegraph-init', 'skipped', 'codegraph not on PATH');
|
|
709
|
+
} else {
|
|
710
|
+
let res;
|
|
711
|
+
try {
|
|
712
|
+
res = run('codegraph', ['init'], { cwd: worktreePath, timeoutMs, env });
|
|
713
|
+
} catch (err) {
|
|
714
|
+
res = { status: null, stdout: '', stderr: '', error: String((err && err.message) || err) };
|
|
715
|
+
}
|
|
716
|
+
if (res && res.status === 0) {
|
|
717
|
+
addStep('codegraph-init', 'done', 'codegraph init exited 0');
|
|
718
|
+
} else {
|
|
719
|
+
const cause = res && (res.stderr || res.error)
|
|
720
|
+
? String(res.stderr || res.error).trim()
|
|
721
|
+
: `exit ${res && res.status !== null && res.status !== undefined ? res.status : 'non-zero'}`;
|
|
722
|
+
const message = `codegraph-init: codegraph init failed (${cause})`;
|
|
723
|
+
warnings.push(message);
|
|
724
|
+
addStep('codegraph-init', 'failed', message);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return { ok: !failed, steps, warnings };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
module.exports = {
|
|
733
|
+
runCreationHooks,
|
|
734
|
+
runCreationHooksAsync,
|
|
735
|
+
DEFAULT_UNVERSIONED_FILES,
|
|
736
|
+
DEFAULT_INSTALL_TABLE,
|
|
737
|
+
DEFAULT_TIMEOUT_MS,
|
|
738
|
+
};
|