|
| 1 | +import { describe, it, expect, vi, beforeEach } from "vitest"; |
| 2 | +import * as childProcess from "child_process"; |
| 3 | +import { |
| 4 | + run, |
| 5 | + getBranch, |
| 6 | + getStatus, |
| 7 | + getRecentCommits, |
| 8 | + getLastCommit, |
| 9 | + getLastCommitTime, |
| 10 | + getDiffFiles, |
| 11 | + getStagedFiles, |
| 12 | + getDiffStat, |
| 13 | +} from "../../src/lib/git.js"; |
| 14 | + |
| 15 | +vi.mock("child_process", () => ({ |
| 16 | + execFileSync: vi.fn(), |
| 17 | +})); |
| 18 | + |
| 19 | +const mockExec = vi.mocked(childProcess.execFileSync); |
| 20 | + |
| 21 | +beforeEach(() => { |
| 22 | + mockExec.mockReset(); |
| 23 | +}); |
| 24 | + |
| 25 | +describe("run()", () => { |
| 26 | + it("passes array args directly to execFileSync", () => { |
| 27 | + mockExec.mockReturnValue("ok\n" as any); |
| 28 | + const result = run(["status", "--short"]); |
| 29 | + expect(result).toBe("ok"); |
| 30 | + expect(mockExec).toHaveBeenCalledWith( |
| 31 | + "git", |
| 32 | + ["status", "--short"], |
| 33 | + expect.objectContaining({ encoding: "utf-8" }), |
| 34 | + ); |
| 35 | + }); |
| 36 | + |
| 37 | + it("splits string args on whitespace", () => { |
| 38 | + mockExec.mockReturnValue("main\n" as any); |
| 39 | + const result = run("branch --show-current"); |
| 40 | + expect(result).toBe("main"); |
| 41 | + expect(mockExec).toHaveBeenCalledWith( |
| 42 | + "git", |
| 43 | + ["branch", "--show-current"], |
| 44 | + expect.any(Object), |
| 45 | + ); |
| 46 | + }); |
| 47 | + |
| 48 | + it("trims output", () => { |
| 49 | + mockExec.mockReturnValue(" abc123 some commit \n" as any); |
| 50 | + expect(run(["log", "--oneline", "-1"])).toBe("abc123 some commit"); |
| 51 | + }); |
| 52 | + |
| 53 | + it("returns timeout message when process is killed", () => { |
| 54 | + const err: any = new Error("timed out"); |
| 55 | + err.killed = true; |
| 56 | + mockExec.mockImplementation(() => { throw err; }); |
| 57 | + expect(run(["status"])).toBe("[timed out after 10000ms]"); |
| 58 | + }); |
| 59 | + |
| 60 | + it("returns stderr on failure", () => { |
| 61 | + const err: any = new Error("fail"); |
| 62 | + err.stderr = "fatal: not a git repo\n"; |
| 63 | + err.stdout = ""; |
| 64 | + mockExec.mockImplementation(() => { throw err; }); |
| 65 | + expect(run(["status"])).toBe("fatal: not a git repo"); |
| 66 | + }); |
| 67 | + |
| 68 | + it("returns ENOENT message when git is missing", () => { |
| 69 | + const err: any = new Error("spawn git ENOENT"); |
| 70 | + err.code = "ENOENT"; |
| 71 | + mockExec.mockImplementation(() => { throw err; }); |
| 72 | + expect(run(["status"])).toBe("[git not found]"); |
| 73 | + }); |
| 74 | + |
| 75 | + it("returns command failed for unknown errors", () => { |
| 76 | + const err: any = new Error("unknown"); |
| 77 | + err.status = 128; |
| 78 | + mockExec.mockImplementation(() => { throw err; }); |
| 79 | + expect(run(["push"])).toBe("[command failed: git push (exit 128)]"); |
| 80 | + }); |
| 81 | + |
| 82 | + it("respects custom timeout", () => { |
| 83 | + const err: any = new Error("timed out"); |
| 84 | + err.killed = true; |
| 85 | + mockExec.mockImplementation(() => { throw err; }); |
| 86 | + expect(run(["status"], { timeout: 5000 })).toBe("[timed out after 5000ms]"); |
| 87 | + }); |
| 88 | +}); |
| 89 | + |
| 90 | +describe("convenience helpers", () => { |
| 91 | + it("getBranch calls branch --show-current", () => { |
| 92 | + mockExec.mockReturnValue("feature/foo\n" as any); |
| 93 | + expect(getBranch()).toBe("feature/foo"); |
| 94 | + }); |
| 95 | + |
| 96 | + it("getStatus calls status --short", () => { |
| 97 | + mockExec.mockReturnValue(" M src/index.ts\n" as any); |
| 98 | + expect(getStatus()).toBe("M src/index.ts"); |
| 99 | + }); |
| 100 | + |
| 101 | + it("getRecentCommits defaults to 5", () => { |
| 102 | + mockExec.mockReturnValue("abc one\ndef two\n" as any); |
| 103 | + const result = getRecentCommits(); |
| 104 | + expect(mockExec).toHaveBeenCalledWith( |
| 105 | + "git", |
| 106 | + ["log", "--oneline", "-5"], |
| 107 | + expect.any(Object), |
| 108 | + ); |
| 109 | + expect(result).toBe("abc one\ndef two"); |
| 110 | + }); |
| 111 | + |
| 112 | + it("getRecentCommits accepts custom count", () => { |
| 113 | + mockExec.mockReturnValue("abc one\n" as any); |
| 114 | + getRecentCommits(3); |
| 115 | + expect(mockExec).toHaveBeenCalledWith( |
| 116 | + "git", |
| 117 | + ["log", "--oneline", "-3"], |
| 118 | + expect.any(Object), |
| 119 | + ); |
| 120 | + }); |
| 121 | + |
| 122 | + it("getLastCommit returns single line", () => { |
| 123 | + mockExec.mockReturnValue("abc123 fix: something\n" as any); |
| 124 | + expect(getLastCommit()).toBe("abc123 fix: something"); |
| 125 | + }); |
| 126 | + |
| 127 | + it("getLastCommitTime returns timestamp", () => { |
| 128 | + mockExec.mockReturnValue("2026-03-12 09:00:00 -0700\n" as any); |
| 129 | + expect(getLastCommitTime()).toBe("2026-03-12 09:00:00 -0700"); |
| 130 | + }); |
| 131 | + |
| 132 | + it("getStagedFiles returns file list", () => { |
| 133 | + mockExec.mockReturnValue("src/a.ts\nsrc/b.ts\n" as any); |
| 134 | + expect(getStagedFiles()).toBe("src/a.ts\nsrc/b.ts"); |
| 135 | + }); |
| 136 | +}); |
| 137 | + |
| 138 | +describe("getDiffFiles", () => { |
| 139 | + it("returns diff output on success", () => { |
| 140 | + mockExec.mockReturnValue("src/a.ts\nsrc/b.ts\n" as any); |
| 141 | + expect(getDiffFiles("HEAD~3")).toBe("src/a.ts\nsrc/b.ts"); |
| 142 | + }); |
| 143 | + |
| 144 | + it("falls back to HEAD~1 when primary ref fails", () => { |
| 145 | + mockExec |
| 146 | + .mockReturnValueOnce("[command failed: git diff (exit 128)]" as any) |
| 147 | + .mockReturnValueOnce("src/c.ts\n" as any); |
| 148 | + expect(getDiffFiles("origin/main")).toBe("src/c.ts"); |
| 149 | + }); |
| 150 | + |
| 151 | + it("returns 'no commits' when both attempts fail", () => { |
| 152 | + mockExec |
| 153 | + .mockReturnValueOnce("[command failed]" as any) |
| 154 | + .mockReturnValueOnce("[command failed]" as any); |
| 155 | + expect(getDiffFiles()).toBe("no commits"); |
| 156 | + }); |
| 157 | +}); |
| 158 | + |
| 159 | +describe("getDiffStat", () => { |
| 160 | + it("returns stat output on success", () => { |
| 161 | + mockExec.mockReturnValue(" 2 files changed, 10 insertions(+)\n" as any); |
| 162 | + expect(getDiffStat("HEAD~5")).toBe("2 files changed, 10 insertions(+)"); |
| 163 | + }); |
| 164 | + |
| 165 | + it("falls back to HEAD~3 when primary ref fails", () => { |
| 166 | + mockExec |
| 167 | + .mockReturnValueOnce("[command failed]" as any) |
| 168 | + .mockReturnValueOnce(" 1 file changed\n" as any); |
| 169 | + expect(getDiffStat("origin/main")).toBe("1 file changed"); |
| 170 | + }); |
| 171 | + |
| 172 | + it("returns fallback message when both attempts fail", () => { |
| 173 | + mockExec |
| 174 | + .mockReturnValueOnce("[timed out]" as any) |
| 175 | + .mockReturnValueOnce("[timed out]" as any); |
| 176 | + expect(getDiffStat()).toBe("no diff stats available"); |
| 177 | + }); |
| 178 | +}); |
0 commit comments