diff --git a/apps/tui/src/lib/worktree.test.ts b/apps/tui/src/lib/worktree.test.ts new file mode 100644 index 0000000..2aca6a5 --- /dev/null +++ b/apps/tui/src/lib/worktree.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test"; +import { resolve } from "node:path"; +import { Worktree } from "./worktree"; + +type FakeResult = { + cwd(directory: string): FakeResult; + text(): Promise; +}; + +type FakeShell = ( + strings: TemplateStringsArray, + ...values: unknown[] +) => FakeResult; + +type CommandCall = { + command: string; + cwd?: string; +}; + +function createFakeShell(outputs: Record): { + shell: FakeShell; + calls: CommandCall[]; +} { + const calls: CommandCall[] = []; + + const shell: FakeShell = (strings, ...values) => { + const command = strings + .reduce((acc, part, idx) => { + const value = idx < values.length ? String(values[idx]) : ""; + return `${acc}${part}${value}`; + }, "") + .trim(); + + const call: CommandCall = { command }; + calls.push(call); + + return { + cwd(directory: string) { + call.cwd = directory; + return this; + }, + async text() { + return outputs[command] ?? ""; + }, + }; + }; + + return { shell, calls }; +} + +describe("Worktree", () => { + it("creates a worktree with expected branch and path", async () => { + const repoRoot = "/tmp/project/repo"; + const { shell, calls } = createFakeShell({ + "git rev-parse --show-toplevel": `${repoRoot}\n`, + }); + const worktree = new Worktree(shell); + + const info = await worktree.create("worker-1"); + + expect(info).toEqual({ + name: "worker-1", + path: resolve(repoRoot, "..", ".worktrees", "worker-1"), + branch: "worktree/worker-1", + }); + expect(calls[1]).toEqual({ + command: `git worktree add ${resolve(repoRoot, "..", ".worktrees", "worker-1")} -b worktree/worker-1`, + cwd: repoRoot, + }); + }); + + it("rejects invalid names before running git commands", async () => { + const { shell, calls } = createFakeShell({ + "git rev-parse --show-toplevel": "/tmp/project/repo\n", + }); + const worktree = new Worktree(shell); + + await expect(worktree.create("../escape")).rejects.toThrow("Invalid worktree name"); + await expect(worktree.remove(" bad")).rejects.toThrow("Invalid worktree name"); + await expect(worktree.merge("a/b")).rejects.toThrow("Invalid worktree name"); + expect(calls).toHaveLength(0); + }); + + it("lists attached and detached worktrees", async () => { + const repoRoot = "/tmp/project/repo"; + const { shell } = createFakeShell({ + "git rev-parse --show-toplevel": `${repoRoot}\n`, + "git worktree list --porcelain": [ + "worktree /tmp/project/repo", + "HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "branch refs/heads/main", + "", + "worktree /tmp/project/.worktrees/worker-2", + "HEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "detached", + "", + ].join("\n"), + }); + const worktree = new Worktree(shell); + + await expect(worktree.list()).resolves.toEqual([ + { name: "repo", path: "/tmp/project/repo", branch: "main" }, + { + name: "worker-2", + path: "/tmp/project/.worktrees/worker-2", + branch: "HEAD", + }, + ]); + }); + + it("fails fast on malformed worktree entries", async () => { + const repoRoot = "/tmp/project/repo"; + const { shell } = createFakeShell({ + "git rev-parse --show-toplevel": `${repoRoot}\n`, + "git worktree list --porcelain": ["HEAD deadbeef", "branch refs/heads/main", ""].join( + "\n", + ), + }); + const worktree = new Worktree(shell); + + await expect(worktree.list()).rejects.toThrow("Unable to parse worktree entry"); + }); + + it("merge runs merge then remove", async () => { + const repoRoot = "/tmp/project/repo"; + const { shell, calls } = createFakeShell({ + "git rev-parse --show-toplevel": `${repoRoot}\n`, + }); + const worktree = new Worktree(shell); + + await worktree.merge("worker-3"); + + expect(calls.map((call) => call.command)).toEqual([ + "git rev-parse --show-toplevel", + "git merge worktree/worker-3", + `git worktree remove ${resolve(repoRoot, "..", ".worktrees", "worker-3")} --force`, + ]); + expect(calls[1]?.cwd).toBe(repoRoot); + expect(calls[2]?.cwd).toBe(repoRoot); + }); +}); diff --git a/apps/tui/src/lib/worktree.ts b/apps/tui/src/lib/worktree.ts new file mode 100644 index 0000000..d0d1e22 --- /dev/null +++ b/apps/tui/src/lib/worktree.ts @@ -0,0 +1,115 @@ +import { $ } from "bun"; +import { basename, isAbsolute, relative, resolve } from "node:path"; + +export interface WorktreeInfo { + name: string; + path: string; + branch: string; +} + +type ShellTag = ( + strings: TemplateStringsArray, + ...values: unknown[] +) => { + cwd(directory: string): { text(): Promise }; + text(): Promise; +}; + +export class Worktree { + constructor(private readonly shell: ShellTag = $) {} + + async create(name: string): Promise { + this.assertValidName(name); + const root = await this.repoRoot(); + const path = this.worktreePath(root, name); + const branch = this.branchName(name); + + await this.shell`git worktree add ${path} -b ${branch}`.cwd(root).text(); + + return { name, path, branch }; + } + + async remove(name: string): Promise { + this.assertValidName(name); + const root = await this.repoRoot(); + await this.removeAt(root, name); + } + + async merge(name: string): Promise { + this.assertValidName(name); + const root = await this.repoRoot(); + const branch = this.branchName(name); + await this.shell`git merge ${branch}`.cwd(root).text(); + await this.removeAt(root, name); + } + + async list(): Promise { + const root = await this.repoRoot(); + const output = await this.shell`git worktree list --porcelain`.cwd(root).text(); + + if (!output.trim()) { + return []; + } + + return output + .trim() + .split("\n\n") + .filter(Boolean) + .map((entry) => { + const lines = entry.split("\n"); + const worktreeLine = lines.find((line) => line.startsWith("worktree ")); + const branchLine = lines.find((line) => line.startsWith("branch ")); + const detached = lines.includes("detached"); + + if (!worktreeLine) { + throw new Error(`Unable to parse worktree entry: ${entry}`); + } + + if (!branchLine && !detached) { + throw new Error(`Unable to parse worktree branch: ${entry}`); + } + + const path = worktreeLine.replace("worktree ", "").trim(); + const branchRef = branchLine?.replace("branch ", "").trim(); + const branch = branchRef ? branchRef.replace("refs/heads/", "") : "HEAD"; + const name = basename(path); + + return { name, path, branch }; + }); + } + + private async removeAt(root: string, name: string): Promise { + const path = this.worktreePath(root, name); + await this.shell`git worktree remove ${path} --force`.cwd(root).text(); + } + + private branchName(name: string): string { + return `worktree/${name}`; + } + + private worktreePath(root: string, name: string): string { + const base = resolve(root, "..", ".worktrees"); + const path = resolve(base, name); + const rel = relative(base, path); + + if (isAbsolute(rel) || rel.startsWith("..")) { + throw new Error(`Worktree path escapes base directory: ${name}`); + } + + return path; + } + + private assertValidName(name: string): void { + if (name.length === 0 || name.trim() !== name) { + throw new Error(`Invalid worktree name: "${name}"`); + } + + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) { + throw new Error(`Invalid worktree name: "${name}"`); + } + } + + private async repoRoot(): Promise { + return (await this.shell`git rev-parse --show-toplevel`.text()).trim(); + } +}