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 16:19:29 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 8c6abcb41f
commit d406c29e16
25 changed files with 324 additions and 42 deletions
@@ -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);
+28 -8
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 &&
@@ -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-"));
+62 -19
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