cinatra 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.
@@ -0,0 +1,623 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Pure logic + a best-effort file lock for the per-branch clone-on-demand
3
+ // system. A "clone" is a full deep-fork dev environment (separate Postgres
4
+ // database `cinatra_clone_<slug>`, dedicated ports). Clones are created
5
+ // DORMANT and started on demand by later slices — so they reserve a port
6
+ // band while NOTHING is listening. `findFreePort()` (which only sees live
7
+ // sockets) cannot allocate clone ports; this registry is the source of
8
+ // truth instead.
9
+ //
10
+ // Registry file: ~/.cinatra/clones.json
11
+ // { "version": 1, "clones": { "<slug>": { index, nextjsPort, wayflowPort,
12
+ // dbName, worktreePath, state, createdAt } } }
13
+ //
14
+ // Public surface (also re-exported as `__test` for hermetic vitest):
15
+ // - constants: CLONE_NEXTJS_PORT_BASE, CLONE_WAYFLOW_PORT_BASE,
16
+ // CLONE_MAX_INDEX, SEED_DB_NAME
17
+ // - slug/name/port: cloneSlugFromBranch, cloneDbName, portsForIndex,
18
+ // isProtectedDbName, isValidSlug
19
+ // - registry I/O: defaultRegistryPath, readRegistry, requireUsableRegistry,
20
+ // writeRegistry
21
+ // - lock: withRegistryLock
22
+ // - slot ops (pure): allocateSlot, markSlotReady, releaseSlot, getClone,
23
+ // listClones
24
+ //
25
+ // Registry safety invariants:
26
+ // - readRegistry distinguishes missing/ok/malformed; mutating callers
27
+ // go through requireUsableRegistry which REFUSES malformed (the bad
28
+ // file is left in place for manual repair, never auto-reset).
29
+ // - withRegistryLock serialises read→allocate→write so two concurrent
30
+ // `setup clone` runs cannot both grab index 0.
31
+ // - allocateSlot throws if a slug already maps to a DIFFERENT worktree
32
+ // (idempotent only when the worktreePath matches).
33
+ // ---------------------------------------------------------------------------
34
+
35
+ import {
36
+ existsSync,
37
+ mkdirSync,
38
+ openSync,
39
+ closeSync,
40
+ readFileSync,
41
+ realpathSync,
42
+ writeFileSync,
43
+ renameSync,
44
+ unlinkSync,
45
+ linkSync,
46
+ statSync,
47
+ fstatSync,
48
+ } from "node:fs";
49
+ import os from "node:os";
50
+ import path from "node:path";
51
+ import process from "node:process";
52
+
53
+ // --- constants -------------------------------------------------------------
54
+
55
+ export const CLONE_NEXTJS_PORT_BASE = 3100;
56
+ export const CLONE_WAYFLOW_PORT_BASE = 3200;
57
+ export const CLONE_MAX_INDEX = 19; // indices 0..19 → 20 clone slots
58
+ export const SEED_DB_NAME = "cinatra_seed";
59
+
60
+ const REGISTRY_VERSION = 1;
61
+ const LOCK_STALE_MS = 60_000; // steal a lock whose file mtime is older than this
62
+ const LOCK_RETRY_MS = 100;
63
+ const LOCK_TIMEOUT_MS = 10_000;
64
+
65
+ // --- slug / name / port ----------------------------------------------------
66
+
67
+ /**
68
+ * Derive a clone slug from a git branch name. Mirrors `sanitizeBranchSlug` in
69
+ * index.mjs but ALSO strips a leading `worktree-` segment so worktree branches
70
+ * collapse to their clone-specific slug.
71
+ * Returns "" when nothing usable remains.
72
+ */
73
+ export function cloneSlugFromBranch(branch) {
74
+ let candidate = String(branch ?? "").trim();
75
+ if (!candidate) return "";
76
+ if (candidate.startsWith("cinatra-ai-")) {
77
+ candidate = candidate.slice("cinatra-ai-".length);
78
+ } else if (candidate.startsWith("worktree-")) {
79
+ candidate = candidate.slice("worktree-".length);
80
+ }
81
+ return candidate
82
+ .toLowerCase()
83
+ .replace(/[^a-z0-9]+/g, "-")
84
+ .replace(/^-+|-+$/g, "")
85
+ .slice(0, 30);
86
+ }
87
+
88
+ /** A slug is valid iff it matches the same shape `cinatra setup branch` enforces. */
89
+ export function isValidSlug(slug) {
90
+ return typeof slug === "string" && /^[a-z0-9][a-z0-9-]{0,29}$/.test(slug);
91
+ }
92
+
93
+ /** Postgres database name for a clone. Dashes → underscores (pg identifier rules). */
94
+ export function cloneDbName(slug) {
95
+ if (!isValidSlug(slug)) {
96
+ throw new Error(`Invalid clone slug "${slug}". Must match /^[a-z0-9][a-z0-9-]{0,29}$/.`);
97
+ }
98
+ return `cinatra_clone_${slug.replace(/-/g, "_")}`;
99
+ }
100
+
101
+ // A clone database name is EXACTLY `cinatra_clone_` + a slug transformed by
102
+ // `cloneDbName` (dashes → underscores). `isValidSlug` constrains slugs to
103
+ // /^[a-z0-9][a-z0-9-]{0,29}$/, so the transformed suffix is
104
+ // /^[a-z0-9][a-z0-9_]{0,29}$/. The destructive-prune guard must match this
105
+ // EXACT shape — `^cinatra_clone_[a-z0-9_]+$` was too loose (it accepted
106
+ // `cinatra_clone__`, `cinatra_clone__prod`, an over-long suffix, etc.), and
107
+ // this is the last line of defense before `DROP DATABASE`.
108
+ const CLONE_DB_NAME_RE = /^cinatra_clone_[a-z0-9][a-z0-9_]{0,29}$/;
109
+
110
+ /**
111
+ * Hard guard for the destructive `clone prune` path. Returns true for any
112
+ * database name that must NEVER be dropped: the maintenance/app DBs, the
113
+ * seed template, the pg system templates, and — critically — ANY name that
114
+ * is not shaped EXACTLY like a `cloneDbName(slug)` output. So a typo, a
115
+ * corrupted registry entry, or a resolution bug fails closed.
116
+ */
117
+ export function isProtectedDbName(name) {
118
+ if (typeof name !== "string" || name.length === 0) return true;
119
+ const reserved = new Set([
120
+ "postgres",
121
+ "cinatra",
122
+ SEED_DB_NAME,
123
+ "template0",
124
+ "template1",
125
+ ]);
126
+ if (reserved.has(name)) return true;
127
+ // Anything not shaped EXACTLY like a clone DB is protected (fail closed).
128
+ return !CLONE_DB_NAME_RE.test(name);
129
+ }
130
+
131
+ /** { nextjsPort, wayflowPort } for a clone index. Throws outside 0..CLONE_MAX_INDEX. */
132
+ export function portsForIndex(index) {
133
+ if (!Number.isInteger(index) || index < 0 || index > CLONE_MAX_INDEX) {
134
+ throw new Error(`Clone index ${index} out of range (0..${CLONE_MAX_INDEX}).`);
135
+ }
136
+ return {
137
+ nextjsPort: CLONE_NEXTJS_PORT_BASE + index,
138
+ wayflowPort: CLONE_WAYFLOW_PORT_BASE + index,
139
+ };
140
+ }
141
+
142
+ // --- registry file I/O -----------------------------------------------------
143
+
144
+ export function defaultRegistryPath() {
145
+ return path.join(os.homedir(), ".cinatra", "clones.json");
146
+ }
147
+
148
+ function emptyRegistry() {
149
+ return { version: REGISTRY_VERSION, clones: {} };
150
+ }
151
+
152
+ const CLONE_STATES = new Set(["provisioning", "ready"]);
153
+
154
+ // Structural validation of one clone slot. A registry entry that does not
155
+ // match this shape is treated as registry corruption — `readRegistry`
156
+ // classifies the whole file `malformed` so `requireUsableRegistry` refuses to
157
+ // mutate. A shallow `clones`-is-an-object check can let malformed slot values
158
+ // through, after which `allocateSlot` builds `usedIndexes` from raw values and
159
+ // risks duplicate port allocation or runtime crashes.
160
+ function isValidCloneSlot(slug, slot) {
161
+ if (!isValidSlug(slug)) return false;
162
+ if (!slot || typeof slot !== "object" || Array.isArray(slot)) return false;
163
+ const { index, nextjsPort, wayflowPort, dbName, worktreePath, state, createdAt } = slot;
164
+ if (!Number.isInteger(index) || index < 0 || index > CLONE_MAX_INDEX) return false;
165
+ if (nextjsPort !== CLONE_NEXTJS_PORT_BASE + index) return false;
166
+ if (wayflowPort !== CLONE_WAYFLOW_PORT_BASE + index) return false;
167
+ if (dbName !== cloneDbName(slug)) return false;
168
+ if (typeof worktreePath !== "string" || worktreePath.length === 0) return false;
169
+ if (!CLONE_STATES.has(state)) return false;
170
+ if (typeof createdAt !== "string" || createdAt.length === 0) return false;
171
+ return true;
172
+ }
173
+
174
+ // Validate every clone entry AND cross-entry index uniqueness.
175
+ function areRegistryEntriesValid(clones) {
176
+ const seenIndexes = new Set();
177
+ for (const [slug, slot] of Object.entries(clones)) {
178
+ if (!isValidCloneSlot(slug, slot)) return false;
179
+ if (seenIndexes.has(slot.index)) return false; // two slugs claiming one index
180
+ seenIndexes.add(slot.index);
181
+ }
182
+ return true;
183
+ }
184
+
185
+ /**
186
+ * Read the registry file. NEVER throws.
187
+ * Returns { status, registry, raw }:
188
+ * - status "missing" → file absent; registry = fresh empty registry
189
+ * - status "ok" → parsed; registry = the parsed object
190
+ * - status "malformed" → unreadable/invalid JSON/wrong shape; registry = null,
191
+ * raw = the bytes on disk (so callers can preserve them)
192
+ */
193
+ export function readRegistry(filePath) {
194
+ if (!existsSync(filePath)) {
195
+ return { status: "missing", registry: emptyRegistry(), raw: null };
196
+ }
197
+ let raw;
198
+ try {
199
+ raw = readFileSync(filePath, "utf8");
200
+ } catch (err) {
201
+ return { status: "malformed", registry: null, raw: null, error: err };
202
+ }
203
+ let parsed;
204
+ try {
205
+ parsed = JSON.parse(raw);
206
+ } catch (err) {
207
+ return { status: "malformed", registry: null, raw, error: err };
208
+ }
209
+ if (
210
+ !parsed ||
211
+ typeof parsed !== "object" ||
212
+ typeof parsed.clones !== "object" ||
213
+ parsed.clones === null ||
214
+ Array.isArray(parsed.clones)
215
+ ) {
216
+ return { status: "malformed", registry: null, raw };
217
+ }
218
+ // Deep-validate every clone entry — a syntactically-valid JSON file can
219
+ // still carry structurally-invalid slots (string index, missing fields,
220
+ // mismatched dbName, duplicate indexes). Those must be classified malformed,
221
+ // not silently reused.
222
+ if (!areRegistryEntriesValid(parsed.clones)) {
223
+ return { status: "malformed", registry: null, raw };
224
+ }
225
+ if (typeof parsed.version !== "number") {
226
+ parsed.version = REGISTRY_VERSION;
227
+ }
228
+ return { status: "ok", registry: parsed, raw };
229
+ }
230
+
231
+ /**
232
+ * Read the registry for a MUTATING command. Throws on a malformed registry
233
+ * because silently resetting it can hand out a port band that an existing
234
+ * dormant clone already owns). The bad file is left untouched on disk.
235
+ */
236
+ export function requireUsableRegistry(filePath) {
237
+ const result = readRegistry(filePath);
238
+ if (result.status === "malformed") {
239
+ throw new Error(
240
+ `Clone registry at ${filePath} is malformed and was NOT modified. ` +
241
+ `Inspect/repair it by hand (or delete it only if you are sure no dormant ` +
242
+ `clones exist), then retry.`,
243
+ );
244
+ }
245
+ return result.registry;
246
+ }
247
+
248
+ /** Atomic write: temp file in the same dir + rename. Creates ~/.cinatra/ if absent. */
249
+ export function writeRegistry(filePath, data) {
250
+ const dir = path.dirname(filePath);
251
+ mkdirSync(dir, { recursive: true });
252
+ const payload = JSON.stringify({ ...data, version: data.version ?? REGISTRY_VERSION }, null, 2) + "\n";
253
+ const tmp = path.join(dir, `.clones.${process.pid}.${Date.now()}.tmp`);
254
+ writeFileSync(tmp, payload, { mode: 0o600 });
255
+ renameSync(tmp, filePath);
256
+ }
257
+
258
+ // --- file lock -------------------------------------------------------------
259
+
260
+ /**
261
+ * Is the process recorded in a registry-lock file still alive? The lock body
262
+ * is written as `"<pid> <iso>\n"`. A LIVE holder must never be judged stale
263
+ * (it may legitimately be mid-long-operation), so staleness requires BOTH an
264
+ * old mtime AND a dead holder pid. Unreadable / unparsable → treat as "not
265
+ * provably alive" so a corrupt lock can still be reclaimed via the mtime
266
+ * gate.
267
+ */
268
+ function lockHolderAlive(lockPath) {
269
+ let pid = null;
270
+ try {
271
+ const first = readFileSync(lockPath, "utf8").trim().split(/\s+/)[0];
272
+ pid = Number.parseInt(first, 10);
273
+ } catch {
274
+ return false;
275
+ }
276
+ if (!Number.isFinite(pid) || pid <= 0) return false;
277
+ try {
278
+ process.kill(pid, 0);
279
+ return true;
280
+ } catch (err) {
281
+ // EPERM → the process exists but is owned by another user: still alive.
282
+ return err && err.code === "EPERM";
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Run `fn` while holding an exclusive lock on `<filePath>.lock`.
288
+ *
289
+ * temp+rename prevents torn writes but not lost updates — two
290
+ * `setup clone` processes can both read, both allocate index 0, last rename
291
+ * wins. Every read→allocate→write sequence runs inside this lock.
292
+ *
293
+ * Best-effort, single-host: `openSync(..., "wx")` is the mutex; a lock whose
294
+ * file mtime is older than LOCK_STALE_MS is considered abandoned and stolen.
295
+ * `fn` may be async; the lock is always released in `finally`.
296
+ */
297
+ export async function withRegistryLock(filePath, fn) {
298
+ const lockPath = `${filePath}.lock`;
299
+ mkdirSync(path.dirname(lockPath), { recursive: true });
300
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
301
+ let fd = null;
302
+
303
+ while (fd === null) {
304
+ try {
305
+ fd = openSync(lockPath, "wx");
306
+ } catch (err) {
307
+ if (err && err.code === "EEXIST") {
308
+ // Lock held — check for staleness, else wait and retry. Stale
309
+ // requires BOTH an old mtime AND a dead holder pid: a live holder
310
+ // running a long operation must never have its lock stolen.
311
+ let stale = false;
312
+ let staleIno = null;
313
+ try {
314
+ const st = statSync(lockPath);
315
+ staleIno = st.ino;
316
+ const mtimeOld = Date.now() - st.mtimeMs > LOCK_STALE_MS;
317
+ stale = mtimeOld && !lockHolderAlive(lockPath);
318
+ } catch {
319
+ // Lock vanished between openSync and statSync — retry immediately.
320
+ }
321
+ if (stale) {
322
+ // Inode-stable steal gate: only steal if the file at lockPath is
323
+ // STILL the exact inode we judged stale. A fresh holder that
324
+ // acquired between the stat above and now is a NEW file (new
325
+ // inode) — we must NOT rename its lock away. This closes the
326
+ // "rob a live fresh holder" race: a changed (or vanished) inode
327
+ // means back off and let the loop re-contend.
328
+ try {
329
+ if (statSync(lockPath).ino !== staleIno) {
330
+ continue;
331
+ }
332
+ } catch {
333
+ continue;
334
+ }
335
+ // TOCTOU-safe steal: an unconditional `unlinkSync(lockPath)` here
336
+ // can delete a *fresh* lock if the stale holder exited and a new
337
+ // holder acquired between the stat above and the unlink — two
338
+ // processes would then enter the critical section. Instead,
339
+ // atomically rename the exact file we judged stale out of the
340
+ // way; `renameSync` moves a single inode and fails (ENOENT) if
341
+ // it's already gone/rotated. Re-verify staleness on the moved
342
+ // file; if it turned out fresh (we raced a new holder), restore
343
+ // it when the slot is free, then retry the normal acquire.
344
+ const stealPath = `${lockPath}.steal.${process.pid}.${Date.now()}`;
345
+ try {
346
+ renameSync(lockPath, stealPath);
347
+ } catch {
348
+ // Already removed/rotated by someone else — just retry.
349
+ continue;
350
+ }
351
+ // Re-verify on the MOVED file: stale needs old mtime AND a dead
352
+ // holder. If we raced a brand-new holder (it wrote a fresh lock
353
+ // with a live pid between our gate and the rename), the moved file
354
+ // is NOT stale and must be restored.
355
+ let stolenStillStale = true;
356
+ try {
357
+ const stolenMtimeOld =
358
+ Date.now() - statSync(stealPath).mtimeMs > LOCK_STALE_MS;
359
+ stolenStillStale = stolenMtimeOld && !lockHolderAlive(stealPath);
360
+ } catch {
361
+ /* moved file vanished — treat as stale/handled */
362
+ }
363
+ if (stolenStillStale) {
364
+ try {
365
+ unlinkSync(stealPath);
366
+ } catch {
367
+ /* already gone — fine */
368
+ }
369
+ } else {
370
+ // We grabbed a still-fresh lock. Restore it ONLY if no newer
371
+ // holder has taken the slot — `linkSync` is atomic and fails
372
+ // EEXIST if `lockPath` now exists (no-clobber; `renameSync`
373
+ // would silently overwrite a newer holder's lock). Either way
374
+ // drop our temp copy.
375
+ try {
376
+ linkSync(stealPath, lockPath);
377
+ } catch {
378
+ /* lockPath taken by a newer holder — discard our copy */
379
+ }
380
+ try {
381
+ unlinkSync(stealPath);
382
+ } catch {
383
+ /* best-effort */
384
+ }
385
+ }
386
+ continue;
387
+ }
388
+ if (Date.now() > deadline) {
389
+ throw new Error(
390
+ `Timed out after ${LOCK_TIMEOUT_MS}ms waiting for the clone registry lock ` +
391
+ `(${lockPath}). If no other 'cinatra clone' command is running, delete the ` +
392
+ `lock file and retry.`,
393
+ );
394
+ }
395
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
396
+ continue;
397
+ }
398
+ throw err;
399
+ }
400
+ }
401
+
402
+ // Capture the inode of the lock file WE created. If another process later
403
+ // judges our lock stale and steals it (unlink + recreate), the path points
404
+ // at a different inode — and we must NOT unlink that new holder's lock on
405
+ // our way out. An unconditional unlink in `finally` lets a resumed stale
406
+ // holder delete the active holder's lock.
407
+ let ourInode = null;
408
+ try {
409
+ ourInode = fstatSync(fd).ino;
410
+ } catch {
411
+ /* fstat failed — fall back to best-effort unlink in finally */
412
+ }
413
+ try {
414
+ writeFileSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
415
+ } catch {
416
+ /* diagnostics only — never fail the lock over this */
417
+ }
418
+ try {
419
+ return await fn();
420
+ } finally {
421
+ try {
422
+ closeSync(fd);
423
+ } catch {
424
+ /* already closed */
425
+ }
426
+ try {
427
+ // Only remove the lock if it is still OURS — same inode we created.
428
+ // ourInode === null (fstat failed) falls back to an unconditional
429
+ // unlink, preserving the prior best-effort behavior.
430
+ if (ourInode === null || statSync(lockPath).ino === ourInode) {
431
+ unlinkSync(lockPath);
432
+ }
433
+ } catch {
434
+ /* lock already removed (e.g. stolen as stale) — nothing to do */
435
+ }
436
+ }
437
+ }
438
+
439
+ // --- slot operations (pure) ------------------------------------------------
440
+
441
+ function cloneRegistry(registry) {
442
+ return {
443
+ version: registry.version ?? REGISTRY_VERSION,
444
+ clones: { ...registry.clones },
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Allocate (or return the existing) registry slot for `slug`.
450
+ *
451
+ * Pure — returns { registry, slot } with a NEW registry object; the caller
452
+ * persists it via writeRegistry inside withRegistryLock.
453
+ *
454
+ * - slug present AND same worktreePath → returns the existing slot unchanged
455
+ * (idempotent re-run, regardless of `state`).
456
+ * - slug present AND different worktreePath → THROWS; never alias
457
+ * two worktrees onto one clone DB).
458
+ * - slug absent → lowest free index 0..CLONE_MAX_INDEX, state "provisioning"
459
+ * (the caller flips it to "ready" only after the DB + .env.local
460
+ * succeed; a leftover "provisioning" entry is a resumable/cleanable ghost,
461
+ * not a silent success).
462
+ */
463
+ export function allocateSlot(registry, slug, { worktreePath }) {
464
+ if (!isValidSlug(slug)) {
465
+ throw new Error(`Invalid clone slug "${slug}". Must match /^[a-z0-9][a-z0-9-]{0,29}$/.`);
466
+ }
467
+ if (typeof worktreePath !== "string" || worktreePath.length === 0) {
468
+ throw new Error("allocateSlot requires a non-empty worktreePath.");
469
+ }
470
+
471
+ const existing = registry.clones[slug];
472
+ if (existing) {
473
+ if (existing.worktreePath !== worktreePath) {
474
+ throw new Error(
475
+ `Clone slug "${slug}" already maps to worktree ${existing.worktreePath} — ` +
476
+ `refusing to alias it onto ${worktreePath}. Use a distinct --slug, or ` +
477
+ `prune the existing clone first.`,
478
+ );
479
+ }
480
+ return { registry: cloneRegistry(registry), slot: existing };
481
+ }
482
+
483
+ const usedIndexes = new Set(
484
+ Object.values(registry.clones).map((c) => c.index),
485
+ );
486
+ let index = -1;
487
+ for (let i = 0; i <= CLONE_MAX_INDEX; i += 1) {
488
+ if (!usedIndexes.has(i)) {
489
+ index = i;
490
+ break;
491
+ }
492
+ }
493
+ if (index === -1) {
494
+ throw new Error(
495
+ `All ${CLONE_MAX_INDEX + 1} clone slots are in use. Run 'cinatra clone prune' on a ` +
496
+ `clone you no longer need.`,
497
+ );
498
+ }
499
+
500
+ const { nextjsPort, wayflowPort } = portsForIndex(index);
501
+ const slot = {
502
+ index,
503
+ nextjsPort,
504
+ wayflowPort,
505
+ dbName: cloneDbName(slug),
506
+ worktreePath,
507
+ state: "provisioning",
508
+ createdAt: new Date().toISOString(),
509
+ };
510
+ const next = cloneRegistry(registry);
511
+ next.clones[slug] = slot;
512
+ return { registry: next, slot };
513
+ }
514
+
515
+ /** Flip a slot to state "ready" after provisioning succeeds. Returns a new registry. */
516
+ export function markSlotReady(registry, slug) {
517
+ const existing = registry.clones[slug];
518
+ if (!existing) {
519
+ throw new Error(`Cannot mark unknown clone slug "${slug}" ready.`);
520
+ }
521
+ const next = cloneRegistry(registry);
522
+ next.clones[slug] = { ...existing, state: "ready" };
523
+ return next;
524
+ }
525
+
526
+ /** Remove a slot. Returns { registry, removed } — `removed` is the dropped slot or null. */
527
+ export function releaseSlot(registry, slug) {
528
+ const removed = registry.clones[slug] ?? null;
529
+ const next = cloneRegistry(registry);
530
+ delete next.clones[slug];
531
+ return { registry: next, removed };
532
+ }
533
+
534
+ export function getClone(registry, slug) {
535
+ return registry.clones[slug] ?? null;
536
+ }
537
+
538
+ export function listClones(registry) {
539
+ return Object.entries(registry.clones)
540
+ .map(([slug, slot]) => ({ slug, ...slot }))
541
+ .sort((a, b) => a.index - b.index);
542
+ }
543
+
544
+ // Worktree-path lookup helpers for the EnterWorktree / ExitWorktree hooks.
545
+ // The realpath fallback to `path.resolve` is critical so a worktree directory
546
+ // removed before the ExitWorktree hook fires can still be matched against the
547
+ // stored slot.
548
+
549
+ /**
550
+ * Canonicalise an absolute worktree path. Returns the realpath when the
551
+ * path exists on disk; otherwise returns `path.resolve(p)` so callers
552
+ * can still string-compare against a stored absolute path (the typical
553
+ * stale-clone / ExitWorktree-after-removal scenario).
554
+ */
555
+ export function canonicalizeWorktreePath(p) {
556
+ if (typeof p !== "string" || p.length === 0) return null;
557
+ try {
558
+ return realpathSync(p);
559
+ } catch {
560
+ return path.resolve(p);
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Find the registry slot whose stored worktreePath matches the input.
566
+ * Matches on either realpath (when both resolve) OR the absolute-normalised
567
+ * string (covers the case where the worktree dir has been removed).
568
+ *
569
+ * @returns {{ slug: string, slot: object } | null}
570
+ */
571
+ export function findCloneByWorktreePath(registry, worktreePath) {
572
+ if (!registry || typeof worktreePath !== "string") return null;
573
+ const inputReal = canonicalizeWorktreePath(worktreePath);
574
+ const inputResolved = path.resolve(worktreePath);
575
+ for (const [slug, slot] of Object.entries(registry.clones)) {
576
+ if (typeof slot?.worktreePath !== "string") continue;
577
+ const slotReal = canonicalizeWorktreePath(slot.worktreePath);
578
+ const slotResolved = path.resolve(slot.worktreePath);
579
+ if (inputReal === slotReal || inputResolved === slotResolved) {
580
+ return { slug, slot };
581
+ }
582
+ }
583
+ return null;
584
+ }
585
+
586
+ /**
587
+ * A slot is stale iff its worktreePath does NOT resolve to an existing
588
+ * directory. There is NO `$HOME` / repo-root exclusion — Cinatra worktrees
589
+ * live under `$HOME`, so excluding `$HOME` makes the rule useless.
590
+ * The existence-of-directory check is the canonical liveness signal.
591
+ */
592
+ export function isWorktreePathStale(slot) {
593
+ if (typeof slot?.worktreePath !== "string") return true;
594
+ try {
595
+ return !statSync(slot.worktreePath).isDirectory();
596
+ } catch {
597
+ return true;
598
+ }
599
+ }
600
+
601
+ // --- test surface ----------------------------------------------------------
602
+
603
+ export const __test = {
604
+ CLONE_NEXTJS_PORT_BASE,
605
+ CLONE_WAYFLOW_PORT_BASE,
606
+ CLONE_MAX_INDEX,
607
+ SEED_DB_NAME,
608
+ cloneSlugFromBranch,
609
+ isValidSlug,
610
+ cloneDbName,
611
+ isProtectedDbName,
612
+ portsForIndex,
613
+ defaultRegistryPath,
614
+ readRegistry,
615
+ requireUsableRegistry,
616
+ writeRegistry,
617
+ withRegistryLock,
618
+ allocateSlot,
619
+ markSlotReady,
620
+ releaseSlot,
621
+ getClone,
622
+ listClones,
623
+ };