From 55d756f9a366b7d1a17828e5c4dc9bd0b4397d4e Mon Sep 17 00:00:00 2001 From: dotta Date: Sun, 5 Apr 2026 23:00:26 -0500 Subject: [PATCH] Use latest repo-managed worktree scripts on reuse Co-Authored-By: Paperclip --- .../src/__tests__/workspace-runtime.test.ts | 90 +++++++++++++++++++ server/src/services/workspace-runtime.ts | 50 +++++++++-- 2 files changed, 134 insertions(+), 6 deletions(-) diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 9d523568..fafceb20 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -483,6 +483,96 @@ describe("realizeExecutionWorkspace", () => { await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n"); }); + it("uses the latest repo-managed provision script when reusing an existing worktree", async () => { + const repoRoot = await createTempRepo(); + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "scripts", "provision.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "printf 'v1\\n' > .paperclip-provision-version", + ].join("\n"), + "utf8", + ); + await runGit(repoRoot, ["add", "scripts/provision.sh"]); + await runGit(repoRoot, ["commit", "-m", "Add initial provision script"]); + + const initial = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/provision.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-449", + title: "Reuse latest provision script", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await expect(fs.readFile(path.join(initial.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v1\n"); + + await fs.writeFile( + path.join(repoRoot, "scripts", "provision.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "printf 'v2\\n' > .paperclip-provision-version", + ].join("\n"), + "utf8", + ); + await runGit(repoRoot, ["add", "scripts/provision.sh"]); + await runGit(repoRoot, ["commit", "-m", "Update provision script"]); + + await expect(fs.readFile(path.join(initial.cwd, "scripts", "provision.sh"), "utf8")).resolves.toContain("v1"); + + const reused = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/provision.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-449", + title: "Reuse latest provision script", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v2\n"); + }); + it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => { const repoRoot = await createTempRepo(); const previousCwd = process.cwd(); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 4137a43f..176dde10 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -500,8 +500,35 @@ function buildWorkspaceCommandEnv(input: { return env; } +function quoteShellArg(value: string) { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function resolveRepoManagedWorkspaceCommand(command: string, repoRoot: string) { + const patterns = [ + /^(?(?:bash|sh|zsh)\s+)(?["']?)(?\.\/[^"'\s]+)\k(?(?:\s.*)?)$/s, + /^(?["']?)(?\.\/[^"'\s]+)\k(?(?:\s.*)?)$/s, + ]; + + for (const pattern of patterns) { + const match = command.match(pattern); + if (!match?.groups) continue; + + const relativePath = match.groups.relative; + const repoManagedPath = path.join(repoRoot, relativePath.slice(2)); + if (!existsSync(repoManagedPath)) continue; + + const prefix = match.groups.prefix ?? ""; + const suffix = match.groups.suffix ?? ""; + return `${prefix}${quoteShellArg(repoManagedPath)}${suffix}`; + } + + return command; +} + async function runWorkspaceCommand(input: { command: string; + resolvedCommand?: string; cwd: string; env: NodeJS.ProcessEnv; label: string; @@ -509,7 +536,7 @@ async function runWorkspaceCommand(input: { const shell = resolveShell(); const proc = await executeProcess({ command: shell, - args: ["-c", input.command], + args: ["-c", input.resolvedCommand ?? input.command], cwd: input.cwd, env: input.env, }); @@ -581,6 +608,7 @@ async function recordWorkspaceCommandOperation( input: { phase: "workspace_provision" | "workspace_teardown"; command: string; + resolvedCommand?: string; cwd: string; env: NodeJS.ProcessEnv; label: string; @@ -605,7 +633,7 @@ async function recordWorkspaceCommandOperation( const shell = resolveShell(); const result = await executeProcess({ command: shell, - args: ["-c", input.command], + args: ["-c", input.resolvedCommand ?? input.command], cwd: input.cwd, env: input.env, }); @@ -645,10 +673,12 @@ async function provisionExecutionWorktree(input: { }) { const provisionCommand = asString(input.strategy.provisionCommand, "").trim(); if (!provisionCommand) return; + const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot); await recordWorkspaceCommandOperation(input.recorder, { phase: "workspace_provision", command: provisionCommand, + resolvedCommand: resolvedProvisionCommand, cwd: input.worktreePath, env: buildWorkspaceCommandEnv({ base: input.base, @@ -665,6 +695,7 @@ async function provisionExecutionWorktree(input: { worktreePath: input.worktreePath, branchName: input.branchName, created: input.created, + resolvedCommand: resolvedProvisionCommand === provisionCommand ? null : resolvedProvisionCommand, }, successMessage: `Provisioned workspace at ${input.worktreePath}\n`, }); @@ -892,6 +923,12 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { }) { const warnings: string[] = []; const workspacePath = input.workspace.providerRef ?? input.workspace.cwd; + const repoRoot = input.workspace.providerType === "git_worktree" && workspacePath + ? await resolveGitRepoRootForWorkspaceCleanup( + workspacePath, + input.projectWorkspace?.cwd ?? null, + ) + : null; const cleanupEnv = buildExecutionWorkspaceCleanupEnv({ workspace: input.workspace, projectWorkspaceCwd: input.projectWorkspace?.cwd ?? null, @@ -907,9 +944,13 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { for (const command of cleanupCommands) { try { + const resolvedCommand = repoRoot + ? resolveRepoManagedWorkspaceCommand(command, repoRoot) + : command; await recordWorkspaceCommandOperation(input.recorder, { phase: "workspace_teardown", command, + resolvedCommand, cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(), env: cleanupEnv, label: `Execution workspace cleanup command "${command}"`, @@ -918,6 +959,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { workspacePath, branchName: input.workspace.branchName, providerType: input.workspace.providerType, + resolvedCommand: resolvedCommand === command ? null : resolvedCommand, }, successMessage: `Completed cleanup command "${command}"\n`, }); @@ -927,10 +969,6 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { } if (input.workspace.providerType === "git_worktree" && workspacePath) { - const repoRoot = await resolveGitRepoRootForWorkspaceCleanup( - workspacePath, - input.projectWorkspace?.cwd ?? null, - ); const worktreeExists = await directoryExists(workspacePath); if (worktreeExists) { if (!repoRoot) {