Fix checkout diff stalls on untracked binary artifacts
This commit is contained in:
@@ -132,6 +132,36 @@ describe("checkout git utilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("short-circuits untracked binary files", async () => {
|
||||
const binaryPath = join(repoDir, "blob.bin");
|
||||
writeFileSync(binaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f, 0x00]));
|
||||
|
||||
const diff = await getCheckoutDiff(repoDir, {
|
||||
mode: "uncommitted",
|
||||
includeStructured: true,
|
||||
});
|
||||
|
||||
const entry = diff.structured?.find((file) => file.path === "blob.bin");
|
||||
expect(entry).toBeTruthy();
|
||||
expect(entry?.status).toBe("binary");
|
||||
expect(diff.diff).toContain("# blob.bin: binary diff omitted");
|
||||
});
|
||||
|
||||
it("marks untracked oversized files as too_large", async () => {
|
||||
const large = Array.from({ length: 240_000 }, (_, i) => `line ${i}`).join("\n") + "\n";
|
||||
writeFileSync(join(repoDir, "untracked-large.txt"), large);
|
||||
|
||||
const diff = await getCheckoutDiff(repoDir, {
|
||||
mode: "uncommitted",
|
||||
includeStructured: true,
|
||||
});
|
||||
|
||||
const entry = diff.structured?.find((file) => file.path === "untracked-large.txt");
|
||||
expect(entry).toBeTruthy();
|
||||
expect(entry?.status).toBe("too_large");
|
||||
expect(diff.diff).toContain("# untracked-large.txt: diff too large omitted");
|
||||
});
|
||||
|
||||
it("handles status/diff/commit in a .paseo worktree", async () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { exec, execFile, spawn } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { resolve, dirname, basename } from "path";
|
||||
import { realpathSync } from "fs";
|
||||
import { open as openFile, stat as statFile } from "fs/promises";
|
||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
||||
@@ -20,13 +21,6 @@ async function execGit(command: string, options: { cwd: string; env?: NodeJS.Pro
|
||||
return execAsync(command, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER });
|
||||
}
|
||||
|
||||
async function execGitFile(
|
||||
args: string[],
|
||||
options: { cwd: string; env?: NodeJS.ProcessEnv }
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return execFileAsync("git", args, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER });
|
||||
}
|
||||
|
||||
type LimitedTextResult = {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
@@ -190,10 +184,20 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
|
||||
|
||||
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
|
||||
|
||||
async function tryGetNumstat(cwd: string, args: string[]): Promise<FileStat> {
|
||||
async function tryGetNumstat(
|
||||
cwd: string,
|
||||
args: string[]
|
||||
): Promise<FileStat> {
|
||||
try {
|
||||
const { stdout } = await execGitFile(args, { cwd, env: READ_ONLY_GIT_ENV });
|
||||
const line = stdout.trim().split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
||||
const { text } = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args,
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: 64 * 1024,
|
||||
acceptExitCodes: [0],
|
||||
});
|
||||
const line = text.trim().split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
||||
if (!line) return null;
|
||||
const [aRaw, dRaw] = line.split(/\s+/);
|
||||
if (!aRaw || !dRaw) return null;
|
||||
@@ -634,6 +638,65 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
|
||||
|
||||
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024;
|
||||
|
||||
async function isLikelyBinaryFile(absolutePath: string): Promise<boolean> {
|
||||
const handle = await openFile(absolutePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.allocUnsafe(UNTRACKED_BINARY_SNIFF_BYTES);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
if (bytesRead === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let suspicious = 0;
|
||||
for (let i = 0; i < bytesRead; i += 1) {
|
||||
const byte = buffer[i];
|
||||
if (byte === 0) {
|
||||
return true;
|
||||
}
|
||||
// Treat control bytes as suspicious while allowing common whitespace.
|
||||
if (byte < 7 || (byte > 14 && byte < 32) || byte === 127) {
|
||||
suspicious += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return suspicious / bytesRead > 0.3;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectUntrackedFile(
|
||||
cwd: string,
|
||||
relativePath: string
|
||||
): Promise<{ stat: FileStat; truncated: boolean }> {
|
||||
const absolutePath = resolve(cwd, relativePath);
|
||||
const metadata = await statFile(absolutePath);
|
||||
|
||||
if (!metadata.isFile()) {
|
||||
return { stat: null, truncated: false };
|
||||
}
|
||||
|
||||
if (await isLikelyBinaryFile(absolutePath)) {
|
||||
return {
|
||||
stat: { additions: 0, deletions: 0, isBinary: true },
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (metadata.size > PER_FILE_DIFF_MAX_BYTES) {
|
||||
return {
|
||||
stat: { additions: 0, deletions: 0, isBinary: false },
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stat: { additions: 0, deletions: 0, isBinary: false },
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlaceholderParsedDiffFile(
|
||||
change: CheckoutFileChange,
|
||||
@@ -655,16 +718,16 @@ async function getPerFileDiffText(
|
||||
ref: string,
|
||||
change: CheckoutFileChange
|
||||
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
||||
const stat: FileStat =
|
||||
change.isUntracked
|
||||
? null
|
||||
: await tryGetNumstat(cwd, ["diff", "--numstat", ref, "--", change.path]);
|
||||
|
||||
if (stat?.isBinary) {
|
||||
return { text: "", truncated: false, stat };
|
||||
}
|
||||
|
||||
if (change.isUntracked) {
|
||||
try {
|
||||
const inspected = await inspectUntrackedFile(cwd, change.path);
|
||||
if (inspected.stat?.isBinary || inspected.truncated) {
|
||||
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to git diff path if metadata probing fails.
|
||||
}
|
||||
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
||||
@@ -673,7 +736,16 @@ async function getPerFileDiffText(
|
||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
return { text: result.text, truncated: result.truncated, stat };
|
||||
return {
|
||||
text: result.text,
|
||||
truncated: result.truncated,
|
||||
stat: { additions: 0, deletions: 0, isBinary: false },
|
||||
};
|
||||
}
|
||||
|
||||
const stat = await tryGetNumstat(cwd, ["diff", "--numstat", ref, "--", change.path]);
|
||||
if (stat?.isBinary) {
|
||||
return { text: "", truncated: false, stat };
|
||||
}
|
||||
|
||||
const result = await spawnLimitedText({
|
||||
|
||||
Reference in New Issue
Block a user