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:
co-authored by
Mohamed Boudra
parent
8c6abcb41f
commit
d406c29e16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user