feat: implement git_worktree_switch tool (Issue #6) #19

Open
david wants to merge 1 commit from issue-6 into main
3 changed files with 276 additions and 22 deletions
Showing only changes of commit 39dd626f10 - Show all commits

View 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);
});
});

View 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);
}

View file

@ -31,6 +31,11 @@ import {
hasUncommittedChanges,
buildWorktreeRemoveCommand,
} from "./pi-worktree-remove";
import {
validateSwitchInput,
resolveWorktreeTarget,
isAlreadyInWorktree,
} from "./pi-worktree-switch";
// ─── Type Definitions ──────────────────────────────────────────────
@ -102,23 +107,6 @@ async function getCurrentBranch(pi: ExtensionAPI, ctx: ExtensionContext): Promis
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.
* @param error - The validation error
@ -272,19 +260,76 @@ function createCreateWorktreeTool(pi: ExtensionAPI) {
/**
* 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
*/
function createSwitchWorktreeTool(_pi: ExtensionAPI) {
function createSwitchWorktreeTool(pi: ExtensionAPI) {
return defineTool({
name: "git_worktree_switch",
label: "Git Worktree Switch",
description: "Switch pi's working directory to a linked worktree.",
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 {
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." }],
isError: true,
content: [{ type: "text", text: `Switched to worktree at '${resolvedPath}' (branch: ${targetBranch}).` }],
details: {
previousCwd: ctx.cwd,
newCwd: resolvedPath,
branch: targetBranch,
},
};
},
});