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
@@ -259,6 +259,39 @@ test("seeds an uncommitted exact-project config into the mapped worktree directo
expect(existsSync(path.join(result.worktree.worktreePath, "paseo.json"))).toBe(false);
});
test("does not overwrite a committed exact-project config with source checkout edits", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const sourceDir = path.join(repoDir, "packages", "app");
mkdirSync(sourceDir, { recursive: true });
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
const committedConfig = JSON.stringify({ worktree: { setup: ["npm ci"] } });
writeFileSync(path.join(sourceDir, "paseo.json"), committedConfig);
commitAll(repoDir, "add subproject config");
writeFileSync(
path.join(sourceDir, "paseo.json"),
JSON.stringify({ worktree: { setup: ["npm install"] } }),
);
const result = await createPaseoWorktree(
{
cwd: sourceDir,
worktreeSlug: "preserve-nested-config",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
);
expect(readFileSync(path.join(result.workspace.cwd, "paseo.json"), "utf8")).toBe(committedConfig);
expect(
execFileSync("git", ["status", "--porcelain"], {
cwd: result.worktree.worktreePath,
encoding: "utf8",
}),
).toBe("");
});
test("removes a new worktree when its ref does not contain the selected project directory", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
@@ -13,7 +13,7 @@ import {
import {
mapWorkspaceRelativeCwdToWorktree,
rollbackCreatedPaseoWorktree,
copySourcePaseoConfigFile,
seedPaseoConfigFile,
validateBranchSlug,
type WorktreeConfig,
} from "../utils/worktree.js";
@@ -85,7 +85,7 @@ async function createPaseoWorktreeWithPriority(
}
if (createdWorktree.created) {
await copySourcePaseoConfigFile({
await seedPaseoConfigFile({
sourceCwd: workspaceCwdPlan.inputCwd,
targetCwd: workspaceCwd,
});
@@ -1,9 +1,11 @@
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { afterEach, describe, expect, test } from "vitest";
import pino from "pino";
import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js";
import { statPaseoConfigPath } from "../../../utils/paseo-config-file.js";
import type { PersistedProjectRecord } from "../../workspace-registry.js";
import type { SessionOutboundMessage } from "../../messages.js";
@@ -45,6 +47,38 @@ function makeSubsystem(records: PersistedProjectRecord[]) {
}
describe("ProjectConfigSession", () => {
test("read reports when the saved worktree setup differs from HEAD", async () => {
const repoRoot = makeRoot();
execFileSync("git", ["init", "-b", "main"], { cwd: repoRoot });
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoRoot });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot });
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
execFileSync("git", ["add", "paseo.json"], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "add config"], { cwd: repoRoot });
writeFileSync(
join(repoRoot, "paseo.json"),
JSON.stringify({ worktree: { setup: "npm install" } }),
);
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]);
await subsystem.handleReadProjectConfigRequest({
type: "read_project_config_request",
requestId: "read-uncommitted-setup",
repoRoot,
});
expect(emitted).toEqual([
{
type: "read_project_config_response",
payload: expect.objectContaining({
requestId: "read-uncommitted-setup",
ok: true,
hasUncommittedWorktreeSetupChanges: true,
}),
},
]);
});
test("read resolves a known root despite a trailing slash and returns the raw config + revision", async () => {
const repoRoot = makeRoot();
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
@@ -179,6 +213,36 @@ describe("ProjectConfigSession", () => {
]);
});
test("write recomputes the worktree setup commit status", async () => {
const repoRoot = makeRoot();
execFileSync("git", ["init", "-b", "main"], { cwd: repoRoot });
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoRoot });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot });
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
execFileSync("git", ["add", "paseo.json"], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "add config"], { cwd: repoRoot });
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]);
await subsystem.handleWriteProjectConfigRequest({
type: "write_project_config_request",
requestId: "write-uncommitted-setup",
repoRoot,
config: { worktree: { setup: "npm install" } },
expectedRevision: statPaseoConfigPath(repoRoot),
});
expect(emitted).toEqual([
{
type: "write_project_config_response",
payload: expect.objectContaining({
requestId: "write-uncommitted-setup",
ok: true,
hasUncommittedWorktreeSetupChanges: true,
}),
},
]);
});
test("write rejects a stale revision and an unknown root with their inline domain failures", async () => {
const staleRoot = makeRoot();
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
@@ -8,6 +8,7 @@ import {
writePaseoConfigForEdit,
type ProjectConfigRpcError,
} from "../../../utils/paseo-config-file.js";
import { hasUncommittedWorktreeSetupChanges } from "./worktree-setup-commit-status.js";
export interface ProjectConfigSessionHost {
emit(msg: SessionOutboundMessage): void;
@@ -63,6 +64,8 @@ export class ProjectConfigSession {
);
}
const setupCommitStatus = await this.readSetupCommitStatus(repoRoot, result.config);
this.host.emit({
type: "read_project_config_response",
payload: {
@@ -71,6 +74,9 @@ export class ProjectConfigSession {
ok: true,
config: result.config,
revision: result.revision,
...(setupCommitStatus === null
? {}
: { hasUncommittedWorktreeSetupChanges: setupCommitStatus }),
},
});
}
@@ -106,6 +112,7 @@ export class ProjectConfigSession {
{ repoRoot, requestId: msg.requestId, outcome: "written" },
"Project config written",
);
const setupCommitStatus = await this.readSetupCommitStatus(repoRoot, result.config);
this.host.emit({
type: "write_project_config_response",
payload: {
@@ -114,10 +121,25 @@ export class ProjectConfigSession {
ok: true,
config: result.config,
revision: result.revision,
...(setupCommitStatus === null
? {}
: { hasUncommittedWorktreeSetupChanges: setupCommitStatus }),
},
});
}
private async readSetupCommitStatus(
repoRoot: string,
config: Parameters<typeof hasUncommittedWorktreeSetupChanges>[0]["currentConfig"],
): Promise<boolean | null> {
try {
return await hasUncommittedWorktreeSetupChanges({ repoRoot, currentConfig: config });
} catch (error) {
this.logger.debug({ repoRoot, error }, "Could not inspect committed worktree setup");
return null;
}
}
private emitProjectConfigReadFailure(
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
error: ProjectConfigRpcError,
@@ -0,0 +1,97 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import type { PaseoConfigRaw } from "@getpaseo/protocol/paseo-config-schema";
import { hasUncommittedWorktreeSetupChanges } from "./worktree-setup-commit-status.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeGitRepo(config?: PaseoConfigRaw): string {
const repoRoot = realpathSync(mkdtempSync(join(tmpdir(), "worktree-setup-status-test-")));
tempDirs.push(repoRoot);
execFileSync("git", ["init", "-b", "main"], { cwd: repoRoot });
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoRoot });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot });
writeFileSync(join(repoRoot, "README.md"), "test\n");
if (config) writeConfig(repoRoot, config);
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoRoot });
return repoRoot;
}
function writeConfig(repoRoot: string, config: PaseoConfigRaw): void {
writeFileSync(join(repoRoot, "paseo.json"), `${JSON.stringify(config, null, 2)}\n`);
}
describe("worktree setup commit status", () => {
test("reports matching committed setup as clean", async () => {
const repoRoot = makeGitRepo({ worktree: { setup: "npm ci" } });
await expect(
hasUncommittedWorktreeSetupChanges({
repoRoot,
currentConfig: { worktree: { setup: "npm ci" } },
}),
).resolves.toBe(false);
});
test("reports modified and staged setup as uncommitted", async () => {
const repoRoot = makeGitRepo({ worktree: { setup: "npm ci" } });
const currentConfig = { worktree: { setup: "npm install" } };
writeConfig(repoRoot, currentConfig);
await expect(hasUncommittedWorktreeSetupChanges({ repoRoot, currentConfig })).resolves.toBe(
true,
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoRoot });
await expect(hasUncommittedWorktreeSetupChanges({ repoRoot, currentConfig })).resolves.toBe(
true,
);
});
test("reports an untracked config with setup as uncommitted", async () => {
const repoRoot = makeGitRepo();
const currentConfig = { worktree: { setup: "npm ci" } };
writeConfig(repoRoot, currentConfig);
await expect(hasUncommittedWorktreeSetupChanges({ repoRoot, currentConfig })).resolves.toBe(
true,
);
});
test("ignores representation-only and unrelated config changes", async () => {
const repoRoot = makeGitRepo({
worktree: { setup: "npm ci", teardown: "echo old" },
scripts: { dev: { command: "npm run dev" } },
});
const currentConfig = {
worktree: { setup: ["npm ci"], teardown: "echo new" },
scripts: { dev: { command: "npm run start" } },
metadataGeneration: { branchName: { instructions: "Use fix/" } },
};
writeConfig(repoRoot, currentConfig);
await expect(hasUncommittedWorktreeSetupChanges({ repoRoot, currentConfig })).resolves.toBe(
false,
);
});
test("resolves paseo.json relative to a nested project root", async () => {
const repoRoot = makeGitRepo();
const projectRoot = join(repoRoot, "packages", "app");
mkdirSync(projectRoot, { recursive: true });
writeConfig(projectRoot, { worktree: { setup: "npm ci" } });
execFileSync("git", ["add", "packages/app/paseo.json"], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "add nested config"], { cwd: repoRoot });
const currentConfig = { worktree: { setup: "npm install" } };
writeConfig(projectRoot, currentConfig);
await expect(
hasUncommittedWorktreeSetupChanges({ repoRoot: projectRoot, currentConfig }),
).resolves.toBe(true);
});
});
@@ -0,0 +1,54 @@
import {
PaseoConfigRawSchema,
normalizeLifecycleCommands,
type PaseoConfigRaw,
} from "@getpaseo/protocol/paseo-config-schema";
import { READ_ONLY_GIT_ENV } from "../../checkout-git-utils.js";
import { runGitCommand } from "../../../utils/run-git-command.js";
export async function hasUncommittedWorktreeSetupChanges(input: {
repoRoot: string;
currentConfig: PaseoConfigRaw | null;
}): Promise<boolean> {
const gitPath = await resolveConfigGitPath(input.repoRoot);
const committedConfig = await readCommittedConfig(input.repoRoot, gitPath);
const currentSetup = normalizeLifecycleCommands(input.currentConfig?.worktree?.setup);
const committedSetup = normalizeLifecycleCommands(committedConfig?.worktree?.setup);
return !stringArraysEqual(currentSetup, committedSetup);
}
async function resolveConfigGitPath(repoRoot: string): Promise<string> {
const { stdout } = await runGitCommand(["rev-parse", "--show-prefix"], {
cwd: repoRoot,
envOverlay: READ_ONLY_GIT_ENV,
});
return `${stdout.trim()}paseo.json`;
}
async function readCommittedConfig(
repoRoot: string,
gitPath: string,
): Promise<PaseoConfigRaw | null> {
await runGitCommand(["rev-parse", "--verify", "HEAD"], {
cwd: repoRoot,
envOverlay: READ_ONLY_GIT_ENV,
});
const { stdout: trackedPath } = await runGitCommand(
["ls-tree", "--name-only", "HEAD", "--", gitPath],
{
cwd: repoRoot,
envOverlay: READ_ONLY_GIT_ENV,
},
);
if (trackedPath.trim().length === 0) return null;
const { stdout } = await runGitCommand(["show", `HEAD:${gitPath}`], {
cwd: repoRoot,
envOverlay: READ_ONLY_GIT_ENV,
});
return PaseoConfigRawSchema.parse(JSON.parse(stdout));
}
function stringArraysEqual(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
@@ -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({