Skip to content

Commit 966a0ae

Browse files
test: add comprehensive test suite for git.ts lib
- 15 tests covering run(), getBranch(), getStatus(), getRecentCommits(), getLastCommit(), getLastCommitTime(), getDiffFiles(), getStagedFiles(), getDiffStat() - Tests use isolated temp git repo with mocked PROJECT_DIR - Validates no shell injection (literal args, not shell-interpreted) - Verifies graceful error handling for invalid refs/flags
1 parent 4637eaf commit 966a0ae

1 file changed

Lines changed: 160 additions & 0 deletions

File tree

tests/lib/git.test.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
2+
import { execFileSync } from "child_process";
3+
import { mkdtempSync, writeFileSync, rmSync } from "fs";
4+
import { join } from "path";
5+
import { tmpdir } from "os";
6+
7+
// We need to mock PROJECT_DIR before importing git functions
8+
let tempDir: string;
9+
10+
beforeAll(() => {
11+
tempDir = mkdtempSync(join(tmpdir(), "preflight-git-test-"));
12+
13+
// Initialize a git repo with a couple of commits
14+
const git = (...args: string[]) =>
15+
execFileSync("git", args, { cwd: tempDir, encoding: "utf-8" });
16+
17+
git("init");
18+
git("config", "user.email", "test@test.com");
19+
git("config", "user.name", "Test");
20+
21+
writeFileSync(join(tempDir, "file1.ts"), "export const a = 1;\n");
22+
git("add", ".");
23+
git("commit", "-m", "initial commit");
24+
25+
writeFileSync(join(tempDir, "file2.ts"), "export const b = 2;\n");
26+
git("add", ".");
27+
git("commit", "-m", "second commit");
28+
});
29+
30+
afterAll(() => {
31+
rmSync(tempDir, { recursive: true, force: true });
32+
});
33+
34+
// Mock PROJECT_DIR to point to our temp repo
35+
vi.mock("../../src/lib/files.js", () => ({
36+
get PROJECT_DIR() {
37+
return tempDir;
38+
},
39+
}));
40+
41+
// Import after mock setup (vitest hoists vi.mock, so this works)
42+
import {
43+
run,
44+
getBranch,
45+
getStatus,
46+
getRecentCommits,
47+
getLastCommit,
48+
getLastCommitTime,
49+
getDiffFiles,
50+
getStagedFiles,
51+
getDiffStat,
52+
} from "../../src/lib/git.js";
53+
54+
describe("git.run()", () => {
55+
it("accepts array args", () => {
56+
const result = run(["log", "--oneline", "-1"]);
57+
expect(result).toContain("second commit");
58+
});
59+
60+
it("accepts string args (split on whitespace)", () => {
61+
const result = run("log --oneline -1");
62+
expect(result).toContain("second commit");
63+
});
64+
65+
it("returns error string on invalid command (no throw)", () => {
66+
const result = run(["log", "--invalid-flag-xyz"]);
67+
expect(result).toMatch(/\[command failed|unknown option|unrecognized/i);
68+
});
69+
70+
it("does NOT interpret shell syntax (no injection)", () => {
71+
// If shell were used, this would try to pipe. With execFileSync it's a literal arg.
72+
const result = run(["log", "--oneline", "-1", "| cat"]);
73+
// Should fail or return an error, not execute "cat"
74+
expect(result.startsWith("[") || result.includes("fatal") || result === "").toBe(true);
75+
});
76+
});
77+
78+
describe("getBranch()", () => {
79+
it("returns current branch name", () => {
80+
const branch = getBranch();
81+
// Default branch varies (main/master), just check it's non-empty
82+
expect(branch.length).toBeGreaterThan(0);
83+
});
84+
});
85+
86+
describe("getStatus()", () => {
87+
it("returns empty string for clean working tree", () => {
88+
const status = getStatus();
89+
expect(status).toBe("");
90+
});
91+
92+
it("shows modified files", () => {
93+
writeFileSync(join(tempDir, "file1.ts"), "export const a = 42;\n");
94+
const status = getStatus();
95+
expect(status).toContain("file1.ts");
96+
// Restore
97+
execFileSync("git", ["checkout", "--", "file1.ts"], { cwd: tempDir });
98+
});
99+
});
100+
101+
describe("getRecentCommits()", () => {
102+
it("returns requested number of commits", () => {
103+
const commits = getRecentCommits(2);
104+
const lines = commits.split("\n").filter(Boolean);
105+
expect(lines).toHaveLength(2);
106+
});
107+
108+
it("defaults to 5 (returns available)", () => {
109+
const commits = getRecentCommits();
110+
const lines = commits.split("\n").filter(Boolean);
111+
// We only have 2 commits, so should return 2
112+
expect(lines.length).toBeLessThanOrEqual(5);
113+
expect(lines.length).toBeGreaterThanOrEqual(1);
114+
});
115+
});
116+
117+
describe("getLastCommit()", () => {
118+
it("returns single line with commit message", () => {
119+
const commit = getLastCommit();
120+
expect(commit).toContain("second commit");
121+
expect(commit.split("\n")).toHaveLength(1);
122+
});
123+
});
124+
125+
describe("getLastCommitTime()", () => {
126+
it("returns ISO-ish timestamp", () => {
127+
const time = getLastCommitTime();
128+
// Format: 2026-03-09 12:00:00 -0700
129+
expect(time).toMatch(/\d{4}-\d{2}-\d{2}/);
130+
});
131+
});
132+
133+
describe("getDiffFiles()", () => {
134+
it("returns changed files since ref", () => {
135+
const files = getDiffFiles("HEAD~1");
136+
expect(files).toContain("file2.ts");
137+
});
138+
139+
it("falls back gracefully for invalid ref", () => {
140+
const result = getDiffFiles("nonexistent-ref");
141+
// Should fall back to HEAD~1 or return "no commits"
142+
expect(typeof result).toBe("string");
143+
});
144+
});
145+
146+
describe("getStagedFiles()", () => {
147+
it("returns empty for no staged changes", () => {
148+
const staged = getStagedFiles();
149+
expect(staged).toBe("");
150+
});
151+
});
152+
153+
describe("getDiffStat()", () => {
154+
it("returns stat output for valid ref", () => {
155+
const stat = getDiffStat("HEAD~1");
156+
expect(stat).toContain("file2.ts");
157+
// Stat output includes insertions/deletions
158+
expect(stat).toMatch(/\d+ insertion|changed/);
159+
});
160+
});

0 commit comments

Comments
 (0)