Skip to content

Commit 966ee6c

Browse files
feat: git worktree wrapper (#13)
Co-authored-by: Chenxin Yan <[email protected]>
1 parent 1f71d19 commit 966ee6c

3 files changed

Lines changed: 318 additions & 0 deletions

File tree

.changeset/nice-cougars-share.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@techatnyu/ralph": patch
3+
---
4+
5+
Add worktree wrapper helpers and tests for the TUI.

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

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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(
79+
"Invalid worktree name",
80+
);
81+
await expect(worktree.remove(" bad")).rejects.toThrow(
82+
"Invalid worktree name",
83+
);
84+
await expect(worktree.merge("a/b")).rejects.toThrow(
85+
"Invalid worktree name",
86+
);
87+
expect(calls).toHaveLength(0);
88+
});
89+
90+
it("lists attached and detached worktrees", async () => {
91+
const repoRoot = "/tmp/project/repo";
92+
const { shell } = createFakeShell({
93+
"git rev-parse --show-toplevel": `${repoRoot}\n`,
94+
"git worktree list --porcelain": [
95+
"worktree /tmp/project/repo",
96+
"HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
97+
"branch refs/heads/main",
98+
"",
99+
"worktree /tmp/project/.worktrees/worker-2",
100+
"HEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
101+
"detached",
102+
"",
103+
].join("\n"),
104+
});
105+
const worktree = new Worktree(shell);
106+
107+
await expect(worktree.list()).resolves.toEqual([
108+
{ name: "repo", path: "/tmp/project/repo", branch: "main" },
109+
{
110+
name: "worker-2",
111+
path: "/tmp/project/.worktrees/worker-2",
112+
branch: "HEAD",
113+
},
114+
]);
115+
});
116+
117+
it("fails fast on malformed worktree entries", async () => {
118+
const repoRoot = "/tmp/project/repo";
119+
const { shell } = createFakeShell({
120+
"git rev-parse --show-toplevel": `${repoRoot}\n`,
121+
"git worktree list --porcelain": [
122+
"HEAD deadbeef",
123+
"branch refs/heads/main",
124+
"",
125+
].join("\n"),
126+
});
127+
const worktree = new Worktree(shell);
128+
129+
await expect(worktree.list()).rejects.toThrow(
130+
"Unable to parse worktree entry",
131+
);
132+
});
133+
134+
it("removes a worktree without force by default", async () => {
135+
const repoRoot = "/tmp/project/repo";
136+
const { shell, calls } = createFakeShell({
137+
"git rev-parse --show-toplevel": `${repoRoot}\n`,
138+
});
139+
const worktree = new Worktree(shell);
140+
141+
await worktree.remove("worker-4");
142+
143+
expect(calls.map((call) => call.command)).toEqual([
144+
"git rev-parse --show-toplevel",
145+
`git worktree remove ${resolve(repoRoot, "..", ".worktrees", "worker-4")}`,
146+
]);
147+
expect(calls[1]?.cwd).toBe(repoRoot);
148+
});
149+
150+
it("removes a worktree with force when specified", async () => {
151+
const repoRoot = "/tmp/project/repo";
152+
const { shell, calls } = createFakeShell({
153+
"git rev-parse --show-toplevel": `${repoRoot}\n`,
154+
});
155+
const worktree = new Worktree(shell);
156+
157+
await worktree.remove("worker-4", { force: true });
158+
159+
expect(calls.map((call) => call.command)).toEqual([
160+
"git rev-parse --show-toplevel",
161+
`git worktree remove ${resolve(repoRoot, "..", ".worktrees", "worker-4")} --force`,
162+
]);
163+
expect(calls[1]?.cwd).toBe(repoRoot);
164+
});
165+
166+
it("merge runs merge, force-removes worktree, and deletes branch", async () => {
167+
const repoRoot = "/tmp/project/repo";
168+
const { shell, calls } = createFakeShell({
169+
"git rev-parse --show-toplevel": `${repoRoot}\n`,
170+
});
171+
const worktree = new Worktree(shell);
172+
173+
await worktree.merge("worker-3");
174+
175+
expect(calls.map((call) => call.command)).toEqual([
176+
"git rev-parse --show-toplevel",
177+
"git merge worktree/worker-3",
178+
`git worktree remove ${resolve(repoRoot, "..", ".worktrees", "worker-3")} --force`,
179+
"git branch -d worktree/worker-3",
180+
]);
181+
expect(calls[1]?.cwd).toBe(repoRoot);
182+
expect(calls[2]?.cwd).toBe(repoRoot);
183+
expect(calls[3]?.cwd).toBe(repoRoot);
184+
});
185+
});

