@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 +0 -2
- package/dist/cli.d.ts +5 -1
- package/dist/cli.js +23 -4
- package/dist/dev-server.d.ts +0 -6
- package/dist/dev-server.js +51 -46
- package/dist/dev-servers-registry.d.ts +2 -0
- package/dist/dev-servers-registry.js +11 -0
- package/dist/slots.d.ts +9 -0
- package/dist/slots.js +17 -0
- package/dist/workspace.d.ts +0 -6
- package/dist/workspace.js +145 -58
- package/dist/worktree.d.ts +2 -4
- package/dist/worktree.js +31 -19
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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,
|
|
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>] [--
|
|
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>]",
|
package/dist/dev-server.d.ts
CHANGED
|
@@ -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. */
|
package/dist/dev-server.js
CHANGED
|
@@ -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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
139
|
-
const entry = findOwnEntry(mainWorktree,
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
387
|
-
const entry = readSlots(mainWorktree,
|
|
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,
|
|
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,
|
|
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
|
|
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(
|
|
486
|
-
const entry = findOwnEntry(mainWorktree,
|
|
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,
|
|
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,
|
|
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,
|
|
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);
|
package/dist/workspace.d.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
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,
|
|
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
|
|
78
|
+
registryDir,
|
|
74
79
|
scheme,
|
|
75
80
|
});
|
|
76
81
|
const setupCtx = ensureWorktree(command, ctx, run, config.worktreeDirName);
|
|
77
|
-
refuseIfFinalizePending(setupCtx,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
298
|
+
function runList(registryDir) {
|
|
293
299
|
const ctx = detectWorktree();
|
|
294
|
-
const entries = Object.entries(readSlots(ctx.mainWorktree,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
413
|
-
removeDevServerEntryByWorktree(ctx.mainWorktree,
|
|
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,
|
|
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
|
|
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,
|
|
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 = {
|
package/dist/worktree.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
}
|