Files
paseo/packages/server/src/utils/worktree.ts
T
cbbf6c1684 Carry local files and shared state into new worktrees (#2419)
* feat(server): support symlink worktree includes

* refactor(server): simplify worktree include handling

* fix(server): constrain worktree include traversal

* fix(server): clean up failed worktree branches

* fix(server): skip missing worktree include entries

* fix(server): make worktree includes best effort

* fix(server): skip failed worktree includes

* fix(server): harden worktree include planning

* fix(server): preserve reused worktree result shape

Materialization reports describe one creation attempt, not durable worktree identity. Carry them beside the worktree so newly created and reused results keep the same stable shape.

* fix(server): harden worktree include boundaries

Replace directory snapshots exactly, keep canonical Git metadata protected, report recovery skips, and only roll back branches owned before worktree creation.

* fix(server): follow safe include aliases

Traverse canonical in-checkout directory links during glob planning and treat coded revalidation failures as per-entry skips.

* fix(server): make include preflight race safe

Create fetched checkout refs atomically, retain overlapping copy entries for independent fallback, and protect the full managed-worktree base.

* fix(server): preserve staged recovery state

Validate completed directory snapshots, retain backups after failed restoration, and derive atomic ref guards from the repository object ID width.

* fix(server): preserve partial include progress

Keep safe glob matches, enforce recursive-directory types, validate staged roots, and retain OID guards through rollback.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 03:18:40 +02:00

1855 lines
53 KiB
TypeScript

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, isAbsolute, resolve, sep } from "path";
import net from "node:net";
import { createHash, randomUUID } from "node:crypto";
import stripAnsi from "strip-ansi";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnv,
} from "./string-command-shell.js";
import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
export {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema,
PaseoConfigSchema,
type PaseoConfig,
type PaseoConfigRaw,
} from "@getpaseo/protocol/paseo-config-schema";
import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import {
normalizeBaseRefName,
type PaseoWorktreeChangeRequestLookupTarget,
readPaseoWorktreeMetadata,
readPaseoWorktreeRuntimePort,
writePaseoWorktreeMetadata,
writePaseoWorktreeRuntimeMetadata,
} from "./worktree-metadata.js";
import { runGitCommand } from "./run-git-command.js";
import { spawnProcess } from "./spawn.js";
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, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
import {
materializeWorktreeIncludePlan,
readWorktreeIncludePlan,
type WorktreeIncludeSummary,
} from "./worktree-include.js";
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
const execFileAsync = promisify(execFile);
const READ_ONLY_GIT_ENV = {
GIT_OPTIONAL_LOCKS: "0",
} as const;
export interface WorktreeConfig {
branchName: string;
worktreePath: string;
}
export interface CreatedWorktreeConfig extends WorktreeConfig {
worktreeIncludeSummary: WorktreeIncludeSummary;
}
export interface WorktreeRuntimeEnv {
[key: string]: string;
PASEO_SOURCE_CHECKOUT_PATH: string;
PASEO_ROOT_PATH: string;
PASEO_WORKTREE_PATH: string;
PASEO_BRANCH_NAME: string;
PASEO_WORKTREE_PORT: string;
}
export interface WorktreeSetupCommandResult {
command: string;
cwd: string;
stdout: string;
stderr: string;
exitCode: number | null;
durationMs: number;
}
export type WorktreeSetupCommandProgressEvent =
| {
type: "command_started";
index: number;
total: number;
command: string;
cwd: string;
}
| {
type: "output";
index: number;
total: number;
command: string;
cwd: string;
stream: "stdout" | "stderr";
chunk: string;
}
| {
type: "command_completed";
index: number;
total: number;
command: string;
cwd: string;
exitCode: number | null;
durationMs: number;
stdout: string;
stderr: string;
};
export interface WorktreeTerminalConfig {
name?: string;
command: string;
}
export interface PlainScriptConfig {
type?: undefined;
command: string;
port?: undefined;
}
export interface ServiceScriptConfig {
type: "service";
command: string;
port?: number; // explicit port override, otherwise auto-assigned
}
export type ScriptConfig = PlainScriptConfig | ServiceScriptConfig;
export function isServiceScript(config: ScriptConfig): config is ServiceScriptConfig {
return "type" in config && config.type === "service";
}
export class WorktreeSetupError extends Error {
readonly results: WorktreeSetupCommandResult[];
constructor(message: string, results: WorktreeSetupCommandResult[]) {
super(message);
this.name = "WorktreeSetupError";
this.results = results;
}
}
export type WorktreeTeardownCommandResult = WorktreeSetupCommandResult;
export class WorktreeTeardownError extends Error {
readonly results: WorktreeTeardownCommandResult[];
constructor(message: string, results: WorktreeTeardownCommandResult[]) {
super(message);
this.name = "WorktreeTeardownError";
this.results = results;
}
}
export interface PaseoWorktreeInfo {
path: string;
createdAt: string;
branchName?: string;
head?: string;
}
export interface PaseoWorktreeOwnership {
allowed: boolean;
repoRoot?: string;
worktreeRoot?: string;
worktreePath?: string;
}
export interface PaseoWorktreeOwnershipOptions extends WorktreeRootOptions {
knownGitCommonDir?: string | null;
}
export interface WorktreeRootOptions {
paseoHome?: string;
worktreesRoot?: string;
}
export interface WorktreeCheckoutRef {
remoteName?: string;
remoteRef: string;
}
export type WorktreeSource =
| { kind: "branch-off"; baseBranch: string; branchName: string }
| { kind: "checkout-branch"; branchName: string }
| {
kind: "checkout-change-request";
forge: string;
changeRequestNumber: number;
headRef: string;
headRepositoryOwner?: string;
baseRefName: string;
checkoutRefs?: WorktreeCheckoutRef[];
localBranchName?: string;
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
| {
kind: "checkout-github-pr";
githubPrNumber: number;
headRef: string;
headRepositoryOwner?: string;
baseRefName: string;
checkoutRefs?: WorktreeCheckoutRef[];
localBranchName?: string;
pushRemoteUrl?: string;
trackOriginHead?: boolean;
};
export interface CreateWorktreeOptions {
cwd: string;
worktreeSlug: string;
source: WorktreeSource;
runSetup: boolean;
paseoHome?: string;
worktreesRoot?: string;
}
interface ResolveExistingWorktreeForSlugOptions {
slug: string;
repoRoot: string;
paseoHome?: string;
worktreesRoot?: string;
}
export class BranchAlreadyCheckedOutError extends Error {
readonly branchName: string;
constructor(branchName: string) {
super(`Branch already checked out: ${branchName}`);
this.name = "BranchAlreadyCheckedOutError";
this.branchName = branchName;
}
}
export class UnknownBranchError extends Error {
readonly branchName: string;
readonly cwd: string;
constructor(params: { branchName: string; cwd: string }) {
super(`Unknown branch: ${params.branchName}`);
this.name = "UnknownBranchError";
this.branchName = params.branchName;
this.cwd = params.cwd;
}
}
export class InvalidGitBranchNameError extends Error {
readonly branchName: string;
constructor(branchName: string) {
super(`Invalid branch name: Git rejected ref name '${branchName}'`);
this.name = "InvalidGitBranchNameError";
this.branchName = branchName;
}
}
export type ReadPaseoConfigResult =
| { ok: true; config: PaseoConfig | null }
| { ok: false; configPath: string; error: unknown };
export function readPaseoConfig(repoRoot: string): ReadPaseoConfigResult {
try {
const json = readPaseoConfigJson(repoRoot);
if (json === null) {
return { ok: true, config: null };
}
return { ok: true, config: PaseoConfigSchema.parse(json) };
} catch (error) {
return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error };
}
}
export function paseoConfigParseError(failure: { configPath: string; error: unknown }): Error {
const detail = failure.error instanceof Error ? failure.error.message : String(failure.error);
return new Error(`Failed to parse paseo.json at ${failure.configPath}: ${detail}`, {
cause: failure.error,
});
}
function readPaseoConfigOrThrow(repoRoot: string): PaseoConfig | null {
const result = readPaseoConfig(repoRoot);
if (!result.ok) {
throw paseoConfigParseError(result);
}
return result.config;
}
export function getWorktreeSetupCommands(repoRoot: string): string[] {
return readPaseoConfigOrThrow(repoRoot)?.worktree?.setup ?? [];
}
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
return readPaseoConfigOrThrow(repoRoot)?.worktree?.teardown ?? [];
}
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
const terminals = readPaseoConfigOrThrow(repoRoot)?.worktree?.terminals;
if (!Array.isArray(terminals) || terminals.length === 0) {
return [];
}
const specs: WorktreeTerminalConfig[] = [];
for (const terminal of terminals) {
if (!terminal || typeof terminal !== "object") {
continue;
}
const rawCommand = terminal.command;
if (typeof rawCommand !== "string") {
continue;
}
const command = rawCommand.trim();
if (!command) {
continue;
}
const rawName = terminal.name;
const name =
typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : undefined;
specs.push({
...(name ? { name } : {}),
command,
});
}
return specs;
}
export function getScriptConfigs(config: PaseoConfig | null): Map<string, ScriptConfig> {
const scripts = config?.scripts;
if (!scripts || typeof scripts !== "object") {
return new Map();
}
const result = new Map<string, ScriptConfig>();
for (const [name, entry] of Object.entries(scripts)) {
if (!entry || typeof entry !== "object") {
continue;
}
const rawCommand = entry.command;
if (typeof rawCommand !== "string") {
continue;
}
const command = rawCommand.trim();
if (!command) {
continue;
}
const scriptConfig: ScriptConfig =
entry.type === "service"
? {
type: "service",
command,
}
: { command };
if (
isServiceScript(scriptConfig) &&
typeof entry.port === "number" &&
Number.isFinite(entry.port)
) {
scriptConfig.port = entry.port;
}
result.set(name, scriptConfig);
}
return result;
}
export function processCarriageReturns(text: string): string {
if (!text.includes("\r")) {
return text;
}
const output: string[] = [];
let line: string[] = [];
let cursor = 0;
const flushLine = () => {
output.push(line.join(""));
line = [];
cursor = 0;
};
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === "\r") {
if (text[index + 1] === "\n") {
flushLine();
output.push("\n");
index += 1;
continue;
}
cursor = 0;
continue;
}
if (char === "\n") {
flushLine();
output.push("\n");
continue;
}
if (cursor < line.length) {
line[cursor] = char;
} else {
line.push(char);
}
cursor += 1;
}
if (line.length > 0) {
output.push(line.join(""));
}
return output.join("");
}
async function execSetupCommand(
command: string,
options: { cwd: string; env: NodeJS.ProcessEnv },
): Promise<WorktreeSetupCommandResult> {
const startedAt = Date.now();
const shellInvocation = buildStringCommandShellInvocation({ command });
try {
const { stdout, stderr } = await execFileAsync(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
});
return {
command,
cwd: options.cwd,
stdout: stdout ?? "",
stderr: stderr ?? "",
exitCode: 0,
durationMs: Date.now() - startedAt,
};
} catch (error) {
const execErr = error as { stdout?: string; stderr?: string; code?: unknown } | undefined;
return {
command,
cwd: options.cwd,
stdout: execErr?.stdout ?? "",
stderr: execErr?.stderr ?? (error instanceof Error ? error.message : String(error)),
exitCode: typeof execErr?.code === "number" ? execErr.code : null,
durationMs: Date.now() - startedAt,
};
}
}
async function execSetupCommandStreamed(options: {
command: string;
cwd: string;
env: NodeJS.ProcessEnv;
index: number;
total: number;
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
}): Promise<WorktreeSetupCommandResult> {
return new Promise((resolvePromise) => {
const startedAt = Date.now();
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
let settled = false;
const emitOutput = (stream: "stdout" | "stderr", chunk: string) => {
const text = stripAnsi(chunk);
if (!text) {
return;
}
if (stream === "stdout") {
stdoutChunks.push(text);
} else {
stderrChunks.push(text);
}
options.onEvent?.({
type: "output",
index: options.index,
total: options.total,
command: options.command,
cwd: options.cwd,
stream,
chunk: text,
});
};
const finish = (exitCode: number | null) => {
if (settled) {
return;
}
settled = true;
const result: WorktreeSetupCommandResult = {
command: options.command,
cwd: options.cwd,
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join(""),
exitCode,
durationMs: Date.now() - startedAt,
};
options.onEvent?.({
type: "command_completed",
index: options.index,
total: options.total,
command: options.command,
cwd: options.cwd,
exitCode: result.exitCode,
durationMs: result.durationMs,
stdout: result.stdout,
stderr: result.stderr,
});
resolvePromise(result);
};
options.onEvent?.({
type: "command_started",
index: options.index,
total: options.total,
command: options.command,
cwd: options.cwd,
});
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const child = spawnProcess(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.on("data", (chunk: Buffer | string) => {
emitOutput("stdout", chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
emitOutput("stderr", chunk.toString());
});
child.on("error", (error) => {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
finish(null);
});
child.on("close", (code) => {
finish(typeof code === "number" ? code : null);
});
});
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolvePromise, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire available port")));
return;
}
server.close((error) => {
if (error) {
reject(error);
return;
}
resolvePromise(address.port);
});
});
});
}
async function assertPortAvailable(port: number): Promise<void> {
await new Promise<void>((resolvePromise, reject) => {
const server = net.createServer();
server.once("error", (error: NodeJS.ErrnoException) => {
let message: string;
if (error?.code === "EADDRINUSE") {
message = `Persisted worktree port ${port} is already in use`;
} else if (error instanceof Error) {
message = error.message;
} else {
message = String(error);
}
reject(new Error(message));
});
server.listen(port, () => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolvePromise();
});
});
});
}
async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<string> {
try {
const commonDir = await getGitCommonDir(worktreePath);
const normalizedCommonDir = normalizePathForOwnership(commonDir);
// Normal repo/worktree: common dir is <repoRoot>/.git
if (basename(normalizedCommonDir) === ".git") {
return dirname(normalizedCommonDir);
}
// Bare repo: common dir is the repo dir itself
return normalizedCommonDir;
} catch {
// Fallback: best-effort resolve toplevel (will be the worktree root in typical cases)
try {
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
cwd: worktreePath,
envOverlay: READ_ONLY_GIT_ENV,
});
const topLevel = parseGitRevParsePath(stdout);
if (topLevel) {
return normalizePathForOwnership(topLevel);
}
} catch {
// ignore
}
return normalizePathForOwnership(worktreePath);
}
}
export async function runWorktreeSetupCommands(options: {
worktreePath: string;
branchName: string;
cleanupOnFailure: boolean;
repoRootPath?: string;
runtimeEnv?: WorktreeRuntimeEnv;
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
}): Promise<WorktreeSetupCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo)
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
if (setupCommands.length === 0) {
return [];
}
const runtimeEnv =
options.runtimeEnv ??
(await resolveWorktreeRuntimeEnv({
worktreePath: options.worktreePath,
branchName: options.branchName,
...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}),
}));
const setupEnv = createStringCommandShellEnv(createExternalProcessEnv(process.env, runtimeEnv));
const results: WorktreeSetupCommandResult[] = [];
for (const [index, cmd] of setupCommands.entries()) {
const result = options.onEvent
? await execSetupCommandStreamed({
command: cmd,
cwd: options.worktreePath,
env: setupEnv,
index: index + 1,
total: setupCommands.length,
onEvent: options.onEvent,
})
: await execSetupCommand(cmd, {
cwd: options.worktreePath,
env: setupEnv,
});
results.push(result);
if (result.exitCode !== 0) {
if (options.cleanupOnFailure) {
try {
await runGitCommand(["worktree", "remove", options.worktreePath, "--force"], {
cwd: options.worktreePath,
timeout: 120_000,
});
} catch {
rmSync(options.worktreePath, { recursive: true, force: true });
}
}
throw new WorktreeSetupError(
`Worktree setup command failed: ${cmd}\n${result.stderr}`.trim(),
results,
);
}
}
return results;
}
async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<string> {
try {
const { stdout } = await runGitCommand(["branch", "--show-current"], {
cwd: worktreePath,
envOverlay: READ_ONLY_GIT_ENV,
});
const branchName = stdout.trim();
if (branchName.length > 0) {
return branchName;
}
} catch {
// ignore
}
return basename(worktreePath);
}
export async function resolveWorktreeRuntimeEnv(options: {
worktreePath: string;
branchName?: string;
repoRootPath?: string;
}): Promise<WorktreeRuntimeEnv> {
const repoRootPath =
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
const branchName =
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
if (worktreePort === null) {
worktreePort = await getAvailablePort();
const metadata = readPaseoWorktreeMetadata(options.worktreePath);
if (metadata) {
writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
}
} else {
await assertPortAvailable(worktreePort);
}
return {
// Source checkout path is the original git repo root (shared across worktrees), not the
// worktree itself. This allows setup scripts to copy local files (e.g. .env) from the
// source checkout.
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
// Backward-compatible alias.
PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: branchName,
PASEO_WORKTREE_PORT: String(worktreePort),
};
}
export async function runWorktreeTeardownCommands(options: {
worktreePath: string;
teardownCwd?: string;
branchName?: string;
repoRootPath?: string;
}): Promise<WorktreeTeardownCommandResult[]> {
const teardownCwd = options.teardownCwd ?? options.worktreePath;
if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) {
throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`);
}
const teardownCommands = getWorktreeTeardownCommands(teardownCwd);
if (teardownCommands.length === 0) {
return [];
}
const repoRootPath =
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
const branchName =
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv(
createExternalProcessEnv(process.env, {
// Source checkout path is the original git repo root (shared across worktrees), not the
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
// from the source checkout.
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
// Backward-compatible alias.
PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: branchName,
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
}),
);
const results: WorktreeTeardownCommandResult[] = [];
for (const cmd of teardownCommands) {
const result = await execSetupCommand(cmd, {
cwd: teardownCwd,
env: teardownEnv,
});
results.push(result);
if (result.exitCode !== 0) {
throw new WorktreeTeardownError(
`Worktree teardown command failed: ${cmd}\n${result.stderr}`.trim(),
results,
);
}
}
return results;
}
export async function seedPaseoConfigFile(options: {
sourceCwd: string;
targetCwd: string;
}): Promise<void> {
const sourceConfigPath = join(options.sourceCwd, "paseo.json");
const targetConfigPath = join(options.targetCwd, "paseo.json");
try {
await stat(targetConfigPath);
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await copyFile(sourceConfigPath, targetConfigPath).catch((error) => {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
});
}
/**
* Get the git common directory (shared across worktrees) for a given cwd.
* This is where refs, objects, etc. are stored.
*/
export async function getGitCommonDir(cwd: string): Promise<string> {
const { stdout } = await runGitCommand(["rev-parse", "--git-common-dir"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
const commonDir = resolveGitRevParsePath(cwd, stdout);
if (!commonDir) {
throw new Error("Not in a git repository");
}
return commonDir;
}
const WORKTREE_PROJECT_HASH_LENGTH = 8;
function deriveShortAlphanumericHash(value: string): string {
const digest = createHash("sha256").update(value).digest();
let hashValue = 0n;
for (let index = 0; index < 8; index += 1) {
hashValue = (hashValue << 8n) | BigInt(digest[index] ?? 0);
}
return hashValue.toString(36).padStart(13, "0").slice(0, WORKTREE_PROJECT_HASH_LENGTH);
}
export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
try {
const commonDir = await getGitCommonDir(cwd);
const normalizedCommonDir = normalizePathForOwnership(commonDir);
const repoRoot =
basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir;
return deriveShortAlphanumericHash(repoRoot);
} catch {
return deriveShortAlphanumericHash(normalizePathForOwnership(cwd));
}
}
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(baseRoot, projectHash);
}
export async function computeWorktreePath(
cwd: string,
slug: string,
paseoHome?: string,
worktreesRoot?: string,
): Promise<string> {
const projectWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot);
return join(projectWorktreesRoot, slug);
}
export function mapWorkspaceCwdToWorktree(input: {
sourceWorktreePath: string;
workspaceCwd: string;
targetWorktreePath: string;
}): string {
const relativeWorkspaceCwd = getRealpathAwareRelativePath(
input.sourceWorktreePath,
input.workspaceCwd,
);
if (relativeWorkspaceCwd === null) {
throw new Error(`Workspace cwd is outside its source worktree: ${input.workspaceCwd}`);
}
return mapWorkspaceRelativeCwdToWorktree({
relativeWorkspaceCwd,
targetWorktreePath: input.targetWorktreePath,
});
}
export function mapWorkspaceRelativeCwdToWorktree(input: {
relativeWorkspaceCwd: string;
targetWorktreePath: string;
}): string {
const mappedCwd = resolve(input.targetWorktreePath, input.relativeWorkspaceCwd);
if (!isPathInsideRoot(input.targetWorktreePath, mappedCwd)) {
throw new Error(`Workspace cwd escapes its target worktree: ${input.relativeWorkspaceCwd}`);
}
return mappedCwd;
}
function normalizePathForOwnership(input: string): string {
try {
return realpathSync(input);
} catch {
return resolve(input);
}
}
function resolveRepoRootFromGitCommonDir(commonDir: string): string {
const normalizedCommonDir = normalizePathForOwnership(commonDir);
return basename(normalizedCommonDir) === ".git"
? dirname(normalizedCommonDir)
: normalizedCommonDir;
}
export async function isPaseoOwnedWorktreeCwd(
cwd: string,
options?: PaseoWorktreeOwnershipOptions,
): Promise<PaseoWorktreeOwnership> {
const resolvedCwd = normalizePathForOwnership(cwd);
// repoRoot is best-effort: git may be unreachable from the worktree (e.g. a
// previous archive attempt removed the admin dir before the working tree
// could be fully cleaned up). We still want to allow archiving in that case.
let repoRoot: string | undefined;
if (options?.knownGitCommonDir) {
repoRoot = resolveRepoRootFromGitCommonDir(options.knownGitCommonDir);
} else if (options?.knownGitCommonDir === undefined) {
try {
const gitCommonDir = await getGitCommonDir(cwd);
repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir);
} catch {
// ignore
}
}
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd);
// 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.
if (relativePath === null) {
return {
allowed: false,
...(repoRoot !== undefined ? { repoRoot } : {}),
worktreePath: resolvedCwd,
};
}
const parts = relativePath.split(sep).filter((part) => part.length > 0);
if (parts.length < 2) {
return {
allowed: false,
...(repoRoot !== undefined ? { repoRoot } : {}),
worktreePath: resolvedCwd,
};
}
const worktreesRoot = join(worktreesBaseRoot, parts[0]);
return {
allowed: true,
...(repoRoot !== undefined ? { repoRoot } : {}),
worktreeRoot: worktreesRoot,
worktreePath: join(worktreesRoot, parts[1]),
};
}
type ParsedPaseoWorktreeInfo = Omit<PaseoWorktreeInfo, "createdAt">;
function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] {
const entries: ParsedPaseoWorktreeInfo[] = [];
let current: ParsedPaseoWorktreeInfo | null = null;
for (const line of output.split("\n")) {
if (line.startsWith("worktree ")) {
if (current?.path) {
entries.push(current);
}
current = { path: line.slice("worktree ".length).trim() };
continue;
}
if (!current) {
continue;
}
if (line.startsWith("branch ")) {
const ref = line.slice("branch ".length).trim();
current.branchName = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
} else if (line.startsWith("HEAD ")) {
current.head = line.slice("HEAD ".length).trim();
} else if (line.trim().length === 0) {
if (current.path) {
entries.push(current);
}
current = null;
}
}
if (current?.path) {
entries.push(current);
}
return entries;
}
function resolveWorktreeCreatedAtIso(worktreePath: string): string {
try {
const stats = statSync(worktreePath);
const birthtimeMs = stats.birthtimeMs;
const createdAtMs =
Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs;
return new Date(createdAtMs).toISOString();
} catch {
return new Date(0).toISOString();
}
}
export async function listPaseoWorktrees({
cwd,
paseoHome,
worktreesRoot,
}: {
cwd: string;
paseoHome?: string;
worktreesRoot?: string;
}): Promise<PaseoWorktreeInfo[]> {
const projectWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot);
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
return parseWorktreeList(stdout)
.map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
.filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null)
.map((entry) =>
Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }),
);
}
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));
if (!existingWorktree) {
return null;
}
const { stdout } = await runGitCommand(["branch", "--show-current"], {
cwd: existingWorktree.path,
envOverlay: READ_ONLY_GIT_ENV,
});
const branchName = stdout.trim();
if (!branchName) {
throw new Error(`Unable to resolve branch for existing worktree: ${existingWorktree.path}`);
}
return {
branchName,
worktreePath: existingWorktree.path,
};
}
export interface DeletePaseoWorktreeOptions {
cwd: string | null;
worktreePath?: string;
teardownCwds?: string[];
worktreeSlug?: string;
worktreesRoot?: string;
paseoHome?: string;
worktreesBaseRoot?: string;
}
export async function deletePaseoWorktree({
cwd,
worktreePath,
teardownCwds,
worktreeSlug,
worktreesRoot,
paseoHome,
worktreesBaseRoot,
}: DeletePaseoWorktreeOptions): Promise<void> {
if (!worktreePath && !worktreeSlug) {
throw new Error("worktreePath or worktreeSlug is required");
}
// Resolve the worktrees-root. With a repo cwd we hash it the normal way; if
// git has forgotten about the worktree we expect the caller to hand us the
// path-derived worktreesRoot from the ownership check.
let resolvedWorktreesRoot: string;
if (worktreesRoot) {
resolvedWorktreesRoot = worktreesRoot;
} else if (cwd) {
resolvedWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesBaseRoot);
} else {
throw new Error("cwd or worktreesRoot is required to delete a Paseo worktree");
}
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
const resolvedRequested = normalizePathForOwnership(requestedPath);
const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, {
paseoHome,
worktreesRoot: worktreesBaseRoot,
});
const resolvedWorktree =
ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested;
const relativeWorktreePath = getRealpathAwareRelativePath(
resolvedWorktreesRoot,
resolvedWorktree,
);
if (relativeWorktreePath === null || relativeWorktreePath === "") {
throw new Error("Refusing to delete non-Paseo worktree");
}
if (await pathExists(resolvedWorktree)) {
for (const teardownCwd of teardownCwds ?? [resolvedWorktree]) {
await runWorktreeTeardownCommands({
worktreePath: resolvedWorktree,
teardownCwd,
});
}
}
if (cwd) {
try {
await runGitCommand(["worktree", "remove", resolvedWorktree, "--force"], {
cwd,
timeout: 120_000,
});
} catch {
// `git worktree remove` fails if the admin dir is already gone (e.g. a
// prior archive attempt removed it before the working tree could be
// fully cleaned up), or if the repo root has moved. Fall through to the
// rm retry loop below so the operation stays idempotent.
}
}
await removeDirectoryWithRetries(resolvedWorktree);
if (cwd) {
try {
await runGitCommand(["worktree", "prune"], { cwd, timeout: 30_000 });
} catch {
// not critical; git will prune lazily
}
}
}
export interface RollbackCreatedPaseoWorktreeOptions extends DeletePaseoWorktreeOptions {
createdBranchName?: string;
expectedOid?: string;
}
async function removeCreatedWorktreeBranch(options: {
createdBranchName?: string;
expectedOid?: string;
cwd?: string | null;
}): Promise<void> {
if (!options.createdBranchName || !options.cwd) {
return;
}
if (!(await localBranchExists(options.cwd, options.createdBranchName))) {
return;
}
if (options.expectedOid) {
await runGitCommand(
["update-ref", "-d", `refs/heads/${options.createdBranchName}`, options.expectedOid],
{ cwd: options.cwd },
);
} else {
await runGitCommand(["branch", "--delete", "--force", options.createdBranchName], {
cwd: options.cwd,
});
}
}
async function rollbackCreatedWorktreeBranch(
options: {
createdBranchName?: string;
expectedOid?: string;
cwd: string;
},
cause: unknown,
): Promise<never> {
let cleanupError: unknown;
try {
await removeCreatedWorktreeBranch(options);
} catch (error) {
cleanupError = error;
}
if (cleanupError) {
const failure = new Error(
`${cause instanceof Error ? cause.message : "Worktree workflow failed"}; rollback also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
{ cause },
);
Object.assign(failure, { cleanupError });
throw failure;
}
throw cause;
}
export async function rollbackCreatedPaseoWorktree(
options: RollbackCreatedPaseoWorktreeOptions,
cause: unknown,
): Promise<never> {
let cleanupError: unknown;
try {
await deletePaseoWorktree(options);
await removeCreatedWorktreeBranch(options);
} catch (error) {
cleanupError = error;
}
if (cleanupError) {
const failure = new Error(
`${cause instanceof Error ? cause.message : "Worktree workflow failed"}; rollback also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
{ cause },
);
Object.assign(failure, { cleanupError });
throw failure;
}
throw cause;
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
async function removeDirectoryWithRetries(path: string): Promise<void> {
if (!(await pathExists(path))) {
return;
}
const delaysMs = [0, 100, 300, 700, 1500];
let lastError: unknown = null;
for (const delay of delaysMs) {
if (delay > 0) {
await new Promise((resolvePromise) => setTimeout(resolvePromise, delay));
}
try {
await rm(path, { recursive: true, force: true });
if (!(await pathExists(path))) {
return;
}
lastError = new Error(`Directory still present after rm: ${path}`);
} catch (error) {
lastError = error;
}
}
if (await pathExists(path)) {
throw lastError instanceof Error
? lastError
: new Error(`Failed to remove worktree directory: ${path}`);
}
}
/**
* Create a git worktree with proper naming conventions
*/
export const createWorktree = async ({
cwd,
source,
worktreeSlug,
runSetup,
paseoHome,
worktreesRoot,
}: CreateWorktreeOptions): Promise<CreatedWorktreeConfig> => {
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
const { worktreeIncludePlan, worktreePath } = await (async () => {
try {
const paseoWorktreesBaseRoot = resolvePaseoWorktreesBaseRoot({ paseoHome, worktreesRoot });
const paseoWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot);
const includePlan = await readWorktreeIncludePlan({
sourceRoot: cwd,
excludedSourceRoots: [paseoWorktreesBaseRoot],
});
const requestedWorktreePath = join(paseoWorktreesRoot, worktreeSlug);
mkdirSync(dirname(requestedWorktreePath), { recursive: true });
// Also handle worktree path collision
let finalWorktreePath = requestedWorktreePath;
let pathSuffix = 1;
while (existsSync(finalWorktreePath)) {
finalWorktreePath = `${requestedWorktreePath}-${pathSuffix}`;
pathSuffix++;
}
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
cwd,
timeout: 120_000,
});
return {
worktreeIncludePlan: includePlan,
worktreePath: normalizePathForOwnership(finalWorktreePath),
};
} catch (error) {
return rollbackCreatedWorktreeBranch(
{
cwd,
createdBranchName: sourcePlan.createdBranchNameBeforeWorktreeAdd,
expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd,
},
error,
);
}
})();
let worktreeIncludeSummary: WorktreeIncludeSummary = {
materialized: 0,
skipped: [...worktreeIncludePlan.skipped],
};
try {
if (sourcePlan.pushRemote) {
await configureWorktreePushRemote({
cwd,
branchName: sourcePlan.branchName,
remote: sourcePlan.pushRemote,
});
}
if (sourcePlan.trackingRemote) {
await configureWorktreeTrackingRemote({
cwd,
branchName: sourcePlan.branchName,
remote: sourcePlan.trackingRemote,
});
}
writePaseoWorktreeMetadata(worktreePath, {
baseRefName: sourcePlan.metadataBaseRefName,
...(sourcePlan.changeRequestLookupTarget
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
: {}),
});
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
const materialization = await materializeWorktreeIncludePlan({
plan: worktreeIncludePlan,
worktreeRoot: worktreePath,
});
worktreeIncludeSummary = {
materialized: materialization.materialized,
skipped: [...worktreeIncludePlan.skipped, ...materialization.skipped],
};
} catch (error) {
await rollbackCreatedPaseoWorktree(
{
cwd,
worktreePath,
teardownCwds: [],
paseoHome,
worktreesBaseRoot: worktreesRoot,
createdBranchName: sourcePlan.createdBranchName,
expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd,
},
error,
);
}
if (runSetup) {
await runWorktreeSetupCommands({
worktreePath,
branchName: sourcePlan.branchName,
cleanupOnFailure: true,
});
}
return {
branchName: sourcePlan.branchName,
worktreeIncludeSummary,
worktreePath,
};
};
interface ResolveWorktreeSourcePlanOptions {
cwd: string;
source: WorktreeSource;
desiredSlug: string;
}
interface WorktreeSourcePlan {
branchName: string;
createdBranchName?: string;
createdBranchNameBeforeWorktreeAdd?: string;
createdBranchOidBeforeWorktreeAdd?: string;
metadataBaseRefName: string;
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
addArguments: string[];
pushRemote?: {
name: string;
url: string;
headRef: string;
track: boolean;
};
trackingRemote?: {
name: string;
headRef: string;
};
}
type ChangeRequestWorktreeSource = Extract<
WorktreeSource,
{ kind: "checkout-change-request" | "checkout-github-pr" }
>;
async function resolveWorktreeSourcePlan({
cwd,
source,
desiredSlug,
}: ResolveWorktreeSourcePlanOptions): Promise<WorktreeSourcePlan> {
switch (source.kind) {
case "branch-off": {
const branchName = source.branchName;
validateWorktreeBranchName(branchName);
const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch);
const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch);
const branchExists = await localBranchExists(cwd, branchName);
const base = branchExists ? branchName : resolvedBaseBranch;
const candidateBranch = branchExists ? desiredSlug : branchName;
const newBranchName = await resolveUniqueLocalBranchName(cwd, candidateBranch);
return {
branchName: newBranchName,
createdBranchName: newBranchName,
metadataBaseRefName: normalizedBaseBranch,
addArguments: ["-b", newBranchName, "--no-track", base],
};
}
case "checkout-branch":
return resolveCheckoutBranchWorktreeSourcePlan({ cwd, branchName: source.branchName });
case "checkout-change-request":
case "checkout-github-pr":
return resolveChangeRequestWorktreeSourcePlan({ cwd, source });
}
}
async function resolveCheckoutBranchWorktreeSourcePlan(options: {
branchName: string;
cwd: string;
}): Promise<WorktreeSourcePlan> {
await validateExistingWorktreeBranchName(options.cwd, options.branchName);
const needsFetch = !(await localBranchExists(options.cwd, options.branchName));
let createdBranchOid: string | undefined;
if (needsFetch) {
try {
createdBranchOid = await fetchNewLocalBranchAtomically({
cwd: options.cwd,
localBranchName: options.branchName,
remoteName: "origin",
remoteRef: `refs/heads/${options.branchName}`,
});
} catch {
throw new UnknownBranchError({ branchName: options.branchName, cwd: options.cwd });
}
}
try {
if (await isBranchCheckedOut(options.cwd, options.branchName)) {
throw new BranchAlreadyCheckedOutError(options.branchName);
}
} catch (error) {
if (!needsFetch) {
throw error;
}
return rollbackCreatedWorktreeBranch(
{
cwd: options.cwd,
createdBranchName: options.branchName,
expectedOid: createdBranchOid,
},
error,
);
}
return {
branchName: options.branchName,
...(needsFetch
? {
createdBranchName: options.branchName,
createdBranchNameBeforeWorktreeAdd: options.branchName,
createdBranchOidBeforeWorktreeAdd: createdBranchOid,
}
: {}),
metadataBaseRefName: options.branchName,
addArguments: [options.branchName],
};
}
async function resolveChangeRequestWorktreeSourcePlan(options: {
cwd: string;
source: ChangeRequestWorktreeSource;
}): Promise<WorktreeSourcePlan> {
const { cwd, source } = options;
const localBranchCandidate = source.localBranchName ?? source.headRef;
await validateExistingWorktreeBranchName(cwd, localBranchCandidate);
const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate);
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
const changeRequestNumber =
source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber;
let createdBranchOid: string | undefined;
try {
createdBranchOid = await fetchWorktreeCheckoutRefs({
cwd,
localBranchName,
checkoutRefs: source.checkoutRefs ?? [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
});
const shouldTrackOriginHead = source.trackOriginHead === true;
const trackingRemote = shouldTrackOriginHead
? await tryFetchWorktreeTrackingRemote({
cwd,
remoteName: "origin",
headRef: source.headRef,
})
: undefined;
const remotePlan: Pick<WorktreeSourcePlan, "pushRemote" | "trackingRemote"> = {};
if (source.pushRemoteUrl) {
const remoteName = `paseo-pr-${changeRequestNumber}`;
remotePlan.pushRemote = {
name: remoteName,
url: source.pushRemoteUrl,
headRef: source.headRef,
track: true,
};
} else if (shouldTrackOriginHead && localBranchName !== source.headRef) {
const originUrl = await getWorktreeRemotePushUrl(cwd, "origin");
if (originUrl) {
remotePlan.pushRemote = {
name: `paseo-pr-${changeRequestNumber}`,
url: originUrl,
headRef: source.headRef,
track: false,
};
}
}
if (trackingRemote) {
remotePlan.trackingRemote = trackingRemote;
}
return {
branchName: localBranchName,
createdBranchName: localBranchName,
createdBranchNameBeforeWorktreeAdd: localBranchName,
createdBranchOidBeforeWorktreeAdd: createdBranchOid,
metadataBaseRefName: normalizedBaseRefName,
changeRequestLookupTarget: {
headRef: source.headRef,
...(source.headRepositoryOwner ? { headRepositoryOwner: source.headRepositoryOwner } : {}),
changeRequestNumber,
},
addArguments: [localBranchName],
...remotePlan,
};
} catch (error) {
return rollbackCreatedWorktreeBranch(
{ cwd, createdBranchName: localBranchName, expectedOid: createdBranchOid },
error,
);
}
}
async function configureWorktreePushRemote(options: {
cwd: string;
branchName: string;
remote: {
name: string;
url: string;
headRef: string;
track: boolean;
};
}): Promise<void> {
await runGitCommand(["config", `remote.${options.remote.name}.url`, options.remote.url], {
cwd: options.cwd,
});
await runGitCommand(
["config", `remote.${options.remote.name}.push`, `HEAD:refs/heads/${options.remote.headRef}`],
{ cwd: options.cwd },
);
await runGitCommand(["config", `branch.${options.branchName}.pushRemote`, options.remote.name], {
cwd: options.cwd,
});
if (!options.remote.track) {
return;
}
await runGitCommand(
[
"config",
`remote.${options.remote.name}.fetch`,
`+refs/heads/${options.remote.headRef}:refs/remotes/${options.remote.name}/${options.remote.headRef}`,
],
{ cwd: options.cwd },
);
const trackingRemote = await tryFetchWorktreeTrackingRemote({
cwd: options.cwd,
remoteName: options.remote.name,
headRef: options.remote.headRef,
});
if (trackingRemote) {
await configureWorktreeTrackingRemote({
cwd: options.cwd,
branchName: options.branchName,
remote: trackingRemote,
});
}
}
async function fetchWorktreeCheckoutRefs(options: {
cwd: string;
localBranchName: string;
checkoutRefs: WorktreeCheckoutRef[];
}): Promise<string> {
let lastResult:
| Awaited<ReturnType<typeof runGitCommand>>
| { stderr: string; stdout: string; exitCode: number | null }
| null = null;
for (const checkoutRef of options.checkoutRefs) {
try {
return await fetchNewLocalBranchAtomically({
cwd: options.cwd,
localBranchName: options.localBranchName,
remoteName: checkoutRef.remoteName ?? "origin",
remoteRef: checkoutRef.remoteRef,
});
} catch (error) {
lastResult =
error instanceof Error ? { stderr: error.message, stdout: "", exitCode: 1 } : null;
}
}
const attemptedRefs = options.checkoutRefs
.map((checkoutRef) => `${checkoutRef.remoteName ?? "origin"} ${checkoutRef.remoteRef}`)
.join(", ");
throw new Error(
`Unable to fetch change request refs for worktree branch ${options.localBranchName}: ${attemptedRefs}${lastResult?.stderr ? `\n${lastResult.stderr}` : ""}`,
);
}
async function fetchNewLocalBranchAtomically(options: {
cwd: string;
localBranchName: string;
remoteName: string;
remoteRef: string;
}): Promise<string> {
const temporaryRef = `refs/paseo/worktree-fetch/${randomUUID()}`;
try {
await runGitCommand(
["fetch", options.remoteName, `${options.remoteRef}:${temporaryRef}`, "--force"],
{ cwd: options.cwd, timeout: 120_000 },
);
const { stdout } = await runGitCommand(["rev-parse", "--verify", temporaryRef], {
cwd: options.cwd,
});
const oid = stdout.trim();
const nullOid = "0".repeat(oid.length);
await runGitCommand(["update-ref", `refs/heads/${options.localBranchName}`, oid, nullOid], {
cwd: options.cwd,
});
return oid;
} finally {
await runGitCommand(["update-ref", "-d", temporaryRef], {
cwd: options.cwd,
acceptExitCodes: [0, 1, 128],
});
}
}
async function tryFetchWorktreeTrackingRemote(options: {
cwd: string;
remoteName: string;
headRef: string;
}): Promise<{ name: string; headRef: string } | undefined> {
const result = await runGitCommand(
[
"fetch",
options.remoteName,
`+refs/heads/${options.headRef}:refs/remotes/${options.remoteName}/${options.headRef}`,
],
{
cwd: options.cwd,
timeout: 120_000,
acceptExitCodes: [0, 1, 128],
},
);
if (result.exitCode !== 0) {
return undefined;
}
await ensureRemoteFetchesBranch(options);
return { name: options.remoteName, headRef: options.headRef };
}
async function ensureRemoteFetchesBranch(options: {
cwd: string;
remoteName: string;
headRef: string;
}): Promise<void> {
const configKey = `remote.${options.remoteName}.fetch`;
const exactRefspec = `refs/heads/${options.headRef}:refs/remotes/${options.remoteName}/${options.headRef}`;
const wildcardRefspec = `refs/heads/*:refs/remotes/${options.remoteName}/*`;
const { stdout } = await runGitCommand(["config", "--get-all", configKey], {
cwd: options.cwd,
acceptExitCodes: [0, 1],
});
const alreadyTracked = stdout
.split("\n")
.map((refspec) => refspec.trim().replace(/^\+/, ""))
.some((refspec) => refspec === exactRefspec || refspec === wildcardRefspec);
if (alreadyTracked) {
return;
}
await runGitCommand(["config", "--add", configKey, `+${exactRefspec}`], { cwd: options.cwd });
}
async function getWorktreeRemotePushUrl(
cwd: string,
remoteName: string,
): Promise<string | undefined> {
try {
const { stdout } = await runGitCommand(["remote", "get-url", "--push", remoteName], {
cwd,
});
const url = stdout.trim();
return url.length > 0 ? url : undefined;
} catch {
return undefined;
}
}
async function configureWorktreeTrackingRemote(options: {
cwd: string;
branchName: string;
remote: {
name: string;
headRef: string;
};
}): Promise<void> {
await runGitCommand(
[
"branch",
"--set-upstream-to",
`${options.remote.name}/${options.remote.headRef}`,
options.branchName,
],
{ cwd: options.cwd },
);
}
function validateWorktreeBranchName(branchName: string): void {
const validation = validateBranchSlug(branchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
}
async function validateExistingWorktreeBranchName(cwd: string, branchName: string): Promise<void> {
const result = await runGitCommand(["check-ref-format", "--branch", branchName], {
cwd,
timeout: 30_000,
acceptExitCodes: [0, 1, 128],
});
if (result.exitCode !== 0) {
throw new InvalidGitBranchNameError(branchName);
}
}
function normalizeRequiredBaseBranch(baseBranch: string): string {
const normalizedBaseBranch = normalizeBaseRefName(baseBranch);
if (!normalizedBaseBranch) {
throw new Error("Base branch is required when creating a Paseo worktree");
}
if (normalizedBaseBranch === "HEAD") {
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
}
return normalizedBaseBranch;
}
async function resolveBaseBranchForWorktree(
cwd: string,
normalizedBaseBranch: string,
): Promise<string> {
try {
await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd });
return `origin/${normalizedBaseBranch}`;
} catch {
try {
await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd });
return normalizedBaseBranch;
} catch {
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
}
}
}
async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
try {
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
cwd,
});
return true;
} catch {
return false;
}
}
async function resolveUniqueLocalBranchName(cwd: string, candidateBranch: string): Promise<string> {
let newBranchName = candidateBranch;
let suffix = 1;
while (await localBranchExists(cwd, newBranchName)) {
newBranchName = `${candidateBranch}-${suffix}`;
suffix++;
}
return newBranchName;
}
async function isBranchCheckedOut(cwd: string, branchName: string): Promise<boolean> {
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
return parseWorktreeList(stdout).some((entry) => entry.branchName === branchName);
}