diff --git a/.pi/extensions/pi-worktree-switch.test.ts b/.pi/extensions/pi-worktree-switch.test.ts new file mode 100644 index 0000000..16e2771 --- /dev/null +++ b/.pi/extensions/pi-worktree-switch.test.ts @@ -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); + }); +}); diff --git a/.pi/extensions/pi-worktree-switch.ts b/.pi/extensions/pi-worktree-switch.ts new file mode 100644 index 0000000..51c5ff5 --- /dev/null +++ b/.pi/extensions/pi-worktree-switch.ts @@ -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); +} diff --git a/.pi/extensions/pi-worktree.ts b/.pi/extensions/pi-worktree.ts index 5f8f949..fd18fd4 100644 --- a/.pi/extensions/pi-worktree.ts +++ b/.pi/extensions/pi-worktree.ts @@ -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, + }, }; }, });