Repository navigation
Expand file tree
/
Copy pathcommit.test.ts
More file actions
257 lines (245 loc) · 14.1 KB
/
Copy pathcommit.test.ts
File metadata and controls
257 lines (245 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile, mkdir, symlink } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { promisify } from "node:util";
import { reviewCommit, finishCommit } from "./commit";
import { experimental_createHostEntryHarness } from "@get-bb/plugin-sdk/testing/host";
import hostEntry from "./host";
process.env.GIT_CONFIG_GLOBAL = "/dev/null";
process.env.GIT_CONFIG_NOSYSTEM = "1";
const run = promisify(execFile);
const MESSAGE = "Make the selected behavior clearer.\n\n- Explain the reason for the selected changes.\n- Keep unrelated work available for a separate commit.\n";
async function repo() {
const root = await mkdtemp(path.join(os.tmpdir(), "bb-commit-test-"));
const git = async (...args: string[]) => (await run("git", ["-C", root, ...args])).stdout;
await git("init", "-b", "feature");
await git("config", "user.name", "Test");
await git("config", "user.email", "test@example.invalid");
await git("config", "commit.gpgsign", "false");
for (const name of ["a.txt", "b.txt", "deleted.txt", " leading.txt"]) await writeFile(path.join(root, name), "base\n");
await git("add", "--", "a.txt", "b.txt", "deleted.txt", " leading.txt");
await git("commit", "-m", "Initial fixture");
const message = path.join(root, "message.txt");
await writeFile(message, MESSAGE);
return { root, git, message, close: () => rm(root, { recursive: true, force: true }) };
}
test("review leaves staging untouched; finish commits literal paths, renames, deletions and untracked files while preserving unrelated staging", async () => {
const r = await repo();
try {
await writeFile(path.join(r.root, "a.txt"), "reviewed\n");
await writeFile(path.join(r.root, "b.txt"), "staged unrelated\n");
await r.git("add", "--", "b.txt");
await writeFile(path.join(r.root, "b.txt"), "unstaged unrelated\n");
await r.git("mv", "deleted.txt", "renamed.txt");
await rm(path.join(r.root, " leading.txt"));
await writeFile(path.join(r.root, "literal[1].txt"), "new\n");
const paths = ["a.txt", "deleted.txt", "renamed.txt", "literal[1].txt", " leading.txt"];
const staged = await r.git("diff", "--cached", "--binary");
const beforeHead = await r.git("rev-parse", "HEAD");
const reviewed = await reviewCommit(r.root, paths);
assert.match(reviewed.diff, /reviewed/);
assert.match(reviewed.diff, /literal\[1\]/);
assert.equal(await r.git("diff", "--cached", "--binary"), staged);
const result = await finishCommit(r.root, paths, reviewed.snapshot, r.message, false);
assert.equal(result.error, null, result.error ?? undefined);
assert.equal(result.pushed, false);
assert.notEqual(result.commit + "\n", beforeHead);
assert.equal(await r.git("show", "HEAD:a.txt"), "reviewed\n");
assert.equal(await r.git("show", "HEAD:b.txt"), "base\n");
assert.equal(await r.git("show", ":b.txt"), "staged unrelated\n");
assert.equal(await readFile(path.join(r.root, "b.txt"), "utf8"), "unstaged unrelated\n");
assert.equal(await r.git("diff", "--cached", "--name-only"), "b.txt\n");
assert.equal(await r.git("show", "--format=%B", "--no-patch", "HEAD"), MESSAGE + "\n");
assert.equal(await r.git("diff", "HEAD", "--", ...paths), "");
} finally { await r.close(); }
});
test("finish refuses changed working files, staging, HEAD and branches without mutating the real index", async () => {
for (const changed of ["files", "staging", "head", "branch"]) {
const r = await repo();
try {
await writeFile(path.join(r.root, "a.txt"), "reviewed\n");
const { snapshot } = await reviewCommit(r.root, ["a.txt"]);
if (changed === "files") await writeFile(path.join(r.root, "a.txt"), "new edit\n");
if (changed === "staging") { await writeFile(path.join(r.root, "b.txt"), "new staged\n"); await r.git("add", "--", "b.txt"); }
if (changed === "head") await r.git("commit", "--allow-empty", "-m", "Concurrent commit");
if (changed === "branch") await r.git("switch", "-c", "other");
const head = await r.git("rev-parse", "HEAD");
const index = await r.git("ls-files", "--stage", "-z");
const result = await finishCommit(r.root, ["a.txt"], snapshot, r.message, false);
assert.match(result.error!, /changed since review/, changed);
assert.equal(result.commit, null);
assert.equal(await r.git("rev-parse", "HEAD"), head);
assert.equal(await r.git("ls-files", "--stage", "-z"), index);
} finally { await r.close(); }
}
});
test("repository hooks run, and failed hooks or hook edits stop before committing with the original staging preserved", async () => {
for (const hook of ["reject", "edit", "stage", "message"]) {
const r = await repo();
try {
await writeFile(path.join(r.root, "a.txt"), "reviewed\n");
await writeFile(path.join(r.root, "b.txt"), "unrelated staged\n");
await r.git("add", "--", "b.txt");
const dir = path.join(r.root, "custom-hooks");
await mkdir(dir);
await r.git("config", "core.hooksPath", hook === "stage" ? "custom-hooks" : dir);
const script = {
reject: "echo hook-ran > hook-marker\nexit 1\n",
edit: "echo hook-ran > hook-marker\necho hook-edit > a.txt\n",
stage: "echo hook-ran > hook-marker\ngit add -- b.txt\n",
message: 'echo hook-ran > hook-marker\necho changed-message > "$1"\n',
}[hook]!;
await writeFile(path.join(dir, hook === "message" ? "commit-msg" : "pre-commit"), "#!/bin/sh\n" + script, { mode: 0o700 });
const { snapshot } = await reviewCommit(r.root, ["a.txt"]);
const head = await r.git("rev-parse", "HEAD");
const staged = await r.git("diff", "--cached", "--binary");
const result = await finishCommit(r.root, ["a.txt"], snapshot, r.message, false);
assert.ok(result.error, hook);
assert.equal(result.commit, null);
assert.equal(await readFile(path.join(r.root, "hook-marker"), "utf8"), "hook-ran\n");
assert.equal(await r.git("rev-parse", "HEAD"), head);
assert.equal(await r.git("diff", "--cached", "--binary"), staged);
} finally { await r.close(); }
}
});
test("repository hooks retain Git glob and exclusion pathspecs", async () => {
const r = await repo();
try {
await writeFile(path.join(r.root, "a.txt"), "reviewed\n");
await writeFile(path.join(r.root, ".git/hooks/pre-commit"), `#!/bin/sh
set -eu
test -z "$(git diff --cached --name-only -- . ':(exclude)a.txt')"
test "$(git diff --cached --name-only -- '*.txt')" = a.txt
`, { mode: 0o700 });
const { snapshot } = await reviewCommit(r.root, ["a.txt"]);
const result = await finishCommit(r.root, ["a.txt"], snapshot, r.message, false);
assert.equal(result.error, null, result.error ?? undefined);
assert.ok(result.commit);
} finally { await r.close(); }
});
test("finish ignores a staged addition removed from disk when another selected file changed", async () => {
const r = await repo();
try {
await writeFile(path.join(r.root, "gone.txt"), "temporary\n");
await r.git("add", "--", "gone.txt");
await rm(path.join(r.root, "gone.txt"));
await writeFile(path.join(r.root, "a.txt"), "reviewed\n");
const paths = ["a.txt", "gone.txt"];
const { snapshot } = await reviewCommit(r.root, paths);
const result = await finishCommit(r.root, paths, snapshot, r.message, false);
assert.equal(result.error, null, result.error ?? undefined);
assert.equal(await r.git("show", "HEAD:a.txt"), "reviewed\n");
assert.equal(await r.git("ls-files", "--", "gone.txt"), "");
assert.equal(await r.git("diff", "--cached"), "");
} finally { await r.close(); }
});
test("a failed post-commit hook reports the local commit and prevents pushing", async () => {
const remote = await mkdtemp(path.join(os.tmpdir(), "bb-commit-remote-"));
const r = await repo();
try {
await run("git", ["init", "--bare", remote]);
await r.git("remote", "add", "origin", remote);
await r.git("push", "origin", "HEAD:refs/heads/feature");
const before = await r.git("rev-parse", "HEAD");
await writeFile(path.join(r.root, "a.txt"), "reviewed\n");
await writeFile(path.join(r.root, ".git/hooks/post-commit"), "#!/bin/sh\nexit 1\n", { mode: 0o700 });
const { snapshot } = await reviewCommit(r.root, ["a.txt"]);
const result = await finishCommit(r.root, ["a.txt"], snapshot, r.message, true);
assert.match(result.error!, /post-commit.*failed/);
assert.ok(result.commit);
assert.equal(result.pushed, false);
assert.equal(await r.git("rev-parse", "HEAD"), result.commit + "\n");
assert.equal(await r.git("diff", "--cached"), "");
assert.equal((await run("git", ["--git-dir", remote, "rev-parse", "refs/heads/feature"])).stdout, before);
} finally { await r.close(); await rm(remote, { recursive: true, force: true }); }
});
test("push targets the current branch even with a differently named upstream; rejected pushes keep the local commit", async () => {
const remote = await mkdtemp(path.join(os.tmpdir(), "bb-commit-remote-"));
const r = await repo();
try {
await run("git", ["init", "--bare", remote]);
await r.git("remote", "add", "origin", remote);
await r.git("push", "origin", "HEAD:refs/heads/main");
await r.git("config", "branch.feature.remote", "origin");
await r.git("config", "branch.feature.merge", "refs/heads/main");
const main = (await run("git", ["--git-dir", remote, "rev-parse", "refs/heads/main"])).stdout;
await writeFile(path.join(r.root, "a.txt"), "reviewed\n");
const { snapshot } = await reviewCommit(r.root, ["a.txt"]);
const result = await finishCommit(r.root, ["a.txt"], snapshot, r.message, true);
assert.equal(result.error, null, result.error ?? undefined);
assert.equal(result.pushed, true);
assert.equal((await run("git", ["--git-dir", remote, "rev-parse", "refs/heads/main"])).stdout, main);
assert.equal((await run("git", ["--git-dir", remote, "rev-parse", "refs/heads/feature"])).stdout.trim(), result.commit);
await writeFile(path.join(remote, "hooks", "pre-receive"), "#!/bin/sh\nexit 1\n", { mode: 0o700 });
await writeFile(path.join(r.root, "a.txt"), "second review\n");
const next = await reviewCommit(r.root, ["a.txt"]);
const rejected = await finishCommit(r.root, ["a.txt"], next.snapshot, r.message, true);
assert.match(rejected.error!, /git push failed/);
assert.ok(rejected.commit);
assert.equal(rejected.pushed, false);
assert.equal((await r.git("rev-parse", "HEAD")).trim(), rejected.commit);
assert.equal(await r.git("diff", "--cached"), "");
} finally { await r.close(); await rm(remote, { recursive: true, force: true }); }
});
test("unborn repositories work; unsafe selections and message attribution are rejected", async () => {
const r = await repo();
try {
await r.git("checkout", "--orphan", "fresh");
await writeFile(path.join(r.root, "a.txt"), "initial reviewed\n");
const reviewed = await reviewCommit(r.root, ["a.txt"]);
assert.equal(reviewed.snapshot.head, null);
const result = await finishCommit(r.root, ["a.txt"], reviewed.snapshot, r.message, false);
assert.equal(result.error, null, result.error ?? undefined);
await assert.rejects(reviewCommit(r.root, ["../outside"]), /inside the repository/);
await mkdir(path.join(r.root, "directory"));
await assert.rejects(reviewCommit(r.root, ["directory"]), /individual files/);
await symlink(os.tmpdir(), path.join(r.root, "outside"));
await assert.rejects(reviewCommit(r.root, ["outside/file"]), /escapes the workspace/);
await writeFile(r.message, MESSAGE + "Co-Authored-By: Bot\n");
await assert.rejects(finishCommit(r.root, ["a.txt"], reviewed.snapshot, r.message, false), /no Co-Authored-By/);
} finally { await r.close(); }
});
test("host RPC works in a linked worktree and refuses an existing index lock without removing it", async () => {
const r = await repo();
const directory = await mkdtemp(path.join(os.tmpdir(), "bb-commit-worktree-"));
const root = path.join(directory, "checkout");
const host = experimental_createHostEntryHarness(hostEntry);
try {
await r.git("worktree", "add", "-b", "linked", root);
await writeFile(path.join(root, "a.txt"), "linked reviewed\n");
const messageFile = path.join(root, "message.txt");
await writeFile(messageFile, MESSAGE);
const review = await host.experimental_call("reviewCommit", { root, paths: ["a.txt"] });
const index = (await run("git", ["-C", root, "rev-parse", "--path-format=absolute", "--git-path", "index"])).stdout.trim();
await writeFile(`${index}.lock`, "another operation");
const blocked = await host.experimental_call("finishCommit", { root, paths: ["a.txt"], snapshot: review.snapshot, messageFile, push: false });
assert.equal(blocked.commit, null);
assert.match(blocked.error!, /EEXIST/);
assert.equal(await readFile(`${index}.lock`, "utf8"), "another operation");
await rm(`${index}.lock`);
const result = await host.experimental_call("finishCommit", { root, paths: ["a.txt"], snapshot: review.snapshot, messageFile, push: false });
assert.equal(result.error, null, result.error ?? undefined);
assert.equal(await r.git("show", "HEAD:a.txt"), "base\n");
assert.equal((await run("git", ["-C", root, "show", "HEAD:a.txt"])).stdout, "linked reviewed\n");
} finally {
await host.experimental_dispose();
await rm(directory, { recursive: true, force: true });
await r.close();
}
});
test("finish accepts Claude's shared /tmp message directory as well as the host's per-user TMPDIR", async () => {
const r = await repo();
const directory = await mkdtemp("/tmp/bb-commit-message-");
try {
const message = path.join(directory, "message.txt");
await writeFile(message, MESSAGE);
await writeFile(path.join(r.root, "a.txt"), "reviewed\n");
const { snapshot } = await reviewCommit(r.root, ["a.txt"]);
const result = await finishCommit(r.root, ["a.txt"], snapshot, message, false);
assert.equal(result.error, null, result.error ?? undefined);
assert.ok(result.commit);
} finally { await r.close(); await rm(directory, { recursive: true, force: true }); }
});