@paleo/workspace 0.16.0 → 0.18.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 CHANGED
@@ -51,7 +51,6 @@ await runWorkspace({
51
51
  portNames: ["server", "frontend", "db"],
52
52
  sharedDirs: [".local", ".plans"],
53
53
  runtimeDir: ".local-wt",
54
- registryDir: ".local/_workspace-registry",
55
54
  configFiles: [
56
55
  {
57
56
  path: ".env",
@@ -89,7 +88,6 @@ import { runDevServer, helpers } from "@paleo/workspace";
89
88
  await runDevServer({
90
89
  basePort: 8100,
91
90
  runtimeDir: ".local-wt",
92
- registryDir: ".local/_workspace-registry",
93
91
  devLimit: 5,
94
92
  servers: [
95
93
  {
package/dist/cli.d.ts CHANGED
@@ -2,6 +2,7 @@ export type WorkspaceCommand = {
2
2
  kind: "setup";
3
3
  branch?: string;
4
4
  newBranch: boolean;
5
+ from?: string;
5
6
  owner?: string;
6
7
  slot?: string;
7
8
  force: boolean;
@@ -9,7 +10,7 @@ export type WorkspaceCommand = {
9
10
  } | {
10
11
  kind: "remove";
11
12
  branch?: string;
12
- noRemoteCheck: boolean;
13
+ force: boolean;
13
14
  } | {
14
15
  kind: "list";
15
16
  } | {
@@ -25,6 +26,9 @@ export type WorkspaceCommand = {
25
26
  kind: "finalize";
26
27
  slot: string;
27
28
  force: boolean;
29
+ } | {
30
+ kind: "migrate";
31
+ oldRegistryDir: string;
28
32
  } | {
29
33
  kind: "help";
30
34
  };
package/dist/cli.js CHANGED
@@ -32,6 +32,8 @@ function parseSubcommand(subcommand, tokens) {
32
32
  return parseSetOwner(tokens);
33
33
  case "__finalize":
34
34
  return parseFinalize(tokens);
35
+ case "migrate-0.16":
36
+ return parseMigrate(tokens);
35
37
  default:
36
38
  throw new ConfigError(`Unknown command "${subcommand}". Run \`workspace --help\`.`);
37
39
  }
@@ -41,6 +43,7 @@ function parseSetup(tokens) {
41
43
  args: tokens,
42
44
  options: {
43
45
  "new-branch": { type: "boolean", short: "c" },
46
+ from: { type: "string" },
44
47
  owner: { type: "string" },
45
48
  slot: { type: "string", short: "s" },
46
49
  force: { type: "boolean" },
@@ -55,11 +58,15 @@ function parseSetup(tokens) {
55
58
  if (newBranch && branch === undefined) {
56
59
  throw new ConfigError("`workspace setup <branch> -c` requires a branch name.");
57
60
  }
61
+ if (values.from !== undefined && !newBranch) {
62
+ throw new ConfigError("`--from` requires `-c`/`--new-branch`.");
63
+ }
58
64
  return {
59
65
  command: {
60
66
  kind: "setup",
61
67
  branch,
62
68
  newBranch,
69
+ from: values.from,
63
70
  owner: values.owner,
64
71
  slot: values.slot,
65
72
  force: values.force ?? false,
@@ -72,7 +79,7 @@ function parseRemove(tokens) {
72
79
  const { values, positionals } = parseArgs({
73
80
  args: tokens,
74
81
  options: {
75
- "no-remote-check": { type: "boolean" },
82
+ force: { type: "boolean" },
76
83
  verbose: { type: "boolean", short: "v" },
77
84
  },
78
85
  allowPositionals: true,
@@ -80,7 +87,7 @@ function parseRemove(tokens) {
80
87
  });
81
88
  const branch = takeOptionalPositional(positionals, "remove");
82
89
  return {
83
- command: { kind: "remove", branch, noRemoteCheck: values["no-remote-check"] ?? false },
90
+ command: { kind: "remove", branch, force: values.force ?? false },
84
91
  verbose: values.verbose ?? false,
85
92
  };
86
93
  }
@@ -140,6 +147,16 @@ function parseFinalize(tokens) {
140
147
  const slot = takeRequiredPositional(positionals, "__finalize", "slot");
141
148
  return { command: { kind: "finalize", slot, force: values.force ?? false }, verbose: false };
142
149
  }
150
+ function parseMigrate(tokens) {
151
+ const { values, positionals } = parseArgs({
152
+ args: tokens,
153
+ options: { verbose: { type: "boolean", short: "v" } },
154
+ allowPositionals: true,
155
+ strict: true,
156
+ });
157
+ const oldRegistryDir = takeRequiredPositional(positionals, "migrate-0.16", "old-registry-dir");
158
+ return { command: { kind: "migrate", oldRegistryDir }, verbose: values.verbose ?? false };
159
+ }
143
160
  function takeOptionalPositional(positionals, command) {
144
161
  if (positionals.length > 1) {
145
162
  throw new ConfigError(`\`workspace ${command}\` accepts at most one positional argument.`);
@@ -164,13 +181,15 @@ export function printWorkspaceHelp() {
164
181
  "Manage workspaces: a git worktree plus its own dev setup (ports, config, database, dev server).",
165
182
  "",
166
183
  "Commands:",
167
- " setup [<branch>] [-c|--new-branch] [--owner <name>] [-s|--slot <port>] [--force] [--wait]",
184
+ " setup [<branch>] [-c|--new-branch] [--from <ref>] [--owner <name>] [-s|--slot <port>] [--force] [--wait]",
168
185
  " Set up the workspace. With <branch>, create a sibling worktree for it",
169
186
  " (add -c to create the branch first). Without, set up the current worktree",
170
187
  " (idempotent; bootstrap and retry path).",
188
+ " With -c, the new branch starts at the current worktree's HEAD, or at <ref> with --from.",
171
189
  " Finalize runs in the background; add --wait to block until it reaches READY.",
172
- " remove [<branch>] [--no-remote-check]",
190
+ " remove [<branch>] [--force]",
173
191
  " Remove a workspace by branch, or the current one when omitted.",
192
+ " Refuses on uncommitted changes unless --force.",
174
193
  " list",
175
194
  " List all registered workspaces (slot, status, branch, path, owner, created).",
176
195
  " status [-s|--slot <port>]",
@@ -8,12 +8,6 @@ export interface DevServerConfig {
8
8
  basePort: number;
9
9
  /** Per-worktree runtime directory, relative to the worktree root (e.g. `.local-wt`). */
10
10
  runtimeDir: string;
11
- /**
12
- * Shared registry directory, relative to a worktree root (e.g. `.local/_workspace-registry`).
13
- * Holds `slots.json` and `dev-servers.json`. Must resolve to the same physical directory
14
- * across linked worktrees — typically via a symlink (e.g. `.local`).
15
- */
16
- registryDir: string;
17
11
  /** Maximum concurrent dev-servers across all worktrees. Omit for no limit. */
18
12
  devLimit?: number;
19
13
  /** One entry per server to start. Started in array order; stopped in reverse order. */
@@ -8,7 +8,7 @@ import { detectCommonJsError, formatDuration, lastLines, setupLogPath } from "./
8
8
  import { awaitAllReady, handleStartupFailure, LOG_TAIL_LINES, } from "./log-polling.js";
9
9
  import { canonicalCwd, detectPortConflicts, sweepStalePorts, waitForPortsFree, } from "./port-holder.js";
10
10
  import { isProcessAlive, stopProcessGroup } from "./process-control.js";
11
- import { readSlots, resolveCurrentSlot } from "./slots.js";
11
+ import { readSlots, registryDirFor, resolveCurrentSlot, warnLegacyRegistryDir, } from "./slots.js";
12
12
  import { detectWorktree, getWorktreeBranch } from "./worktree.js";
13
13
  function logFileFor(runtimeDir, name) {
14
14
  return join(runtimeDir, "logs", `${name}.log`);
@@ -30,60 +30,65 @@ export async function runDevServer(config) {
30
30
  printDevHelp();
31
31
  return;
32
32
  }
33
+ warnLegacyRegistryDir(config);
34
+ const registryDir = registryDirFor(config.runtimeDir);
33
35
  const { mainWorktree } = detectWorktree();
34
36
  switch (command.kind) {
35
37
  case "list":
36
- listDevServers(mainWorktree, config.registryDir);
38
+ listDevServers(mainWorktree, registryDir);
37
39
  return;
38
40
  case "down":
39
41
  if (command.all) {
40
42
  await stopAllRegistered({
41
43
  mainWorktree,
42
- registryDir: config.registryDir,
44
+ registryDir,
43
45
  callbackServers: callbackServersOf(config),
44
46
  });
45
47
  }
46
48
  else {
47
- await stopLocal(config, mainWorktree);
49
+ await stopLocal(config, mainWorktree, registryDir);
48
50
  }
49
51
  return;
50
52
  case "up":
51
- await start(config, mainWorktree, { evict: command.evict, restart: command.restart });
53
+ await start(config, mainWorktree, registryDir, {
54
+ evict: command.evict,
55
+ restart: command.restart,
56
+ });
52
57
  return;
53
58
  case "restart":
54
- await start(config, mainWorktree, { evict: command.evict, restart: true });
59
+ await start(config, mainWorktree, registryDir, { evict: command.evict, restart: true });
55
60
  return;
56
61
  case "status":
57
- printStatus(config, mainWorktree);
62
+ printStatus(config, mainWorktree, registryDir);
58
63
  return;
59
64
  case "foreground":
60
- await runForeground(config, mainWorktree, {
65
+ await runForeground(config, mainWorktree, registryDir, {
61
66
  evict: command.evict,
62
67
  restart: command.restart,
63
68
  });
64
69
  return;
65
70
  }
66
71
  }
67
- function printStatus(config, mainWorktree) {
68
- const entry = findOwnEntry(mainWorktree, config.registryDir, process.cwd());
72
+ function printStatus(config, mainWorktree, registryDir) {
73
+ const entry = findOwnEntry(mainWorktree, registryDir, process.cwd());
69
74
  if (!entry || !Object.values(entry.pids).some(isProcessAlive)) {
70
75
  console.log("Dev-server status: DOWN.");
71
76
  return;
72
77
  }
73
78
  console.log("Dev-server status: UP.");
74
- const slot = resolveCurrentSlot(config.basePort, config.registryDir);
79
+ const slot = resolveCurrentSlot(config.basePort, registryDir);
75
80
  printStartSummary(config, slot, entry.pids);
76
81
  }
77
82
  function callbackServersOf(config) {
78
83
  return config.servers.filter((s) => s.kind === "callback");
79
84
  }
80
- async function start(config, mainWorktree, options) {
85
+ async function start(config, mainWorktree, registryDir, options) {
81
86
  const ctx = { cwd: process.cwd() };
82
- if (await runStartChecks(config, mainWorktree, ctx, options))
87
+ if (await runStartChecks(config, mainWorktree, registryDir, ctx, options))
83
88
  return;
84
89
  const state = { spawnPids: {}, startedCallbacks: [] };
85
90
  await spawnWithRollback(config, ctx, state);
86
- const slot = registerStartedServer(config, mainWorktree, state.spawnPids);
91
+ const slot = registerStartedServer(config, mainWorktree, registryDir, state.spawnPids);
87
92
  printStartSummary(config, slot, state.spawnPids);
88
93
  }
89
94
  /**
@@ -91,10 +96,10 @@ async function start(config, mainWorktree, options) {
91
96
  * handlers are installed before starting so an interrupt during startup rolls back; after a
92
97
  * successful start they switch to the local stop sequence.
93
98
  */
94
- async function runForeground(config, mainWorktree, options) {
99
+ async function runForeground(config, mainWorktree, registryDir, options) {
95
100
  const ctx = { cwd: process.cwd() };
96
101
  if (!options.restart) {
97
- const running = findOwnLiveEntry(config, mainWorktree, ctx.cwd);
102
+ const running = findOwnLiveEntry(mainWorktree, registryDir, ctx.cwd);
98
103
  if (running)
99
104
  return attachForeground(config, running);
100
105
  }
@@ -106,7 +111,7 @@ async function runForeground(config, mainWorktree, options) {
106
111
  return;
107
112
  shuttingDown = true;
108
113
  if (started) {
109
- void shutdownForeground(config, mainWorktree);
114
+ void shutdownForeground(config, mainWorktree, registryDir);
110
115
  }
111
116
  else {
112
117
  void rollbackStart(state.spawnPids, state.startedCallbacks, ctx).then(() => process.exit(130));
@@ -114,7 +119,7 @@ async function runForeground(config, mainWorktree, options) {
114
119
  };
115
120
  process.on("SIGINT", onSignal);
116
121
  process.on("SIGTERM", onSignal);
117
- if (await runStartChecks(config, mainWorktree, ctx, options))
122
+ if (await runStartChecks(config, mainWorktree, registryDir, ctx, options))
118
123
  process.exit(0);
119
124
  // Stream logs from the first byte so the whole startup (e.g. a slow build) is visible live.
120
125
  const streamLogs = () => tailLogs(config, state.spawnPids, { fromStart: true });
@@ -123,7 +128,7 @@ async function runForeground(config, mainWorktree, options) {
123
128
  if (!(await spawnWithRollback(config, ctx, state, () => shuttingDown, streamLogs))) {
124
129
  await new Promise(() => { });
125
130
  }
126
- const slot = registerStartedServer(config, mainWorktree, state.spawnPids);
131
+ const slot = registerStartedServer(config, mainWorktree, registryDir, state.spawnPids);
127
132
  started = true;
128
133
  printStartSummary(config, slot, state.spawnPids);
129
134
  watchForExternalStop(Object.values(state.spawnPids), () => {
@@ -135,8 +140,8 @@ async function runForeground(config, mainWorktree, options) {
135
140
  });
136
141
  await new Promise(() => { });
137
142
  }
138
- function findOwnLiveEntry(config, mainWorktree, cwd) {
139
- const entry = findOwnEntry(mainWorktree, config.registryDir, cwd);
143
+ function findOwnLiveEntry(mainWorktree, registryDir, cwd) {
144
+ const entry = findOwnEntry(mainWorktree, registryDir, cwd);
140
145
  if (!entry)
141
146
  return;
142
147
  return Object.values(entry.pids).some(isProcessAlive) ? entry : undefined;
@@ -171,18 +176,18 @@ async function attachForeground(config, entry) {
171
176
  });
172
177
  await new Promise(() => { });
173
178
  }
174
- async function shutdownForeground(config, mainWorktree) {
179
+ async function shutdownForeground(config, mainWorktree, registryDir) {
175
180
  console.log("\nStopping dev servers...");
176
- await stopLocal(config, mainWorktree);
181
+ await stopLocal(config, mainWorktree, registryDir);
177
182
  console.log("Stopped.");
178
183
  process.exit(0);
179
184
  }
180
- async function runStartChecks(config, mainWorktree, ctx, { evict, restart }) {
181
- checkWorktreeReady(config, mainWorktree, ctx.cwd);
182
- if (await handleAlreadyRunning(config, mainWorktree, ctx, restart))
185
+ async function runStartChecks(config, mainWorktree, registryDir, ctx, { evict, restart }) {
186
+ checkWorktreeReady(config, mainWorktree, registryDir, ctx.cwd);
187
+ if (await handleAlreadyRunning(config, mainWorktree, registryDir, ctx, restart))
183
188
  return true;
184
- await enforceCap(config, mainWorktree, evict);
185
- checkNoLocalRegistryConflict(config, mainWorktree, ctx.cwd);
189
+ await enforceCap(config, mainWorktree, registryDir, evict);
190
+ checkNoLocalRegistryConflict(mainWorktree, registryDir, ctx.cwd);
186
191
  await checkPortsFree(config.servers, ctx.cwd);
187
192
  return false;
188
193
  }
@@ -244,8 +249,8 @@ async function spawnAndAwait(config, ctx, state, onSpawned) {
244
249
  const pollPids = spawnEntries.map((s) => state.spawnPids[s.name]);
245
250
  await awaitAllReady(pollables, pollPids);
246
251
  }
247
- function registerStartedServer(config, mainWorktree, spawnPids) {
248
- const slot = resolveCurrentSlot(config.basePort, config.registryDir);
252
+ function registerStartedServer(config, mainWorktree, registryDir, spawnPids) {
253
+ const slot = resolveCurrentSlot(config.basePort, registryDir);
249
254
  const devEntry = {
250
255
  slot: slot.slot,
251
256
  worktree: slot.worktree,
@@ -255,7 +260,7 @@ function registerStartedServer(config, mainWorktree, spawnPids) {
255
260
  };
256
261
  if (slot.main)
257
262
  devEntry.main = true;
258
- registerDevServer(mainWorktree, config.registryDir, devEntry);
263
+ registerDevServer(mainWorktree, registryDir, devEntry);
259
264
  return slot;
260
265
  }
261
266
  function printStartSummary(config, slot, spawnPids) {
@@ -382,9 +387,9 @@ export function buildWorktreeReadyMessage(input) {
382
387
  "Re-run `workspace setup` to retry the finalize.",
383
388
  };
384
389
  }
385
- function checkWorktreeReady(config, mainWorktree, cwd) {
386
- const slot = resolveCurrentSlot(config.basePort, config.registryDir);
387
- const entry = readSlots(mainWorktree, config.registryDir).slots[String(slot.slot)];
390
+ function checkWorktreeReady(config, mainWorktree, registryDir, cwd) {
391
+ const slot = resolveCurrentSlot(config.basePort, registryDir);
392
+ const entry = readSlots(mainWorktree, registryDir).slots[String(slot.slot)];
388
393
  const result = buildWorktreeReadyMessage({
389
394
  slotPort: slot.slot,
390
395
  worktreePath: cwd,
@@ -402,8 +407,8 @@ function checkWorktreeReady(config, mainWorktree, cwd) {
402
407
  * normal start path can proceed, or print a friendly notice and return `true` to short-circuit.
403
408
  * Returns `true` when the caller should exit cleanly without starting.
404
409
  */
405
- async function handleAlreadyRunning(config, mainWorktree, ctx, restart) {
406
- const entry = findOwnEntry(mainWorktree, config.registryDir, ctx.cwd);
410
+ async function handleAlreadyRunning(config, mainWorktree, registryDir, ctx, restart) {
411
+ const entry = findOwnEntry(mainWorktree, registryDir, ctx.cwd);
407
412
  if (!entry)
408
413
  return false;
409
414
  const livePids = Object.entries(entry.pids).filter(([, pid]) => isProcessAlive(pid));
@@ -411,7 +416,7 @@ async function handleAlreadyRunning(config, mainWorktree, ctx, restart) {
411
416
  return false;
412
417
  if (restart) {
413
418
  console.log("Restarting dev-server in this worktree...");
414
- await stopLocal(config, mainWorktree);
419
+ await stopLocal(config, mainWorktree, registryDir);
415
420
  return false;
416
421
  }
417
422
  const pidList = livePids.map(([name, pid]) => `${name}=${pid}`).join(", ");
@@ -422,11 +427,11 @@ async function handleAlreadyRunning(config, mainWorktree, ctx, restart) {
422
427
  // TOCTOU: the cap check and the subsequent register are not atomic. Two concurrent `dev up --evict`
423
428
  // from different worktrees can both pass the cap check and both register, exceeding the limit by
424
429
  // one. Accepted: the race window is narrow and the consequence is bounded (one extra dev-server).
425
- async function enforceCap(config, mainWorktree, evict) {
430
+ async function enforceCap(config, mainWorktree, registryDir, evict) {
426
431
  const limit = config.devLimit;
427
432
  if (limit === undefined)
428
433
  return;
429
- const active = pruneAndPersist(mainWorktree, config.registryDir).servers;
434
+ const active = pruneAndPersist(mainWorktree, registryDir).servers;
430
435
  if (active.length < limit)
431
436
  return;
432
437
  if (!evict) {
@@ -440,7 +445,7 @@ async function enforceCap(config, mainWorktree, evict) {
440
445
  console.log(`Evicting ${toEvict} dev-server(s) to make room (cap ${limit}).`);
441
446
  const evicted = await evictOldest({
442
447
  mainWorktree,
443
- registryDir: config.registryDir,
448
+ registryDir,
444
449
  callbackServers: callbackServersOf(config),
445
450
  count: toEvict,
446
451
  });
@@ -482,8 +487,8 @@ async function checkPortsFree(servers, cwd) {
482
487
  process.exit(1);
483
488
  }
484
489
  }
485
- function checkNoLocalRegistryConflict(config, mainWorktree, cwd) {
486
- const entry = findOwnEntry(mainWorktree, config.registryDir, cwd);
490
+ function checkNoLocalRegistryConflict(mainWorktree, registryDir, cwd) {
491
+ const entry = findOwnEntry(mainWorktree, registryDir, cwd);
487
492
  if (!entry)
488
493
  return;
489
494
  for (const [name, pid] of Object.entries(entry.pids)) {
@@ -493,11 +498,11 @@ function checkNoLocalRegistryConflict(config, mainWorktree, cwd) {
493
498
  }
494
499
  }
495
500
  // Stale entry — drop it so registration overwrites cleanly.
496
- removeDevServerEntryByWorktree(mainWorktree, config.registryDir, cwd);
501
+ removeDevServerEntryByWorktree(mainWorktree, registryDir, cwd);
497
502
  }
498
- async function stopLocal(config, mainWorktree) {
503
+ async function stopLocal(config, mainWorktree, registryDir) {
499
504
  const ctx = { cwd: process.cwd() };
500
- const entry = findOwnEntry(mainWorktree, config.registryDir, ctx.cwd);
505
+ const entry = findOwnEntry(mainWorktree, registryDir, ctx.cwd);
501
506
  if (!entry) {
502
507
  console.log("No dev-server running in this worktree.");
503
508
  await sweepStalePorts(config.servers, ctx.cwd);
@@ -519,7 +524,7 @@ async function stopLocal(config, mainWorktree) {
519
524
  console.error(` Failed to stop ${server.name}: ${err.message}`);
520
525
  }
521
526
  }
522
- unregisterDevServer(mainWorktree, config.registryDir, ctx.cwd);
527
+ unregisterDevServer(mainWorktree, registryDir, ctx.cwd);
523
528
  await sweepStalePorts(config.servers, ctx.cwd);
524
529
  }
525
530
  function defaultPrintSummary(slot, servers, runtimeDir) {
@@ -42,3 +42,5 @@ export declare function pruneDeadServers(data: DevServersData, isAlive?: IsAlive
42
42
  export declare function liveWorktrees(data: DevServersData, isAlive?: IsAliveFn): Set<string>;
43
43
  export declare function readDevServers(mainWorktree: string, registryDir: string): DevServersData;
44
44
  export declare function writeDevServers(mainWorktree: string, registryDir: string, data: DevServersData): void;
45
+ /** Union by resolved `worktree`; `override` wins on conflict. Base-first then override-only order. */
46
+ export declare function mergeDevServers(base: DevServersData, override: DevServersData): DevServersData;
@@ -136,6 +136,17 @@ export function writeDevServers(mainWorktree, registryDir, data) {
136
136
  mkdirSync(join(mainWorktree, registryDir), { recursive: true });
137
137
  writeFileSync(fp, `${JSON.stringify(data, undefined, 2)}\n`);
138
138
  }
139
+ /** Union by resolved `worktree`; `override` wins on conflict. Base-first then override-only order. */
140
+ export function mergeDevServers(base, override) {
141
+ const overrideByWorktree = new Map(override.servers.map((e) => [resolve(e.worktree), e]));
142
+ const merged = base.servers.map((entry) => overrideByWorktree.get(resolve(entry.worktree)) ?? entry);
143
+ const baseWorktrees = new Set(base.servers.map((e) => resolve(e.worktree)));
144
+ for (const entry of override.servers) {
145
+ if (!baseWorktrees.has(resolve(entry.worktree)))
146
+ merged.push(entry);
147
+ }
148
+ return { servers: merged };
149
+ }
139
150
  function filePath(mainWorktree, registryDir) {
140
151
  return join(mainWorktree, registryDir, DEV_SERVERS_FILENAME);
141
152
  }
package/dist/slots.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import { type PortScheme } from "./ports.js";
2
+ export declare const REGISTRY_SUBDIR = "shared-registry";
3
+ export declare function registryDirFor(runtimeDir: string): string;
4
+ /** `registryDir` is gone from the config types but may linger in a consumer's config file. */
5
+ export declare function warnLegacyRegistryDir(config: {
6
+ runtimeDir: string;
7
+ registryDir?: string;
8
+ }): void;
2
9
  export interface ResolvedSlot {
3
10
  slot: number;
4
11
  worktree: string;
@@ -24,6 +31,8 @@ export interface SlotsRegistry {
24
31
  }
25
32
  export declare function readSlots(mainWorktree: string, registryDir: string): SlotsRegistry;
26
33
  export declare function writeSlots(mainWorktree: string, registryDir: string, registry: SlotsRegistry): void;
34
+ /** Union of slots keyed by port; `override` wins on conflict. */
35
+ export declare function mergeSlots(base: SlotsRegistry, override: SlotsRegistry): SlotsRegistry;
27
36
  export interface RegisterSlotInput {
28
37
  slot?: string;
29
38
  currentWorktree: string;
package/dist/slots.js CHANGED
@@ -3,7 +3,20 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { allPorts, isValidPort } from "./ports.js";
5
5
  import { getWorktreeBranch } from "./worktree.js";
6
+ export const REGISTRY_SUBDIR = "shared-registry";
6
7
  const SLOTS_FILENAME = "slots.json";
8
+ export function registryDirFor(runtimeDir) {
9
+ return join(runtimeDir, REGISTRY_SUBDIR);
10
+ }
11
+ /** `registryDir` is gone from the config types but may linger in a consumer's config file. */
12
+ export function warnLegacyRegistryDir(config) {
13
+ if (config.registryDir === undefined)
14
+ return;
15
+ console.warn("Warning: `registryDir` is obsolete and ignored. The registry now lives at " +
16
+ `\`${registryDirFor(config.runtimeDir)}\`. Remove \`registryDir\` from your config. ` +
17
+ `If you have an existing registry at "${config.registryDir}", run ` +
18
+ `\`workspace migrate-0.16 ${config.registryDir}\` once to merge it.`);
19
+ }
7
20
  export function readSlots(mainWorktree, registryDir) {
8
21
  const filePath = join(mainWorktree, registryDir, SLOTS_FILENAME);
9
22
  if (!existsSync(filePath))
@@ -15,6 +28,10 @@ export function writeSlots(mainWorktree, registryDir, registry) {
15
28
  mkdirSync(join(mainWorktree, registryDir), { recursive: true });
16
29
  writeFileSync(filePath, `${JSON.stringify(registry, undefined, 2)}\n`);
17
30
  }
31
+ /** Union of slots keyed by port; `override` wins on conflict. */
32
+ export function mergeSlots(base, override) {
33
+ return { slots: { ...base.slots, ...override.slots } };
34
+ }
18
35
  export function resolveAndRegisterSlot(input) {
19
36
  const registry = readSlots(input.mainWorktree, input.registryDir);
20
37
  const port = pickSlotPort(input, registry);
@@ -32,12 +32,6 @@ export interface WorkspaceConfig {
32
32
  * Holds the setup log and dev-server logs.
33
33
  */
34
34
  runtimeDir: string;
35
- /**
36
- * Shared registry directory, relative to a worktree root (e.g. `.local/_workspace-registry`).
37
- * Holds `slots.json` and `dev-servers.json`. Must resolve to the same physical directory
38
- * across linked worktrees — typically via a symlink listed in `sharedDirs` (e.g. `.local`).
39
- */
40
- registryDir: string;
41
35
  /** Config files copied from the main worktree and patched per slot. */
42
36
  configFiles: ConfigFileEntry[];
43
37
  /**
package/dist/workspace.js CHANGED
@@ -1,14 +1,14 @@
1
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";
2
+ import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { parseWorkspaceArgs, printWorkspaceHelp } from "./cli.js";
5
- import { findOwnEntry, liveWorktrees, readDevServers, removeDevServerEntryByWorktree, } from "./dev-servers-registry.js";
5
+ import { findOwnEntry, liveWorktrees, mergeDevServers, readDevServers, removeDevServerEntryByWorktree, writeDevServers, } from "./dev-servers-registry.js";
6
6
  import { ConfigError } from "./errors.js";
7
7
  import { copyAndPatchFile, formatDuration, setupLogPath, } from "./helpers.js";
8
8
  import { isProcessAlive } from "./process-control.js";
9
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";
10
+ import { handleSetOwner, markSlotFailed, markSlotReady, mergeSlots, readSlots, REGISTRY_SUBDIR, registryDirFor, resolveAndRegisterSlot, resolveCurrentSlot, validateSlotAvailability, warnLegacyRegistryDir, writeSlots, } from "./slots.js";
11
+ import { createBranch, detectWorktree, getWorktreeBranch, isWorktreeDirty, removeWorktree, useExistingBranch, } from "./worktree.js";
12
12
  export async function runWorkspace(config) {
13
13
  let command;
14
14
  let verbose;
@@ -27,6 +27,12 @@ export async function runWorkspace(config) {
27
27
  printWorkspaceHelp();
28
28
  return;
29
29
  }
30
+ warnLegacyRegistryDir(config);
31
+ const registryDir = registryDirFor(config.runtimeDir);
32
+ if (command.kind === "migrate") {
33
+ handleMigrate(command, config, registryDir);
34
+ return;
35
+ }
30
36
  if (!existsSync(config.scriptPath)) {
31
37
  console.error(`Error: scriptPath does not exist: ${config.scriptPath}. ` +
32
38
  "Pass `fileURLToPath(import.meta.url)` from your wrapper script.");
@@ -34,53 +40,52 @@ export async function runWorkspace(config) {
34
40
  }
35
41
  switch (command.kind) {
36
42
  case "finalize":
37
- await runFinalize(command, config);
43
+ await runFinalize(command, config, registryDir);
38
44
  return;
39
45
  case "wait":
40
- await runWait(command, config);
46
+ await runWait(command, config, registryDir);
41
47
  return;
42
48
  case "status":
43
- runStatus(command, config);
49
+ runStatus(command, config, registryDir);
44
50
  return;
45
51
  case "list":
46
- runList(config);
52
+ runList(registryDir);
47
53
  return;
48
54
  }
49
55
  const ctx = detectWorktree();
50
- enforceWorktreeMode(command, ctx);
51
56
  const run = { verbose };
52
57
  switch (command.kind) {
53
58
  case "remove":
54
- await handleRemove(command, ctx, run, config);
59
+ await handleRemove(command, ctx, run, config, registryDir);
55
60
  return;
56
61
  case "set-owner":
57
- handleSetOwnerMode(command, ctx, config);
62
+ handleSetOwnerMode(command, ctx, registryDir);
58
63
  return;
59
64
  case "setup": {
60
- const { slot } = await runSetup(command, ctx, run, config);
65
+ const { slot } = await runSetup(command, ctx, run, config, registryDir);
61
66
  if (command.wait)
62
- await waitForSlot(slot, config, { printSummary: false });
67
+ await waitForSlot(slot, config, registryDir, { printSummary: false });
63
68
  return;
64
69
  }
65
70
  }
66
71
  }
67
- async function runSetup(command, ctx, run, config) {
72
+ async function runSetup(command, ctx, run, config, registryDir) {
68
73
  const scheme = resolvePortScheme(config);
69
74
  const portsFn = resolvePortsFn(config);
70
75
  validateSlotAvailability(command.slot, {
71
76
  currentWorktree: ctx.currentWorktree,
72
77
  mainWorktree: ctx.mainWorktree,
73
- registryDir: config.registryDir,
78
+ registryDir,
74
79
  scheme,
75
80
  });
76
81
  const setupCtx = ensureWorktree(command, ctx, run, config.worktreeDirName);
77
- refuseIfFinalizePending(setupCtx, config.registryDir, command.force);
82
+ refuseIfFinalizePending(setupCtx, registryDir, command.force);
78
83
  const branch = getWorktreeBranch(setupCtx.currentWorktree) ?? "(detached)";
79
84
  const { port: slot, owner, status, } = resolveAndRegisterSlot({
80
85
  slot: command.slot,
81
86
  currentWorktree: setupCtx.currentWorktree,
82
87
  mainWorktree: setupCtx.mainWorktree,
83
- registryDir: config.registryDir,
88
+ registryDir,
84
89
  scheme,
85
90
  requestedOwner: command.owner,
86
91
  isMainWorktree: setupCtx.isMainWorktree,
@@ -117,6 +122,7 @@ async function runSetup(command, ctx, run, config) {
117
122
  });
118
123
  }
119
124
  linkSharedDirectories(setupCtx, config.sharedDirs, verboseLog);
125
+ linkSharedRegistry(setupCtx, config.runtimeDir, verboseLog);
120
126
  generateConfigFiles(setupCtx, config.configFiles, slot, ports, command.force, verboseLog);
121
127
  teeLog(config.printSummary({
122
128
  slot,
@@ -159,14 +165,14 @@ function refuseIfFinalizePending(ctx, registryDir, force) {
159
165
  "then retry. Use --force to bypass.");
160
166
  process.exit(1);
161
167
  }
162
- async function runFinalize(command, config) {
168
+ async function runFinalize(command, config, registryDir) {
163
169
  const slot = Number(command.slot);
164
170
  const ctx = detectWorktree();
165
171
  const logPath = setupLogPath(ctx.currentWorktree, config.runtimeDir);
166
172
  const appendLog = (message) => {
167
173
  appendFileSync(logPath, `${message}\n`);
168
174
  };
169
- const registry = readSlots(ctx.mainWorktree, config.registryDir);
175
+ const registry = readSlots(ctx.mainWorktree, registryDir);
170
176
  const entry = registry.slots[String(slot)];
171
177
  if (!entry || resolve(entry.worktree) !== resolve(ctx.currentWorktree)) {
172
178
  appendLog(`FAILED: No matching slot ${slot} for worktree ${ctx.currentWorktree}.`);
@@ -193,7 +199,7 @@ async function runFinalize(command, config) {
193
199
  };
194
200
  try {
195
201
  await config.finalizeWorktree(setupContext);
196
- markSlotReady(ctx.mainWorktree, config.registryDir, slot);
202
+ markSlotReady(ctx.mainWorktree, registryDir, slot);
197
203
  appendLog("============================================================");
198
204
  appendLog(`READY: branch ${branch} (slot ${slot})`);
199
205
  appendLog("============================================================");
@@ -201,14 +207,14 @@ async function runFinalize(command, config) {
201
207
  catch (err) {
202
208
  const message = err.message;
203
209
  const stack = err.stack ?? "";
204
- markSlotFailed(ctx.mainWorktree, config.registryDir, slot, message);
210
+ markSlotFailed(ctx.mainWorktree, registryDir, slot, message);
205
211
  appendLog(`FAILED: ${message}`);
206
212
  if (stack)
207
213
  appendLog(stack);
208
214
  process.exit(1);
209
215
  }
210
216
  }
211
- function resolveTargetSlot(slotArg, config) {
217
+ function resolveTargetSlot(slotArg, config, registryDir) {
212
218
  if (slotArg !== undefined) {
213
219
  const slot = Number(slotArg);
214
220
  const scheme = resolvePortScheme(config);
@@ -218,11 +224,11 @@ function resolveTargetSlot(slotArg, config) {
218
224
  }
219
225
  return slot;
220
226
  }
221
- return resolveCurrentSlot(config.basePort, config.registryDir).slot;
227
+ return resolveCurrentSlot(config.basePort, registryDir).slot;
222
228
  }
223
- function printWorktreeInfo(config, slot, worktreeForLog, fallback) {
229
+ function printWorktreeInfo(config, registryDir, slot, worktreeForLog, fallback) {
224
230
  const ctx = detectWorktree();
225
- const registry = readSlots(ctx.mainWorktree, config.registryDir);
231
+ const registry = readSlots(ctx.mainWorktree, registryDir);
226
232
  const entry = registry.slots[String(slot)];
227
233
  const ports = resolvePortsFn(config)(slot);
228
234
  const owner = entry?.owner ?? fallback.owner;
@@ -252,10 +258,10 @@ function printWorktreeInfo(config, slot, worktreeForLog, fallback) {
252
258
  const elapsed = formatDuration(now - Date.parse(entry.createdAt));
253
259
  console.log(`Pending since ${elapsed} ago (tail ${setupLogPath})`);
254
260
  }
255
- printDevServerBlock(config, ctx.mainWorktree, targetWorktree, now);
261
+ printDevServerBlock(config, registryDir, ctx.mainWorktree, targetWorktree, now);
256
262
  }
257
- function printDevServerBlock(config, mainWorktree, targetWorktree, now) {
258
- const entry = findOwnEntry(mainWorktree, config.registryDir, targetWorktree);
263
+ function printDevServerBlock(config, registryDir, mainWorktree, targetWorktree, now) {
264
+ const entry = findOwnEntry(mainWorktree, registryDir, targetWorktree);
259
265
  const liveEntries = entry
260
266
  ? Object.entries(entry.pids)
261
267
  .filter(([, pid]) => isProcessAlive(pid))
@@ -272,31 +278,31 @@ function printDevServerBlock(config, mainWorktree, targetWorktree, now) {
272
278
  console.log(` log: ${join(targetWorktree, config.runtimeDir, "logs", `${name}.log`)}`);
273
279
  }
274
280
  }
275
- function runStatus(command, config) {
281
+ function runStatus(command, config, registryDir) {
276
282
  if (command.slot !== undefined) {
277
- const slot = resolveTargetSlot(command.slot, config);
283
+ const slot = resolveTargetSlot(command.slot, config, registryDir);
278
284
  const ctx = detectWorktree();
279
- const entry = readSlots(ctx.mainWorktree, config.registryDir).slots[String(slot)];
285
+ const entry = readSlots(ctx.mainWorktree, registryDir).slots[String(slot)];
280
286
  if (!entry) {
281
287
  console.error(`Error: No slot ${slot} in registry.`);
282
288
  process.exit(1);
283
289
  }
284
- printWorktreeInfo(config, slot, entry.worktree, { owner: entry.owner });
290
+ printWorktreeInfo(config, registryDir, slot, entry.worktree, { owner: entry.owner });
285
291
  return;
286
292
  }
287
- const resolved = resolveCurrentSlot(config.basePort, config.registryDir);
288
- printWorktreeInfo(config, resolved.slot, resolved.worktree, {
293
+ const resolved = resolveCurrentSlot(config.basePort, registryDir);
294
+ printWorktreeInfo(config, registryDir, resolved.slot, resolved.worktree, {
289
295
  owner: resolved.owner,
290
296
  });
291
297
  }
292
- function runList(config) {
298
+ function runList(registryDir) {
293
299
  const ctx = detectWorktree();
294
- const entries = Object.entries(readSlots(ctx.mainWorktree, config.registryDir).slots).sort(([a], [b]) => Number(a) - Number(b));
300
+ const entries = Object.entries(readSlots(ctx.mainWorktree, registryDir).slots).sort(([a], [b]) => Number(a) - Number(b));
295
301
  if (entries.length === 0) {
296
302
  console.log("No workspaces registered.");
297
303
  return;
298
304
  }
299
- const liveSet = liveWorktrees(readDevServers(ctx.mainWorktree, config.registryDir));
305
+ const liveSet = liveWorktrees(readDevServers(ctx.mainWorktree, registryDir));
300
306
  const rows = entries.map(([port, e]) => ({
301
307
  slot: port,
302
308
  type: e.main ? "main" : "linked",
@@ -331,15 +337,15 @@ function runList(config) {
331
337
  for (const r of rows)
332
338
  console.log(fmt(r));
333
339
  }
334
- async function runWait(command, config) {
340
+ async function runWait(command, config, registryDir) {
335
341
  // standalone `workspace wait` (no prior setup in this invocation) → print the full summary on success.
336
- const slot = resolveTargetSlot(command.slot, config);
337
- await waitForSlot(slot, config);
342
+ const slot = resolveTargetSlot(command.slot, config, registryDir);
343
+ await waitForSlot(slot, config, registryDir);
338
344
  }
339
- async function waitForSlot(slot, config, options = {}) {
345
+ async function waitForSlot(slot, config, registryDir, options = {}) {
340
346
  const printSummary = options.printSummary ?? true;
341
347
  const ctx = detectWorktree();
342
- const initial = readSlots(ctx.mainWorktree, config.registryDir).slots[String(slot)];
348
+ const initial = readSlots(ctx.mainWorktree, registryDir).slots[String(slot)];
343
349
  if (!initial) {
344
350
  console.error(`Error: No slot ${slot} in registry.`);
345
351
  process.exit(1);
@@ -348,7 +354,7 @@ async function waitForSlot(slot, config, options = {}) {
348
354
  // Poll slots.json — the finalize child writes `status` on success or failure. Tiny file, no
349
355
  // log-tailing race.
350
356
  for (;;) {
351
- const entry = readSlots(ctx.mainWorktree, config.registryDir).slots[String(slot)];
357
+ const entry = readSlots(ctx.mainWorktree, registryDir).slots[String(slot)];
352
358
  if (!entry) {
353
359
  console.error(`Error: Slot ${slot} disappeared from registry.`);
354
360
  process.exit(1);
@@ -356,7 +362,7 @@ async function waitForSlot(slot, config, options = {}) {
356
362
  if (entry.status === "ready") {
357
363
  console.log("\n… ready");
358
364
  if (printSummary) {
359
- printWorktreeInfo(config, slot, entry.worktree, {
365
+ printWorktreeInfo(config, registryDir, slot, entry.worktree, {
360
366
  owner: entry.owner,
361
367
  });
362
368
  }
@@ -371,10 +377,10 @@ async function waitForSlot(slot, config, options = {}) {
371
377
  await new Promise((r) => setTimeout(r, pollMs));
372
378
  }
373
379
  }
374
- async function handleRemove(command, ctx, run, config) {
380
+ async function handleRemove(command, ctx, run, config, registryDir) {
375
381
  const verboseLog = makeVerboseLog(run.verbose);
376
382
  const removeHere = command.branch === undefined;
377
- const registry = readSlots(ctx.mainWorktree, config.registryDir);
383
+ const registry = readSlots(ctx.mainWorktree, registryDir);
378
384
  const target = resolveRemoveTarget(command, ctx, registry);
379
385
  // Refuse to remove while the detached finalize is still writing to slots.json / workspace-setup.log:
380
386
  // racing the two corrupts the registry and leaves the worktree directory orphaned.
@@ -383,18 +389,19 @@ async function handleRemove(command, ctx, run, config) {
383
389
  `Run 'workspace wait --slot ${target.slotPort}' to wait for it to finish (or fail), then retry the removal.`);
384
390
  process.exit(1);
385
391
  }
386
- if (!command.noRemoteCheck) {
387
- verifyBranchAbsentFromRemote(target.branch, run);
388
- }
389
392
  const ownerSuffix = target.owner ? `, owner ${target.owner}` : "";
390
393
  if (!existsSync(target.worktreePath)) {
391
394
  console.warn(`Warning: Worktree directory ${target.worktreePath} not found. Cleaning up registry only.`);
392
395
  delete registry.slots[target.slotPort];
393
- writeSlots(ctx.mainWorktree, config.registryDir, registry);
396
+ writeSlots(ctx.mainWorktree, registryDir, registry);
394
397
  console.log(`Removed registry entry for branch "${target.branch}" (slot ${target.slotPort}${ownerSuffix}).`);
395
398
  return;
396
399
  }
397
- const targetEntry = findOwnEntry(ctx.mainWorktree, config.registryDir, target.worktreePath);
400
+ if (!command.force && isWorktreeDirty(target.worktreePath)) {
401
+ console.error(`Error: Uncommitted changes in ${target.worktreePath}. Commit or stash them, or pass --force.`);
402
+ process.exit(1);
403
+ }
404
+ const targetEntry = findOwnEntry(ctx.mainWorktree, registryDir, target.worktreePath);
398
405
  if (targetEntry) {
399
406
  stopTargetDevServer(config.devServerScript, target.worktreePath, verboseLog);
400
407
  }
@@ -409,8 +416,8 @@ async function handleRemove(command, ctx, run, config) {
409
416
  });
410
417
  }
411
418
  delete registry.slots[target.slotPort];
412
- writeSlots(ctx.mainWorktree, config.registryDir, registry);
413
- removeDevServerEntryByWorktree(ctx.mainWorktree, config.registryDir, target.worktreePath);
419
+ writeSlots(ctx.mainWorktree, registryDir, registry);
420
+ removeDevServerEntryByWorktree(ctx.mainWorktree, registryDir, target.worktreePath);
414
421
  if (removeHere) {
415
422
  process.chdir(ctx.mainWorktree);
416
423
  }
@@ -421,17 +428,17 @@ async function handleRemove(command, ctx, run, config) {
421
428
  console.log(`Now run: cd ${ctx.mainWorktree}`);
422
429
  }
423
430
  }
424
- function handleSetOwnerMode(command, ctx, config) {
431
+ function handleSetOwnerMode(command, ctx, registryDir) {
425
432
  const newOwner = command.name;
426
433
  const { slotPort } = handleSetOwner({
427
434
  newOwner,
428
435
  currentWorktree: ctx.currentWorktree,
429
436
  mainWorktree: ctx.mainWorktree,
430
- registryDir: config.registryDir,
437
+ registryDir,
431
438
  isMainWorktree: ctx.isMainWorktree,
432
439
  });
433
440
  // Propagate to dev-servers.json entries for this worktree.
434
- const devServersPath = join(ctx.mainWorktree, config.registryDir, "dev-servers.json");
441
+ const devServersPath = join(ctx.mainWorktree, registryDir, "dev-servers.json");
435
442
  if (existsSync(devServersPath)) {
436
443
  const data = JSON.parse(readFileSync(devServersPath, "utf-8"));
437
444
  let changed = false;
@@ -452,11 +459,61 @@ function handleSetOwnerMode(command, ctx, config) {
452
459
  }
453
460
  console.log(`Owner for slot ${slotPort}: ${newOwner ?? "(none)"}`);
454
461
  }
462
+ /** Transitional (0.16 only): merge a pre-0.16 registry into `${runtimeDir}/shared-registry`. */
463
+ function handleMigrate(command, config, newRel) {
464
+ const ctx = detectWorktree();
465
+ const oldRel = command.oldRegistryDir;
466
+ const oldAbs = join(ctx.mainWorktree, oldRel);
467
+ if (resolve(oldAbs) === resolve(join(ctx.mainWorktree, newRel))) {
468
+ console.log(`Registry already at ${newRel}; relinking worktrees.`);
469
+ relinkWorktrees(readSlots(ctx.mainWorktree, newRel), ctx.mainWorktree, config.runtimeDir);
470
+ return;
471
+ }
472
+ refuseUnlessOldRegistry(oldAbs, ctx.mainWorktree);
473
+ const mergedSlots = mergeSlots(readSlots(ctx.mainWorktree, oldRel), readSlots(ctx.mainWorktree, newRel));
474
+ writeSlots(ctx.mainWorktree, newRel, mergedSlots);
475
+ const mergedDevServers = mergeDevServers(readDevServers(ctx.mainWorktree, oldRel), readDevServers(ctx.mainWorktree, newRel));
476
+ writeDevServers(ctx.mainWorktree, newRel, mergedDevServers);
477
+ rmSync(oldAbs, { recursive: true, force: true });
478
+ const relinked = relinkWorktrees(mergedSlots, ctx.mainWorktree, config.runtimeDir);
479
+ console.log(`Migrated ${oldRel} → ${newRel}: ${Object.keys(mergedSlots.slots).length} slot(s), ` +
480
+ `${mergedDevServers.servers.length} dev-server(s); ${relinked} symlink(s) recreated.`);
481
+ }
482
+ /** `oldAbs` is recursively deleted after the merge — refuse anything that isn't clearly a registry. */
483
+ function refuseUnlessOldRegistry(oldAbs, mainWorktree) {
484
+ const fromMain = relative(mainWorktree, resolve(oldAbs));
485
+ if (fromMain.startsWith("..") || isAbsolute(fromMain)) {
486
+ console.error(`Error: the old registry must be inside the main worktree; got ${oldAbs}.`);
487
+ process.exit(1);
488
+ }
489
+ if (!existsSync(oldAbs)) {
490
+ console.error(`Error: nothing to migrate at ${oldAbs}.`);
491
+ process.exit(1);
492
+ }
493
+ if (!existsSync(join(oldAbs, "slots.json")) && !existsSync(join(oldAbs, "dev-servers.json"))) {
494
+ console.error(`Error: ${oldAbs} does not look like a registry (no slots.json or dev-servers.json).`);
495
+ process.exit(1);
496
+ }
497
+ }
498
+ function relinkWorktrees(slots, mainWorktree, runtimeDir) {
499
+ let count = 0;
500
+ for (const entry of Object.values(slots.slots)) {
501
+ if (entry.main)
502
+ continue;
503
+ if (!existsSync(entry.worktree)) {
504
+ console.warn(`Warning: worktree ${entry.worktree} not found; skipping symlink.`);
505
+ continue;
506
+ }
507
+ linkSharedRegistry({ currentWorktree: entry.worktree, mainWorktree, isMainWorktree: false }, runtimeDir, console.log, { force: true });
508
+ ++count;
509
+ }
510
+ return count;
511
+ }
455
512
  function ensureWorktree(command, ctx, run, dirNameFn) {
456
513
  if (command.branch === undefined)
457
514
  return ctx;
458
515
  if (command.newBranch)
459
- return createBranch(command.branch, ctx, run, dirNameFn);
516
+ return createBranch(command.branch, ctx, run, dirNameFn, command.from);
460
517
  return useExistingBranch(command.branch, ctx, run, dirNameFn);
461
518
  }
462
519
  function linkSharedDirectories(ctx, dirs, log) {
@@ -476,6 +533,36 @@ function linkSharedDirectories(ctx, dirs, log) {
476
533
  }
477
534
  }
478
535
  }
536
+ /**
537
+ * Symlinks the linked worktree's `${runtimeDir}/shared-registry` to the main worktree's, so the
538
+ * cwd-relative registry read in `resolveCurrentSlot` reaches main. `runtimeDir` is per-worktree and
539
+ * not in `sharedDirs`, so this is distinct from {@link linkSharedDirectories}.
540
+ */
541
+ function linkSharedRegistry(ctx, runtimeDir, log, opts) {
542
+ if (ctx.isMainWorktree)
543
+ return;
544
+ const mainDir = join(ctx.mainWorktree, runtimeDir, REGISTRY_SUBDIR);
545
+ mkdirSync(mainDir, { recursive: true });
546
+ const runtimeRoot = join(ctx.currentWorktree, runtimeDir);
547
+ mkdirSync(runtimeRoot, { recursive: true });
548
+ const link = join(runtimeRoot, REGISTRY_SUBDIR);
549
+ // lstat, not existsSync: existsSync follows symlinks, so a broken one would read as absent and
550
+ // make symlinkSync throw EEXIST. A broken symlink is recreated even without `force`.
551
+ const linkStat = lstatSync(link, { throwIfNoEntry: false });
552
+ if (linkStat) {
553
+ if (!linkStat.isSymbolicLink()) {
554
+ log("Skipped shared-registry symlink (a real directory exists here).");
555
+ return;
556
+ }
557
+ if (!opts?.force && existsSync(link)) {
558
+ log("Skipped shared-registry symlink (already exists).");
559
+ return;
560
+ }
561
+ rmSync(link);
562
+ }
563
+ symlinkSync(relative(runtimeRoot, mainDir), link);
564
+ log("Created shared-registry symlink → main worktree.");
565
+ }
479
566
  function generateConfigFiles(ctx, entries, slot, ports, force, log) {
480
567
  for (const entry of entries) {
481
568
  const patchCtx = {
@@ -1,4 +1,3 @@
1
- import type { WorkspaceCommand } from "./cli.js";
2
1
  export interface WorktreeContext {
3
2
  currentWorktree: string;
4
3
  mainWorktree: string;
@@ -8,10 +7,9 @@ export interface RunCtx {
8
7
  verbose: boolean;
9
8
  }
10
9
  export declare function detectWorktree(): WorktreeContext;
11
- export declare function enforceWorktreeMode(command: WorkspaceCommand, ctx: WorktreeContext): void;
12
10
  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;
11
+ export declare function createBranch(requestedBranch: string, ctx: WorktreeContext, run: RunCtx, dirNameFn?: WorktreeDirNameFn, from?: string): WorktreeContext;
12
+ export declare function isWorktreeDirty(worktreePath: string): boolean;
15
13
  export declare function getWorktreeBranch(worktreePath: string): string | undefined;
16
14
  export declare function removeWorktree(worktreePath: string, run: RunCtx): void;
17
15
  /** Pure function that produces the basename of a worktree directory from a branch. */
package/dist/worktree.js CHANGED
@@ -10,14 +10,6 @@ export function detectWorktree() {
10
10
  const isMainWorktree = resolve(currentWorktree) === resolve(mainWorktree);
11
11
  return { currentWorktree, mainWorktree, isMainWorktree };
12
12
  }
13
- export function enforceWorktreeMode(command, ctx) {
14
- // Adding a worktree for a branch must happen from the main worktree. A branch-less
15
- // `workspace setup` runs anywhere: linked worktree (retry path) or main (initial bootstrap).
16
- if (command.kind === "setup" && command.branch !== undefined && !ctx.isMainWorktree) {
17
- console.error("Error: Adding a workspace for a branch must be run from the main worktree.");
18
- process.exit(1);
19
- }
20
- }
21
13
  export function useExistingBranch(branch, ctx, run, dirNameFn = defaultWorktreeDirName) {
22
14
  if (!branchExists(branch)) {
23
15
  console.error(`Error: Branch "${branch}" does not exist locally or on the remote.`);
@@ -27,7 +19,9 @@ export function useExistingBranch(branch, ctx, run, dirNameFn = defaultWorktreeD
27
19
  execFileSync("git", ["worktree", "add", worktreePath, branch], { stdio: stdioFor(run) });
28
20
  return { ...ctx, currentWorktree: worktreePath, isMainWorktree: false };
29
21
  }
30
- export function createBranch(requestedBranch, ctx, run, dirNameFn = defaultWorktreeDirName) {
22
+ export function createBranch(requestedBranch, ctx, run, dirNameFn = defaultWorktreeDirName, from) {
23
+ if (from !== undefined)
24
+ verifyFromRef(from);
31
25
  let finalBranch = requestedBranch;
32
26
  if (branchExists(finalBranch)) {
33
27
  let suffix = 2;
@@ -38,18 +32,36 @@ export function createBranch(requestedBranch, ctx, run, dirNameFn = defaultWorkt
38
32
  console.warn(`Warning: Branch "${requestedBranch}" already exists; using "${finalBranch}" instead.`);
39
33
  }
40
34
  const worktreePath = dedupeWorktreePath(computeWorktreePath(ctx.mainWorktree, finalBranch, dirNameFn));
41
- execFileSync("git", ["worktree", "add", "-b", finalBranch, worktreePath], {
42
- stdio: stdioFor(run),
43
- });
35
+ const addArgs = ["worktree", "add", "-b", finalBranch, "--end-of-options", worktreePath];
36
+ if (from !== undefined)
37
+ addArgs.push(from);
38
+ execFileSync("git", addArgs, { stdio: stdioFor(run) });
44
39
  return { ...ctx, currentWorktree: worktreePath, isMainWorktree: false };
45
40
  }
46
- export function verifyBranchAbsentFromRemote(branch, run) {
47
- execFileSync("git", ["fetch"], { stdio: stdioFor(run) });
48
- const remoteBranches = execFileSync("git", ["branch", "-r", "--list", `origin/${branch}`], {
49
- encoding: "utf-8",
50
- }).trim();
51
- if (remoteBranches.length > 0) {
52
- console.error(`Error: Branch "${branch}" still exists on the remote. Use --no-remote-check to skip this verification.`);
41
+ function verifyFromRef(from) {
42
+ try {
43
+ // `^{commit}` accepts any commit-ish: branch, origin/x, tag, SHA.
44
+ // `--end-of-options` guards against option-like refs (rev-parse treats args after `--` as paths).
45
+ execFileSync("git", ["rev-parse", "--verify", "--end-of-options", `${from}^{commit}`], {
46
+ stdio: "pipe",
47
+ });
48
+ }
49
+ catch {
50
+ console.error(`Error: --from ref "${from}" does not resolve to a commit.`);
51
+ process.exit(1);
52
+ }
53
+ }
54
+ export function isWorktreeDirty(worktreePath) {
55
+ try {
56
+ const out = execFileSync("git", ["status", "--porcelain"], {
57
+ stdio: "pipe",
58
+ cwd: worktreePath,
59
+ encoding: "utf-8",
60
+ });
61
+ return out.trim().length > 0;
62
+ }
63
+ catch {
64
+ console.error(`Error: Cannot check for uncommitted changes in ${worktreePath}. Pass --force to remove anyway.`);
53
65
  process.exit(1);
54
66
  }
55
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paleo/workspace",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Run multiple git-worktree dev environments side by side.",
5
5
  "keywords": [
6
6
  "workspace",