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.
This commit is contained in:
Mohamed Boudra
2026-08-11 21:20:30 +02:00
committed by GitHub
parent b9d7d4b18e
commit 2f54c0e309
4 changed files with 64 additions and 19 deletions
@@ -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,
});
@@ -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 () => {
+9 -9
View File
@@ -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<void> {
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({