Commit cd9bcdb3b9e4

Vincent Demeester <vincent@sbr.pm>
2026-02-23 11:00:09
feat: leverage lazyworktree list, exec, create hooks
Added list --json support for richer worktree info (dirty, ahead/ behind, last_active). Added exec action to run commands inside worktrees without switching cwd. Added --exec post-create hook support for automatic project setup after worktree creation.
1 parent 0ba65c4
Changed files (3)
dots
pi
dots/pi/agent/extensions/git/index.ts
@@ -8,6 +8,7 @@ import {
 	listWorktrees,
 	createWorktree,
 	removeWorktree,
+	execInWorktree,
 	pruneWorktrees,
 	getCurrentWorktreeInfo,
 } from "./worktree.js";
@@ -192,11 +193,21 @@ export default function (pi: ExtensionAPI) {
 		for (const wt of worktrees) {
 			const isCurrent = currentInfo && wt.path === currentInfo.path;
 			const marker = isCurrent ? "โžœ" : " ";
-			const dirty = hasUncommittedChanges(wt.path) ? "*" : "";
+			const dirty = (wt as any).dirty ?? hasUncommittedChanges(wt.path);
+			const dirtyMark = dirty ? "*" : "";
 
-			lines.push(`${marker} ${wt.branch}${dirty}`);
+			lines.push(`${marker} ${wt.branch}${dirtyMark}`);
 			lines.push(`  Path: ${wt.path}`);
-			lines.push(`  Commit: ${wt.commit.substring(0, 8)}`);
+			if (wt.commit) lines.push(`  Commit: ${wt.commit.substring(0, 8)}`);
+
+			const ahead = (wt as any).ahead;
+			const behind = (wt as any).behind;
+			if (ahead || behind) {
+				lines.push(`  โ†‘${ahead ?? 0} โ†“${behind ?? 0}`);
+			}
+
+			const lastActive = (wt as any).lastActive;
+			if (lastActive) lines.push(`  Last active: ${lastActive}`);
 
 			if (wt.locked) lines.push("  ๐Ÿ”’ Locked");
 			if (wt.prunable) lines.push("  โ™ป๏ธ  Prunable");
@@ -302,6 +313,7 @@ export default function (pi: ExtensionAPI) {
 				Type.Literal("list"),
 				Type.Literal("create"),
 				Type.Literal("remove"),
+				Type.Literal("exec"),
 			], {
 				description: "The worktree action to perform"
 			}),
@@ -314,17 +326,26 @@ export default function (pi: ExtensionAPI) {
 			force: Type.Optional(Type.Boolean({
 				description: "Force removal even with uncommitted changes"
 			})),
+			exec: Type.Optional(Type.String({
+				description: "Command to run: post-create hook (with create) or command to execute (with exec action, requires branch)"
+			})),
 		}),
 		execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
