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:
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user