From 8fe30eae75aef069acde60b49cfb7713e96b8411 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 24 Jan 2026 21:24:48 +0700 Subject: [PATCH] feat(checkout): add merge-from-base and push operations for worktree branches --- packages/app/e2e/checkout-ship.spec.ts | 14 +- packages/app/src/components/agent-list.tsx | 4 - packages/app/src/components/git-diff-pane.tsx | 76 ++++++ .../src/client/daemon-client-v2.test.ts | 1 + .../server/src/client/daemon-client-v2.ts | 70 +++++- .../server/src/server/agent/agent-manager.ts | 4 - .../agent-self-identification.e2e.test.ts | 6 + .../server/src/server/agent/mcp-server.ts | 4 +- packages/server/src/server/bootstrap.ts | 1 + .../daemon-e2e/checkout-ship.e2e.test.ts | 104 ++++++++- .../daemon-e2e/git-operations.e2e.test.ts | 12 +- packages/server/src/server/session.ts | 196 +++++++++++++--- .../src/server/test-utils/paseo-daemon.ts | 10 +- .../src/server/websocket-session-bridge.ts | 3 + packages/server/src/shared/messages.ts | 45 ++++ .../server/src/utils/checkout-git.test.ts | 141 ++++++++++- packages/server/src/utils/checkout-git.ts | 220 +++++++++++++++++- packages/server/src/utils/worktree.test.ts | 37 ++- packages/server/src/utils/worktree.ts | 120 ++++++++-- 19 files changed, 947 insertions(+), 121 deletions(-) diff --git a/packages/app/e2e/checkout-ship.spec.ts b/packages/app/e2e/checkout-ship.spec.ts index dc9539a75..d09638133 100644 --- a/packages/app/e2e/checkout-ship.spec.ts +++ b/packages/app/e2e/checkout-ship.spec.ts @@ -232,14 +232,12 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { ]); const normalizedRepo = normalizeTmpPath(resolvedRepo); const normalizedCwd = normalizeTmpPath(resolvedCwd); - const expectedRoot = path.join(normalizedRepo, '.paseo', 'worktrees'); - const expectedRootRaw = normalizeTmpPath( - path.join(repo.path, '.paseo', 'worktrees') - ); - expect( - normalizedCwd.startsWith(expectedRoot) || - normalizedCwd.startsWith(expectedRootRaw) - ).toBeTruthy(); + const expectedMarker = `${path.sep}.paseo${path.sep}worktrees${path.sep}`; + expect(normalizedCwd.includes(expectedMarker)).toBeTruthy(); + if (repo.name) { + const expectedProjectRoot = path.join('.paseo', 'worktrees', repo.name); + expect(normalizedCwd.includes(expectedProjectRoot)).toBeTruthy(); + } await page.getByTestId('sidebar-new-agent').click(); await expect(page).toHaveURL(/\/$/); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index ecfe50b45..ca71842cb 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -133,10 +133,6 @@ export function AgentList({ const idx = basePath.indexOf(worktreeMarker); if (idx !== -1) { const afterMarker = basePath.slice(idx + worktreeMarker.length); - const slashIdx = afterMarker.indexOf("/"); - if (slashIdx !== -1) { - return afterMarker.slice(slashIdx + 1); - } return afterMarker; } return basePath; diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index d62f42bb4..3facfd176 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -585,6 +585,56 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { }, }); + const mergeFromBaseMutation = useMutation({ + mutationFn: async () => { + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.checkoutMergeFromBase(agentId, { + baseRef, + requireCleanTarget: true, + }); + if (payload.error) { + throw new Error(payload.error.message); + } + return payload; + }, + onSuccess: () => { + setActionError(null); + setActionStatus("Merged from base."); + void refreshDiff(); + void refreshStatus(); + }, + onError: (err) => { + const message = err instanceof Error ? err.message : "Failed to merge from base"; + setActionStatus(null); + setActionError(message); + }, + }); + + const pushMutation = useMutation({ + mutationFn: async () => { + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.checkoutPush(agentId); + if (payload.error) { + throw new Error(payload.error.message); + } + return payload; + }, + onSuccess: () => { + setActionError(null); + setActionStatus("Pushed branch."); + void refreshStatus(); + }, + onError: (err) => { + const message = err instanceof Error ? err.message : "Failed to push"; + setActionStatus(null); + setActionError(message); + }, + }); + const archiveMutation = useMutation({ mutationFn: async () => { if (!client) { @@ -658,6 +708,10 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { const commitDisabled = actionsDisabled || commitMutation.isPending; const prDisabled = actionsDisabled || prMutation.isPending; const mergeDisabled = actionsDisabled || mergeMutation.isPending || hasUncommittedChanges; + const mergeFromBaseDisabled = + actionsDisabled || mergeFromBaseMutation.isPending || hasUncommittedChanges; + const pushDisabled = + actionsDisabled || pushMutation.isPending || !(gitStatus?.hasRemote ?? false); const archiveDisabled = actionsDisabled || archiveMutation.isPending || @@ -936,6 +990,28 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { Refresh + mergeFromBaseMutation.mutate()} + > + Merge from {baseRefLabel} + + {mergeFromBaseDisabled && hasUncommittedChanges ? ( + Requires a clean working tree. + ) : null} + + pushMutation.mutate()} + > + Push branch + + {pushDisabled && !(gitStatus?.hasRemote ?? false) ? ( + Remote 'origin' is not configured. + ) : null} + { isDirty: null, baseRef: null, aheadBehind: null, + hasRemote: false, }, }, }; diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index b0f933965..6e82ca6d4 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -22,12 +22,14 @@ import type { GitRepoInfoResponse, HighlightedDiffResponse, CheckoutStatusResponse, - CheckoutDiffResponse, - CheckoutCommitResponse, - CheckoutMergeResponse, - CheckoutPrCreateResponse, - CheckoutPrStatusResponse, - PaseoWorktreeListResponse, + CheckoutDiffResponse, + CheckoutCommitResponse, + CheckoutMergeResponse, + CheckoutMergeFromBaseResponse, + CheckoutPushResponse, + CheckoutPrCreateResponse, + CheckoutPrStatusResponse, + PaseoWorktreeListResponse, PaseoWorktreeArchiveResponse, ListCommandsResponse, ExecuteCommandResponse, @@ -172,6 +174,8 @@ type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; type CheckoutDiffPayload = CheckoutDiffResponse["payload"]; type CheckoutCommitPayload = CheckoutCommitResponse["payload"]; type CheckoutMergePayload = CheckoutMergeResponse["payload"]; +type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; +type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"]; @@ -1173,6 +1177,60 @@ export class DaemonClientV2 { return response; } + async checkoutMergeFromBase( + agentId: string, + input: { baseRef?: string; requireCleanTarget?: boolean }, + requestId?: string + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "checkout_merge_from_base_request", + agentId, + baseRef: input.baseRef, + requireCleanTarget: input.requireCleanTarget, + requestId: resolvedRequestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "checkout_merge_from_base_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + 60000, + { skipQueue: true } + ); + this.sendSessionMessage(message); + return response; + } + + async checkoutPush(agentId: string, requestId?: string): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "checkout_push_request", + agentId, + requestId: resolvedRequestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "checkout_push_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + 60000, + { skipQueue: true } + ); + this.sendSessionMessage(message); + return response; + } + async checkoutPrCreate( agentId: string, input: { title?: string; body?: string; baseRef?: string }, diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index e76d6f7b2..d63612066 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1048,10 +1048,6 @@ export class AgentManager { } private dispatchStream(agentId: string, event: AgentStreamEvent): void { - const eventDesc = event.type === "timeline" - ? `timeline:${event.item.type}${event.item.type === "tool_call" ? `:${(event.item as any).name}` : ""}` - : event.type; - console.error(`[AGENT-MANAGER:dispatchStream] agentId=${agentId.substring(0, 8)} event=${eventDesc} t=${Date.now()}`); this.dispatch({ type: "agent_stream", agentId, event }); } 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 1fe524437..57c1e87ba 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 @@ -122,6 +122,7 @@ describe("self-identification MCP tools", () => { test("set_branch renames branch for Paseo worktree", async () => { const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); + const paseoHome = path.join(repoDir, "paseo-home"); try { initGitRepo(repoDir); @@ -130,6 +131,7 @@ describe("self-identification MCP tools", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "self-ident", + paseoHome, }); const storagePath = path.join(repoDir, "agents"); @@ -149,6 +151,7 @@ describe("self-identification MCP tools", () => { const server = await createAgentMcpServer({ agentManager: manager, agentStorage: storage, + paseoHome, callerAgentId: agent.id, logger, }); @@ -171,6 +174,7 @@ describe("self-identification MCP tools", () => { test("set_branch allows agents running in a subdirectory of a Paseo worktree", async () => { const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); + const paseoHome = path.join(repoDir, "paseo-home"); try { initGitRepo(repoDir); @@ -179,6 +183,7 @@ describe("self-identification MCP tools", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "self-ident-subdir", + paseoHome, }); const nestedDir = path.join(worktree.worktreePath, "nested"); execSync(`mkdir -p "${nestedDir}"`, { stdio: "ignore" }); @@ -200,6 +205,7 @@ describe("self-identification MCP tools", () => { const server = await createAgentMcpServer({ agentManager: manager, agentStorage: storage, + paseoHome, callerAgentId: agent.id, logger, }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 5a7aac81b..847fca848 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -37,6 +37,7 @@ import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; agentStorage: AgentStorage; + paseoHome?: string; /** * ID of the agent that is connecting to this MCP server. * When set, create_agent will auto-inject this as parentAgentId. @@ -450,6 +451,7 @@ export async function createAgentMcpServer( cwd: resolvedCwd, baseBranch, worktreeSlug: worktreeName, + paseoHome: options.paseoHome, }); resolvedCwd = worktree.worktreePath; } @@ -1006,7 +1008,7 @@ export async function createAgentMcpServer( let ownership; try { - ownership = await isPaseoOwnedWorktreeCwd(agent.cwd); + ownership = await isPaseoOwnedWorktreeCwd(agent.cwd, { paseoHome: options.paseoHome }); } catch (error) { const notGitError = error instanceof NotGitRepoError diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index d5d3fa643..61b878758 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -230,6 +230,7 @@ export async function createPaseoDaemon( const agentMcpServer = await createAgentMcpServer({ agentManager, agentStorage, + paseoHome: config.paseoHome, callerAgentId, logger, }); 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 3c705659e..355997193 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 @@ -148,9 +148,9 @@ describe("daemon checkout ship loop", () => { await ctx.cleanup(); }, 60000); - testWithGitHubCliAuth( - "runs the full checkout ship loop via checkout RPCs", - async () => { + testWithGitHubCliAuth( + "runs the full checkout ship loop via checkout RPCs", + async () => { const repoDir = tmpCwd("checkout-ship-"); let repoFullName: string | null = null; let mcpClient: McpClient | null = null; @@ -179,6 +179,7 @@ describe("daemon checkout ship loop", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "ship-loop", + paseoHome: ctx.daemon.paseoHome, }); const agent = await ctx.client.createAgent({ @@ -318,13 +319,98 @@ describe("daemon checkout ship loop", () => { rmSync(repoDir, { recursive: true, force: true }); } }, - 180000 - ); + 180000 + ); - test( - "checkout RPCs return NOT_GIT_REPO for non-git directories", - async () => { - const cwd = tmpCwd("checkout-ship-non-git-"); + test( + "merge-from-base and push RPCs work with a local origin remote", + async () => { + const repoDir = tmpCwd("checkout-merge-from-base-"); + let agentId: string | null = null; + + try { + initGitRepo(repoDir); + + const remoteDir = path.join(repoDir, "remote.git"); + execSync(`git init --bare ${remoteDir}`, { stdio: "pipe" }); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir, stdio: "pipe" }); + execSync("git push -u origin main", { cwd: repoDir, stdio: "pipe" }); + + const worktree = await createWorktree({ + branchName: "merge-from-base", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "merge-from-base", + paseoHome: ctx.daemon.paseoHome, + }); + + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + reasoningEffort: CODEX_TEST_REASONING_EFFORT, + cwd: worktree.worktreePath, + title: "Merge From Base Test", + }); + agentId = agent.id; + + const status = await ctx.client.getCheckoutStatus(agent.id); + expect(status.isGit).toBe(true); + if (status.isGit) { + expect(status.hasRemote).toBe(true); + expect(status.baseRef).toBe("main"); + } + + // Advance local main, but leave the agent branch behind it. + execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "base.txt"), "base update\n"); + execSync("git add base.txt", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'base update'", { + cwd: repoDir, + stdio: "pipe", + }); + const baseCommit = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }) + .toString() + .trim(); + + // Add a commit on the agent branch. + writeFileSync(path.join(worktree.worktreePath, "feature.txt"), "feature\n"); + const commitResult = await ctx.client.checkoutCommit(agent.id, { + message: "feature commit", + addAll: true, + }); + expect(commitResult.error).toBeNull(); + expect(commitResult.success).toBe(true); + + const mergeFromBase = await ctx.client.checkoutMergeFromBase(agent.id, { + baseRef: "main", + requireCleanTarget: true, + }); + expect(mergeFromBase.error).toBeNull(); + expect(mergeFromBase.success).toBe(true); + + // Verify the agent branch now contains the base commit. + execSync(`git merge-base --is-ancestor ${baseCommit} HEAD`, { + cwd: worktree.worktreePath, + stdio: "pipe", + }); + + const pushResult = await ctx.client.checkoutPush(agent.id); + expect(pushResult.error).toBeNull(); + expect(pushResult.success).toBe(true); + } finally { + if (agentId) { + await ctx.client.deleteAgent(agentId).catch(() => undefined); + } + rmSync(repoDir, { recursive: true, force: true }); + } + }, + 90000 + ); + + test( + "checkout RPCs return NOT_GIT_REPO for non-git directories", + async () => { + const cwd = tmpCwd("checkout-ship-non-git-"); let agentId: string | null = null; try { 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 72ee40998..181823030 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 @@ -6,6 +6,7 @@ import { createDaemonTestContext, type DaemonTestContext, } from "../test-utils/index.js"; +import { slugify } from "../../utils/worktree.js"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js"; @@ -543,7 +544,7 @@ describe("daemon E2E", () => { describe("createAgent with worktree", () => { test( - "creates agent in .paseo/worktrees when worktree is requested", + "creates agent in ~/.paseo/worktrees/{project} when worktree is requested", async () => { const cwd = tmpCwd(); @@ -578,7 +579,14 @@ describe("daemon E2E", () => { expect(agent.id).toBeTruthy(); expect(agent.status).toBe("idle"); expect(realpathSync(agent.cwd)).toBe( - realpathSync(path.join(cwd, ".paseo", "worktrees", "worktree-test")) + realpathSync( + path.join( + ctx.daemon.paseoHome, + "worktrees", + slugify(path.basename(cwd)), + "worktree-test" + ) + ) ); expect(existsSync(agent.cwd)).toBe(true); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c1e7ab2e5..40ca3971e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -89,8 +89,11 @@ import { getCheckoutStatus, NotGitRepoError, MergeConflictError, + MergeFromBaseConflictError, commitChanges, mergeToBase, + mergeFromBase, + pushCurrentBranch, createPullRequest, getPullRequestStatus, } from "../utils/checkout-git.js"; @@ -299,6 +302,7 @@ export class Session { private readonly onMessage: (msg: SessionOutboundMessage) => void; private readonly sessionLogger: pino.Logger; private readonly voiceConversationStore: VoiceConversationStore; + private readonly paseoHome: string; // State machine private abortController: AbortController; @@ -372,6 +376,7 @@ export class Session { logger: pino.Logger, downloadTokenStore: DownloadTokenStore, pushTokenStore: PushTokenStore, + paseoHome: string, agentManager: AgentManager, agentStorage: AgentStorage, agentMcpConfig: AgentMcpClientConfig, @@ -385,6 +390,7 @@ export class Session { this.onMessage = onMessage; this.downloadTokenStore = downloadTokenStore; this.pushTokenStore = pushTokenStore; + this.paseoHome = paseoHome; this.agentManager = agentManager; this.agentStorage = agentStorage; this.agentMcpConfig = agentMcpConfig; @@ -606,11 +612,6 @@ export class Session { timestamp: new Date().toISOString(), } as const; - const itemType = event.event.type === "timeline" ? event.event.item.type : undefined; - this.sessionLogger.debug( - { timestamp: Date.now(), agentId: event.agentId, eventType: event.event.type, itemType }, - "Agent stream event" - ); this.emit({ type: "agent_stream", payload, @@ -886,13 +887,21 @@ export class Session { await this.handleCheckoutCommitRequest(msg); break; - case "checkout_merge_request": - await this.handleCheckoutMergeRequest(msg); - break; + case "checkout_merge_request": + await this.handleCheckoutMergeRequest(msg); + break; - case "checkout_pr_create_request": - await this.handleCheckoutPrCreateRequest(msg); - break; + case "checkout_merge_from_base_request": + await this.handleCheckoutMergeFromBaseRequest(msg); + break; + + case "checkout_push_request": + await this.handleCheckoutPushRequest(msg); + break; + + case "checkout_pr_create_request": + await this.handleCheckoutPrCreateRequest(msg); + break; case "checkout_pr_status_request": await this.handleCheckoutPrStatusRequest(msg); @@ -1950,6 +1959,7 @@ export class Session { baseBranch: normalized.baseBranch!, worktreeSlug: normalized.worktreeSlug ?? targetBranch, runSetup: false, + paseoHome: this.paseoHome, }); cwd = createdWorktree.worktreePath; worktreeConfig = createdWorktree; @@ -2063,7 +2073,7 @@ export class Session { const resolvedCwd = expandTilde(cwd); try { - const status = await getCheckoutStatus(resolvedCwd); + const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome }); if (!status.isGit) { throw new NotGitRepoError(resolvedCwd); } @@ -2226,6 +2236,9 @@ export class Session { if (error instanceof MergeConflictError) { return { code: "MERGE_CONFLICT", message: error.message }; } + if (error instanceof MergeFromBaseConflictError) { + return { code: "MERGE_CONFLICT", message: error.message }; + } if (error instanceof Error) { return { code: "UNKNOWN", message: error.message }; } @@ -2252,7 +2265,7 @@ export class Session { } private async generateCommitMessage(agent: ManagedAgent): Promise { - const diff = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }); + const diff = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome }); const schema = z.object({ message: z .string() @@ -2288,10 +2301,14 @@ export class Session { title: string; body: string; }> { - const diff = await getCheckoutDiff(agent.cwd, { - mode: "base", - baseRef, - }); + const diff = await getCheckoutDiff( + agent.cwd, + { + mode: "base", + baseRef, + }, + { paseoHome: this.paseoHome } + ); const schema = z.object({ title: z.string().min(1).max(72), body: z.string().min(1), @@ -2696,7 +2713,7 @@ export class Session { return; } - const diffResult = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }); + const diffResult = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome }); const combinedDiff = diffResult.diff; this.emit({ @@ -2747,6 +2764,7 @@ export class Session { isDirty: null, baseRef: null, aheadBehind: null, + hasRemote: false, isPaseoOwnedWorktree: false, error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, requestId, @@ -2756,7 +2774,7 @@ export class Session { } try { - const status = await getCheckoutStatus(agent.cwd); + const status = await getCheckoutStatus(agent.cwd, { paseoHome: this.paseoHome }); if (!status.isGit) { this.emit({ type: "checkout_status_response", @@ -2769,6 +2787,7 @@ export class Session { isDirty: null, baseRef: null, aheadBehind: null, + hasRemote: false, isPaseoOwnedWorktree: false, error: null, requestId, @@ -2789,6 +2808,7 @@ export class Session { isDirty: status.isDirty ?? null, baseRef: status.baseRef, aheadBehind: status.aheadBehind ?? null, + hasRemote: status.hasRemote, isPaseoOwnedWorktree: true, error: null, requestId, @@ -2808,6 +2828,7 @@ export class Session { isDirty: status.isDirty ?? null, baseRef: status.baseRef ?? null, aheadBehind: status.aheadBehind ?? null, + hasRemote: status.hasRemote, isPaseoOwnedWorktree: false, error: null, requestId, @@ -2825,6 +2846,7 @@ export class Session { isDirty: null, baseRef: null, aheadBehind: null, + hasRemote: false, isPaseoOwnedWorktree: false, error: this.toCheckoutError(error), requestId, @@ -2852,11 +2874,15 @@ export class Session { } try { - const diffResult = await getCheckoutDiff(agent.cwd, { - mode: compare.mode, - baseRef: compare.baseRef, - includeStructured: true, - }); + const diffResult = await getCheckoutDiff( + agent.cwd, + { + mode: compare.mode, + baseRef: compare.baseRef, + includeStructured: true, + }, + { paseoHome: this.paseoHome } + ); this.emit({ type: "checkout_diff_response", payload: { @@ -2952,7 +2978,7 @@ export class Session { } try { - const status = await getCheckoutStatus(agent.cwd); + const status = await getCheckoutStatus(agent.cwd, { paseoHome: this.paseoHome }); if (!status.isGit) { // `getCheckoutStatus` can return `isGit=false` in transient situations (e.g. cwd lookup // running during other git operations). Double-check with git directly before failing. @@ -2990,10 +3016,14 @@ export class Session { baseRef = baseRef.slice("origin/".length); } - await mergeToBase(agent.cwd, { - baseRef, - mode: msg.strategy === "squash" ? "squash" : "merge", - }); + await mergeToBase( + agent.cwd, + { + baseRef, + mode: msg.strategy === "squash" ? "squash" : "merge", + }, + { paseoHome: this.paseoHome } + ); this.emit({ type: "checkout_merge_response", @@ -3017,6 +3047,107 @@ export class Session { } } + private async handleCheckoutMergeFromBaseRequest( + msg: Extract + ): Promise { + const { agentId, requestId } = msg; + const agent = this.agentManager.getAgent(agentId); + if (!agent) { + this.emit({ + type: "checkout_merge_from_base_response", + payload: { + agentId, + success: false, + error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, + requestId, + }, + }); + return; + } + + try { + if (msg.requireCleanTarget ?? true) { + 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."); + } + } + + await mergeFromBase( + agent.cwd, + { + baseRef: msg.baseRef, + requireCleanTarget: msg.requireCleanTarget ?? true, + }, + ); + + this.emit({ + type: "checkout_merge_from_base_response", + payload: { + agentId, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_merge_from_base_response", + payload: { + agentId, + success: false, + error: this.toCheckoutError(error), + requestId, + }, + }); + } + } + + private async handleCheckoutPushRequest( + msg: Extract + ): Promise { + const { agentId, requestId } = msg; + const agent = this.agentManager.getAgent(agentId); + if (!agent) { + this.emit({ + type: "checkout_push_response", + payload: { + agentId, + success: false, + error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, + requestId, + }, + }); + return; + } + + try { + await pushCurrentBranch(agent.cwd); + this.emit({ + type: "checkout_push_response", + payload: { + agentId, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_push_response", + payload: { + agentId, + success: false, + error: this.toCheckoutError(error), + requestId, + }, + }); + } + } + private async handleCheckoutPrCreateRequest( msg: Extract ): Promise { @@ -3142,7 +3273,7 @@ export class Session { } try { - const worktrees = await listPaseoWorktrees({ cwd }); + const worktrees = await listPaseoWorktrees({ cwd, paseoHome: this.paseoHome }); this.emit({ type: "paseo_worktree_list_response", payload: { @@ -3179,7 +3310,7 @@ export class Session { if (!repoRoot || !msg.branchName) { throw new Error("worktreePath or repoRoot+branchName is required"); } - const worktrees = await listPaseoWorktrees({ cwd: repoRoot }); + const worktrees = await listPaseoWorktrees({ cwd: repoRoot, paseoHome: this.paseoHome }); const match = worktrees.find((entry) => entry.branchName === msg.branchName); if (!match) { throw new Error(`Paseo worktree not found for branch ${msg.branchName}`); @@ -3187,7 +3318,7 @@ export class Session { targetPath = match.path; } - const ownership = await isPaseoOwnedWorktreeCwd(targetPath); + const ownership = await isPaseoOwnedWorktreeCwd(targetPath, { paseoHome: this.paseoHome }); if (!ownership.allowed) { this.emit({ type: "paseo_worktree_archive_response", @@ -3242,6 +3373,7 @@ export class Session { await deletePaseoWorktree({ cwd: repoRoot, worktreePath: targetPath, + paseoHome: this.paseoHome, }); for (const agentId of removedAgents) { diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index c6ffd937f..b144090b5 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -1,7 +1,7 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import pino from "pino"; import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js"; @@ -47,7 +47,9 @@ export async function createTestPaseoDaemon( let lastError: unknown; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); + const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + await mkdir(paseoHome, { recursive: true }); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); const port = await getAvailablePort(); @@ -80,7 +82,7 @@ export async function createTestPaseoDaemon( const close = async (): Promise => { await daemon.stop().catch(() => undefined); await new Promise((r) => setTimeout(r, 200)); - await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + await rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); }; @@ -95,7 +97,7 @@ export async function createTestPaseoDaemon( } catch (error) { lastError = error; await daemon.stop().catch(() => undefined); - await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + await rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); if (!isAddressInUseError(error) || attempt === maxAttempts - 1) { diff --git a/packages/server/src/server/websocket-session-bridge.ts b/packages/server/src/server/websocket-session-bridge.ts index a50e56491..60c479aa4 100644 --- a/packages/server/src/server/websocket-session-bridge.ts +++ b/packages/server/src/server/websocket-session-bridge.ts @@ -31,6 +31,7 @@ export class WebSocketSessionBridge { private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; private readonly downloadTokenStore: DownloadTokenStore; + private readonly paseoHome: string; private readonly pushTokenStore: PushTokenStore; private readonly pushService: PushService; private readonly agentMcpConfig: AgentMcpClientConfig; @@ -53,6 +54,7 @@ export class WebSocketSessionBridge { this.agentManager = agentManager; this.agentStorage = agentStorage; this.downloadTokenStore = downloadTokenStore; + this.paseoHome = paseoHome; this.agentMcpConfig = agentMcpConfig; this.stt = speech?.stt ?? null; this.tts = speech?.tts ?? null; @@ -86,6 +88,7 @@ export class WebSocketSessionBridge { connectionLogger.child({ module: "session" }), this.downloadTokenStore, this.pushTokenStore, + this.paseoHome, this.agentManager, this.agentStorage, this.agentMcpConfig, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 1fc63feba..cba072668 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -504,6 +504,20 @@ export const CheckoutMergeRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutMergeFromBaseRequestSchema = z.object({ + type: z.literal("checkout_merge_from_base_request"), + agentId: z.string(), + baseRef: z.string().optional(), + requireCleanTarget: z.boolean().optional(), + requestId: z.string(), +}); + +export const CheckoutPushRequestSchema = z.object({ + type: z.literal("checkout_push_request"), + agentId: z.string(), + requestId: z.string(), +}); + export const CheckoutPrCreateRequestSchema = z.object({ type: z.literal("checkout_pr_create_request"), agentId: z.string(), @@ -723,6 +737,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutDiffRequestSchema, CheckoutCommitRequestSchema, CheckoutMergeRequestSchema, + CheckoutMergeFromBaseRequestSchema, + CheckoutPushRequestSchema, CheckoutPrCreateRequestSchema, CheckoutPrStatusRequestSchema, PaseoWorktreeListRequestSchema, @@ -1039,6 +1055,7 @@ const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({ isDirty: z.null(), baseRef: z.null(), aheadBehind: z.null(), + hasRemote: z.boolean(), }); const CheckoutStatusGitNonPaseoSchema = CheckoutStatusCommonSchema.extend({ @@ -1049,6 +1066,7 @@ const CheckoutStatusGitNonPaseoSchema = CheckoutStatusCommonSchema.extend({ isDirty: z.boolean(), baseRef: z.string().nullable(), aheadBehind: AheadBehindSchema.nullable(), + hasRemote: z.boolean(), }); const CheckoutStatusGitPaseoSchema = CheckoutStatusCommonSchema.extend({ @@ -1059,6 +1077,7 @@ const CheckoutStatusGitPaseoSchema = CheckoutStatusCommonSchema.extend({ isDirty: z.boolean(), baseRef: z.string(), aheadBehind: AheadBehindSchema.nullable(), + hasRemote: z.boolean(), }); export const CheckoutStatusResponseSchema = z.object({ @@ -1100,6 +1119,26 @@ export const CheckoutMergeResponseSchema = z.object({ }), }); +export const CheckoutMergeFromBaseResponseSchema = z.object({ + type: z.literal("checkout_merge_from_base_response"), + payload: z.object({ + agentId: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +export const CheckoutPushResponseSchema = z.object({ + type: z.literal("checkout_push_response"), + payload: z.object({ + agentId: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + export const CheckoutPrCreateResponseSchema = z.object({ type: z.literal("checkout_pr_create_response"), payload: z.object({ @@ -1354,6 +1393,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutDiffResponseSchema, CheckoutCommitResponseSchema, CheckoutMergeResponseSchema, + CheckoutMergeFromBaseResponseSchema, + CheckoutPushResponseSchema, CheckoutPrCreateResponseSchema, CheckoutPrStatusResponseSchema, PaseoWorktreeListResponseSchema, @@ -1437,6 +1478,10 @@ export type CheckoutCommitRequest = z.infer; export type CheckoutCommitResponse = z.infer; export type CheckoutMergeRequest = z.infer; export type CheckoutMergeResponse = z.infer; +export type CheckoutMergeFromBaseRequest = z.infer; +export type CheckoutMergeFromBaseResponse = z.infer; +export type CheckoutPushRequest = z.infer; +export type CheckoutPushResponse = z.infer; export type CheckoutPrCreateRequest = z.infer; export type CheckoutPrCreateResponse = z.infer; export type CheckoutPrStatusRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 37d3c27c9..e7aabde6c 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -8,8 +8,11 @@ import { getCheckoutDiff, getCheckoutStatus, mergeToBase, + mergeFromBase, MergeConflictError, + MergeFromBaseConflictError, NotGitRepoError, + pushCurrentBranch, } from "./checkout-git.js"; import { createWorktree } from "./worktree.js"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; @@ -30,11 +33,13 @@ function initRepo(): { tempDir: string; repoDir: string } { describe("checkout git utilities", () => { let tempDir: string; let repoDir: string; + let paseoHome: string; beforeEach(() => { const setup = initRepo(); tempDir = setup.tempDir; repoDir = setup.repoDir; + paseoHome = join(tempDir, "paseo-home"); }); afterEach(() => { @@ -57,6 +62,7 @@ describe("checkout git utilities", () => { expect(status.isGit).toBe(true); expect(status.currentBranch).toBe("main"); expect(status.isDirty).toBe(true); + expect(status.hasRemote).toBe(false); const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted" }); expect(diff.diff).toContain("-hello"); @@ -72,6 +78,18 @@ describe("checkout git utilities", () => { expect(message).toBe("update file"); }); + it("exposes hasRemote when origin is configured", async () => { + const remoteDir = join(tempDir, "remote.git"); + execSync(`git init --bare ${remoteDir}`); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + + const status = await getCheckoutStatus(repoDir); + expect(status.isGit).toBe(true); + if (status.isGit) { + expect(status.hasRemote).toBe(true); + } + }); + it("commits messages with quotes safely", async () => { const message = `He said "hello" and it's fine`; writeFileSync(join(repoDir, "file.txt"), "quoted\n"); @@ -90,22 +108,23 @@ describe("checkout git utilities", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "alpha", + paseoHome, }); writeFileSync(join(result.worktreePath, "file.txt"), "worktree change\n"); - const status = await getCheckoutStatus(result.worktreePath); + const status = await getCheckoutStatus(result.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); expect(status.repoRoot).toBe(repoDir); expect(status.isDirty).toBe(true); - const diff = await getCheckoutDiff(result.worktreePath, { mode: "uncommitted" }); + const diff = await getCheckoutDiff(result.worktreePath, { mode: "uncommitted" }, { paseoHome }); expect(diff.diff).toContain("-hello"); expect(diff.diff).toContain("+worktree change"); await commitAll(result.worktreePath, "worktree update"); - const cleanStatus = await getCheckoutStatus(result.worktreePath); + const cleanStatus = await getCheckoutStatus(result.worktreePath, { paseoHome }); expect(cleanStatus.isDirty).toBe(false); const message = execSync("git log -1 --pretty=%B", { cwd: result.worktreePath, @@ -121,6 +140,7 @@ describe("checkout git utilities", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "merge", + paseoHome, }); writeFileSync(join(worktree.worktreePath, "merge.txt"), "feature\n"); @@ -133,7 +153,7 @@ describe("checkout git utilities", () => { .toString() .trim(); - await mergeToBase(worktree.worktreePath, { baseRef: "main" }); + await mergeToBase(worktree.worktreePath, { baseRef: "main" }, { paseoHome }); const baseContainsFeature = execSync(`git merge-base --is-ancestor ${featureCommit} main`, { cwd: repoDir, @@ -141,7 +161,7 @@ describe("checkout git utilities", () => { }); expect(baseContainsFeature).toBeDefined(); - const statusAfterMerge = await getCheckoutStatus(worktree.worktreePath); + const statusAfterMerge = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(statusAfterMerge.isGit).toBe(true); if (statusAfterMerge.isGit) { expect(statusAfterMerge.aheadBehind?.ahead ?? 0).toBe(0); @@ -155,6 +175,102 @@ describe("checkout git utilities", () => { expect(currentBranch).toBe("feature"); }); + it("merges from the most-ahead base ref (origin/main when it is ahead)", async () => { + const remoteDir = join(tempDir, "remote.git"); + execSync(`git init --bare ${remoteDir}`); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + execSync("git push -u origin main", { cwd: repoDir }); + + // Advance origin/main without advancing local main. + const otherClone = join(tempDir, "other-clone"); + execSync(`git clone ${remoteDir} ${otherClone}`); + execSync("git config user.email 'test@test.com'", { cwd: otherClone }); + execSync("git config user.name 'Test'", { cwd: otherClone }); + writeFileSync(join(otherClone, "remote-only.txt"), "remote\n"); + execSync("git add remote-only.txt", { cwd: otherClone }); + execSync("git -c commit.gpgsign=false commit -m 'remote only'", { cwd: otherClone }); + const remoteOnlyCommit = execSync("git rev-parse HEAD", { cwd: otherClone }) + .toString() + .trim(); + execSync("git push", { cwd: otherClone }); + execSync("git fetch origin", { cwd: repoDir }); + + execSync("git checkout -b feature", { cwd: repoDir }); + writeFileSync(join(repoDir, "feature.txt"), "feature\n"); + execSync("git add feature.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir }); + + await mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true }); + + execSync(`git merge-base --is-ancestor ${remoteOnlyCommit} feature`, { cwd: repoDir }); + }); + + it("merges from the most-ahead base ref (local main when it is ahead)", async () => { + const remoteDir = join(tempDir, "remote.git"); + execSync(`git init --bare ${remoteDir}`); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + execSync("git push -u origin main", { cwd: repoDir }); + + // Advance local main without pushing. + writeFileSync(join(repoDir, "local-only.txt"), "local\n"); + execSync("git add local-only.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'local only'", { cwd: repoDir }); + const localOnlyCommit = execSync("git rev-parse HEAD", { cwd: repoDir }) + .toString() + .trim(); + + execSync(`git checkout -b feature ${localOnlyCommit}~1`, { cwd: repoDir }); + writeFileSync(join(repoDir, "feature.txt"), "feature\n"); + execSync("git add feature.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir }); + + await mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true }); + + execSync(`git merge-base --is-ancestor ${localOnlyCommit} feature`, { cwd: repoDir }); + }); + + it("aborts merge-from-base on conflicts and leaves no merge in progress", async () => { + writeFileSync(join(repoDir, "conflict.txt"), "base\n"); + execSync("git add conflict.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'add conflict file'", { cwd: repoDir }); + + execSync("git checkout -b feature", { cwd: repoDir }); + writeFileSync(join(repoDir, "conflict.txt"), "feature\n"); + execSync("git add conflict.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'feature change'", { cwd: repoDir }); + + execSync("git checkout main", { cwd: repoDir }); + writeFileSync(join(repoDir, "conflict.txt"), "main change\n"); + execSync("git add conflict.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'main change'", { cwd: repoDir }); + + execSync("git checkout feature", { cwd: repoDir }); + + await expect( + mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true }) + ).rejects.toBeInstanceOf(MergeFromBaseConflictError); + + const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim(); + expect(porcelain).toBe(""); + expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow(); + }); + + it("pushes the current branch to origin", async () => { + const remoteDir = join(tempDir, "remote.git"); + execSync(`git init --bare ${remoteDir}`); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + execSync("git push -u origin main", { cwd: repoDir }); + + execSync("git checkout -b feature", { cwd: repoDir }); + writeFileSync(join(repoDir, "push.txt"), "push\n"); + execSync("git add push.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'push commit'", { cwd: repoDir }); + + await pushCurrentBranch(repoDir); + + execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`); + }); + it("returns typed MergeConflictError on merge conflicts", async () => { const conflictFile = join(repoDir, "conflict.txt"); writeFileSync(conflictFile, "base\n"); @@ -198,6 +314,7 @@ describe("checkout git utilities", () => { cwd: repoDir, baseBranch: "develop", worktreeSlug: "feature", + paseoHome, }); writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); @@ -206,12 +323,12 @@ describe("checkout git utilities", () => { cwd: worktree.worktreePath, }); - const status = await getCheckoutStatus(worktree.worktreePath); + const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); expect(status.baseRef).toBe("develop"); expect(status.aheadBehind?.ahead).toBe(1); - const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }); + const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome }); expect(baseDiff.diff).toContain("feature.txt"); expect(baseDiff.diff).not.toContain("file.txt"); }); @@ -230,6 +347,7 @@ describe("checkout git utilities", () => { cwd: repoDir, baseBranch: "develop", worktreeSlug: "merge-to-develop", + paseoHome, }); writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); @@ -242,7 +360,7 @@ describe("checkout git utilities", () => { .trim(); // No baseRef passed: should merge into the configured base (develop), not default/main. - await mergeToBase(worktree.worktreePath); + await mergeToBase(worktree.worktreePath, {}, { paseoHome }); execSync(`git merge-base --is-ancestor ${featureCommit} develop`, { cwd: repoDir, @@ -262,17 +380,18 @@ describe("checkout git utilities", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "missing-metadata", + paseoHome, }); const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); - await expect(getCheckoutStatus(worktree.worktreePath)).rejects.toThrow( + await expect(getCheckoutStatus(worktree.worktreePath, { paseoHome })).rejects.toThrow( /base/i ); - await expect(getCheckoutDiff(worktree.worktreePath, { mode: "base" })).rejects.toThrow( + await expect(getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome })).rejects.toThrow( /base/i ); - await expect(mergeToBase(worktree.worktreePath)).rejects.toThrow(/base/i); + await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).rejects.toThrow(/base/i); }); }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index dfe362cd5..cd9984bac 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -38,6 +38,22 @@ export class MergeConflictError extends Error { } } +export class MergeFromBaseConflictError extends Error { + readonly baseRef: string; + readonly currentBranch: string; + readonly conflictFiles: string[]; + + constructor(options: { baseRef: string; currentBranch: string; conflictFiles: string[] }) { + super( + `Merge conflict while merging ${options.baseRef} into ${options.currentBranch}. Please merge manually.` + ); + this.name = "MergeFromBaseConflictError"; + this.baseRef = options.baseRef; + this.currentBranch = options.currentBranch; + this.conflictFiles = options.conflictFiles; + } +} + export interface AheadBehind { ahead: number; behind: number; @@ -54,6 +70,7 @@ export type CheckoutStatusGitNonPaseo = { isDirty: boolean; baseRef: string | null; aheadBehind: AheadBehind | null; + hasRemote: boolean; isPaseoOwnedWorktree: false; }; @@ -64,6 +81,7 @@ export type CheckoutStatusGitPaseo = { isDirty: boolean; baseRef: string; aheadBehind: AheadBehind | null; + hasRemote: boolean; isPaseoOwnedWorktree: true; }; @@ -88,6 +106,15 @@ export interface MergeToBaseOptions { commitMessage?: string; } +export interface MergeFromBaseOptions { + baseRef?: string; + requireCleanTarget?: boolean; +} + +export type CheckoutContext = { + paseoHome?: string; +}; + function isGitError(error: unknown): boolean { if (!(error instanceof Error)) { return false; @@ -204,9 +231,10 @@ type ConfiguredBaseRefForCwd = | { baseRef: string; isPaseoOwnedWorktree: true }; async function getConfiguredBaseRefForCwd( - cwd: string + cwd: string, + context?: CheckoutContext ): Promise { - const ownership = await isPaseoOwnedWorktreeCwd(cwd); + const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome }); if (!ownership.allowed) { return { baseRef: null, isPaseoOwnedWorktree: false }; } @@ -229,6 +257,18 @@ async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Pro return stdout.trim().length > 0; } +async function hasOriginRemote(cwd: string): Promise { + try { + const { stdout } = await execAsync("git config --get remote.origin.url", { + cwd, + env: READ_ONLY_GIT_ENV, + }); + return stdout.trim().length > 0; + } catch { + return false; + } +} + async function resolveBaseRef(repoRoot: string): Promise { try { const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", { @@ -280,6 +320,53 @@ function normalizeLocalBranchRefName(input: string): string { return input.startsWith("origin/") ? input.slice("origin/".length) : input; } +async function doesGitRefExist(cwd: string, fullRef: string): Promise { + try { + await execAsync(`git show-ref --verify --quiet ${fullRef}`, { + cwd, + env: READ_ONLY_GIT_ENV, + }); + return true; + } catch { + return false; + } +} + +async function resolveBestBaseRefForMerge(cwd: string, normalizedBaseRef: string): Promise { + const [hasLocal, hasOrigin] = await Promise.all([ + doesGitRefExist(cwd, `refs/heads/${normalizedBaseRef}`), + doesGitRefExist(cwd, `refs/remotes/origin/${normalizedBaseRef}`), + ]); + + if (hasLocal && !hasOrigin) { + return normalizedBaseRef; + } + if (!hasLocal && hasOrigin) { + return `origin/${normalizedBaseRef}`; + } + if (!hasLocal && !hasOrigin) { + throw new Error(`Base branch not found locally or on origin: ${normalizedBaseRef}`); + } + + // Both exist: choose the ref with more unique commits compared to the other. + try { + const { stdout } = await execAsync( + `git rev-list --left-right --count ${normalizedBaseRef}...origin/${normalizedBaseRef}`, + { cwd, env: READ_ONLY_GIT_ENV } + ); + const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/); + const localOnly = Number.parseInt(localOnlyRaw ?? "0", 10); + const originOnly = Number.parseInt(originOnlyRaw ?? "0", 10); + if (!Number.isNaN(localOnly) && !Number.isNaN(originOnly) && originOnly > localOnly) { + return `origin/${normalizedBaseRef}`; + } + } catch { + // ignore and fall back to local + } + + return normalizedBaseRef; +} + async function getAheadBehind(cwd: string, baseRef: string, currentBranch: string): Promise { const normalizedBaseRef = normalizeLocalBranchRefName(baseRef); if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) { @@ -326,7 +413,10 @@ async function getUntrackedDiff(cwd: string): Promise { return untrackedDiff; } -export async function getCheckoutStatus(cwd: string): Promise { +export async function getCheckoutStatus( + cwd: string, + context?: CheckoutContext +): Promise { let repoInfo: Awaited>; try { repoInfo = await detectRepoInfo(cwd); @@ -339,7 +429,8 @@ export async function getCheckoutStatus(cwd: string): Promise { await requireRepoInfo(cwd); @@ -382,7 +476,7 @@ export async function getCheckoutDiff( const untrackedDiff = await getUntrackedDiff(cwd); diff = trackedDiff + untrackedDiff; } else { - const configured = await getConfiguredBaseRefForCwd(cwd); + const configured = await getConfiguredBaseRefForCwd(cwd, context); const repoInfo = await detectRepoInfo(cwd); const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(repoInfo.path)); if (!baseRef) { @@ -422,10 +516,14 @@ export async function commitAll(cwd: string, message: string): Promise { await commitChanges(cwd, { message, addAll: true }); } -export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}): Promise { +export async function mergeToBase( + cwd: string, + options: MergeToBaseOptions = {}, + context?: CheckoutContext +): Promise { const repoInfo = await requireRepoInfo(cwd); const currentBranch = await getCurrentBranch(cwd); - const configured = await getConfiguredBaseRefForCwd(cwd); + const configured = await getConfiguredBaseRefForCwd(cwd, context); const baseRef = configured.baseRef ?? options.baseRef ?? (await resolveBaseRef(repoInfo.path)); if (!baseRef) { throw new Error("Unable to determine base branch for merge"); @@ -524,6 +622,112 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}) } } +export async function mergeFromBase( + cwd: string, + options: MergeFromBaseOptions = {}, + context?: CheckoutContext +): Promise { + const repoInfo = await requireRepoInfo(cwd); + const currentBranch = await getCurrentBranch(cwd); + if (!currentBranch || currentBranch === "HEAD") { + throw new Error("Unable to determine current branch for merge"); + } + + const configured = await getConfiguredBaseRefForCwd(cwd, context); + 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}`); + } + + const requireCleanTarget = options.requireCleanTarget ?? true; + if (requireCleanTarget) { + const { stdout } = await execAsync("git status --porcelain", { + cwd, + env: READ_ONLY_GIT_ENV, + }); + if (stdout.trim().length > 0) { + throw new Error("Working directory has uncommitted changes."); + } + } + + const normalizedBaseRef = normalizeLocalBranchRefName(baseRef); + const bestBaseRef = await resolveBestBaseRefForMerge(cwd, normalizedBaseRef); + if (bestBaseRef === currentBranch) { + return; + } + + try { + await execAsync(`git merge ${bestBaseRef}`, { cwd }); + } catch (error) { + const errorDetails = + error instanceof Error + ? `${error.message}\n${(error as any).stderr ?? ""}\n${(error as any).stdout ?? ""}` + : String(error); + try { + const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ + execAsync("git diff --name-only --diff-filter=U", { cwd }), + execAsync("git ls-files -u", { cwd }), + execAsync("git status --porcelain", { cwd }), + ]); + const statusConflicts = statusOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => /^(UU|AA|DD|AU|UA|UD|DU)\s/.test(line)) + .map((line) => line.slice(3).trim()); + const conflicts = [ + ...unmergedOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean), + ...lsFilesOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.split("\t").pop() as string), + ...statusConflicts, + ].filter(Boolean); + const conflictDetected = + conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); + if (conflictDetected) { + try { + await execAsync("git merge --abort", { cwd }); + } catch { + // ignore + } + throw new MergeFromBaseConflictError({ + baseRef: bestBaseRef, + currentBranch, + conflictFiles: conflicts.length > 0 ? conflicts : [], + }); + } + } catch (innerError) { + if (innerError instanceof MergeFromBaseConflictError) { + throw innerError; + } + // ignore detection failures + } + + throw error; + } +} + +export async function pushCurrentBranch(cwd: string): Promise { + await requireRepoInfo(cwd); + const currentBranch = await getCurrentBranch(cwd); + if (!currentBranch || currentBranch === "HEAD") { + throw new Error("Unable to determine current branch for push"); + } + const hasRemote = await hasOriginRemote(cwd); + if (!hasRemote) { + throw new Error("Remote 'origin' is not configured."); + } + await execAsync(`git push -u origin ${currentBranch}`, { cwd }); +} + export interface CreatePullRequestOptions { title: string; body?: string; diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index dd9f8a520..62c5fc578 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -16,11 +16,13 @@ import { tmpdir } from "os"; describe("createWorktree", () => { let tempDir: string; let repoDir: string; + let paseoHome: string; beforeEach(() => { // Use realpathSync to resolve symlinks (e.g., /var -> /private/var on macOS) tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-test-"))); repoDir = join(tempDir, "test-repo"); + paseoHome = join(tempDir, "paseo-home"); // Create a git repo with an initial commit execSync(`mkdir -p ${repoDir}`); @@ -42,10 +44,11 @@ describe("createWorktree", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "hello-world", + paseoHome, }); expect(result.worktreePath).toBe( - join(repoDir, ".paseo", "worktrees", "hello-world") + join(paseoHome, "worktrees", "test-repo", "hello-world") ); expect(existsSync(result.worktreePath)).toBe(true); expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); @@ -62,6 +65,7 @@ describe("createWorktree", () => { const varTempDir = mkdtempSync(join(tmpdir(), "worktree-realpath-test-")); const privateTempDir = realpathSync(varTempDir); const varRepoDir = join(varTempDir, "test-repo"); + const varPaseoHome = join(varTempDir, "paseo-home"); execSync(`mkdir -p ${varRepoDir}`); execSync("git init -b main", { cwd: varRepoDir }); execSync("git config user.email 'test@test.com'", { cwd: varRepoDir }); @@ -75,12 +79,13 @@ describe("createWorktree", () => { cwd: varRepoDir, baseBranch: "main", worktreeSlug: "realpath-test", + paseoHome: varPaseoHome, }); - const privateWorktreePath = join(privateTempDir, "test-repo", ".paseo", "worktrees", "realpath-test"); + const privateWorktreePath = join(privateTempDir, "paseo-home", "worktrees", "test-repo", "realpath-test"); expect(existsSync(privateWorktreePath)).toBe(true); - const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath); + const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath, { paseoHome: varPaseoHome }); expect(ownership.allowed).toBe(true); rmSync(varTempDir, { recursive: true, force: true }); @@ -92,10 +97,11 @@ describe("createWorktree", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "my-feature", + paseoHome, }); expect(result.worktreePath).toBe( - join(repoDir, ".paseo", "worktrees", "my-feature") + join(paseoHome, "worktrees", "test-repo", "my-feature") ); expect(existsSync(result.worktreePath)).toBe(true); @@ -129,11 +135,12 @@ describe("createWorktree", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "hello", + paseoHome, }); // Should create branch "hello-1" since "hello" exists expect(result.worktreePath).toBe( - join(repoDir, ".paseo", "worktrees", "hello") + join(paseoHome, "worktrees", "test-repo", "hello") ); expect(existsSync(result.worktreePath)).toBe(true); @@ -151,6 +158,7 @@ describe("createWorktree", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "hello", + paseoHome, }); expect(existsSync(result.worktreePath)).toBe(true); @@ -178,6 +186,7 @@ describe("createWorktree", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "setup-test", + paseoHome, }); expect(existsSync(result.worktreePath)).toBe(true); @@ -207,6 +216,7 @@ describe("createWorktree", () => { baseBranch: "main", worktreeSlug: "no-setup-test", runSetup: false, + paseoHome, }); expect(existsSync(result.worktreePath)).toBe(true); @@ -224,9 +234,9 @@ describe("createWorktree", () => { execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", { cwd: repoDir }); const expectedWorktreePath = join( - repoDir, - ".paseo", + paseoHome, "worktrees", + "test-repo", "fail-test" ); @@ -236,6 +246,7 @@ describe("createWorktree", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "fail-test", + paseoHome, }) ).rejects.toThrow("Worktree setup command failed"); @@ -247,10 +258,12 @@ describe("createWorktree", () => { describe("paseo worktree manager", () => { let tempDir: string; let repoDir: string; + let paseoHome: string; beforeEach(() => { tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-manager-test-"))); repoDir = join(tempDir, "test-repo"); + paseoHome = join(tempDir, "paseo-home"); execSync(`mkdir -p ${repoDir}`); execSync("git init", { cwd: repoDir }); @@ -265,28 +278,30 @@ describe("paseo worktree manager", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("lists and deletes paseo worktrees under .paseo/worktrees", async () => { + it("lists and deletes paseo worktrees under ~/.paseo/worktrees/{project}", async () => { const first = await createWorktree({ branchName: "main", cwd: repoDir, baseBranch: "main", worktreeSlug: "alpha", + paseoHome, }); const second = await createWorktree({ branchName: "main", cwd: repoDir, baseBranch: "main", worktreeSlug: "beta", + paseoHome, }); - const worktrees = await listPaseoWorktrees({ cwd: repoDir }); + const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome }); const paths = worktrees.map((worktree) => worktree.path).sort(); expect(paths).toEqual([first.worktreePath, second.worktreePath].sort()); - await deletePaseoWorktree({ cwd: repoDir, worktreePath: first.worktreePath }); + await deletePaseoWorktree({ cwd: repoDir, worktreePath: first.worktreePath, paseoHome }); expect(existsSync(first.worktreePath)).toBe(false); - const remaining = await listPaseoWorktrees({ cwd: repoDir }); + const remaining = await listPaseoWorktrees({ cwd: repoDir, paseoHome }); expect(remaining.map((worktree) => worktree.path)).toEqual([second.worktreePath]); }); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index b2fd07b19..dc0d1fea3 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,9 +1,10 @@ import { exec } from "child_process"; import { promisify } from "util"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; import { createNameId } from "mnemonic-id"; import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js"; +import { resolvePaseoHome } from "../server/paseo-home.js"; interface PaseoConfig { worktree?: { @@ -67,6 +68,7 @@ interface CreateWorktreeOptions { baseBranch: string; worktreeSlug?: string; runSetup?: boolean; + paseoHome?: string; } function readPaseoConfig(repoRoot: string): PaseoConfig | null { @@ -315,17 +317,87 @@ function generateWorktreeSlug(): string { return createNameId(); } -function getPaseoWorktreesRoot(repoRoot: string): string { - return join(repoRoot, ".paseo", "worktrees"); +function tryParseGitRemote(remoteUrl: string): { host?: string; path: string } | null { + const cleaned = remoteUrl.trim().replace(/\.git$/i, ""); + if (!cleaned) { + return null; + } + + if (cleaned.includes("://")) { + try { + const url = new URL(cleaned); + return { host: url.hostname, path: url.pathname.replace(/^\/+/, "") }; + } catch { + // fall through + } + } + + // Support scp-like syntax: git@github.com:owner/repo + const scpMatch = cleaned.match(/^(?:.+@)?([^:]+):(.+)$/); + if (scpMatch?.[2]) { + return { host: scpMatch[1], path: scpMatch[2].replace(/^\/+/, "") }; + } + + return { path: cleaned.replace(/^\/+/, "") }; +} + +function inferProjectNameFromRemote(remoteUrl: string): string | null { + const parsed = tryParseGitRemote(remoteUrl); + if (!parsed?.path) { + return null; + } + const segments = parsed.path.split("/").filter((segment) => segment.length > 0); + if (segments.length === 0) { + return null; + } + return segments[segments.length - 1] ?? null; +} + +async function detectWorktreeProject(repoRoot: string): Promise { + try { + const { stdout } = await execAsync("git config --get remote.origin.url", { + cwd: repoRoot, + env: READ_ONLY_GIT_ENV, + }); + const remote = stdout.trim(); + if (remote) { + const inferred = inferProjectNameFromRemote(remote); + if (inferred) { + const projected = slugify(inferred); + if (projected) { + return projected; + } + } + } + } catch { + // ignore + } + + return slugify(basename(repoRoot)); +} + +async function getPaseoWorktreesRoot(repoRoot: string, paseoHome?: string): Promise { + const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome(); + const project = await detectWorktreeProject(repoRoot); + return join(home, "worktrees", project); +} + +function normalizePathForOwnership(input: string): string { + try { + return realpathSync(input); + } catch { + return resolve(input); + } } export async function isPaseoOwnedWorktreeCwd( - cwd: string + cwd: string, + options?: { paseoHome?: string } ): Promise { const repoInfo = await detectRepoInfo(cwd); - const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path); - const resolvedRoot = resolve(worktreesRoot) + sep; - const resolvedCwd = resolve(cwd); + const worktreesRoot = await getPaseoWorktreesRoot(repoInfo.path, options?.paseoHome); + const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep; + const resolvedCwd = normalizePathForOwnership(cwd); if (!resolvedCwd.startsWith(resolvedRoot)) { return { @@ -336,7 +408,7 @@ export async function isPaseoOwnedWorktreeCwd( }; } - const worktrees = await listPaseoWorktrees({ cwd: repoInfo.path }); + const worktrees = await listPaseoWorktrees({ cwd: repoInfo.path, paseoHome: options?.paseoHome }); const allowed = worktrees.some((entry) => { const worktreePath = resolve(entry.path); return resolvedCwd === worktreePath || resolvedCwd.startsWith(worktreePath + sep); @@ -424,51 +496,56 @@ function parseWorktreeList(output: string): PaseoWorktreeInfo[] { export async function listPaseoWorktrees({ cwd, + paseoHome, }: { cwd: string; + paseoHome?: string; }): Promise { const repoInfo = await detectRepoInfo(cwd); - const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path); + const worktreesRoot = await getPaseoWorktreesRoot(repoInfo.path, paseoHome); const { stdout } = await execAsync("git worktree list --porcelain", { cwd: repoInfo.path, env: READ_ONLY_GIT_ENV, }); - const rootPrefix = resolve(worktreesRoot) + sep; - return parseWorktreeList(stdout).filter((entry) => - resolve(entry.path).startsWith(rootPrefix) - ); + const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep; + return parseWorktreeList(stdout) + .map((entry) => ({ ...entry, path: normalizePathForOwnership(entry.path) })) + .filter((entry) => entry.path.startsWith(rootPrefix)); } export async function deletePaseoWorktree({ cwd, worktreePath, worktreeSlug, + paseoHome, }: { cwd: string; worktreePath?: string; worktreeSlug?: string; + paseoHome?: string; }): Promise { if (!worktreePath && !worktreeSlug) { throw new Error("worktreePath or worktreeSlug is required"); } const repoInfo = await detectRepoInfo(cwd); - const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path); + const worktreesRoot = await getPaseoWorktreesRoot(repoInfo.path, paseoHome); const targetPath = worktreePath ?? join(worktreesRoot, worktreeSlug!); - const resolvedRoot = resolve(worktreesRoot) + sep; - const resolvedTarget = resolve(targetPath); + const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep; + const resolvedTarget = normalizePathForOwnership(targetPath); if (!resolvedTarget.startsWith(resolvedRoot)) { throw new Error("Refusing to delete non-Paseo worktree"); } - await execAsync(`git worktree remove "${targetPath}" --force`, { + const canonicalTargetPath = normalizePathForOwnership(targetPath); + await execAsync(`git worktree remove "${canonicalTargetPath}" --force`, { cwd: repoInfo.path, }); - if (existsSync(targetPath)) { - rmSync(targetPath, { recursive: true, force: true }); + if (existsSync(canonicalTargetPath)) { + rmSync(canonicalTargetPath, { recursive: true, force: true }); } } @@ -482,6 +559,7 @@ export async function createWorktree({ baseBranch, worktreeSlug, runSetup = true, + paseoHome, }: CreateWorktreeOptions): Promise { // Validate branch name const validation = validateBranchSlug(branchName); @@ -512,7 +590,7 @@ export async function createWorktree({ let worktreePath: string; const desiredSlug = worktreeSlug || generateWorktreeSlug(); - worktreePath = join(getPaseoWorktreesRoot(repoInfo.path), desiredSlug); + worktreePath = join(await getPaseoWorktreesRoot(repoInfo.path, paseoHome), desiredSlug); mkdirSync(dirname(worktreePath), { recursive: true }); // Check if branch already exists @@ -560,7 +638,7 @@ export async function createWorktree({ const command = `git worktree add "${finalWorktreePath}" -b "${newBranchName}" "${base}"`; await execAsync(command, { cwd: repoInfo.path }); - worktreePath = finalWorktreePath; + worktreePath = normalizePathForOwnership(finalWorktreePath); writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch });