Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions apps/tui/src/lib/worktree.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
};

type FakeShell = (
strings: TemplateStringsArray,
...values: unknown[]
) => FakeResult;

type CommandCall = {
command: string;
cwd?: string;
};

function createFakeShell(outputs: Record<string, string>): {
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);
});
});
115 changes: 115 additions & 0 deletions apps/tui/src/lib/worktree.ts
Original file line number Diff line number Diff line change
@@ -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<string> };
text(): Promise<string>;
};

export class Worktree {
constructor(private readonly shell: ShellTag = $) {}

async create(name: string): Promise<WorktreeInfo> {
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<void> {
this.assertValidName(name);
const root = await this.repoRoot();
await this.removeAt(root, name);
}

async merge(name: string): Promise<void> {
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<WorktreeInfo[]> {
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<void> {
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<string> {
return (await this.shell`git rev-parse --show-toplevel`.text()).trim();
}
}