From 12216b5cc60334cb5d30604f3b3a73dd5a6ed241 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 13:50:29 -0500 Subject: [PATCH] Rebind seeded project workspaces to the current worktree --- cli/src/__tests__/worktree.test.ts | 30 ++++++- cli/src/commands/worktree.ts | 139 ++++++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 4 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 9ce48cf7..ac316df2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { copySeededSecretsKey } from "../commands/worktree.js"; +import { copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js"; import { buildWorktreeConfig, buildWorktreeEnvEntries, @@ -171,4 +171,32 @@ describe("worktree helpers", () => { 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(); + }); }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 8306c8ed..4ef43572 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -5,10 +5,13 @@ import { execFileSync } from "node:child_process"; import { createServer } from "node:net"; import * as p from "@clack/prompts"; import pc from "picocolors"; +import { eq } from "drizzle-orm"; import { applyPendingMigrations, + createDb, ensurePostgresDatabase, formatDatabaseBackupResult, + projectWorkspaces, runDatabaseBackup, runDatabaseRestore, } from "@paperclipai/db"; @@ -74,6 +77,20 @@ type EmbeddedPostgresHandle = { stop: () => Promise; }; +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 { 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 { + 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 }; + }; + + 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 { if (opts.fromConfig) return path.resolve(opts.fromConfig); const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip")); @@ -260,7 +378,7 @@ async function seedWorktreeDatabase(input: { targetPaths: WorktreeLocalPaths; instanceId: string; seedMode: WorktreeSeedMode; -}): Promise { +}): Promise { const seedPlan = resolveWorktreeSeedPlan(input.seedMode); const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); @@ -308,8 +426,15 @@ async function seedWorktreeDatabase(input: { backupFile: backup.backupFile, }); await applyPendingMigrations(targetConnectionString); + const reboundWorkspaces = await rebindSeededProjectWorkspaces({ + targetConnectionString, + currentCwd: input.targetPaths.cwd, + }); - return formatDatabaseBackupResult(backup); + return { + backupSummary: formatDatabaseBackupResult(backup), + reboundWorkspaces, + }; } finally { if (targetHandle?.startedByThisProcess) { await targetHandle.stop(); @@ -370,6 +495,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise ${rebound.toCwd}`), + ); + } } p.outro( pc.green(