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
106 changes: 51 additions & 55 deletions src/workspace/get-workspace-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface ChangesFromRefInput {
interface DiffStat {
additions: number;
deletions: number;
isBinary: boolean;
}

interface FileFingerprint {
Expand Down Expand Up @@ -192,40 +193,53 @@ async function readWorkingTreeFile(repoRoot: string, path: string): Promise<stri

function fallbackStats(oldText: string | null, newText: string | null): DiffStat {
if (oldText == null && newText == null) {
return { additions: 0, deletions: 0 };
return { additions: 0, deletions: 0, isBinary: false };
}
if (oldText == null) {
return { additions: toLineCount(newText ?? ""), deletions: 0 };
return { additions: toLineCount(newText ?? ""), deletions: 0, isBinary: false };
}
if (newText == null) {
return { additions: 0, deletions: toLineCount(oldText) };
return { additions: 0, deletions: toLineCount(oldText), isBinary: false };
}

const oldLines = toLineCount(oldText);
const newLines = toLineCount(newText);
return {
additions: Math.max(newLines - oldLines, 0),
deletions: Math.max(oldLines - newLines, 0),
isBinary: false,
};
}

function parseDiffStatOutput(output: string): DiffStat | null {
const firstLine = output
.split("\n")
.map((line) => line.trim())
.find(Boolean);
if (!firstLine) {
return null;
}
const [addedRaw, deletedRaw] = firstLine.split("\t");
if (addedRaw === "-" && deletedRaw === "-") {
return {
additions: 0,
deletions: 0,
isBinary: true,
};
}
const additions = Number.parseInt(addedRaw ?? "", 10);
const deletions = Number.parseInt(deletedRaw ?? "", 10);
return {
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
isBinary: false,
};
}

async function readDiffStat(repoRoot: string, path: string): Promise<DiffStat | null> {
try {
const output = await getGitStdout(["diff", "--numstat", "HEAD", "--", path], repoRoot);
const firstLine = output
.split("\n")
.map((line) => line.trim())
.find(Boolean);
if (!firstLine) {
return null;
}
const [addedRaw, deletedRaw] = firstLine.split("\t");
const additions = Number.parseInt(addedRaw ?? "", 10);
const deletions = Number.parseInt(deletedRaw ?? "", 10);
return {
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
};
return parseDiffStatOutput(output);
} catch {
return null;
}
Expand All @@ -239,20 +253,7 @@ async function readDiffStatBetweenRefs(
): Promise<DiffStat | null> {
try {
const output = await getGitStdout(["diff", "--numstat", fromRef, toRef, "--", path], repoRoot);
const firstLine = output
.split("\n")
.map((line) => line.trim())
.find(Boolean);
if (!firstLine) {
return null;
}
const [addedRaw, deletedRaw] = firstLine.split("\t");
const additions = Number.parseInt(addedRaw ?? "", 10);
const deletions = Number.parseInt(deletedRaw ?? "", 10);
return {
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
};
return parseDiffStatOutput(output);
} catch {
return null;
}
Expand All @@ -261,34 +262,25 @@ async function readDiffStatBetweenRefs(
async function readDiffStatFromRef(repoRoot: string, fromRef: string, path: string): Promise<DiffStat | null> {
try {
const output = await getGitStdout(["diff", "--numstat", fromRef, "--", path], repoRoot);
const firstLine = output
.split("\n")
.map((line) => line.trim())
.find(Boolean);
if (!firstLine) {
return null;
}
const [addedRaw, deletedRaw] = firstLine.split("\t");
const additions = Number.parseInt(addedRaw ?? "", 10);
const deletions = Number.parseInt(deletedRaw ?? "", 10);
return {
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
};
return parseDiffStatOutput(output);
} catch {
return null;
}
}

async function buildFileChange(repoRoot: string, entry: NameStatusEntry): Promise<RuntimeWorkspaceFileChange> {
const basePath = entry.previousPath ?? entry.path;
const diffStat = entry.status === "untracked" ? null : await readDiffStat(repoRoot, entry.path);
const oldText =
entry.status === "added" || entry.status === "untracked" ? null : await readHeadFile(repoRoot, basePath);
const newText = entry.status === "deleted" ? null : await readWorkingTreeFile(repoRoot, entry.path);
entry.status === "added" || entry.status === "untracked" || diffStat?.isBinary
? null
: await readHeadFile(repoRoot, basePath);
const newText =
entry.status === "deleted" || diffStat?.isBinary ? null : await readWorkingTreeFile(repoRoot, entry.path);
const stats =
entry.status === "untracked"
? { additions: toLineCount(newText ?? ""), deletions: 0 }
: ((await readDiffStat(repoRoot, entry.path)) ?? fallbackStats(oldText, newText));
: (diffStat ?? fallbackStats(oldText, newText));

return {
path: entry.path,
Expand All @@ -308,10 +300,12 @@ async function buildFileChangeBetweenRefs(
toRef: string,
): Promise<RuntimeWorkspaceFileChange> {
const basePath = entry.previousPath ?? entry.path;
const oldText = entry.status === "added" ? null : await readFileAtRef(repoRoot, fromRef, basePath);
const newText = entry.status === "deleted" ? null : await readFileAtRef(repoRoot, toRef, entry.path);
const stats =
(await readDiffStatBetweenRefs(repoRoot, fromRef, toRef, entry.path)) ?? fallbackStats(oldText, newText);
const diffStat = await readDiffStatBetweenRefs(repoRoot, fromRef, toRef, entry.path);
const oldText =
entry.status === "added" || diffStat?.isBinary ? null : await readFileAtRef(repoRoot, fromRef, basePath);
const newText =
entry.status === "deleted" || diffStat?.isBinary ? null : await readFileAtRef(repoRoot, toRef, entry.path);
const stats = diffStat ?? fallbackStats(oldText, newText);

return {
path: entry.path,
Expand All @@ -330,15 +324,17 @@ async function buildFileChangeFromRef(
fromRef: string,
): Promise<RuntimeWorkspaceFileChange> {
const basePath = entry.previousPath ?? entry.path;
const diffStat = entry.status === "untracked" ? null : await readDiffStatFromRef(repoRoot, fromRef, entry.path);
const oldText =
entry.status === "added" || entry.status === "untracked"
entry.status === "added" || entry.status === "untracked" || diffStat?.isBinary
? null
: await readFileAtRef(repoRoot, fromRef, basePath);
const newText = entry.status === "deleted" ? null : await readWorkingTreeFile(repoRoot, entry.path);
const newText =
entry.status === "deleted" || diffStat?.isBinary ? null : await readWorkingTreeFile(repoRoot, entry.path);
const stats =
entry.status === "untracked"
? { additions: toLineCount(newText ?? ""), deletions: 0 }
: ((await readDiffStatFromRef(repoRoot, fromRef, entry.path)) ?? fallbackStats(oldText, newText));
: (diffStat ?? fallbackStats(oldText, newText));

return {
path: entry.path,
Expand Down
92 changes: 92 additions & 0 deletions test/runtime/get-workspace-changes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { execFile } from "node:child_process";
import { mkdtemp, open, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";

import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { getWorkspaceChanges, getWorkspaceChangesBetweenRefs } from "../../src/workspace/get-workspace-changes";

const execFileAsync = promisify(execFile);
const FIFTY_MIB = 50 * 1024 * 1024;

async function runGit(repoRoot: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", ["-c", "core.quotepath=false", ...args], {
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_NOSYSTEM: "1",
},
});
return stdout.trim();
}

async function commitAll(repoRoot: string, message: string): Promise<string> {
await runGit(repoRoot, ["add", "."]);
await runGit(repoRoot, ["commit", "-m", message]);
return await runGit(repoRoot, ["rev-parse", "HEAD"]);
}

async function createSparseFile(path: string, size: number): Promise<void> {
const handle = await open(path, "w");
try {
await handle.truncate(size);
} finally {
await handle.close();
}
}

describe("getWorkspaceChanges", () => {
let repoRoot: string;

beforeEach(async () => {
repoRoot = await mkdtemp(join(tmpdir(), "kanban-workspace-changes-"));
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.email", "test@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Test User"]);
});

afterEach(async () => {
await rm(repoRoot, { recursive: true, force: true });
});

it("skips a 50 MiB working tree binary without loading file text", async () => {
const filePath = join(repoRoot, "large.bin");
await writeFile(filePath, "initial\n");
await commitAll(repoRoot, "Initial commit");

await createSparseFile(filePath, FIFTY_MIB);

const response = await getWorkspaceChanges(repoRoot);
const file = response.files.find((entry) => entry.path === "large.bin");

expect(file).toBeDefined();
expect(file?.status).toBe("modified");
expect(file?.oldText).toBeNull();
expect(file?.newText).toBeNull();
});

it("skips a 50 MiB historical binary before reading it from git", async () => {
const filePath = join(repoRoot, "large.bin");
await writeFile(filePath, "initial\n");
const smallRef = await commitAll(repoRoot, "Initial commit");

await createSparseFile(filePath, FIFTY_MIB);
const largeRef = await commitAll(repoRoot, "Large binary commit");

const response = await getWorkspaceChangesBetweenRefs({
cwd: repoRoot,
fromRef: smallRef,
toRef: largeRef,
});
const file = response.files.find((entry) => entry.path === "large.bin");

expect(file).toBeDefined();
expect(file?.status).toBe("modified");
expect(file?.oldText).toBeNull();
expect(file?.newText).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ describe("DiffViewerPanel", () => {

expect(container.textContent).toContain("assets/logo.png");
expect(container.textContent).toContain("Binary");
expect(container.textContent).toContain("Binary file not shown.");
expect(container.querySelector(".kb-diff-row")).toBeNull();
});

Expand Down
12 changes: 11 additions & 1 deletion web-ui/src/components/detail-panels/diff-viewer-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,17 @@ export function DiffViewerPanel({
>
{group.entries.map((entry) => (
<div key={entry.id} className="kb-diff-entry">
{entry.isBinary ? null : viewMode === "split" ? (
{entry.isBinary ? (
<div
style={{
padding: "12px",
fontSize: 12,
color: "var(--color-text-tertiary)",
}}
>
Binary file not shown.
</div>
) : viewMode === "split" ? (
<SplitDiff
path={group.path}
oldText={entry.oldText}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe("GitCommitDiffPanel", () => {
expect(scrollContainer.scrollTop).toBe(547);
});

it("shows only the file header for binary paths", async () => {
it("shows a binary not shown message for binary paths", async () => {
const diffSource: GitCommitDiffSource = {
type: "commit",
files: [
Expand Down Expand Up @@ -162,6 +162,7 @@ describe("GitCommitDiffPanel", () => {

expect(container.textContent).toContain("assets/logo.png");
expect(container.textContent).toContain("Binary");
expect(container.textContent).toContain("Binary file not shown.");
expect(container.textContent).not.toContain("No textual diff available.");
expect(container.querySelector(".kb-diff-row")).toBeNull();
});
Expand Down
16 changes: 13 additions & 3 deletions web-ui/src/components/git-history/git-commit-diff-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -412,9 +412,19 @@ export function GitCommitDiffPanel({
Renamed from <code className="font-mono">{commitFile.previousPath}</code>
</div>
) : null}
{!isBinaryFile && rows.length > 0 ? (
{isBinaryFile ? (
<div
style={{
padding: "12px",
fontSize: 12,
color: "var(--color-text-tertiary)",
}}
>
Binary file not shown.
</div>
) : rows.length > 0 ? (
<ReadOnlyUnifiedDiff rows={rows} path={path} />
) : !isBinaryFile ? (
) : (
<div
style={{
padding: "12px",
Expand All @@ -424,7 +434,7 @@ export function GitCommitDiffPanel({
>
No textual diff available.
</div>
) : null}
)}
</div>
</div>
) : null}
Expand Down
Loading