-			const { action, branch, path: customPath, force } = params;
+			const { action, branch, path: customPath, force, exec: execCmd } = params;
 
 			try {
 				switch (action) {
 					case "list": {
 						const worktrees = listWorktrees(ctx.cwd);
 						const lines = worktrees.map((wt) => {
-							const dirty = hasUncommittedChanges(wt.path) ? "*" : "";
-							return `- ${wt.branch}${dirty}: ${wt.path} (${wt.commit.substring(0, 8)})`;
+							const dirty = (wt as any).dirty ?? hasUncommittedChanges(wt.path);
+							const dirtyMark = dirty ? "*" : "";
+							const commit = wt.commit ? ` (${wt.commit.substring(0, 8)})` : "";
+							const lastActive = (wt as any).lastActive ? ` [${(wt as any).lastActive}]` : "";
+							const sync = ((wt as any).ahead || (wt as any).behind)
+								? ` โ†‘${(wt as any).ahead ?? 0}โ†“${(wt as any).behind ?? 0}`
+								: "";
+							return `- ${wt.branch}${dirtyMark}: ${wt.path}${commit}${sync}${lastActive}`;
 						});
 						
 						return {
@@ -352,6 +373,7 @@ export default function (pi: ExtensionAPI) {
 								branch,
 								path: customPath,
 								fromRemote: true,
+								exec: execCmd,
 							},
 							ctx.cwd
 						);
@@ -393,6 +415,28 @@ export default function (pi: ExtensionAPI) {
 						};
 					}
 
+					case "exec": {
+						if (!branch || !execCmd) {
+							return {
+								content: [{
+									type: "text",
+									text: "Error: Both branch (worktree name/path) and exec (command) are required for exec action"
+								}],
+								details: { error: true },
+							};
+						}
+
+						const output = execInWorktree(branch, execCmd, ctx.cwd);
+
+						return {
+							content: [{
+								type: "text",
+								text: output || "(no output)"
+							}],
+							details: { branch, command: execCmd },
+						};
+					}
+
 					default:
 						return {
 							content: [{
dots/pi/agent/extensions/git/types.ts
@@ -29,9 +29,23 @@ export interface WorktreeCreateOptions {
 	createBranch?: boolean;
 	fromRemote?: boolean;
 	checkout?: boolean;
+	exec?: string;
 }
 
 export interface WorktreeRemoveOptions {
 	branch: string;
 	force?: boolean;
 }
+
+// lazyworktree list --json output
+export interface LazyworktreeInfo {
+	path: string;
+	name: string;
+	branch: string;
+	is_main: boolean;
+	dirty: boolean;
+	ahead: number;
+	behind: number;
+	unpushed?: number;
+	last_active: string;
+}
dots/pi/agent/extensions/git/worktree.ts
@@ -6,7 +6,7 @@
 import { execSync } from "child_process";
 import * as path from "path";
 import * as fs from "fs";
-import type { WorktreeInfo, WorktreeCreateOptions, WorktreeRemoveOptions } from "./types.js";
+import type { WorktreeInfo, WorktreeCreateOptions, WorktreeRemoveOptions, LazyworktreeInfo } from "./types.js";
 import {
 	findRepoRoot,
 	getCurrentBranch,
@@ -50,6 +50,15 @@ export function listWorktrees(cwd: string = process.cwd()): WorktreeInfo[] {
 		throw new Error("Not in a git repository");
 	}
 
+	// Use lazyworktree list --json for richer info
+	if (hasLazyworktree()) {
+		try {
+			return listWithLazyworktree(repoRoot);
+		} catch {
+			// Fall through to git
+		}
+	}
+
 	try {
 		const output = execSync("git worktree list --porcelain", {
 			cwd: repoRoot,
@@ -62,6 +71,32 @@ export function listWorktrees(cwd: string = process.cwd()): WorktreeInfo[] {
 	}
 }
 
+function listWithLazyworktree(cwd: string): WorktreeInfo[] {
+	const output = execSync("lazyworktree list --json", {
+		cwd,
+		encoding: "utf-8",
+		stdio: "pipe",
+	}).trim();
+
+	const items: LazyworktreeInfo[] = JSON.parse(output);
+
+	return items.map((item) => ({
+		path: item.path,
+		branch: item.branch,
+		commit: "", // lazyworktree doesn't expose commit hash
+		bare: false,
+		detached: false,
+		locked: false,
+		prunable: false,
+		// Extra fields from lazyworktree
+		dirty: item.dirty,
+		isMain: item.is_main,
+		ahead: item.ahead,
+		behind: item.behind,
+		lastActive: item.last_active,
+	}));
+}
+
 function parseWorktreeList(output: string): WorktreeInfo[] {
 	const worktrees: WorktreeInfo[] = [];
 	const lines = output.trim().split("\n");
@@ -142,14 +177,18 @@ export function createWorktree(options: WorktreeCreateOptions, cwd: string = pro
 
 function createWithLazyworktree(options: WorktreeCreateOptions, repoRoot: string): string {
 	try {
-		const output = execSync(
-			`lazyworktree create --from-branch ${options.branch} --silent`,
-			{
-				cwd: repoRoot,
-				encoding: "utf-8",
-				stdio: "pipe",
-			}
-		).trim();
+		let cmd = `lazyworktree create --from-branch ${options.branch} --silent`;
+
+		// Post-create hook: run a command inside the new worktree
+		if (options.exec) {
+			cmd += ` --exec "${options.exec.replace(/"/g, '\\"')}"`;
+		}
+
+		const output = execSync(cmd, {
+			cwd: repoRoot,
+			encoding: "utf-8",
+			stdio: "pipe",
+		}).trim();
 
 		// lazyworktree prints the worktree path to stdout
 		if (!output) {
@@ -214,12 +253,73 @@ function createWithGit(options: WorktreeCreateOptions, repoRoot: string, cwd: st
 			stdio: "pipe",
 		});
 
+		// Run post-create command if specified (git fallback)
+		if (options.exec) {
+			try {
+				execSync(options.exec, {
+					cwd: worktreePath,
+					encoding: "utf-8",
+					stdio: "pipe",
+				});
+			} catch {
+				// Don't fail creation if exec fails
+			}
+		}
+
 		return worktreePath;
 	} catch (error) {
 		throw new Error(`Failed to create worktree: ${error}`);
 	}
 }
 
+// =============================================================================
+// Execute in Worktree
+// =============================================================================
+
+export function execInWorktree(nameOrPath: string, command: string, cwd: string = process.cwd()): string {
+	const repoRoot = findRepoRoot(cwd);
+	if (!repoRoot) {
+		throw new Error("Not in a git repository");
+	}
+
+	if (hasLazyworktree()) {
+		try {
+			return execSync(
+				`lazyworktree exec -w "${nameOrPath}" "${command.replace(/"/g, '\\"')}"`,
+				{
+					cwd: repoRoot,
+					encoding: "utf-8",
+					stdio: "pipe",
+				}
+			).trim();
+		} 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}`);
+		}
+	}
+
+	// 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}`)
+	);
+
+	if (!worktree) {
+		throw new Error(`Worktree '${nameOrPath}' not found`);
+	}
+
+	try {
+		return execSync(command, {
+			cwd: worktree.path,
+			encoding: "utf-8",
+			stdio: "pipe",
+		}).trim();
+	} catch (error) {
+		throw new Error(`Failed to exec in worktree: ${error}`);
+	}
+}
+
 // =============================================================================
 // Remove Worktree
 // =============================================================================