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:
+3
-1
@@ -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
|
the optional field and retain the previous local-first behavior; older worktree metadata without the
|
||||||
exact ref also resolves through its stored branch name.
|
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
|
## paseo.json service scripts
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
mapWorkspaceRelativeCwdToWorktree,
|
mapWorkspaceRelativeCwdToWorktree,
|
||||||
rollbackCreatedPaseoWorktree,
|
rollbackCreatedPaseoWorktree,
|
||||||
seedPaseoConfigFile,
|
copySourcePaseoConfigFile,
|
||||||
validateBranchSlug,
|
validateBranchSlug,
|
||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
@@ -85,7 +85,7 @@ async function createPaseoWorktreeWithPriority(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (createdWorktree.created) {
|
if (createdWorktree.created) {
|
||||||
await seedPaseoConfigFile({
|
await copySourcePaseoConfigFile({
|
||||||
sourceCwd: workspaceCwdPlan.inputCwd,
|
sourceCwd: workspaceCwdPlan.inputCwd,
|
||||||
targetCwd: workspaceCwd,
|
targetCwd: workspaceCwd,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ import {
|
|||||||
writeFileSync,
|
writeFileSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
chmodSync,
|
chmodSync,
|
||||||
|
lstatSync,
|
||||||
|
symlinkSync,
|
||||||
|
unlinkSync,
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import { delimiter, dirname, join } from "path";
|
import { delimiter, dirname, join } from "path";
|
||||||
import { tmpdir } from "os";
|
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(
|
writeFileSync(
|
||||||
join(repoDir, "paseo.json"),
|
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", ["add", "paseo.json"], { cwd: repoDir });
|
||||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], {
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], {
|
||||||
@@ -1200,21 +1203,61 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(repoDir, "paseo.json"),
|
join(repoDir, "paseo.json"),
|
||||||
JSON.stringify({ scripts: { dev: { command: "uncommitted" } } }),
|
JSON.stringify({ worktree: { setup: 'echo "edited" > edited-setup.log' } }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await createLegacyWorktreeForTest({
|
const result = await createLegacyWorktreeForTest({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
worktreeSlug: "preserve-committed",
|
worktreeSlug: "edited-config",
|
||||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/preserve" },
|
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/edited-config" },
|
||||||
runSetup: false,
|
runSetup: true,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
});
|
});
|
||||||
|
|
||||||
const worktreeConfigPath = join(result.worktreePath, "paseo.json");
|
const worktreeConfigPath = join(result.worktreePath, "paseo.json");
|
||||||
expect(JSON.parse(readFileSync(worktreeConfigPath, "utf8"))).toEqual({
|
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 () => {
|
it("creates a worktree without error when no paseo.json exists in the main repo", async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { execFile } from "child_process";
|
import { execFile } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
|
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 { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import { runGitCommand } from "./run-git-command.js";
|
import { runGitCommand } from "./run-git-command.js";
|
||||||
import { spawnProcess } from "./spawn.js";
|
import { spawnProcess } from "./spawn.js";
|
||||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||||
|
import { writeFileAtomic } from "../server/atomic-file.js";
|
||||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||||
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||||
@@ -793,21 +794,20 @@ export async function runWorktreeTeardownCommands(options: {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedPaseoConfigFile(options: {
|
export async function copySourcePaseoConfigFile(options: {
|
||||||
sourceCwd: string;
|
sourceCwd: string;
|
||||||
targetCwd: string;
|
targetCwd: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const sourceConfigPath = join(options.sourceCwd, "paseo.json");
|
const sourceConfigPath = join(options.sourceCwd, "paseo.json");
|
||||||
const targetConfigPath = join(options.targetCwd, "paseo.json");
|
const targetConfigPath = join(options.targetCwd, "paseo.json");
|
||||||
|
let sourceConfig: Buffer;
|
||||||
try {
|
try {
|
||||||
await stat(targetConfigPath);
|
sourceConfig = await readFile(sourceConfigPath);
|
||||||
return;
|
|
||||||
} catch (error) {
|
} 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) => {
|
await writeFileAtomic(targetConfigPath, sourceConfig);
|
||||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1257,7 +1257,7 @@ export const createWorktree = async ({
|
|||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
await copySourcePaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||||
|
|
||||||
if (runSetup) {
|
if (runSetup) {
|
||||||
await runWorktreeSetupCommands({
|
await runWorktreeSetupCommands({
|
||||||
|
|||||||
Reference in New Issue
Block a user