pi-odd-worktree 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
|
@@ -0,0 +1,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
|
+
};
|