apps/tui/src/lib/worktree.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { basename, isAbsolute, relative, resolve } from "node:path";
2+
import { $, type ShellExpression } from "bun";
3+
4+
export interface WorktreeInfo {
5+
name: string;
6+
path: string;
7+
branch: string;
8+
}
9+
10+
type ShellTag = (
11+
strings: TemplateStringsArray,
12+
...values: ShellExpression[]
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, { force = false } = {}): Promise<void> {
33+
this.assertValidName(name);
34+
const root = await this.repoRoot();
35+
await this.removeAt(root, name, force);
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, true);
44+
await this.shell`git branch -d ${branch}`.cwd(root).text();
45+
}
46+
47+
async list(): Promise<WorktreeInfo[]> {
48+
const root = await this.repoRoot();
49+
const output = await this.shell`git worktree list --porcelain`
50+
.cwd(root)
51+
.text();
52+
53+
if (!output.trim()) {
54+
return [];
55+
}
56+
57+
return output
58+
.trim()
59+
.split("\n\n")
60+
.filter(Boolean)
61+
.map((entry) => {
62+
const lines = entry.split("\n");
63+
const worktreeLine = lines.find((line) => line.startsWith("worktree "));
64+
const branchLine = lines.find((line) => line.startsWith("branch "));
65+
const detached = lines.includes("detached");
66+
67+
if (!worktreeLine) {
68+
throw new Error(`Unable to parse worktree entry: ${entry}`);
69+
}
70+
71+
if (!branchLine && !detached) {
72+
throw new Error(`Unable to parse worktree branch: ${entry}`);
73+
}
74+
75+
const path = worktreeLine.replace("worktree ", "").trim();
76+
const branchRef = branchLine?.replace("branch ", "").trim();
77+
const branch = branchRef
78+
? branchRef.replace("refs/heads/", "")
79+
: "HEAD";
80+
const name = basename(path);
81+
82+
return { name, path, branch };
83+
});
84+
}
85+
86+
private async removeAt(
87+
root: string,
88+
name: string,
89+
force = false,
90+
): Promise<void> {
91+
const path = this.worktreePath(root, name);
92+
if (force) {
93+
await this.shell`git worktree remove ${path} --force`.cwd(root).text();
94+
} else {
95+
await this.shell`git worktree remove ${path}`.cwd(root).text();
96+
}
97+
}
98+
99+
private branchName(name: string): string {
100+
return `worktree/${name}`;
101+
}
102+
103+
private worktreePath(root: string, name: string): string {
104+
const base = resolve(root, "..", ".worktrees");
105+
const path = resolve(base, name);
106+
const rel = relative(base, path);
107+
108+
if (isAbsolute(rel) || rel.startsWith("..")) {
109+
throw new Error(`Worktree path escapes base directory: ${name}`);
110+
}
111+
112+
return path;
113+
}
114+
115+
private assertValidName(name: string): void {
116+
if (name.length === 0 || name.trim() !== name) {
117+
throw new Error(`Invalid worktree name: "${name}"`);
118+
}
119+
120+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
121+
throw new Error(`Invalid worktree name: "${name}"`);
122+
}
123+
}
124+
125+
private async repoRoot(): Promise<string> {
126+
return (await this.shell`git rev-parse --show-toplevel`.text()).trim();
127+
}
128+
}

0 commit comments

Comments
 (0)