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,886 @@
1
+ 'use strict';
2
+ // Registry of agent-owned worktrees (P2-S1 foundation).
3
+ //
4
+ // The file lives at <git-common-dir>/pi-worktrees.json: inside `.git`,
5
+ // never in a working tree, never committed, shared by every worktree of
6
+ // the repository. Entries carry
7
+ // {branch, path, thread, issues[], odd, createdAt, updatedAt}, where `odd`
8
+ // is ONE odd document path or `null` (S8: a worktree carries at most one
9
+ // associated odd document — the entity rule, not a UI convention).
10
+ //
11
+ // Reads tolerate malformed content and report it instead of throwing, and
12
+ // never delete the file. Writes are atomic (temporary file plus rename)
13
+ // and serialized by withRegistryLock, an exclusive lock file beside the
14
+ // registry with a bounded wait and stale-lock recovery. Pure Node: the
15
+ // only subprocess used is the git plumbing that locates the common dir.
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+ const crypto = require('node:crypto');
20
+ const { commonDirFor } = require('./worktree-git.js');
21
+
22
+ const REGISTRY_VERSION = 1;
23
+ const REGISTRY_NAME = 'pi-worktrees.json';
24
+ const LOCK_NAME = 'pi-worktrees.lock';
25
+
26
+ const DEFAULT_TIMEOUT_MS = 10000;
27
+ const DEFAULT_POLL_MS = 50;
28
+ const DEFAULT_STALE_MS = 30000;
29
+
30
+ // S22 (round 3): the storage directory comes from the shared, memoized
31
+ // `commonDirFor`, so locating the registry never spawns git more than once per
32
+ // checkout and shares that one resolution with the warp-session store.
33
+ function resetRegistryCaches() {
34
+ readCache.clear();
35
+ }
36
+
37
+ function registryPath(repoRoot) {
38
+ return path.join(commonDirFor(repoRoot), REGISTRY_NAME);
39
+ }
40
+
41
+ function lockPath(repoRoot) {
42
+ return path.join(commonDirFor(repoRoot), LOCK_NAME);
43
+ }
44
+
45
+ function emptyRegistry() {
46
+ return { ok: true, version: REGISTRY_VERSION, worktrees: [], error: null };
47
+ }
48
+
49
+ // S8 entity rule: a worktree carries AT MOST ONE odd document, stored as a
50
+ // single `odd/<rel>` path string, or `null` when it has none. This is the
51
+ // ONLY normalization of that field: every read and every write funnels
52
+ // through it, so no caller can reintroduce a second one.
53
+ //
54
+ // Legacy migration (S8b): a row written by an older version may carry the
55
+ // old `odd[]` array. It normalizes deterministically to its FIRST entry and
56
+ // never fails, and `dropped` names the entries left behind so the WRITE that
57
+ // collapsed them can report the loss once instead of discarding a stored
58
+ // association silently. An empty array, like an empty string, means "none".
59
+ function normalizeOddDoc(value) {
60
+ if (typeof value === 'string') return value.length > 0 ? { odd: value, dropped: [] } : { odd: null, dropped: [] };
61
+ if (Array.isArray(value)) {
62
+ const entries = value.filter((name) => typeof name === 'string' && name.length > 0);
63
+ return { odd: entries.length > 0 ? entries[0] : null, dropped: entries.slice(1) };
64
+ }
65
+ return { odd: null, dropped: [] };
66
+ }
67
+
68
+ function isEntryShape(entry) {
69
+ return typeof entry === 'object' && entry !== null && !Array.isArray(entry)
70
+ && typeof entry.branch === 'string' && typeof entry.path === 'string';
71
+ }
72
+
73
+ // S8b: a row read from a legacy array carries the names it left behind on a
74
+ // NON-enumerable property, so `JSON.stringify` can never persist it while a
75
+ // write that COLLAPSES that row can still report the loss once.
76
+ function stampDroppedOdds(row, dropped) {
77
+ if (dropped.length === 0) return row;
78
+ Object.defineProperty(row, 'oddDropped', { value: dropped, enumerable: false });
79
+ return row;
80
+ }
81
+
82
+ // S1: a write that addresses ONE row used to rewrite the whole file, so every
83
+ // OTHER row was re-serialized from its normalized in-memory shape — and a
84
+ // legacy `odd[]` list silently lost the documents past its first entry. A
85
+ // worktree carries no association any more, which leaves that loss nowhere to
86
+ // be reported: so it must not happen. Each read row therefore remembers the
87
+ // entry it was parsed from on a NON-enumerable stamp (invisible to JSON and to
88
+ // every caller's view of the row), and the rows a write does not address are
89
+ // written back from that entry — byte for byte, unknown fields included.
90
+ function stampStoredEntry(row, entry) {
91
+ Object.defineProperty(row, 'storedEntry', { value: entry, enumerable: false });
92
+ return row;
93
+ }
94
+
95
+ // The rows a write persists: an unaddressed row goes back exactly as it came
96
+ // in; a row the write BUILT — every write path constructs the row it addresses
97
+ // — carries no stamp and is serialized in its normalized shape.
98
+ function rowsForWrite(worktrees) {
99
+ return worktrees.map((row) => (Object.prototype.hasOwnProperty.call(row, 'storedEntry')
100
+ ? row.storedEntry
101
+ : row));
102
+ }
103
+
104
+ // The association report a write returns: the entries a supplied legacy list
105
+ // lost, plus the ones the ADDRESSED row lost while being persisted. Only a row
106
+ // the write built can drop anything — every other row is preserved verbatim
107
+ // (rowsForWrite) — so a preserved row never reaches this list.
108
+ function reportDroppedOdds(result, addressed, extra = []) {
109
+ const dropped = [...extra];
110
+ for (const row of addressed || []) {
111
+ if (Array.isArray(row && row.oddDropped)) dropped.push(...row.oddDropped);
112
+ }
113
+ if (dropped.length > 0) result.dropped = dropped;
114
+ return result;
115
+ }
116
+
117
+ // Read the registry. Missing means empty; malformed content is reported
118
+ // ({ok:false, error}) instead of thrown, and the file is left untouched.
119
+ // The parsed result is cached per file identity — inode, size and nanosecond
120
+ // mtime — because the registry is read several times per action and a fresh
121
+ // JSON parse plus row normalization is the whole cost of a read once git is
122
+ // out of the way. `rename` (every write) changes the inode and an in-place
123
+ // edit changes mtime/size, so a cached read can never outlive a write, ours
124
+ // or another process's. Callers receive a CLONE: the returned rows are
125
+ // mutable, and handing out the cached object would let one caller's edit
126
+ // reach another caller's next read.
127
+ const READ_CACHE_LIMIT = 32;
128
+ const readCache = new Map();
129
+
130
+ function fileIdentity(file) {
131
+ try {
132
+ const stat = fs.statSync(file, { bigint: true });
133
+ return `${stat.ino}:${stat.size}:${stat.mtimeNs}`;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ function cloneRead(result) {
140
+ return {
141
+ ok: result.ok,
142
+ version: result.version,
143
+ worktrees: result.worktrees.map((row) => {
144
+ const copy = JSON.parse(JSON.stringify(row));
145
+ // The drop report rides on a NON-enumerable stamp (a legacy association
146
+ // list collapsed at read time), so a plain JSON clone would lose it and
147
+ // a later write would stop reporting what it dropped.
148
+ if (Array.isArray(row.oddDropped)) {
149
+ Object.defineProperty(copy, 'oddDropped', { value: row.oddDropped.slice(), enumerable: false });
150
+ }
151
+ // The verbatim entry a later write persists rides on the same kind of
152
+ // stamp: without it a cached read would hand back a row whose next
153
+ // whole-file write silently normalized the legacy list after all.
154
+ if (row.storedEntry !== undefined) {
155
+ Object.defineProperty(copy, 'storedEntry', {
156
+ value: JSON.parse(JSON.stringify(row.storedEntry)),
157
+ enumerable: false,
158
+ });
159
+ }
160
+ return copy;
161
+ }),
162
+ error: result.error,
163
+ };
164
+ }
165
+
166
+ function readRegistry(repoRoot) {
167
+ const file = registryPath(repoRoot);
168
+ const identity = fileIdentity(file);
169
+ if (identity !== null) {
170
+ const cached = readCache.get(file);
171
+ if (cached && cached.identity === identity) return cloneRead(cached.result);
172
+ }
173
+ const result = readRegistryFresh(file);
174
+ if (identity === null) {
175
+ readCache.delete(file);
176
+ } else {
177
+ readCache.delete(file);
178
+ readCache.set(file, { identity, result });
179
+ if (readCache.size > READ_CACHE_LIMIT) {
180
+ const oldest = readCache.keys().next();
181
+ if (!oldest.done) readCache.delete(oldest.value);
182
+ }
183
+ }
184
+ return cloneRead(result);
185
+ }
186
+
187
+ function readRegistryFresh(file) {
188
+ let raw;
189
+ try {
190
+ raw = fs.readFileSync(file, 'utf8');
191
+ } catch (err) {
192
+ if (err && err.code === 'ENOENT') return emptyRegistry();
193
+ return { ok: false, version: null, worktrees: [], error: `cannot read registry: ${err.message}` };
194
+ }
195
+ let doc;
196
+ try {
197
+ doc = JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw);
198
+ } catch (err) {
199
+ return { ok: false, version: null, worktrees: [], error: `malformed registry (invalid JSON): ${err.message}` };
200
+ }
201
+ if (typeof doc !== 'object' || doc === null || Array.isArray(doc) || !Array.isArray(doc.worktrees)) {
202
+ return { ok: false, version: null, worktrees: [], error: 'malformed registry (expected {worktrees: []})' };
203
+ }
204
+ const version = doc.version === undefined ? REGISTRY_VERSION : doc.version;
205
+ const worktrees = doc.worktrees.filter(isEntryShape).map((entry) => {
206
+ const odd = normalizeOddDoc(entry.odd);
207
+ return stampStoredEntry(stampDroppedOdds({
208
+ ...entry,
209
+ issues: Array.isArray(entry.issues) ? entry.issues : [],
210
+ odd: odd.odd,
211
+ }, odd.dropped), entry);
212
+ });
213
+ return { ok: true, version, worktrees, error: null };
214
+ }
215
+
216
+ function findEntry(worktrees, predicate) {
217
+ return worktrees.find(predicate) || null;
218
+ }
219
+
220
+ function findByBranch(repoRoot, branch) {
221
+ const read = readRegistry(repoRoot);
222
+ if (!read.ok) return null;
223
+ return findEntry(read.worktrees, (entry) => entry.branch === branch);
224
+ }
225
+
226
+ function findByPath(repoRoot, target) {
227
+ const read = readRegistry(repoRoot);
228
+ if (!read.ok) return null;
229
+ // A relative key matches nothing: registry paths are absolute, and
230
+ // resolving either side against the process cwd would name a checkout
231
+ // relative to wherever the caller happens to run, not the repository.
232
+ if (typeof target !== 'string' || !path.isAbsolute(target)) return null;
233
+ const resolved = path.resolve(target);
234
+ return findEntry(read.worktrees, (entry) => typeof entry.path === 'string'
235
+ && path.isAbsolute(entry.path)
236
+ && path.resolve(entry.path) === resolved);
237
+ }
238
+
239
+ function findByThread(repoRoot, thread) {
240
+ const read = readRegistry(repoRoot);
241
+ if (!read.ok) return null;
242
+ return findEntry(read.worktrees, (entry) => entry.thread === thread);
243
+ }
244
+
245
+ // Acquire the exclusive lock file beside the registry, run fn, release.
246
+ // Bounded wait (timeoutMs) with stale-lock recovery (staleMs): a lock
247
+ // file older than staleMs is treated as orphaned and removed. Throws on
248
+ // timeout so a stuck holder fails loudly instead of hanging forever.
249
+ function withRegistryLock(repoRoot, fn, opts = {}) {
250
+ const file = lockPath(repoRoot);
251
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
252
+ const pollMs = opts.pollIntervalMs ?? DEFAULT_POLL_MS;
253
+ const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
254
+ const deadline = Date.now() + timeoutMs;
255
+ let fd = null;
256
+ while (fd === null) {
257
+ try {
258
+ fd = fs.openSync(file, 'wx', 0o644);
259
+ try {
260
+ fs.writeSync(fd, String(process.pid));
261
+ } catch {
262
+ // Best effort owner hint; the lock itself is what matters.
263
+ }
264
+ } catch (err) {
265
+ if (!err || err.code !== 'EEXIST') throw err;
266
+ if (isStaleLock(file, staleMs)) {
267
+ try {
268
+ fs.unlinkSync(file);
269
+ } catch {
270
+ // Lost a race with another recoverer; retry acquisition.
271
+ }
272
+ continue;
273
+ }
274
+ if (Date.now() >= deadline) {
275
+ throw new Error(`timed out after ${timeoutMs}ms waiting for registry lock ${file}`);
276
+ }
277
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(pollMs, deadline - Date.now()));
278
+ }
279
+ }
280
+ try {
281
+ return fn();
282
+ } finally {
283
+ try {
284
+ fs.closeSync(fd);
285
+ } catch {
286
+ // Releasing must not mask the callback result.
287
+ }
288
+ try {
289
+ fs.unlinkSync(file);
290
+ } catch {
291
+ // Another stale recovery may have removed it first.
292
+ }
293
+ }
294
+ }
295
+
296
+ function isStaleLock(file, staleMs) {
297
+ let stat;
298
+ try {
299
+ stat = fs.statSync(file);
300
+ } catch {
301
+ return false;
302
+ }
303
+ return Date.now() - stat.mtimeMs > staleMs;
304
+ }
305
+
306
+ function backupCorruptRegistry(file) {
307
+ // Preserve the previous bytes beside the registry before a malformed
308
+ // file is replaced. Best-effort and never fatal: a missing or
309
+ // unreadable file, or a failed copy, simply yields no backup.
310
+ // The backup name carries an unpredictable suffix and is created
311
+ // exclusively (`wx`) so a planted symlink or file at a predictable
312
+ // name is never followed or overwritten; on collision a new name is
313
+ // retried a bounded number of times, then the operation continues
314
+ // WITHOUT a backup (the caller reports that clearly).
315
+ let raw;
316
+ try {
317
+ raw = fs.readFileSync(file);
318
+ } catch {
319
+ return null;
320
+ }
321
+ for (let attempt = 0; attempt < 10; attempt += 1) {
322
+ let suffix;
323
+ try {
324
+ suffix = crypto.randomBytes(8).toString('hex');
325
+ } catch {
326
+ return null;
327
+ }
328
+ const backup = `${file}.corrupt-${Date.now()}-${suffix}`;
329
+ try {
330
+ fs.writeFileSync(backup, raw, { flag: 'wx' });
331
+ return backup;
332
+ } catch (err) {
333
+ if (err && err.code === 'EEXIST') continue;
334
+ // Best-effort: a failed backup write never leaves litter behind.
335
+ try {
336
+ fs.unlinkSync(backup);
337
+ } catch {
338
+ // Either never created or already gone; nothing to clean.
339
+ }
340
+ return null;
341
+ }
342
+ }
343
+ return null;
344
+ }
345
+
346
+ function validateEntry(entry) {
347
+ if (typeof entry !== 'object' || entry === null) return 'entry must be an object';
348
+ if (typeof entry.branch !== 'string' || entry.branch === '') return 'entry.branch is required';
349
+ if (typeof entry.path !== 'string' || entry.path === '') return 'entry.path is required';
350
+ if (typeof entry.thread !== 'string' || entry.thread === '') return 'entry.thread is required';
351
+ if (!Array.isArray(entry.issues)) return 'entry.issues must be an array (possibly empty)';
352
+ // S8: one document or none. A legacy array is tolerated here (it collapses
353
+ // in storeLocked and the drop is reported); anything else is refused.
354
+ if (entry.odd !== undefined && entry.odd !== null
355
+ && typeof entry.odd !== 'string' && !Array.isArray(entry.odd)) {
356
+ return 'entry.odd must be a single odd document path (or null)';
357
+ }
358
+ return null;
359
+ }
360
+
361
+ // Atomically replace the registry file: write a sibling temp file
362
+ // exclusively (`wx`) with an unpredictable suffix, then rename over the
363
+ // target. A pre-existing name is never followed or reused: on collision
364
+ // a new name is retried a bounded number of times, then the write is
365
+ // refused with a clear error. The temp file never survives the call.
366
+ function writeRegistryAtomic(file, doc) {
367
+ const payload = `${JSON.stringify({ ...doc, worktrees: rowsForWrite(doc.worktrees) }, null, 2)}\n`;
368
+ let tmp = null;
369
+ let wrote = false;
370
+ for (let attempt = 0; attempt < 10; attempt += 1) {
371
+ let suffix;
372
+ try {
373
+ suffix = crypto.randomBytes(8).toString('hex');
374
+ } catch (err) {
375
+ throw new Error(`cannot write registry ${file}: cannot generate a temporary file name: ${(err && err.message) || err}`);
376
+ }
377
+ tmp = `${file}.tmp.${process.pid}.${Date.now()}.${suffix}`;
378
+ try {
379
+ fs.writeFileSync(tmp, payload, { flag: 'wx' });
380
+ wrote = true;
381
+ break;
382
+ } catch (err) {
383
+ // A non-EEXIST failure may still have created the temp file
384
+ // (e.g. ENOSPC mid-write): unlink it best-effort so a failed
385
+ // write never leaves litter behind, then rethrow (never success).
386
+ const failedTmp = tmp;
387
+ tmp = null;
388
+ if (err && err.code === 'EEXIST') continue;
389
+ if (failedTmp) {
390
+ try {
391
+ fs.unlinkSync(failedTmp);
392
+ } catch {
393
+ // Best effort: the rethrow below is what matters.
394
+ }
395
+ }
396
+ throw err;
397
+ }
398
+ }
399
+ if (!wrote || !tmp) {
400
+ throw new Error(`cannot write registry ${file}: could not create a unique temporary file after 10 attempts; refusing to follow or reuse an existing path`);
401
+ }
402
+ try {
403
+ fs.renameSync(tmp, file);
404
+ // Our own write must be visible to the next read immediately, not after
405
+ // the caches notice a new inode.
406
+ readCache.delete(file);
407
+ } finally {
408
+ try {
409
+ fs.unlinkSync(tmp);
410
+ } catch {
411
+ // Either renamed (gone) or never created; nothing to clean.
412
+ }
413
+ }
414
+ }
415
+
416
+ function upsertWorktree(repoRoot, entry, opts = {}) {
417
+ const invalid = validateEntry(entry);
418
+ if (invalid) return { ok: false, error: invalid };
419
+ return withRegistryLock(repoRoot, () => storeLocked(repoRoot, entry, 'upsert'), opts);
420
+ }
421
+
422
+ // Always-append variant of the upsert: the incoming entry is recorded
423
+ // WITHOUT replacing any row that already carries its branch. The create
424
+ // path uses this when a hand-written row with the same branch points at
425
+ // a DIFFERENT live checkout, so that checkout keeps its registration and
426
+ // the new checkout is recorded alongside it. Malformed-file handling
427
+ // (replace, never merge, with the same best-effort backup) matches the
428
+ // upsert exactly.
429
+ function appendWorktree(repoRoot, entry, opts = {}) {
430
+ const invalid = validateEntry(entry);
431
+ if (invalid) return { ok: false, error: invalid };
432
+ return withRegistryLock(repoRoot, () => storeLocked(repoRoot, entry, 'append'), opts);
433
+ }
434
+
435
+ function storeLocked(repoRoot, entry, mode) {
436
+ const file = registryPath(repoRoot);
437
+ const read = readRegistry(repoRoot);
438
+ // A malformed file is replaced, never merged: the incoming entry is
439
+ // the only trustworthy data. Reads already reported the damage. The
440
+ // previous bytes are preserved beside the registry first so the
441
+ // damage stays inspectable.
442
+ const backup = read.ok ? null : backupCorruptRegistry(file);
443
+ const worktrees = read.ok ? read.worktrees.slice() : [];
444
+ const now = new Date().toISOString();
445
+ const index = mode === 'upsert'
446
+ ? worktrees.findIndex((item) => item.branch === entry.branch)
447
+ : -1;
448
+ const previous = index >= 0 ? worktrees[index] : null;
449
+ // S8/S1: the row holds one document or none. A SUPPLIED legacy list
450
+ // collapses to its first entry and names the rest in `dropped` (S8b); an
451
+ // OMITTED association leaves the stored value exactly as it is on disk —
452
+ // a legacy list included — because a create is not the operation that
453
+ // discards a document it never looked at.
454
+ const oddSupplied = entry.odd !== undefined;
455
+ const keptStored = !oddSupplied && previous !== null && previous.storedEntry !== undefined;
456
+ const odd = normalizeOddDoc(oddSupplied
457
+ ? entry.odd
458
+ : (previous ? previous.odd : undefined));
459
+ // The row this write replaces is also the only row whose fields are
460
+ // rewritten: its unknown fields (a field this version does not model) are
461
+ // carried over, so an addressed write never sheds data either.
462
+ const base = previous && previous.storedEntry !== undefined ? previous.storedEntry : {};
463
+ const stored = {
464
+ ...base,
465
+ branch: entry.branch,
466
+ path: entry.path,
467
+ thread: entry.thread,
468
+ issues: entry.issues.slice(),
469
+ odd: keptStored ? previous.storedEntry.odd : odd.odd,
470
+ updatedAt: now,
471
+ };
472
+ // S10: a discovery-registered row has no `createdAt` on purpose (the
473
+ // registration time is not the checkout's birth date); a later update
474
+ // must not synthesize one. Only a brand-new create records `createdAt`,
475
+ // and a recorded one is preserved.
476
+ if (previous) {
477
+ if (previous.createdAt !== undefined) stored.createdAt = previous.createdAt;
478
+ if (previous.registeredAt !== undefined) stored.registeredAt = previous.registeredAt;
479
+ } else {
480
+ stored.createdAt = now;
481
+ }
482
+ if (index >= 0) worktrees[index] = stored;
483
+ else worktrees.push(stored);
484
+ writeRegistryAtomic(file, { version: REGISTRY_VERSION, worktrees });
485
+ // Only the row this write replaced can have lost an association; every
486
+ // other row in `read.worktrees` is carried through verbatim.
487
+ const result = reportDroppedOdds(
488
+ { ok: true, entry: stored },
489
+ keptStored || previous === null ? [] : [previous],
490
+ keptStored ? [] : odd.dropped,
491
+ );
492
+ // Name the backup so the caller can report it; absent when the
493
+ // previous registry was valid (or there was nothing to preserve).
494
+ // When the previous file was malformed but no backup could be
495
+ // written, the operation still succeeds (best-effort, never fatal)
496
+ // but the failure is reported clearly via backupError and never
497
+ // clobbers anything.
498
+ if (backup) result.backup = backup;
499
+ else if (!read.ok) result.backupError = `could not preserve malformed registry ${file}: backup unavailable; the registry was replaced without a backup`;
500
+ return result;
501
+ }
502
+
503
+ // Update-in-place for the create-or-update tool (S27/S31): overwrite
504
+ // ONLY the supplied fields of the row identified by `key` (branch or
505
+ // absolute path, the same matching `removeWorktree` uses) and leave
506
+ // every omitted field untouched — so a supplied `issues` (including
507
+ // `[]`) and a supplied `odd` (including `null`) REPLACE the stored value
508
+ // while an omitted field stays as it was. `odd` is ONE document path (S8):
509
+ // a supplied legacy list collapses to its first entry and the dropped
510
+ // entries are reported. The branch and the path are never rewritten here.
511
+ // Returns {ok, entry} or {ok:false, error}; never throws on a readable
512
+ // registry, and writes atomically under the lock.
513
+ function updateWorktree(repoRoot, key, patch = {}, opts = {}) {
514
+ const hasIssues = patch && Object.prototype.hasOwnProperty.call(patch, 'issues');
515
+ const hasOdd = patch && Object.prototype.hasOwnProperty.call(patch, 'odd');
516
+ if (hasIssues && !Array.isArray(patch.issues)) {
517
+ return { ok: false, error: 'patch.issues must be an array (possibly empty)' };
518
+ }
519
+ if (hasOdd && patch.odd !== null && typeof patch.odd !== 'string' && !Array.isArray(patch.odd)) {
520
+ return { ok: false, error: 'patch.odd must be a single odd document path (string) or null' };
521
+ }
522
+ return withRegistryLock(repoRoot, () => {
523
+ const read = readRegistry(repoRoot);
524
+ if (!read.ok) return { ok: false, error: read.error };
525
+ const index = read.worktrees.findIndex((entry) => matchesKey(entry, key));
526
+ if (index < 0) return { ok: false, error: `no registered worktree for ${key}` };
527
+ const worktrees = read.worktrees.slice();
528
+ const current = worktrees[index];
529
+ const odd = normalizeOddDoc(hasOdd ? patch.odd : current.odd);
530
+ // Unknown fields the row already carries survive an addressed edit too:
531
+ // this version rewrites the fields it models, never the rest.
532
+ const base = current.storedEntry !== undefined ? current.storedEntry : {};
533
+ const stored = {
534
+ ...base,
535
+ branch: current.branch,
536
+ path: current.path,
537
+ thread: current.thread,
538
+ issues: hasIssues ? patch.issues.slice() : (Array.isArray(current.issues) ? current.issues.slice() : []),
539
+ odd: odd.odd,
540
+ updatedAt: new Date().toISOString(),
541
+ };
542
+ // S10: preserve whatever creation provenance the row already carries —
543
+ // including the deliberate absence a discovery row records, so an edit
544
+ // never turns a registration time into a pretended birth date.
545
+ if (current.createdAt !== undefined) stored.createdAt = current.createdAt;
546
+ if (current.registeredAt !== undefined) stored.registeredAt = current.registeredAt;
547
+ worktrees[index] = stored;
548
+ writeRegistryAtomic(registryPath(repoRoot), { version: read.version, worktrees });
549
+ return reportDroppedOdds({ ok: true, entry: stored }, [current], odd.dropped);
550
+ }, opts);
551
+ }
552
+
553
+ // Startup discovery registration (S28/S10): record every live checkout the
554
+ // registry does not already own, without touching any existing row. `live`
555
+ // is git's listing. The CANONICAL PRINCIPAL is registered too — `principalPath`
556
+ // is its resolved identity, NOT an exclusion — once and idempotently, as the
557
+ // FIRST metadata row, so the principal is always listed and its stored
558
+ // associations survive. Discovery rows deliberately carry `registeredAt`
559
+ // instead of `createdAt`: the registration time is not the checkout's
560
+ // filesystem birth date, and an unknown birth stays unknown. Matching is by
561
+ // resolved path identity, so a registered row reached through a symlink
562
+ // still counts as owned. Returns {ok, registered:[{branch,path}]}; never
563
+ // throws on a readable registry — failures report {ok:false, error}.
564
+ function registerMissingWorktrees(repoRoot, live, principalPath, opts = {}) {
565
+ return withRegistryLock(repoRoot, () => {
566
+ const read = readRegistry(repoRoot);
567
+ if (!read.ok) return { ok: false, error: read.error, registered: [] };
568
+ const owned = new Set();
569
+ for (const row of read.worktrees) {
570
+ if (!row || typeof row.path !== 'string' || !path.isAbsolute(row.path)) continue;
571
+ try {
572
+ owned.add(path.resolve(row.path));
573
+ } catch {
574
+ // An unresolvable row owns nothing.
575
+ }
576
+ try {
577
+ owned.add(fs.realpathSync(row.path));
578
+ } catch {
579
+ // A missing checkout contributes only its spelling.
580
+ }
581
+ }
582
+ const now = new Date().toISOString();
583
+ const worktrees = read.worktrees.slice();
584
+ const registered = [];
585
+
586
+ // Resolve one live listing entry to the absolute path git names plus
587
+ // its resolved identity and branch, or null when it is not a live
588
+ // directory checkout. Never resolves a relative path against the
589
+ // process cwd.
590
+ const resolveLive = (entry) => {
591
+ if (!entry || typeof entry.path !== 'string' || entry.path.length === 0) return null;
592
+ if (!entry.branch || typeof entry.branch !== 'string' || entry.branch.length === 0) return null;
593
+ let absolute;
594
+ try {
595
+ absolute = path.isAbsolute(entry.path) ? path.resolve(entry.path) : null;
596
+ } catch {
597
+ return null;
598
+ }
599
+ if (absolute === null) return null;
600
+ try {
601
+ if (!fs.statSync(absolute).isDirectory()) return null;
602
+ } catch {
603
+ return null;
604
+ }
605
+ let real = null;
606
+ try {
607
+ real = fs.realpathSync(absolute);
608
+ } catch {
609
+ real = null;
610
+ }
611
+ return { absolute, real, id: real !== null ? real : absolute, branch: entry.branch };
612
+ };
613
+
614
+ // S10: register the canonical principal FIRST and only once. An existing
615
+ // row reaching the principal's resolved identity (or already claiming its
616
+ // branch) means it is owned; nothing is written. Otherwise the principal
617
+ // row is inserted at index 0, so it is the first metadata row.
618
+ const principalId = typeof principalPath === 'string' && principalPath.length > 0 ? principalPath : null;
619
+ if (principalId !== null) {
620
+ for (const entry of live || []) {
621
+ const info = resolveLive(entry);
622
+ if (!info || info.id !== principalId) continue;
623
+ if (owned.has(info.absolute) || (info.real !== null && owned.has(info.real))) break;
624
+ if (worktrees.some((row) => row.branch === info.branch)) break;
625
+ const stored = {
626
+ branch: info.branch,
627
+ path: info.absolute,
628
+ thread: 'principal',
629
+ issues: [],
630
+ odd: null,
631
+ registeredAt: now,
632
+ updatedAt: now,
633
+ };
634
+ worktrees.unshift(stored);
635
+ owned.add(info.absolute);
636
+ if (info.real !== null) owned.add(info.real);
637
+ registered.push({ branch: stored.branch, path: stored.path });
638
+ break;
639
+ }
640
+ }
641
+
642
+ for (const entry of live || []) {
643
+ const info = resolveLive(entry);
644
+ if (!info) continue;
645
+ if (owned.has(info.absolute) || (info.real !== null && owned.has(info.real))) continue;
646
+ if (worktrees.some((row) => row.branch === info.branch)) continue;
647
+ const stored = {
648
+ branch: info.branch,
649
+ path: info.absolute,
650
+ thread: 'discovery',
651
+ issues: [],
652
+ odd: null,
653
+ registeredAt: now,
654
+ updatedAt: now,
655
+ };
656
+ worktrees.push(stored);
657
+ owned.add(info.absolute);
658
+ if (info.real !== null) owned.add(info.real);
659
+ registered.push({ branch: stored.branch, path: stored.path });
660
+ }
661
+ if (registered.length === 0) return { ok: true, registered: [] };
662
+ writeRegistryAtomic(registryPath(repoRoot), { version: read.version, worktrees });
663
+ // Discovery only ever appends rows; it addresses none of the stored ones,
664
+ // so it can report no loss.
665
+ return { ok: true, registered };
666
+ }, opts);
667
+ }
668
+
669
+ function matchesKey(entry, key) {
670
+ if (entry.branch === key) return true;
671
+ // Path keys match by exact absolute path equality only. A branch name
672
+ // (or any other relative string) must never be resolved against the
673
+ // process cwd as a path key: from a different cwd that would match a
674
+ // checkout the caller never named.
675
+ if (typeof key !== 'string' || !path.isAbsolute(key)) return false;
676
+ if (!entry || typeof entry.path !== 'string' || !path.isAbsolute(entry.path)) return false;
677
+ try {
678
+ return path.resolve(entry.path) === path.resolve(key);
679
+ } catch {
680
+ return false;
681
+ }
682
+ }
683
+
684
+ function removeWorktree(repoRoot, key, opts = {}) {
685
+ return withRegistryLock(repoRoot, () => {
686
+ const read = readRegistry(repoRoot);
687
+ if (!read.ok) return { ok: false, error: read.error };
688
+ const index = read.worktrees.findIndex((entry) => matchesKey(entry, key));
689
+ if (index < 0) return { ok: false, error: `no registered worktree for ${key}` };
690
+ const worktrees = read.worktrees.slice();
691
+ worktrees.splice(index, 1);
692
+ writeRegistryAtomic(registryPath(repoRoot), { version: read.version, worktrees });
693
+ // Removing one row addresses no surviving row: each is written verbatim.
694
+ return { ok: true };
695
+ }, opts);
696
+ }
697
+
698
+ // Every identity an absolute path can present: its resolved form, its
699
+ // realpath when the filesystem still resolves it, and — via an explicit
700
+ // readlink walk that never touches the process cwd — every referent of a
701
+ // symlink chain, so a DANGLING link left behind by a removed checkout
702
+ // still names the checkout it used to reach. All inputs are absolute, so
703
+ // path.resolve here never consults the process cwd.
704
+ function pathIdentities(absolutePath) {
705
+ const ids = new Set();
706
+ let resolved;
707
+ try {
708
+ resolved = path.resolve(absolutePath);
709
+ } catch {
710
+ return ids;
711
+ }
712
+ ids.add(resolved);
713
+ try {
714
+ ids.add(fs.realpathSync(absolutePath));
715
+ } catch {
716
+ // Unresolvable (removed checkout, dangling link): the readlink walk
717
+ // below still recovers the referent.
718
+ }
719
+ let current = resolved;
720
+ for (let depth = 0; depth < 10; depth += 1) {
721
+ let stat;
722
+ try {
723
+ stat = fs.lstatSync(current);
724
+ } catch {
725
+ break;
726
+ }
727
+ if (!stat.isSymbolicLink()) break;
728
+ let link;
729
+ try {
730
+ link = fs.readlinkSync(current);
731
+ } catch {
732
+ break;
733
+ }
734
+ current = path.resolve(path.dirname(current), link);
735
+ ids.add(current);
736
+ try {
737
+ ids.add(fs.realpathSync(current));
738
+ } catch {
739
+ // Keep walking: a deeper link may still resolve.
740
+ }
741
+ }
742
+ return ids;
743
+ }
744
+
745
+ // Path-identity removal for the close flow: drops every entry that reaches
746
+ // the removed checkout — the same absolute value the close resolved,
747
+ // verified, and removed, a realpath-equal row, or a row whose path is a
748
+ // symlink (live or dangling) to it. A non-absolute key is refused, no
749
+ // branch comparison happens here, and a relative row can never match, so
750
+ // duplicate branches or a branch that equals another entry's path can
751
+ // never divert the drop. Other callers keep using `removeWorktree` with
752
+ // its branch behaviour.
753
+ function removeWorktreeByPath(repoRoot, absolutePath, opts = {}) {
754
+ if (typeof absolutePath !== 'string' || !path.isAbsolute(absolutePath)) {
755
+ return { ok: false, error: `refusing to drop registry entry: path ${JSON.stringify(absolutePath)} is not absolute` };
756
+ }
757
+ const targetIds = pathIdentities(absolutePath);
758
+ return withRegistryLock(repoRoot, () => {
759
+ const read = readRegistry(repoRoot);
760
+ if (!read.ok) return { ok: false, error: read.error };
761
+ // Drop EVERY row reaching the target: a hand-edited registry may hold
762
+ // duplicate rows sharing one path, or a row whose path is a symlink
763
+ // to the removed checkout, and leaving one behind would keep a stale
764
+ // entry after the close.
765
+ const worktrees = read.worktrees.filter((entry) => {
766
+ if (typeof entry.path !== 'string' || !path.isAbsolute(entry.path)) return true;
767
+ for (const id of pathIdentities(entry.path)) {
768
+ if (targetIds.has(id)) return false;
769
+ }
770
+ return true;
771
+ });
772
+ if (worktrees.length === read.worktrees.length) return { ok: false, error: `no registered worktree for ${absolutePath}` };
773
+ writeRegistryAtomic(registryPath(repoRoot), { version: read.version, worktrees });
774
+ // The close addresses the row it drops; every surviving row is verbatim.
775
+ return { ok: true };
776
+ }, opts);
777
+ }
778
+
779
+ // Row-identity removal for the create-path stale cleanup: drops the rows
780
+ // whose (branch, path) pair exactly equals the selected entry — the ONE
781
+ // row the caller identified as its own. Never matches by branch alone
782
+ // and never resolves paths, so a live entry sharing the branch (or a
783
+ // path that resolves the same) is never diverted onto.
784
+ function removeWorktreeExact(repoRoot, branch, entryPath, opts = {}) {
785
+ return withRegistryLock(repoRoot, () => {
786
+ const read = readRegistry(repoRoot);
787
+ if (!read.ok) return { ok: false, error: read.error };
788
+ const worktrees = read.worktrees.filter((entry) => !(entry.branch === branch && entry.path === entryPath));
789
+ if (worktrees.length === read.worktrees.length) return { ok: false, error: `no registered worktree for ${branch}` };
790
+ writeRegistryAtomic(registryPath(repoRoot), { version: read.version, worktrees });
791
+ return { ok: true };
792
+ }, opts);
793
+ }
794
+
795
+ // Batch prune for the list self-heal (S16): drops every row whose
796
+ // absolute path is one of `absolutePaths` in ONE locked atomic write.
797
+ // Only absolute paths participate on either side — a relative row can
798
+ // never name a checkout without resolving against the process cwd, so it
799
+ // is never matched here and callers must not pass one. Returns
800
+ // {ok, pruned} where pruned lists the dropped entry paths; a write
801
+ // failure throws so the caller can still exclude the rows from its
802
+ // listing while reporting the failure.
803
+ function pruneWorktrees(repoRoot, absolutePaths, opts = {}) {
804
+ const targets = new Set();
805
+ for (const candidate of absolutePaths || []) {
806
+ if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) continue;
807
+ try {
808
+ targets.add(path.resolve(candidate));
809
+ } catch {
810
+ // An unresolvable candidate matches nothing.
811
+ }
812
+ }
813
+ if (targets.size === 0) return { ok: true, pruned: [] };
814
+ return withRegistryLock(repoRoot, () => {
815
+ const read = readRegistry(repoRoot);
816
+ if (!read.ok) return { ok: false, error: read.error, pruned: [] };
817
+ const pruned = [];
818
+ const worktrees = read.worktrees.filter((entry) => {
819
+ if (typeof entry.path !== 'string' || !path.isAbsolute(entry.path)) return true;
820
+ let resolved;
821
+ try {
822
+ resolved = path.resolve(entry.path);
823
+ } catch {
824
+ return true;
825
+ }
826
+ if (targets.has(resolved)) {
827
+ pruned.push(entry.path);
828
+ return false;
829
+ }
830
+ return true;
831
+ });
832
+ if (pruned.length === 0) return { ok: true, pruned: [] };
833
+ writeRegistryAtomic(registryPath(repoRoot), { version: read.version, worktrees });
834
+ // The pruned rows are gone; every row that stays is written verbatim.
835
+ return { ok: true, pruned };
836
+ }, opts);
837
+ }
838
+
839
+ // Associate ONE odd document with a worktree (S8). A worktree carries at
840
+ // most one, so this is a SELECT, not an append: associating another document
841
+ // REPLACES the stored one, and `replaced` names the document that lost the
842
+ // association so the caller can report it rather than letting it disappear.
843
+ // A legacy array in the row collapses here too (`dropped`).
844
+ function addOddDoc(repoRoot, key, relPath, opts = {}) {
845
+ if (typeof relPath !== 'string' || relPath === '') return { ok: false, error: 'relPath is required' };
846
+ return withRegistryLock(repoRoot, () => {
847
+ const read = readRegistry(repoRoot);
848
+ if (!read.ok) return { ok: false, error: read.error };
849
+ const index = read.worktrees.findIndex((entry) => matchesKey(entry, key));
850
+ if (index < 0) return { ok: false, error: `no registered worktree for ${key}` };
851
+ const worktrees = read.worktrees.slice();
852
+ // The row as it was read: the only row this selection addresses, so the
853
+ // only one whose collapsed entries are reported.
854
+ const addressed = worktrees[index];
855
+ const previous = normalizeOddDoc(addressed.odd);
856
+ const entry = { ...addressed, odd: relPath, updatedAt: new Date().toISOString() };
857
+ worktrees[index] = entry;
858
+ writeRegistryAtomic(registryPath(repoRoot), { version: read.version, worktrees });
859
+ return reportDroppedOdds({
860
+ ok: true,
861
+ entry,
862
+ added: previous.odd !== relPath,
863
+ replaced: previous.odd !== null && previous.odd !== relPath ? previous.odd : null,
864
+ }, [addressed], previous.dropped);
865
+ }, opts);
866
+ }
867
+
868
+ module.exports = {
869
+ REGISTRY_VERSION,
870
+ normalizeOddDoc,
871
+ registryPath,
872
+ readRegistry,
873
+ findByBranch,
874
+ findByPath,
875
+ findByThread,
876
+ upsertWorktree,
877
+ appendWorktree,
878
+ removeWorktree,
879
+ removeWorktreeByPath,
880
+ removeWorktreeExact,
881
+ pruneWorktrees,
882
+ addOddDoc,
883
+ updateWorktree,
884
+ registerMissingWorktrees,
885
+ withRegistryLock,
886
+ };