Critical fixes:
- Align create_branch schema description with runtime default (true)
- Add ^ and . to branch name start-of-name validation regex
Major fixes:
- Move dynamic import('node:fs/promises') to static top-level import
- Add integration tests for git_worktree_create execute flow
- Use WorktreeError + formatValidationError for current-branch error
Minor fixes:
- Replace fragile .toContain() assertions with specific strings
- Add 14 new tests for branch name start/end and dangerous char validation
- Document resolveWorktreeTarget as dead code until switch/remove tools ship
Suggestions implemented:
- Reject leading dot in branch names (git compliance)
- Extract formatSuccessResponse() helper for consistency
444 lines
14 KiB
TypeScript
444 lines
14 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).toContain("cannot be empty");
|
|
});
|
|
|
|
it("returns error for empty branch name", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/new-worktree", "");
|
|
expect(result).not.toBeNull();
|
|
if (result) expect(result.message).toContain("cannot be empty");
|
|
});
|
|
|
|
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("invalid characters");
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
// Task 007: Branch name start/end validation
|
|
it("rejects branch name starting with dash", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "-feature");
|
|
expect(result).not.toBeNull();
|
|
if (result) expect(result.message).toContain("invalid character");
|
|
});
|
|
|
|
it("rejects branch name starting with slash", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "/feature");
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it("rejects branch name starting with tilde", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "~user");
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it("rejects branch name starting with caret", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "^feature");
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it("rejects branch name starting with dot", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", ".hidden");
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it("rejects branch name ending with slash", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "feature/");
|
|
expect(result).not.toBeNull();
|
|
if (result) expect(result.message).toContain("invalid character");
|
|
});
|
|
|
|
it("rejects branch name ending with dash", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "feature-");
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it("rejects branch name ending with dot", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "feature.");
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
// Task 008: Dangerous character validation
|
|
it("rejects branch name with square brackets", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "feature[v1]");
|
|
expect(result).not.toBeNull();
|
|
if (result) expect(result.message).toContain("invalid characters");
|
|
});
|
|
|
|
it("rejects branch name with double quotes", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", 'my"branch');
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it("rejects branch name with single quotes", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "my'branch");
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it("rejects branch name with backslash", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "feature\\name");
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it("rejects branch name with asterisk", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "feature*name");
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it("rejects branch name with question mark", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "feature?name");
|
|
expect(result).not.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);
|
|
});
|
|
});
|
|
|
|
// ─── Integration Tests for git_worktree_create execute flow (Task 004) ────
|
|
|
|
describe("git_worktree_create execute flow (Issue #4)", () => {
|
|
// Minimal mock types matching what the extension needs
|
|
interface MockExecResult {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
interface MockContext {
|
|
cwd: string;
|
|
}
|
|
|
|
interface MockPI {
|
|
exec: (cmd: string, args: string[], opts?: { cwd: string }) => Promise<MockExecResult>;
|
|
}
|
|
|
|
describe("validation pipeline ordering", () => {
|
|
it("rejects empty path before checking filesystem", async () => {
|
|
const result = validateCreateWorktreeInput("", "feature-x");
|
|
expect(result).not.toBeNull();
|
|
expect(result?.message).toContain("cannot be empty");
|
|
});
|
|
|
|
it("rejects invalid branch name before checking filesystem", async () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "-bad-name");
|
|
expect(result).not.toBeNull();
|
|
expect(result?.message).toContain("invalid character");
|
|
});
|
|
});
|
|
|
|
describe("path existence check", () => {
|
|
it("returns error when path exists", () => {
|
|
const existingPath = "/tmp/existing-worktree";
|
|
const result = validatePathDoesNotExist(existingPath, (p) => p === existingPath);
|
|
expect(result).not.toBeNull();
|
|
expect(result?.message).toContain("/tmp/existing-worktree");
|
|
expect(result?.suggestion).toContain("different");
|
|
});
|
|
|
|
it("returns null when path does not exist", () => {
|
|
const result = validatePathDoesNotExist(
|
|
"/tmp/nonexistent-path-abc123",
|
|
(p) => p === "/existing/path",
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("current branch check", () => {
|
|
it("detects when target matches current branch", () => {
|
|
expect(isCurrentBranch("main", "main")).toBe(true);
|
|
});
|
|
|
|
it("allows different branch from current", () => {
|
|
expect(isCurrentBranch("feature-x", "main")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("command building after all validations pass", () => {
|
|
it("builds correct command with createBranch=true", () => {
|
|
const args = buildWorktreeAddCommand({
|
|
path: "/tmp/new-worktree",
|
|
branch: "feature-x",
|
|
createBranch: true,
|
|
});
|
|
expect(args).toEqual(["worktree", "add", "-b", "feature-x", "/tmp/new-worktree"]);
|
|
});
|
|
|
|
it("builds correct command with createBranch=false", () => {
|
|
const args = buildWorktreeAddCommand({
|
|
path: "/tmp/new-worktree",
|
|
branch: "existing-branch",
|
|
createBranch: false,
|
|
});
|
|
expect(args).toEqual(["worktree", "add", "existing-branch", "/tmp/new-worktree"]);
|
|
});
|
|
|
|
it("defaults createBranch to true when omitted", () => {
|
|
const args = buildWorktreeAddCommand({
|
|
path: "/tmp/new-worktree",
|
|
branch: "new-feature",
|
|
});
|
|
expect(args).toContain("-b");
|
|
});
|
|
});
|
|
|
|
describe("error formatting consistency", () => {
|
|
it("formats input validation error with suggestion", () => {
|
|
const result = validateCreateWorktreeInput("", "feature-x");
|
|
expect(result).not.toBeNull();
|
|
expect(result?.suggestion).toBeDefined();
|
|
expect(result?.suggestion).toContain("Provide a valid path");
|
|
});
|
|
|
|
it("formats path exists error with suggestion", () => {
|
|
const result = validatePathDoesNotExist("/tmp/x", () => true);
|
|
expect(result).not.toBeNull();
|
|
expect(result?.suggestion).toBeDefined();
|
|
expect(result?.suggestion).toContain("different");
|
|
});
|
|
|
|
it("formats branch name error with suggestion", () => {
|
|
const result = validateCreateWorktreeInput("/tmp/x", "bad name here");
|
|
expect(result).not.toBeNull();
|
|
expect(result?.suggestion).toBeDefined();
|
|
expect(result?.suggestion).toContain("hyphens");
|
|
});
|
|
});
|
|
});
|