Commit e8fb21ed5051

Vincent Demeester <vincent@sbr.pm>
2026-02-06 17:17:11
feat(pi): use XDG worktree layout and enable AI tool
Changed worktree location from .worktrees/ to XDG data directory: ~/.local/share/worktrees/<org>/<repo>/<branch>/ This follows the UsingGitWorktrees skill pattern and is compatible with lazyworktree. Features: - Parse org/repo from git remote URL (SSH, HTTPS, local) - Create worktrees in ~/.local/share/worktrees/<org>/<repo>/ - Enabled git_worktree tool for AI to use - Argument completion for subcommands and branch names - Clean output (suppressed git verbose messages) AI can now: - List worktrees - Create worktrees with automatic org/repo detection - Remove worktrees Completions: - Type '/wt ' → shows: list, ls, create, new, add, remove, rm, delete, prune - Type '/wt rm ' → shows available worktree branches Example: AI can create worktree at ~/.local/share/worktrees/vdemeester/home/feature-branch/
1 parent 5cd0a63
Changed files (3)
dots
pi
dots/pi/agent/extensions/git/index.ts
@@ -3,6 +3,7 @@
 // =============================================================================
 
 import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { Type } from "@sinclair/typebox";
 import {
 	listWorktrees,
 	createWorktree,
@@ -19,6 +20,36 @@ export default function (pi: ExtensionAPI) {
 
 	pi.registerCommand("worktree", {
 		description: "Manage git worktrees (list, create, remove, prune)",
+		getArgumentCompletions: (prefix, ctx) => {
+			const parts = prefix.trim().split(/\s+/);
+			
+			// First argument: subcommand
+			if (parts.length <= 1) {
+				const subcommands = ["list", "ls", "create", "new", "add", "remove", "rm", "delete", "prune"];
+				const filtered = subcommands.filter((s) => s.startsWith(parts[0] || ""));
+				return filtered.length > 0 ? filtered.map((s) => ({ value: s, label: s })) : null;
+			}
+			
+			// Second argument: branch name for remove/delete
+			const subcommand = parts[0];
+			if ((subcommand === "remove" || subcommand === "rm" || subcommand === "delete") && parts.length === 2) {
+				try {
+					const worktrees = listWorktrees(ctx.cwd);
+					// Don't suggest removing the main worktree
+					const branches = worktrees
+						.filter((wt) => !wt.bare)
+						.map((wt) => wt.branch);
+					
+					const searchTerm = parts[1] || "";
+					const filtered = branches.filter((b) => b.startsWith(searchTerm));
+					return filtered.length > 0 ? filtered.map((b) => ({ value: b, label: b })) : null;
+				} catch {
+					return null;
+				}
+			}
+			
+			return null;
+		},
 		handler: async (args, ctx) => {
 			const parts = args.trim().split(/\s+/).filter(Boolean);
 			const subcommand = parts[0] || "list";
@@ -63,6 +94,36 @@ export default function (pi: ExtensionAPI) {
 	// Register /wt alias
 	pi.registerCommand("wt", {
 		description: "Alias for /worktree",
+		getArgumentCompletions: (prefix, ctx) => {
+			const parts = prefix.trim().split(/\s+/);
+			
+			// First argument: subcommand
+			if (parts.length <= 1) {
+				const subcommands = ["list", "ls", "create", "new", "add", "remove", "rm", "delete", "prune"];
+				const filtered = subcommands.filter((s) => s.startsWith(parts[0] || ""));
+				return filtered.length > 0 ? filtered.map((s) => ({ value: s, label: s })) : null;
+			}
+			
+			// Second argument: branch name for remove/delete
+			const subcommand = parts[0];
+			if ((subcommand === "remove" || subcommand === "rm" || subcommand === "delete") && parts.length === 2) {
+				try {
+					const worktrees = listWorktrees(ctx.cwd);
+					// Don't suggest removing the main worktree
+					const branches = worktrees
+						.filter((wt) => !wt.bare)
+						.map((wt) => wt.branch);
+					
+					const searchTerm = parts[1] || "";
+					const filtered = branches.filter((b) => b.startsWith(searchTerm));
+					return filtered.length > 0 ? filtered.map((b) => ({ value: b, label: b })) : null;
+				} catch {
+					return null;
+				}
+			}
+			
+			return null;
+		},
 		handler: async (args, ctx) => {
 			const parts = args.trim().split(/\s+/).filter(Boolean);
 			const subcommand = parts[0] || "list";
@@ -229,59 +290,61 @@ export default function (pi: ExtensionAPI) {
 
 	// =========================================================================
 	// Tool: git-worktree (for AI to use)
-	// TODO: Implement with TypeBox schema
 	// =========================================================================
 
-	/* TODO: Add TypeBox dependency and implement tool
 	pi.registerTool({
-		name: "git-worktree",
+		name: "git_worktree",
+		label: "Git Worktree",
 		description:
-			"Manage git worktrees. Create isolated working directories for different branches without switching in the main repository.",
-		input_schema: {
-			type: "object",
-			properties: {
-				action: {
-					type: "string",
-					enum: ["list", "create", "remove"],
-					description: "The worktree action to perform",
-				},
-				branch: {
-					type: "string",
-					description: "Branch name (required for create/remove)",
-				},
-				path: {
-					type: "string",
-					description: "Custom path for worktree (optional for create)",
-				},
-				force: {
-					type: "boolean",
-					description: "Force removal even with uncommitted changes",
-				},
-			},
-			required: ["action"],
-		},
-		async execute(input, ctx) {
-			const { action, branch, path: customPath, force } = input;
+			"Manage git worktrees in ~/.local/share/worktrees/<org>/<repo>/<branch>. Create isolated working directories for different branches without switching in the main repository. USE WHEN starting feature work that needs isolation OR working on multiple branches simultaneously.",
+		parameters: Type.Object({
+			action: Type.Union([
+				Type.Literal("list"),
+				Type.Literal("create"),
+				Type.Literal("remove"),
+			], {
+				description: "The worktree action to perform"
+			}),
+			branch: Type.Optional(Type.String({
+				description: "Branch name (required for create/remove)"
+			})),
+			path: Type.Optional(Type.String({
+				description: "Custom path for worktree (defaults to ~/.local/share/worktrees/<org>/<repo>/<branch>)"
+			})),
+			force: Type.Optional(Type.Boolean({
+				description: "Force removal even with uncommitted changes"
+			})),
+		}),
+		execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
+			const { action, branch, path: customPath, force } = 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)})`;
+						});
+						
 						return {
-							worktrees: worktrees.map((wt) => ({
-								path: wt.path,
-								branch: wt.branch,
-								commit: wt.commit.substring(0, 8),
-								dirty: hasUncommittedChanges(wt.path),
-								locked: wt.locked,
-								prunable: wt.prunable,
-							})),
+							content: [{
+								type: "text",
+								text: `Worktrees:\n${lines.join("\n")}`
+							}],
+							details: {},
 						};
 					}
 
 					case "create": {
 						if (!branch) {
-							throw new Error("Branch name required for create action");
+							return {
+								content: [{
+									type: "text",
+									text: "Error: Branch name required for create action"
+								}],
+								details: { error: true },
+							};
 						}
 
 						const worktreePath = createWorktree(
@@ -294,16 +357,23 @@ export default function (pi: ExtensionAPI) {
 						);
 
 						return {
-							success: true,
-							path: worktreePath,
-							branch,
-							message: `Worktree created at ${worktreePath}`,
+							content: [{
+								type: "text",
+								text: `✓ Worktree created at ${worktreePath}\n\nTo switch: cd ${worktreePath}`
+							}],
+							details: { path: worktreePath, branch },
 						};
 					}
 
 					case "remove": {
 						if (!branch) {
-							throw new Error("Branch name required for remove action");
+							return {
+								content: [{
+									type: "text",
+									text: "Error: Branch name required for remove action"
+								}],
+								details: { error: true },
+							};
 						}
 
 						removeWorktree(
@@ -315,22 +385,32 @@ export default function (pi: ExtensionAPI) {
 						);
 
 						return {
-							success: true,
-							branch,
-							message: `Worktree removed for branch ${branch}`,
+							content: [{
+								type: "text",
+								text: `✓ Worktree removed for branch ${branch}`
+							}],
+							details: { branch },
 						};
 					}
 
 					default:
-						throw new Error(`Unknown action: ${action}`);
+						return {
+							content: [{
+								type: "text",
+								text: `Error: Unknown action: ${action}`
+							}],
+							details: { error: true },
+						};
 				}
 			} catch (error: any) {
 				return {
-					success: false,
-					error: error.message,
+					content: [{
+						type: "text",
+						text: `Error: ${error.message}`
+					}],
+					details: { error: true },
 				};
 			}
 		},
 	});
-	*/
 }
dots/pi/agent/extensions/git/utils.ts
@@ -94,13 +94,73 @@ export function remoteBranchExists(remote: string, branch: string, cwd: string =
 	}
 }
 
-export function getWorktreeDir(repoRoot: string): string {
-	return path.join(repoRoot, ".worktrees");
+export function getRemoteOrgAndRepo(cwd: string = process.cwd()): { org: string; repo: string } | null {
+	try {
+		const remoteUrl = execSync("git remote get-url origin", {
+			cwd,
+			encoding: "utf-8",
+		}).trim();
+
+		// Parse org/repo from various URL formats
+		// SSH: git@github.com:tektoncd/pipeline.git
+		// HTTPS: https://github.com/tektoncd/pipeline.git
+		// Local: /path/to/repo.git or kerkouane.vpn:git/public/home.git
+
+		let org: string | undefined;
+		let repo: string | undefined;
+
+		// SSH format: git@host:org/repo.git
+		const sshMatch = remoteUrl.match(/git@[^:]+:([^/]+)\/([^/.]+)/);
+		if (sshMatch) {
+			org = sshMatch[1];
+			repo = sshMatch[2];
+		}
+
+		// HTTPS format: https://host/org/repo.git
+		const httpsMatch = remoteUrl.match(/https:\/\/[^/]+\/([^/]+)\/([^/.]+)/);
+		if (httpsMatch) {
+			org = httpsMatch[1];
+			repo = httpsMatch[2];
+		}
+
+		// Local SSH without git@: host:path/org/repo.git
+		const localSshMatch = remoteUrl.match(/[^@]+:(?:.*\/)?([^/]+)\/([^/.]+)/);
+		if (localSshMatch && !org) {
+			org = localSshMatch[1];
+			repo = localSshMatch[2];
+		}
+
+		if (!org || !repo) {
+			return null;
+		}
+
+		// Remove .git suffix if present
+		repo = repo.replace(/\.git$/, "");
+
+		return { org, repo };
+	} catch {
+		return null;
+	}
 }
 
-export function ensureWorktreeDir(repoRoot: string): void {
-	const worktreeDir = getWorktreeDir(repoRoot);
-	if (!fs.existsSync(worktreeDir)) {
+export function getWorktreeBaseDir(): string {
+	// XDG data directory: ~/.local/share/worktrees/
+	const home = process.env.HOME || process.env.USERPROFILE || "";
+	return path.join(home, ".local", "share", "worktrees");
+}
+
+export function getWorktreeDir(cwd: string = process.cwd()): string | null {
+	const orgRepo = getRemoteOrgAndRepo(cwd);
+	if (!orgRepo) {
+		return null;
+	}
+
+	return path.join(getWorktreeBaseDir(), orgRepo.org, orgRepo.repo);
+}
+
+export function ensureWorktreeDir(cwd: string = process.cwd()): void {
+	const worktreeDir = getWorktreeDir(cwd);
+	if (worktreeDir && !fs.existsSync(worktreeDir)) {
 		fs.mkdirSync(worktreeDir, { recursive: true });
 	}
 }
dots/pi/agent/extensions/git/worktree.ts
@@ -110,12 +110,17 @@ export function createWorktree(options: WorktreeCreateOptions, cwd: string = pro
 		throw new Error("Not in a git repository");
 	}
 
-	// Ensure .worktrees directory exists
-	ensureWorktreeDir(repoRoot);
+	// Ensure worktree directory exists
+	ensureWorktreeDir(cwd);
 
 	// Determine worktree path
+	const worktreeDir = getWorktreeDir(cwd);
+	if (!worktreeDir) {
+		throw new Error("Could not determine org/repo from git remote");
+	}
+
 	const worktreePath =
-		options.path ?? path.join(getWorktreeDir(repoRoot), sanitizeBranchName(options.branch));
+		options.path ?? path.join(worktreeDir, sanitizeBranchName(options.branch));
 
 	// Check if path already exists
 	if (fs.existsSync(worktreePath)) {
@@ -149,7 +154,7 @@ export function createWorktree(options: WorktreeCreateOptions, cwd: string = pro
 		execSync(cmd, {
 			cwd: repoRoot,
 			encoding: "utf-8",
-			stdio: "inherit",
+			stdio: "pipe", // Capture output instead of inheriting
 		});
 
 		return worktreePath;
@@ -194,7 +199,7 @@ export function removeWorktree(options: WorktreeRemoveOptions, cwd: string = pro
 		execSync(cmd, {
 			cwd: repoRoot,
 			encoding: "utf-8",
-			stdio: "inherit",
+			stdio: "pipe", // Capture output instead of inheriting
 		});
 	} catch (error) {
 		throw new Error(`Failed to remove worktree: ${error}`);
@@ -215,7 +220,7 @@ export function pruneWorktrees(cwd: string = process.cwd()): void {
 		execSync("git worktree prune", {
 			cwd: repoRoot,
 			encoding: "utf-8",
-			stdio: "inherit",
+			stdio: "pipe", // Capture output instead of inheriting
 		});
 	} catch (error) {
 		throw new Error(`Failed to prune worktrees: ${error}`);