feat: implement git_worktree_switch tool (Issue #6)
- Add pi-worktree-switch.ts with pure functions: - validateSwitchInput: validates target is non-empty - resolveWorktreeTarget: resolves path or branch name to worktree path - isAlreadyInWorktree: checks if cwd matches target - Wire up createSwitchWorktreeTool with full validation pipeline: 1. Validate input parameters 2. List worktrees and resolve target to path 3. Refuse if already in target worktree 4. Fail fast on uncommitted changes in source 5. Change cwd via ctx.chdir() 6. Return success with branch info - Remove dead resolveWorktreeTarget from main module - 21 unit tests, 103 total passing, 0 regressions
This commit is contained in:
parent
8761c3af5f
commit
39dd626f10
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