Commit 09f5f6f66708

Vincent Demeester <vincent@sbr.pm>
2026-05-18 15:10:29
fix(pi): use async spawn for worktree exec
Replaced blocking execSync with async spawn in execInWorktree to prevent pi from crashing during long-running commands like git push on large repositories. Resolved worktree path via lazyworktree list instead of delegating to lazyworktree exec, and improved stderr handling for git commands.
1 parent a939d2a
Changed files (2)
dots
pi
agent
dots/pi/agent/extensions/git/index.ts
@@ -441,7 +441,7 @@ export default function (pi: ExtensionAPI) {
 							};
 						}
 
-						const output = execInWorktree(branch, execCmd, ctx.cwd);
+						const output = await execInWorktree(branch, execCmd, ctx.cwd);
 
 						return {
 							content: [{
dots/pi/agent/extensions/git/worktree.ts
@@ -3,7 +3,7 @@
 // Uses lazyworktree when available, falls back to raw git commands.
 // =============================================================================
 
-import { execSync } from "child_process";
+import { execSync, spawn } from "child_process";
 import * as path from "path";
 import * as fs from "fs";
 import type { WorktreeInfo, WorktreeCreateOptions, WorktreeRemoveOptions, LazyworktreeInfo } from "./types.js";
@@ -276,48 +276,83 @@ function createWithGit(options: WorktreeCreateOptions, repoRoot: string, cwd: st
 // Execute in Worktree
 // =============================================================================
 
-export function execInWorktree(nameOrPath: string, command: string, cwd: string = process.cwd()): string {
+export async function execInWorktree(nameOrPath: string, command: string, cwd: string = process.cwd()): Promise<string> {
 	const repoRoot = findRepoRoot(cwd);
 	if (!repoRoot) {
 		throw new Error("Not in a git repository");
 	}
 
+	// Resolve worktree path
+	let worktreePath: string;
+
 	if (hasLazyworktree()) {
 		try {
-			return execSync(
-				`lazyworktree exec -w "${nameOrPath}" "${command.replace(/"/g, '\\"')}"`,
-				{
-					cwd: repoRoot,
-					encoding: "utf-8",
-					stdio: "pipe",
-				}
-			).trim();
+			// Use lazyworktree to resolve the worktree path
+			const output = execSync(`lazyworktree list --json`, {
+				cwd: repoRoot,
+				encoding: "utf-8",
+				stdio: "pipe",
+			}).trim();
+			const items: LazyworktreeInfo[] = JSON.parse(output);
+			const match = items.find(
+				(wt) => wt.name === nameOrPath || wt.branch === nameOrPath || wt.path === nameOrPath || wt.path.endsWith(`/${nameOrPath}`)
+			);
+			if (match) {
+				worktreePath = match.path;
+			} else {
+				throw new Error(`Worktree '${nameOrPath}' not found`);
+			}
 		} catch (error: any) {
-			// If lazyworktree exec fails, try direct execution
-			if (error.stdout) return error.stdout.toString().trim();
-			throw new Error(`Failed to exec in worktree: ${error.message}`);
+			if (error.message?.includes("not found")) throw error;
+			// Fall through to git worktree list
+			const worktrees = listWorktrees(cwd);
+			const worktree = worktrees.find(
+				(wt) => wt.branch === nameOrPath || wt.path === nameOrPath || wt.path.endsWith(`/${nameOrPath}`)
+			);
+			if (!worktree) throw new Error(`Worktree '${nameOrPath}' not found`);
+			worktreePath = worktree.path;
 		}
+	} else {
+		const worktrees = listWorktrees(cwd);
+		const worktree = worktrees.find(
+			(wt) => wt.branch === nameOrPath || wt.path === nameOrPath || wt.path.endsWith(`/${nameOrPath}`)
+		);
+		if (!worktree) throw new Error(`Worktree '${nameOrPath}' not found`);
+		worktreePath = worktree.path;
 	}
 
-	// Fallback: find worktree path and exec directly
-	const worktrees = listWorktrees(cwd);
-	const worktree = worktrees.find(
-		(wt) => wt.branch === nameOrPath || wt.path === nameOrPath || wt.path.endsWith(`/${nameOrPath}`)
-	);
+	// Execute command asynchronously to avoid blocking the event loop
+	return new Promise<string>((resolve, reject) => {
+		const child = spawn("sh", ["-c", command], {
+			cwd: worktreePath,
+			stdio: ["ignore", "pipe", "pipe"],
+		});
 
-	if (!worktree) {
-		throw new Error(`Worktree '${nameOrPath}' not found`);
-	}
+		const stdoutChunks: Buffer[] = [];
+		const stderrChunks: Buffer[] = [];
 
-	try {
-		return execSync(command, {
-			cwd: worktree.path,
-			encoding: "utf-8",
-			stdio: "pipe",
-		}).trim();
-	} catch (error) {
-		throw new Error(`Failed to exec in worktree: ${error}`);
-	}
+		child.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
+		child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
+
+		child.on("error", (err) => {
+			reject(new Error(`Failed to exec in worktree: ${err.message}`));
+		});
+
+		child.on("close", (code) => {
+			const stdout = Buffer.concat(stdoutChunks).toString("utf-8").trim();
+			const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
+
+			if (code !== 0) {
+				// Include both stdout and stderr in output for failed commands
+				const output = [stdout, stderr].filter(Boolean).join("\n");
+				reject(new Error(`Command failed (exit ${code}): ${output.substring(0, 2000)}`));
+				return;
+			}
+
+			// Return stdout if available, otherwise stderr (git writes to stderr)
+			resolve(stdout || stderr);
+		});
+	});
 }
 
 // =============================================================================