Avoid sibling worktree port collisions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-26 10:56:13 -05:00
parent e91da556ee
commit 555f026c24
4 changed files with 445 additions and 5 deletions

View file

@ -344,6 +344,87 @@ describe("worktree helpers", () => {
}
});
it("avoids ports already claimed by sibling worktree instance configs", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
const repoRoot = path.join(tempRoot, "repo");
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree");
const originalCwd = process.cwd();
try {
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(siblingInstanceRoot, { recursive: true });
fs.writeFileSync(
path.join(siblingInstanceRoot, "config.json"),
JSON.stringify(
{
...buildSourceConfig(),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
embeddedPostgresPort: 54330,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(siblingInstanceRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(siblingInstanceRoot, "logs"),
},
server: {
deploymentMode: "authenticated",
exposure: "private",
host: "127.0.0.1",
port: 3101,
allowedHostnames: ["localhost"],
serveUi: true,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(siblingInstanceRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(siblingInstanceRoot, "secrets", "master.key"),
},
},
},
null,
2,
) + "\n",
);
process.chdir(repoRoot);
await worktreeInitCommand({
seed: false,
fromConfig: path.join(tempRoot, "missing", "config.json"),
home: homeDir,
});
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8"));
expect(config.server.port).toBe(3102);
expect(config.database.embeddedPostgresPort).not.toBe(54330);
expect(config.database.embeddedPostgresPort).not.toBe(config.server.port);
expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330);
} finally {
process.chdir(originalCwd);
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("defaults the seed source config to the current repo-local Paperclip config", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
const repoRoot = path.join(tempRoot, "repo");

View file

@ -465,6 +465,62 @@ async function findAvailablePort(preferredPort: number, reserved = new Set<numbe
return port;
}
function resolveRepoManagedWorktreesRoot(cwd: string): string | null {
const normalized = path.resolve(cwd);
const marker = `${path.sep}.paperclip${path.sep}worktrees${path.sep}`;
const index = normalized.indexOf(marker);
if (index === -1) return null;
const repoRoot = normalized.slice(0, index);
return path.resolve(repoRoot, ".paperclip", "worktrees");
}
function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string, cwd: string): {
serverPorts: Set<number>;
databasePorts: Set<number>;
} {
const serverPorts = new Set<number>();
const databasePorts = new Set<number>();
const configPaths = new Set<string>();
const instancesDir = path.resolve(homeDir, "instances");
if (existsSync(instancesDir)) {
for (const entry of readdirSync(instancesDir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === currentInstanceId) continue;
const configPath = path.resolve(instancesDir, entry.name, "config.json");
if (existsSync(configPath)) {
configPaths.add(configPath);
}
}
}
const repoManagedWorktreesRoot = resolveRepoManagedWorktreesRoot(cwd);
if (repoManagedWorktreesRoot && existsSync(repoManagedWorktreesRoot)) {
for (const entry of readdirSync(repoManagedWorktreesRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const configPath = path.resolve(repoManagedWorktreesRoot, entry.name, ".paperclip", "config.json");
if (existsSync(configPath)) {
configPaths.add(configPath);
}
}
}
for (const configPath of configPaths) {
try {
const config = readConfig(configPath);
if (config?.server.port) {
serverPorts.add(config.server.port);
}
if (config?.database.mode === "embedded-postgres") {
databasePorts.add(config.database.embeddedPostgresPort);
}
} catch {
// Ignore malformed sibling configs.
}
}
return { serverPorts, databasePorts };
}
function detectGitBranchName(cwd: string): string | null {
try {
const value = execFileSync("git", ["branch", "--show-current"], {
@ -886,10 +942,14 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
rmSync(paths.instanceRoot, { recursive: true, force: true });
}
const claimedPorts = collectClaimedWorktreePorts(paths.homeDir, paths.instanceId, paths.cwd);
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
const serverPort = await findAvailablePort(preferredServerPort);
const serverPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts);
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
const databasePort = await findAvailablePort(
preferredDbPort,
new Set([...claimedPorts.databasePorts, serverPort]),
);
const targetConfig = buildWorktreeConfig({
sourceConfig,
paths,