Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 230 additions & 22 deletions src/utils/__tests__/git.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,22 @@ import {
getWorkspaceGitInfo,
convertGitUrlToHttps,
getGitStatus,
getCommitContext,
} from "../git"
import { truncateOutput } from "../../integrations/misc/extract-text"

type ExecFunction = (
command: string,
options: { cwd?: string },
callback: (error: ExecException | null, result?: { stdout: string; stderr: string }) => void,
) => void

type PromisifiedExec = (command: string, options?: { cwd?: string }) => Promise<{ stdout: string; stderr: string }>

// Mock child_process.exec
vitest.mock("child_process", () => ({
exec: vitest.fn(),
execFile: vitest.fn(),
}))

// Mock fs.promises
vitest.mock("fs", () => ({
promises: {
access: vitest.fn(),
readFile: vitest.fn(),
open: vitest.fn(),
},
}))

Expand All @@ -49,21 +44,27 @@ vitest.mock("vscode", () => ({

// Mock util.promisify to return our own mock function
vitest.mock("util", () => ({
promisify: vitest.fn((fn: ExecFunction): PromisifiedExec => {
return async (command: string, options?: { cwd?: string }) => {
promisify: vitest.fn((fn: (...args: unknown[]) => void) => {
return async (...args: unknown[]) => {
// Call the original mock to maintain the mock implementation
return new Promise((resolve, reject) => {
fn(
command,
options || {},
(error: ExecException | null, result?: { stdout: string; stderr: string }) => {
if (error) {
reject(error)
} else {
resolve(result!)
}
},
)
const callback = (error: ExecException | null, result?: { stdout: string; stderr: string }) => {
if (error) {
reject(error)
} else {
resolve(result!)
}
}

// `exec(command, options, cb)` and `execFile(file, args, options, cb)` differ in
// arity, so both shapes are normalized here rather than mocking promisify twice.
const [first, second, third] = args

if (Array.isArray(second)) {
fn(first, second, third || {}, callback)
} else {
fn(first, second || {}, callback)
}
})
}
}),
Expand All @@ -76,7 +77,7 @@ vitest.mock("../../integrations/misc/extract-text", () => ({
}),
}))

import { exec } from "child_process"
import { exec, execFile } from "child_process"

describe("git utils", () => {
const cwd = "/test/path"
Expand Down Expand Up @@ -351,6 +352,213 @@ describe("git utils", () => {
})
})

describe("getCommitContext", () => {
const NUL = "\0"
const mockDiff = "@@ -1,1 +1,2 @@\n-old line\n+new line"

type ExecResult = { stdout: string; stderr: string }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
type ExecCallback = (error: Error | null, result?: ExecResult) => void

// `checkGitInstalled` and `checkGitRepo` are fixed strings, so they still run through `exec`.
const mockProbes = ({ installed = true, repo = true } = {}) => {
vitest.mocked(exec).mockImplementation(((command: string, _options: unknown, callback: ExecCallback) => {
const available = command === "git --version" ? installed : repo

if (available) {
callback(null, { stdout: "ok", stderr: "" })
} else {
callback(new Error(`unavailable: ${command}`))
}

return {} as ReturnType<typeof exec>
}) as unknown as typeof exec)
}

// Keyed by the joined argument array, since that is what the collector passes now. Anything
// not listed rejects, which is how the failure paths are exercised.
const mockGit = (responses: Record<string, string>) => {
const calls: Array<{ file: string; args: string[] }> = []

vitest.mocked(execFile).mockImplementation(((
file: string,
args: string[],
_options: unknown,
callback: ExecCallback,
) => {
calls.push({ file, args })
const stdout = responses[args.join(" ")]

if (stdout === undefined) {
callback(new Error(`unexpected command: git ${args.join(" ")}`))
} else {
callback(null, { stdout, stderr: "" })
}

return {} as ReturnType<typeof execFile>
}) as unknown as typeof execFile)

return calls
}

const staged = (nameStatus: string, diff = mockDiff): Record<string, string> => ({
"diff --cached --name-status -z": nameStatus,
"diff --cached --unified=1": diff,
"branch --show-current": "feature/x\n",
"log -n5 --format=%s": "earlier subject\n",
})

const workingTree = (status: string, diff = mockDiff): Record<string, string> => ({
"diff --cached --name-status -z": "",
"status --porcelain=v1 -z --untracked-files=all": status,
"diff --unified=1": diff,
"rev-parse --show-toplevel": `${cwd}\n`,
"branch --show-current": "main\n",
"log -n5 --format=%s": "earlier subject\n",
})

// Narrows the result so a failure reports its reason instead of a property-of-undefined.
const expectContext = async () => {
const result = await getCommitContext(cwd)

if (!result.ok) {
throw new Error(`expected a context, got "${result.reason}"`)
}

return result.context
}

it("should collect staged changes as structured entries", async () => {
mockProbes()
mockGit(staged(`M${NUL}src/file1.ts${NUL}A${NUL}src/new.ts${NUL}D${NUL}src/gone.ts${NUL}`))

const context = await expectContext()
expect(context.files).toEqual([
{ status: "modified", path: "src/file1.ts" },
{ status: "added", path: "src/new.ts" },
{ status: "deleted", path: "src/gone.ts" },
])
expect(context.branch).toBe("feature/x")
expect(context.recentCommits).toEqual(["earlier subject"])
expect(context.diff).toContain("+new line")
})

// A rename or copy record carries two paths. Reading one where there are two would shift
// every later record onto the wrong file, so the trailing entry is the real assertion.
it("should parse renames and copies without desyncing later entries", async () => {
mockProbes()
mockGit(
staged(
`R100${NUL}old name.ts${NUL}new name.ts${NUL}` +
`C075${NUL}src/base.ts${NUL}src/copy.ts${NUL}` +
`M${NUL}src/after.ts${NUL}`,
),
)

expect((await expectContext()).files).toEqual([
{ status: "renamed", path: "new name.ts", oldPath: "old name.ts" },
{ status: "copied", path: "src/copy.ts", oldPath: "src/base.ts" },
{ status: "modified", path: "src/after.ts" },
])
})

it("should keep paths with spaces and unusual characters verbatim", async () => {
mockProbes()
mockGit(staged(`A${NUL}src/a "quoted" & odd (file).ts${NUL}`))

expect((await expectContext()).files).toEqual([{ status: "added", path: 'src/a "quoted" & odd (file).ts' }])
})

// Replaces an older test that checked the command string for shell metacharacters. With
// `execFile` there is no shell at all, so the guard is that arguments stay separate values.
it("should pass every argument as an array element rather than a shell string", async () => {
mockProbes()
const calls = mockGit(staged(`M${NUL}src/file1.ts${NUL}`))

await getCommitContext(cwd)

expect(calls.length).toBeGreaterThan(0)
expect(calls.every((call) => call.file === "git")).toBe(true)
expect(calls.map((call) => call.args)).toContainEqual(["diff", "--cached", "--name-status", "-z"])
expect(calls.map((call) => call.args)).toContainEqual(["diff", "--cached", "--unified=1"])
})

// Only the index is described, so a dirty working tree with an empty index is a distinct
// outcome: the user can fix it by staging, and the caller says so.
it("should report nothing-staged when the working tree is dirty but the index is empty", async () => {
mockProbes()
mockGit(workingTree(` M src/file1.ts${NUL}?? src/untracked.ts${NUL}`))

expect(await getCommitContext(cwd)).toEqual({ ok: false, reason: "nothing-staged" })
})

it("should describe only the index when both it and the working tree have changes", async () => {
mockProbes()
// `workingTree` blanks the staged listing, so the staged responses have to win.
mockGit({
...workingTree(` M src/unstaged.ts${NUL}`),
...staged(`M${NUL}src/staged.ts${NUL}`),
})

expect((await expectContext()).files).toEqual([{ status: "modified", path: "src/staged.ts" }])
})

it("should work in a repository without an initial commit", async () => {
mockProbes()
// `git log` fails before the first commit, and must not take the collection down with it.
const responses = staged(`A${NUL}file.txt${NUL}`)
delete responses["log -n5 --format=%s"]
mockGit(responses)

const context = await expectContext()
expect(context.recentCommits).toEqual([])
expect(context.files).toEqual([{ status: "added", path: "file.txt" }])
})

// A line limit alone is not a bound: one generated file can be a single enormous line.
it("should cap output by characters as well as by lines", async () => {
mockProbes()
mockGit(staged(`M${NUL}dist/bundle.js${NUL}`, `+${"a".repeat(200_000)}`))

await getCommitContext(cwd)

expect(vitest.mocked(truncateOutput)).toHaveBeenCalledWith(expect.any(String), 500, 102_400)
})

it("should report no-changes on a clean tree", async () => {
mockProbes()
mockGit(workingTree(""))

expect(await getCommitContext(cwd)).toEqual({ ok: false, reason: "no-changes" })
})

it("should report git-missing when git is not installed", async () => {
mockProbes({ installed: false })
mockGit({})

expect(await getCommitContext(cwd)).toEqual({ ok: false, reason: "git-missing" })
})

it("should report not-a-repo outside a repository", async () => {
mockProbes({ repo: false })
mockGit({})

expect(await getCommitContext(cwd)).toEqual({ ok: false, reason: "not-a-repo" })
})

// An oversized diff exceeding `maxBuffer` is expected, not exceptional: the documented
// contract is a reason, never a rejection.
it("should report failed instead of rejecting when a git command fails", async () => {
mockProbes()
const responses = staged(`M${NUL}src/file1.ts${NUL}`)
delete responses["diff --cached --unified=1"]
mockGit(responses)

const result = await getCommitContext(cwd)
expect(result.ok).toBe(false)
expect(result).toMatchObject({ reason: "failed" })
})
})

describe("getWorkingState", () => {
const mockStatus = " M src/file1.ts\n?? src/file2.ts"
const mockDiff = "@@ -1,1 +1,2 @@\n-old line\n+new line"
Expand Down
Loading
Loading