Commit e8fb21ed5051
Changed files (3)
dots
pi
agent
extensions
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}`);