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,1048 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Shared git plumbing for the worktree tools (P2-S1 foundation).
|
|
3
|
+
//
|
|
4
|
+
// Every external process is spawned with a fixed argv array — no shell
|
|
5
|
+
// string is ever built, so branch names and paths cannot inject commands.
|
|
6
|
+
// `listWorktrees` parses `git worktree list --porcelain -z` itself instead
|
|
7
|
+
// of trusting Herdr JSON output.
|
|
8
|
+
|
|
9
|
+
const { spawnSync } = require('node:child_process');
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
// Run a binary with a fixed argument vector. Never a shell string.
|
|
15
|
+
function runArgv(bin, args, opts = {}) {
|
|
16
|
+
if (!Array.isArray(args) || args.some((entry) => typeof entry !== 'string')) {
|
|
17
|
+
throw new TypeError('runArgv requires an array of string arguments');
|
|
18
|
+
}
|
|
19
|
+
const result = spawnSync(bin, args, {
|
|
20
|
+
cwd: opts.cwd,
|
|
21
|
+
input: opts.input,
|
|
22
|
+
timeout: opts.timeoutMs,
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
windowsHide: true,
|
|
25
|
+
});
|
|
26
|
+
if (result.error) {
|
|
27
|
+
return { status: null, stdout: result.stdout || '', stderr: result.stderr || '', error: result.error.message };
|
|
28
|
+
}
|
|
29
|
+
return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || '', error: null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Async twin of `runArgv` for interactive paths: the same fixed argv
|
|
33
|
+
// (never a shell string) over `spawn`, with a bounded timeout that
|
|
34
|
+
// reaps the owned child instead of blocking the host event loop. Resolves to
|
|
35
|
+
// the same `{status, stdout, stderr, error}` shape, so callers can
|
|
36
|
+
// `await` either runner interchangeably. `timeoutMs` defaults to
|
|
37
|
+
// 120000 like the longest creation pole; pass a smaller bound for
|
|
38
|
+
// network CLIs.
|
|
39
|
+
//
|
|
40
|
+
// Ownership (S63 follow-up): on timeout — or on `opts.signal` abort — the
|
|
41
|
+
// runner SIGTERMs the owned process, waits a bounded `killGraceMs` for
|
|
42
|
+
// the `close` event, then SIGKILLs and waits the same bound again before
|
|
43
|
+
// settling, so settlement never precedes cleanup and the await never hangs
|
|
44
|
+
// on a child that ignores signals. On POSIX the child spawns `detached`
|
|
45
|
+
// (its own process group, pgid === pid) and the signals go to the whole
|
|
46
|
+
// group, so background grandchildren die with it; only the owned pgid is
|
|
47
|
+
// ever signaled, never an unrelated process. On win32 group containment is
|
|
48
|
+
// unsupported (negative-pid signals do not exist there): only the owned
|
|
49
|
+
// child handle is signaled with the same TERM-then-KILL escalation, and a
|
|
50
|
+
// grandchild that outlives its parent may survive — that platform limit is
|
|
51
|
+
// stated here, not hidden. `opts.env` rides through to spawn; `opts.signal`
|
|
52
|
+
// (AbortSignal) produces `{error: 'aborted'}`; without a signal there is no
|
|
53
|
+
// cancellation source and none is claimed.
|
|
54
|
+
const KILL_GRACE_MS_DEFAULT = 2000;
|
|
55
|
+
|
|
56
|
+
// Signal ONLY the owned process (or its owned group on POSIX). The group
|
|
57
|
+
// path requires the pgid this runner created (child.pid); anything
|
|
58
|
+
// unexpected falls back to the direct child handle. Never throws, never
|
|
59
|
+
// touches an unrelated pid.
|
|
60
|
+
function ownKill(child, sig) {
|
|
61
|
+
try {
|
|
62
|
+
if (child && typeof child.pid === 'number' && child.pid > 0 && process.platform !== 'win32') {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(-child.pid, sig);
|
|
65
|
+
return;
|
|
66
|
+
} catch {
|
|
67
|
+
// ESRCH (already gone) or no group support: fall through to direct.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// A hostile child object never breaks the cleanup.
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
if (child && typeof child.kill === 'function') child.kill(sig);
|
|
75
|
+
} catch {
|
|
76
|
+
// Killing a dead child never fails the run.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function runArgvAsync(bin, args, opts = {}) {
|
|
81
|
+
if (!Array.isArray(args) || args.some((entry) => typeof entry !== 'string')) {
|
|
82
|
+
return Promise.reject(new TypeError('runArgvAsync requires an array of string arguments'));
|
|
83
|
+
}
|
|
84
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? Math.floor(opts.timeoutMs) : 120000;
|
|
85
|
+
const killGraceMs = Number.isFinite(opts.killGraceMs) && opts.killGraceMs > 0
|
|
86
|
+
? Math.floor(opts.killGraceMs)
|
|
87
|
+
: KILL_GRACE_MS_DEFAULT;
|
|
88
|
+
let spawnFn = opts.spawnFn;
|
|
89
|
+
if (typeof spawnFn !== 'function') {
|
|
90
|
+
try {
|
|
91
|
+
spawnFn = require('node:child_process').spawn;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return Promise.resolve({ status: null, stdout: '', stderr: '', error: (err && err.message) || String(err) });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const spawnOpts = { cwd: opts.cwd, windowsHide: true };
|
|
97
|
+
if (opts.input !== undefined) spawnOpts.input = opts.input;
|
|
98
|
+
if (opts.env !== undefined) spawnOpts.env = opts.env;
|
|
99
|
+
// POSIX-only new group: group signals stay inside the owned pgid, so a
|
|
100
|
+
// negative-pid kill can never reach our own process group.
|
|
101
|
+
if (process.platform !== 'win32') spawnOpts.detached = true;
|
|
102
|
+
let child;
|
|
103
|
+
try {
|
|
104
|
+
child = spawnFn(bin, args, spawnOpts);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return Promise.resolve({ status: null, stdout: '', stderr: '', error: (err && err.message) || String(err) });
|
|
107
|
+
}
|
|
108
|
+
if (!child || typeof child.on !== 'function') {
|
|
109
|
+
return Promise.resolve({ status: null, stdout: '', stderr: '', error: 'spawn returned nothing usable' });
|
|
110
|
+
}
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
let stdout = '';
|
|
113
|
+
let stderr = '';
|
|
114
|
+
let done = false;
|
|
115
|
+
let cleaningUp = false;
|
|
116
|
+
let terminalError = null;
|
|
117
|
+
let timer = null;
|
|
118
|
+
let grace = null;
|
|
119
|
+
let reap = null;
|
|
120
|
+
const clearTimers = () => {
|
|
121
|
+
try { if (timer !== null) clearTimeout(timer); } catch { /* a dead timer never fails the run */ }
|
|
122
|
+
try { if (grace !== null) clearTimeout(grace); } catch { /* a dead timer never fails the run */ }
|
|
123
|
+
try { if (reap !== null) clearTimeout(reap); } catch { /* a dead timer never fails the run */ }
|
|
124
|
+
};
|
|
125
|
+
const detachSignal = () => {
|
|
126
|
+
try {
|
|
127
|
+
if (opts.signal && typeof opts.signal.removeEventListener === 'function') {
|
|
128
|
+
opts.signal.removeEventListener('abort', onAbort);
|
|
129
|
+
}
|
|
130
|
+
} catch { /* a hostile signal never fails the run */ }
|
|
131
|
+
};
|
|
132
|
+
const settle = (value) => {
|
|
133
|
+
if (done) return;
|
|
134
|
+
done = true;
|
|
135
|
+
clearTimers();
|
|
136
|
+
detachSignal();
|
|
137
|
+
resolve(value);
|
|
138
|
+
};
|
|
139
|
+
// Second bound: after SIGKILL the run settles even if `close` never
|
|
140
|
+
// fires (an uninterruptible or fake child), so the await cannot hang.
|
|
141
|
+
const startReap = () => {
|
|
142
|
+
reap = setTimeout(() => {
|
|
143
|
+
settle({ status: null, stdout, stderr, error: terminalError });
|
|
144
|
+
}, killGraceMs);
|
|
145
|
+
};
|
|
146
|
+
const beginCleanup = (error) => {
|
|
147
|
+
if (done || cleaningUp) return;
|
|
148
|
+
cleaningUp = true;
|
|
149
|
+
terminalError = error;
|
|
150
|
+
ownKill(child, 'SIGTERM');
|
|
151
|
+
grace = setTimeout(() => {
|
|
152
|
+
if (done) return;
|
|
153
|
+
ownKill(child, 'SIGKILL');
|
|
154
|
+
startReap();
|
|
155
|
+
}, killGraceMs);
|
|
156
|
+
};
|
|
157
|
+
const onAbort = () => {
|
|
158
|
+
beginCleanup('aborted');
|
|
159
|
+
};
|
|
160
|
+
timer = setTimeout(() => {
|
|
161
|
+
beginCleanup(`timed out after ${timeoutMs}ms`);
|
|
162
|
+
}, timeoutMs);
|
|
163
|
+
try {
|
|
164
|
+
if (opts.signal) {
|
|
165
|
+
if (opts.signal.aborted) {
|
|
166
|
+
onAbort();
|
|
167
|
+
} else if (typeof opts.signal.addEventListener === 'function') {
|
|
168
|
+
opts.signal.addEventListener('abort', onAbort, { once: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (child.stdout && typeof child.stdout.on === 'function') {
|
|
172
|
+
child.stdout.on('data', (chunk) => { stdout += String(chunk); });
|
|
173
|
+
}
|
|
174
|
+
if (child.stderr && typeof child.stderr.on === 'function') {
|
|
175
|
+
child.stderr.on('data', (chunk) => { stderr += String(chunk); });
|
|
176
|
+
}
|
|
177
|
+
child.on('error', (err) => {
|
|
178
|
+
if (cleaningUp) {
|
|
179
|
+
settle({ status: null, stdout, stderr, error: terminalError });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
settle({ status: null, stdout, stderr, error: (err && err.message) || String(err) });
|
|
183
|
+
});
|
|
184
|
+
child.on('close', (code) => {
|
|
185
|
+
// A close during cleanup is the reap: settle the terminal error
|
|
186
|
+
// now instead of waiting out the grace/reap bounds.
|
|
187
|
+
if (cleaningUp) {
|
|
188
|
+
settle({ status: null, stdout, stderr, error: terminalError });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
settle({ status: code, stdout, stderr, error: null });
|
|
192
|
+
});
|
|
193
|
+
} catch (err) {
|
|
194
|
+
settle({ status: null, stdout, stderr, error: (err && err.message) || String(err) });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Resolve the process runner for an async git read: the caller's injected
|
|
200
|
+
// `opts.runAsync` when present, else the shared async runner. `opts.timeoutMs`
|
|
201
|
+
// rides through only when it is a positive finite number, so an unset timeout
|
|
202
|
+
// keeps the runner's own default instead of becoming `undefined`.
|
|
203
|
+
function asyncRunner(opts = {}) {
|
|
204
|
+
if (typeof opts.runAsync === 'function') return opts.runAsync;
|
|
205
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : undefined;
|
|
206
|
+
return (bin, args, cwd) => runArgvAsync(bin, args, { cwd, timeoutMs });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The exported sync reader is a DOCUMENTED seam: tests replace it to simulate
|
|
210
|
+
// identities and listings git would never emit (the same reason
|
|
211
|
+
// `isPathInAnyCheckout` reads `module.exports.listWorktrees` instead of the
|
|
212
|
+
// internal name). The async twins honor that seam too, so a test that stubs
|
|
213
|
+
// the sync reader keeps describing the world the async path sees — the two
|
|
214
|
+
// readers can never disagree about a simulated repository.
|
|
215
|
+
function overriddenSyncTwin(name, own) {
|
|
216
|
+
try {
|
|
217
|
+
const exported = module.exports[name];
|
|
218
|
+
return typeof exported === 'function' && exported !== own ? exported : null;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Identify the repository containing repoRoot.
|
|
225
|
+
// Returns {root, commonDir, repoName, parentDir}; throws when repoRoot is
|
|
226
|
+
// not inside a git repository (a programmer error, never user input).
|
|
227
|
+
function repoIdentity(repoRoot) {
|
|
228
|
+
const top = runArgv('git', ['rev-parse', '--show-toplevel'], { cwd: repoRoot });
|
|
229
|
+
if (top.status !== 0) throw new Error(`not a git repository: ${repoRoot}`);
|
|
230
|
+
const root = path.resolve(top.stdout.trim());
|
|
231
|
+
const common = runArgv('git', ['rev-parse', '--git-common-dir'], { cwd: repoRoot });
|
|
232
|
+
if (common.status !== 0) throw new Error(`cannot resolve git common dir: ${repoRoot}`);
|
|
233
|
+
return finishRepoIdentity(repoRoot, root, common);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The resolution shared by the sync and async readers, so both spell the
|
|
237
|
+
// common dir identically (see the realpath note below).
|
|
238
|
+
function finishRepoIdentity(repoRoot, root, common) {
|
|
239
|
+
const rawCommon = common.stdout.trim();
|
|
240
|
+
// --git-common-dir prints a path relative to the git invocation's own cwd
|
|
241
|
+
// (`.git` at the repository root, `../.git` from one level below), and
|
|
242
|
+
// that cwd may be a symlinked spelling of the real directory. Resolve
|
|
243
|
+
// against its realpath: a lexical base turns `../.git` seen through a
|
|
244
|
+
// symlink into the symlink's parent, and the toplevel is the wrong base
|
|
245
|
+
// outright for a subdirectory (`../.git` there names the repo's parent).
|
|
246
|
+
let base = repoRoot;
|
|
247
|
+
try {
|
|
248
|
+
base = fs.realpathSync(repoRoot);
|
|
249
|
+
} catch {
|
|
250
|
+
// git already ran, so an unreadable cwd keeps the lexical base.
|
|
251
|
+
}
|
|
252
|
+
const commonDir = path.isAbsolute(rawCommon) ? path.normalize(rawCommon) : path.resolve(base, rawCommon);
|
|
253
|
+
// The answer is now known, so hand it to the shared `commonDirFor` memo in
|
|
254
|
+
// BOTH spellings (the caller's path and git's toplevel): a synchronous
|
|
255
|
+
// caller that runs after an identity read — the registry store and the
|
|
256
|
+
// change summary, which cannot await — then reads it instead of spawning
|
|
257
|
+
// its own pair of `git rev-parse` probes (worktree-issues S11).
|
|
258
|
+
rememberCommonDir(repoRoot, commonDir);
|
|
259
|
+
rememberCommonDir(root, commonDir);
|
|
260
|
+
return { root, commonDir, repoName: path.basename(root), parentDir: path.dirname(root) };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Async twin of `repoIdentity` for interactive paths: the same two fixed
|
|
264
|
+
// argv reads and the same resolution, awaited instead of blocking the host
|
|
265
|
+
// event loop. Rejects with the identical messages, so the two are
|
|
266
|
+
// interchangeable to a caller that can await.
|
|
267
|
+
async function repoIdentityAsync(repoRoot, opts = {}) {
|
|
268
|
+
const seam = overriddenSyncTwin('repoIdentity', repoIdentity);
|
|
269
|
+
if (seam !== null) return seam(repoRoot);
|
|
270
|
+
const runAsync = asyncRunner(opts);
|
|
271
|
+
const top = await runAsync('git', ['rev-parse', '--show-toplevel'], repoRoot);
|
|
272
|
+
if (!top || top.status !== 0) throw new Error(`not a git repository: ${repoRoot}`);
|
|
273
|
+
const root = path.resolve(String(top.stdout || '').trim());
|
|
274
|
+
const common = await runAsync('git', ['rev-parse', '--git-common-dir'], repoRoot);
|
|
275
|
+
if (!common || common.status !== 0) throw new Error(`cannot resolve git common dir: ${repoRoot}`);
|
|
276
|
+
return finishRepoIdentity(repoRoot, root, common);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// S22 (round 3): the shared git common dir of a checkout.
|
|
280
|
+
//
|
|
281
|
+
// Every registry read/write and every warp-session read used to call
|
|
282
|
+
// `repoIdentity`, i.e. two `spawnSync` git probes (~19 ms here) — several
|
|
283
|
+
// times per tool event and per repaint once the callers are added up. The
|
|
284
|
+
// answer is stable for a checkout inside one process, so it is resolved once
|
|
285
|
+
// and shared by every lib that needs the directory. Bounded and
|
|
286
|
+
// most-recently-used: an interactive session touches a handful of checkouts,
|
|
287
|
+
// and re-inserting on a hit keeps the one in use out of the evicted tail.
|
|
288
|
+
const COMMON_DIR_CACHE_LIMIT = 64;
|
|
289
|
+
const commonDirCache = new Map();
|
|
290
|
+
|
|
291
|
+
// Record an ALREADY RESOLVED common dir under one spelling of its repository.
|
|
292
|
+
// Bounded and most-recently-used exactly like `commonDirFor`'s own inserts, so
|
|
293
|
+
// an async identity read can warm the memo without a second cache or a second
|
|
294
|
+
// eviction rule. Never throws: a spelling that cannot be resolved is simply
|
|
295
|
+
// not remembered, and the caller that needs it spawns git as it always did.
|
|
296
|
+
function rememberCommonDir(spelling, commonDir) {
|
|
297
|
+
if (typeof spelling !== 'string' || spelling.length === 0) return;
|
|
298
|
+
if (typeof commonDir !== 'string' || commonDir.length === 0) return;
|
|
299
|
+
let key;
|
|
300
|
+
try {
|
|
301
|
+
key = path.resolve(spelling);
|
|
302
|
+
} catch {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
commonDirCache.delete(key);
|
|
306
|
+
commonDirCache.set(key, commonDir);
|
|
307
|
+
if (commonDirCache.size > COMMON_DIR_CACHE_LIMIT) {
|
|
308
|
+
const oldest = commonDirCache.keys().next();
|
|
309
|
+
if (!oldest.done) commonDirCache.delete(oldest.value);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function commonDirFor(repoRoot) {
|
|
314
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
315
|
+
// Never cache a caller-supplied nonsense key: resolve and fail like
|
|
316
|
+
// `repoIdentity` does, so the error stays the caller's to handle.
|
|
317
|
+
return repoIdentity(repoRoot).commonDir;
|
|
318
|
+
}
|
|
319
|
+
const key = path.resolve(repoRoot);
|
|
320
|
+
const hit = commonDirCache.get(key);
|
|
321
|
+
if (hit !== undefined) {
|
|
322
|
+
commonDirCache.delete(key);
|
|
323
|
+
commonDirCache.set(key, hit);
|
|
324
|
+
return hit;
|
|
325
|
+
}
|
|
326
|
+
const { commonDir } = repoIdentity(repoRoot);
|
|
327
|
+
commonDirCache.set(key, commonDir);
|
|
328
|
+
if (commonDirCache.size > COMMON_DIR_CACHE_LIMIT) {
|
|
329
|
+
const oldest = commonDirCache.keys().next();
|
|
330
|
+
if (!oldest.done) commonDirCache.delete(oldest.value);
|
|
331
|
+
}
|
|
332
|
+
return commonDir;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Forget every resolved common dir: for a test that builds a fresh
|
|
336
|
+
// repository at the same path, or a long-lived process that moved a checkout.
|
|
337
|
+
function resetCommonDirCache() {
|
|
338
|
+
commonDirCache.clear();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Flatten a branch name into a single path component, exactly like the
|
|
342
|
+
// AGENTS.md rule: lowercase, runs of non-alphanumerics collapse to one
|
|
343
|
+
// `-`, leading/trailing dashes trimmed, `worktree` when nothing survives.
|
|
344
|
+
function slugifyBranch(branch) {
|
|
345
|
+
const slug = String(branch).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
346
|
+
return slug === '' ? 'worktree' : slug;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Absolute target path for a branch: <repo-parent>/<repo-name>-worktrees/<slug>.
|
|
350
|
+
function worktreePath(repoRoot, branch) {
|
|
351
|
+
return worktreePathOf(repoIdentity(repoRoot), branch);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// The pure layout of `worktreePath` over an identity already read, so an
|
|
355
|
+
// interactive caller that awaited `repoIdentityAsync` reuses that one read
|
|
356
|
+
// instead of spawning git again.
|
|
357
|
+
function worktreePathOf(identity, branch) {
|
|
358
|
+
return path.join(identity.parentDir, `${identity.repoName}-worktrees`, slugifyBranch(branch));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The PRINCIPAL checkout of the repository containing repoRoot: the
|
|
362
|
+
// checkout that owns the shared git common dir. For a linked worktree
|
|
363
|
+
// `git rev-parse --git-common-dir` resolves (through the realpath-aware
|
|
364
|
+
// base in repoIdentity) to `<principal>/.git`, so the principal is its
|
|
365
|
+
// dirname — no branch-name assumption (`main`, `master`, anything) is
|
|
366
|
+
// ever consulted. For the principal itself this is its own root, and
|
|
367
|
+
// for anything unresolvable it falls back to the toplevel: callers
|
|
368
|
+
// always get a usable directory, never a throw.
|
|
369
|
+
function principalRoot(repoRoot) {
|
|
370
|
+
let identity;
|
|
371
|
+
try {
|
|
372
|
+
identity = repoIdentity(repoRoot);
|
|
373
|
+
} catch {
|
|
374
|
+
return repoRoot;
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
if (typeof identity.commonDir === 'string' && path.basename(identity.commonDir) === '.git') {
|
|
378
|
+
const candidate = path.resolve(path.dirname(identity.commonDir));
|
|
379
|
+
return candidate;
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
// Fall through to the toplevel below.
|
|
383
|
+
}
|
|
384
|
+
return identity.root;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Canonical target path for a branch: the SAME sibling layout as
|
|
388
|
+
// worktreePath, but ALWAYS derived from the principal checkout's
|
|
389
|
+
// parent/name — even when the caller runs inside a linked worktree
|
|
390
|
+
// (where worktreePath would nest `<worktree>-worktrees/` inside the
|
|
391
|
+
// calling checkout). The branch HEAD still comes from the CURRENT
|
|
392
|
+
// checkout (creation never changes its base); only the directory
|
|
393
|
+
// layout is principal-anchored.
|
|
394
|
+
function canonicalWorktreePath(repoRoot, branch) {
|
|
395
|
+
const principal = principalRoot(repoRoot);
|
|
396
|
+
return path.join(path.dirname(principal), `${path.basename(principal)}-worktrees`, slugifyBranch(branch));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// The pure layout of `canonicalWorktreePath` over an identity already read:
|
|
400
|
+
// the principal is recovered from the shared common dir, and anything else
|
|
401
|
+
// (a common dir that is not `<principal>/.git`) falls back to the toplevel,
|
|
402
|
+
// exactly like `principalRoot`.
|
|
403
|
+
function canonicalWorktreePathOf(identity, branch) {
|
|
404
|
+
const principal = typeof identity.commonDir === 'string' && path.basename(identity.commonDir) === '.git'
|
|
405
|
+
? path.resolve(path.dirname(identity.commonDir))
|
|
406
|
+
: identity.root;
|
|
407
|
+
return path.join(path.dirname(principal), `${path.basename(principal)}-worktrees`, slugifyBranch(branch));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Unicode bidi control marks that can visually reorder a branch name.
|
|
411
|
+
const BIDI_RE = /[\u200E\u200F\u202A-\u202E\u2066-\u2069\uFEFF\u2028\u2029]/;
|
|
412
|
+
|
|
413
|
+
// Validate a branch name. Uses `git check-ref-format --branch` as the
|
|
414
|
+
// authority, after rejecting empty names, a leading `-`, whitespace,
|
|
415
|
+
// control characters and Unicode bidi marks up front (fail closed).
|
|
416
|
+
function validateBranch(name) {
|
|
417
|
+
const refused = refuseMalformedBranch(name);
|
|
418
|
+
if (refused !== null) return refused;
|
|
419
|
+
const checked = runArgv('git', ['check-ref-format', '--branch', name], {});
|
|
420
|
+
if (checked.status !== 0) return { ok: false, reason: `invalid branch name: ${name}` };
|
|
421
|
+
return { ok: true, reason: null };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The pure half of `validateBranch`: every rejection that needs no child.
|
|
425
|
+
// Returns the refusal, or null when git still has to answer.
|
|
426
|
+
function refuseMalformedBranch(name) {
|
|
427
|
+
if (typeof name !== 'string' || name === '') return { ok: false, reason: 'branch name is empty' };
|
|
428
|
+
if (name.startsWith('-')) return { ok: false, reason: 'branch name must not start with `-`' };
|
|
429
|
+
if (/\s/.test(name)) return { ok: false, reason: 'branch name must not contain whitespace' };
|
|
430
|
+
// eslint-disable-next-line no-control-regex
|
|
431
|
+
if (/[\x00-\x1F\x7F]/.test(name)) return { ok: false, reason: 'branch name must not contain control characters' };
|
|
432
|
+
if (BIDI_RE.test(name)) return { ok: false, reason: 'branch name must not contain Unicode bidi marks' };
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Async twin of `validateBranch` for interactive paths: the same refusals in
|
|
437
|
+
// the same order, the same `git check-ref-format --branch` authority and the
|
|
438
|
+
// same reason strings — only the check-ref-format wait is non-blocking.
|
|
439
|
+
async function validateBranchAsync(name, opts = {}) {
|
|
440
|
+
const refused = refuseMalformedBranch(name);
|
|
441
|
+
if (refused !== null) return refused;
|
|
442
|
+
const seam = overriddenSyncTwin('validateBranch', validateBranch);
|
|
443
|
+
if (seam !== null) return seam(name);
|
|
444
|
+
const runAsync = asyncRunner(opts);
|
|
445
|
+
const checked = await runAsync('git', ['check-ref-format', '--branch', name], undefined);
|
|
446
|
+
if (!checked || checked.status !== 0) return { ok: false, reason: `invalid branch name: ${name}` };
|
|
447
|
+
return { ok: true, reason: null };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// List every checkout from `git worktree list --porcelain -z`.
|
|
451
|
+
// Returns [{path, branch, head, detached, bare}]; branch is null when detached.
|
|
452
|
+
function listWorktrees(repoRoot) {
|
|
453
|
+
const listed = runArgv('git', ['worktree', 'list', '--porcelain', '-z'], { cwd: repoRoot });
|
|
454
|
+
if (listed.status !== 0) throw new Error(`git worktree list failed: ${(listed.stderr || '').trim()}`);
|
|
455
|
+
return parseWorktreeList(listed.stdout);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Async twin of `listWorktrees` for interactive paths: the same fixed argv and
|
|
459
|
+
// the SAME parser, over the async runner, so listing a repository never blocks
|
|
460
|
+
// the host event loop. Resolves to the identical entries and rejects with the
|
|
461
|
+
// identical message on failure, so the two are interchangeable to a caller
|
|
462
|
+
// that can await. The sync API stays for the callers that cannot.
|
|
463
|
+
// `opts.runAsync` is the sibling convention `(bin, args, cwd) => Promise`.
|
|
464
|
+
async function listWorktreesAsync(repoRoot, opts = {}) {
|
|
465
|
+
const seam = overriddenSyncTwin('listWorktrees', listWorktrees);
|
|
466
|
+
if (seam !== null) return seam(repoRoot);
|
|
467
|
+
const runAsync = typeof opts.runAsync === 'function'
|
|
468
|
+
? opts.runAsync
|
|
469
|
+
: (bin, args, cwd) => runArgvAsync(bin, args, { cwd, timeoutMs: opts.timeoutMs });
|
|
470
|
+
const listed = await runAsync('git', ['worktree', 'list', '--porcelain', '-z'], repoRoot);
|
|
471
|
+
if (!listed || listed.status !== 0) {
|
|
472
|
+
const stderr = listed && typeof listed.stderr === 'string' ? listed.stderr : '';
|
|
473
|
+
throw new Error(`git worktree list failed: ${stderr.trim()}`);
|
|
474
|
+
}
|
|
475
|
+
return parseWorktreeList(listed.stdout);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function isInsideOrEqual(parent, child) {
|
|
479
|
+
const relative = path.relative(parent, child);
|
|
480
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// True when the resolved target is the path of, or inside, any existing
|
|
484
|
+
// checkout — or inside the shared git common dir. Used to refuse targets
|
|
485
|
+
// that would nest checkouts or clobber repository metadata. Only a LIVE
|
|
486
|
+
// checkout blocks: a git record whose path no longer exists as a
|
|
487
|
+
// directory never refuses a create (the create path prunes such stale
|
|
488
|
+
// records first). Relative listing paths resolve against the repository
|
|
489
|
+
// root, never the process cwd.
|
|
490
|
+
function isPathInAnyCheckout(repoRoot, target) {
|
|
491
|
+
const identity = repoIdentity(repoRoot);
|
|
492
|
+
const resolved = path.isAbsolute(target) ? path.resolve(target) : path.resolve(identity.root, target);
|
|
493
|
+
// Through the export so tests can simulate listings git would never
|
|
494
|
+
// emit (notably relative paths); production behavior is unchanged.
|
|
495
|
+
const entries = module.exports.listWorktrees(repoRoot);
|
|
496
|
+
return matchesAnyCheckout(identity, resolved, entries);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Async twin of `isPathInAnyCheckout` for interactive paths: the same
|
|
500
|
+
// identity read, the same listing (awaited) and the SAME matcher, so a
|
|
501
|
+
// create refuses exactly the same targets. `opts.runAsync` is the sibling
|
|
502
|
+
// convention `(bin, args, cwd) => Promise`; `opts.list` lets a caller reuse
|
|
503
|
+
// a listing it already awaited.
|
|
504
|
+
async function isPathInAnyCheckoutAsync(repoRoot, target, opts = {}) {
|
|
505
|
+
const runAsync = asyncRunner(opts);
|
|
506
|
+
const identity = opts.identity !== undefined
|
|
507
|
+
? opts.identity
|
|
508
|
+
: await repoIdentityAsync(repoRoot, { runAsync });
|
|
509
|
+
const resolved = path.isAbsolute(target) ? path.resolve(target) : path.resolve(identity.root, target);
|
|
510
|
+
const entries = Array.isArray(opts.listed)
|
|
511
|
+
? opts.listed
|
|
512
|
+
: await listWorktreesAsync(repoRoot, { runAsync });
|
|
513
|
+
return matchesAnyCheckout(identity, resolved, entries);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// The single inside-verdict shared by the sync and async readers.
|
|
517
|
+
function matchesAnyCheckout(identity, resolved, entries) {
|
|
518
|
+
if (isInsideOrEqual(identity.commonDir, resolved)) return true;
|
|
519
|
+
// One liveness predicate (exists AND is-a-directory, never the process
|
|
520
|
+
// cwd) plus realpath identity: a symlink spelling of a checkout is the
|
|
521
|
+
// same checkout, so inside-verdicts compare both spellings and realpaths
|
|
522
|
+
// (resolving non-existent tails through their parents, so a not-yet-
|
|
523
|
+
// created subpath through a symlink still matches).
|
|
524
|
+
const realish = (p) => {
|
|
525
|
+
try {
|
|
526
|
+
return fs.realpathSync(p);
|
|
527
|
+
} catch {
|
|
528
|
+
// Walk up to the nearest existing ancestor, resolve it, re-append.
|
|
529
|
+
let cur = p;
|
|
530
|
+
const tail = [];
|
|
531
|
+
for (let i = 0; i < 32; i += 1) {
|
|
532
|
+
const parent = path.dirname(cur);
|
|
533
|
+
if (parent === cur) break;
|
|
534
|
+
tail.unshift(path.basename(cur));
|
|
535
|
+
cur = parent;
|
|
536
|
+
try {
|
|
537
|
+
return path.join(fs.realpathSync(cur), ...tail);
|
|
538
|
+
} catch {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
const resolvedReal = realish(resolved);
|
|
546
|
+
for (const entry of entries || []) {
|
|
547
|
+
if (!entry || typeof entry.path !== 'string' || entry.path.length === 0) continue;
|
|
548
|
+
const base = path.isAbsolute(entry.path) ? path.resolve(entry.path) : path.resolve(identity.root, entry.path);
|
|
549
|
+
let live = false;
|
|
550
|
+
try {
|
|
551
|
+
live = fs.statSync(base).isDirectory();
|
|
552
|
+
} catch {
|
|
553
|
+
live = false;
|
|
554
|
+
}
|
|
555
|
+
if (!live) continue;
|
|
556
|
+
if (isInsideOrEqual(base, resolved)) return true;
|
|
557
|
+
// Realpath identity: the same checkout reached through a symlink must
|
|
558
|
+
// give the same verdict as its real spelling.
|
|
559
|
+
const baseReal = realish(base);
|
|
560
|
+
if (baseReal !== null && resolvedReal !== null && isInsideOrEqual(baseReal, resolvedReal)) return true;
|
|
561
|
+
if (baseReal !== null && baseReal === resolved) return true;
|
|
562
|
+
if (resolvedReal !== null && resolvedReal === base) return true;
|
|
563
|
+
}
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Closed union of herdr operations with a per-operation flag allowlist,
|
|
568
|
+
// mirroring the installed CLI surface (herdr 0.9.4, verified read-only
|
|
569
|
+
// against `herdr worktree list|create|open --help`, `herdr pane list
|
|
570
|
+
// --help` and `herdr agent start|get|list --help`: every flag below
|
|
571
|
+
// exists there with the shape used here). Every value travels as its own
|
|
572
|
+
// argv element because herdr rejects `--flag=value`. Unknown operations
|
|
573
|
+
// or flags throw. `--trust-repository` is deliberately absent everywhere:
|
|
574
|
+
// trusting a repository is a human decision these tools never take.
|
|
575
|
+
// `--focus` stays opt-in for the worktree create/open calls that predate
|
|
576
|
+
// the session tool; the session tool itself only ever passes `--no-focus`.
|
|
577
|
+
const HERDR_ALLOWLIST = {
|
|
578
|
+
list: ['cwd'],
|
|
579
|
+
create: ['cwd', 'branch', 'base', 'path', 'label', 'focus'],
|
|
580
|
+
open: ['cwd', 'path', 'branch', 'noFocus', 'focus'],
|
|
581
|
+
remove: ['workspace', 'force'],
|
|
582
|
+
'pane-list': ['workspace'],
|
|
583
|
+
'agent-start': ['name', 'kind', 'pane', 'timeout', 'agentArgs'],
|
|
584
|
+
'agent-get': ['target'],
|
|
585
|
+
'agent-list': [],
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const HERDR_FLAG = {
|
|
589
|
+
cwd: '--cwd',
|
|
590
|
+
workspace: '--workspace',
|
|
591
|
+
branch: '--branch',
|
|
592
|
+
base: '--base',
|
|
593
|
+
path: '--path',
|
|
594
|
+
label: '--label',
|
|
595
|
+
focus: '--focus',
|
|
596
|
+
noFocus: '--no-focus',
|
|
597
|
+
force: '--force',
|
|
598
|
+
kind: '--kind',
|
|
599
|
+
pane: '--pane',
|
|
600
|
+
timeout: '--timeout',
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// Build the fixed argv for a herdr operation. Worktree operations produce
|
|
604
|
+
// `herdr worktree <op> ...`; the session composition additionally needs
|
|
605
|
+
// `herdr pane list`, `herdr agent start <NAME> ... -- [AGENT_ARG]...`,
|
|
606
|
+
// `herdr agent get <target>` and `herdr agent list`, each with its own
|
|
607
|
+
// fixed shape below so no caller hand-builds herdr argv.
|
|
608
|
+
function herdrArgv(operation, params = {}) {
|
|
609
|
+
const allowed = HERDR_ALLOWLIST[operation];
|
|
610
|
+
if (!allowed) throw new Error(`unknown herdr operation: ${operation}`);
|
|
611
|
+
for (const key of Object.keys(params)) {
|
|
612
|
+
if (!allowed.includes(key)) throw new Error(`flag not allowed for herdr ${operation}: ${key}`);
|
|
613
|
+
}
|
|
614
|
+
// `herdr agent start <NAME> --kind <KIND> --pane <ID> --timeout <MS>
|
|
615
|
+
// -- [AGENT_ARG]...`: the agent name is positional and always first,
|
|
616
|
+
// the Pi argv travels after a `--` separator. Fixed order, values as
|
|
617
|
+
// separate elements, never `--flag=value`.
|
|
618
|
+
if (operation === 'agent-start') {
|
|
619
|
+
const { name, kind, pane, timeout, agentArgs } = params;
|
|
620
|
+
if (typeof name !== 'string' || name === '') throw new Error('invalid value for herdr agent-start name');
|
|
621
|
+
for (const [key, value] of [['kind', kind], ['pane', pane], ['timeout', timeout]]) {
|
|
622
|
+
if (typeof value !== 'string' || value === '') throw new Error(`invalid value for herdr agent-start ${key}`);
|
|
623
|
+
}
|
|
624
|
+
if (!Array.isArray(agentArgs) || agentArgs.some((entry) => typeof entry !== 'string')) {
|
|
625
|
+
throw new Error('invalid value for herdr agent-start agentArgs');
|
|
626
|
+
}
|
|
627
|
+
return ['agent', 'start', name,
|
|
628
|
+
'--kind', kind, '--pane', pane, '--timeout', timeout,
|
|
629
|
+
'--', ...agentArgs];
|
|
630
|
+
}
|
|
631
|
+
// `herdr agent get <target>`: the target is positional.
|
|
632
|
+
if (operation === 'agent-get') {
|
|
633
|
+
const { target } = params;
|
|
634
|
+
if (typeof target !== 'string' || target === '') throw new Error('invalid value for herdr agent-get target');
|
|
635
|
+
return ['agent', 'get', target];
|
|
636
|
+
}
|
|
637
|
+
// `herdr agent list` takes no flags.
|
|
638
|
+
if (operation === 'agent-list') return ['agent', 'list'];
|
|
639
|
+
// `herdr pane list [--workspace <ID>]`.
|
|
640
|
+
if (operation === 'pane-list') {
|
|
641
|
+
const argv = ['pane', 'list'];
|
|
642
|
+
if (params.workspace !== undefined) {
|
|
643
|
+
if (typeof params.workspace !== 'string' || params.workspace === '') {
|
|
644
|
+
throw new Error('invalid value for herdr pane-list workspace');
|
|
645
|
+
}
|
|
646
|
+
argv.push('--workspace', params.workspace);
|
|
647
|
+
}
|
|
648
|
+
return argv;
|
|
649
|
+
}
|
|
650
|
+
const argv = ['worktree', operation];
|
|
651
|
+
for (const key of Object.keys(params)) {
|
|
652
|
+
if (!allowed.includes(key)) throw new Error(`flag not allowed for herdr ${operation}: ${key}`);
|
|
653
|
+
const value = params[key];
|
|
654
|
+
if (typeof value === 'boolean') {
|
|
655
|
+
if (value) argv.push(HERDR_FLAG[key]);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (typeof value !== 'string' || value === '') throw new Error(`invalid value for herdr ${operation} ${key}`);
|
|
659
|
+
argv.push(HERDR_FLAG[key], value);
|
|
660
|
+
}
|
|
661
|
+
return argv;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Where the herdr binary comes from. Production honors the documented
|
|
665
|
+
// HERDR_BIN_PATH override (tests), else `herdr` on PATH — the same
|
|
666
|
+
// convention lib/gentle-diagnostics.js uses. Production (no harness
|
|
667
|
+
// marker) keeps that historical behavior untouched.
|
|
668
|
+
//
|
|
669
|
+
// The test harness (GENTLE_WORKTREE_LIB_DIR, set only by the test loaders
|
|
670
|
+
// that import the extension copy) is resolved FIRST, before the override is
|
|
671
|
+
// trusted: an exported HERDR_BIN_PATH is inherited by every test process, so
|
|
672
|
+
// honoring it first let a test run spawn the developer's LIVE herdr and
|
|
673
|
+
// create real Herdr workspaces. Under the harness, therefore:
|
|
674
|
+
//
|
|
675
|
+
// - no binary at all still fails closed with the same refusal as before;
|
|
676
|
+
// - a binary is honored only when the path itself proves it cannot be the
|
|
677
|
+
// live one (`isProvablyNotLiveHerdr`, which decides from the filesystem and
|
|
678
|
+
// never executes anything);
|
|
679
|
+
// - a possible live herdr is refused unless the CURRENT test opts in with the
|
|
680
|
+
// dedicated flag below. The flag is the test's own declaration, so it must
|
|
681
|
+
// be set deliberately rather than inherited along with the path.
|
|
682
|
+
const LIVE_HERDR_OPT_IN = 'GENTLE_WORKTREE_ALLOW_LIVE_HERDR';
|
|
683
|
+
|
|
684
|
+
function realPathOrNull(target) {
|
|
685
|
+
try {
|
|
686
|
+
return fs.realpathSync(target);
|
|
687
|
+
} catch {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function isInsideDir(candidate, dir) {
|
|
693
|
+
if (candidate === dir) return true;
|
|
694
|
+
return candidate.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// The scratch locations this repository's tests build their stub binaries in.
|
|
698
|
+
// os.tmpdir() is the convention every fixture follows (`fs.mkdtempSync(
|
|
699
|
+
// path.join(os.tmpdir(), ...))`), and no live Herdr install lives there.
|
|
700
|
+
function testScratchRoots() {
|
|
701
|
+
const roots = [];
|
|
702
|
+
for (const candidate of [os.tmpdir(), path.resolve(os.tmpdir())]) {
|
|
703
|
+
const real = realPathOrNull(candidate) || candidate;
|
|
704
|
+
if (!roots.includes(real)) roots.push(real);
|
|
705
|
+
}
|
|
706
|
+
return roots;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Can this path be PROVEN not to be a live herdr? Only filesystem facts are
|
|
710
|
+
// used — the binary is never executed and nothing is spawned, so the answer
|
|
711
|
+
// costs one realpath plus one stat and cannot reach a Herdr session.
|
|
712
|
+
//
|
|
713
|
+
// Proven not live: an absolute path that does not exist (a spawn of it fails),
|
|
714
|
+
// that is not a regular file, that is not executable (a spawn of it fails),
|
|
715
|
+
// or whose realpath is inside the test scratch directory. Everything else —
|
|
716
|
+
// a relative or bare value (PATH/cwd resolved) and an existing executable
|
|
717
|
+
// outside scratch — could be the live binary and is left unproven.
|
|
718
|
+
function isProvablyNotLiveHerdr(bin) {
|
|
719
|
+
if (typeof bin !== 'string' || !path.isAbsolute(bin)) return false;
|
|
720
|
+
const real = realPathOrNull(bin);
|
|
721
|
+
if (real === null) return true;
|
|
722
|
+
let stat;
|
|
723
|
+
try {
|
|
724
|
+
stat = fs.statSync(real);
|
|
725
|
+
} catch {
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
if (!stat.isFile()) return true;
|
|
729
|
+
try {
|
|
730
|
+
fs.accessSync(real, fs.constants.X_OK);
|
|
731
|
+
} catch {
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
return testScratchRoots().some((root) => isInsideDir(real, root));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function resolveHerdrBinary(env = {}) {
|
|
738
|
+
const source = env || {};
|
|
739
|
+
const bin = source.HERDR_BIN_PATH;
|
|
740
|
+
const injected = typeof bin === 'string' && bin.length > 0;
|
|
741
|
+
const harness = source.GENTLE_WORKTREE_LIB_DIR;
|
|
742
|
+
const inHarness = typeof harness === 'string' && harness.length > 0;
|
|
743
|
+
if (!inHarness) return injected ? bin : 'herdr';
|
|
744
|
+
if (source[LIVE_HERDR_OPT_IN] === '1') return injected ? bin : 'herdr';
|
|
745
|
+
if (!injected) {
|
|
746
|
+
throw new Error('herdr binary is not injected: set HERDR_BIN_PATH in tests (refusing to run live herdr)');
|
|
747
|
+
}
|
|
748
|
+
if (isProvablyNotLiveHerdr(bin)) return bin;
|
|
749
|
+
throw new Error(
|
|
750
|
+
`refusing to run live herdr from a test harness: HERDR_BIN_PATH=${bin} can execute a live herdr; `
|
|
751
|
+
+ `inject a scratch stub inside the OS temp directory, or set ${LIVE_HERDR_OPT_IN}=1 in the test that needs a live session`,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ---------------------------------------------------------------------
|
|
756
|
+
// Commits ahead of / behind a base branch (warp-popup W1).
|
|
757
|
+
//
|
|
758
|
+
// Nothing else in the repository computes this. The answer is one fixed
|
|
759
|
+
// argv call — `git rev-list --left-right --count <base>...HEAD` — whose
|
|
760
|
+
// output is `<behind>\t<ahead>`: the left side is what the base has and
|
|
761
|
+
// HEAD does not, the right side is what HEAD has and the base does not.
|
|
762
|
+
//
|
|
763
|
+
// The base is RESOLVED, never assumed: `main` first, then the other
|
|
764
|
+
// conventional names, then the branch the principal checkout has checked
|
|
765
|
+
// out (git documents that `git worktree list` prints the main worktree
|
|
766
|
+
// first, so no branch-name guess is needed), then the conventional
|
|
767
|
+
// remote-tracking names. The chosen base rides back in the result, so no
|
|
768
|
+
// caller has to re-derive it and nothing is guessed silently. With no
|
|
769
|
+
// resolvable base the call fails loudly instead of inventing one; an
|
|
770
|
+
// explicitly requested base that does not exist fails the same way.
|
|
771
|
+
//
|
|
772
|
+
// Every git read goes through `runAsync` (default: the async runner) so a
|
|
773
|
+
// repainting caller never blocks on a spawn, and every failure path —
|
|
774
|
+
// bad path, not a repository, unparsable output — RESOLVES to
|
|
775
|
+
// `{ok:false, error}` instead of throwing.
|
|
776
|
+
const BASE_CANDIDATES = [
|
|
777
|
+
{ name: 'main', source: 'conventional' },
|
|
778
|
+
{ name: 'master', source: 'conventional' },
|
|
779
|
+
{ name: 'trunk', source: 'conventional' },
|
|
780
|
+
{ name: 'develop', source: 'conventional' },
|
|
781
|
+
{ name: 'development', source: 'conventional' },
|
|
782
|
+
{ name: 'origin/main', source: 'remote' },
|
|
783
|
+
{ name: 'origin/master', source: 'remote' },
|
|
784
|
+
{ name: 'origin/trunk', source: 'remote' },
|
|
785
|
+
{ name: 'origin/develop', source: 'remote' },
|
|
786
|
+
];
|
|
787
|
+
|
|
788
|
+
// A ref name that is safe as ONE argv element (injection is already
|
|
789
|
+
// impossible with a fixed argv; this only rules out a value git would
|
|
790
|
+
// reparse, such as a leading `-` flag or a revision expression).
|
|
791
|
+
function isPlainRefName(name) {
|
|
792
|
+
if (typeof name !== 'string' || name.length === 0) return false;
|
|
793
|
+
if (name.startsWith('-')) return false;
|
|
794
|
+
if (/\s/.test(name)) return false;
|
|
795
|
+
// eslint-disable-next-line no-control-regex
|
|
796
|
+
if (/[\x00-\x1F\x7F]/.test(name)) return false;
|
|
797
|
+
if (BIDI_RE.test(name)) return false;
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// `<behind>\t<ahead>` -> {behind, ahead}; anything else is unreadable.
|
|
802
|
+
function parseAheadBehindCounts(stdout) {
|
|
803
|
+
const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(String(stdout || ''));
|
|
804
|
+
if (!match) return null;
|
|
805
|
+
return { behind: Number(match[1]), ahead: Number(match[2]) };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// First line of a failed git result, for error strings that stay short.
|
|
809
|
+
function gitFailureReason(result) {
|
|
810
|
+
const stderr = result && typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
811
|
+
if (stderr.length > 0) return stderr.split('\n')[0];
|
|
812
|
+
if (result && result.error) return result.error;
|
|
813
|
+
return `exit ${result ? result.status : 'unknown'}`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Compare the checkout at `checkoutPath` against a base branch.
|
|
817
|
+
// Resolves to {ok:true, ahead, behind, base, baseSource}, or
|
|
818
|
+
// {ok:false, error}. Options: `base` (explicit branch name), `runAsync`
|
|
819
|
+
// (injected `(bin, args, cwd) -> Promise<{status, stdout, stderr, error}>`),
|
|
820
|
+
// `timeoutMs`, and `principalBranch`.
|
|
821
|
+
//
|
|
822
|
+
// `principalBranch` is the branch the principal checkout has checked out, for
|
|
823
|
+
// a caller that ALREADY read the repository listing (`git worktree list`) —
|
|
824
|
+
// the warp selector's facts read does, to answer the branch of every row. A
|
|
825
|
+
// string reuses that answer; `null` states "the listing names no principal
|
|
826
|
+
// branch" (a detached or bare principal); omitting it (the default) reads the
|
|
827
|
+
// listing itself. Either way the same candidate order runs, so the base and
|
|
828
|
+
// its `baseSource` are what this function always answered; only the second
|
|
829
|
+
// listing — one child process per checkout — is skipped.
|
|
830
|
+
async function aheadBehind(checkoutPath, opts = {}) {
|
|
831
|
+
try {
|
|
832
|
+
if (typeof checkoutPath !== 'string' || checkoutPath.length === 0) {
|
|
833
|
+
return { ok: false, error: 'checkout path is empty' };
|
|
834
|
+
}
|
|
835
|
+
const checkout = path.resolve(checkoutPath);
|
|
836
|
+
const runAsync = typeof opts.runAsync === 'function'
|
|
837
|
+
? opts.runAsync
|
|
838
|
+
: (bin, args, cwd) => runArgvAsync(bin, args, { cwd, timeoutMs: opts.timeoutMs });
|
|
839
|
+
|
|
840
|
+
// One read of every local and remote-tracking ref name: the set is
|
|
841
|
+
// what makes resolution a lookup instead of a probe per candidate.
|
|
842
|
+
const refs = await runAsync('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes'], checkout);
|
|
843
|
+
if (!refs || refs.status !== 0) {
|
|
844
|
+
return { ok: false, error: `cannot list branches in ${checkout}: ${gitFailureReason(refs)}` };
|
|
845
|
+
}
|
|
846
|
+
const names = new Set(
|
|
847
|
+
String(refs.stdout || '').split('\n').map((line) => line.trim()).filter((line) => line.length > 0),
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
let base = null;
|
|
851
|
+
let baseSource = null;
|
|
852
|
+
if (opts.base !== undefined) {
|
|
853
|
+
if (!isPlainRefName(opts.base)) {
|
|
854
|
+
return { ok: false, error: `invalid base branch: ${JSON.stringify(opts.base)}` };
|
|
855
|
+
}
|
|
856
|
+
if (!names.has(opts.base)) return { ok: false, error: `base branch not found: ${opts.base}` };
|
|
857
|
+
base = opts.base;
|
|
858
|
+
baseSource = 'explicit';
|
|
859
|
+
} else {
|
|
860
|
+
// Candidate order: conventions, then the repository's own answer
|
|
861
|
+
// (the principal checkout's branch), then remote-tracking names. The
|
|
862
|
+
// principal's branch comes from the listing the CALLER already read
|
|
863
|
+
// when it supplies one, so this path spawns no second listing.
|
|
864
|
+
const principal = opts.principalBranch !== undefined
|
|
865
|
+
? (typeof opts.principalBranch === 'string' && opts.principalBranch.length > 0
|
|
866
|
+
? opts.principalBranch
|
|
867
|
+
: null)
|
|
868
|
+
: await principalBranchOf(checkout, runAsync);
|
|
869
|
+
const ordered = [BASE_CANDIDATES[0], BASE_CANDIDATES[1]];
|
|
870
|
+
if (principal !== null) ordered.push({ name: principal, source: 'principal' });
|
|
871
|
+
ordered.push(...BASE_CANDIDATES.slice(2));
|
|
872
|
+
for (const candidate of ordered) {
|
|
873
|
+
if (!names.has(candidate.name)) continue;
|
|
874
|
+
base = candidate.name;
|
|
875
|
+
baseSource = candidate.source;
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
878
|
+
if (base === null) {
|
|
879
|
+
return { ok: false, error: `no base branch to compare with in ${checkout} (looked for ${BASE_CANDIDATES.map((entry) => entry.name).join(', ')} and the principal checkout's branch)` };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const counts = await runAsync('git', ['rev-list', '--left-right', '--count', `${base}...HEAD`], checkout);
|
|
884
|
+
if (!counts || counts.status !== 0) {
|
|
885
|
+
return { ok: false, error: `cannot compare with ${base} in ${checkout}: ${gitFailureReason(counts)}` };
|
|
886
|
+
}
|
|
887
|
+
const parsed = parseAheadBehindCounts(counts.stdout);
|
|
888
|
+
if (parsed === null) {
|
|
889
|
+
return { ok: false, error: `unreadable ahead/behind output for ${checkout}: ${JSON.stringify(String(counts.stdout || '').trim())}` };
|
|
890
|
+
}
|
|
891
|
+
return { ok: true, ahead: parsed.ahead, behind: parsed.behind, base, baseSource };
|
|
892
|
+
} catch (err) {
|
|
893
|
+
return { ok: false, error: (err && err.message) || String(err) };
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ---------------------------------------------------------------------
|
|
898
|
+
// Last commit timestamp of a checkout (warp-popup W7 — S12).
|
|
899
|
+
//
|
|
900
|
+
// One fixed argv read — `git log -1 --format=%ct HEAD` — the committer
|
|
901
|
+
// timestamp in whole seconds. Kept a SEPARATE export from `aheadBehind` so
|
|
902
|
+
// a caller that only needs recency pays for one spawn, never the ref
|
|
903
|
+
// listing plus the rev-list comparison.
|
|
904
|
+
//
|
|
905
|
+
// Every failure path RESOLVES to `{ok:false, error}` instead of throwing,
|
|
906
|
+
// exactly like `aheadBehind`.
|
|
907
|
+
async function lastCommitTime(checkoutPath, opts = {}) {
|
|
908
|
+
try {
|
|
909
|
+
if (typeof checkoutPath !== 'string' || checkoutPath.length === 0) {
|
|
910
|
+
return { ok: false, error: 'checkout path is empty' };
|
|
911
|
+
}
|
|
912
|
+
const checkout = path.resolve(checkoutPath);
|
|
913
|
+
const runAsync = typeof opts.runAsync === 'function'
|
|
914
|
+
? opts.runAsync
|
|
915
|
+
: (bin, args, cwd) => runArgvAsync(bin, args, { cwd, timeoutMs: opts.timeoutMs });
|
|
916
|
+
const log = await runAsync('git', ['log', '-1', '--format=%ct', 'HEAD'], checkout);
|
|
917
|
+
if (!log || log.status !== 0) {
|
|
918
|
+
return { ok: false, error: `cannot read the last commit in ${checkout}: ${gitFailureReason(log)}` };
|
|
919
|
+
}
|
|
920
|
+
const seconds = Number(String(log.stdout || '').trim());
|
|
921
|
+
if (!Number.isFinite(seconds)) {
|
|
922
|
+
return { ok: false, error: `unreadable last-commit output for ${checkout}: ${JSON.stringify(String(log.stdout || '').trim())}` };
|
|
923
|
+
}
|
|
924
|
+
const timestampMs = seconds * 1000;
|
|
925
|
+
return { ok: true, timestampMs, committedAt: new Date(timestampMs).toISOString() };
|
|
926
|
+
} catch (err) {
|
|
927
|
+
return { ok: false, error: (err && err.message) || String(err) };
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ---------------------------------------------------------------------
|
|
932
|
+
// Oldest root commit timestamp of a checkout (worktree UI S12).
|
|
933
|
+
//
|
|
934
|
+
// "When the project's git history began" is the only beginning git
|
|
935
|
+
// records: the commit(s) with no parent. One fixed argv read —
|
|
936
|
+
// `git log --max-parents=0 --format=%ct HEAD` — prints the committer
|
|
937
|
+
// timestamp (whole seconds) of EVERY root commit reachable from HEAD, so a
|
|
938
|
+
// history with several independent roots yields several lines. The MINIMUM
|
|
939
|
+
// is the answer, which makes it deterministic without depending on the
|
|
940
|
+
// order git happens to print them in.
|
|
941
|
+
//
|
|
942
|
+
// Kept a SEPARATE export from `lastCommitTime` so a caller that does not
|
|
943
|
+
// need the beginning pays for no extra spawn, and every failure path — not
|
|
944
|
+
// a repository, no commit yet, unparsable output — RESOLVES to
|
|
945
|
+
// `{ok:false, error}` instead of throwing, exactly like its siblings.
|
|
946
|
+
async function firstCommitTime(checkoutPath, opts = {}) {
|
|
947
|
+
try {
|
|
948
|
+
if (typeof checkoutPath !== 'string' || checkoutPath.length === 0) {
|
|
949
|
+
return { ok: false, error: 'checkout path is empty' };
|
|
950
|
+
}
|
|
951
|
+
const checkout = path.resolve(checkoutPath);
|
|
952
|
+
const runAsync = typeof opts.runAsync === 'function'
|
|
953
|
+
? opts.runAsync
|
|
954
|
+
: (bin, args, cwd) => runArgvAsync(bin, args, { cwd, timeoutMs: opts.timeoutMs });
|
|
955
|
+
const log = await runAsync('git', ['log', '--max-parents=0', '--format=%ct', 'HEAD'], checkout);
|
|
956
|
+
if (!log || log.status !== 0) {
|
|
957
|
+
return { ok: false, error: `cannot read the first commit in ${checkout}: ${gitFailureReason(log)}` };
|
|
958
|
+
}
|
|
959
|
+
const raw = String(log.stdout || '').trim();
|
|
960
|
+
const seconds = raw
|
|
961
|
+
.split('\n')
|
|
962
|
+
.map((line) => line.trim())
|
|
963
|
+
.filter((line) => line.length > 0)
|
|
964
|
+
.map(Number)
|
|
965
|
+
.filter((value) => Number.isFinite(value) && value >= 0);
|
|
966
|
+
if (seconds.length === 0) {
|
|
967
|
+
return { ok: false, error: `unreadable first-commit output for ${checkout}: ${JSON.stringify(raw)}` };
|
|
968
|
+
}
|
|
969
|
+
const earliest = Math.min(...seconds);
|
|
970
|
+
const timestampMs = earliest * 1000;
|
|
971
|
+
return { ok: true, timestampMs, committedAt: new Date(timestampMs).toISOString(), rootCount: seconds.length };
|
|
972
|
+
} catch (err) {
|
|
973
|
+
return { ok: false, error: (err && err.message) || String(err) };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Branch checked out in the PRINCIPAL checkout, read from the listing
|
|
978
|
+
// git prints with the main worktree first. Null when that entry is bare,
|
|
979
|
+
// detached or unreadable — a missing principal branch is not an error.
|
|
980
|
+
async function principalBranchOf(checkout, runAsync) {
|
|
981
|
+
const listed = await runAsync('git', ['worktree', 'list', '--porcelain', '-z'], checkout);
|
|
982
|
+
if (!listed || listed.status !== 0) return null;
|
|
983
|
+
const entries = parseWorktreeList(listed.stdout);
|
|
984
|
+
if (entries.length === 0) return null;
|
|
985
|
+
const principal = entries[0];
|
|
986
|
+
return typeof principal.branch === 'string' && principal.branch.length > 0 ? principal.branch : null;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Parse `git worktree list --porcelain -z` output. Split out from
|
|
990
|
+
// `listWorktrees` so the async paths reuse the exact same parsing.
|
|
991
|
+
function parseWorktreeList(stdout) {
|
|
992
|
+
// With -z every line is NUL-terminated; a blank token ends a record.
|
|
993
|
+
const tokens = String(stdout || '').split('\0');
|
|
994
|
+
const entries = [];
|
|
995
|
+
let current = null;
|
|
996
|
+
const flush = () => {
|
|
997
|
+
if (current && current.path) entries.push(current);
|
|
998
|
+
current = null;
|
|
999
|
+
};
|
|
1000
|
+
for (const token of tokens) {
|
|
1001
|
+
if (token === '') {
|
|
1002
|
+
flush();
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
if (token.startsWith('worktree ')) {
|
|
1006
|
+
flush();
|
|
1007
|
+
current = { path: token.slice('worktree '.length), branch: null, head: null, detached: false, bare: false };
|
|
1008
|
+
} else if (current && token.startsWith('HEAD ')) {
|
|
1009
|
+
current.head = token.slice('HEAD '.length);
|
|
1010
|
+
} else if (current && token.startsWith('branch ')) {
|
|
1011
|
+
const ref = token.slice('branch '.length);
|
|
1012
|
+
current.branch = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref;
|
|
1013
|
+
} else if (current && token === 'detached') {
|
|
1014
|
+
current.detached = true;
|
|
1015
|
+
} else if (current && token === 'bare') {
|
|
1016
|
+
current.bare = true;
|
|
1017
|
+
}
|
|
1018
|
+
// `locked` / `prunable` tokens carry no identity and are ignored.
|
|
1019
|
+
}
|
|
1020
|
+
flush();
|
|
1021
|
+
return entries;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
module.exports = {
|
|
1025
|
+
runArgv,
|
|
1026
|
+
aheadBehind,
|
|
1027
|
+
lastCommitTime,
|
|
1028
|
+
firstCommitTime,
|
|
1029
|
+
runArgvAsync,
|
|
1030
|
+
repoIdentity,
|
|
1031
|
+
repoIdentityAsync,
|
|
1032
|
+
commonDirFor,
|
|
1033
|
+
resetCommonDirCache,
|
|
1034
|
+
slugifyBranch,
|
|
1035
|
+
worktreePath,
|
|
1036
|
+
worktreePathOf,
|
|
1037
|
+
principalRoot,
|
|
1038
|
+
canonicalWorktreePath,
|
|
1039
|
+
canonicalWorktreePathOf,
|
|
1040
|
+
validateBranch,
|
|
1041
|
+
validateBranchAsync,
|
|
1042
|
+
listWorktrees,
|
|
1043
|
+
listWorktreesAsync,
|
|
1044
|
+
isPathInAnyCheckout,
|
|
1045
|
+
isPathInAnyCheckoutAsync,
|
|
1046
|
+
herdrArgv,
|
|
1047
|
+
resolveHerdrBinary,
|
|
1048
|
+
};
|