From eab8713c24108b634196d484c9c811b77ccaf9e8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Jan 2026 15:27:42 +0700 Subject: [PATCH] Persist base branch for Paseo worktrees and gate merge action --- packages/app/e2e/checkout-ship.spec.ts | 6 +- packages/app/src/app/index.tsx | 20 ++- packages/app/src/components/git-diff-pane.tsx | 55 +++++--- .../src/hooks/use-checkout-status-query.ts | 19 +++ .../agent-self-identification.e2e.test.ts | 2 + .../server/src/server/agent/mcp-server.ts | 12 ++ .../daemon-e2e/checkout-ship.e2e.test.ts | 10 ++ .../daemon-e2e/git-operations.e2e.test.ts | 3 +- packages/server/src/server/session.ts | 75 +++++++--- packages/server/src/shared/messages.ts | 55 ++++++-- .../server/src/utils/checkout-git.test.ts | 101 ++++++++++++++ packages/server/src/utils/checkout-git.ts | 128 ++++++++++++++---- .../server/src/utils/worktree-metadata.ts | 87 ++++++++++++ packages/server/src/utils/worktree.test.ts | 51 +++++++ packages/server/src/utils/worktree.ts | 20 ++- 15 files changed, 565 insertions(+), 79 deletions(-) create mode 100644 packages/server/src/utils/worktree-metadata.ts diff --git a/packages/app/e2e/checkout-ship.spec.ts b/packages/app/e2e/checkout-ship.spec.ts index a1aba9f27..d4965cce7 100644 --- a/packages/app/e2e/checkout-ship.spec.ts +++ b/packages/app/e2e/checkout-ship.spec.ts @@ -108,7 +108,7 @@ async function createAgentAndWait(page: Page, message: string) { async function requestCwd(page: Page) { await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: '); const message = await waitForAssistantText(page, 'CWD:'); - const content = await message.innerText(); + const content = (await message.textContent()) ?? ''; const match = content.match(/CWD:\s*(\S+)/); if (!match) { throw new Error(`Expected agent to respond with "CWD: ", got: ${content}`); @@ -303,6 +303,8 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({ timeout: 60000, }); + await refreshChangesTab(page); + await expect(getChangesActionLabel(page, 'Merge to base')).toHaveCount(0, { timeout: 30000 }); await getChangesActionButton(page, 'Archive').click(); // Archiving a worktree deletes agents and redirects to home @@ -331,7 +333,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible(); await expect(getChangesActionButton(page, 'Commit')).toHaveAttribute('aria-disabled', 'true'); await expect(getChangesActionButton(page, 'Create PR')).toHaveAttribute('aria-disabled', 'true'); - await expect(getChangesActionButton(page, 'Merge to base')).toHaveAttribute('aria-disabled', 'true'); + await expect(getChangesActionLabel(page, 'Merge to base')).toHaveCount(0); } finally { await rm(nonGitDir, { recursive: true, force: true }); await repo.cleanup(); diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 3ca2e0282..580a9788f 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -416,9 +416,12 @@ export default function HomeScreen() { ]); const baseBranchError = useMemo(() => { - if (!isCreateWorktree || isNonGitDirectory || !baseBranch) { + if (!isCreateWorktree || isNonGitDirectory) { return null; } + if (!baseBranch) { + return "Base branch is required"; + } const branches = repoInfo?.branches ?? []; if (branches.length === 0) { return null; @@ -442,6 +445,19 @@ export default function HomeScreen() { [setWorkingDirFromUser] ); + useEffect(() => { + if (!isCreateWorktree || isNonGitDirectory) { + return; + } + if (baseBranch) { + return; + } + const current = repoInfo?.currentBranch?.trim(); + if (current) { + setBaseBranch(current); + } + }, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.currentBranch]); + useEffect(() => { if (isNonGitDirectory && worktreeMode !== "none") { setWorktreeMode("none"); @@ -514,7 +530,7 @@ export default function HomeScreen() { ...(modeId ? { modeId } : {}), ...(trimmedModel ? { model: trimmedModel } : {}), }; - const effectiveBaseBranch = baseBranch.trim() || repoInfo?.currentBranch || undefined; + const effectiveBaseBranch = baseBranch.trim(); const effectiveWorktreeSlug = isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug; if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) { diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 2f33f5941..33a669536 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -356,12 +356,13 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { const [actionStatus, setActionStatus] = useState(null); const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } = useCheckoutStatusQuery({ serverId, agentId }); - const isGit = status?.isGit ?? false; - const notGit = Boolean(status) && !status?.isGit && !status?.error; + const gitStatus = status && status.isGit ? status : null; + const isGit = Boolean(gitStatus); + const notGit = status !== null && !status.isGit && !status.error; const statusErrorMessage = status?.error?.message ?? (isStatusError && statusError instanceof Error ? statusError.message : null); - const baseRef = status?.baseRef ?? undefined; + const baseRef = gitStatus?.baseRef ?? undefined; const { files, payloadError: diffPayloadError, @@ -593,17 +594,22 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { diffPayloadError?.message ?? (isDiffError && diffError instanceof Error ? diffError.message : null); const prErrorMessage = prPayloadError?.message ?? null; - const branchLabel = status?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown"); + const branchLabel = + gitStatus?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown"); const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; - const hasUncommittedChanges = Boolean(status?.isDirty); + const hasUncommittedChanges = Boolean(gitStatus?.isDirty); const showCommitAction = !isGit || hasUncommittedChanges || (diffMode === "uncommitted" && hasChanges); const showCreatePrAction = !isGit || !prStatus; + const showMergeAction = + diffMode === "base" && isGit && (gitStatus?.aheadBehind?.ahead ?? 0) > 0; const commitDisabled = actionsDisabled || commitMutation.isPending; const prDisabled = actionsDisabled || prMutation.isPending; const mergeDisabled = actionsDisabled || mergeMutation.isPending; const archiveDisabled = - actionsDisabled || archiveMutation.isPending || !status?.isPaseoOwnedWorktree; + actionsDisabled || + archiveMutation.isPending || + !gitStatus?.isPaseoOwnedWorktree; let bodyContent: ReactElement; @@ -745,18 +751,20 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { )} ) : null} - mergeMutation.mutate()} - disabled={mergeDisabled} - > - {mergeMutation.isPending ? ( - - ) : ( - Merge to base - )} - + {showMergeAction ? ( + mergeMutation.mutate()} + disabled={mergeDisabled} + > + {mergeMutation.isPending ? ( + + ) : ( + Merge to base + )} + + ) : null} + + mode={diffMode} files={files.length} isGit={String(isGit)} ahead= + {gitStatus?.aheadBehind ? String(gitStatus.aheadBehind.ahead) : "null"} + + {actionStatus ? {actionStatus} : null} {actionError ? {actionError} : null} {prStatus ? ( @@ -866,6 +879,12 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontWeight: theme.fontWeight.medium, }, + debugText: { + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[1], + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, actionStatusText: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[1], diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts index 2aec4c476..bd0044e09 100644 --- a/packages/app/src/hooks/use-checkout-status-query.ts +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -1,5 +1,8 @@ import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { UnistylesRuntime } from "react-native-unistyles"; import { useSessionStore } from "@/stores/session-store"; +import { usePanelStore } from "@/stores/panel-store"; import type { CheckoutStatusResponse } from "@server/shared/messages"; const CHECKOUT_STATUS_STALE_TIME = 15_000; @@ -22,6 +25,12 @@ export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQ const isConnected = useSessionStore( (state) => state.sessions[serverId]?.connection.isConnected ?? false ); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const mobileView = usePanelStore((state) => state.mobileView); + const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const explorerTab = usePanelStore((state) => state.explorerTab); + const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const query = useQuery({ queryKey: checkoutStatusQueryKey(serverId, agentId), @@ -34,8 +43,18 @@ export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQ enabled: !!client && isConnected && !!agentId, staleTime: CHECKOUT_STATUS_STALE_TIME, refetchInterval: 10_000, + refetchIntervalInBackground: true, + refetchOnMount: "always", }); + // Revalidate when sidebar is open with "changes" tab active. + useEffect(() => { + if (!isOpen || explorerTab !== "changes" || !agentId) { + return; + } + void query.refetch(); + }, [isOpen, explorerTab, agentId, query]); + return { status: query.data ?? null, isLoading: query.isLoading, diff --git a/packages/server/src/server/agent/agent-self-identification.e2e.test.ts b/packages/server/src/server/agent/agent-self-identification.e2e.test.ts index 4cd4f10b5..17054d0e8 100644 --- a/packages/server/src/server/agent/agent-self-identification.e2e.test.ts +++ b/packages/server/src/server/agent/agent-self-identification.e2e.test.ts @@ -128,6 +128,7 @@ describe("self-identification MCP tools", () => { const worktree = await createWorktree({ branchName: "self-ident", cwd: repoDir, + baseBranch: "main", worktreeSlug: "self-ident", }); @@ -176,6 +177,7 @@ describe("self-identification MCP tools", () => { const worktree = await createWorktree({ branchName: "self-ident-subdir", cwd: repoDir, + baseBranch: "main", worktreeSlug: "self-ident-subdir", }); const nestedDir = path.join(worktree.worktreePath, "nested"); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index c580e5618..79aff5c72 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -336,6 +336,12 @@ export async function createAgentMcpServer( .describe( "Optional git worktree branch name (lowercase alphanumerics + hyphen)." ), + baseBranch: z + .string() + .optional() + .describe( + "Required when worktreeName is set: the base branch to diff/merge against." + ), background: z .boolean() .optional() @@ -422,21 +428,27 @@ export async function createAgentMcpServer( cwd: string; initialMode: string; worktreeName?: string; + baseBranch?: string; parentAgentId?: string; }; const { cwd, initialMode, worktreeName, + baseBranch, parentAgentId, } = topLevelArgs; resolvedCwd = expandPath(cwd); if (worktreeName) { + if (!baseBranch) { + throw new Error("baseBranch is required when creating a worktree"); + } const worktree = await createWorktree({ branchName: worktreeName, cwd: resolvedCwd, + baseBranch, worktreeSlug: worktreeName, }); resolvedCwd = worktree.worktreePath; diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts index 71073687d..3c705659e 100644 --- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -194,6 +194,9 @@ describe("daemon checkout ship loop", () => { expect(status.isGit).toBe(true); expect(status.isPaseoOwnedWorktree).toBe(true); expect(status.repoRoot).toContain(repoDir); + if (status.isGit) { + expect(status.baseRef).toBe("main"); + } mcpClient = await createMcpClient(ctx.daemon.port, agent.id); const renameResult = (await mcpClient.callTool({ @@ -261,6 +264,13 @@ describe("daemon checkout ship loop", () => { expect(mergeResult.error).toBeNull(); expect(mergeResult.success).toBe(true); + const statusAfterMerge = await ctx.client.getCheckoutStatus(agent.id); + expect(statusAfterMerge.isGit).toBe(true); + if (statusAfterMerge.isGit) { + expect(statusAfterMerge.baseRef).toBe("main"); + expect(statusAfterMerge.aheadBehind?.ahead ?? 0).toBe(0); + } + const baseDiffAfterMerge = await ctx.client.getCheckoutDiff(agent.id, { mode: "base", baseRef: "main", diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index a32b5ec77..72ee40998 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -128,7 +128,7 @@ describe("daemon E2E", () => { // Initialize git repo const { execSync } = await import("child_process"); - execSync("git init", { cwd, stdio: "pipe" }); + execSync("git init -b main", { cwd, stdio: "pipe" }); execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); @@ -571,6 +571,7 @@ describe("daemon E2E", () => { createNewBranch: true, newBranchName: "worktree-test", worktreeSlug: "worktree-test", + baseBranch: "main", }, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8e7b9013e..81c7f1f8c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1587,9 +1587,7 @@ export class Session { const createdWorktree = await createWorktree({ branchName: targetBranch, cwd, - baseBranch: normalized.createNewBranch - ? normalized.baseBranch - : undefined, + baseBranch: normalized.baseBranch!, worktreeSlug: normalized.worktreeSlug ?? targetBranch, runSetup: false, }); @@ -1598,7 +1596,7 @@ export class Session { } else if (normalized.createNewBranch) { await this.createBranchFromBase({ cwd, - baseBranch: normalized.baseBranch ?? "HEAD", + baseBranch: normalized.baseBranch!, newBranchName: normalized.newBranchName!, }); } else if (normalized.baseBranch) { @@ -1822,6 +1820,13 @@ export class Session { this.assertSafeGitRef(baseBranch, "base branch"); } + if (createWorktree && !baseBranch) { + throw new Error("Base branch is required when creating a worktree"); + } + if (createNewBranch && !baseBranch) { + throw new Error("Base branch is required when creating a new branch"); + } + if (createNewBranch) { if (!normalizedBranchName) { throw new Error("New branch name is required"); @@ -2382,7 +2387,7 @@ export class Session { isDirty: null, baseRef: null, aheadBehind: null, - isPaseoOwnedWorktree: null, + isPaseoOwnedWorktree: false, error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, requestId, }, @@ -2412,12 +2417,24 @@ export class Session { return; } - let isPaseoOwnedWorktree = false; - try { - const ownership = await isPaseoOwnedWorktreeCwd(agent.cwd); - isPaseoOwnedWorktree = ownership.allowed; - } catch { - isPaseoOwnedWorktree = false; + if (status.isPaseoOwnedWorktree) { + this.emit({ + type: "checkout_status_response", + payload: { + agentId, + cwd: agent.cwd, + isGit: true, + repoRoot: status.repoRoot ?? null, + currentBranch: status.currentBranch ?? null, + isDirty: status.isDirty ?? null, + baseRef: status.baseRef, + aheadBehind: status.aheadBehind ?? null, + isPaseoOwnedWorktree: true, + error: null, + requestId, + }, + }); + return; } this.emit({ @@ -2431,7 +2448,7 @@ export class Session { isDirty: status.isDirty ?? null, baseRef: status.baseRef ?? null, aheadBehind: status.aheadBehind ?? null, - isPaseoOwnedWorktree, + isPaseoOwnedWorktree: false, error: null, requestId, }, @@ -2448,7 +2465,7 @@ export class Session { isDirty: null, baseRef: null, aheadBehind: null, - isPaseoOwnedWorktree: null, + isPaseoOwnedWorktree: false, error: this.toCheckoutError(error), requestId, }, @@ -2577,13 +2594,35 @@ export class Session { try { const status = await getCheckoutStatus(agent.cwd); if (!status.isGit) { - throw new NotGitRepoError(agent.cwd); - } - if (msg.requireCleanTarget && status.isDirty) { - throw new Error("Working directory has uncommitted changes."); + // `getCheckoutStatus` can return `isGit=false` in transient situations (e.g. cwd lookup + // running during other git operations). Double-check with git directly before failing. + try { + await execAsync("git rev-parse --is-inside-work-tree", { + cwd: agent.cwd, + env: READ_ONLY_GIT_ENV, + }); + } catch (error) { + const details = + typeof (error as any)?.stderr === "string" + ? String((error as any).stderr).trim() + : error instanceof Error + ? error.message + : String(error); + throw new Error(`Not a git repository: ${agent.cwd}\n${details}`.trim()); + } } - let baseRef = msg.baseRef ?? status.baseRef ?? null; + if (msg.requireCleanTarget) { + const { stdout } = await execAsync("git status --porcelain", { + cwd: agent.cwd, + env: READ_ONLY_GIT_ENV, + }); + if (stdout.trim().length > 0) { + throw new Error("Working directory has uncommitted changes."); + } + } + + let baseRef = msg.baseRef ?? (status.isGit ? status.baseRef : null); if (!baseRef) { throw new Error("Base branch is required for merge"); } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index ea2f5adbf..1e36ff5a7 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -966,21 +966,50 @@ const AheadBehindSchema = z.object({ behind: z.number(), }); +const CheckoutStatusCommonSchema = z.object({ + agentId: z.string(), + cwd: z.string(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), +}); + +const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({ + isGit: z.literal(false), + isPaseoOwnedWorktree: z.literal(false), + repoRoot: z.null(), + currentBranch: z.null(), + isDirty: z.null(), + baseRef: z.null(), + aheadBehind: z.null(), +}); + +const CheckoutStatusGitNonPaseoSchema = CheckoutStatusCommonSchema.extend({ + isGit: z.literal(true), + isPaseoOwnedWorktree: z.literal(false), + repoRoot: z.string(), + currentBranch: z.string().nullable(), + isDirty: z.boolean(), + baseRef: z.string().nullable(), + aheadBehind: AheadBehindSchema.nullable(), +}); + +const CheckoutStatusGitPaseoSchema = CheckoutStatusCommonSchema.extend({ + isGit: z.literal(true), + isPaseoOwnedWorktree: z.literal(true), + repoRoot: z.string(), + currentBranch: z.string().nullable(), + isDirty: z.boolean(), + baseRef: z.string(), + aheadBehind: AheadBehindSchema.nullable(), +}); + export const CheckoutStatusResponseSchema = z.object({ type: z.literal("checkout_status_response"), - payload: z.object({ - agentId: z.string(), - cwd: z.string(), - isGit: z.boolean(), - repoRoot: z.string().nullable().optional(), - currentBranch: z.string().nullable().optional(), - isDirty: z.boolean().nullable().optional(), - baseRef: z.string().nullable().optional(), - aheadBehind: AheadBehindSchema.nullable().optional(), - isPaseoOwnedWorktree: z.boolean().nullable().optional(), - error: CheckoutErrorSchema.nullable(), - requestId: z.string(), - }), + payload: z.union([ + CheckoutStatusNotGitSchema, + CheckoutStatusGitNonPaseoSchema, + CheckoutStatusGitPaseoSchema, + ]), }); export const CheckoutDiffResponseSchema = z.object({ diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 84f8fa58d..37d3c27c9 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -12,6 +12,7 @@ import { NotGitRepoError, } from "./checkout-git.js"; import { createWorktree } from "./worktree.js"; +import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; function initRepo(): { tempDir: string; repoDir: string } { const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-test-"))); @@ -87,6 +88,7 @@ describe("checkout git utilities", () => { const result = await createWorktree({ branchName: "main", cwd: repoDir, + baseBranch: "main", worktreeSlug: "alpha", }); @@ -117,6 +119,7 @@ describe("checkout git utilities", () => { const worktree = await createWorktree({ branchName: "main", cwd: repoDir, + baseBranch: "main", worktreeSlug: "merge", }); @@ -138,6 +141,12 @@ describe("checkout git utilities", () => { }); expect(baseContainsFeature).toBeDefined(); + const statusAfterMerge = await getCheckoutStatus(worktree.worktreePath); + expect(statusAfterMerge.isGit).toBe(true); + if (statusAfterMerge.isGit) { + expect(statusAfterMerge.aheadBehind?.ahead ?? 0).toBe(0); + } + const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: worktree.worktreePath, }) @@ -174,4 +183,96 @@ describe("checkout git utilities", () => { mergeToBase(repoDir, { baseRef: "main" }) ).rejects.toBeInstanceOf(MergeConflictError); }); + + it("uses stored baseRefName for Paseo worktrees (no heuristics)", async () => { + // Create a non-default base branch with a unique commit. + execSync("git checkout -b develop", { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "develop\n"); + execSync("git add file.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'develop change'", { cwd: repoDir }); + execSync("git checkout main", { cwd: repoDir }); + + // Create a worktree/branch based on develop, but keep main as the repo default. + const worktree = await createWorktree({ + branchName: "feature", + cwd: repoDir, + baseBranch: "develop", + worktreeSlug: "feature", + }); + + writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); + execSync("git add feature.txt", { cwd: worktree.worktreePath }); + execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + cwd: worktree.worktreePath, + }); + + const status = await getCheckoutStatus(worktree.worktreePath); + expect(status.isGit).toBe(true); + expect(status.baseRef).toBe("develop"); + expect(status.aheadBehind?.ahead).toBe(1); + + const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }); + expect(baseDiff.diff).toContain("feature.txt"); + expect(baseDiff.diff).not.toContain("file.txt"); + }); + + it("merges to stored baseRefName when baseRef is not provided", async () => { + // Create a non-default base branch with a unique commit. + execSync("git checkout -b develop", { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "develop\n"); + execSync("git add file.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'develop change'", { cwd: repoDir }); + execSync("git checkout main", { cwd: repoDir }); + + // Create a Paseo worktree configured to use develop as base. + const worktree = await createWorktree({ + branchName: "feature", + cwd: repoDir, + baseBranch: "develop", + worktreeSlug: "merge-to-develop", + }); + + writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); + execSync("git add feature.txt", { cwd: worktree.worktreePath }); + execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + cwd: worktree.worktreePath, + }); + const featureCommit = execSync("git rev-parse HEAD", { cwd: worktree.worktreePath }) + .toString() + .trim(); + + // No baseRef passed: should merge into the configured base (develop), not default/main. + await mergeToBase(worktree.worktreePath); + + execSync(`git merge-base --is-ancestor ${featureCommit} develop`, { + cwd: repoDir, + stdio: "pipe", + }); + expect(() => + execSync(`git merge-base --is-ancestor ${featureCommit} main`, { + cwd: repoDir, + stdio: "pipe", + }) + ).toThrow(); + }); + + it("throws if Paseo worktree base metadata is missing", async () => { + const worktree = await createWorktree({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "missing-metadata", + }); + + const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); + rmSync(metadataPath, { force: true }); + + await expect(getCheckoutStatus(worktree.worktreePath)).rejects.toThrow( + /base/i + ); + await expect(getCheckoutDiff(worktree.worktreePath, { mode: "base" })).rejects.toThrow( + /base/i + ); + await expect(mergeToBase(worktree.worktreePath)).rejects.toThrow(/base/i); + }); }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 1482c4954..dfe362cd5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -3,7 +3,8 @@ import { promisify } from "util"; import { resolve } from "path"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; -import { detectRepoInfo } from "./worktree.js"; +import { detectRepoInfo, isPaseoOwnedWorktreeCwd } from "./worktree.js"; +import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js"; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); @@ -43,14 +44,33 @@ export interface AheadBehind { } export interface CheckoutStatus { - isGit: boolean; - repoRoot?: string; - currentBranch?: string | null; - isDirty?: boolean; - baseRef?: string | null; - aheadBehind?: AheadBehind | null; + isGit: false; } +export type CheckoutStatusGitNonPaseo = { + isGit: true; + repoRoot: string; + currentBranch: string | null; + isDirty: boolean; + baseRef: string | null; + aheadBehind: AheadBehind | null; + isPaseoOwnedWorktree: false; +}; + +export type CheckoutStatusGitPaseo = { + isGit: true; + repoRoot: string; + currentBranch: string | null; + isDirty: boolean; + baseRef: string; + aheadBehind: AheadBehind | null; + isPaseoOwnedWorktree: true; +}; + +export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPaseo; + +export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit; + export interface CheckoutDiffResult { diff: string; structured?: ParsedDiffFile[]; @@ -179,6 +199,25 @@ export async function renameCurrentBranch( return { previousBranch, currentBranch }; } +type ConfiguredBaseRefForCwd = + | { baseRef: null; isPaseoOwnedWorktree: false } + | { baseRef: string; isPaseoOwnedWorktree: true }; + +async function getConfiguredBaseRefForCwd( + cwd: string +): Promise { + const ownership = await isPaseoOwnedWorktreeCwd(cwd); + if (!ownership.allowed) { + return { baseRef: null, isPaseoOwnedWorktree: false }; + } + + const worktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd; + return { + baseRef: requirePaseoWorktreeBaseRefName(worktreeRoot), + isPaseoOwnedWorktree: true, + }; +} + async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Promise { if (repoType === "bare") { return false; @@ -198,7 +237,21 @@ async function resolveBaseRef(repoRoot: string): Promise { }); const ref = stdout.trim(); if (ref) { - return ref.replace(/^refs\/remotes\//, ""); + // Prefer a local branch name (e.g. "main") over the remote-tracking ref (e.g. "origin/main") + // so that status/diff/merge all operate against the same base ref. + const remoteShort = ref.replace(/^refs\/remotes\//, ""); + const localName = remoteShort.startsWith("origin/") + ? remoteShort.slice("origin/".length) + : remoteShort; + try { + await execAsync(`git show-ref --verify --quiet refs/heads/${localName}`, { + cwd: repoRoot, + env: READ_ONLY_GIT_ENV, + }); + return localName; + } catch { + return remoteShort; + } } } catch { // ignore @@ -223,12 +276,17 @@ async function resolveBaseRef(repoRoot: string): Promise { return null; } +function normalizeLocalBranchRefName(input: string): string { + return input.startsWith("origin/") ? input.slice("origin/".length) : input; +} + async function getAheadBehind(cwd: string, baseRef: string, currentBranch: string): Promise { - if (!baseRef || !currentBranch || baseRef === currentBranch) { + const normalizedBaseRef = normalizeLocalBranchRefName(baseRef); + if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) { return null; } const { stdout } = await execAsync( - `git rev-list --left-right --count ${baseRef}...${currentBranch}`, + `git rev-list --left-right --count ${normalizedBaseRef}...${currentBranch}`, { cwd, env: READ_ONLY_GIT_ENV } ); const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/); @@ -268,7 +326,7 @@ async function getUntrackedDiff(cwd: string): Promise { return untrackedDiff; } -export async function getCheckoutStatus(cwd: string): Promise { +export async function getCheckoutStatus(cwd: string): Promise { let repoInfo: Awaited>; try { repoInfo = await detectRepoInfo(cwd); @@ -281,10 +339,22 @@ export async function getCheckoutStatus(cwd: string): Promise { const currentBranch = await getCurrentBranch(cwd); const isDirty = await isWorkingTreeDirty(cwd, repoInfo.type); - const baseRef = await resolveBaseRef(repoInfo.path); - const aheadBehind = baseRef && currentBranch - ? await getAheadBehind(cwd, baseRef, currentBranch) - : null; + const configured = await getConfiguredBaseRefForCwd(cwd); + const baseRef = configured.baseRef ?? (await resolveBaseRef(repoInfo.path)); + const aheadBehind = + baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch) : null; + + if (configured.isPaseoOwnedWorktree) { + return { + isGit: true, + repoRoot: repoInfo.path, + currentBranch, + isDirty, + baseRef: configured.baseRef, + aheadBehind, + isPaseoOwnedWorktree: true, + }; + } return { isGit: true, @@ -293,6 +363,7 @@ export async function getCheckoutStatus(cwd: string): Promise { isDirty, baseRef, aheadBehind, + isPaseoOwnedWorktree: false, }; } @@ -311,12 +382,16 @@ export async function getCheckoutDiff( const untrackedDiff = await getUntrackedDiff(cwd); diff = trackedDiff + untrackedDiff; } else { + const configured = await getConfiguredBaseRefForCwd(cwd); const repoInfo = await detectRepoInfo(cwd); - const baseRef = compare.baseRef ?? (await resolveBaseRef(repoInfo.path)); + const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(repoInfo.path)); if (!baseRef) { diff = ""; + } else if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) { + throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`); } else { - const { stdout } = await execAsync(`git diff ${baseRef}...HEAD`, { + const normalizedBaseRef = normalizeLocalBranchRefName(baseRef); + const { stdout } = await execAsync(`git diff ${normalizedBaseRef}...HEAD`, { cwd, env: READ_ONLY_GIT_ENV, }); @@ -350,17 +425,19 @@ export async function commitAll(cwd: string, message: string): Promise { export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}): Promise { const repoInfo = await requireRepoInfo(cwd); const currentBranch = await getCurrentBranch(cwd); - const baseRef = options.baseRef ?? (await resolveBaseRef(repoInfo.path)); + const configured = await getConfiguredBaseRefForCwd(cwd); + const baseRef = configured.baseRef ?? options.baseRef ?? (await resolveBaseRef(repoInfo.path)); if (!baseRef) { throw new Error("Unable to determine base branch for merge"); } + if (configured.isPaseoOwnedWorktree && options.baseRef && options.baseRef !== baseRef) { + throw new Error(`Base ref mismatch: expected ${baseRef}, got ${options.baseRef}`); + } if (!currentBranch) { throw new Error("Unable to determine current branch for merge"); } let normalizedBaseRef = baseRef; - if (normalizedBaseRef.startsWith("origin/")) { - normalizedBaseRef = normalizedBaseRef.slice("origin/".length); - } + normalizedBaseRef = normalizeLocalBranchRefName(normalizedBaseRef); if (normalizedBaseRef === currentBranch) { return; } @@ -523,19 +600,24 @@ export async function createPullRequest( const repoInfo = await detectRepoInfo(cwd); const head = options.head ?? (await getCurrentBranch(cwd)); - const base = options.base ?? (await resolveBaseRef(repoInfo.path)); + const configured = await getConfiguredBaseRefForCwd(cwd); + const base = configured.baseRef ?? options.base ?? (await resolveBaseRef(repoInfo.path)); if (!head) { throw new Error("Unable to determine head branch for PR"); } if (!base) { throw new Error("Unable to determine base branch for PR"); } + const normalizedBase = normalizeLocalBranchRefName(base); + if (configured.isPaseoOwnedWorktree && options.base && options.base !== base) { + throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`); + } await execAsync(`git push -u origin ${head}`, { cwd }); const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`]; args.push("-f", `head=${head}`); - args.push("-f", `base=${base}`); + args.push("-f", `base=${normalizedBase}`); if (options.body) { args.push("-f", `body=${options.body}`); } diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts new file mode 100644 index 000000000..259292006 --- /dev/null +++ b/packages/server/src/utils/worktree-metadata.ts @@ -0,0 +1,87 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { isAbsolute, join, resolve } from "path"; +import { z } from "zod"; + +const PaseoWorktreeMetadataSchema = z.object({ + version: z.literal(1), + baseRefName: z.string().min(1), +}); + +export type PaseoWorktreeMetadata = z.infer; + +function getGitDirForWorktreeRoot(worktreeRoot: string): string { + const gitPath = join(worktreeRoot, ".git"); + if (!existsSync(gitPath)) { + throw new Error(`Not a git repository: ${worktreeRoot}`); + } + + // In a worktree checkout, `.git` is a file containing `gitdir: `. + // In a normal checkout, `.git` is a directory. + try { + const gitFileContent = readFileSync(gitPath, "utf8"); + const match = gitFileContent.match(/gitdir:\s*(.+)/); + if (match?.[1]) { + const raw = match[1].trim(); + return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw); + } + } catch { + // If `.git` is a directory, readFileSync will throw; fall through. + } + + return gitPath; +} + +export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string { + const gitDir = getGitDirForWorktreeRoot(worktreeRoot); + return join(gitDir, "paseo", "worktree.json"); +} + +export function normalizeBaseRefName(input: string): string { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("Base branch is required"); + } + if (trimmed.startsWith("origin/")) { + return trimmed.slice("origin/".length); + } + return trimmed; +} + +export function writePaseoWorktreeMetadata( + worktreeRoot: string, + options: { baseRefName: string } +): void { + const baseRefName = normalizeBaseRefName(options.baseRefName); + if (baseRefName === "HEAD") { + throw new Error("Base branch cannot be HEAD"); + } + if (baseRefName.includes("..") || baseRefName.includes("@{")) { + throw new Error(`Invalid base branch: ${baseRefName}`); + } + if (!/^[0-9A-Za-z._/-]+$/.test(baseRefName)) { + throw new Error(`Invalid base branch: ${baseRefName}`); + } + + const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); + mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true }); + const metadata: PaseoWorktreeMetadata = { version: 1, baseRefName }; + writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); +} + +export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null { + const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); + if (!existsSync(metadataPath)) { + return null; + } + const parsed = JSON.parse(readFileSync(metadataPath, "utf8")); + return PaseoWorktreeMetadataSchema.parse(parsed); +} + +export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string { + const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); + const metadata = readPaseoWorktreeMetadata(worktreeRoot); + if (!metadata) { + throw new Error(`Missing Paseo worktree base metadata: ${metadataPath}`); + } + return metadata.baseRefName; +} diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index d5aeec948..dd9f8a520 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -3,9 +3,11 @@ import { createWorktree, deletePaseoWorktree, ensurePaseoIgnored, + isPaseoOwnedWorktreeCwd, listPaseoWorktrees, slugify, } from "./worktree"; +import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import { execSync } from "child_process"; import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; import { join } from "path"; @@ -38,6 +40,7 @@ describe("createWorktree", () => { const result = await createWorktree({ branchName: "main", cwd: repoDir, + baseBranch: "main", worktreeSlug: "hello-world", }); @@ -46,6 +49,41 @@ describe("createWorktree", () => { ); expect(existsSync(result.worktreePath)).toBe(true); expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); + const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + expect(existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse( + readFileSync(metadataPath, "utf8") + ); + expect(metadata).toMatchObject({ version: 1, baseRefName: "main" }); + }); + + it("detects paseo-owned worktrees across realpath differences (macOS /var vs /private/var)", async () => { + // Intentionally create repo using the non-realpath tmpdir() variant (often /var/... on macOS). + const varTempDir = mkdtempSync(join(tmpdir(), "worktree-realpath-test-")); + const privateTempDir = realpathSync(varTempDir); + const varRepoDir = join(varTempDir, "test-repo"); + execSync(`mkdir -p ${varRepoDir}`); + execSync("git init -b main", { cwd: varRepoDir }); + execSync("git config user.email 'test@test.com'", { cwd: varRepoDir }); + execSync("git config user.name 'Test'", { cwd: varRepoDir }); + execSync("echo 'hello' > file.txt", { cwd: varRepoDir }); + execSync("git add .", { cwd: varRepoDir }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: varRepoDir }); + + const result = await createWorktree({ + branchName: "main", + cwd: varRepoDir, + baseBranch: "main", + worktreeSlug: "realpath-test", + }); + + const privateWorktreePath = join(privateTempDir, "test-repo", ".paseo", "worktrees", "realpath-test"); + expect(existsSync(privateWorktreePath)).toBe(true); + + const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath); + expect(ownership.allowed).toBe(true); + + rmSync(varTempDir, { recursive: true, force: true }); }); it("creates a worktree with a new branch", async () => { @@ -64,6 +102,11 @@ describe("createWorktree", () => { // Verify branch was created const branches = execSync("git branch", { cwd: repoDir }).toString(); expect(branches).toContain("feature-branch"); + const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadata = JSON.parse( + readFileSync(metadataPath, "utf8") + ); + expect(metadata).toMatchObject({ version: 1, baseRefName: "main" }); }); it("fails with invalid branch name", async () => { @@ -71,6 +114,7 @@ describe("createWorktree", () => { createWorktree({ branchName: "INVALID_UPPERCASE", cwd: repoDir, + baseBranch: "main", worktreeSlug: "test", }) ).rejects.toThrow("Invalid branch name"); @@ -83,6 +127,7 @@ describe("createWorktree", () => { const result = await createWorktree({ branchName: "main", cwd: repoDir, + baseBranch: "main", worktreeSlug: "hello", }); @@ -104,6 +149,7 @@ describe("createWorktree", () => { const result = await createWorktree({ branchName: "main", cwd: repoDir, + baseBranch: "main", worktreeSlug: "hello", }); @@ -130,6 +176,7 @@ describe("createWorktree", () => { const result = await createWorktree({ branchName: "main", cwd: repoDir, + baseBranch: "main", worktreeSlug: "setup-test", }); @@ -157,6 +204,7 @@ describe("createWorktree", () => { const result = await createWorktree({ branchName: "main", cwd: repoDir, + baseBranch: "main", worktreeSlug: "no-setup-test", runSetup: false, }); @@ -186,6 +234,7 @@ describe("createWorktree", () => { createWorktree({ branchName: "main", cwd: repoDir, + baseBranch: "main", worktreeSlug: "fail-test", }) ).rejects.toThrow("Worktree setup command failed"); @@ -220,11 +269,13 @@ describe("paseo worktree manager", () => { const first = await createWorktree({ branchName: "main", cwd: repoDir, + baseBranch: "main", worktreeSlug: "alpha", }); const second = await createWorktree({ branchName: "main", cwd: repoDir, + baseBranch: "main", worktreeSlug: "beta", }); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 4092cb3a8..b2fd07b19 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -3,6 +3,7 @@ import { promisify } from "util"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; import { createNameId } from "mnemonic-id"; +import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js"; interface PaseoConfig { worktree?: { @@ -63,7 +64,7 @@ export type PaseoWorktreeOwnership = { interface CreateWorktreeOptions { branchName: string; cwd: string; - baseBranch?: string; + baseBranch: string; worktreeSlug?: string; runSetup?: boolean; } @@ -494,6 +495,19 @@ export async function createWorktree({ // Ensure .paseo exists and is ignored ensurePaseoIgnoredForRepo(repoInfo); + const normalizedBaseBranch = baseBranch ? normalizeBaseRefName(baseBranch) : ""; + if (!normalizedBaseBranch) { + throw new Error("Base branch is required when creating a Paseo worktree"); + } + if (normalizedBaseBranch === "HEAD") { + throw new Error("Base branch cannot be HEAD when creating a Paseo worktree"); + } + try { + await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd: repoInfo.path }); + } catch { + throw new Error(`Base branch not found: ${normalizedBaseBranch}`); + } + // Determine worktree directory based on repo type let worktreePath: string; const desiredSlug = worktreeSlug || generateWorktreeSlug(); @@ -516,7 +530,7 @@ export async function createWorktree({ // Always create a new branch for the worktree // If branchName already exists, use it as base and create worktree-slug as branch name // If branchName doesn't exist, create it from baseBranch - const base = branchExists ? branchName : (baseBranch ?? "HEAD"); + const base = branchExists ? branchName : normalizedBaseBranch; const candidateBranch = branchExists ? desiredSlug : branchName; // Find unique branch name if collision @@ -548,6 +562,8 @@ export async function createWorktree({ await execAsync(command, { cwd: repoInfo.path }); worktreePath = finalWorktreePath; + writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch }); + if (runSetup) { await runWorktreeSetupCommands({ repoRoot: repoInfo.path,