Fix checkout diff stalls on untracked binary artifacts
This commit is contained in:
@@ -1,186 +0,0 @@
|
|||||||
#!/usr/bin/env npx tsx
|
|
||||||
/**
|
|
||||||
* Ad-hoc checkout diff debugger.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* npx tsx packages/server/src/server/daemon-e2e/checkout-diff-debug.ts
|
|
||||||
* npx tsx packages/server/src/server/daemon-e2e/checkout-diff-debug.ts --agent <agentId>
|
|
||||||
* npx tsx packages/server/src/server/daemon-e2e/checkout-diff-debug.ts --cwd <path>
|
|
||||||
* npx tsx packages/server/src/server/daemon-e2e/checkout-diff-debug.ts --limit 3
|
|
||||||
*
|
|
||||||
* Optional env:
|
|
||||||
* PASEO_LISTEN=127.0.0.1:6767
|
|
||||||
*/
|
|
||||||
|
|
||||||
import os from "node:os";
|
|
||||||
import { DaemonClient } from "../../client/daemon-client.js";
|
|
||||||
|
|
||||||
type CliArgs = {
|
|
||||||
agentId?: string;
|
|
||||||
cwd?: string;
|
|
||||||
limit: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseArgs(argv: string[]): CliArgs {
|
|
||||||
const args: CliArgs = { limit: 5 };
|
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
|
||||||
const token = argv[i];
|
|
||||||
if (token === "--agent") {
|
|
||||||
const value = argv[i + 1];
|
|
||||||
if (value) {
|
|
||||||
args.agentId = value;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token === "--cwd") {
|
|
||||||
const value = argv[i + 1];
|
|
||||||
if (value) {
|
|
||||||
args.cwd = value;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token === "--limit") {
|
|
||||||
const value = Number.parseInt(argv[i + 1] ?? "", 10);
|
|
||||||
if (!Number.isNaN(value) && value > 0) {
|
|
||||||
args.limit = value;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtMs(ms: number): string {
|
|
||||||
return `${ms.toLocaleString()}ms`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const args = parseArgs(process.argv.slice(2));
|
|
||||||
const listen = process.env.PASEO_LISTEN ?? "127.0.0.1:6767";
|
|
||||||
const url = `ws://${listen}/ws`;
|
|
||||||
const home = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`;
|
|
||||||
|
|
||||||
console.log("Checkout Diff Debugger");
|
|
||||||
console.log(`daemon=${url}`);
|
|
||||||
console.log(`PASEO_HOME=${home}`);
|
|
||||||
console.log(
|
|
||||||
`filters agent=${args.agentId ?? "-"} cwd=${args.cwd ?? "-"} limit=${args.limit}`
|
|
||||||
);
|
|
||||||
console.log("");
|
|
||||||
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url,
|
|
||||||
reconnect: { enabled: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("checkout_status_response", (message) => {
|
|
||||||
if (message.type !== "checkout_status_response") return;
|
|
||||||
const payload = message.payload;
|
|
||||||
console.log(
|
|
||||||
`[raw] checkout_status_response requestId=${payload.requestId} cwd=${payload.cwd} isGit=${payload.isGit}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("checkout_diff_response", (message) => {
|
|
||||||
if (message.type !== "checkout_diff_response") return;
|
|
||||||
const payload = message.payload;
|
|
||||||
console.log(
|
|
||||||
`[raw] checkout_diff_response requestId=${payload.requestId} cwd=${payload.cwd} files=${payload.files.length} error=${payload.error ? "yes" : "no"}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("rpc_error", (message) => {
|
|
||||||
if (message.type !== "rpc_error") return;
|
|
||||||
const payload = message.payload;
|
|
||||||
console.log(
|
|
||||||
`[raw] rpc_error requestId=${payload.requestId} requestType=${payload.requestType} code=${payload.code ?? "none"}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
const ping = await client.ping({ timeoutMs: 3000 });
|
|
||||||
console.log(`ping=${fmtMs(ping.rttMs)}`);
|
|
||||||
|
|
||||||
const snapshots = await client.fetchAgents({ filter: { labels: { ui: "true" } } });
|
|
||||||
const candidates = snapshots
|
|
||||||
.filter((snapshot) => !args.agentId || snapshot.id === args.agentId)
|
|
||||||
.map((snapshot) => ({
|
|
||||||
id: snapshot.id,
|
|
||||||
title: snapshot.title ?? "(untitled)",
|
|
||||||
cwd: snapshot.cwd,
|
|
||||||
updatedAt: snapshot.updatedAt,
|
|
||||||
}))
|
|
||||||
.filter((item) => !args.cwd || item.cwd === args.cwd)
|
|
||||||
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
|
||||||
|
|
||||||
const targets = (args.cwd
|
|
||||||
? [{ id: "(manual)", title: "(manual)", cwd: args.cwd, updatedAt: new Date().toISOString() }]
|
|
||||||
: candidates
|
|
||||||
)
|
|
||||||
.slice(0, args.limit)
|
|
||||||
.filter((item, index, list) => list.findIndex((v) => v.cwd === item.cwd) === index);
|
|
||||||
|
|
||||||
if (targets.length === 0) {
|
|
||||||
console.log("No matching agents/cwds found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Testing ${targets.length} cwd target(s)\n`);
|
|
||||||
|
|
||||||
for (const target of targets) {
|
|
||||||
console.log(`--- ${target.cwd}`);
|
|
||||||
console.log(`agent=${target.id} title=${target.title}`);
|
|
||||||
|
|
||||||
const statusStart = Date.now();
|
|
||||||
let statusPayload: Awaited<ReturnType<typeof client.getCheckoutStatus>>;
|
|
||||||
try {
|
|
||||||
statusPayload = await client.getCheckoutStatus(target.cwd);
|
|
||||||
console.log(
|
|
||||||
`status: ok ${fmtMs(Date.now() - statusStart)} isGit=${statusPayload.isGit} branch=${statusPayload.currentBranch ?? "-"} dirty=${statusPayload.isDirty ?? "-"} baseRef=${statusPayload.baseRef ?? "-"}`
|
|
||||||
);
|
|
||||||
if (statusPayload.error) {
|
|
||||||
console.log(`status.error=${statusPayload.error.message}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`status: FAIL ${fmtMs(Date.now() - statusStart)} ${String(error)}`);
|
|
||||||
console.log("");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!statusPayload.isGit) {
|
|
||||||
console.log("diff: skipped (not a git repo)\n");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const compareMode = statusPayload.isDirty ? "uncommitted" : "base";
|
|
||||||
const diffStart = Date.now();
|
|
||||||
try {
|
|
||||||
const diff = await client.getCheckoutDiff(target.cwd, {
|
|
||||||
mode: compareMode,
|
|
||||||
baseRef: statusPayload.baseRef ?? undefined,
|
|
||||||
});
|
|
||||||
const diffDuration = Date.now() - diffStart;
|
|
||||||
console.log(
|
|
||||||
`diff: ok ${fmtMs(diffDuration)} mode=${compareMode} files=${diff.files.length} error=${diff.error ? "yes" : "no"}`
|
|
||||||
);
|
|
||||||
if (diff.error) {
|
|
||||||
console.log(`diff.error=${diff.error.message}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`diff: FAIL ${fmtMs(Date.now() - diffStart)} ${String(error)}`);
|
|
||||||
}
|
|
||||||
console.log("");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void main().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -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 () => {
|
it("handles status/diff/commit in a .paseo worktree", async () => {
|
||||||
const result = await createWorktree({
|
const result = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { exec, execFile, spawn } from "child_process";
|
|||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { resolve, dirname, basename } from "path";
|
import { resolve, dirname, basename } from "path";
|
||||||
import { realpathSync } from "fs";
|
import { realpathSync } from "fs";
|
||||||
|
import { open as openFile, stat as statFile } from "fs/promises";
|
||||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||||
import { isPaseoOwnedWorktreeCwd } from "./worktree.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 });
|
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 = {
|
type LimitedTextResult = {
|
||||||
text: string;
|
text: string;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
@@ -190,10 +184,20 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
|
|||||||
|
|
||||||
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
|
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 {
|
try {
|
||||||
const { stdout } = await execGitFile(args, { cwd, env: READ_ONLY_GIT_ENV });
|
const { text } = await spawnLimitedText({
|
||||||
const line = stdout.trim().split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
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;
|
if (!line) return null;
|
||||||
const [aRaw, dRaw] = line.split(/\s+/);
|
const [aRaw, dRaw] = line.split(/\s+/);
|
||||||
if (!aRaw || !dRaw) return null;
|
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 PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||||
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
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(
|
function buildPlaceholderParsedDiffFile(
|
||||||
change: CheckoutFileChange,
|
change: CheckoutFileChange,
|
||||||
@@ -655,16 +718,16 @@ async function getPerFileDiffText(
|
|||||||
ref: string,
|
ref: string,
|
||||||
change: CheckoutFileChange
|
change: CheckoutFileChange
|
||||||
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
): 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) {
|
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({
|
const result = await spawnLimitedText({
|
||||||
cmd: "git",
|
cmd: "git",
|
||||||
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
||||||
@@ -673,7 +736,16 @@ async function getPerFileDiffText(
|
|||||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||||
acceptExitCodes: [0, 1],
|
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({
|
const result = await spawnLimitedText({
|
||||||
|
|||||||
Reference in New Issue
Block a user