Skip to content

Commit b14c215

Browse files
committed
feat: intial worktree wrapper + some tests
1 parent 50f28b0 commit b14c215

2 files changed

Lines changed: 256 additions & 0 deletions

File tree

apps/tui/src/lib/worktree.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { resolve } from "node:path";
3+
import { Worktree } from "./worktree";
4+
5+
type FakeResult = {
6+
cwd(directory: string): FakeResult;
7+
text(): Promise<string>;
8+
};
9+
10+
type FakeShell = (
11+
strings: TemplateStringsArray,
12+
...values: unknown[]
13+
) => FakeResult;
14+
15+
type CommandCall = {
16+
command: string;
17+
cwd?: string;
18+
};
19+
20+
function createFakeShell(outputs: Record<string, string>): {
21+
shell: FakeShell;
22+
calls: CommandCall[];
23+
} {
24+
const calls: CommandCall[] = [];
25+
26+
const shell: FakeShell = (strings, ...values) => {
27+
const command = strings
28+
.reduce((acc, part, idx) => {
29+
const value = idx < values.length ? String(values[idx]) : "";
30+
return `${acc}${part}${value}`;
31+
}, "")
32+
.trim();
33+
34+
const call: CommandCall = { command };
35+
calls.push(call);
36+
37+
return {
38+
cwd(directory: string) {
39+
call.cwd = directory;
40+
return this;
41+
},
42+
async text() {
43+
return outputs[command] ?? "";
44+
},
45+
};
46+
};
47+
48+
return { shell, calls };
49+
}
50+
51+
describe("Worktree", () => {
52+
it("creates a worktree with expected branch and path", async () => {
53+
const repoRoot = "/tmp/project/repo";
54+
const { shell, calls } = createFakeShell({
55+
"git rev-parse --show-toplevel": `${repoRoot}\n`,
56+
});
57+
const worktree = new Worktree(shell);
58+
59+
const info = await worktree.create("worker-1");
60+
61+
expect(info).toEqual({
62+
name: "worker-1",
63+
path: resolve(repoRoot, "..", ".worktrees", "worker-1"),
64+
branch: "worktree/worker-1",
65+
});
66+
expect(calls[1]).toEqual({
67+
command: `git worktree add ${resolve(repoRoot, "..", ".worktrees", "worker-1")} -b worktree/worker-1`,
68+
cwd: repoRoot,
69+
});
70+
});
71+
72+
it("rejects invalid names before running git commands", async () => {
73+
const { shell, calls } = createFakeShell({
74+
"git rev-parse --show-toplevel": "/tmp/project/repo\n",
75+
});
76+
const worktree = new Worktree(shell);
77+
78+
await expect(worktree.create("../escape")).rejects.toThrow("Invalid worktree name");
79+
await expect(worktree.remove(" bad")).rejects.toThrow("Invalid worktree name");
80+
await expect(worktree.merge("a/b")).rejects.toThrow("Invalid worktree name");
81+
expect(calls).toHaveLength(0);
82+
});
83+
84+
it("lists attached and detached worktrees", async () => {
85+
const repoRoot = "/tmp/project/repo";
86+
const { shell } = createFakeShell({
87+
"git rev-parse --show-toplevel": `${repoRoot}\n`,
88+
"git worktree list --porcelain": [
89+
"worktree /tmp/project/repo",
90+
"HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
91+
"branch refs/heads/main",
92+
"",
93+
"worktree /tmp/project/.worktrees/worker-2",
94+
"HEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
95+
"detached",
96+
"",
97+
].join("\n"),
98+
});
99+
const worktree = new Worktree(shell);
100+
101+
await expect(worktree.list()).resolves.toEqual([
102+
{ name: "repo", path: "/tmp/project/repo", branch: "main" },
103+
{
104+
name: "worker-2",
105+
path: "/tmp/project/.worktrees/worker-2",
106+
branch: "HEAD",
107+
},
108+
]);
109+
});
110+
111+
it("fails fast on malformed worktree entries", async () => {
112+
const repoRoot = "/tmp/project/repo";
113+
const { shell } = createFakeShell({
114+
"git rev-parse --show-toplevel": `${repoRoot}\n`,
115+
"git worktree list --porcelain": ["HEAD deadbeef", "branch refs/heads/main", ""].join(
116+
"\n",
117+
),
118+
});
119+
const worktree = new Worktree(shell);
120+
121+
await expect(worktree.list()).rejects.toThrow("Unable to parse worktree entry");
122+
});
123+
124+
it("merge runs merge then remove", async () => {
125+
const repoRoot = "/tmp/project/repo";
126+
const { shell, calls } = createFakeShell({
127+
"git rev-parse --show-toplevel": `${repoRoot}\n`,
128+
});
129+
const worktree = new Worktree(shell);
130+
131+
await worktree.merge("worker-3");
132+
133+
expect(calls.map((call) => call.command)).toEqual([
134+
"git rev-parse --show-toplevel",
135+
"git merge worktree/worker-3",
136+
`git worktree remove ${resolve(repoRoot, "..", ".worktrees", "worker-3")} --force`,
137+
]);
138+
expect(calls[1]?.cwd).toBe(repoRoot);
139+
expect(calls[2]?.cwd).toBe(repoRoot);
140+
});
141+
});

