Keep new worktrees clean when setup changes are uncommitted (#3311)

* fix(worktrees): preserve selected base config

New worktrees were overwriting the selected ref's paseo.json with source-checkout bytes, which could create an accidental revert in an otherwise fresh workspace. Keep committed base config authoritative and warn when locally saved setup changes still need to be committed.

* fix(worktrees): seed config exclusively

* fix(projects): refresh config status on focus
This commit is contained in:
Mohamed Boudra
2026-08-13 12:02:15 +02:00
committed by GitHub
parent 9a7301d9a0
commit c190fed27c
23 changed files with 460 additions and 55 deletions
@@ -41,8 +41,8 @@ import {
readFileSync,
chmodSync,
lstatSync,
readlinkSync,
symlinkSync,
unlinkSync,
} from "fs";
import { delimiter, dirname, join } from "path";
import { tmpdir } from "os";
@@ -1191,73 +1191,95 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
});
});
it("runs setup from the edited source config when the selected ref already has paseo.json", async () => {
it("keeps a new worktree clean when its upstream ref has a newer paseo.json", async () => {
const remoteDir = join(tempDir, "remote.git");
const updaterDir = join(tempDir, "updater");
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({ worktree: { setup: 'echo "committed" > committed-setup.log' } }),
JSON.stringify({ worktree: { setup: "echo old" } }),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], {
cwd: repoDir,
});
execFileSync("git", ["clone", "--bare", repoDir, remoteDir]);
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
execFileSync("git", ["clone", remoteDir, updaterDir]);
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: updaterDir });
execFileSync("git", ["config", "user.name", "Test"], { cwd: updaterDir });
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({ worktree: { setup: 'echo "edited" > edited-setup.log' } }),
join(updaterDir, "paseo.json"),
JSON.stringify({ worktree: { setup: "echo new" } }),
);
execFileSync("git", ["add", "paseo.json"], { cwd: updaterDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "update paseo.json"], {
cwd: updaterDir,
});
execFileSync("git", ["push", "origin", "main"], { cwd: updaterDir });
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "edited-config",
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/edited-config" },
runSetup: true,
worktreeSlug: "upstream-config",
source: {
kind: "branch-off",
baseBranch: "origin/main",
branchName: "feature/upstream-config",
},
runSetup: false,
paseoHome,
});
const worktreeConfigPath = join(result.worktreePath, "paseo.json");
expect(JSON.parse(readFileSync(worktreeConfigPath, "utf8"))).toEqual({
worktree: { setup: 'echo "edited" > edited-setup.log' },
worktree: { setup: "echo new" },
});
expect(readFileSync(join(result.worktreePath, "edited-setup.log"), "utf8")).toBe("edited\n");
expect(existsSync(join(result.worktreePath, "committed-setup.log"))).toBe(false);
expect(
execFileSync("git", ["status", "--porcelain"], {
cwd: result.worktreePath,
encoding: "utf8",
}),
).toBe("");
});
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);
it("preserves a dangling paseo.json symlink from the selected ref", async () => {
const externalConfigPath = join(tempDir, "outside-paseo.json");
execFileSync("git", ["checkout", "-b", "symlink-config"], { cwd: repoDir });
symlinkSync(externalConfigPath, join(repoDir, "paseo.json"));
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);
execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json symlink"],
{ cwd: repoDir },
);
execFileSync("git", ["checkout", "main"], { cwd: repoDir });
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({ worktree: { setup: "echo source" } }),
);
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "replace-config-link",
worktreeSlug: "symlink-config",
source: {
kind: "branch-off",
baseBranch: "main",
branchName: "feature/replace-config-link",
baseBranch: "symlink-config",
branchName: "feature/symlink-config",
},
runSetup: true,
runSetup: false,
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);
expect(lstatSync(worktreeConfigPath).isSymbolicLink()).toBe(true);
expect(readlinkSync(worktreeConfigPath)).toBe(externalConfigPath);
expect(existsSync(externalConfigPath)).toBe(false);
expect(
execFileSync("git", ["status", "--porcelain"], {
cwd: result.worktreePath,
encoding: "utf8",
}),
).toBe("");
});
it("creates a worktree without error when no paseo.json exists in the main repo", async () => {
+15 -13
View File
@@ -1,7 +1,14 @@
import { execFile } from "child_process";
import { promisify } from "util";
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
import { readFile, rm, stat } from "fs/promises";
import {
constants as fsConstants,
existsSync,
mkdirSync,
realpathSync,
rmSync,
statSync,
} from "fs";
import { copyFile, 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,7 +40,6 @@ 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";
@@ -794,20 +800,16 @@ export async function runWorktreeTeardownCommands(options: {
return results;
}
export async function copySourcePaseoConfigFile(options: {
export async function seedPaseoConfigFile(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 {
sourceConfig = await readFile(sourceConfigPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw error;
}
await writeFileAtomic(targetConfigPath, sourceConfig);
await copyFile(sourceConfigPath, targetConfigPath, fsConstants.COPYFILE_EXCL).catch((error) => {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST" && code !== "ENOENT") throw error;
});
}
/**
@@ -1257,7 +1259,7 @@ export const createWorktree = async ({
: {}),
});
await copySourcePaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
if (runSetup) {
await runWorktreeSetupCommands({