diff --git a/docs/development.md b/docs/development.md index 244db268e..b763e83c8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 exact ref also resolves through its stored branch name. -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. +Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied. ## paseo.json service scripts diff --git a/packages/app/e2e/browser/projects-settings.spec.ts b/packages/app/e2e/browser/projects-settings.spec.ts index a6a68a3ba..52954a329 100644 --- a/packages/app/e2e/browser/projects-settings.spec.ts +++ b/packages/app/e2e/browser/projects-settings.spec.ts @@ -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); }); }); diff --git a/packages/app/e2e/support/helpers/project-settings.ts b/packages/app/e2e/support/helpers/project-settings.ts index 703a0fa83..8b3b23faa 100644 --- a/packages/app/e2e/support/helpers/project-settings.ts +++ b/packages/app/e2e/support/helpers/project-settings.ts @@ -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 { await expect(page.getByTestId("save-button")).toBeDisabled(); } +export async function expectUncommittedSetupWarning(page: Page): Promise { + 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 { + 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 { @@ -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 { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b42e61b66..f72bcb1a3 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2405,6 +2405,9 @@ export const ar: TranslationResources = { docsTooltip: "راجع المستندات لمزيد من التفاصيل ومتغيرات البيئة المتاحة لهذه الأوامر", setup: "يثبت", setupAccessibility: "أوامر إعداد شجرة العمل", + uncommittedTitle: "ثبّت تغييرات paseo.json", + uncommittedDescription: + "تستخدم أشجار العمل الجديدة نص الإعداد البرمجي من الفرع الأساسي الذي تحدده.", teardown: "هدم", teardownAccessibility: "أوامر هدم شجرة العمل", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index af43bec2e..83ff3c8b3 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 60e14d81e..4d38d6e70 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 471de6482..44c61c65e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 57331e14a..6d2eaace6 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2430,6 +2430,9 @@ export const ja: TranslationResources = { "これらのコマンドで使用可能な詳細と環境変数についてはドキュメントを参照してください", setup: "セットアップ", setupAccessibility: "ワークツリーセットアップコマンド", + uncommittedTitle: "paseo.json の変更をコミットしてください", + uncommittedDescription: + "新しいワークツリーでは、選択したベースブランチのセットアップスクリプトが使われます。", teardown: "削除時", teardownAccessibility: "ワークツリー削除時のコマンド", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 6be87e7b9..f7e76a6c5 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2422,6 +2422,8 @@ export const ko: TranslationResources = { docsTooltip: "자세한 내용과 이 명령에 사용할 수 있는 환경 변수는 문서를 참조하세요", setup: "설정", setupAccessibility: "워크트리 설정 명령", + uncommittedTitle: "paseo.json 변경 사항을 커밋하세요", + uncommittedDescription: "새 워크트리는 선택한 기본 브랜치의 설정 스크립트를 사용합니다.", teardown: "정리", teardownAccessibility: "워크트리 정리 명령", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 7c05f5e00..5006b2251 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e68c7e4e7..0bd5ccbbe 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2448,6 +2448,9 @@ export const ru: TranslationResources = { "Дополнительную информацию и переменные среды, доступные для этих команд, см. в документации.", setup: "Настраивать", setupAccessibility: "Команды настройки рабочего дерева", + uncommittedTitle: "Закоммитьте изменения paseo.json", + uncommittedDescription: + "Новые рабочие деревья используют сценарий настройки из выбранной базовой ветки.", teardown: "Срывать", teardownAccessibility: "Команды разрушения рабочего дерева", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 9c70d7b70..fffc9dc6a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2377,6 +2377,8 @@ export const zhCN: TranslationResources = { docsTooltip: "查看命令可用的环境变量和更多细节", setup: "Setup", setupAccessibility: "Worktree setup 命令", + uncommittedTitle: "提交 paseo.json 更改", + uncommittedDescription: "新工作树使用所选基础分支中的设置脚本。", teardown: "Teardown", teardownAccessibility: "Worktree teardown 命令", }, diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index fd9ff3240..5d9c4fba2 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -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>; 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 ? ( + + ) : null} { + 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", diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 614820dd4..c40f710d3 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -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); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 7e7227560..b32123cb3 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -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, }); diff --git a/packages/server/src/server/session/project-config/project-config-session.test.ts b/packages/server/src/server/session/project-config/project-config-session.test.ts index 0faa95404..6e93dff80 100644 --- a/packages/server/src/server/session/project-config/project-config-session.test.ts +++ b/packages/server/src/server/session/project-config/project-config-session.test.ts @@ -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" } })); diff --git a/packages/server/src/server/session/project-config/project-config-session.ts b/packages/server/src/server/session/project-config/project-config-session.ts index ae62bd3d2..6056d0882 100644 --- a/packages/server/src/server/session/project-config/project-config-session.ts +++ b/packages/server/src/server/session/project-config/project-config-session.ts @@ -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[0]["currentConfig"], + ): Promise { + 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, error: ProjectConfigRpcError, diff --git a/packages/server/src/server/session/project-config/worktree-setup-commit-status.test.ts b/packages/server/src/server/session/project-config/worktree-setup-commit-status.test.ts new file mode 100644 index 000000000..73c54014a --- /dev/null +++ b/packages/server/src/server/session/project-config/worktree-setup-commit-status.test.ts @@ -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); + }); +}); diff --git a/packages/server/src/server/session/project-config/worktree-setup-commit-status.ts b/packages/server/src/server/session/project-config/worktree-setup-commit-status.ts new file mode 100644 index 000000000..dca1eca8e --- /dev/null +++ b/packages/server/src/server/session/project-config/worktree-setup-commit-status.ts @@ -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 { + 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 { + 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 { + 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]); +} diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 73bd6c01f..36e1642f7 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -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 () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 5add0f76e..e3cb98aba 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -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 { 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({