From 2f54c0e30984b76a7b539939e6519f53b1d45ba1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 21:20:30 +0200 Subject: [PATCH] Use saved Project Settings for new worktree setup (#3233) * fix(worktrees): honor saved setup configuration Project Settings writes paseo.json in the source checkout, but existing committed copies in new worktrees previously won silently. Copy the saved project config before lifecycle setup so the command shown to users is the command that runs. * fix(worktrees): replace config symlinks safely A selected ref can place a symlink at paseo.json, so copying over that path may overwrite a file outside the worktree. Materialize the saved source config with atomic rename semantics to replace the path entry without following it. --- docs/development.md | 4 +- .../src/server/paseo-worktree-service.ts | 4 +- .../server/src/utils/worktree.posix.test.ts | 57 ++++++++++++++++--- packages/server/src/utils/worktree.ts | 18 +++--- 4 files changed, 64 insertions(+), 19 deletions(-) diff --git a/docs/development.md b/docs/development.md index 22548cb1e..bd573b5b9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -295,7 +295,9 @@ another remote fails closed until the worktree records an explicit local target. the optional field and retain the previous local-first behavior; older worktree metadata without the exact ref also resolves through its stored branch name. -Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied. +Worktrees inherit committed Git state. Before lifecycle setup, Paseo copies the source checkout's +`paseo.json` over the worktree copy so saved Project Settings apply without a commit. Other +uncommitted source-checkout changes are not copied. ## paseo.json service scripts diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index b32123cb3..7e7227560 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -13,7 +13,7 @@ import { import { mapWorkspaceRelativeCwdToWorktree, rollbackCreatedPaseoWorktree, - seedPaseoConfigFile, + copySourcePaseoConfigFile, validateBranchSlug, type WorktreeConfig, } from "../utils/worktree.js"; @@ -85,7 +85,7 @@ async function createPaseoWorktreeWithPriority( } if (createdWorktree.created) { - await seedPaseoConfigFile({ + await copySourcePaseoConfigFile({ sourceCwd: workspaceCwdPlan.inputCwd, targetCwd: workspaceCwd, }); diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 2d45b0fe5..73bd6c01f 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -40,6 +40,9 @@ import { writeFileSync, readFileSync, chmodSync, + lstatSync, + symlinkSync, + unlinkSync, } from "fs"; import { delimiter, dirname, join } from "path"; import { tmpdir } from "os"; @@ -1188,10 +1191,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); - it("does not overwrite a committed paseo.json with uncommitted edits in the main repo", async () => { + it("runs setup from the edited source config when the selected ref already has paseo.json", async () => { writeFileSync( join(repoDir, "paseo.json"), - JSON.stringify({ scripts: { dev: { command: "committed" } } }), + JSON.stringify({ worktree: { setup: 'echo "committed" > committed-setup.log' } }), ); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { @@ -1200,21 +1203,61 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { writeFileSync( join(repoDir, "paseo.json"), - JSON.stringify({ scripts: { dev: { command: "uncommitted" } } }), + JSON.stringify({ worktree: { setup: 'echo "edited" > edited-setup.log' } }), ); const result = await createLegacyWorktreeForTest({ cwd: repoDir, - worktreeSlug: "preserve-committed", - source: { kind: "branch-off", baseBranch: "main", branchName: "feature/preserve" }, - runSetup: false, + worktreeSlug: "edited-config", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/edited-config" }, + runSetup: true, paseoHome, }); const worktreeConfigPath = join(result.worktreePath, "paseo.json"); expect(JSON.parse(readFileSync(worktreeConfigPath, "utf8"))).toEqual({ - scripts: { dev: { command: "committed" } }, + worktree: { setup: 'echo "edited" > edited-setup.log' }, }); + expect(readFileSync(join(result.worktreePath, "edited-setup.log"), "utf8")).toBe("edited\n"); + expect(existsSync(join(result.worktreePath, "committed-setup.log"))).toBe(false); + }); + + it("replaces a selected ref's paseo.json symlink without writing through it", async () => { + const sourceConfigPath = join(repoDir, "paseo.json"); + const externalConfigPath = join(tempDir, "external-paseo.json"); + const committedConfig = JSON.stringify({ + worktree: { setup: 'echo "committed" > committed-setup.log' }, + }); + const editedConfig = JSON.stringify({ + worktree: { setup: 'echo "edited" > edited-setup.log' }, + }); + writeFileSync(externalConfigPath, committedConfig); + symlinkSync(externalConfigPath, sourceConfigPath); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add config link"], { + cwd: repoDir, + }); + unlinkSync(sourceConfigPath); + writeFileSync(sourceConfigPath, editedConfig); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "replace-config-link", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "feature/replace-config-link", + }, + runSetup: true, + paseoHome, + }); + + const worktreeConfigPath = join(result.worktreePath, "paseo.json"); + expect(readFileSync(externalConfigPath, "utf8")).toBe(committedConfig); + expect(lstatSync(worktreeConfigPath).isFile()).toBe(true); + expect(readFileSync(worktreeConfigPath, "utf8")).toBe(editedConfig); + expect(readFileSync(join(result.worktreePath, "edited-setup.log"), "utf8")).toBe("edited\n"); + expect(existsSync(join(result.worktreePath, "committed-setup.log"))).toBe(false); }); it("creates a worktree without error when no paseo.json exists in the main repo", async () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 1403ef7ce..5add0f76e 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,7 +1,7 @@ import { execFile } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs"; -import { copyFile, rm, stat } from "fs/promises"; +import { readFile, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; @@ -33,6 +33,7 @@ import { import { runGitCommand } from "./run-git-command.js"; import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; +import { writeFileAtomic } from "../server/atomic-file.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -793,21 +794,20 @@ export async function runWorktreeTeardownCommands(options: { return results; } -export async function seedPaseoConfigFile(options: { +export async function copySourcePaseoConfigFile(options: { sourceCwd: string; targetCwd: string; }): Promise { const sourceConfigPath = join(options.sourceCwd, "paseo.json"); const targetConfigPath = join(options.targetCwd, "paseo.json"); + let sourceConfig: Buffer; try { - await stat(targetConfigPath); - return; + sourceConfig = await readFile(sourceConfigPath); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; } - await copyFile(sourceConfigPath, targetConfigPath).catch((error) => { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - }); + await writeFileAtomic(targetConfigPath, sourceConfig); } /** @@ -1257,7 +1257,7 @@ export const createWorktree = async ({ : {}), }); - await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); + await copySourcePaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); if (runSetup) { await runWorktreeSetupCommands({