apps/tui/src/lib/worktree.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { $ } from "bun";
2+
import { basename, isAbsolute, relative, resolve } from "node:path";
3+
4+
export interface WorktreeInfo {
5+
name: string;
6+
path: string;
7+
branch: string;
8+
}
9+
10+
type ShellTag = (
11+
strings: TemplateStringsArray,
12+
...values: unknown[]
13+
) => {
14+
cwd(directory: string): { text(): Promise<string> };
15+
text(): Promise<string>;
16+
};
17+
18+
export class Worktree {
19+
constructor(private readonly shell: ShellTag = $) {}
20+
21+
async create(name: string): Promise<WorktreeInfo> {
22+
this.assertValidName(name);
23+
const root = await this.repoRoot();
24+
const path = this.worktreePath(root, name);
25+
const branch = this.branchName(name);
26+
27+
await this.shell`git worktree add ${path} -b ${branch}`.cwd(root).text();
28+
29+
return { name, path, branch };
30+
}
31+
32+
async remove(name: string): Promise<void> {
33+
this.assertValidName(name);
34+
const root = await this.repoRoot();
35+
await this.removeAt(root, name);
36+
}
37+
38+
async merge(name: string): Promise<void> {
39+
this.assertValidName(name);
40+
const root = await this.repoRoot();
41+
const branch = this.branchName(name);
42+
await this.shell`git merge ${branch}`.cwd(root).text();
43+
await this.removeAt(root, name);
44+
}
45+
46+
async list(): Promise<WorktreeInfo[]> {
47+
const root = await this.repoRoot();
48+
const output = await this.shell`git worktree list --porcelain`.cwd(root).text();
49+
50+
if (!output.trim()) {
51+
return [];
52+
}
53+
54+
return output
55+
.trim()
56+
.split("\n\n")
57+
.filter(Boolean)
58+
.map((entry) => {
59+
const lines = entry.split("\n");
60+
const worktreeLine = lines.find((line) => line.startsWith("worktree "));
61+
const branchLine = lines.find((line) => line.startsWith("branch "));
62+
const detached = lines.includes("detached");
63+
64+
if (!worktreeLine) {
65+
throw new Error(`Unable to parse worktree entry: ${entry}`);
66+
}
67+
68+
if (!branchLine && !detached) {
69+
throw new Error(`Unable to parse worktree branch: ${entry}`);
70+
}
71+
72+
const path = worktreeLine.replace("worktree ", "").trim();
73+
const branchRef = branchLine?.replace("branch ", "").trim();
74+
const branch = branchRef ? branchRef.replace("refs/heads/", "") : "HEAD";
75+
const name = basename(path);
76+
77+
return { name, path, branch };
78+
});
79+
}
80+
81+
private async removeAt(root: string, name: string): Promise<void> {
82+
const path = this.worktreePath(root, name);
83+
await this.shell`git worktree remove ${path} --force`.cwd(root).text();
84+
}
85+
86+
private branchName(name: string): string {
87+
return `worktree/${name}`;
88+
}
89+
90+
private worktreePath(root: string, name: string): string {
91+
const base = resolve(root, "..", ".worktrees");
92+
const path = resolve(base, name);
93+
const rel = relative(base, path);
94+
95+
if (isAbsolute(rel) || rel.startsWith("..")) {
96+
throw new Error(`Worktree path escapes base directory: ${name}`);
97+
}
98+
99+
return path;
100+
}
101+
102+
private assertValidName(name: string): void {
103+
if (name.length === 0 || name.trim() !== name) {
104+
throw new Error(`Invalid worktree name: "${name}"`);
105+
}
106+
107+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
108+
throw new Error(`Invalid worktree name: "${name}"`);
109+
}
110+
}
111+
112+
private async repoRoot(): Promise<string> {
113+
return (await this.shell`git rev-parse --show-toplevel`.text()).trim();
114+
}
115+
}

0 commit comments

Comments
 (0)