Rebind seeded project workspaces to the current worktree
This commit is contained in:
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { copySeededSecretsKey } from "../commands/worktree.js";
|
import { copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js";
|
||||||
import {
|
import {
|
||||||
buildWorktreeConfig,
|
buildWorktreeConfig,
|
||||||
buildWorktreeEnvEntries,
|
buildWorktreeEnvEntries,
|
||||||
@@ -171,4 +171,32 @@ describe("worktree helpers", () => {
|
|||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rebinds same-repo workspace paths onto the current worktree root", () => {
|
||||||
|
expect(
|
||||||
|
rebindWorkspaceCwd({
|
||||||
|
sourceRepoRoot: "/Users/nmurray/paperclip",
|
||||||
|
targetRepoRoot: "/Users/nmurray/paperclip-pr-432",
|
||||||
|
workspaceCwd: "/Users/nmurray/paperclip",
|
||||||
|
}),
|
||||||
|
).toBe("/Users/nmurray/paperclip-pr-432");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
rebindWorkspaceCwd({
|
||||||
|
sourceRepoRoot: "/Users/nmurray/paperclip",
|
||||||
|
targetRepoRoot: "/Users/nmurray/paperclip-pr-432",
|
||||||
|
workspaceCwd: "/Users/nmurray/paperclip/packages/db",
|
||||||
|
}),
|
||||||
|
).toBe("/Users/nmurray/paperclip-pr-432/packages/db");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not rebind paths outside the source repo root", () => {
|
||||||
|
expect(
|
||||||
|
rebindWorkspaceCwd({
|
||||||
|
sourceRepoRoot: "/Users/nmurray/paperclip",
|
||||||
|
targetRepoRoot: "/Users/nmurray/paperclip-pr-432",
|
||||||
|
workspaceCwd: "/Users/nmurray/other-project",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { execFileSync } from "node:child_process";
|
|||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
applyPendingMigrations,
|
applyPendingMigrations,
|
||||||
|
createDb,
|
||||||
ensurePostgresDatabase,
|
ensurePostgresDatabase,
|
||||||
formatDatabaseBackupResult,
|
formatDatabaseBackupResult,
|
||||||
|
projectWorkspaces,
|
||||||
runDatabaseBackup,
|
runDatabaseBackup,
|
||||||
runDatabaseRestore,
|
runDatabaseRestore,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
@@ -74,6 +77,20 @@ type EmbeddedPostgresHandle = {
|
|||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GitWorkspaceInfo = {
|
||||||
|
root: string;
|
||||||
|
commonDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeedWorktreeDatabaseResult = {
|
||||||
|
backupSummary: string;
|
||||||
|
reboundWorkspaces: Array<{
|
||||||
|
name: string;
|
||||||
|
fromCwd: string;
|
||||||
|
toCwd: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
function nonEmpty(value: string | null | undefined): string | null {
|
function nonEmpty(value: string | null | undefined): string | null {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
}
|
}
|
||||||
@@ -133,6 +150,107 @@ function detectGitBranchName(cwd: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
|
||||||
|
try {
|
||||||
|
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
}).trim();
|
||||||
|
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
}).trim();
|
||||||
|
return {
|
||||||
|
root: path.resolve(root),
|
||||||
|
commonDir: path.resolve(root, commonDirRaw),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rebindWorkspaceCwd(input: {
|
||||||
|
sourceRepoRoot: string;
|
||||||
|
targetRepoRoot: string;
|
||||||
|
workspaceCwd: string;
|
||||||
|
}): string | null {
|
||||||
|
const sourceRepoRoot = path.resolve(input.sourceRepoRoot);
|
||||||
|
const targetRepoRoot = path.resolve(input.targetRepoRoot);
|
||||||
|
const workspaceCwd = path.resolve(input.workspaceCwd);
|
||||||
|
const relative = path.relative(sourceRepoRoot, workspaceCwd);
|
||||||
|
if (!relative || relative === "") {
|
||||||
|
return targetRepoRoot;
|
||||||
|
}
|
||||||
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.resolve(targetRepoRoot, relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebindSeededProjectWorkspaces(input: {
|
||||||
|
targetConnectionString: string;
|
||||||
|
currentCwd: string;
|
||||||
|
}): Promise<SeedWorktreeDatabaseResult["reboundWorkspaces"]> {
|
||||||
|
const targetRepo = detectGitWorkspaceInfo(input.currentCwd);
|
||||||
|
if (!targetRepo) return [];
|
||||||
|
|
||||||
|
const db = createDb(input.targetConnectionString);
|
||||||
|
const closableDb = db as typeof db & {
|
||||||
|
$client?: { end?: (opts?: { timeout?: number }) => Promise<void> };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: projectWorkspaces.id,
|
||||||
|
name: projectWorkspaces.name,
|
||||||
|
cwd: projectWorkspaces.cwd,
|
||||||
|
})
|
||||||
|
.from(projectWorkspaces);
|
||||||
|
|
||||||
|
const rebound: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const workspaceCwd = nonEmpty(row.cwd);
|
||||||
|
if (!workspaceCwd) continue;
|
||||||
|
|
||||||
|
const sourceRepo = detectGitWorkspaceInfo(workspaceCwd);
|
||||||
|
if (!sourceRepo) continue;
|
||||||
|
if (sourceRepo.commonDir !== targetRepo.commonDir) continue;
|
||||||
|
|
||||||
|
const reboundCwd = rebindWorkspaceCwd({
|
||||||
|
sourceRepoRoot: sourceRepo.root,
|
||||||
|
targetRepoRoot: targetRepo.root,
|
||||||
|
workspaceCwd,
|
||||||
|
});
|
||||||
|
if (!reboundCwd) continue;
|
||||||
|
|
||||||
|
const normalizedCurrent = path.resolve(workspaceCwd);
|
||||||
|
if (reboundCwd === normalizedCurrent) continue;
|
||||||
|
if (!existsSync(reboundCwd)) continue;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(projectWorkspaces)
|
||||||
|
.set({
|
||||||
|
cwd: reboundCwd,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(projectWorkspaces.id, row.id));
|
||||||
|
|
||||||
|
rebound.push({
|
||||||
|
name: row.name,
|
||||||
|
fromCwd: normalizedCurrent,
|
||||||
|
toCwd: reboundCwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rebound;
|
||||||
|
} finally {
|
||||||
|
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||||
if (opts.fromConfig) return path.resolve(opts.fromConfig);
|
if (opts.fromConfig) return path.resolve(opts.fromConfig);
|
||||||
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
|
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
|
||||||
@@ -260,7 +378,7 @@ async function seedWorktreeDatabase(input: {
|
|||||||
targetPaths: WorktreeLocalPaths;
|
targetPaths: WorktreeLocalPaths;
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
seedMode: WorktreeSeedMode;
|
seedMode: WorktreeSeedMode;
|
||||||
}): Promise<string> {
|
}): Promise<SeedWorktreeDatabaseResult> {
|
||||||
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
||||||
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
||||||
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
|
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
|
||||||
@@ -308,8 +426,15 @@ async function seedWorktreeDatabase(input: {
|
|||||||
backupFile: backup.backupFile,
|
backupFile: backup.backupFile,
|
||||||
});
|
});
|
||||||
await applyPendingMigrations(targetConnectionString);
|
await applyPendingMigrations(targetConnectionString);
|
||||||
|
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
|
||||||
|
targetConnectionString,
|
||||||
|
currentCwd: input.targetPaths.cwd,
|
||||||
|
});
|
||||||
|
|
||||||
return formatDatabaseBackupResult(backup);
|
return {
|
||||||
|
backupSummary: formatDatabaseBackupResult(backup),
|
||||||
|
reboundWorkspaces,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (targetHandle?.startedByThisProcess) {
|
if (targetHandle?.startedByThisProcess) {
|
||||||
await targetHandle.stop();
|
await targetHandle.stop();
|
||||||
@@ -370,6 +495,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
|||||||
loadPaperclipEnvFile(paths.configPath);
|
loadPaperclipEnvFile(paths.configPath);
|
||||||
|
|
||||||
let seedSummary: string | null = null;
|
let seedSummary: string | null = null;
|
||||||
|
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
|
||||||
if (opts.seed !== false) {
|
if (opts.seed !== false) {
|
||||||
if (!sourceConfig) {
|
if (!sourceConfig) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -379,7 +505,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
|||||||
const spinner = p.spinner();
|
const spinner = p.spinner();
|
||||||
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
|
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
|
||||||
try {
|
try {
|
||||||
seedSummary = await seedWorktreeDatabase({
|
const seeded = await seedWorktreeDatabase({
|
||||||
sourceConfigPath,
|
sourceConfigPath,
|
||||||
sourceConfig,
|
sourceConfig,
|
||||||
targetConfig,
|
targetConfig,
|
||||||
@@ -387,6 +513,8 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
|||||||
instanceId,
|
instanceId,
|
||||||
seedMode,
|
seedMode,
|
||||||
});
|
});
|
||||||
|
seedSummary = seeded.backupSummary;
|
||||||
|
reboundWorkspaceSummary = seeded.reboundWorkspaces;
|
||||||
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
|
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
spinner.stop(pc.red("Failed to seed worktree database."));
|
spinner.stop(pc.red("Failed to seed worktree database."));
|
||||||
@@ -402,6 +530,11 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
|||||||
if (seedSummary) {
|
if (seedSummary) {
|
||||||
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
|
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
|
||||||
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
|
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
|
||||||
|
for (const rebound of reboundWorkspaceSummary) {
|
||||||
|
p.log.message(
|
||||||
|
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
p.outro(
|
p.outro(
|
||||||
pc.green(
|
pc.green(
|
||||||
|
|||||||
Reference in New Issue
Block a user