test(git): add comprehensive tests for bun:git APIs

Add tests for error handling, edge cases, and temporary repository
scenarios including status detection, diff operations, and detached
HEAD state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sosuke Suzuki
2026-02-04 13:29:45 +09:00
parent c9dc5dd381
commit 0b6d896adf

View File

@@ -1,5 +1,8 @@
import { Commit, DeltaType, Repository, Status, StatusEntry } from "bun:git";
import { describe, expect, test } from "bun:test";
import { tempDir } from "harness";
import { unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
describe("bun:git", () => {
describe("Repository", () => {
@@ -38,6 +41,13 @@ describe("bun:git", () => {
test("Repository.open throws for non-repository path", () => {
expect(() => Repository.open("/tmp")).toThrow();
});
test("Repository.open works with .git directory path", () => {
const repo = Repository.open("./.git");
expect(repo).toBeInstanceOf(Repository);
expect(repo.path).toEndWith(".git/");
});
});
describe("Commit", () => {
@@ -148,18 +158,67 @@ describe("bun:git", () => {
expect(Array.isArray(withoutUntracked)).toBe(true);
});
test("getStatus with all options", () => {
const repo = Repository.open(".");
// Should not throw with various option combinations
expect(() =>
repo.getStatus({
includeUntracked: true,
includeIgnored: false,
recurseUntrackedDirs: true,
detectRenames: false,
}),
).not.toThrow();
expect(() =>
repo.getStatus({
includeUntracked: false,
includeIgnored: true,
recurseUntrackedDirs: false,
detectRenames: true,
}),
).not.toThrow();
});
test("Status constants are defined", () => {
expect(Status.CURRENT).toBe(0);
expect(Status.INDEX_NEW).toBe(1);
expect(Status.INDEX_MODIFIED).toBe(2);
expect(Status.INDEX_DELETED).toBe(4);
expect(Status.INDEX_RENAMED).toBe(8);
expect(Status.INDEX_TYPECHANGE).toBe(16);
expect(Status.WT_NEW).toBe(128);
expect(Status.WT_MODIFIED).toBe(256);
expect(Status.WT_DELETED).toBe(512);
expect(Status.WT_TYPECHANGE).toBe(1024);
expect(Status.WT_RENAMED).toBe(2048);
expect(Status.IGNORED).toBe(16384);
expect(Status.CONFLICTED).toBe(32768);
});
test("StatusEntry helper methods work correctly with status flags", () => {
// Create a StatusEntry-like object manually to test helpers
const entry = new StatusEntry({ path: "test.txt", status: Status.WT_NEW });
expect(entry.isNew()).toBe(true);
expect(entry.isModified()).toBe(false);
expect(entry.inWorkingTree()).toBe(true);
expect(entry.inIndex()).toBe(false);
const modifiedEntry = new StatusEntry({ path: "test.txt", status: Status.INDEX_MODIFIED });
expect(modifiedEntry.isModified()).toBe(true);
expect(modifiedEntry.isNew()).toBe(false);
expect(modifiedEntry.inIndex()).toBe(true);
const deletedEntry = new StatusEntry({ path: "test.txt", status: Status.WT_DELETED });
expect(deletedEntry.isDeleted()).toBe(true);
const renamedEntry = new StatusEntry({ path: "test.txt", status: Status.INDEX_RENAMED });
expect(renamedEntry.isRenamed()).toBe(true);
const ignoredEntry = new StatusEntry({ path: "test.txt", status: Status.IGNORED });
expect(ignoredEntry.isIgnored()).toBe(true);
});
});
describe("revParse", () => {
@@ -182,11 +241,61 @@ describe("bun:git", () => {
expect(parent).not.toBe(head);
});
test("revParse resolves HEAD^", () => {
const repo = Repository.open(".");
const parent1 = repo.revParse("HEAD~1");
const parent2 = repo.revParse("HEAD^");
// HEAD^ and HEAD~1 should be the same for non-merge commits
expect(parent1).toBe(parent2);
});
test("revParse resolves HEAD~n for various n", () => {
const repo = Repository.open(".");
const head = repo.revParse("HEAD");
const parent1 = repo.revParse("HEAD~1");
const parent2 = repo.revParse("HEAD~2");
const parent5 = repo.revParse("HEAD~5");
// All should be different and valid
expect(head).not.toBe(parent1);
expect(parent1).not.toBe(parent2);
expect(parent2).not.toBe(parent5);
// All should be valid OIDs
expect(parent5).toMatch(/^[0-9a-f]{40}$/);
});
test("revParse resolves short SHA", () => {
const repo = Repository.open(".");
const head = repo.head();
const shortSha = head.id.slice(0, 7);
const resolved = repo.revParse(shortSha);
expect(resolved).toBe(head.id);
});
test("revParse throws for invalid spec", () => {
const repo = Repository.open(".");
expect(() => repo.revParse("invalid-ref-that-does-not-exist")).toThrow();
});
test("revParse throws for empty string", () => {
const repo = Repository.open(".");
expect(() => repo.revParse("")).toThrow();
});
test("revParse result matches head().id for HEAD", () => {
const repo = Repository.open(".");
const headFromRevParse = repo.revParse("HEAD");
const headFromHead = repo.head().id;
expect(headFromRevParse).toBe(headFromHead);
});
});
describe("getCurrentBranch", () => {
@@ -198,6 +307,8 @@ describe("bun:git", () => {
if (branch !== null) {
expect(typeof branch).toBe("string");
expect(branch.length).toBeGreaterThan(0);
// Branch name should not contain refs/heads/ prefix
expect(branch).not.toContain("refs/heads/");
}
});
});
@@ -228,6 +339,37 @@ describe("bun:git", () => {
expect(result.ahead).toBe(5);
expect(result.behind).toBe(0);
});
test("aheadBehind with same ref returns 0/0", () => {
const repo = Repository.open(".");
const result = repo.aheadBehind("HEAD", "HEAD");
expect(result.ahead).toBe(0);
expect(result.behind).toBe(0);
});
test("aheadBehind is symmetric", () => {
const repo = Repository.open(".");
const result1 = repo.aheadBehind("HEAD", "HEAD~3");
const result2 = repo.aheadBehind("HEAD~3", "HEAD");
expect(result1.ahead).toBe(result2.behind);
expect(result1.behind).toBe(result2.ahead);
});
test("aheadBehind throws for invalid local ref", () => {
const repo = Repository.open(".");
expect(() => repo.aheadBehind("invalid-ref-xxx", "HEAD")).toThrow();
});
test("aheadBehind throws for invalid upstream ref", () => {
const repo = Repository.open(".");
expect(() => repo.aheadBehind("HEAD", "invalid-ref-xxx")).toThrow();
});
});
describe("listFiles", () => {
@@ -257,6 +399,37 @@ describe("bun:git", () => {
expect(packageJson).toBeDefined();
expect(packageJson!.path).toBe("package.json");
});
test("listFiles entries have stage 0 for non-conflicted files", () => {
const repo = Repository.open(".");
const files = repo.listFiles();
// In a normal repository, all files should have stage 0
for (const entry of files) {
expect(entry.stage).toBe(0);
}
});
test("listFiles file modes are valid", () => {
const repo = Repository.open(".");
const files = repo.listFiles();
// Common file modes: 0o100644 (regular), 0o100755 (executable), 0o120000 (symlink)
const validModes = [0o100644, 0o100755, 0o120000, 0o040000, 0o160000];
for (const entry of files.slice(0, 100)) {
expect(validModes).toContain(entry.mode);
}
});
test("listFiles returns files in consistent order", () => {
const repo = Repository.open(".");
const files1 = repo.listFiles();
const files2 = repo.listFiles();
// Same order on repeated calls
expect(files1.map(f => f.path)).toEqual(files2.map(f => f.path));
});
});
describe("diff", () => {
@@ -296,6 +469,22 @@ describe("bun:git", () => {
}
});
test("diff stats are non-negative", () => {
const repo = Repository.open(".");
const diff = repo.diff();
expect(diff.stats.filesChanged).toBeGreaterThanOrEqual(0);
expect(diff.stats.insertions).toBeGreaterThanOrEqual(0);
expect(diff.stats.deletions).toBeGreaterThanOrEqual(0);
});
test("diff filesChanged matches files array length", () => {
const repo = Repository.open(".");
const diff = repo.diff();
expect(diff.stats.filesChanged).toBe(diff.files.length);
});
test("DeltaType constants are defined", () => {
expect(DeltaType.UNMODIFIED).toBe(0);
expect(DeltaType.ADDED).toBe(1);
@@ -328,6 +517,30 @@ describe("bun:git", () => {
expect(typeof count).toBe("number");
expect(count).toBe(10);
});
test("countCommits with various ranges", () => {
const repo = Repository.open(".");
expect(repo.countCommits("HEAD~1..HEAD")).toBe(1);
expect(repo.countCommits("HEAD~5..HEAD")).toBe(5);
expect(repo.countCommits("HEAD~20..HEAD")).toBe(20);
});
test("countCommits with empty range returns 0", () => {
const repo = Repository.open(".");
// HEAD..HEAD should be 0 commits
const count = repo.countCommits("HEAD..HEAD");
expect(count).toBe(0);
});
test("countCommits throws for invalid range", () => {
const repo = Repository.open(".");
expect(() => repo.countCommits("invalid-ref..HEAD")).toThrow();
expect(() => repo.countCommits("HEAD..invalid-ref")).toThrow();
});
});
describe("log", () => {
@@ -355,6 +568,15 @@ describe("bun:git", () => {
expect(five.length).toBe(5);
});
test("log with limit=1", () => {
const repo = Repository.open(".");
const commits = repo.log({ limit: 1 });
expect(commits.length).toBe(1);
expect(commits[0].id).toBe(repo.head().id);
});
test("log with range option", () => {
const repo = Repository.open(".");
@@ -373,6 +595,16 @@ describe("bun:git", () => {
expect(commits[0].id).toBe(head.id);
});
test("log with from option using commit SHA", () => {
const repo = Repository.open(".");
const parent = repo.revParse("HEAD~2");
const commits = repo.log({ from: parent, limit: 1 });
expect(commits.length).toBe(1);
expect(commits[0].id).toBe(parent);
});
test("log returns commits in chronological order (newest first)", () => {
const repo = Repository.open(".");
const commits = repo.log({ limit: 5 });
@@ -382,5 +614,302 @@ describe("bun:git", () => {
expect(commits[i - 1].time).toBeGreaterThanOrEqual(commits[i].time);
}
});
test("log without limit returns all commits up to HEAD", () => {
const repo = Repository.open(".");
const allCommits = repo.log({});
const countedCommits = repo.countCommits();
expect(allCommits.length).toBe(countedCommits);
});
test("log range matches countCommits", () => {
const repo = Repository.open(".");
const commits = repo.log({ range: "HEAD~7..HEAD" });
const count = repo.countCommits("HEAD~7..HEAD");
expect(commits.length).toBe(count);
expect(commits.length).toBe(7);
});
test("log throws for invalid from ref", () => {
const repo = Repository.open(".");
expect(() => repo.log({ from: "invalid-ref-xxx" })).toThrow();
});
test("log throws for invalid range", () => {
const repo = Repository.open(".");
expect(() => repo.log({ range: "invalid..HEAD" })).toThrow();
});
test("log commit properties are accessible", () => {
const repo = Repository.open(".");
const commits = repo.log({ limit: 3 });
for (const commit of commits) {
// All properties should be accessible without throwing
expect(commit.id).toMatch(/^[0-9a-f]{40}$/);
expect(typeof commit.message).toBe("string");
expect(typeof commit.summary).toBe("string");
expect(typeof commit.time).toBe("number");
expect(typeof commit.author.name).toBe("string");
expect(typeof commit.author.email).toBe("string");
expect(typeof commit.committer.name).toBe("string");
expect(typeof commit.committer.email).toBe("string");
}
});
});
describe("temporary repository tests", () => {
test("getStatus detects new untracked file", async () => {
using dir = tempDir("git-status-test", {});
const dirPath = String(dir);
// Initialize a git repository
await Bun.$`git init ${dirPath}`.quiet();
await Bun.$`git -C ${dirPath} config user.email "test@test.com"`.quiet();
await Bun.$`git -C ${dirPath} config user.name "Test"`.quiet();
// Create initial commit
writeFileSync(join(dirPath, "initial.txt"), "initial content");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "initial"`.quiet();
// Create an untracked file
writeFileSync(join(dirPath, "untracked.txt"), "untracked content");
const repo = Repository.open(dirPath);
const status = repo.getStatus();
expect(status.length).toBe(1);
expect(status[0].path).toBe("untracked.txt");
expect(status[0].status & Status.WT_NEW).toBeTruthy();
expect(status[0].isNew()).toBe(true);
});
test("getStatus detects modified file", async () => {
using dir = tempDir("git-status-modified-test", {});
const dirPath = String(dir);
await Bun.$`git init ${dirPath}`.quiet();
await Bun.$`git -C ${dirPath} config user.email "test@test.com"`.quiet();
await Bun.$`git -C ${dirPath} config user.name "Test"`.quiet();
writeFileSync(join(dirPath, "file.txt"), "original content");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "initial"`.quiet();
// Modify the file
writeFileSync(join(dirPath, "file.txt"), "modified content");
const repo = Repository.open(dirPath);
const status = repo.getStatus();
expect(status.length).toBe(1);
expect(status[0].path).toBe("file.txt");
expect(status[0].status & Status.WT_MODIFIED).toBeTruthy();
expect(status[0].isModified()).toBe(true);
});
test("getStatus detects staged file", async () => {
using dir = tempDir("git-status-staged-test", {});
const dirPath = String(dir);
await Bun.$`git init ${dirPath}`.quiet();
await Bun.$`git -C ${dirPath} config user.email "test@test.com"`.quiet();
await Bun.$`git -C ${dirPath} config user.name "Test"`.quiet();
writeFileSync(join(dirPath, "file.txt"), "original content");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "initial"`.quiet();
// Modify and stage
writeFileSync(join(dirPath, "file.txt"), "modified content");
await Bun.$`git -C ${dirPath} add file.txt`.quiet();
const repo = Repository.open(dirPath);
const status = repo.getStatus();
expect(status.length).toBe(1);
expect(status[0].path).toBe("file.txt");
expect(status[0].status & Status.INDEX_MODIFIED).toBeTruthy();
expect(status[0].inIndex()).toBe(true);
});
test("getStatus detects deleted file", async () => {
using dir = tempDir("git-status-deleted-test", {});
const dirPath = String(dir);
await Bun.$`git init ${dirPath}`.quiet();
await Bun.$`git -C ${dirPath} config user.email "test@test.com"`.quiet();
await Bun.$`git -C ${dirPath} config user.name "Test"`.quiet();
writeFileSync(join(dirPath, "file.txt"), "content");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "initial"`.quiet();
// Delete the file
unlinkSync(join(dirPath, "file.txt"));
const repo = Repository.open(dirPath);
const status = repo.getStatus();
expect(status.length).toBe(1);
expect(status[0].path).toBe("file.txt");
expect(status[0].status & Status.WT_DELETED).toBeTruthy();
expect(status[0].isDeleted()).toBe(true);
});
test("diff detects changes in temp repo", async () => {
using dir = tempDir("git-diff-test", {});
const dirPath = String(dir);
await Bun.$`git init ${dirPath}`.quiet();
await Bun.$`git -C ${dirPath} config user.email "test@test.com"`.quiet();
await Bun.$`git -C ${dirPath} config user.name "Test"`.quiet();
writeFileSync(join(dirPath, "file.txt"), "line1\nline2\nline3\n");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "initial"`.quiet();
// Modify the file
writeFileSync(join(dirPath, "file.txt"), "line1\nmodified\nline3\nnewline\n");
const repo = Repository.open(dirPath);
const diff = repo.diff();
expect(diff.files.length).toBe(1);
expect(diff.files[0].newPath).toBe("file.txt");
expect(diff.files[0].status).toBe(DeltaType.MODIFIED);
expect(diff.stats.filesChanged).toBe(1);
expect(diff.stats.insertions).toBeGreaterThan(0);
expect(diff.stats.deletions).toBeGreaterThan(0);
});
test("diff cached shows staged changes", async () => {
using dir = tempDir("git-diff-cached-test", {});
const dirPath = String(dir);
await Bun.$`git init ${dirPath}`.quiet();
await Bun.$`git -C ${dirPath} config user.email "test@test.com"`.quiet();
await Bun.$`git -C ${dirPath} config user.name "Test"`.quiet();
writeFileSync(join(dirPath, "file.txt"), "original\n");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "initial"`.quiet();
// Modify and stage
writeFileSync(join(dirPath, "file.txt"), "modified\n");
await Bun.$`git -C ${dirPath} add file.txt`.quiet();
const repo = Repository.open(dirPath);
const cachedDiff = repo.diff({ cached: true });
// Staged changes should show the modification
expect(cachedDiff.files.length).toBe(1);
expect(cachedDiff.files[0].status).toBe(DeltaType.MODIFIED);
});
test("listFiles in new repo", async () => {
using dir = tempDir("git-listfiles-test", {});
const dirPath = String(dir);
await Bun.$`git init ${dirPath}`.quiet();
await Bun.$`git -C ${dirPath} config user.email "test@test.com"`.quiet();
await Bun.$`git -C ${dirPath} config user.name "Test"`.quiet();
writeFileSync(join(dirPath, "a.txt"), "a");
writeFileSync(join(dirPath, "b.txt"), "b");
writeFileSync(join(dirPath, "c.txt"), "c");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "initial"`.quiet();
const repo = Repository.open(dirPath);
const files = repo.listFiles();
expect(files.length).toBe(3);
expect(files.map(f => f.path).sort()).toEqual(["a.txt", "b.txt", "c.txt"]);
});
test("log and countCommits in new repo", async () => {
using dir = tempDir("git-log-test", {});
const dirPath = String(dir);
await Bun.$`git init ${dirPath}`.quiet();
await Bun.$`git -C ${dirPath} config user.email "test@test.com"`.quiet();
await Bun.$`git -C ${dirPath} config user.name "Test"`.quiet();
// Create 3 commits
writeFileSync(join(dirPath, "file.txt"), "1");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "first commit"`.quiet();
writeFileSync(join(dirPath, "file.txt"), "2");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "second commit"`.quiet();
writeFileSync(join(dirPath, "file.txt"), "3");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "third commit"`.quiet();
const repo = Repository.open(dirPath);
expect(repo.countCommits()).toBe(3);
const commits = repo.log({});
expect(commits.length).toBe(3);
// Verify all commit messages are present (order may vary due to same timestamp)
const summaries = commits.map(c => c.summary).sort();
expect(summaries).toEqual(["first commit", "second commit", "third commit"]);
});
test("getCurrentBranch returns main/master in new repo", async () => {
using dir = tempDir("git-branch-test", {});
const dirPath = String(dir);
await Bun.$`git init ${dirPath}`.quiet();
await Bun.$`git -C ${dirPath} config user.email "test@test.com"`.quiet();
await Bun.$`git -C ${dirPath} config user.name "Test"`.quiet();
writeFileSync(join(dirPath, "file.txt"), "content");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "initial"`.quiet();
const repo = Repository.open(dirPath);
const branch = repo.getCurrentBranch();
// Default branch could be "main" or "master" depending on git config
expect(branch === "main" || branch === "master").toBe(true);
});
test("getCurrentBranch returns null for detached HEAD", async () => {
using dir = tempDir("git-detached-test", {});
const dirPath = String(dir);
await Bun.$`git init ${dirPath}`.quiet();
await Bun.$`git -C ${dirPath} config user.email "test@test.com"`.quiet();
await Bun.$`git -C ${dirPath} config user.name "Test"`.quiet();
writeFileSync(join(dirPath, "file.txt"), "1");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "first"`.quiet();
writeFileSync(join(dirPath, "file.txt"), "2");
await Bun.$`git -C ${dirPath} add .`.quiet();
await Bun.$`git -C ${dirPath} commit -m "second"`.quiet();
// Detach HEAD
await Bun.$`git -C ${dirPath} checkout HEAD~1`.quiet();
const repo = Repository.open(dirPath);
const branch = repo.getCurrentBranch();
expect(branch).toBeNull();
});
});
});