From cfc3a91f3d7b5a850a20279117c18949b84f47d9 Mon Sep 17 00:00:00 2001 From: PatrickYu17 Date: Tue, 10 Mar 2026 03:02:15 -0400 Subject: [PATCH 1/4] feat: intial worktree wrapper + some tests --- apps/tui/src/lib/worktree.test.ts | 141 ++++++++++++++++++++++++++++++ apps/tui/src/lib/worktree.ts | 115 ++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 apps/tui/src/lib/worktree.test.ts create mode 100644 apps/tui/src/lib/worktree.ts 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(); + } +} From 169ea8260c711719253e3e04a81aa38824a3356d Mon Sep 17 00:00:00 2001 From: Chenxin Yan Date: Wed, 25 Mar 2026 20:03:43 -0400 Subject: [PATCH 2/4] add force for remove --- apps/tui/src/lib/worktree.test.ts | 36 ++++++++++++++++++++++++++++++- apps/tui/src/lib/worktree.ts | 15 ++++++++----- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/tui/src/lib/worktree.test.ts b/apps/tui/src/lib/worktree.test.ts index 2aca6a5..c7d758f 100644 --- a/apps/tui/src/lib/worktree.test.ts +++ b/apps/tui/src/lib/worktree.test.ts @@ -121,7 +121,39 @@ describe("Worktree", () => { await expect(worktree.list()).rejects.toThrow("Unable to parse worktree entry"); }); - it("merge runs merge then remove", async () => { + it("removes a worktree without force by default", async () => { + const repoRoot = "/tmp/project/repo"; + const { shell, calls } = createFakeShell({ + "git rev-parse --show-toplevel": `${repoRoot}\n`, + }); + const worktree = new Worktree(shell); + + await worktree.remove("worker-4"); + + expect(calls.map((call) => call.command)).toEqual([ + "git rev-parse --show-toplevel", + `git worktree remove ${resolve(repoRoot, "..", ".worktrees", "worker-4")}`, + ]); + expect(calls[1]?.cwd).toBe(repoRoot); + }); + + it("removes a worktree with force when specified", async () => { + const repoRoot = "/tmp/project/repo"; + const { shell, calls } = createFakeShell({ + "git rev-parse --show-toplevel": `${repoRoot}\n`, + }); + const worktree = new Worktree(shell); + + await worktree.remove("worker-4", { force: true }); + + expect(calls.map((call) => call.command)).toEqual([ + "git rev-parse --show-toplevel", + `git worktree remove ${resolve(repoRoot, "..", ".worktrees", "worker-4")} --force`, + ]); + expect(calls[1]?.cwd).toBe(repoRoot); + }); + + it("merge runs merge, force-removes worktree, and deletes branch", async () => { const repoRoot = "/tmp/project/repo"; const { shell, calls } = createFakeShell({ "git rev-parse --show-toplevel": `${repoRoot}\n`, @@ -134,8 +166,10 @@ describe("Worktree", () => { "git rev-parse --show-toplevel", "git merge worktree/worker-3", `git worktree remove ${resolve(repoRoot, "..", ".worktrees", "worker-3")} --force`, + "git branch -d worktree/worker-3", ]); expect(calls[1]?.cwd).toBe(repoRoot); expect(calls[2]?.cwd).toBe(repoRoot); + expect(calls[3]?.cwd).toBe(repoRoot); }); }); diff --git a/apps/tui/src/lib/worktree.ts b/apps/tui/src/lib/worktree.ts index d0d1e22..7457247 100644 --- a/apps/tui/src/lib/worktree.ts +++ b/apps/tui/src/lib/worktree.ts @@ -29,10 +29,10 @@ export class Worktree { return { name, path, branch }; } - async remove(name: string): Promise { + async remove(name: string, { force = false } = {}): Promise { this.assertValidName(name); const root = await this.repoRoot(); - await this.removeAt(root, name); + await this.removeAt(root, name, force); } async merge(name: string): Promise { @@ -40,7 +40,8 @@ export class Worktree { const root = await this.repoRoot(); const branch = this.branchName(name); await this.shell`git merge ${branch}`.cwd(root).text(); - await this.removeAt(root, name); + await this.removeAt(root, name, true); + await this.shell`git branch -d ${branch}`.cwd(root).text(); } async list(): Promise { @@ -78,9 +79,13 @@ export class Worktree { }); } - private async removeAt(root: string, name: string): Promise { + private async removeAt(root: string, name: string, force = false): Promise { const path = this.worktreePath(root, name); - await this.shell`git worktree remove ${path} --force`.cwd(root).text(); + if (force) { + await this.shell`git worktree remove ${path} --force`.cwd(root).text(); + } else { + await this.shell`git worktree remove ${path}`.cwd(root).text(); + } } private branchName(name: string): string { From dce5cc7999c6f56199daa614e7c44cd78b757dc6 Mon Sep 17 00:00:00 2001 From: Chenxin Yan Date: Wed, 25 Mar 2026 20:05:22 -0400 Subject: [PATCH 3/4] lint --- apps/tui/src/lib/worktree.test.ts | 24 +++++++++++++++++------- apps/tui/src/lib/worktree.ts | 16 ++++++++++++---- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/tui/src/lib/worktree.test.ts b/apps/tui/src/lib/worktree.test.ts index c7d758f..0f42a12 100644 --- a/apps/tui/src/lib/worktree.test.ts +++ b/apps/tui/src/lib/worktree.test.ts @@ -75,9 +75,15 @@ describe("Worktree", () => { }); 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"); + 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); }); @@ -112,13 +118,17 @@ describe("Worktree", () => { 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", - ), + "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"); + await expect(worktree.list()).rejects.toThrow( + "Unable to parse worktree entry", + ); }); it("removes a worktree without force by default", async () => { diff --git a/apps/tui/src/lib/worktree.ts b/apps/tui/src/lib/worktree.ts index 7457247..03f1000 100644 --- a/apps/tui/src/lib/worktree.ts +++ b/apps/tui/src/lib/worktree.ts @@ -1,5 +1,5 @@ -import { $ } from "bun"; import { basename, isAbsolute, relative, resolve } from "node:path"; +import { $ } from "bun"; export interface WorktreeInfo { name: string; @@ -46,7 +46,9 @@ export class Worktree { async list(): Promise { const root = await this.repoRoot(); - const output = await this.shell`git worktree list --porcelain`.cwd(root).text(); + const output = await this.shell`git worktree list --porcelain` + .cwd(root) + .text(); if (!output.trim()) { return []; @@ -72,14 +74,20 @@ export class Worktree { const path = worktreeLine.replace("worktree ", "").trim(); const branchRef = branchLine?.replace("branch ", "").trim(); - const branch = branchRef ? branchRef.replace("refs/heads/", "") : "HEAD"; + const branch = branchRef + ? branchRef.replace("refs/heads/", "") + : "HEAD"; const name = basename(path); return { name, path, branch }; }); } - private async removeAt(root: string, name: string, force = false): Promise { + private async removeAt( + root: string, + name: string, + force = false, + ): Promise { const path = this.worktreePath(root, name); if (force) { await this.shell`git worktree remove ${path} --force`.cwd(root).text(); From 72a98229c3c833db0b7b06e808bd5655cb435ad6 Mon Sep 17 00:00:00 2001 From: Chenxin Yan Date: Wed, 25 Mar 2026 20:09:29 -0400 Subject: [PATCH 4/4] fix type checks --- apps/tui/src/lib/worktree.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/tui/src/lib/worktree.ts b/apps/tui/src/lib/worktree.ts index 03f1000..1daa840 100644 --- a/apps/tui/src/lib/worktree.ts +++ b/apps/tui/src/lib/worktree.ts @@ -1,5 +1,5 @@ import { basename, isAbsolute, relative, resolve } from "node:path"; -import { $ } from "bun"; +import { type ShellExpression, $ } from "bun"; export interface WorktreeInfo { name: string; @@ -9,7 +9,7 @@ export interface WorktreeInfo { type ShellTag = ( strings: TemplateStringsArray, - ...values: unknown[] + ...values: ShellExpression[] ) => { cwd(directory: string): { text(): Promise }; text(): Promise;