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({