Add configurable worktree root

* Add configurable worktree root

Allow new Paseo worktrees to be created under a custom worktrees.root from config.json while preserving the existing PASEO_HOME/worktrees default. Thread the resolved root through create/list/archive/ownership flows, MCP/session paths, checkout metadata, schema, docs, and targeted tests.

* Address worktree root review feedback

Use the resolved Paseo worktree base when a custom PASEO_HOME is provided, and remove an unused worktreesRoot dependency from worktree list handling.

* Fix checkout worktree path test on Windows

Use platform-native paths in the custom PASEO_HOME regression test so Windows CI compares against the same resolved root shape as production code.

* Clarify optional worktrees config docs

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Matt Cowger
2026-06-01 01:19:29 -07:00
committed by GitHub
parent 8c6abcb41f
commit d406c29e16
25 changed files with 324 additions and 42 deletions

View File

@@ -147,6 +147,9 @@ Single file, validated with `PersistedConfigSchema`.
app: {
baseUrl: string
},
worktrees?: {
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { apiKey: string },
local: { modelsDir: string }

View File

@@ -19,6 +19,7 @@ import type { AgentStorage } from "./agent-storage.js";
interface CreateAgentLifecycleDispatchDependencies {
paseoHome: string;
worktreesRoot?: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
github: GitHubService;
@@ -106,6 +107,7 @@ export class CreateAgentLifecycleDispatch {
firstAgentContext,
runSetup: false,
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
} as const;
switch (target.mode) {
@@ -191,6 +193,7 @@ export class CreateAgentLifecycleDispatch {
}): Promise<void> {
const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, {
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
});
if (!ownership.allowed) {
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
@@ -199,6 +202,7 @@ export class CreateAgentLifecycleDispatch {
await archivePaseoWorktree(
{
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
github: this.dependencies.github,
workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager,
@@ -215,6 +219,7 @@ export class CreateAgentLifecycleDispatch {
targetPath: options.worktreePath,
repoRoot: options.repoRoot ?? ownership.repoRoot ?? null,
worktreesRoot: ownership.worktreeRoot,
worktreesBaseRoot: this.dependencies.worktreesRoot,
requestId: randomUUID(),
},
);

View File

@@ -46,6 +46,7 @@ interface CreateAgentCommandDependencies {
agentStorage: AgentStorage;
logger: Logger;
paseoHome?: string;
worktreesRoot?: string;
workspaceGitService?: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
@@ -419,6 +420,7 @@ async function resolveMcpCwd(params: {
...(params.initialPrompt ? { firstAgentContext: { prompt: params.initialPrompt } } : {}),
runSetup: false,
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
},
createPaseoWorktree: dependencies.createPaseoWorktree,
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,

View File

@@ -99,6 +99,7 @@ export interface AgentMcpServerOptions {
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
paseoHome?: string;
worktreesRoot?: string;
/**
* ID of the agent that is connecting to this MCP server.
* Used for cwd/mode inheritance when agents spawn child agents.
@@ -918,6 +919,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
agentStorage,
logger: childLogger,
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
workspaceGitService: options.workspaceGitService,
terminalManager,
providerSnapshotManager,
@@ -2152,6 +2154,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const commandResult = await createPaseoWorktreeCommand(
{
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
createPaseoWorktreeWorkflow: options.createPaseoWorktree,
},
createMcpWorktreeCommandInput(repoRoot, target),
@@ -2414,6 +2417,7 @@ function archiveWorktreeDependencies(
}
return {
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
github: options.github,
workspaceGitService: options.workspaceGitService,
agentManager: context.agentManager,

View File

@@ -15,6 +15,7 @@ import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
export interface AutoArchiveArchiveOptions {
paseoHome: string;
worktreesRoot?: string;
daemonConfigStore: DaemonConfigStore;
workspaceGitService: WorkspaceGitServiceImpl;
github: GitHubService;
@@ -81,7 +82,10 @@ export async function archiveIfSafe(input: {
return;
}
const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, { paseoHome: options.paseoHome });
const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, {
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
});
if (!ownership.allowed) {
return;
}
@@ -90,6 +94,7 @@ export async function archiveIfSafe(input: {
await deps.archivePaseoWorktree(
{
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
github: options.github,
workspaceGitService: options.workspaceGitService,
agentManager: options.agentManager,
@@ -115,6 +120,7 @@ export async function archiveIfSafe(input: {
targetPath: cwd,
repoRoot: ownership.repoRoot ?? null,
worktreesRoot: ownership.worktreeRoot,
worktreesBaseRoot: options.worktreesRoot,
requestId: "auto-archive-on-merge",
},
);

View File

@@ -231,6 +231,7 @@ export type DaemonLifecycleIntent =
export interface PaseoDaemonConfig {
listen: string;
paseoHome: string;
worktreesRoot?: string;
corsAllowedOrigins: string[];
allowedHosts?: HostnamesConfig;
hostnames?: HostnamesConfig;
@@ -518,6 +519,7 @@ export async function createPaseoDaemon(
const workspaceGitService = new WorkspaceGitServiceImpl({
logger,
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
deps: {
github,
},
@@ -657,6 +659,7 @@ export async function createPaseoDaemon(
setupAutoArchiveOnMerge({
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
daemonConfigStore,
workspaceGitService,
github,
@@ -694,6 +697,7 @@ export async function createPaseoDaemon(
return createPaseoWorktreeWorkflow(
{
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
createPaseoWorktree: async (workflowInput, workflowOptions) => {
return createRegisteredPaseoWorktree(workflowInput, {
github,
@@ -740,6 +744,7 @@ export async function createPaseoDaemon(
);
},
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
callerAgentId,
enableVoiceTools: false,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
@@ -965,6 +970,7 @@ export async function createPaseoDaemon(
providerSnapshotManager,
{
listen: formatListenTarget(boundListenTarget ?? listenTarget),
worktreesRoot: config.worktreesRoot,
relay: {
enabled: relayEnabled,
endpoint: relayEndpoint,

View File

@@ -83,3 +83,29 @@ describe("daemon relay config", () => {
expect(config.relayPublicUseTls).toBe(true);
});
});
describe("daemon worktree root config", () => {
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("resolves relative worktrees.root against PASEO_HOME", async () => {
const home = await createPaseoHome({
version: 1,
worktrees: { root: "custom-worktrees" },
});
expect(loadConfig(home, { env: {} }).worktreesRoot).toBe(path.join(home, "custom-worktrees"));
});
test("keeps absolute worktrees.root absolute", async () => {
const home = await createPaseoHome({
version: 1,
worktrees: { root: path.join(os.tmpdir(), "paseo-custom-worktrees") },
});
expect(loadConfig(home, { env: {} }).worktreesRoot).toBe(
path.join(os.tmpdir(), "paseo-custom-worktrees"),
);
});
});

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { resolvePaseoNodeEnv } from "./paseo-env.js";
import { z } from "zod";
import { expandTilde } from "../utils/path.js";
import type { PaseoDaemonConfig } from "./bootstrap.js";
import {
@@ -256,6 +257,21 @@ function resolveAuthConfig(
: undefined;
}
function resolveWorktreesRoot(
paseoHome: string,
persisted: ReturnType<typeof loadPersistedConfig>,
): string | undefined {
const configuredRoot = persisted.worktrees?.root?.trim();
if (!configuredRoot) {
return undefined;
}
const expandedRoot = expandTilde(configuredRoot);
return path.isAbsolute(expandedRoot)
? path.resolve(expandedRoot)
: path.resolve(paseoHome, expandedRoot);
}
function resolveAppendSystemPrompt(persisted: ReturnType<typeof loadPersistedConfig>): string {
return persisted.daemon?.appendSystemPrompt ?? "";
}
@@ -321,6 +337,7 @@ export function loadConfig(
return {
listen,
paseoHome,
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
hostnames,
mcpEnabled,

View File

@@ -14,6 +14,7 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
export interface ArchivePaseoWorktreeDependencies {
paseoHome?: string;
worktreesRoot?: string;
github: GitHubService;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
@@ -41,12 +42,14 @@ export async function archivePaseoWorktree(
targetPath: string;
repoRoot: string | null;
worktreesRoot?: string;
worktreesBaseRoot?: string;
requestId: string;
},
): Promise<string[]> {
let targetPath = options.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: dependencies.paseoHome,
worktreesRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
@@ -115,6 +118,7 @@ export async function archivePaseoWorktree(
worktreePath: targetPath,
worktreesRoot: options.worktreesRoot,
paseoHome: dependencies.paseoHome,
worktreesBaseRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
});
} catch (error) {
if (error instanceof WorktreeTeardownError) {

View File

@@ -63,6 +63,18 @@ describe("PersistedConfigSchema daemon relay config", () => {
});
});
describe("PersistedConfigSchema worktrees config", () => {
test("accepts optional worktree root", () => {
const parsed = PersistedConfigSchema.parse({
worktrees: {
root: "/mnt/fast/paseo-worktrees",
},
});
expect(parsed.worktrees?.root).toBe("/mnt/fast/paseo-worktrees");
});
});
describe("PersistedConfigSchema daemon append system prompt", () => {
test("accepts optional append system prompt", () => {
const parsed = PersistedConfigSchema.parse({

View File

@@ -63,6 +63,12 @@ const ProvidersSchema = z
})
.strict();
const WorktreesConfigSchema = z
.object({
root: z.string().min(1).optional(),
})
.strict();
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
message: "Expected a bcrypt hash",
});
@@ -248,6 +254,7 @@ export const PersistedConfigSchema = z
.optional(),
providers: ProvidersSchema.optional(),
worktrees: WorktreesConfigSchema.optional(),
agents: z
.object({
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),

View File

@@ -569,6 +569,7 @@ export interface SessionOptions {
downloadTokenStore: DownloadTokenStore;
pushTokenStore: PushTokenStore;
paseoHome: string;
worktreesRoot?: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
@@ -742,6 +743,7 @@ export class Session {
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
// State machine
private abortController: AbortController;
@@ -859,6 +861,7 @@ export class Session {
downloadTokenStore,
pushTokenStore,
paseoHome,
worktreesRoot,
agentManager,
agentStorage,
projectRegistry,
@@ -900,6 +903,7 @@ export class Session {
this.downloadTokenStore = downloadTokenStore;
this.pushTokenStore = pushTokenStore;
this.paseoHome = paseoHome;
this.worktreesRoot = worktreesRoot;
this.sessionLogger = logger.child({
module: "session",
clientId: this.clientId,
@@ -930,6 +934,7 @@ export class Session {
});
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
github: this.github,
@@ -3111,6 +3116,7 @@ export class Session {
agentStorage: this.agentStorage,
logger: this.sessionLogger,
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
workspaceGitService: this.workspaceGitService,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readStructuredGenerationDaemonConfig(),
@@ -3491,6 +3497,7 @@ export class Session {
return buildWorktreeAgentSessionConfig(
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
sessionLogger: this.sessionLogger,
workspaceGitService: this.workspaceGitService,
createPaseoWorktree: (input, serviceOptions) =>
@@ -5230,7 +5237,7 @@ export class Session {
baseRef,
mode: msg.strategy === "squash" ? "squash" : "merge",
},
{ paseoHome: this.paseoHome },
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
);
await Promise.all([
this.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }),
@@ -5711,6 +5718,7 @@ export class Session {
return handleWorktreeArchiveRequest(
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
github: this.github,
workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager,
@@ -7298,6 +7306,7 @@ export class Session {
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
emit: (message) => this.emit(message),
sessionLogger: this.sessionLogger,
@@ -7317,6 +7326,7 @@ export class Session {
return createWorktreeWorkflow(
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
createPaseoWorktree: (workflowInput, serviceOptions) =>
this.createPaseoWorktree(workflowInput, serviceOptions),
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),

View File

@@ -350,6 +350,7 @@ export class VoiceAssistantWebSocketServer {
private readonly workspaceGitService: WorkspaceGitService;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushTokenStore: PushTokenStore;
private readonly pushNotificationSender: PushNotificationSender;
@@ -419,6 +420,7 @@ export class VoiceAssistantWebSocketServer {
providerSnapshotManager?: ProviderSnapshotManager,
daemonRuntimeConfig?: {
listen: string | null;
worktreesRoot?: string;
relay: {
enabled: boolean;
endpoint: string;
@@ -453,6 +455,7 @@ export class VoiceAssistantWebSocketServer {
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
this.daemonConfigStore = daemonConfigStore;
this.mcpBaseUrl = mcpBaseUrl;
this.assignOptionalServices({
@@ -858,6 +861,7 @@ export class VoiceAssistantWebSocketServer {
downloadTokenStore: this.downloadTokenStore,
pushTokenStore: this.pushTokenStore,
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
projectRegistry: this.projectRegistry,

View File

@@ -259,6 +259,7 @@ interface WorkspaceGitServiceDependencies {
interface WorkspaceGitServiceOptions {
logger: pino.Logger;
paseoHome: string;
worktreesRoot?: string;
deps?: Partial<WorkspaceGitServiceDependencies>;
}
@@ -347,6 +348,7 @@ function resolveWorkspaceGitServiceDeps(
export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly logger: pino.Logger;
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
private readonly deps: WorkspaceGitServiceDependencies;
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
@@ -385,6 +387,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
constructor(options: WorkspaceGitServiceOptions) {
this.logger = options.logger.child({ module: "workspace-git-service" });
this.paseoHome = options.paseoHome;
this.worktreesRoot = options.worktreesRoot;
this.deps = resolveWorkspaceGitServiceDeps(options.deps);
}
@@ -438,6 +441,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
try {
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
logger: this.logger,
});
if (!status.isGit) {
@@ -484,7 +488,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const normalizedOptions = this.normalizeCheckoutDiffOptions(options);
const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions);
return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () =>
this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { paseoHome: this.paseoHome }),
this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
}),
);
}
@@ -581,6 +588,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.deps.listPaseoWorktrees({
cwd: repoRoot,
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
}),
);
}
@@ -1566,7 +1574,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const cwd = target.cwd;
const previousGitHubPollKey = this.getGitHubPollKey(target);
const baseContext: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
const baseContext: CheckoutContext = {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
logger: this.logger,
};
const facts = await this.loadCheckoutFacts(target, {
...baseContext,
allowRecent: !request.force,

View File

@@ -24,6 +24,7 @@ export interface CreateWorktreeCoreInput {
githubPrNumber?: number;
firstAgentContext?: FirstAgentContext;
paseoHome?: string;
worktreesRoot?: string;
runSetup?: boolean;
}
@@ -98,6 +99,7 @@ export async function createWorktreeCore(
slug: normalizedSlug,
repoRoot,
paseoHome: input.paseoHome,
worktreesRoot: input.worktreesRoot,
});
if (existingWorktree) {
return { worktree: existingWorktree, intent, repoRoot, created: false };
@@ -110,6 +112,7 @@ export async function createWorktreeCore(
source: intent,
runSetup: input.runSetup ?? true,
paseoHome: input.paseoHome,
worktreesRoot: input.worktreesRoot,
}),
intent,
repoRoot,

View File

@@ -75,6 +75,7 @@ type AgentWorktreeSetupTimelineWriter = (input: {
interface BuildAgentSessionConfigDependencies {
paseoHome?: string;
worktreesRoot?: string;
sessionLogger: Logger;
workspaceGitService?: WorkspaceGitService;
createPaseoWorktree: (
@@ -95,6 +96,7 @@ interface BuildAgentSessionConfigDependencies {
interface CreatePaseoWorktreeInBackgroundDependencies {
paseoHome?: string;
worktreesRoot?: string;
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void;
emit: EmitSessionMessage;
@@ -158,6 +160,7 @@ interface HandleWorkspaceSetupStatusRequestDependencies {
interface HandleCreatePaseoWorktreeRequestDependencies {
paseoHome?: string;
worktreesRoot?: string;
describeWorkspaceRecord: (
result: CreatePaseoWorktreeResult,
) => Promise<WorkspaceDescriptorPayload>;
@@ -224,6 +227,7 @@ export async function buildAgentSessionConfig(
firstAgentContext,
runSetup: false,
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
},
{
resolveDefaultBranch: normalized.baseBranch
@@ -491,6 +495,7 @@ export async function handleCreatePaseoWorktreeRequest(
const commandResult = await createPaseoWorktreeCommand(
{
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
createPaseoWorktreeWorkflow: dependencies.createPaseoWorktreeWorkflow,
},
{
@@ -572,6 +577,7 @@ export async function createPaseoWorktreeWorkflow(
...input,
runSetup: false,
paseoHome: input.paseoHome ?? dependencies.paseoHome,
worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot,
},
options?.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }

View File

@@ -39,6 +39,7 @@ export interface CreatePaseoWorktreeCommandDependencies<
Result extends CreatePaseoWorktreeResult = CreatePaseoWorktreeResult,
> {
paseoHome?: string;
worktreesRoot?: string;
createPaseoWorktreeWorkflow?: CreatePaseoWorktreeWorkflow<Result>;
}
@@ -47,6 +48,7 @@ export type CreatePaseoWorktreeCommandInput = Omit<
"paseoHome" | "runSetup"
> & {
paseoHome?: string;
worktreesRoot?: string;
};
export type CreatePaseoWorktreeCommandResult<Result extends CreatePaseoWorktreeResult> =
@@ -73,6 +75,7 @@ export async function createPaseoWorktreeCommand<Result extends CreatePaseoWorkt
...input,
runSetup: false,
paseoHome: input.paseoHome ?? dependencies.paseoHome,
worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot,
});
return { ok: true, createdWorktree };
} catch (error) {
@@ -118,6 +121,7 @@ export async function archivePaseoWorktreeCommand(
const resolvedTarget = await resolveArchiveTarget(dependencies, input);
const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, {
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
});
if (!ownership.allowed) {
@@ -134,6 +138,7 @@ export async function archivePaseoWorktreeCommand(
targetPath: resolvedTarget.targetPath,
repoRoot,
worktreesRoot: ownership.worktreeRoot,
worktreesBaseRoot: dependencies.worktreesRoot,
requestId: input.requestId,
});
@@ -184,6 +189,10 @@ async function resolveWorktreeSlugPath(
repoRoot: string,
worktreeSlug: string,
): Promise<string> {
const worktreesRoot = await getPaseoWorktreesRoot(repoRoot, dependencies.paseoHome);
const worktreesRoot = await getPaseoWorktreesRoot(
repoRoot,
dependencies.paseoHome,
dependencies.worktreesRoot,
);
return join(worktreesRoot, worktreeSlug);
}

View File

@@ -10,6 +10,7 @@ import {
mkdirSync,
} from "fs";
import { join } from "path";
import { win32 } from "node:path";
import { tmpdir } from "os";
import {
__resetCheckoutShortstatCacheForTests,
@@ -2355,6 +2356,20 @@ const x = 1;
expect(isPaseoWorktreePath("C:\\Users\\dev\\.paseo\\worktrees\\feature")).toBe(true);
});
it("matches worktrees under a custom PASEO_HOME", () => {
const customPaseoHome = process.platform === "win32" ? "C:\\paseo" : "/var/lib/paseo";
const worktreePath =
process.platform === "win32"
? win32.join(customPaseoHome, "worktrees", "project", "feature")
: `${customPaseoHome}/worktrees/project/feature`;
expect(
isPaseoWorktreePath(worktreePath, {
paseoHome: customPaseoHome,
}),
).toBe(true);
});
it("rejects paths without .paseo/worktrees segment", () => {
expect(isPaseoWorktreePath("/home/user/repo")).toBe(false);
expect(isPaseoWorktreePath("C:\\Users\\dev\\repo")).toBe(false);

View File

@@ -19,7 +19,7 @@ import {
} from "../services/github-service.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js";
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
import { readPaseoWorktreeMetadata } from "./worktree-metadata.js";
const READ_ONLY_GIT_ENV = {
GIT_OPTIONAL_LOCKS: "0",
@@ -779,6 +779,7 @@ export interface MergeFromBaseOptions {
export interface CheckoutContext {
paseoHome?: string;
worktreesRoot?: string;
logger?: Pick<Logger, "trace">;
facts?: CheckoutSnapshotFacts | null;
}
@@ -882,6 +883,7 @@ export async function getMainRepoRoot(cwd: string): Promise<string> {
async function getMainRepoRootFromCommonDir(
cwd: string,
commonDir: string | null,
context?: CheckoutContext,
): Promise<string> {
if (!commonDir) {
throw new Error("Not in a git repository");
@@ -897,7 +899,14 @@ async function getMainRepoRootFromCommonDir(
envOverlay: READ_ONLY_GIT_ENV,
});
const worktrees = parseWorktreeList(worktreeOut);
const nonBareNonPaseo = worktrees.filter((wt) => !wt.isBare && !isPaseoWorktreePath(wt.path));
const nonBareNonPaseo = worktrees.filter(
(wt) =>
!wt.isBare &&
!isPaseoWorktreePath(wt.path, {
paseoHome: context?.paseoHome,
worktreesRoot: context?.worktreesRoot,
}),
);
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized));
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
@@ -909,8 +918,14 @@ export interface GitWorktreeEntry {
isBare?: boolean;
}
/** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */
export function isPaseoWorktreePath(p: string): boolean {
/** Check whether a path is under Paseo's worktree root. */
export function isPaseoWorktreePath(
p: string,
options?: { paseoHome?: string; worktreesRoot?: string },
): boolean {
if (options?.worktreesRoot || options?.paseoHome) {
return isDescendantPath(p, resolvePaseoWorktreesBaseRoot(options));
}
return /[/\\]\.paseo[/\\]worktrees[/\\]/.test(p);
}
@@ -1008,7 +1023,10 @@ async function getPaseoWorktreeForCwd(
return { isPaseoOwnedWorktree: false };
}
const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome });
const ownership = await isPaseoOwnedWorktreeCwd(cwd, {
paseoHome: context?.paseoHome,
worktreesRoot: context?.worktreesRoot,
});
if (!ownership.allowed) {
return { isPaseoOwnedWorktree: false };
}
@@ -1548,9 +1566,11 @@ export async function getCheckoutSnapshotFacts(
? readPaseoWorktreeBaseRef(inspected.paseoWorktree.worktreeRoot)
: null;
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
const mainRepoRoot = await getMainRepoRootFromCommonDir(cwd, inspected.gitCommonDir).catch(
() => null,
);
const mainRepoRoot = await getMainRepoRootFromCommonDir(
cwd,
inspected.gitCommonDir,
context,
).catch(() => null);
let comparisonBaseRef: string | null = null;
if (
resolvedBaseRef &&

View File

@@ -49,6 +49,7 @@ interface LegacyCreateWorktreeTestOptions {
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
worktreesRoot?: string;
}
function createLegacyWorktreeForTest(
@@ -68,6 +69,7 @@ function createLegacyWorktreeForTest(
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
});
}
@@ -118,6 +120,38 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
});
it("creates and owns worktrees under a configured root", async () => {
const worktreesRoot = join(tempDir, "custom-worktrees");
const projectHash = await deriveWorktreeProjectHash(repoDir);
const result = await createLegacyWorktreeForTest({
branchName: "custom-root",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "custom-root",
paseoHome,
worktreesRoot,
});
expect(result.worktreePath).toBe(join(worktreesRoot, projectHash, "custom-root"));
await expect(
isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome, worktreesRoot }),
).resolves.toMatchObject({ allowed: true, worktreeRoot: join(worktreesRoot, projectHash) });
await expect(
isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }),
).resolves.toMatchObject({ allowed: false });
const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome, worktreesRoot });
expect(worktrees.map((entry) => entry.path)).toContain(result.worktreePath);
await deletePaseoWorktree({
cwd: repoDir,
worktreePath: result.worktreePath,
paseoHome,
worktreesBaseRoot: worktreesRoot,
});
expect(existsSync(result.worktreePath)).toBe(false);
});
it.skip("detects paseo-owned worktrees across realpath differences (macOS /var vs /private/var)", async () => {
// Intentionally create repo using the non-realpath tmpdir() variant (often /var/... on macOS).
const varTempDir = mkdtempSync(join(tmpdir(), "worktree-realpath-test-"));

View File

@@ -2,7 +2,7 @@ import { execFile } from "child_process";
import { promisify } from "util";
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
import { copyFile, rm, stat } from "fs/promises";
import { join, basename, dirname, resolve, sep } from "path";
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
import stripAnsi from "strip-ansi";
@@ -31,6 +31,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import { expandTilde } from "./path.js";
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
@@ -150,6 +151,11 @@ export interface PaseoWorktreeOwnership {
worktreePath?: string;
}
export interface WorktreeRootOptions {
paseoHome?: string;
worktreesRoot?: string;
}
export type WorktreeSource =
| { kind: "branch-off"; baseBranch: string; branchName: string }
| { kind: "checkout-branch"; branchName: string }
@@ -168,12 +174,14 @@ export interface CreateWorktreeOptions {
source: WorktreeSource;
runSetup: boolean;
paseoHome?: string;
worktreesRoot?: string;
}
interface ResolveExistingWorktreeForSlugOptions {
slug: string;
repoRoot: string;
paseoHome?: string;
worktreesRoot?: string;
}
export class BranchAlreadyCheckedOutError extends Error {
@@ -766,19 +774,38 @@ export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
}
}
export async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> {
const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome();
export function resolvePaseoWorktreesBaseRoot(options?: WorktreeRootOptions): string {
if (options?.worktreesRoot) {
const expandedRoot = expandTilde(options.worktreesRoot);
if (isAbsolute(expandedRoot)) {
return resolve(expandedRoot);
}
const home = options.paseoHome ? resolve(options.paseoHome) : resolvePaseoHome();
return resolve(home, expandedRoot);
}
const home = options?.paseoHome ? resolve(options.paseoHome) : resolvePaseoHome();
return join(home, "worktrees");
}
export async function getPaseoWorktreesRoot(
cwd: string,
paseoHome?: string,
worktreesRoot?: string,
): Promise<string> {
const baseRoot = resolvePaseoWorktreesBaseRoot({ paseoHome, worktreesRoot });
const projectHash = await deriveWorktreeProjectHash(cwd);
return join(home, "worktrees", projectHash);
return join(baseRoot, projectHash);
}
export async function computeWorktreePath(
cwd: string,
slug: string,
paseoHome?: string,
worktreesRoot?: string,
): Promise<string> {
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
return join(worktreesRoot, slug);
const projectWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot);
return join(projectWorktreesRoot, slug);
}
function normalizePathForOwnership(input: string): string {
@@ -798,7 +825,7 @@ function resolveRepoRootFromGitCommonDir(commonDir: string): string {
export async function isPaseoOwnedWorktreeCwd(
cwd: string,
options?: { paseoHome?: string },
options?: WorktreeRootOptions,
): Promise<PaseoWorktreeOwnership> {
const resolvedCwd = normalizePathForOwnership(cwd);
@@ -813,10 +840,10 @@ export async function isPaseoOwnedWorktreeCwd(
// ignore
}
const paseoHome = options?.paseoHome ? resolve(options.paseoHome) : resolvePaseoHome();
const paseoWorktreesPrefix = normalizePathForOwnership(join(paseoHome, "worktrees")) + sep;
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
const paseoWorktreesPrefix = normalizePathForOwnership(worktreesBaseRoot) + sep;
// Ownership is defined by the path living under $PASEO_HOME/worktrees/<hash>/<slug>[/...].
// Ownership is defined by the path living under <worktrees-root>/<hash>/<slug>[/...].
// The <hash>/<slug> prefix is Paseo-private — nothing else writes there — so the
// path shape alone is sufficient proof of ownership, even when git has already
// forgotten about the worktree.
@@ -838,7 +865,7 @@ export async function isPaseoOwnedWorktreeCwd(
};
}
const worktreesRoot = join(paseoHome, "worktrees", parts[0]);
const worktreesRoot = join(worktreesBaseRoot, parts[0]);
return {
allowed: true,
...(repoRoot !== undefined ? { repoRoot } : {}),
@@ -901,17 +928,19 @@ function resolveWorktreeCreatedAtIso(worktreePath: string): string {
export async function listPaseoWorktrees({
cwd,
paseoHome,
worktreesRoot,
}: {
cwd: string;
paseoHome?: string;
worktreesRoot?: string;
}): Promise<PaseoWorktreeInfo[]> {
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
const projectWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot);
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep;
const rootPrefix = normalizePathForOwnership(projectWorktreesRoot) + sep;
return parseWorktreeList(stdout)
.map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
.filter((entry) => entry.path.startsWith(rootPrefix))
@@ -924,10 +953,12 @@ export async function resolveExistingWorktreeForSlug({
slug,
repoRoot,
paseoHome,
worktreesRoot,
}: ResolveExistingWorktreeForSlugOptions): Promise<WorktreeConfig | null> {
const worktrees = await listPaseoWorktrees({
cwd: repoRoot,
paseoHome,
worktreesRoot,
});
const slugSuffix = `${sep}${slug}`;
const existingWorktree = worktrees.find((worktree) => worktree.path.endsWith(slugSuffix));
@@ -952,7 +983,7 @@ export async function resolveExistingWorktreeForSlug({
export async function resolvePaseoWorktreeRootForCwd(
cwd: string,
options?: { paseoHome?: string },
options?: WorktreeRootOptions,
): Promise<{ repoRoot: string; worktreeRoot: string; worktreePath: string } | null> {
let gitCommonDir: string;
try {
@@ -961,7 +992,11 @@ export async function resolvePaseoWorktreeRootForCwd(
return null;
}
const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome);
const worktreesRoot = await getPaseoWorktreesRoot(
cwd,
options?.paseoHome,
options?.worktreesRoot,
);
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
let worktreeRoot: string | null = null;
@@ -987,6 +1022,7 @@ export async function resolvePaseoWorktreeRootForCwd(
const knownWorktrees = await listPaseoWorktrees({
cwd,
paseoHome: options?.paseoHome,
worktreesRoot: options?.worktreesRoot,
});
const match = knownWorktrees.find((entry) => entry.path === resolvedWorktreeRoot);
if (!match) {
@@ -1006,12 +1042,14 @@ export async function deletePaseoWorktree({
worktreeSlug,
worktreesRoot,
paseoHome,
worktreesBaseRoot,
}: {
cwd: string | null;
worktreePath?: string;
worktreeSlug?: string;
worktreesRoot?: string;
paseoHome?: string;
worktreesBaseRoot?: string;
}): Promise<void> {
if (!worktreePath && !worktreeSlug) {
throw new Error("worktreePath or worktreeSlug is required");
@@ -1024,7 +1062,7 @@ export async function deletePaseoWorktree({
if (worktreesRoot) {
resolvedWorktreesRoot = worktreesRoot;
} else if (cwd) {
resolvedWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
resolvedWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesBaseRoot);
} else {
throw new Error("cwd or worktreesRoot is required to delete a Paseo worktree");
}
@@ -1033,8 +1071,12 @@ export async function deletePaseoWorktree({
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
const resolvedRequested = normalizePathForOwnership(requestedPath);
const resolvedWorktree =
(await resolvePaseoWorktreeRootForCwd(requestedPath, { paseoHome }))?.worktreePath ??
resolvedRequested;
(
await resolvePaseoWorktreeRootForCwd(requestedPath, {
paseoHome,
worktreesRoot: worktreesBaseRoot,
})
)?.worktreePath ?? resolvedRequested;
if (!resolvedWorktree.startsWith(resolvedRoot)) {
throw new Error("Refusing to delete non-Paseo worktree");
@@ -1121,9 +1163,10 @@ export const createWorktree = async ({
worktreeSlug,
runSetup,
paseoHome,
worktreesRoot,
}: CreateWorktreeOptions): Promise<WorktreeConfig> => {
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome), worktreeSlug);
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
mkdirSync(dirname(worktreePath), { recursive: true });
// Also handle worktree path collision

View File

@@ -115,6 +115,16 @@
},
"additionalProperties": false
},
"worktrees": {
"type": "object",
"properties": {
"root": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": false
},
"providers": {
"type": "object",
"properties": {

View File

@@ -54,6 +54,20 @@ Agent providers, both the first-class ones Paseo ships with and custom entries y
See [Providers](/docs/providers) for the mental model and [Supported providers](/docs/supported-providers) for the full list of agents Paseo can launch. For pointing Claude at Anthropic-compatible endpoints (Z.AI, Alibaba/Qwen), multiple profiles, custom binaries, ACP agents, and the `additionalModels` merge behavior, see [Custom providers](/docs/custom-providers). The full field reference lives on GitHub at [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md).
## Worktrees
New worktrees are created under `$PASEO_HOME/worktrees` by default. To place new worktrees somewhere else, set `worktrees.root`:
```json
{
"worktrees": {
"root": "/mnt/fast/paseo-worktrees"
}
}
```
Relative paths are resolved against `PASEO_HOME`. Existing worktrees remain where they are; changing this setting only changes where Paseo creates and discovers Paseo-managed worktrees going forward.
## Voice
Voice is configured through `features.dictation` and `features.voiceMode`, with provider credentials under `providers`.

View File

@@ -11,7 +11,7 @@ Each agent runs in its own git worktree, a separate directory on a separate bran
## Layout and workflow
Worktrees live under `$PASEO_HOME/worktrees/`, grouped by a hash of the source checkout path. Each worktree gets a random slug; the branch name is chosen when you first launch an agent.
Worktrees live under `$PASEO_HOME/worktrees/` by default, grouped by a hash of the source checkout path. You can change the base directory with `worktrees.root` in `config.json`. Each worktree gets a random slug; the branch name is chosen when you first launch an agent.
```
~/.paseo/worktrees/
@@ -20,6 +20,16 @@ Worktrees live under `$PASEO_HOME/worktrees/`, grouped by a hash of the source c
└── bold-owl/
```
With a custom root, Paseo keeps the same hashed layout under that directory:
```json
{
"worktrees": {
"root": "/mnt/fast/paseo-worktrees"
}
}
```
1. Create a worktree, Paseo runs your setup hooks
2. Launch an agent, a branch is created or assigned
3. Review the diff against the base branch

View File

@@ -124,15 +124,15 @@ The desktop app's first-run hook (`installCli`) symlinks this to `~/.local/bin/p
Daemon-client architecture: the daemon owns agent lifecycle, state, and the WebSocket API. Tools, CLI, mobile, and desktop apps are all clients.
| | Default |
| -------------- | ------------------------------------------ |
| Listen address | `127.0.0.1:6767` (override `PASEO_LISTEN`) |
| Home | `~/.paseo` (override `PASEO_HOME`) |
| Daemon log | `$PASEO_HOME/daemon.log` |
| Agent state | `$PASEO_HOME/agents/<id>.json` |
| Worktrees | `$PASEO_HOME/worktrees/` |
| PID file | `$PASEO_HOME/paseo.pid` |
| Health | `GET http://127.0.0.1:6767/api/health` |
| | Default |
| -------------- | --------------------------------------------------------------- |
| Listen address | `127.0.0.1:6767` (override `PASEO_LISTEN`) |
| Home | `~/.paseo` (override `PASEO_HOME`) |
| Daemon log | `$PASEO_HOME/daemon.log` |
| Agent state | `$PASEO_HOME/agents/<id>.json` |
| Worktrees | `$PASEO_HOME/worktrees/` (or `worktrees.root` in `config.json`) |
| PID file | `$PASEO_HOME/paseo.pid` |
| Health | `GET http://127.0.0.1:6767/api/health` |
Debug order: