pi-odd-worktree 0.0.0-stage → 1.0.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.
Files changed (60) hide show
  1. package/README.md +234 -2
  2. package/extensions/odd-worktree-tools.ts +7593 -0
  3. package/lib/checkout-facts.js +598 -0
  4. package/lib/clipboard.js +226 -0
  5. package/lib/gh-issues.js +1555 -0
  6. package/lib/issue-preview.js +273 -0
  7. package/lib/issues-view.js +2274 -0
  8. package/lib/odd-docs.js +505 -0
  9. package/lib/odd-markdown.js +413 -0
  10. package/lib/odd-view.js +990 -0
  11. package/lib/open-external.js +106 -0
  12. package/lib/preview-scroll-keys.js +192 -0
  13. package/lib/session-auto-name.js +292 -0
  14. package/lib/session-worktree-register.js +62 -0
  15. package/lib/tui-paint.js +273 -0
  16. package/lib/view-layout.js +368 -0
  17. package/lib/warp-create-panel.js +722 -0
  18. package/lib/warp-create-picker.js +906 -0
  19. package/lib/warp-delete-confirm.js +465 -0
  20. package/lib/warp-selector-view.js +1265 -0
  21. package/lib/worktree-card.js +1117 -0
  22. package/lib/worktree-changes.js +1395 -0
  23. package/lib/worktree-close-herdr.js +73 -0
  24. package/lib/worktree-git.js +1048 -0
  25. package/lib/worktree-hooks.js +738 -0
  26. package/lib/worktree-model-result.js +291 -0
  27. package/lib/worktree-registry.js +886 -0
  28. package/lib/worktree-session-guard.js +595 -0
  29. package/lib/worktree-symlinks.js +336 -0
  30. package/lib/worktree-warp-sessions.js +566 -0
  31. package/package.json +36 -4
  32. package/tools/commands.test.js +176 -0
  33. package/tools/fixtures/gh-api-user.json +1 -0
  34. package/tools/fixtures/gh-issue-list.json +1 -0
  35. package/tools/fixtures/gh-issue-view.json +1 -0
  36. package/tools/fixtures/gh-label-list.json +1 -0
  37. package/tools/fixtures/gh-repo-view.json +1 -0
  38. package/tools/fixtures/load-extension.js +66 -0
  39. package/tools/gh-issues-feedback.test.js +359 -0
  40. package/tools/gh-issues.test.js +1502 -0
  41. package/tools/issue-preview.test.js +216 -0
  42. package/tools/issues-command.test.js +1217 -0
  43. package/tools/issues-view.test.js +4401 -0
  44. package/tools/odd-docs.test.js +803 -0
  45. package/tools/odd-markdown.test.js +236 -0
  46. package/tools/odd-view.test.js +2328 -0
  47. package/tools/package-surface.test.js +138 -0
  48. package/tools/t5-session-identity.test.js +306 -0
  49. package/tools/warp-create-panel.test.js +185 -0
  50. package/tools/warp-create-picker.test.js +1357 -0
  51. package/tools/warp-delete-confirm.test.js +616 -0
  52. package/tools/warp-selector-view.test.js +1463 -0
  53. package/tools/worktree-changes.test.js +951 -0
  54. package/tools/worktree-close-herdr-async.test.js +204 -0
  55. package/tools/worktree-odd-migration.test.js +224 -0
  56. package/tools/worktree-registry-cache.test.js +163 -0
  57. package/tools/worktree-registry-odd.test.js +231 -0
  58. package/tools/worktree-registry.test.js +244 -0
  59. package/tools/worktree-session-guard.test.js +507 -0
  60. 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
+ };