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 });