Files
paseo/packages/server/src/utils/worktree.ts
T
Mohamed Boudra c54e2ccabe Fix CI failures: server tests, app tests, CLI typecheck
- worktree.ts: remove double-shelling in execFileAsync (shell integration tests)
- agent-manager.test: expect "closed" status after archive+close
- use-open-project.test: fix workspace ID encoding (no longer base64)
- use-agent-input-draft.live.test: add QueryClientProvider wrapper
- voice-runtime.test: align assertions with dev branch runtime changes
- ci.yml: add server build step before typecheck (CLI needs dist types)
2026-04-14 05:05:54 +07:00

1171 lines
32 KiB
TypeScript

import { execFile, spawn } from "child_process";
import { promisify } from "util";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
import * as pty from "node-pty";
import { createNameId } from "mnemonic-id";
import stripAnsi from "strip-ansi";
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
import {
normalizeBaseRefName,
readPaseoWorktreeMetadata,
readPaseoWorktreeRuntimePort,
writePaseoWorktreeMetadata,
writePaseoWorktreeRuntimeMetadata,
} from "./worktree-metadata.js";
import { runGitCommand } from "./run-git-command.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
interface PaseoConfig {
worktree?: {
setup?: string[];
teardown?: string[];
terminals?: WorktreeTerminalConfig[];
};
scripts?: Record<
string,
{
type?: unknown;
command?: unknown;
port?: unknown;
}
>;
}
const execFileAsync = promisify(execFile);
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
...process.env,
GIT_OPTIONAL_LOCKS: "0",
};
export interface WorktreeConfig {
branchName: string;
worktreePath: string;
}
export type WorktreeRuntimeEnv = {
PASEO_SOURCE_CHECKOUT_PATH: string;
PASEO_ROOT_PATH: string;
PASEO_WORKTREE_PATH: string;
PASEO_BRANCH_NAME: string;
PASEO_WORKTREE_PORT: string;
};
export type 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 type PaseoWorktreeOwnership = {
allowed: boolean;
repoRoot?: string;
worktreeRoot?: string;
worktreePath?: string;
};
interface CreateWorktreeOptions {
branchName: string;
cwd: string;
baseBranch: string;
worktreeSlug?: string;
runSetup?: boolean;
paseoHome?: string;
}
function readPaseoConfig(repoRoot: string): PaseoConfig | null {
const paseoConfigPath = join(repoRoot, "paseo.json");
if (!existsSync(paseoConfigPath)) {
return null;
}
try {
return JSON.parse(readFileSync(paseoConfigPath, "utf8"));
} catch {
throw new Error(`Failed to parse paseo.json`);
}
}
export function getWorktreeSetupCommands(repoRoot: string): string[] {
const config = readPaseoConfig(repoRoot);
const setupCommands = config?.worktree?.setup;
if (!setupCommands || setupCommands.length === 0) {
return [];
}
return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
}
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
const config = readPaseoConfig(repoRoot);
const teardownCommands = config?.worktree?.teardown;
if (!teardownCommands || teardownCommands.length === 0) {
return [];
}
return teardownCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
}
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
const config = readPaseoConfig(repoRoot);
const terminals = config?.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(repoRoot: string): Map<string, ScriptConfig> {
const config = readPaseoConfig(repoRoot);
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: any) {
return {
command,
cwd: options.cwd,
stdout: error?.stdout ?? "",
stderr: error?.stderr ?? (error instanceof Error ? error.message : String(error)),
exitCode: typeof error?.code === "number" ? error.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((resolve) => {
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,
});
resolve(result);
};
options.onEvent?.({
type: "command_started",
index: options.index,
total: options.total,
command: options.command,
cwd: options.cwd,
});
const spawnWithPipes = () => {
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const child = spawn(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
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);
});
};
try {
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const terminal = pty.spawn(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
name: "xterm-color",
cols: 120,
rows: 30,
});
terminal.onData((data) => {
emitOutput("stdout", data);
});
terminal.onExit(({ exitCode }) => {
finish(typeof exitCode === "number" ? exitCode : null);
});
} catch (error) {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
spawnWithPipes();
}
});
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, 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;
}
resolve(address.port);
});
});
});
}
async function assertPortAvailable(port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const server = net.createServer();
server.once("error", (error: NodeJS.ErrnoException) => {
const message =
error?.code === "EADDRINUSE"
? `Persisted worktree port ${port} is already in use`
: error instanceof Error
? error.message
: String(error);
reject(new Error(message));
});
server.listen(port, () => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
});
}
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", "--path-format=absolute", "--show-toplevel"],
{
cwd: worktreePath,
env: READ_ONLY_GIT_ENV,
},
);
const topLevel = stdout.trim();
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 = {
...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,
env: 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;
branchName?: string;
repoRootPath?: string;
}): Promise<WorktreeTeardownCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo)
const teardownCommands = getWorktreeTeardownCommands(options.worktreePath);
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 = {
...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: options.worktreePath,
env: teardownEnv,
});
results.push(result);
if (result.exitCode !== 0) {
throw new WorktreeTeardownError(
`Worktree teardown command failed: ${cmd}\n${result.stderr}`.trim(),
results,
);
}
}
return results;
}
/**
* 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", "--path-format=absolute", "--git-common-dir"],
{
cwd,
env: READ_ONLY_GIT_ENV,
},
);
const commonDir = stdout.trim();
if (!commonDir) {
throw new Error("Not in a git repository");
}
return commonDir;
}
/**
* Validate that a string is a valid git branch name slug
* Must be lowercase, alphanumeric, hyphens only
*/
export function validateBranchSlug(slug: string): {
valid: boolean;
error?: string;
} {
if (!slug || slug.length === 0) {
return { valid: false, error: "Branch name cannot be empty" };
}
if (slug.length > 100) {
return { valid: false, error: "Branch name too long (max 100 characters)" };
}
// Check for valid characters: lowercase letters, numbers, hyphens, forward slashes
const validPattern = /^[a-z0-9-/]+$/;
if (!validPattern.test(slug)) {
return {
valid: false,
error:
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
};
}
// Cannot start or end with hyphen
if (slug.startsWith("-") || slug.endsWith("-")) {
return {
valid: false,
error: "Branch name cannot start or end with a hyphen",
};
}
// Cannot have consecutive hyphens
if (slug.includes("--")) {
return { valid: false, error: "Branch name cannot have consecutive hyphens" };
}
return { valid: true };
}
const MAX_SLUG_LENGTH = 50;
/**
* Convert string to kebab-case for branch names
*/
export function slugify(input: string): string {
const slug = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (slug.length <= MAX_SLUG_LENGTH) {
return slug;
}
// Truncate at word boundary (hyphen) if possible
const truncated = slug.slice(0, MAX_SLUG_LENGTH);
const lastHyphen = truncated.lastIndexOf("-");
if (lastHyphen > MAX_SLUG_LENGTH / 2) {
return truncated.slice(0, lastHyphen);
}
return truncated.replace(/-+$/, "");
}
function generateWorktreeSlug(): string {
return createNameId();
}
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 async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> {
const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome();
const projectHash = await deriveWorktreeProjectHash(cwd);
return join(home, "worktrees", projectHash);
}
export async function computeWorktreePath(
cwd: string,
slug: string,
paseoHome?: string,
): Promise<string> {
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
return join(worktreesRoot, slug);
}
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?: { paseoHome?: string },
): Promise<PaseoWorktreeOwnership> {
let gitCommonDir: string;
try {
gitCommonDir = await getGitCommonDir(cwd);
} catch {
return {
allowed: false,
worktreePath: normalizePathForOwnership(cwd),
};
}
const repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir);
const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome);
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
const resolvedCwd = normalizePathForOwnership(cwd);
if (!resolvedCwd.startsWith(resolvedRoot)) {
return {
allowed: false,
repoRoot,
worktreeRoot: worktreesRoot,
worktreePath: resolvedCwd,
};
}
const worktrees = await listPaseoWorktrees({ cwd, paseoHome: options?.paseoHome });
const allowed = worktrees.some((entry) => {
const worktreePath = resolve(entry.path);
return resolvedCwd === worktreePath || resolvedCwd.startsWith(worktreePath + sep);
});
return {
allowed,
repoRoot,
worktreeRoot: worktreesRoot,
worktreePath: resolvedCwd,
};
}
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,
}: {
cwd: string;
paseoHome?: string;
}): Promise<PaseoWorktreeInfo[]> {
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep;
return parseWorktreeList(stdout)
.map((entry) => ({ ...entry, path: normalizePathForOwnership(entry.path) }))
.filter((entry) => entry.path.startsWith(rootPrefix))
.map((entry) => ({
...entry,
createdAt: resolveWorktreeCreatedAtIso(entry.path),
}));
}
export async function resolvePaseoWorktreeRootForCwd(
cwd: string,
options?: { paseoHome?: string },
): Promise<{ repoRoot: string; worktreeRoot: string; worktreePath: string } | null> {
let gitCommonDir: string;
try {
gitCommonDir = await getGitCommonDir(cwd);
} catch {
return null;
}
const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome);
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
let worktreeRoot: string | null = null;
try {
const { stdout } = await runGitCommand(
["rev-parse", "--path-format=absolute", "--show-toplevel"],
{
cwd,
env: READ_ONLY_GIT_ENV,
},
);
const trimmed = stdout.trim();
worktreeRoot = trimmed.length > 0 ? trimmed : null;
} catch {
worktreeRoot = null;
}
if (!worktreeRoot) {
return null;
}
const resolvedWorktreeRoot = normalizePathForOwnership(worktreeRoot);
if (!resolvedWorktreeRoot.startsWith(resolvedRoot)) {
return null;
}
const knownWorktrees = await listPaseoWorktrees({
cwd,
paseoHome: options?.paseoHome,
});
const match = knownWorktrees.find((entry) => entry.path === resolvedWorktreeRoot);
if (!match) {
return null;
}
return {
repoRoot: gitCommonDir,
worktreeRoot: worktreesRoot,
worktreePath: match.path,
};
}
export async function deletePaseoWorktree({
cwd,
worktreePath,
worktreeSlug,
paseoHome,
}: {
cwd: string;
worktreePath?: string;
worktreeSlug?: string;
paseoHome?: string;
}): Promise<void> {
if (!worktreePath && !worktreeSlug) {
throw new Error("worktreePath or worktreeSlug is required");
}
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
const requestedPath = worktreePath ?? join(worktreesRoot, worktreeSlug!);
const resolvedRequested = normalizePathForOwnership(requestedPath);
const resolvedWorktree =
(await resolvePaseoWorktreeRootForCwd(requestedPath, { paseoHome }))?.worktreePath ??
resolvedRequested;
if (!resolvedWorktree.startsWith(resolvedRoot)) {
throw new Error("Refusing to delete non-Paseo worktree");
}
await runWorktreeTeardownCommands({
worktreePath: resolvedWorktree,
});
await runGitCommand(["worktree", "remove", resolvedWorktree, "--force"], {
cwd,
timeout: 120_000,
});
if (existsSync(resolvedWorktree)) {
rmSync(resolvedWorktree, { recursive: true, force: true });
}
}
/**
* Create a git worktree with proper naming conventions
*/
export async function createWorktree({
branchName,
cwd,
baseBranch,
worktreeSlug,
runSetup = true,
paseoHome,
}: CreateWorktreeOptions): Promise<WorktreeConfig> {
// Validate branch name
const validation = validateBranchSlug(branchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
const normalizedBaseBranch = baseBranch ? 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");
}
// Resolve the base branch - prefer origin/{branch}, then fall back to local
let resolvedBaseBranch = normalizedBaseBranch;
try {
await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd });
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
} catch {
try {
await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd });
} catch {
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
}
}
let worktreePath: string;
const desiredSlug = worktreeSlug || generateWorktreeSlug();
worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome), desiredSlug);
mkdirSync(dirname(worktreePath), { recursive: true });
// Check if branch already exists
let branchExists = false;
try {
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
cwd,
});
branchExists = true;
} catch {
branchExists = false;
}
// Always create a new branch for the worktree
// If branchName already exists, use it as base and create worktree-slug as branch name
// If branchName doesn't exist, create it from baseBranch (resolved to remote if needed)
const base = branchExists ? branchName : resolvedBaseBranch;
const candidateBranch = branchExists ? desiredSlug : branchName;
// Find unique branch name if collision
let newBranchName = candidateBranch;
let suffix = 1;
while (true) {
try {
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${newBranchName}`], {
cwd,
});
// Branch exists, try with suffix
newBranchName = `${candidateBranch}-${suffix}`;
suffix++;
} catch {
break;
}
}
// Also handle worktree path collision
let finalWorktreePath = worktreePath;
let pathSuffix = 1;
while (existsSync(finalWorktreePath)) {
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
pathSuffix++;
}
await runGitCommand(["worktree", "add", finalWorktreePath, "-b", newBranchName, base], {
cwd,
timeout: 120_000,
});
worktreePath = normalizePathForOwnership(finalWorktreePath);
writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch });
if (runSetup) {
await runWorktreeSetupCommands({
worktreePath,
branchName: newBranchName,
cleanupOnFailure: true,
});
}
return {
branchName: newBranchName,
worktreePath,
};
}