diff --git a/test/js/bun/git/repository.test.ts b/test/js/bun/git/repository.test.ts index d0137520b4..76a52d043c 100644 --- a/test/js/bun/git/repository.test.ts +++ b/test/js/bun/git/repository.test.ts @@ -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(); + }); }); });