feat: stream worktree setup progress and terminal subscriptions

This commit is contained in:
Mohamed Boudra
2026-02-16 08:09:27 +07:00
parent b980abb7af
commit 037a6a15a4
29 changed files with 1449 additions and 102 deletions
@@ -5,6 +5,8 @@ import {
getWorktreeTerminalSpecs,
isPaseoOwnedWorktreeCwd,
listPaseoWorktrees,
type WorktreeSetupCommandProgressEvent,
runWorktreeSetupCommands,
slugify,
} from "./worktree";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
@@ -233,6 +235,36 @@ describe("createWorktree", () => {
expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false);
});
it("streams setup command progress events while commands are executing", async () => {
const paseoConfig = {
worktree: {
setup: [
'echo "first line"; echo "second line" 1>&2',
],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync(
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add streaming setup'",
{ cwd: repoDir }
);
const progressEvents: WorktreeSetupCommandProgressEvent[] = [];
const results = await runWorktreeSetupCommands({
worktreePath: repoDir,
branchName: "main",
cleanupOnFailure: false,
onEvent: (event) => {
progressEvents.push(event);
},
});
expect(results).toHaveLength(1);
expect(progressEvents.some((event) => event.type === "command_started")).toBe(true);
expect(progressEvents.some((event) => event.type === "output")).toBe(true);
expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true);
});
it("cleans up worktree if setup command fails", async () => {
// Create paseo.json with failing setup command
const paseoConfig = {
+143 -6
View File
@@ -1,4 +1,4 @@
import { exec } from "child_process";
import { exec, spawn } from "child_process";
import { promisify } from "util";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
import { join, basename, dirname, resolve, sep } from "path";
@@ -32,8 +32,38 @@ export type WorktreeSetupCommandResult = {
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;
@@ -153,6 +183,7 @@ async function execSetupCommand(
command: string,
options: { cwd: string; env: NodeJS.ProcessEnv }
): Promise<WorktreeSetupCommandResult> {
const startedAt = Date.now();
try {
const { stdout, stderr } = await execAsync(command, {
cwd: options.cwd,
@@ -165,6 +196,7 @@ async function execSetupCommand(
stdout: stdout ?? "",
stderr: stderr ?? "",
exitCode: 0,
durationMs: Date.now() - startedAt,
};
} catch (error: any) {
return {
@@ -175,10 +207,105 @@ async function execSetupCommand(
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 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 child = spawn("/bin/bash", ["-lc", options.command], {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.on("data", (chunk: Buffer | string) => {
const text = chunk.toString();
stdoutChunks.push(text);
options.onEvent?.({
type: "output",
index: options.index,
total: options.total,
command: options.command,
cwd: options.cwd,
stream: "stdout",
chunk: text,
});
});
child.stderr?.on("data", (chunk: Buffer | string) => {
const text = chunk.toString();
stderrChunks.push(text);
options.onEvent?.({
type: "output",
index: options.index,
total: options.total,
command: options.command,
cwd: options.cwd,
stream: "stderr",
chunk: text,
});
});
child.on("error", (error) => {
stderrChunks.push(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((resolve, reject) => {
const server = net.createServer();
@@ -233,6 +360,7 @@ export async function runWorktreeSetupCommands(options: {
branchName: string;
cleanupOnFailure: boolean;
repoRootPath?: string;
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);
@@ -258,11 +386,20 @@ export async function runWorktreeSetupCommands(options: {
};
const results: WorktreeSetupCommandResult[] = [];
for (const cmd of setupCommands) {
const result = await execSetupCommand(cmd, {
cwd: options.worktreePath,
env: setupEnv,
});
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) {