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,598 @@
1
+ 'use strict';
2
+ // Focused-checkout facts for the warp selector (warp-popup W1 — S2, S4, S9).
3
+ //
4
+ // One call yields everything the selector's footer shows for ONE checkout:
5
+ // the branch, when the checkout was created, its last activity, its
6
+ // uncommitted-change summary (the file count plus the added/deleted LINE
7
+ // counts of the tracked diff and of every untracked file, S16), and the
8
+ // commits it is ahead of / behind a base branch. S1: a worktree no longer
9
+ // receives issues or ODD documents, so the payload carries NO association
10
+ // field at all. Legacy registry rows still store `issues` and `odd`; that data
11
+ // is preserved untouched (never migrated, never erased) and is simply never
12
+ // read here. The row is still read for the ONE fact it legitimately owns — a
13
+ // linked worktree's `createdAt` (S12).
14
+ //
15
+ // Each fact is its own envelope — `{available:true, value, reason:null}` or
16
+ // `{available:false, value:null, reason}` — because S9 forbids rendering an
17
+ // unreadable value as a number. A failing git read, a missing registry row
18
+ // or a malformed registry is therefore "unavailable", never zero and never
19
+ // an empty list. Facts fail independently: a broken registry leaves the git
20
+ // facts intact and vice versa. Two facts are about TIME, and what they answer
21
+ // depends on the KIND of checkout (S12) rather than on one rule for all: the
22
+ // PRINCIPAL is the checkout git lists first, so its `createdAt` is when its
23
+ // git history began (the oldest root commit, `source:'root-commit'`), while a
24
+ // linked worktree reports when THAT checkout was created — its registry row's
25
+ // `createdAt` (`source:'registry'`) or, when that is missing or unreadable,
26
+ // the directory's birth time (`source:'birthtime'`). `lastActivity` is the
27
+ // winning side of `last commit` vs `newest uncommitted file` for either kind
28
+ // (S12), and S8 adds its provenance: an inherited commit older than the
29
+ // checkout's own creation is not local modification and is dropped, while a
30
+ // later commit or a dirty mtime stays honest. A checkout whose kind cannot be
31
+ // established is never treated as the principal, so git's beginning is never
32
+ // claimed without evidence.
33
+ //
34
+ // Cost control (S9 again): the expensive reads — `summarizeChanges` spawns
35
+ // two git processes, ahead/behind spawns up to two more, the last-commit
36
+ // read one, and the oldest-root-commit read one more but ONLY for the
37
+ // principal — only ever run for the checkout the caller names, and the whole
38
+ // per-path result is cached for `ttlMs`, so a selector that repaints per
39
+ // keystroke does not re-spawn git. The dirty summary is computed once and
40
+ // feeds both `changes` and the dirty side of `lastActivity`; the birth-time
41
+ // fallback is only stat'ed for a linked worktree with no usable registry row.
42
+ // Concurrent callers for the same path share one in-flight read. `facts()`
43
+ // is async (the git work is awaited, never spawned inline) and `peek()`
44
+ // answers from the cache synchronously at paint time.
45
+ //
46
+ // Reuse, not duplication: the dirty summary comes from
47
+ // lib/worktree-changes.js, the registry row from lib/worktree-registry.js,
48
+ // the branch from `listWorktrees`, and the comparison from
49
+ // lib/worktree-git.js's `aheadBehind`. The registry file read is local and
50
+ // network-free; every net-capable or heavier read, the repository listing
51
+ // included, is async so it never blocks the host event loop.
52
+
53
+ const path = require('node:path');
54
+ const fs = require('node:fs');
55
+
56
+ const gitLib = require('./worktree-git.js');
57
+ const changesLib = require('./worktree-changes.js');
58
+ const registryLib = require('./worktree-registry.js');
59
+
60
+ const DEFAULT_TTL_MS = 5000;
61
+
62
+ function available(value) {
63
+ return { available: true, value, reason: null };
64
+ }
65
+
66
+ function unavailable(reason) {
67
+ const text = typeof reason === 'string' && reason.length > 0 ? reason : 'unavailable';
68
+ return { available: false, value: null, reason: text };
69
+ }
70
+
71
+ function errorText(err) {
72
+ return (err && err.message) || String(err);
73
+ }
74
+
75
+ // Awaits a seam that may be sync or async and never lets a rejection escape.
76
+ async function attempt(fn, label) {
77
+ try {
78
+ return { ok: true, value: await fn() };
79
+ } catch (err) {
80
+ return { ok: false, error: `${label}: ${errorText(err)}` };
81
+ }
82
+ }
83
+
84
+ // Build a facts provider for one repository.
85
+ //
86
+ // Options (every seam is injectable so tests and hosts can substitute it):
87
+ // repoRoot repository whose registry and worktree listing are read
88
+ // readRegistry (repoRoot) -> {ok, worktrees, error}
89
+ // listWorktrees (repoRoot) -> [{path, branch, detached}] or a promise of
90
+ // the same; the default is the async repository listing
91
+ // summarize (checkoutPath) -> Promise<summary> (worktree-changes shape)
92
+ // aheadBehind (checkoutPath) -> Promise<{ok, ahead, behind, base}>
93
+ // lastCommit (checkoutPath) -> Promise<{ok, timestampMs}> (git log -1)
94
+ // firstCommit (checkoutPath) -> Promise<{ok, timestampMs}> (oldest root commit)
95
+ // repoIdentity (checkoutPath) -> identity or a promise of one; the
96
+ // default is the async `repoIdentityAsync`
97
+ // fs fs seam for the createdAt birth-time fallback (statSync)
98
+ // runAsync git runner forwarded to the default git seams
99
+ // ttlMs cache lifetime in ms (default 5000)
100
+ // now clock in ms (default Date.now)
101
+ function createCheckoutFacts(opts = {}) {
102
+ const repoRoot = typeof opts.repoRoot === 'string' && opts.repoRoot.length > 0 ? opts.repoRoot : null;
103
+ const ttlMs = Number.isFinite(opts.ttlMs) && opts.ttlMs >= 0 ? opts.ttlMs : DEFAULT_TTL_MS;
104
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
105
+ const runAsync = typeof opts.runAsync === 'function' ? opts.runAsync : null;
106
+ const fsImpl = opts.fs && typeof opts.fs.statSync === 'function' ? opts.fs : fs;
107
+
108
+ const readRegistry = typeof opts.readRegistry === 'function'
109
+ ? opts.readRegistry
110
+ : (root) => registryLib.readRegistry(root);
111
+ const listWorktrees = typeof opts.listWorktrees === 'function'
112
+ ? opts.listWorktrees
113
+ : (root) => gitLib.listWorktreesAsync(root, runAsync ? { runAsync } : {});
114
+ const summarize = typeof opts.summarize === 'function'
115
+ ? opts.summarize
116
+ : (checkout) => summarizeWithIdentity(checkout);
117
+ const aheadBehind = typeof opts.aheadBehind === 'function' ? opts.aheadBehind : null;
118
+ const lastCommit = typeof opts.lastCommit === 'function'
119
+ ? opts.lastCommit
120
+ : (checkout) => gitLib.lastCommitTime(checkout, runAsync ? { runAsync } : {});
121
+ const firstCommit = typeof opts.firstCommit === 'function'
122
+ ? opts.firstCommit
123
+ : (checkout) => gitLib.firstCommitTime(checkout, runAsync ? { runAsync } : {});
124
+ const repoIdentity = typeof opts.repoIdentity === 'function' ? opts.repoIdentity : null;
125
+
126
+ // Repository identity of ONE checkout, resolved through the ASYNC twin
127
+ // (worktree-issues S11). `summarizeChanges` resolves the repository root
128
+ // itself, and it can only do that synchronously — `git rev-parse
129
+ // --show-toplevel` plus `--git-common-dir` through `spawnSync`, i.e. two
130
+ // children that freeze the host for their whole lifetime, per checkout. The
131
+ // selector loads every row's facts at once, so N checkouts froze the host N
132
+ // times over while the panel was merely reading. The answer here is the same
133
+ // one, and it is read before `summarizeChanges` runs, so the identity is
134
+ // handed to it instead of re-spawned.
135
+ function identityFor(checkout) {
136
+ const read = repoIdentity !== null
137
+ ? () => repoIdentity(checkout)
138
+ : () => gitLib.repoIdentityAsync(checkout, runAsync ? { runAsync } : {});
139
+ // A read that fails is NOT reported here: it falls through to the shared
140
+ // reader inside `summarizeChanges`, which produces its own verdict and its
141
+ // own message, exactly as before this read existed.
142
+ return Promise.resolve().then(read).catch(() => null);
143
+ }
144
+
145
+ // The `git` seam `summarizeChanges` takes: the identity already read, for the
146
+ // path it was read for, and the shared synchronous reader otherwise (a
147
+ // spelling this read did not answer for), so correctness never depends on
148
+ // the pass-through.
149
+ function identitySeam(identity) {
150
+ let resolved = null;
151
+ try {
152
+ resolved = identity && typeof identity.root === 'string' ? path.resolve(identity.root) : null;
153
+ } catch {
154
+ resolved = null;
155
+ }
156
+ return {
157
+ ...gitLib,
158
+ repoIdentity: (target) => {
159
+ try {
160
+ if (resolved !== null && typeof target === 'string' && path.resolve(target) === resolved) return identity;
161
+ } catch {
162
+ // A path that cannot be resolved falls through to the shared reader.
163
+ }
164
+ return gitLib.repoIdentity(target);
165
+ },
166
+ };
167
+ }
168
+
169
+ async function summarizeWithIdentity(checkout) {
170
+ const identity = await identityFor(checkout);
171
+ const options = { git: identitySeam(identity) };
172
+ if (runAsync) options.runAsync = runAsync;
173
+ return changesLib.summarizeChanges(checkout, options);
174
+ }
175
+
176
+ const cache = new Map(); // absolute checkout path -> {at, facts}
177
+ const inFlight = new Map(); // absolute checkout path -> Promise<facts>
178
+ const listingInFlight = new Map(); // repository root -> Promise<listing entries>
179
+
180
+ // Absolute spelling used as the cache key and reported back to the
181
+ // caller; an unusable input stays as the caller wrote it.
182
+ function keyOf(checkoutPath) {
183
+ if (typeof checkoutPath !== 'string' || checkoutPath.length === 0) return null;
184
+ return path.resolve(checkoutPath);
185
+ }
186
+
187
+ // Two spellings of one checkout are the same checkout: the resolved paths
188
+ // first, then the realpaths (a listing may name a symlinked checkout).
189
+ function sameCheckout(a, b) {
190
+ if (typeof a !== 'string' || typeof b !== 'string' || a.length === 0 || b.length === 0) return false;
191
+ if (path.resolve(a) === path.resolve(b)) return true;
192
+ if (typeof fsImpl.realpathSync !== 'function') return false;
193
+ try {
194
+ return fsImpl.realpathSync(a) === fsImpl.realpathSync(b);
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ // Which KIND of checkout this is (S12). The PRINCIPAL is the one git lists
201
+ // FIRST — `git worktree list` documents the main worktree first — never the
202
+ // calling checkout and never a branch name, so the answer does not depend on
203
+ // where the session runs or on `main`/`master` being called that. A listing
204
+ // that cannot be read leaves the kind unknown, and an unknown kind never
205
+ // claims principal semantics.
206
+ function isPrincipalCheckout(checkout, listing) {
207
+ if (!listing || listing.ok !== true) return false;
208
+ const entries = Array.isArray(listing.value) ? listing.value : [];
209
+ const first = entries[0];
210
+ return Boolean(first) && sameCheckout(first.path, checkout);
211
+ }
212
+
213
+ // The listing entry for exactly this checkout (resolve-equality, the
214
+ // same rule the registry's findByPath uses). Unavailable when the
215
+ // listing fails or the path is not a checkout git knows about.
216
+ function branchFact(checkout, listed) {
217
+ if (!listed.ok) return unavailable(listed.error);
218
+ const entries = Array.isArray(listed.value) ? listed.value : [];
219
+ const entry = entries.find((candidate) => candidate && typeof candidate.path === 'string'
220
+ && path.isAbsolute(candidate.path)
221
+ && path.resolve(candidate.path) === checkout);
222
+ if (!entry) return unavailable(`not a known checkout: ${checkout}`);
223
+ // A detached HEAD has no branch name; saying "detached" is honest,
224
+ // inventing a name is not.
225
+ return { ...available(entry.branch), detached: entry.detached === true };
226
+ }
227
+
228
+ // Registry row for this checkout — read ONLY for the one fact it owns, a
229
+ // linked worktree's `createdAt`. `ok:false` (malformed/unreadable registry)
230
+ // and "no row" are different reasons but the same verdict: that creation
231
+ // has no registry answer, so the birth-time fallback decides.
232
+ function registryRow(checkout) {
233
+ let read;
234
+ try {
235
+ read = readRegistry(repoRoot || checkout);
236
+ } catch (err) {
237
+ return { row: null, reason: `registry: ${errorText(err)}` };
238
+ }
239
+ if (!read || read.ok !== true) {
240
+ const reason = (read && read.error) || 'cannot read registry';
241
+ return { row: null, reason };
242
+ }
243
+ const rows = Array.isArray(read.worktrees) ? read.worktrees : [];
244
+ const row = rows.find((candidate) => candidate && typeof candidate.path === 'string'
245
+ && path.isAbsolute(candidate.path)
246
+ && path.resolve(candidate.path) === checkout);
247
+ if (!row) return { row: null, reason: 'no registry entry for this checkout' };
248
+ return { row, reason: null };
249
+ }
250
+
251
+ // When the checkout was created (S12). The answer depends on the KIND:
252
+ // principal — when its git HISTORY began: the oldest root commit. Git
253
+ // records nothing older, so an unreadable history stays
254
+ // unavailable instead of borrowing the directory's birth
255
+ // time (which is when the folder was made, not when the
256
+ // project started).
257
+ // worktree — when THAT checkout was created: its registry row's
258
+ // `createdAt` (a registration time is never a creation, so
259
+ // `registeredAt` is ignored) or, when the row has no usable
260
+ // timestamp, the directory's birth time.
261
+ // `source` names which answer came back — 'root-commit', 'registry' or
262
+ // 'birthtime' — and with none readable the fact stays unavailable.
263
+ function birthTimeMs(checkout) {
264
+ try {
265
+ const ms = Number(fsImpl.statSync(checkout).birthtimeMs);
266
+ if (Number.isFinite(ms) && ms > 0) return ms;
267
+ } catch {
268
+ // A checkout that cannot be stat'ed has no birth time to report.
269
+ }
270
+ return null;
271
+ }
272
+
273
+ function createdAtFact(checkout, row, registryReason, principal, root) {
274
+ if (principal) {
275
+ if (root.ok) return { ...available(new Date(root.ms).toISOString()), source: 'root-commit' };
276
+ return { ...unavailable(root.reason), source: null };
277
+ }
278
+ if (row && typeof row.createdAt === 'string' && row.createdAt.length > 0
279
+ && Number.isFinite(Date.parse(row.createdAt))) {
280
+ return { ...available(row.createdAt), source: 'registry' };
281
+ }
282
+ const birth = birthTimeMs(checkout);
283
+ if (birth === null) {
284
+ return { ...unavailable(registryReason || 'no registry entry and no readable birth time'), source: null };
285
+ }
286
+ return { ...available(new Date(birth).toISOString()), source: 'birthtime' };
287
+ }
288
+
289
+ // The mtime of the most recently modified uncommitted file in the summary,
290
+ // or null when nothing still exists. Reads the SAME summary the `changes`
291
+ // fact already computed, so last activity never spawns git a second time
292
+ // for the dirty side.
293
+ function latestDirtyMtime(entries) {
294
+ let latest = null;
295
+ for (const entry of Array.isArray(entries) ? entries : []) {
296
+ if (!entry || typeof entry.path !== 'string' || entry.path.length === 0) continue;
297
+ let ms;
298
+ try {
299
+ ms = Number(fsImpl.statSync(entry.path).mtimeMs);
300
+ } catch {
301
+ continue; // Deleted since the summary; it contributes nothing.
302
+ }
303
+ if (Number.isFinite(ms) && ms > 0 && (latest === null || ms > latest)) latest = ms;
304
+ }
305
+ return latest;
306
+ }
307
+
308
+ // The shared summary measures each untracked file from disk and reports an
309
+ // UNREADABLE one as zero added lines — indistinguishable from an empty
310
+ // file. S16 forbids counting that as a zero, so the untracked entries the
311
+ // summary measured as zero lines are re-opened here: a file that still
312
+ // exists but cannot be read makes the whole fact unavailable, naming the
313
+ // path, instead of a fake zero. Binary files are labelled, not counted, and
314
+ // a file gone since the summary contributes nothing.
315
+ function unreadableUntracked(entries, fsImpl) {
316
+ if (typeof fsImpl.readFileSync !== 'function') return null;
317
+ for (const entry of Array.isArray(entries) ? entries : []) {
318
+ if (!entry || entry.untracked !== true || typeof entry.path !== 'string') continue;
319
+ if (Number(entry.added) !== 0 || entry.binary === true) continue;
320
+ try {
321
+ fsImpl.readFileSync(entry.path);
322
+ } catch {
323
+ try {
324
+ fsImpl.statSync(entry.path);
325
+ } catch {
326
+ continue; // Gone since the summary; it contributes nothing.
327
+ }
328
+ return entry.rel || entry.path;
329
+ }
330
+ }
331
+ return null;
332
+ }
333
+
334
+ async function changesRead(checkout) {
335
+ const summary = await attempt(() => summarize(checkout), 'uncommitted changes');
336
+ if (!summary.ok) return { fact: unavailable(summary.error), latestMtimeMs: null };
337
+ const value = summary.value;
338
+ if (!value || value.ok !== true) {
339
+ return {
340
+ fact: unavailable(`uncommitted changes: ${(value && value.error) || 'unreadable summary'}`),
341
+ latestMtimeMs: null,
342
+ };
343
+ }
344
+ const unreadable = unreadableUntracked(value.entries, fsImpl);
345
+ if (unreadable !== null) {
346
+ return {
347
+ fact: unavailable(`uncommitted changes: cannot read untracked file ${JSON.stringify(unreadable)}`),
348
+ latestMtimeMs: null,
349
+ };
350
+ }
351
+ return {
352
+ fact: available({
353
+ files: value.files,
354
+ added: value.added,
355
+ deleted: value.deleted,
356
+ binaryCount: value.binaryCount || 0,
357
+ text: changesLib.formatChangeSummary(value),
358
+ }),
359
+ latestMtimeMs: latestDirtyMtime(value.entries),
360
+ };
361
+ }
362
+
363
+ // The last commit timestamp as an internal `{ok, ms, reason}` so the
364
+ // activity fact can merge it with the dirty mtimes behind one read.
365
+ async function commitRead(checkout) {
366
+ const read = await attempt(() => lastCommit(checkout), 'last commit');
367
+ if (!read.ok) return { ok: false, reason: read.error };
368
+ const value = read.value;
369
+ if (!value || value.ok !== true) {
370
+ return { ok: false, reason: `last commit: ${(value && value.error) || 'unreadable commit'}` };
371
+ }
372
+ const ms = Number(value.timestampMs);
373
+ if (!Number.isFinite(ms) || ms < 0) return { ok: false, reason: 'last commit: unreadable timestamp' };
374
+ return { ok: true, ms };
375
+ }
376
+
377
+ // The root commit timestamp as an internal `{ok, ms, reason}`, mirroring
378
+ // `commitRead` so the principal's creation answers in the same shape.
379
+ async function rootCommitRead(checkout) {
380
+ const read = await attempt(() => firstCommit(checkout), 'first commit');
381
+ if (!read.ok) return { ok: false, reason: read.error };
382
+ const value = read.value;
383
+ if (!value || value.ok !== true) {
384
+ return { ok: false, reason: `first commit: ${(value && value.error) || 'unreadable commit'}` };
385
+ }
386
+ const ms = Number(value.timestampMs);
387
+ if (!Number.isFinite(ms) || ms < 0) return { ok: false, reason: 'first commit: unreadable timestamp' };
388
+ return { ok: true, ms };
389
+ }
390
+
391
+ // Last activity (S12/S8): the greater of the last commit timestamp and the
392
+ // mtime of the most recently modified uncommitted file. `source` records
393
+ // which side won; with neither readable the fact is unavailable, never a
394
+ // zero time. An inherited pre-checkout commit — one older than the
395
+ // checkout's own creation — is not local modification: when the creation
396
+ // is known it is dropped, and with no local evidence left the fact is
397
+ // unavailable rather than a modification dated before the checkout.
398
+ function activityFact(commit, dirtyMs, createdFact) {
399
+ let createdMs = null;
400
+ if (createdFact && createdFact.available === true) {
401
+ const parsed = Date.parse(createdFact.value);
402
+ if (Number.isFinite(parsed)) createdMs = parsed;
403
+ }
404
+ const inherited = commit.ok && createdMs !== null && commit.ms < createdMs;
405
+ const candidates = [];
406
+ if (commit.ok && !inherited) candidates.push({ ms: commit.ms, source: 'commit' });
407
+ if (Number.isFinite(dirtyMs)) candidates.push({ ms: dirtyMs, source: 'dirty' });
408
+ if (candidates.length === 0) {
409
+ if (inherited) return { ...unavailable('no activity since the checkout was created'), source: null };
410
+ return unavailable(commit.reason || 'no commit and no uncommitted file to read');
411
+ }
412
+ let winner = candidates[0];
413
+ for (const candidate of candidates) {
414
+ if (candidate.ms > winner.ms) winner = candidate;
415
+ }
416
+ return { ...available(new Date(winner.ms).toISOString()), source: winner.source };
417
+ }
418
+
419
+ async function aheadBehindFact(checkout, listingRead) {
420
+ // The base is resolved against the repository listing, which this read
421
+ // already awaited for the branch fact, so the shared helper is handed the
422
+ // principal's branch instead of spawning its own `git worktree list` once
423
+ // per checkout (worktree-issues S11). An injected `aheadBehind` seam is
424
+ // called exactly as before.
425
+ const listed = await listingRead;
426
+ const principalBranch = principalBranchFor(checkout, listed);
427
+ const compare = aheadBehind !== null
428
+ ? () => aheadBehind(checkout)
429
+ : () => {
430
+ const options = { principalBranch };
431
+ if (runAsync) options.runAsync = runAsync;
432
+ return gitLib.aheadBehind(checkout, options);
433
+ };
434
+ const compared = await attempt(compare, 'commits ahead/behind');
435
+ if (!compared.ok) return unavailable(compared.error);
436
+ const value = compared.value;
437
+ if (!value || value.ok !== true) {
438
+ return unavailable(`commits ahead/behind: ${(value && value.error) || 'unreadable comparison'}`);
439
+ }
440
+ return available({
441
+ ahead: value.ahead, behind: value.behind, base: value.base, baseSource: value.baseSource,
442
+ });
443
+ }
444
+
445
+ // The principal checkout's branch, from the listing this read already has.
446
+ // `undefined` — the answer that keeps the previous behavior — means "this
447
+ // listing does not describe this checkout" (a checkout of ANOTHER
448
+ // repository, which the caller may ask about), so the shared helper reads
449
+ // the repository itself. `null` states that the principal has no branch to
450
+ // offer (detached or bare), which is what such a listing always meant.
451
+ function principalBranchFor(checkout, listed) {
452
+ if (!listed || listed.ok !== true) return undefined;
453
+ const entries = Array.isArray(listed.value) ? listed.value : [];
454
+ const known = entries.some((entry) => entry && typeof entry.path === 'string'
455
+ && path.isAbsolute(entry.path) && path.resolve(entry.path) === checkout);
456
+ if (!known) return undefined;
457
+ const first = entries[0];
458
+ if (!first) return undefined;
459
+ return typeof first.branch === 'string' && first.branch.length > 0 ? first.branch : null;
460
+ }
461
+
462
+ // One repository listing per root while it is PENDING (S23): the branch
463
+ // fact and the checkout KIND need the same `git worktree list`, and a
464
+ // selector entry lists every checkout at once, so N concurrent checkouts
465
+ // must not spawn N identical listings. Ownership is published before the
466
+ // seam runs, so a seam that throws synchronously cannot leave an entry
467
+ // without an owner, and it is released the moment it settles: a settled
468
+ // listing is never retained, so a later read asks git again instead of
469
+ // showing a repository state that has since changed.
470
+ function listingFor(root) {
471
+ const key = path.resolve(root);
472
+ const pending = listingInFlight.get(key);
473
+ if (pending) return pending;
474
+ const promise = Promise.resolve().then(() => listWorktrees(root));
475
+ listingInFlight.set(key, promise);
476
+ // Release only OUR entry: an `invalidate()` may already have dropped it
477
+ // and a newer listing may own the key, so a late completion never clears
478
+ // newer work.
479
+ const release = () => {
480
+ if (listingInFlight.get(key) === promise) listingInFlight.delete(key);
481
+ };
482
+ promise.then(release, release);
483
+ return promise;
484
+ }
485
+
486
+ async function read(checkout) {
487
+ const startedAt = now();
488
+ // The expensive git reads run together but only for THIS checkout; the
489
+ // cheap synchronous registry read runs first (it answers nothing but the
490
+ // linked worktree's `createdAt`), and the listing is read once because
491
+ // both the branch fact and the checkout KIND need it. The dirty summary is
492
+ // computed once and feeds both `changes` and the dirty side of
493
+ // `lastActivity`.
494
+ const registry = registryRow(checkout);
495
+ // The listing is started FIRST and awaited by both the branch fact and the
496
+ // ahead/behind base, so one `git worktree list` serves the whole read.
497
+ const listingRead = attempt(() => listingFor(repoRoot || checkout), 'git worktree list');
498
+ const [listing, changes, compared, commit] = await Promise.all([
499
+ listingRead,
500
+ changesRead(checkout),
501
+ aheadBehindFact(checkout, listingRead),
502
+ commitRead(checkout),
503
+ ]);
504
+ const principal = isPrincipalCheckout(checkout, listing);
505
+ // The root-commit read only ever runs for the principal: a linked
506
+ // worktree's creation is never git's history beginning.
507
+ const root = principal
508
+ ? await rootCommitRead(checkout)
509
+ : { ok: false, reason: 'not the principal checkout' };
510
+ const createdAt = createdAtFact(checkout, registry.row, registry.reason, principal, root);
511
+ return {
512
+ checkout,
513
+ fetchedAt: startedAt,
514
+ cached: false,
515
+ branch: branchFact(checkout, listing),
516
+ createdAt,
517
+ lastActivity: activityFact(commit, changes.latestMtimeMs, createdAt),
518
+ changes: changes.fact,
519
+ aheadBehind: compared,
520
+ };
521
+ }
522
+
523
+ function fresh(entry) {
524
+ return entry !== null && now() - entry.at < ttlMs;
525
+ }
526
+
527
+ function clone(entry, extra) {
528
+ return { ...entry.facts, cached: true, ...extra };
529
+ }
530
+
531
+ // Resolve the facts for one checkout: cached within the TTL, the shared
532
+ // in-flight read while one is running, otherwise a fresh read.
533
+ async function facts(checkoutPath) {
534
+ const checkout = keyOf(checkoutPath);
535
+ if (checkout === null) {
536
+ return {
537
+ checkout: checkoutPath === undefined ? null : checkoutPath,
538
+ fetchedAt: now(),
539
+ cached: false,
540
+ branch: unavailable('checkout path is empty'),
541
+ createdAt: unavailable('checkout path is empty'),
542
+ lastActivity: unavailable('checkout path is empty'),
543
+ changes: unavailable('checkout path is empty'),
544
+ aheadBehind: unavailable('checkout path is empty'),
545
+ };
546
+ }
547
+ const entry = cache.get(checkout);
548
+ if (entry && fresh(entry)) return clone(entry);
549
+ const pending = inFlight.get(checkout);
550
+ if (pending) return clone({ facts: await pending });
551
+ const promise = read(checkout);
552
+ inFlight.set(checkout, promise);
553
+ try {
554
+ const values = await promise;
555
+ // A read that an `invalidate()` superseded must not repopulate the
556
+ // cache: the in-flight entry is cleared on invalidate, so this guard
557
+ // keeps a slow pre-save read from resurrecting stale facts.
558
+ if (inFlight.get(checkout) === promise) cache.set(checkout, { at: now(), facts: values });
559
+ return values;
560
+ } finally {
561
+ inFlight.delete(checkout);
562
+ }
563
+ }
564
+
565
+ // Synchronous paint-time read of the cache: null when nothing was read
566
+ // yet, `stale:true` when the entry is past its TTL (the caller may show
567
+ // it and refresh in the background, but is told it is old).
568
+ function peek(checkoutPath) {
569
+ const checkout = keyOf(checkoutPath);
570
+ if (checkout === null) return null;
571
+ const entry = cache.get(checkout);
572
+ if (!entry) return null;
573
+ return clone(entry, { stale: !fresh(entry) });
574
+ }
575
+
576
+ // Drop one checkout's cached facts, or all of them (after a create, a
577
+ // registry edit or a commit the selector itself performed).
578
+ function invalidate(checkoutPath) {
579
+ const checkout = keyOf(checkoutPath);
580
+ if (checkout === null) {
581
+ cache.clear();
582
+ inFlight.clear();
583
+ listingInFlight.clear();
584
+ return;
585
+ }
586
+ cache.delete(checkout);
587
+ // Drop any in-flight read too: its result predates the invalidation.
588
+ inFlight.delete(checkout);
589
+ // The pending listing predates the invalidation as well, so it must not
590
+ // be served to a read that follows it; dropping the ownership makes the
591
+ // next read list again.
592
+ listingInFlight.delete(path.resolve(repoRoot || checkout));
593
+ }
594
+
595
+ return { facts, peek, invalidate, ttlMs };
596
+ }
597
+
598
+ module.exports = { createCheckoutFacts, DEFAULT_TTL_MS };