@paleo/workspace 0.11.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,550 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, symlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+ import { parseWorkspaceArgs, printWorkspaceHelp } from "./cli.js";
5
+ import { findOwnEntry, removeDevServerEntryByWorktree } from "./dev-servers-registry.js";
6
+ import { ConfigError } from "./errors.js";
7
+ import { copyAndPatchFile, formatDuration } from "./helpers.js";
8
+ import { isProcessAlive } from "./process-control.js";
9
+ import { defaultComputePorts, isValidPort, resolvePortScheme } from "./ports.js";
10
+ import { handleSetOwner, markSlotFailed, markSlotReady, readSlots, resolveAndRegisterSlot, resolveCurrentSlot, validateSlotAvailability, writeSlots, } from "./slots.js";
11
+ import { createBranch, detectWorktree, enforceWorktreeMode, getWorktreeBranch, removeWorktree, useExistingBranch, verifyBranchAbsentFromRemote, } from "./worktree.js";
12
+ export async function runWorkspace(config) {
13
+ let command;
14
+ let verbose;
15
+ try {
16
+ ({ command, verbose } = parseWorkspaceArgs());
17
+ }
18
+ catch (err) {
19
+ if (err instanceof ConfigError) {
20
+ console.error(err.message);
21
+ process.exit(err.exitCode);
22
+ }
23
+ throw err;
24
+ }
25
+ if (command.kind === "help") {
26
+ printWorkspaceHelp();
27
+ return;
28
+ }
29
+ if (!existsSync(config.scriptPath)) {
30
+ console.error(`Error: scriptPath does not exist: ${config.scriptPath}. ` +
31
+ "Pass `fileURLToPath(import.meta.url)` from your wrapper script.");
32
+ process.exit(1);
33
+ }
34
+ switch (command.kind) {
35
+ case "finalize":
36
+ await runFinalize(command, config);
37
+ return;
38
+ case "wait":
39
+ await runWait(command, config);
40
+ return;
41
+ case "info":
42
+ runInfo(command, config);
43
+ return;
44
+ case "list":
45
+ runList(config);
46
+ return;
47
+ }
48
+ const ctx = detectWorktree();
49
+ enforceWorktreeMode(command, ctx);
50
+ const run = { verbose };
51
+ switch (command.kind) {
52
+ case "remove":
53
+ await handleRemove(command, ctx, run, config);
54
+ return;
55
+ case "set-owner":
56
+ handleSetOwnerMode(command, ctx, config);
57
+ return;
58
+ case "setup": {
59
+ const { slot } = await runSetup(command, ctx, run, config);
60
+ if (command.wait)
61
+ await waitForSlot(slot, config, { printSummary: false });
62
+ return;
63
+ }
64
+ }
65
+ }
66
+ async function runSetup(command, ctx, run, config) {
67
+ const scheme = resolvePortScheme(config);
68
+ const portsFn = resolvePortsFn(config);
69
+ validateSlotAvailability(command.slot, {
70
+ currentWorktree: ctx.currentWorktree,
71
+ mainWorktree: ctx.mainWorktree,
72
+ registryDir: config.registryDir,
73
+ scheme,
74
+ });
75
+ const setupCtx = ensureWorktree(command, ctx, run, config.worktreeDirName);
76
+ refuseIfFinalizePending(setupCtx, config.registryDir, command.force);
77
+ const branch = getWorktreeBranch(setupCtx.currentWorktree) ?? "(detached)";
78
+ const { port: slot, owner, status, } = resolveAndRegisterSlot({
79
+ slot: command.slot,
80
+ currentWorktree: setupCtx.currentWorktree,
81
+ mainWorktree: setupCtx.mainWorktree,
82
+ registryDir: config.registryDir,
83
+ scheme,
84
+ requestedOwner: command.owner,
85
+ isMainWorktree: setupCtx.isMainWorktree,
86
+ force: command.force,
87
+ });
88
+ const ports = portsFn(slot);
89
+ const runtimeDir = join(setupCtx.currentWorktree, config.runtimeDir);
90
+ mkdirSync(runtimeDir, { recursive: true });
91
+ const logPath = join(runtimeDir, "wt-setup.log");
92
+ // Truncate any prior log so `workspace setup` retries start with a clean record (the previous run's
93
+ // FAILED: banner would otherwise linger and produce false positives for grep-based tooling).
94
+ writeFileSync(logPath, "");
95
+ // Opened "a" so the same fd can be inherited by the detached finalize child below.
96
+ const logFd = openSync(logPath, "a");
97
+ const teeLog = (message) => {
98
+ console.log(message);
99
+ appendFileSync(logFd, `${message}\n`);
100
+ };
101
+ const verboseLog = (msg) => {
102
+ if (run.verbose)
103
+ teeLog(msg);
104
+ else
105
+ appendFileSync(logFd, `${msg}\n`);
106
+ };
107
+ verboseLog(`Using slot ${slot} (${Object.entries(ports)
108
+ .map(([k, v]) => `${k}: ${v}`)
109
+ .join(", ")})`);
110
+ if (config.preSetup) {
111
+ await config.preSetup({
112
+ currentWorktree: setupCtx.currentWorktree,
113
+ mainWorktree: setupCtx.mainWorktree,
114
+ isMainWorktree: setupCtx.isMainWorktree,
115
+ force: command.force,
116
+ log: teeLog,
117
+ });
118
+ }
119
+ linkSharedDirectories(setupCtx, config.sharedDirs, verboseLog);
120
+ generateConfigFiles(setupCtx, config.configFiles, slot, ports, command.force, verboseLog);
121
+ teeLog(config.printSummary({
122
+ slot,
123
+ branch,
124
+ owner,
125
+ ports,
126
+ currentWorktree: setupCtx.currentWorktree,
127
+ mainWorktree: setupCtx.mainWorktree,
128
+ isMainWorktree: setupCtx.isMainWorktree,
129
+ status,
130
+ }));
131
+ teeLog(`WORKTREE_CREATED path=${setupCtx.currentWorktree} branch=${branch} slot=${slot}`);
132
+ if (status !== "ready") {
133
+ teeLog(`Setup continuing in background. Tail: ${logPath}`);
134
+ teeLog(`Block until ready: workspace wait --slot ${slot}`);
135
+ }
136
+ const finalizeArgs = [config.scriptPath, "__finalize", String(slot)];
137
+ if (command.force)
138
+ finalizeArgs.push("--force");
139
+ const child = spawn(process.execPath, finalizeArgs, {
140
+ detached: true,
141
+ stdio: ["ignore", logFd, logFd],
142
+ cwd: setupCtx.currentWorktree,
143
+ });
144
+ child.unref();
145
+ closeSync(logFd);
146
+ return { slot };
147
+ }
148
+ function refuseIfFinalizePending(ctx, registryDir, force) {
149
+ if (force)
150
+ return;
151
+ const registry = readSlots(ctx.mainWorktree, registryDir);
152
+ const resolvedCurrent = resolve(ctx.currentWorktree);
153
+ const found = Object.entries(registry.slots).find(([, e]) => resolve(e.worktree) === resolvedCurrent && e.status === "pending");
154
+ if (!found)
155
+ return;
156
+ const [slotPort] = found;
157
+ console.error(`Error: Setup is already in progress for slot ${slotPort}. ` +
158
+ `Run 'workspace wait --slot ${slotPort}' to wait for it to finish (or fail), ` +
159
+ "then retry. Use --force to bypass.");
160
+ process.exit(1);
161
+ }
162
+ async function runFinalize(command, config) {
163
+ const slot = Number(command.slot);
164
+ const ctx = detectWorktree();
165
+ const logPath = join(ctx.currentWorktree, config.runtimeDir, "wt-setup.log");
166
+ const appendLog = (message) => {
167
+ appendFileSync(logPath, `${message}\n`);
168
+ };
169
+ const registry = readSlots(ctx.mainWorktree, config.registryDir);
170
+ const entry = registry.slots[String(slot)];
171
+ if (!entry || resolve(entry.worktree) !== resolve(ctx.currentWorktree)) {
172
+ appendLog(`FAILED: No matching slot ${slot} for worktree ${ctx.currentWorktree}.`);
173
+ process.exit(1);
174
+ }
175
+ const branch = getWorktreeBranch(ctx.currentWorktree) ?? "(detached)";
176
+ if (entry.status === "ready" && !command.force) {
177
+ appendLog(`READY: branch ${branch} (slot ${slot}) already finalized; skipping.`);
178
+ return;
179
+ }
180
+ const portsFn = resolvePortsFn(config);
181
+ const ports = portsFn(slot);
182
+ appendLog(`--- finalizing slot ${slot} at ${new Date().toISOString()} ---`);
183
+ const setupContext = {
184
+ currentWorktree: ctx.currentWorktree,
185
+ mainWorktree: ctx.mainWorktree,
186
+ isMainWorktree: ctx.isMainWorktree,
187
+ slot,
188
+ branch,
189
+ owner: entry.owner,
190
+ ports,
191
+ force: command.force,
192
+ verbose: false,
193
+ };
194
+ try {
195
+ await config.finalizeWorktree(setupContext);
196
+ markSlotReady(ctx.mainWorktree, config.registryDir, slot);
197
+ appendLog("============================================================");
198
+ appendLog(`READY: branch ${branch} (slot ${slot})`);
199
+ appendLog("============================================================");
200
+ }
201
+ catch (err) {
202
+ const message = err.message;
203
+ const stack = err.stack ?? "";
204
+ markSlotFailed(ctx.mainWorktree, config.registryDir, slot, message);
205
+ appendLog(`FAILED: ${message}`);
206
+ if (stack)
207
+ appendLog(stack);
208
+ process.exit(1);
209
+ }
210
+ }
211
+ function resolveTargetSlot(slotArg, config) {
212
+ if (slotArg !== undefined) {
213
+ const slot = Number(slotArg);
214
+ const scheme = resolvePortScheme(config);
215
+ if (!isValidPort(slot, scheme)) {
216
+ console.error(`Error: --slot expects a port in [${scheme.minPort}, ${scheme.maxPort}] stepped by ${scheme.portStep}; got "${slotArg}".`);
217
+ process.exit(1);
218
+ }
219
+ return slot;
220
+ }
221
+ return resolveCurrentSlot(config.basePort, config.registryDir).slot;
222
+ }
223
+ function printWorktreeInfo(config, slot, worktreeForLog, fallback) {
224
+ const ctx = detectWorktree();
225
+ const registry = readSlots(ctx.mainWorktree, config.registryDir);
226
+ const entry = registry.slots[String(slot)];
227
+ const ports = resolvePortsFn(config)(slot);
228
+ const owner = entry?.owner ?? fallback.owner;
229
+ const status = entry?.status ?? "pending";
230
+ const setupLogPath = join(worktreeForLog, config.runtimeDir, "wt-setup.log");
231
+ const now = Date.now();
232
+ const isMainWorktree = entry?.main ?? false;
233
+ const targetWorktree = entry?.worktree ?? ctx.currentWorktree;
234
+ const branch = getWorktreeBranch(targetWorktree) ?? "(detached)";
235
+ console.log(config.printSummary({
236
+ slot,
237
+ branch,
238
+ owner,
239
+ ports,
240
+ currentWorktree: targetWorktree,
241
+ mainWorktree: ctx.mainWorktree,
242
+ isMainWorktree,
243
+ status,
244
+ }));
245
+ if (status === "failed") {
246
+ const at = entry?.failure?.at ?? entry?.createdAt;
247
+ const elapsed = at ? formatDuration(now - Date.parse(at)) : "?";
248
+ const reason = entry?.failure?.message ?? "(no message)";
249
+ console.log(`Failure: ${reason} (${elapsed} ago, tail ${setupLogPath})`);
250
+ }
251
+ else if (status === "pending" && entry) {
252
+ const elapsed = formatDuration(now - Date.parse(entry.createdAt));
253
+ console.log(`Pending since ${elapsed} ago (tail ${setupLogPath})`);
254
+ }
255
+ printDevServerBlock(config, ctx.mainWorktree, targetWorktree, now);
256
+ }
257
+ function printDevServerBlock(config, mainWorktree, targetWorktree, now) {
258
+ const entry = findOwnEntry(mainWorktree, config.registryDir, targetWorktree);
259
+ const liveEntries = entry
260
+ ? Object.entries(entry.pids)
261
+ .filter(([, pid]) => isProcessAlive(pid))
262
+ .sort(([a], [b]) => a.localeCompare(b))
263
+ : [];
264
+ if (liveEntries.length === 0 || !entry) {
265
+ console.log("Dev-server: not running");
266
+ return;
267
+ }
268
+ const elapsed = formatDuration(now - Date.parse(entry.startedAt));
269
+ console.log(`Dev-server: running, started ${elapsed} ago`);
270
+ for (const [name, pid] of liveEntries) {
271
+ console.log(` ${name}: PID ${pid}`);
272
+ console.log(` log: ${join(targetWorktree, config.runtimeDir, "logs", `${name}.log`)}`);
273
+ }
274
+ }
275
+ function runInfo(command, config) {
276
+ if (command.slot !== undefined) {
277
+ const slot = resolveTargetSlot(command.slot, config);
278
+ const ctx = detectWorktree();
279
+ const entry = readSlots(ctx.mainWorktree, config.registryDir).slots[String(slot)];
280
+ if (!entry) {
281
+ console.error(`Error: No slot ${slot} in registry.`);
282
+ process.exit(1);
283
+ }
284
+ printWorktreeInfo(config, slot, entry.worktree, { owner: entry.owner });
285
+ return;
286
+ }
287
+ const resolved = resolveCurrentSlot(config.basePort, config.registryDir);
288
+ printWorktreeInfo(config, resolved.slot, resolved.worktree, {
289
+ owner: resolved.owner,
290
+ });
291
+ }
292
+ function runList(config) {
293
+ const ctx = detectWorktree();
294
+ const entries = Object.entries(readSlots(ctx.mainWorktree, config.registryDir).slots).sort(([a], [b]) => Number(a) - Number(b));
295
+ if (entries.length === 0) {
296
+ console.log("No worktrees registered.");
297
+ return;
298
+ }
299
+ const rows = entries.map(([port, e]) => ({
300
+ slot: port,
301
+ type: e.main ? "main" : "linked",
302
+ status: e.status,
303
+ branch: getWorktreeBranch(e.worktree) ?? "(detached)",
304
+ worktree: e.worktree,
305
+ owner: e.owner ?? "-",
306
+ created: e.createdAt,
307
+ }));
308
+ const headers = {
309
+ slot: "SLOT",
310
+ type: "TYPE",
311
+ status: "STATUS",
312
+ branch: "BRANCH",
313
+ worktree: "WORKTREE",
314
+ owner: "OWNER",
315
+ created: "CREATED",
316
+ };
317
+ const widths = {
318
+ slot: Math.max(headers.slot.length, ...rows.map((r) => r.slot.length)),
319
+ type: Math.max(headers.type.length, ...rows.map((r) => r.type.length)),
320
+ status: Math.max(headers.status.length, ...rows.map((r) => r.status.length)),
321
+ branch: Math.max(headers.branch.length, ...rows.map((r) => r.branch.length)),
322
+ worktree: Math.max(headers.worktree.length, ...rows.map((r) => r.worktree.length)),
323
+ owner: Math.max(headers.owner.length, ...rows.map((r) => r.owner.length)),
324
+ };
325
+ const fmt = (r) => `${r.slot.padEnd(widths.slot)} ${r.type.padEnd(widths.type)} ${r.status.padEnd(widths.status)} ${r.branch.padEnd(widths.branch)} ${r.worktree.padEnd(widths.worktree)} ${r.owner.padEnd(widths.owner)} ${r.created}`;
326
+ console.log(fmt(headers));
327
+ for (const r of rows)
328
+ console.log(fmt(r));
329
+ }
330
+ async function runWait(command, config) {
331
+ // standalone `workspace wait` (no prior setup in this invocation) → print the full summary on success.
332
+ const slot = resolveTargetSlot(command.slot, config);
333
+ await waitForSlot(slot, config);
334
+ }
335
+ async function waitForSlot(slot, config, options = {}) {
336
+ const printSummary = options.printSummary ?? true;
337
+ const ctx = detectWorktree();
338
+ const initial = readSlots(ctx.mainWorktree, config.registryDir).slots[String(slot)];
339
+ if (!initial) {
340
+ console.error(`Error: No slot ${slot} in registry.`);
341
+ process.exit(1);
342
+ }
343
+ const pollMs = 500;
344
+ // Poll slots.json — the finalize child writes `status` on success or failure. Tiny file, no
345
+ // log-tailing race.
346
+ for (;;) {
347
+ const entry = readSlots(ctx.mainWorktree, config.registryDir).slots[String(slot)];
348
+ if (!entry) {
349
+ console.error(`Error: Slot ${slot} disappeared from registry.`);
350
+ process.exit(1);
351
+ }
352
+ if (entry.status === "ready") {
353
+ console.log("\n… ready");
354
+ if (printSummary) {
355
+ printWorktreeInfo(config, slot, entry.worktree, {
356
+ owner: entry.owner,
357
+ });
358
+ }
359
+ return;
360
+ }
361
+ if (entry.status === "failed") {
362
+ const logPath = join(entry.worktree, config.runtimeDir, "wt-setup.log");
363
+ console.error(`FAILED: ${entry.failure?.message ?? "(no message)"}`);
364
+ console.error(`Full log: ${logPath}`);
365
+ process.exit(1);
366
+ }
367
+ await new Promise((r) => setTimeout(r, pollMs));
368
+ }
369
+ }
370
+ async function handleRemove(command, ctx, run, config) {
371
+ const verboseLog = makeVerboseLog(run.verbose);
372
+ const removeHere = command.branch === undefined;
373
+ const registry = readSlots(ctx.mainWorktree, config.registryDir);
374
+ const target = resolveRemoveTarget(command, ctx, registry);
375
+ // Refuse to remove while the detached finalize is still writing to slots.json / wt-setup.log:
376
+ // racing the two corrupts the registry and leaves the worktree directory orphaned.
377
+ if (registry.slots[target.slotPort]?.status === "pending") {
378
+ console.error(`Error: Setup is still in progress for slot ${target.slotPort}. ` +
379
+ `Run 'workspace wait --slot ${target.slotPort}' to wait for it to finish (or fail), then retry the removal.`);
380
+ process.exit(1);
381
+ }
382
+ if (!command.noRemoteCheck) {
383
+ verifyBranchAbsentFromRemote(target.branch, run);
384
+ }
385
+ const ownerSuffix = target.owner ? `, owner ${target.owner}` : "";
386
+ if (!existsSync(target.worktreePath)) {
387
+ console.warn(`Warning: Worktree directory ${target.worktreePath} not found. Cleaning up registry only.`);
388
+ delete registry.slots[target.slotPort];
389
+ writeSlots(ctx.mainWorktree, config.registryDir, registry);
390
+ console.log(`Removed registry entry for branch "${target.branch}" (slot ${target.slotPort}${ownerSuffix}).`);
391
+ return;
392
+ }
393
+ const targetEntry = findOwnEntry(ctx.mainWorktree, config.registryDir, target.worktreePath);
394
+ if (targetEntry) {
395
+ stopTargetDevServer(config.devServerScript, target.worktreePath, verboseLog);
396
+ }
397
+ else {
398
+ verboseLog(`No dev-server running in ${target.worktreePath}; skipping --stop.`);
399
+ }
400
+ if (config.purgeInfrastructure) {
401
+ await config.purgeInfrastructure({
402
+ worktree: target.worktreePath,
403
+ mainWorktree: ctx.mainWorktree,
404
+ verbose: run.verbose,
405
+ });
406
+ }
407
+ delete registry.slots[target.slotPort];
408
+ writeSlots(ctx.mainWorktree, config.registryDir, registry);
409
+ removeDevServerEntryByWorktree(ctx.mainWorktree, config.registryDir, target.worktreePath);
410
+ if (removeHere) {
411
+ process.chdir(ctx.mainWorktree);
412
+ }
413
+ removeWorktree(target.worktreePath, run);
414
+ console.log(`Removed worktree for branch "${target.branch}" (slot ${target.slotPort}${ownerSuffix}). ` +
415
+ `Branch "${target.branch}" kept.`);
416
+ if (removeHere) {
417
+ console.log(`Now run: cd ${ctx.mainWorktree}`);
418
+ }
419
+ }
420
+ function handleSetOwnerMode(command, ctx, config) {
421
+ const newOwner = command.name;
422
+ const { slotPort } = handleSetOwner({
423
+ newOwner,
424
+ currentWorktree: ctx.currentWorktree,
425
+ mainWorktree: ctx.mainWorktree,
426
+ registryDir: config.registryDir,
427
+ isMainWorktree: ctx.isMainWorktree,
428
+ });
429
+ // Propagate to dev-servers.json entries for this worktree.
430
+ const devServersPath = join(ctx.mainWorktree, config.registryDir, "dev-servers.json");
431
+ if (existsSync(devServersPath)) {
432
+ const data = JSON.parse(readFileSync(devServersPath, "utf-8"));
433
+ let changed = false;
434
+ const resolvedCurrent = resolve(ctx.currentWorktree);
435
+ for (const server of data.servers) {
436
+ if (resolve(server.worktree) === resolvedCurrent) {
437
+ if (newOwner !== undefined)
438
+ server.owner = newOwner;
439
+ else
440
+ delete server.owner;
441
+ changed = true;
442
+ }
443
+ }
444
+ if (changed) {
445
+ mkdirSync(dirname(devServersPath), { recursive: true });
446
+ writeFileSync(devServersPath, `${JSON.stringify(data, undefined, 2)}\n`);
447
+ }
448
+ }
449
+ console.log(`Owner for slot ${slotPort}: ${newOwner ?? "(none)"}`);
450
+ }
451
+ function ensureWorktree(command, ctx, run, dirNameFn) {
452
+ if (command.branch === undefined)
453
+ return ctx;
454
+ if (command.newBranch)
455
+ return createBranch(command.branch, ctx, run, dirNameFn);
456
+ return useExistingBranch(command.branch, ctx, run, dirNameFn);
457
+ }
458
+ function linkSharedDirectories(ctx, dirs, log) {
459
+ for (const dirName of dirs) {
460
+ const link = join(ctx.currentWorktree, dirName);
461
+ const mainDir = join(ctx.mainWorktree, dirName);
462
+ if (!existsSync(mainDir)) {
463
+ log(`Skipped ${dirName} symlink (not present in main worktree).`);
464
+ }
465
+ else if (existsSync(link)) {
466
+ log(`Skipped ${dirName} symlink (already exists).`);
467
+ }
468
+ else {
469
+ const relTarget = relative(ctx.currentWorktree, mainDir);
470
+ symlinkSync(relTarget, link);
471
+ log(`Created ${dirName} symlink → main worktree.`);
472
+ }
473
+ }
474
+ }
475
+ function generateConfigFiles(ctx, entries, slot, ports, force, log) {
476
+ for (const entry of entries) {
477
+ copyAndPatchFile({ currentWorktree: ctx.currentWorktree, mainWorktree: ctx.mainWorktree, log }, entry.path, (content) => entry.patch(content, {
478
+ slot,
479
+ ports,
480
+ mainWorktree: ctx.mainWorktree,
481
+ currentWorktree: ctx.currentWorktree,
482
+ }), entry.path, force, entry.optional ?? false);
483
+ }
484
+ }
485
+ function resolveRemoveTarget(command, ctx, registry) {
486
+ if (command.branch === undefined) {
487
+ if (ctx.isMainWorktree) {
488
+ console.error("Error: Cannot remove the main worktree.");
489
+ process.exit(1);
490
+ }
491
+ const resolvedCurrent = resolve(ctx.currentWorktree);
492
+ const entry = Object.entries(registry.slots).find(([, v]) => resolve(v.worktree) === resolvedCurrent);
493
+ if (!entry) {
494
+ console.error("Error: No slot found for this worktree in the registry.");
495
+ process.exit(1);
496
+ }
497
+ const branch = getWorktreeBranch(ctx.currentWorktree) ?? "(detached)";
498
+ return {
499
+ slotPort: entry[0],
500
+ branch,
501
+ worktreePath: ctx.currentWorktree,
502
+ owner: entry[1].owner,
503
+ };
504
+ }
505
+ const branch = command.branch;
506
+ const entry = Object.entries(registry.slots).find(([, v]) => getWorktreeBranch(v.worktree) === branch);
507
+ if (!entry) {
508
+ console.error(`Error: No worktree found for branch "${branch}" in the slot registry.`);
509
+ process.exit(1);
510
+ }
511
+ const worktreePath = entry[1].worktree;
512
+ if (resolve(ctx.currentWorktree) === resolve(worktreePath)) {
513
+ console.error("Error: You are currently in this worktree. Run `workspace remove` (no branch) instead.");
514
+ process.exit(1);
515
+ }
516
+ return { slotPort: entry[0], branch, worktreePath, owner: entry[1].owner };
517
+ }
518
+ /**
519
+ * Stops the dev-server running in the target worktree by shelling out to
520
+ * `node <devServerScript> --stop` with `cwd: worktreePath`. The subprocess runs the target's
521
+ * own stop flow — registry-based spawn-PID kill + callback `stop()` from the target's branch.
522
+ */
523
+ function stopTargetDevServer(devServerScript, worktreePath, log) {
524
+ log(`Stopping dev-server in ${worktreePath}...`);
525
+ const result = spawnSync(process.execPath, [devServerScript, "--stop"], {
526
+ cwd: worktreePath,
527
+ stdio: "inherit",
528
+ timeout: 30_000,
529
+ });
530
+ if (result.error) {
531
+ console.warn(`Warning: failed to run dev-server --stop: ${result.error.message}`);
532
+ }
533
+ else if (result.status !== 0) {
534
+ console.warn(`Warning: dev-server --stop exited with code ${result.status}.`);
535
+ }
536
+ }
537
+ function resolvePortsFn(config) {
538
+ if (config.ports)
539
+ return config.ports;
540
+ if (config.portNames && config.portNames.length > 0) {
541
+ return defaultComputePorts(config.portNames);
542
+ }
543
+ throw new ConfigError("Config error: provide either `ports` (function) or `portNames` (array).");
544
+ }
545
+ function makeVerboseLog(verbose) {
546
+ return (msg) => {
547
+ if (verbose)
548
+ console.log(msg);
549
+ };
550
+ }
@@ -0,0 +1,28 @@
1
+ import type { WorkspaceCommand } from "./cli.js";
2
+ export interface WorktreeContext {
3
+ currentWorktree: string;
4
+ mainWorktree: string;
5
+ isMainWorktree: boolean;
6
+ }
7
+ export interface RunCtx {
8
+ verbose: boolean;
9
+ }
10
+ export declare function detectWorktree(): WorktreeContext;
11
+ export declare function enforceWorktreeMode(command: WorkspaceCommand, ctx: WorktreeContext): void;
12
+ export declare function useExistingBranch(branch: string, ctx: WorktreeContext, run: RunCtx, dirNameFn?: WorktreeDirNameFn): WorktreeContext;
13
+ export declare function createBranch(requestedBranch: string, ctx: WorktreeContext, run: RunCtx, dirNameFn?: WorktreeDirNameFn): WorktreeContext;
14
+ export declare function verifyBranchAbsentFromRemote(branch: string, run: RunCtx): void;
15
+ export declare function getWorktreeBranch(worktreePath: string): string | undefined;
16
+ export declare function removeWorktree(worktreePath: string, run: RunCtx): void;
17
+ /** Pure function that produces the basename of a worktree directory from a branch. */
18
+ export type WorktreeDirNameFn = (opts: {
19
+ branch: string;
20
+ repoName: string;
21
+ }) => string;
22
+ /**
23
+ * Default {@link WorktreeDirNameFn}. Strips a recognizable ticket suffix from the last branch
24
+ * segment (`feat/ABC-123-extra` → `feat-ABC-123`), caps the result at 22 chars, and strips
25
+ * trailing dashes. Falls back to the full sanitized branch when no ticket pattern is found.
26
+ */
27
+ export declare const defaultWorktreeDirName: WorktreeDirNameFn;
28
+ export declare function computeWorktreePath(mainWorktree: string, branch: string, dirNameFn?: WorktreeDirNameFn): string;