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
@@ -9,12 +9,14 @@ import {
clickReloadProjectSettings,
clickRetryProjectSettingsSave,
clickSaveProjectSettings,
commitPaseoConfig,
corruptPaseoConfig,
editWorktreeSetup,
expectEmptyScriptList,
expectProjectHostContextHidden,
expectNoEditableTarget,
expectNoProjectSettingsError,
expectNoUncommittedSetupWarning,
expectProjectEditFailed,
expectProjectEditName,
expectProjectEditSaved,
@@ -27,6 +29,7 @@ import {
expectSaveButtonDisabled,
expectScriptRowCount,
expectWriteFailedCalloutActions,
expectUncommittedSetupWarning,
fillProjectIconUrl,
fillProjectName,
installDaemonConnectionGate,
@@ -216,9 +219,16 @@ test.describe("Projects settings", () => {
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectNoUncommittedSetupWarning(page);
await editWorktreeSetup(page, updatedSetup);
await clickSaveProjectSettings(page);
await expectProjectConfigSaved(editableProject);
await expectUncommittedSetupWarning(page);
commitPaseoConfig(editableProject.path);
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectNoUncommittedSetupWarning(page);
});
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
@@ -439,5 +449,7 @@ test.describe("Projects settings — error UX", () => {
await expectScriptRowCount(page, 0);
await expectEmptyScriptList(page);
await clickSaveProjectSettings(page);
await expectNoUncommittedSetupWarning(page);
});
});
@@ -1,4 +1,5 @@
import { chmod, readFile, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import type { WebSocketRoute } from "@playwright/test";
@@ -177,6 +178,19 @@ export async function expectSaveButtonDisabled(page: Page): Promise<void> {
await expect(page.getByTestId("save-button")).toBeDisabled();
}
export async function expectUncommittedSetupWarning(page: Page): Promise<void> {
const warning = page.getByRole("alert").filter({ hasText: "Commit paseo.json changes" });
await expect(warning).toContainText("Commit paseo.json changes");
await expect(warning).toContainText(
"New worktrees use the setup script from the base branch you select.",
);
}
export async function expectNoUncommittedSetupWarning(page: Page): Promise<void> {
const warning = page.getByRole("alert").filter({ hasText: "Commit paseo.json changes" });
await expect(warning).toHaveCount(0);
}
// --- Form-state assertions ---
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
@@ -249,6 +263,11 @@ export async function restorePaseoConfig(
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
}
export function commitPaseoConfig(repoPath: string): void {
execFileSync("git", ["add", "paseo.json"], { cwd: repoPath });
execFileSync("git", ["commit", "-m", "Update project config"], { cwd: repoPath });
}
// The daemon writes atomically via a temp file + rename, so blocking writes requires
// removing write permission from the *directory*, not just the file.
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
+3
View File
@@ -2405,6 +2405,9 @@ export const ar: TranslationResources = {
docsTooltip: "راجع المستندات لمزيد من التفاصيل ومتغيرات البيئة المتاحة لهذه الأوامر",
setup: "يثبت",
setupAccessibility: "أوامر إعداد شجرة العمل",
uncommittedTitle: "ثبّت تغييرات paseo.json",
uncommittedDescription:
"تستخدم أشجار العمل الجديدة نص الإعداد البرمجي من الفرع الأساسي الذي تحدده.",
teardown: "هدم",
teardownAccessibility: "أوامر هدم شجرة العمل",
},
+3
View File
@@ -2419,6 +2419,9 @@ export const en = {
"See docs for more details and the environment variables available to these commands",
setup: "Setup",
setupAccessibility: "Worktree setup commands",
uncommittedTitle: "Commit paseo.json changes",
uncommittedDescription:
"New worktrees use the setup script from the base branch you select.",
teardown: "Teardown",
teardownAccessibility: "Worktree teardown commands",
},
+3
View File
@@ -2460,6 +2460,9 @@ export const es: TranslationResources = {
"Consulte los documentos para obtener más detalles y las variables de entorno disponibles para estos comandos.",
setup: "Configuración",
setupAccessibility: "Comandos de configuración del árbol de trabajo",
uncommittedTitle: "Confirma los cambios de paseo.json",
uncommittedDescription:
"Los árboles de trabajo nuevos usan el script de configuración de la rama base que selecciones.",
teardown: "Demoler",
teardownAccessibility: "Comandos de desmontaje del árbol de trabajo",
},
+3
View File
@@ -2466,6 +2466,9 @@ export const fr: TranslationResources = {
"Voir la documentation pour plus de détails et les variables d'environnement disponibles pour ces commandes",
setup: "Installation",
setupAccessibility: "Commandes de configuration de Worktree",
uncommittedTitle: "Validez les modifications de paseo.json",
uncommittedDescription:
"Les nouveaux worktrees utilisent le script de configuration de la branche de base sélectionnée.",
teardown: "Démolir",
teardownAccessibility: "Commandes de démontage de Worktree",
},
+3
View File
@@ -2430,6 +2430,9 @@ export const ja: TranslationResources = {
"これらのコマンドで使用可能な詳細と環境変数についてはドキュメントを参照してください",
setup: "セットアップ",
setupAccessibility: "ワークツリーセットアップコマンド",
uncommittedTitle: "paseo.json の変更をコミットしてください",
uncommittedDescription:
"新しいワークツリーでは、選択したベースブランチのセットアップスクリプトが使われます。",
teardown: "削除時",
teardownAccessibility: "ワークツリー削除時のコマンド",
},
+2
View File
@@ -2422,6 +2422,8 @@ export const ko: TranslationResources = {
docsTooltip: "자세한 내용과 이 명령에 사용할 수 있는 환경 변수는 문서를 참조하세요",
setup: "설정",
setupAccessibility: "워크트리 설정 명령",
uncommittedTitle: "paseo.json 변경 사항을 커밋하세요",
uncommittedDescription: "새 워크트리는 선택한 기본 브랜치의 설정 스크립트를 사용합니다.",
teardown: "정리",
teardownAccessibility: "워크트리 정리 명령",
},
+3
View File
@@ -2445,6 +2445,9 @@ export const ptBR: TranslationResources = {
"Veja a documentação para mais detalhes e as variáveis de ambiente disponíveis para estes comandos",
setup: "Configuração",
setupAccessibility: "Comandos de configuração do worktree",
uncommittedTitle: "Faça commit das alterações no paseo.json",
uncommittedDescription:
"Novos worktrees usam o script de configuração do branch base selecionado.",
teardown: "Desmontagem",
teardownAccessibility: "Comandos de desmontagem do worktree",
},
+3
View File
@@ -2448,6 +2448,9 @@ export const ru: TranslationResources = {
"Дополнительную информацию и переменные среды, доступные для этих команд, см. в документации.",
setup: "Настраивать",
setupAccessibility: "Команды настройки рабочего дерева",
uncommittedTitle: "Закоммитьте изменения paseo.json",
uncommittedDescription:
"Новые рабочие деревья используют сценарий настройки из выбранной базовой ветки.",
teardown: "Срывать",
teardownAccessibility: "Команды разрушения рабочего дерева",
},
+2
View File
@@ -2377,6 +2377,8 @@ export const zhCN: TranslationResources = {
docsTooltip: "查看命令可用的环境变量和更多细节",
setup: "Setup",
setupAccessibility: "Worktree setup 命令",
uncommittedTitle: "提交 paseo.json 更改",
uncommittedDescription: "新工作树使用所选基础分支中的设置脚本。",
teardown: "Teardown",
teardownAccessibility: "Worktree teardown 命令",
},
@@ -3,6 +3,7 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Pressable, Text, TextInput, View } from "react-native";
import { router } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, MoreVertical, Pencil, Plus } from "lucide-react-native";
@@ -200,6 +201,12 @@ function ProjectSettingsBody({
queryFn: () => client.readProjectConfig(selectedHost.repoRoot),
retry: false,
});
const refetchProjectConfig = readQuery.refetch;
useFocusEffect(
useCallback(() => {
void refetchProjectConfig();
}, [refetchProjectConfig]),
);
const data = readQuery.data;
const supportsCustomIcon = useHostFeature(selectedHost.serverId, "projectCustomIcon");
@@ -240,6 +247,8 @@ function ProjectSettingsBody({
);
const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null;
const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null;
const hasUncommittedWorktreeSetupChanges =
data?.ok === true && data.hasUncommittedWorktreeSetupChanges === true;
const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null;
const handleReload = useCallback(() => {
@@ -290,6 +299,7 @@ function ProjectSettingsBody({
readQuery,
loadedConfig,
loadedRevision,
hasUncommittedWorktreeSetupChanges,
readError,
selectedHost,
queryKey,
@@ -305,6 +315,7 @@ interface RenderContentInput {
readQuery: ReturnType<typeof useQuery<ReadProjectConfigData>>;
loadedConfig: PaseoConfigRaw | null;
loadedRevision: PaseoConfigRevision | null;
hasUncommittedWorktreeSetupChanges: boolean;
readError: ProjectConfigRpcError | null;
selectedHost: ProjectHostEntry;
queryKey: readonly [string, string, string];
@@ -317,6 +328,7 @@ function renderContent({
readQuery,
loadedConfig,
loadedRevision,
hasUncommittedWorktreeSetupChanges,
readError,
selectedHost,
queryKey,
@@ -358,6 +370,7 @@ function renderContent({
key={formKey}
baseConfig={loadedConfig}
revision={loadedRevision}
hasUncommittedWorktreeSetupChanges={hasUncommittedWorktreeSetupChanges}
repoRoot={selectedHost.repoRoot}
queryKey={queryKey}
client={client}
@@ -438,6 +451,7 @@ function errorToDetail(error: unknown): string | null {
interface ProjectConfigFormProps {
baseConfig: PaseoConfigRaw;
revision: PaseoConfigRevision | null;
hasUncommittedWorktreeSetupChanges: boolean;
repoRoot: string;
queryKey: readonly [string, string, string];
client: DaemonClient;
@@ -447,6 +461,7 @@ interface ProjectConfigFormProps {
function ProjectConfigForm({
baseConfig,
revision,
hasUncommittedWorktreeSetupChanges,
repoRoot,
queryKey,
client,
@@ -479,6 +494,11 @@ function ProjectConfigForm({
revision: result.revision,
requestId: "local-cache",
repoRoot,
...(result.hasUncommittedWorktreeSetupChanges === undefined
? {}
: {
hasUncommittedWorktreeSetupChanges: result.hasUncommittedWorktreeSetupChanges,
}),
});
setWriteError(null);
queryClient.invalidateQueries({ queryKey: ["projects"] });
@@ -660,6 +680,13 @@ function ProjectConfigForm({
testID="worktree-setup-section"
trailing={setupDocsLink}
>
{hasUncommittedWorktreeSetupChanges ? (
<Alert
variant="warning"
title={t("settings.project.worktree.uncommittedTitle")}
description={t("settings.project.worktree.uncommittedDescription")}
/>
) : null}
<SettingsTextAreaCard
testID="worktree-setup-input"
accessibilityLabel={t("settings.project.worktree.setupAccessibility")}
+2
View File
@@ -4219,6 +4219,7 @@ export const ReadProjectConfigResponseMessageSchema = z.object({
ok: z.literal(true),
config: PaseoConfigRawSchema.nullable(),
revision: PaseoConfigRevisionSchema.nullable(),
hasUncommittedWorktreeSetupChanges: z.boolean().optional(),
}),
z.object({
requestId: z.string(),
@@ -4240,6 +4241,7 @@ export const WriteProjectConfigResponseMessageSchema = z.object({
ok: z.literal(true),
config: PaseoConfigRawSchema,
revision: PaseoConfigRevisionSchema,
hasUncommittedWorktreeSetupChanges: z.boolean().optional(),
}),
z.object({
requestId: z.string(),
@@ -138,6 +138,34 @@ const SourceSchema = z.object({
);
});
it("accepts project config responses with and without setup commit status", () => {
const payload = {
requestId: "project-config-read",
repoRoot: "/repo",
ok: true,
config: null,
revision: null,
};
const envelope = (
responsePayload: typeof payload & {
hasUncommittedWorktreeSetupChanges?: boolean;
},
) => ({
type: "session",
message: {
type: "read_project_config_response",
payload: responsePayload,
},
});
expect(GeneratedWSOutboundMessageSchema.safeParse(envelope(payload)).success).toBe(true);
expect(
GeneratedWSOutboundMessageSchema.safeParse(
envelope({ ...payload, hasUncommittedWorktreeSetupChanges: true }),
).success,
).toBe(true);
});
it("accepts a compact provider snapshot envelope", () => {
const envelope = {
type: "session",
@@ -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({