Skip to content

Commit e030a32

Browse files
committed
fix(vscode): fall back to git CLI for diffs
1 parent 9f9ded7 commit e030a32

3 files changed

Lines changed: 159 additions & 20 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
export type ExecFn = (
2+
command: string,
3+
options: { cwd: string; maxBuffer?: number },
4+
) => Promise<{
5+
stdout: string;
6+
stderr?: string;
7+
}>;
8+
9+
const GIT_DIFF_MAX_BUFFER = 10 * 1024 * 1024;
10+
11+
export function splitGitDiff(diffString: string): string[] {
12+
const fileDiffHeaderRegex = /(?=diff --git a\/.* b\/.*)/;
13+
const diffs = diffString.split(fileDiffHeaderRegex);
14+
15+
if (diffs[0]?.trim() === "") {
16+
diffs.shift();
17+
}
18+
19+
return diffs.filter((diff) => diff.trim().length > 0);
20+
}
21+
22+
export async function collectGitDiffsWithCli(
23+
candidateDirs: string[],
24+
includeUnstaged: boolean,
25+
execFn: ExecFn,
26+
): Promise<string[]> {
27+
const visitedRoots = new Set<string>();
28+
const diffs: string[] = [];
29+
30+
for (const candidateDir of candidateDirs) {
31+
if (!candidateDir) {
32+
continue;
33+
}
34+
35+
let root: string;
36+
try {
37+
const { stdout } = await execFn("git rev-parse --show-toplevel", {
38+
cwd: candidateDir,
39+
maxBuffer: GIT_DIFF_MAX_BUFFER,
40+
});
41+
root = stdout.trim();
42+
} catch {
43+
continue;
44+
}
45+
46+
if (!root || visitedRoots.has(root)) {
47+
continue;
48+
}
49+
visitedRoots.add(root);
50+
51+
const commands = includeUnstaged
52+
? ["git diff --cached", "git diff"]
53+
: ["git diff --cached"];
54+
55+
for (const command of commands) {
56+
try {
57+
const { stdout } = await execFn(command, {
58+
cwd: root,
59+
maxBuffer: GIT_DIFF_MAX_BUFFER,
60+
});
61+
diffs.push(...splitGitDiff(stdout));
62+
} catch {
63+
// Skip directories where git diff cannot be retrieved via CLI.
64+
}
65+
}
66+
}
67+
68+
return diffs;
69+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import { collectGitDiffsWithCli, splitGitDiff } from "./gitDiffFallback";
4+
5+
describe("splitGitDiff", () => {
6+
it("splits combined git diff output into per-file entries", () => {
7+
const combined = [
8+
"diff --git a/a.txt b/a.txt\n+hello",
9+
"diff --git a/b.txt b/b.txt\n+world",
10+
].join("\n");
11+
12+
expect(splitGitDiff(combined)).toEqual([
13+
"diff --git a/a.txt b/a.txt\n+hello\n",
14+
"diff --git a/b.txt b/b.txt\n+world",
15+
]);
16+
});
17+
});
18+
19+
describe("collectGitDiffsWithCli", () => {
20+
it("falls back to git CLI and returns staged and unstaged diffs", async () => {
21+
const execFn = vi.fn(async (command: string, options: { cwd: string }) => {
22+
if (command === "git rev-parse --show-toplevel") {
23+
return { stdout: "/repo\n" };
24+
}
25+
if (command === "git diff --cached") {
26+
expect(options.cwd).toBe("/repo");
27+
return { stdout: "diff --git a/staged.ts b/staged.ts\n+staged\n" };
28+
}
29+
if (command === "git diff") {
30+
expect(options.cwd).toBe("/repo");
31+
return { stdout: "diff --git a/unstaged.ts b/unstaged.ts\n+unstaged\n" };
32+
}
33+
throw new Error("unexpected command: " + command);
34+
});
35+
36+
await expect(
37+
collectGitDiffsWithCli(["/repo", "/repo/subdir"], true, execFn),
38+
).resolves.toEqual([
39+
"diff --git a/staged.ts b/staged.ts\n+staged\n",
40+
"diff --git a/unstaged.ts b/unstaged.ts\n+unstaged\n",
41+
]);
42+
43+
expect(execFn).toHaveBeenCalledTimes(4);
44+
});
45+
46+
it("skips non-git directories and only uses staged diff when requested", async () => {
47+
const execFn = vi.fn(async (command: string, options: { cwd: string }) => {
48+
if (
49+
command === "git rev-parse --show-toplevel" &&
50+
options.cwd === "/not-a-repo"
51+
) {
52+
throw new Error("fatal: not a git repository");
53+
}
54+
if (command === "git rev-parse --show-toplevel" && options.cwd === "/repo") {
55+
return { stdout: "/repo\n" };
56+
}
57+
if (command === "git diff --cached") {
58+
return { stdout: "diff --git a/file.ts b/file.ts\n+only-staged\n" };
59+
}
60+
throw new Error("unexpected command: " + command);
61+
});
62+
63+
await expect(
64+
collectGitDiffsWithCli(["/not-a-repo", "/repo"], false, execFn),
65+
).resolves.toEqual(["diff --git a/file.ts b/file.ts\n+only-staged\n"]);
66+
67+
expect(execFn).toHaveBeenCalledTimes(3);
68+
});
69+
});

extensions/vscode/src/util/ideUtils.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
showSuggestion as showSuggestionInEditor,
1515
} from "../suggestions";
1616

17+
import { collectGitDiffsWithCli, splitGitDiff } from "./gitDiffFallback";
1718
import { getUniqueId, openEditorAndRevealRange } from "./vscode";
1819

1920
import type { Range, Thread } from "core";
@@ -604,37 +605,37 @@ export class VsCodeIdeUtils {
604605
return repo?.state?.HEAD?.name || "NONE";
605606
}
606607

607-
private splitDiff(diffString: string): string[] {
608-
const fileDiffHeaderRegex = /(?=diff --git a\/.* b\/.*)/;
609-
610-
const diffs = diffString.split(fileDiffHeaderRegex);
611-
612-
if (diffs[0].trim() === "") {
613-
diffs.shift();
614-
}
615-
616-
return diffs;
617-
}
618-
619608
async getDiff(includeUnstaged: boolean): Promise<string[]> {
620-
const diffs: string[] = [];
621-
609+
const apiDiffs: string[] = [];
622610
const repos = this._getRepositories();
623611

624612
try {
625613
if (repos) {
626614
for (const repo of repos) {
627-
const staged = await repo.diff(true);
628-
629-
diffs.push(staged);
615+
apiDiffs.push(await repo.diff(true));
630616
if (includeUnstaged) {
631-
const unstaged = await repo.diff(false);
632-
diffs.push(unstaged);
617+
apiDiffs.push(await repo.diff(false));
633618
}
634619
}
635620
}
636621

637-
return diffs.flatMap((diff) => this.splitDiff(diff));
622+
const parsedApiDiffs = apiDiffs.flatMap((diff) => splitGitDiff(diff));
623+
if (parsedApiDiffs.length > 0) {
624+
return parsedApiDiffs;
625+
}
626+
627+
const candidateDirs = Array.from(
628+
new Set([
629+
...(repos?.map((repo) => repo.rootUri.fsPath) ?? []),
630+
...this.getWorkspaceDirectories().map((dir) => dir.fsPath),
631+
]),
632+
);
633+
634+
return await collectGitDiffsWithCli(
635+
candidateDirs,
636+
includeUnstaged,
637+
asyncExec,
638+
);
638639
} catch (e) {
639640
console.error(e);
640641
return [];

0 commit comments

Comments
 (0)