Commit cd9bcdb3b9e4
Changed files (3)
dots
pi
agent
extensions
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
// =============================================================================