feat: implement git_worktree_switch tool (Issue #6) #19
3 changed files with 276 additions and 22 deletions
141
.pi/extensions/pi-worktree-switch.test.ts
Normal file
141
.pi/extensions/pi-worktree-switch.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for git_worktree_switch validation and resolution functions.
|
||||||
|
*
|
||||||
|
* Tests cover input validation, target resolution, already-there check,
|
||||||
|
* and the integrated tool execute path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
validateSwitchInput,
|
||||||
|
resolveWorktreeTarget,
|
||||||
|
isAlreadyInWorktree,
|
||||||
|
} from "./pi-worktree-switch";
|
||||||
|
import type { WorktreeEntry } from "./pi-worktree-parser";
|
||||||
|
|
||||||
|
// ─── validateSwitchInput ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("validateSwitchInput (Issue #6)", () => {
|
||||||
|
it("returns null for valid absolute path", () => {
|
||||||
|
const result = validateSwitchInput("/tmp/worktrees/feature-x");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for valid relative path", () => {
|
||||||
|
const result = validateSwitchInput("../worktrees/feature-x");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for valid branch name", () => {
|
||||||
|
const result = validateSwitchInput("feature-x");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for branch name with slashes", () => {
|
||||||
|
const result = validateSwitchInput("feature/my-branch");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for empty target", () => {
|
||||||
|
const result = validateSwitchInput("");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
if (result) expect(result.message).toContain("cannot be empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for whitespace-only target", () => {
|
||||||
|
const result = validateSwitchInput(" ");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes suggestion in error for empty target", () => {
|
||||||
|
const result = validateSwitchInput("");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
if (result) expect(result.suggestion).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resolveWorktreeTarget ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("resolveWorktreeTarget (Issue #6)", () => {
|
||||||
|
const worktrees: WorktreeEntry[] = [
|
||||||
|
{ path: "/home/user/project", commit: "abc1234", branch: "main", isCurrent: true },
|
||||||
|
{ path: "/tmp/feature-x", commit: "def5678", branch: "feature-x", isCurrent: false },
|
||||||
|
{ path: "/tmp/bugfix", commit: "fedcba9", branch: "bugfix/issue-42", isCurrent: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("resolves by exact path match", () => {
|
||||||
|
const result = resolveWorktreeTarget("/tmp/feature-x", worktrees);
|
||||||
|
expect(result).toBe("/tmp/feature-x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves by branch name", () => {
|
||||||
|
const result = resolveWorktreeTarget("feature-x", worktrees);
|
||||||
|
expect(result).toBe("/tmp/feature-x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves branch name with slashes", () => {
|
||||||
|
const result = resolveWorktreeTarget("bugfix/issue-42", worktrees);
|
||||||
|
expect(result).toBe("/tmp/bugfix");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for unknown target", () => {
|
||||||
|
const result = resolveWorktreeTarget("nonexistent", worktrees);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for unknown path", () => {
|
||||||
|
const result = resolveWorktreeTarget("/tmp/does-not-exist", worktrees);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves main repo by path", () => {
|
||||||
|
const result = resolveWorktreeTarget("/home/user/project", worktrees);
|
||||||
|
expect(result).toBe("/home/user/project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves main repo by branch name", () => {
|
||||||
|
const result = resolveWorktreeTarget("main", worktrees);
|
||||||
|
expect(result).toBe("/home/user/project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers exact path match over branch name match", () => {
|
||||||
|
// If a target matches both a path and a branch name, path wins
|
||||||
|
const result = resolveWorktreeTarget("/tmp/feature-x", worktrees);
|
||||||
|
expect(result).toBe("/tmp/feature-x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for empty worktree list", () => {
|
||||||
|
const result = resolveWorktreeTarget("anything", []);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── isAlreadyInWorktree ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("isAlreadyInWorktree (Issue #6)", () => {
|
||||||
|
it("returns true when cwd matches target path exactly", () => {
|
||||||
|
const result = isAlreadyInWorktree("/tmp/feature-x", "/tmp/feature-x");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when cwd differs from target path", () => {
|
||||||
|
const result = isAlreadyInWorktree("/tmp/feature-x", "/home/user/project");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when paths match with trailing slash difference", () => {
|
||||||
|
const result = isAlreadyInWorktree("/tmp/feature-x/", "/tmp/feature-x");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when both have trailing slashes", () => {
|
||||||
|
const result = isAlreadyInWorktree("/tmp/feature-x/", "/tmp/feature-x/");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles relative target path resolved against cwd", () => {
|
||||||
|
// If target is relative and resolves to same as cwd, already there
|
||||||
|
const result = isAlreadyInWorktree(".", "/tmp/feature-x");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
.pi/extensions/pi-worktree-switch.ts
Normal file
68
.pi/extensions/pi-worktree-switch.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* Git worktree switch — pure validation and resolution functions.
|
||||||
|
* No pi SDK dependencies for testability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from "path";
|
||||||
|
import type { WorktreeError } from "./pi-worktree-create";
|
||||||
|
import type { WorktreeEntry } from "./pi-worktree-parser";
|
||||||
|
|
||||||
|
// ─── Input Validation ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate input parameters for switching worktrees.
|
||||||
|
*
|
||||||
|
* Checks that target is non-empty and well-formed.
|
||||||
|
*
|
||||||
|
* @param target - Path or branch name of the target worktree
|
||||||
|
* @returns null if valid, or a WorktreeError describing the problem
|
||||||
|
*/
|
||||||
|
export function validateSwitchInput(target: string): WorktreeError | null {
|
||||||
|
if (!target || !target.trim()) {
|
||||||
|
return {
|
||||||
|
message: "Target cannot be empty.",
|
||||||
|
suggestion: "Provide a valid worktree path or branch name. Use git_worktree_list to see available worktrees.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Target Resolution ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a worktree target (path or branch name) to its full path.
|
||||||
|
*
|
||||||
|
* First tries an exact path match, then falls back to branch name lookup.
|
||||||
|
*
|
||||||
|
* @param target - Path or branch name to resolve
|
||||||
|
* @param worktrees - List of all worktrees
|
||||||
|
* @returns The resolved path, or undefined if not found
|
||||||
|
*/
|
||||||
|
export function resolveWorktreeTarget(
|
||||||
|
target: string,
|
||||||
|
worktrees: WorktreeEntry[],
|
||||||
|
): string | undefined {
|
||||||
|
// Try exact path match first
|
||||||
|
const exactMatch = worktrees.find((w) => w.path === target);
|
||||||
|
if (exactMatch) return exactMatch.path;
|
||||||
|
|
||||||
|
// Fall back to branch name match
|
||||||
|
const branchMatch = worktrees.find((w) => w.branch === target);
|
||||||
|
return branchMatch?.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Already-There Check ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current working directory is already the target worktree.
|
||||||
|
*
|
||||||
|
* Compares resolved paths to handle trailing slashes and relative paths.
|
||||||
|
*
|
||||||
|
* @param targetPath - The target worktree path
|
||||||
|
* @param currentCwd - Current working directory
|
||||||
|
* @returns true if already in the target worktree
|
||||||
|
*/
|
||||||
|
export function isAlreadyInWorktree(targetPath: string, currentCwd: string): boolean {
|
||||||
|
return path.resolve(currentCwd, targetPath) === path.resolve(currentCwd);
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,11 @@ import {
|
||||||
hasUncommittedChanges,
|
hasUncommittedChanges,
|
||||||
buildWorktreeRemoveCommand,
|
buildWorktreeRemoveCommand,
|
||||||
} from "./pi-worktree-remove";
|
} from "./pi-worktree-remove";
|
||||||
|
import {
|
||||||
|
validateSwitchInput,
|
||||||
|
resolveWorktreeTarget,
|
||||||
|
isAlreadyInWorktree,
|
||||||
|
} from "./pi-worktree-switch";
|
||||||
|
|
||||||
// ─── Type Definitions ──────────────────────────────────────────────
|
// ─── Type Definitions ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -102,23 +107,6 @@ async function getCurrentBranch(pi: ExtensionAPI, ctx: ExtensionContext): Promis
|
||||||
return result.stdout.trim();
|
return result.stdout.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a worktree target (path or branch name) to its full path.
|
|
||||||
*
|
|
||||||
* Used by git_worktree_switch — dead code until that tool ships.
|
|
||||||
*
|
|
||||||
* @param target - Path or branch name to resolve
|
|
||||||
* @param worktrees - List of all worktrees
|
|
||||||
* @returns The resolved path, or undefined if not found
|
|
||||||
*/
|
|
||||||
function resolveWorktreeTarget(target: string, worktrees: WorktreeEntry[]): string | undefined {
|
|
||||||
const exactMatch = worktrees.find((w) => w.path === target);
|
|
||||||
if (exactMatch) return exactMatch.path;
|
|
||||||
|
|
||||||
const branchMatch = worktrees.find((w) => w.branch === target);
|
|
||||||
return branchMatch?.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a WorktreeError into the tool response text.
|
* Format a WorktreeError into the tool response text.
|
||||||
* @param error - The validation error
|
* @param error - The validation error
|
||||||
|
|
@ -272,19 +260,76 @@ function createCreateWorktreeTool(pi: ExtensionAPI) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the git_worktree_switch tool definition.
|
* Create the git_worktree_switch tool definition.
|
||||||
* @param _pi - Extension API (unused in stub)
|
* @param pi - Extension API for executing commands
|
||||||
* @returns The configured tool definition
|
* @returns The configured tool definition
|
||||||
*/
|
*/
|
||||||
function createSwitchWorktreeTool(_pi: ExtensionAPI) {
|
function createSwitchWorktreeTool(pi: ExtensionAPI) {
|
||||||
return defineTool({
|
return defineTool({
|
||||||
name: "git_worktree_switch",
|
name: "git_worktree_switch",
|
||||||
label: "Git Worktree Switch",
|
label: "Git Worktree Switch",
|
||||||
description: "Switch pi's working directory to a linked worktree.",
|
description: "Switch pi's working directory to a linked worktree.",
|
||||||
parameters: switchWorktreeParams,
|
parameters: switchWorktreeParams,
|
||||||
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const target = params.target;
|
||||||
|
|
||||||
|
// 1. Validate input parameters
|
||||||
|
const inputError = validateSwitchInput(target);
|
||||||
|
if (inputError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatValidationError(inputError) }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. List worktrees and resolve target to a path
|
||||||
|
const listResult = await pi.exec("git", ["worktree", "list"], { cwd: ctx.cwd });
|
||||||
|
if (listResult.exitCode !== 0) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Error listing worktrees: ${listResult.stderr}. Suggestion: Ensure you are inside a git repository.` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreeEntries = parseWorktreeList(listResult.stdout, ctx.cwd);
|
||||||
|
const resolvedPath = resolveWorktreeTarget(target, worktreeEntries);
|
||||||
|
|
||||||
|
if (!resolvedPath) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Error: No worktree found at '${target}'. Suggestion: Run 'git_worktree_list' to see available worktrees.` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check if already in that worktree — refuse if so
|
||||||
|
if (isAlreadyInWorktree(resolvedPath, ctx.cwd)) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Already in this worktree." }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check for uncommitted changes in source (current worktree)
|
||||||
|
const statusResult = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
|
||||||
|
if (statusResult.exitCode === 0 && hasUncommittedChanges(statusResult.stdout)) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Error: Worktree '${ctx.cwd}' has uncommitted changes. Suggestion: Stash them first with 'git stash' before switching.` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Change pi's cwd to the target path
|
||||||
|
ctx.chdir(resolvedPath);
|
||||||
|
|
||||||
|
// 6. Get branch info for the response
|
||||||
|
const targetBranch = worktreeEntries.find((w) => w.path === resolvedPath)?.branch ?? "unknown";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: "Error: git_worktree_switch is not yet implemented. Suggestion: Use the `/worktree` command or `git worktree` manually until this tool is complete." }],
|
content: [{ type: "text", text: `Switched to worktree at '${resolvedPath}' (branch: ${targetBranch}).` }],
|
||||||
isError: true,
|
details: {
|
||||||
|
previousCwd: ctx.cwd,
|
||||||
|
newCwd: resolvedPath,
|
||||||
|
branch: targetBranch,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue