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,
|
||||
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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue