pi-worktrees/.pi/extensions/pi-worktree.test.ts
David Kong 5bc15a1e69 Milestone 2.1: Implement git_worktree_create tool (#4)
- Add pi-worktree-create.ts with pure validation/command-building functions
- validateCreateWorktreeInput: checks path and branch name validity
- validatePathDoesNotExist: refuses if target path exists (injectable for testing)
- isCurrentBranch: prevents creating worktree on current branch
- buildWorktreeAddCommand: builds correct git command with -b flag support
- Full error messages match issue acceptance criteria exactly
- 17 new tests covering all validation paths and edge cases
2026-07-24 23:18:54 +10:00

257 lines
8 KiB
TypeScript

/**
* Unit tests for parseWorktreeList parser.
*
* Tests cover normal output, edge cases, malformed input, and path normalization.
*/
import { describe, it, expect } from "bun:test";
import { parseWorktreeList } from "./pi-worktree-parser";
import {
validateCreateWorktreeInput,
buildWorktreeAddCommand,
validatePathDoesNotExist,
isCurrentBranch,
} from "./pi-worktree-create";
describe("parseWorktreeList", () => {
describe("normal output", () => {
it("parses a single worktree entry with branch in brackets", () => {
const rawOutput = "/home/user/project abc1234 [main]";
const result = parseWorktreeList(rawOutput, "/home/user/project");
expect(result).toHaveLength(1);
expect(result[0].path).toBe("/home/user/project");
expect(result[0].commit).toBe("abc1234");
expect(result[0].branch).toBe("main");
expect(result[0].isCurrent).toBe(true);
});
it("parses multiple worktree entries", () => {
const rawOutput = `/home/user/project abc1234 [main]
/home/user/feature-x def5678 [feature-x]`;
const result = parseWorktreeList(rawOutput, "/home/user/project");
expect(result).toHaveLength(2);
expect(result[0].branch).toBe("main");
expect(result[0].isCurrent).toBe(true);
expect(result[1].branch).toBe("feature-x");
expect(result[1].isCurrent).toBe(false);
});
it("handles detached HEAD entries", () => {
const rawOutput = "/home/user/detached fedcba9 HEAD";
const result = parseWorktreeList(rawOutput, "/other/path");
expect(result).toHaveLength(1);
expect(result[0].branch).toBe("(detached)");
expect(result[0].commit).toBe("fedcba9");
});
});
describe("path normalization (Task 001)", () => {
it("marks isCurrent=true when paths match with trailing slash difference", () => {
const rawOutput = "/home/user/project abc1234 [main]";
const result = parseWorktreeList(rawOutput, "/home/user/project/");
expect(result[0].isCurrent).toBe(true);
});
it("marks isCurrent=true when paths match with different casing in components", () => {
const rawOutput = "/home/user/Project abc1234 [main]";
// On Linux, path.resolve preserves case — this tests normalization of slashes
const result = parseWorktreeList(rawOutput, "/home/user/Project/");
expect(result[0].isCurrent).toBe(true);
});
});
describe("malformed input validation (Task 002)", () => {
it("skips lines with fewer than 3 tokens", () => {
const rawOutput = `/home/user/project abc1234 [main]
/incomplete/path`;
const result = parseWorktreeList(rawOutput, "/other");
expect(result).toHaveLength(1);
expect(result[0].path).toBe("/home/user/project");
});
it("skips lines with only 2 tokens (missing branch)", () => {
const rawOutput = `/home/user/project abc1234 [main]
/incomplete/path def5678`;
const result = parseWorktreeList(rawOutput, "/other");
expect(result).toHaveLength(1);
});
it("returns empty array for completely malformed input", () => {
const rawOutput = `just some random text`;
const result = parseWorktreeList(rawOutput, "/any/path");
expect(result).toHaveLength(0);
});
});
describe("empty input (Task 006)", () => {
it("returns empty array for empty string", () => {
const result = parseWorktreeList("", "/some/path");
expect(result).toHaveLength(0);
});
it("returns empty array for whitespace-only input", () => {
const result = parseWorktreeList(" \n \n ", "/some/path");
expect(result).toHaveLength(0);
});
});
describe("branch names with special characters", () => {
it("handles branch names with hyphens and slashes", () => {
const rawOutput = "/home/user/feature abc1234 [feature/my-branch-name]";
const result = parseWorktreeList(rawOutput, "/other");
expect(result).toHaveLength(1);
expect(result[0].branch).toBe("feature/my-branch-name");
});
it("handles branch names with underscores", () => {
const rawOutput = "/home/user/feature abc1234 [issue_42_fix]";
const result = parseWorktreeList(rawOutput, "/other");
expect(result).toHaveLength(1);
expect(result[0].branch).toBe("issue_42_fix");
});
});
});
describe("validateCreateWorktreeInput (Issue #4)", () => {
it("returns null for valid path and branch", () => {
const result = validateCreateWorktreeInput("/tmp/new-worktree", "feature-x");
expect(result).toBeNull();
});
it("returns error for empty path", () => {
const result = validateCreateWorktreeInput("", "feature-x");
expect(result).not.toBeNull();
if (result) expect(result.message.toLowerCase()).toContain("path");
});
it("returns error for empty branch name", () => {
const result = validateCreateWorktreeInput("/tmp/new-worktree", "");
expect(result).not.toBeNull();
if (result) expect(result.message.toLowerCase()).toContain("branch");
});
it("returns error for path with only whitespace", () => {
const result = validateCreateWorktreeInput(" ", "feature-x");
expect(result).not.toBeNull();
});
it("returns error for branch name with invalid characters (spaces)", () => {
const result = validateCreateWorktreeInput("/tmp/new-worktree", "my bad branch");
expect(result).not.toBeNull();
if (result) expect(result.message).toContain("branch");
});
it("accepts branch names with hyphens and slashes", () => {
const result = validateCreateWorktreeInput("/tmp/new-worktree", "feature/my-branch-name");
expect(result).toBeNull();
});
it("accepts branch names with underscores", () => {
const result = validateCreateWorktreeInput("/tmp/new-worktree", "issue_42_fix");
expect(result).toBeNull();
});
});
describe("buildWorktreeAddCommand (Issue #4)", () => {
it("returns command with -b flag when create_branch is true", () => {
const result = buildWorktreeAddCommand({
path: "/tmp/new-worktree",
branch: "feature-x",
createBranch: true,
});
expect(result).toEqual([
"worktree",
"add",
"-b",
"feature-x",
"/tmp/new-worktree",
]);
});
it("returns command without -b flag when create_branch is false", () => {
const result = buildWorktreeAddCommand({
path: "/tmp/new-worktree",
branch: "existing-branch",
createBranch: false,
});
expect(result).toEqual([
"worktree",
"add",
"existing-branch",
"/tmp/new-worktree",
]);
});
it("defaults createBranch to true when not provided", () => {
const result = buildWorktreeAddCommand({
path: "/tmp/new-worktree",
branch: "new-feature",
});
expect(result).toContain("-b");
});
it("places path as the last argument", () => {
const result = buildWorktreeAddCommand({
path: "/tmp/new-worktree",
branch: "feature-x",
createBranch: true,
});
expect(result[result.length - 1]).toBe("/tmp/new-worktree");
});
});
describe("validatePathDoesNotExist (Issue #4)", () => {
it("returns null when path does not exist", () => {
const result = validatePathDoesNotExist(
"/tmp/nonexistent-path-abc123",
(p) => p === "/existing/path",
);
expect(result).toBeNull();
});
it("returns error with path in message when path exists", () => {
const existingPath = "/tmp/existing-worktree";
const result = validatePathDoesNotExist(existingPath, (p) => p === existingPath);
expect(result).not.toBeNull();
if (result) expect(result.message).toContain("/tmp/existing-worktree");
if (result) expect(result.message.toLowerCase()).toContain("already exists");
});
it("suggests choosing a different location", () => {
const existingPath = "/tmp/existing-worktree";
const result = validatePathDoesNotExist(existingPath, (p) => p === existingPath);
expect(result).not.toBeNull();
if (result) expect(result.suggestion?.toLowerCase()).toContain("different");
});
});
describe("isCurrentBranch (Issue #4)", () => {
it("returns true when branch matches current branch", () => {
expect(isCurrentBranch("main", "main")).toBe(true);
});
it("returns false when branch differs from current branch", () => {
expect(isCurrentBranch("feature-x", "main")).toBe(false);
});
it("handles branch names with slashes", () => {
expect(isCurrentBranch("feature/my-branch", "main")).toBe(false);
expect(isCurrentBranch("feature/my-branch", "feature/my-branch")).toBe(true);
});
});