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
+1 -3
View File
@@ -297,9 +297,7 @@ 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. Before lifecycle setup, Paseo copies the source checkout's Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied.
`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
@@ -9,12 +9,14 @@ import {
clickReloadProjectSettings, clickReloadProjectSettings,
clickRetryProjectSettingsSave, clickRetryProjectSettingsSave,
clickSaveProjectSettings, clickSaveProjectSettings,
commitPaseoConfig,
corruptPaseoConfig, corruptPaseoConfig,
editWorktreeSetup, editWorktreeSetup,
expectEmptyScriptList, expectEmptyScriptList,
expectProjectHostContextHidden, expectProjectHostContextHidden,
expectNoEditableTarget, expectNoEditableTarget,
expectNoProjectSettingsError, expectNoProjectSettingsError,
expectNoUncommittedSetupWarning,
expectProjectEditFailed, expectProjectEditFailed,
expectProjectEditName, expectProjectEditName,
expectProjectEditSaved, expectProjectEditSaved,
@@ -27,6 +29,7 @@ import {
expectSaveButtonDisabled, expectSaveButtonDisabled,
expectScriptRowCount, expectScriptRowCount,
expectWriteFailedCalloutActions, expectWriteFailedCalloutActions,
expectUncommittedSetupWarning,
fillProjectIconUrl, fillProjectIconUrl,
fillProjectName, fillProjectName,
installDaemonConnectionGate, installDaemonConnectionGate,
@@ -216,9 +219,16 @@ test.describe("Projects settings", () => {
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => { test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
await openProjects(page); await openProjects(page);
await openProjectSettings(page, editableProject.name); await openProjectSettings(page, editableProject.name);
await expectNoUncommittedSetupWarning(page);
await editWorktreeSetup(page, updatedSetup); await editWorktreeSetup(page, updatedSetup);
await clickSaveProjectSettings(page); await clickSaveProjectSettings(page);
await expectProjectConfigSaved(editableProject); 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 }) => { 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 expectScriptRowCount(page, 0);
await expectEmptyScriptList(page); await expectEmptyScriptList(page);
await clickSaveProjectSettings(page);
await expectNoUncommittedSetupWarning(page);
}); });
}); });
@@ -1,4 +1,5 @@
import { chmod, readFile, writeFile } from "node:fs/promises"; import { chmod, readFile, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import path from "node:path"; import path from "node:path";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import type { WebSocketRoute } 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(); 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 --- // --- Form-state assertions ---
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> { 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"); 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 // The daemon writes atomically via a temp file + rename, so blocking writes requires
// removing write permission from the *directory*, not just the file. // removing write permission from the *directory*, not just the file.
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> { export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
+3
View File
@@ -2405,6 +2405,9 @@ export const ar: TranslationResources = {
docsTooltip: "راجع المستندات لمزيد من التفاصيل ومتغيرات البيئة المتاحة لهذه الأوامر", docsTooltip: "راجع المستندات لمزيد من التفاصيل ومتغيرات البيئة المتاحة لهذه الأوامر",
setup: "يثبت", setup: "يثبت",
setupAccessibility: "أوامر إعداد شجرة العمل", setupAccessibility: "أوامر إعداد شجرة العمل",
uncommittedTitle: "ثبّت تغييرات paseo.json",
uncommittedDescription:
"تستخدم أشجار العمل الجديدة نص الإعداد البرمجي من الفرع الأساسي الذي تحدده.",
teardown: "هدم", teardown: "هدم",
teardownAccessibility: "أوامر هدم شجرة العمل", teardownAccessibility: "أوامر هدم شجرة العمل",
}, },
+3
View File
@@ -2419,6 +2419,9 @@ export const en = {
"See docs for more details and the environment variables available to these commands", "See docs for more details and the environment variables available to these commands",
setup: "Setup", setup: "Setup",
setupAccessibility: "Worktree setup commands", setupAccessibility: "Worktree setup commands",
uncommittedTitle: "Commit paseo.json changes",
uncommittedDescription:
"New worktrees use the setup script from the base branch you select.",
teardown: "Teardown", teardown: "Teardown",
teardownAccessibility: "Worktree teardown commands", 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.", "Consulte los documentos para obtener más detalles y las variables de entorno disponibles para estos comandos.",
setup: "Configuración", setup: "Configuración",
setupAccessibility: "Comandos de configuración del árbol de trabajo", 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", teardown: "Demoler",
teardownAccessibility: "Comandos de desmontaje del árbol de trabajo", 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", "Voir la documentation pour plus de détails et les variables d'environnement disponibles pour ces commandes",
setup: "Installation", setup: "Installation",
setupAccessibility: "Commandes de configuration de Worktree", 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", teardown: "Démolir",
teardownAccessibility: "Commandes de démontage de Worktree", teardownAccessibility: "Commandes de démontage de Worktree",
}, },
+3
View File
@@ -2430,6 +2430,9 @@ export const ja: TranslationResources = {
"これらのコマンドで使用可能な詳細と環境変数についてはドキュメントを参照してください", "これらのコマンドで使用可能な詳細と環境変数についてはドキュメントを参照してください",
setup: "セットアップ", setup: "セットアップ",
setupAccessibility: "ワークツリーセットアップコマンド", setupAccessibility: "ワークツリーセットアップコマンド",
uncommittedTitle: "paseo.json の変更をコミットしてください",
uncommittedDescription:
"新しいワークツリーでは、選択したベースブランチのセットアップスクリプトが使われます。",
teardown: "削除時", teardown: "削除時",
teardownAccessibility: "ワークツリー削除時のコマンド", teardownAccessibility: "ワークツリー削除時のコマンド",
}, },
+2
View File
@@ -2422,6 +2422,8 @@ export const ko: TranslationResources = {
docsTooltip: "자세한 내용과 이 명령에 사용할 수 있는 환경 변수는 문서를 참조하세요", docsTooltip: "자세한 내용과 이 명령에 사용할 수 있는 환경 변수는 문서를 참조하세요",
setup: "설정", setup: "설정",
setupAccessibility: "워크트리 설정 명령", setupAccessibility: "워크트리 설정 명령",
uncommittedTitle: "paseo.json 변경 사항을 커밋하세요",
uncommittedDescription: "새 워크트리는 선택한 기본 브랜치의 설정 스크립트를 사용합니다.",
teardown: "정리", teardown: "정리",
teardownAccessibility: "워크트리 정리 명령", 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", "Veja a documentação para mais detalhes e as variáveis de ambiente disponíveis para estes comandos",
setup: "Configuração", setup: "Configuração",
setupAccessibility: "Comandos de configuração do worktree", 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", teardown: "Desmontagem",
teardownAccessibility: "Comandos de desmontagem do worktree", teardownAccessibility: "Comandos de desmontagem do worktree",
}, },
+3
View File
@@ -2448,6 +2448,9 @@ export const ru: TranslationResources = {
"Дополнительную информацию и переменные среды, доступные для этих команд, см. в документации.", "Дополнительную информацию и переменные среды, доступные для этих команд, см. в документации.",
setup: "Настраивать", setup: "Настраивать",
setupAccessibility: "Команды настройки рабочего дерева", setupAccessibility: "Команды настройки рабочего дерева",
uncommittedTitle: "Закоммитьте изменения paseo.json",
uncommittedDescription:
"Новые рабочие деревья используют сценарий настройки из выбранной базовой ветки.",
teardown: "Срывать", teardown: "Срывать",
teardownAccessibility: "Команды разрушения рабочего дерева", teardownAccessibility: "Команды разрушения рабочего дерева",
}, },
+2
View File
@@ -2377,6 +2377,8 @@ export const zhCN: TranslationResources = {
docsTooltip: "查看命令可用的环境变量和更多细节", docsTooltip: "查看命令可用的环境变量和更多细节",
setup: "Setup", setup: "Setup",
setupAccessibility: "Worktree setup 命令", setupAccessibility: "Worktree setup 命令",
uncommittedTitle: "提交 paseo.json 更改",
uncommittedDescription: "新工作树使用所选基础分支中的设置脚本。",
teardown: "Teardown", teardown: "Teardown",
teardownAccessibility: "Worktree teardown 命令", teardownAccessibility: "Worktree teardown 命令",
}, },
@@ -3,6 +3,7 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable, Text, TextInput, View } from "react-native"; import { Pressable, Text, TextInput, View } from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, MoreVertical, Pencil, Plus } from "lucide-react-native"; import { ArrowLeft, MoreVertical, Pencil, Plus } from "lucide-react-native";
@@ -200,6 +201,12 @@ function ProjectSettingsBody({
queryFn: () => client.readProjectConfig(selectedHost.repoRoot), queryFn: () => client.readProjectConfig(selectedHost.repoRoot),
retry: false, retry: false,
}); });
const refetchProjectConfig = readQuery.refetch;
useFocusEffect(
useCallback(() => {
void refetchProjectConfig();
}, [refetchProjectConfig]),
);
const data = readQuery.data; const data = readQuery.data;
const supportsCustomIcon = useHostFeature(selectedHost.serverId, "projectCustomIcon"); const supportsCustomIcon = useHostFeature(selectedHost.serverId, "projectCustomIcon");
@@ -240,6 +247,8 @@ function ProjectSettingsBody({
); );
const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null; const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null;
const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : 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 readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null;
const handleReload = useCallback(() => { const handleReload = useCallback(() => {
@@ -290,6 +299,7 @@ function ProjectSettingsBody({
readQuery, readQuery,
loadedConfig, loadedConfig,
loadedRevision, loadedRevision,
hasUncommittedWorktreeSetupChanges,
readError, readError,
selectedHost, selectedHost,
queryKey, queryKey,
@@ -305,6 +315,7 @@ interface RenderContentInput {
readQuery: ReturnType<typeof useQuery<ReadProjectConfigData>>; readQuery: ReturnType<typeof useQuery<ReadProjectConfigData>>;
loadedConfig: PaseoConfigRaw | null; loadedConfig: PaseoConfigRaw | null;
loadedRevision: PaseoConfigRevision | null; loadedRevision: PaseoConfigRevision | null;
hasUncommittedWorktreeSetupChanges: boolean;
readError: ProjectConfigRpcError | null; readError: ProjectConfigRpcError | null;
selectedHost: ProjectHostEntry; selectedHost: ProjectHostEntry;
queryKey: readonly [string, string, string]; queryKey: readonly [string, string, string];
@@ -317,6 +328,7 @@ function renderContent({
readQuery, readQuery,
loadedConfig, loadedConfig,
loadedRevision, loadedRevision,
hasUncommittedWorktreeSetupChanges,
readError, readError,
selectedHost, selectedHost,
queryKey, queryKey,
@@ -358,6 +370,7 @@ function renderContent({
key={formKey} key={formKey}
baseConfig={loadedConfig} baseConfig={loadedConfig}
revision={loadedRevision} revision={loadedRevision}
hasUncommittedWorktreeSetupChanges={hasUncommittedWorktreeSetupChanges}
repoRoot={selectedHost.repoRoot} repoRoot={selectedHost.repoRoot}
queryKey={queryKey} queryKey={queryKey}
client={client} client={client}
@@ -438,6 +451,7 @@ function errorToDetail(error: unknown): string | null {
interface ProjectConfigFormProps { interface ProjectConfigFormProps {
baseConfig: PaseoConfigRaw; baseConfig: PaseoConfigRaw;
revision: PaseoConfigRevision | null; revision: PaseoConfigRevision | null;
hasUncommittedWorktreeSetupChanges: boolean;
repoRoot: string; repoRoot: string;
queryKey: readonly [string, string, string]; queryKey: readonly [string, string, string];
client: DaemonClient; client: DaemonClient;
@@ -447,6 +461,7 @@ interface ProjectConfigFormProps {
function ProjectConfigForm({ function ProjectConfigForm({
baseConfig, baseConfig,
revision, revision,
hasUncommittedWorktreeSetupChanges,
repoRoot, repoRoot,
queryKey, queryKey,
client, client,
@@ -479,6 +494,11 @@ function ProjectConfigForm({
revision: result.revision, revision: result.revision,
requestId: "local-cache", requestId: "local-cache",
repoRoot, repoRoot,
...(result.hasUncommittedWorktreeSetupChanges === undefined
? {}
: {
hasUncommittedWorktreeSetupChanges: result.hasUncommittedWorktreeSetupChanges,
}),
}); });
setWriteError(null); setWriteError(null);
queryClient.invalidateQueries({ queryKey: ["projects"] }); queryClient.invalidateQueries({ queryKey: ["projects"] });
@@ -660,6 +680,13 @@ function ProjectConfigForm({
testID="worktree-setup-section" testID="worktree-setup-section"
trailing={setupDocsLink} trailing={setupDocsLink}
> >
{hasUncommittedWorktreeSetupChanges ? (
<Alert
variant="warning"
title={t("settings.project.worktree.uncommittedTitle")}
description={t("settings.project.worktree.uncommittedDescription")}
/>
) : null}
<SettingsTextAreaCard <SettingsTextAreaCard
testID="worktree-setup-input" testID="worktree-setup-input"
accessibilityLabel={t("settings.project.worktree.setupAccessibility")} accessibilityLabel={t("settings.project.worktree.setupAccessibility")}
+2
View File
@@ -4219,6 +4219,7 @@ export const ReadProjectConfigResponseMessageSchema = z.object({
ok: z.literal(true), ok: z.literal(true),
config: PaseoConfigRawSchema.nullable(), config: PaseoConfigRawSchema.nullable(),
revision: PaseoConfigRevisionSchema.nullable(), revision: PaseoConfigRevisionSchema.nullable(),
hasUncommittedWorktreeSetupChanges: z.boolean().optional(),
}), }),
z.object({ z.object({
requestId: z.string(), requestId: z.string(),
@@ -4240,6 +4241,7 @@ export const WriteProjectConfigResponseMessageSchema = z.object({
ok: z.literal(true), ok: z.literal(true),
config: PaseoConfigRawSchema, config: PaseoConfigRawSchema,
revision: PaseoConfigRevisionSchema, revision: PaseoConfigRevisionSchema,
hasUncommittedWorktreeSetupChanges: z.boolean().optional(),
}), }),
z.object({ z.object({
requestId: z.string(), 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", () => { it("accepts a compact provider snapshot envelope", () => {
const envelope = { const envelope = {
type: "session", 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); 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 () => { test("removes a new worktree when its ref does not contain the selected project directory", async () => {
const { repoDir, tempDir } = createGitRepo(); const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
@@ -13,7 +13,7 @@ import {
import { import {
mapWorkspaceRelativeCwdToWorktree, mapWorkspaceRelativeCwdToWorktree,
rollbackCreatedPaseoWorktree, rollbackCreatedPaseoWorktree,
copySourcePaseoConfigFile, seedPaseoConfigFile,
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 copySourcePaseoConfigFile({ await seedPaseoConfigFile({
sourceCwd: workspaceCwdPlan.inputCwd, sourceCwd: workspaceCwdPlan.inputCwd,
targetCwd: workspaceCwd, targetCwd: workspaceCwd,
}); });
@@ -1,9 +1,11 @@
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import pino from "pino"; import pino from "pino";
import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js"; 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 { PersistedProjectRecord } from "../../workspace-registry.js";
import type { SessionOutboundMessage } from "../../messages.js"; import type { SessionOutboundMessage } from "../../messages.js";
@@ -45,6 +47,38 @@ function makeSubsystem(records: PersistedProjectRecord[]) {
} }
describe("ProjectConfigSession", () => { 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 () => { test("read resolves a known root despite a trailing slash and returns the raw config + revision", async () => {
const repoRoot = makeRoot(); const repoRoot = makeRoot();
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } })); 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 () => { test("write rejects a stale revision and an unknown root with their inline domain failures", async () => {
const staleRoot = makeRoot(); const staleRoot = makeRoot();
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
@@ -8,6 +8,7 @@ import {
writePaseoConfigForEdit, writePaseoConfigForEdit,
type ProjectConfigRpcError, type ProjectConfigRpcError,
} from "../../../utils/paseo-config-file.js"; } from "../../../utils/paseo-config-file.js";
import { hasUncommittedWorktreeSetupChanges } from "./worktree-setup-commit-status.js";
export interface ProjectConfigSessionHost { export interface ProjectConfigSessionHost {
emit(msg: SessionOutboundMessage): void; emit(msg: SessionOutboundMessage): void;
@@ -63,6 +64,8 @@ export class ProjectConfigSession {
); );
} }
const setupCommitStatus = await this.readSetupCommitStatus(repoRoot, result.config);
this.host.emit({ this.host.emit({
type: "read_project_config_response", type: "read_project_config_response",
payload: { payload: {
@@ -71,6 +74,9 @@ export class ProjectConfigSession {
ok: true, ok: true,
config: result.config, config: result.config,
revision: result.revision, revision: result.revision,
...(setupCommitStatus === null
? {}
: { hasUncommittedWorktreeSetupChanges: setupCommitStatus }),
}, },
}); });
} }
@@ -106,6 +112,7 @@ export class ProjectConfigSession {
{ repoRoot, requestId: msg.requestId, outcome: "written" }, { repoRoot, requestId: msg.requestId, outcome: "written" },
"Project config written", "Project config written",
); );
const setupCommitStatus = await this.readSetupCommitStatus(repoRoot, result.config);
this.host.emit({ this.host.emit({
type: "write_project_config_response", type: "write_project_config_response",
payload: { payload: {
@@ -114,10 +121,25 @@ export class ProjectConfigSession {
ok: true, ok: true,
config: result.config, config: result.config,
revision: result.revision, 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( private emitProjectConfigReadFailure(
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>, msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
error: ProjectConfigRpcError, 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, readFileSync,
chmodSync, chmodSync,
lstatSync, lstatSync,
readlinkSync,
symlinkSync, 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";
@@ -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( writeFileSync(
join(repoDir, "paseo.json"), 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", ["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"], {
cwd: repoDir, 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( writeFileSync(
join(repoDir, "paseo.json"), join(updaterDir, "paseo.json"),
JSON.stringify({ worktree: { setup: 'echo "edited" > edited-setup.log' } }), 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({ const result = await createLegacyWorktreeForTest({
cwd: repoDir, cwd: repoDir,
worktreeSlug: "edited-config", worktreeSlug: "upstream-config",
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/edited-config" }, source: {
runSetup: true, kind: "branch-off",
baseBranch: "origin/main",
branchName: "feature/upstream-config",
},
runSetup: false,
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({
worktree: { setup: 'echo "edited" > edited-setup.log' }, worktree: { setup: "echo new" },
}); });
expect(readFileSync(join(result.worktreePath, "edited-setup.log"), "utf8")).toBe("edited\n"); expect(
expect(existsSync(join(result.worktreePath, "committed-setup.log"))).toBe(false); execFileSync("git", ["status", "--porcelain"], {
cwd: result.worktreePath,
encoding: "utf8",
}),
).toBe("");
}); });
it("replaces a selected ref's paseo.json symlink without writing through it", async () => { it("preserves a dangling paseo.json symlink from the selected ref", async () => {
const sourceConfigPath = join(repoDir, "paseo.json"); const externalConfigPath = join(tempDir, "outside-paseo.json");
const externalConfigPath = join(tempDir, "external-paseo.json"); execFileSync("git", ["checkout", "-b", "symlink-config"], { cwd: repoDir });
const committedConfig = JSON.stringify({ symlinkSync(externalConfigPath, join(repoDir, "paseo.json"));
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", ["add", "paseo.json"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add config link"], { execFileSync(
cwd: repoDir, "git",
}); ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json symlink"],
unlinkSync(sourceConfigPath); { cwd: repoDir },
writeFileSync(sourceConfigPath, editedConfig); );
execFileSync("git", ["checkout", "main"], { cwd: repoDir });
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({ worktree: { setup: "echo source" } }),
);
const result = await createLegacyWorktreeForTest({ const result = await createLegacyWorktreeForTest({
cwd: repoDir, cwd: repoDir,
worktreeSlug: "replace-config-link", worktreeSlug: "symlink-config",
source: { source: {
kind: "branch-off", kind: "branch-off",
baseBranch: "main", baseBranch: "symlink-config",
branchName: "feature/replace-config-link", branchName: "feature/symlink-config",
}, },
runSetup: true, runSetup: false,
paseoHome, paseoHome,
}); });
const worktreeConfigPath = join(result.worktreePath, "paseo.json"); const worktreeConfigPath = join(result.worktreePath, "paseo.json");
expect(readFileSync(externalConfigPath, "utf8")).toBe(committedConfig); expect(lstatSync(worktreeConfigPath).isSymbolicLink()).toBe(true);
expect(lstatSync(worktreeConfigPath).isFile()).toBe(true); expect(readlinkSync(worktreeConfigPath)).toBe(externalConfigPath);
expect(readFileSync(worktreeConfigPath, "utf8")).toBe(editedConfig); expect(existsSync(externalConfigPath)).toBe(false);
expect(readFileSync(join(result.worktreePath, "edited-setup.log"), "utf8")).toBe("edited\n"); expect(
expect(existsSync(join(result.worktreePath, "committed-setup.log"))).toBe(false); 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 () => { 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 { execFile } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs"; import {
import { readFile, rm, stat } from "fs/promises"; 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 { 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,7 +40,6 @@ 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";
@@ -794,20 +800,16 @@ export async function runWorktreeTeardownCommands(options: {
return results; return results;
} }
export async function copySourcePaseoConfigFile(options: { export async function seedPaseoConfigFile(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; await copyFile(sourceConfigPath, targetConfigPath, fsConstants.COPYFILE_EXCL).catch((error) => {
try { const code = (error as NodeJS.ErrnoException).code;
sourceConfig = await readFile(sourceConfigPath); if (code !== "EEXIST" && code !== "ENOENT") throw error;
} catch (error) { });
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw error;
}
await writeFileAtomic(targetConfigPath, sourceConfig);
} }
/** /**
@@ -1257,7 +1259,7 @@ export const createWorktree = async ({
: {}), : {}),
}); });
await copySourcePaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
if (runSetup) { if (runSetup) {
await runWorktreeSetupCommands({ await runWorktreeSetupCommands({