Run worktree setup async and stream status

This commit is contained in:
Mohamed Boudra
2026-01-23 11:28:48 +07:00
parent 8154a05fa5
commit 69f32da90d
11 changed files with 632 additions and 101 deletions
@@ -142,6 +142,29 @@ describe("createWorktree", () => {
expect(setupLog).toContain("branch=setup-test");
});
it("does not run setup commands when runSetup=false", async () => {
const paseoConfig = {
worktree: {
setup: ['echo "setup ran" > setup.log'],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync(
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'",
{ cwd: repoDir }
);
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
worktreeSlug: "no-setup-test",
runSetup: false,
});
expect(existsSync(result.worktreePath)).toBe(true);
expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false);
});
it("cleans up worktree if setup command fails", async () => {
// Create paseo.json with failing setup command
const paseoConfig = {
+125 -43
View File
@@ -22,13 +22,31 @@ interface RepoInfo {
name: string;
}
interface WorktreeConfig {
export interface WorktreeConfig {
branchName: string;
worktreePath: string;
repoType: "bare" | "normal";
repoPath: string;
}
export type WorktreeSetupCommandResult = {
command: string;
cwd: string;
stdout: string;
stderr: string;
exitCode: number | null;
};
export class WorktreeSetupError extends Error {
readonly results: WorktreeSetupCommandResult[];
constructor(message: string, results: WorktreeSetupCommandResult[]) {
super(message);
this.name = "WorktreeSetupError";
this.results = results;
}
}
export interface PaseoWorktreeInfo {
path: string;
branchName?: string;
@@ -47,6 +65,104 @@ interface CreateWorktreeOptions {
cwd: string;
baseBranch?: string;
worktreeSlug?: string;
runSetup?: boolean;
}
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);
}
async function execSetupCommand(
command: string,
options: { cwd: string; env: NodeJS.ProcessEnv }
): Promise<WorktreeSetupCommandResult> {
try {
const { stdout, stderr } = await execAsync(command, {
cwd: options.cwd,
env: options.env,
shell: "/bin/bash",
});
return {
command,
cwd: options.cwd,
stdout: stdout ?? "",
stderr: stderr ?? "",
exitCode: 0,
};
} 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,
};
}
}
export async function runWorktreeSetupCommands(options: {
repoRoot: string;
worktreePath: string;
branchName: string;
cleanupOnFailure: boolean;
}): Promise<WorktreeSetupCommandResult[]> {
const setupCommands = getWorktreeSetupCommands(options.repoRoot);
if (setupCommands.length === 0) {
return [];
}
const setupEnv = {
...process.env,
PASEO_ROOT_PATH: options.repoRoot,
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: options.branchName,
};
const results: WorktreeSetupCommandResult[] = [];
for (const cmd of setupCommands) {
const result = await execSetupCommand(cmd, {
cwd: options.worktreePath,
env: setupEnv,
});
results.push(result);
if (result.exitCode !== 0) {
if (options.cleanupOnFailure) {
try {
await execAsync(`git worktree remove "${options.worktreePath}" --force`, {
cwd: options.repoRoot,
});
} catch {
rmSync(options.worktreePath, { recursive: true, force: true });
}
}
throw new WorktreeSetupError(
`Worktree setup command failed: ${cmd}\n${result.stderr}`.trim(),
results
);
}
}
return results;
}
/**
@@ -364,6 +480,7 @@ export async function createWorktree({
cwd,
baseBranch,
worktreeSlug,
runSetup = true,
}: CreateWorktreeOptions): Promise<WorktreeConfig> {
// Validate branch name
const validation = validateBranchSlug(branchName);
@@ -431,48 +548,13 @@ export async function createWorktree({
await execAsync(command, { cwd: repoInfo.path });
worktreePath = finalWorktreePath;
// Run setup commands from paseo.json if present (look in source worktree, not bare repo)
const paseoConfigPath = join(repoInfo.path, "paseo.json");
if (existsSync(paseoConfigPath)) {
let config: PaseoConfig;
try {
config = JSON.parse(readFileSync(paseoConfigPath, "utf8"));
} catch {
throw new Error(`Failed to parse paseo.json`);
}
const setupCommands = config.worktree?.setup;
if (setupCommands && setupCommands.length > 0) {
const setupEnv = {
...process.env,
PASEO_ROOT_PATH: repoInfo.path,
PASEO_WORKTREE_PATH: worktreePath,
PASEO_BRANCH_NAME: newBranchName,
};
for (const cmd of setupCommands) {
try {
await execAsync(cmd, {
cwd: worktreePath,
env: setupEnv,
shell: "/bin/bash",
});
} catch (error) {
// Cleanup worktree on setup failure
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: repoInfo.path,
});
} catch {
// If git worktree remove fails, try rmSync
rmSync(worktreePath, { recursive: true, force: true });
}
throw new Error(
`Worktree setup command failed: ${cmd}\n${error instanceof Error ? error.message : String(error)}`
);
}
}
}
if (runSetup) {
await runWorktreeSetupCommands({
repoRoot: repoInfo.path,
worktreePath,
branchName: newBranchName,
cleanupOnFailure: true,
});
}
return {