diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-debug.ts b/packages/server/src/server/daemon-e2e/checkout-diff-debug.ts deleted file mode 100644 index 8e6d53d2e..000000000 --- a/packages/server/src/server/daemon-e2e/checkout-diff-debug.ts +++ /dev/null @@ -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 - * npx tsx packages/server/src/server/daemon-e2e/checkout-diff-debug.ts --cwd - * 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>; - 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; -}); - diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 58f447e15..781f4dedc 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -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", diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index f40cc1086..88d53d1c1 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -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 { +async function tryGetNumstat( + cwd: string, + args: string[] +): Promise { 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 { + 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({