Skip to content
Open
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
185 changes: 185 additions & 0 deletions apps/tui/src/lib/worktree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
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("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`,
});
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`,
"git branch -d worktree/worker-3",
]);
expect(calls[1]?.cwd).toBe(repoRoot);
expect(calls[2]?.cwd).toBe(repoRoot);
expect(calls[3]?.cwd).toBe(repoRoot);
});
});
128 changes: 128 additions & 0 deletions apps/tui/src/lib/worktree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { basename, isAbsolute, relative, resolve } from "node:path";
import { type ShellExpression, $ } from "bun";

export interface WorktreeInfo {
name: string;
path: string;
branch: string;
}

type ShellTag = (
strings: TemplateStringsArray,
...values: ShellExpression[]
) => {
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, { force = false } = {}): Promise<void> {
this.assertValidName(name);
const root = await this.repoRoot();
await this.removeAt(root, name, force);
}

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, true);
await this.shell`git branch -d ${branch}`.cwd(root).text();
}

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,
force = false,
): Promise<void> {
const path = this.worktreePath(root, name);
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 {
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();
}
}
Loading