Files
paperclip/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts
T
Dotta 43c5bb81b6 [codex] Workspace diff polish (#6383)
## Thinking Path

> - Paperclip gives operators a workspace diff plugin so they can
inspect agent changes before review
> - The diff view needs reliable base-ref defaults and controls that
stay usable while scrolling large diffs
> - The working branch mixed those plugin improvements with unrelated
server and cloud work
> - Keeping the workspace diff plugin changes isolated makes them easy
to test and review
> - This pull request polishes the workspace diff plugin controls,
base-ref behavior, and sticky headers
> - The benefit is a more predictable diff review surface for agent
workspaces

## What Changed

- Fixed workspace diff default base-ref resolution.
- Improved split/unified and working-tree/against-ref pane controls.
- Made workspace diff headers stay sticky while scrolling.
- Added a review screenshot at
`screenshots/PAP-9841-workspace-diff.png`.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm --filter @paperclipai/plugin-sdk build`
- `pnpm --filter @paperclipai/plugin-workspace-diff exec vitest run
tests/plugin.spec.ts`
- Result: 9 tests passed.

## Risks

- UI-only plugin branch with low data risk.
- The default base-ref inference should be reviewed against unusual
worktree/upstream combinations.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent with local shell/git/tool use.
Exact hosted model ID and context-window size are not exposed by the
local Paperclip adapter runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-19 15:51:13 -05:00

846 lines
25 KiB
TypeScript

import { execFile } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
import type {
WorkspaceDiffCaps,
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffFileStatus,
WorkspaceDiffPatchKind,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
WorkspaceDiffWarning,
WorkspaceDiffWarningCode,
} from "./contracts.js";
const execFileAsync = promisify(execFile);
export const WORKSPACE_DIFF_CAPS: WorkspaceDiffCaps = {
maxFiles: 200,
maxFileBytes: 512 * 1024,
maxPatchBytes: 256 * 1024,
maxTotalPatchBytes: 1024 * 1024,
};
const GIT_TIMEOUT_MS = 10_000;
const GIT_LIST_MAX_BUFFER = 2 * 1024 * 1024;
const OPEN_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
interface GitStatusEntry {
status: WorkspaceDiffFileStatus;
path: string;
oldPath: string | null;
}
type DiffScope = "staged" | "unstaged" | "head";
interface MutableWorkspaceDiffFile extends WorkspaceDiffFile {
patchScopes: DiffScope[];
}
interface PatchBudget {
totalPatchBytes: number;
}
type WorkspaceDiffTarget = Pick<PluginExecutionWorkspaceMetadata, "id" | "companyId" | "cwd" | "baseRef">;
function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning {
return { code, message, path: filePath };
}
function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record<string, unknown> = {}) {
const error = new Error(message);
Object.assign(error, { code, status: 422, details: { code, ...details } });
return error;
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return String(error);
}
async function runGit(cwd: string, args: string[], maxBuffer = GIT_LIST_MAX_BUFFER) {
try {
return await execFileAsync("git", ["-C", cwd, ...args], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer,
});
} catch (error) {
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
? String((error as { stderr?: unknown }).stderr).trim()
: "";
const message = stderr || toErrorMessage(error);
throw workspaceDiffError("git_command_failed", message, { args });
}
}
async function realDirectory(value: string, code: WorkspaceDiffWarningCode) {
if (!path.isAbsolute(value)) {
throw workspaceDiffError(code, "Execution workspace path must be absolute", { cwd: value });
}
let stat: Awaited<ReturnType<typeof fs.stat>>;
try {
stat = await fs.stat(value);
} catch {
throw workspaceDiffError(code, "Execution workspace path does not exist", { cwd: value });
}
if (!stat.isDirectory()) {
throw workspaceDiffError(code, "Execution workspace path is not a directory", { cwd: value });
}
return await fs.realpath(value);
}
function isWithinDirectory(childPath: string, parentPath: string) {
const relative = path.relative(parentPath, childPath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function resolveWorkspacePaths(workspace: WorkspaceDiffTarget) {
if (!workspace.cwd?.trim()) {
throw workspaceDiffError(
"missing_cwd",
"Execution workspace needs a local path before Paperclip can inspect diffs",
{ workspaceId: workspace.id },
);
}
const cwd = await realDirectory(workspace.cwd.trim(), "workspace_path_invalid");
let repoRoot: string;
try {
repoRoot = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim();
} catch {
throw workspaceDiffError(
"non_git_workspace",
"Execution workspace path is not inside a git repository",
{ workspaceId: workspace.id, cwd },
);
}
const repoRootReal = await realDirectory(repoRoot, "non_git_workspace");
if (!isWithinDirectory(cwd, repoRootReal)) {
throw workspaceDiffError(
"workspace_path_invalid",
"Execution workspace path resolved outside its git repository",
{ workspaceId: workspace.id, cwd, repoRoot: repoRootReal },
);
}
return { cwd, repoRoot: repoRootReal };
}
function normalizePathFilter(rawPath: string) {
const value = rawPath.trim().replaceAll("\\", "/");
if (!value || value === ".") return null;
if (value.includes("\0") || value.startsWith("/")) {
throw workspaceDiffError("path_filter_invalid", "Path filters must be relative workspace paths", { path: rawPath });
}
const normalized = path.posix.normalize(value);
if (
normalized === "." ||
normalized === ".." ||
normalized.startsWith("../") ||
normalized.includes("/../")
) {
throw workspaceDiffError(
"path_filter_invalid",
"Path filters must not contain traversal segments",
{ path: rawPath },
);
}
return normalized;
}
function normalizePathFilters(paths: string[]) {
return Array.from(new Set(paths.map(normalizePathFilter).filter((value): value is string => Boolean(value))));
}
function statusFromGitStatus(status: string): WorkspaceDiffFileStatus {
if (status.startsWith("R")) return "renamed";
if (status.startsWith("C")) return "copied";
switch (status[0]) {
case "A":
return "added";
case "D":
return "deleted";
case "M":
return "modified";
case "T":
return "type_changed";
default:
return "unknown";
}
}
function parseNameStatus(output: string): GitStatusEntry[] {
const tokens = output.split("\0").filter(Boolean);
const entries: GitStatusEntry[] = [];
let index = 0;
while (index < tokens.length) {
const statusCode = tokens[index++] ?? "";
if (!statusCode) continue;
if (statusCode.startsWith("R") || statusCode.startsWith("C")) {
const oldPath = tokens[index++] ?? "";
const newPath = tokens[index++] ?? "";
if (newPath) {
entries.push({
status: statusFromGitStatus(statusCode),
path: newPath,
oldPath: oldPath || null,
});
}
continue;
}
const filePath = tokens[index++] ?? "";
if (filePath) {
entries.push({
status: statusFromGitStatus(statusCode),
path: filePath,
oldPath: null,
});
}
}
return entries;
}
async function readDiffNameStatus(cwd: string, scopeArgs: string[], paths: string[]) {
const result = await runGit(cwd, [
"diff",
"--name-status",
"-z",
"--no-ext-diff",
"--find-renames",
...scopeArgs,
"--",
...paths,
]);
return parseNameStatus(result.stdout);
}
async function readUntrackedPaths(cwd: string, paths: string[]) {
const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths]);
return result.stdout.split("\0").filter(Boolean);
}
function ensureFile(
files: Map<string, MutableWorkspaceDiffFile>,
filePath: string,
status: WorkspaceDiffFileStatus,
oldPath: string | null,
) {
const existing = files.get(filePath);
if (existing) {
if (existing.status === "unknown" || status === "renamed" || status === "copied") {
existing.status = status;
}
if (!existing.oldPath && oldPath) existing.oldPath = oldPath;
return existing;
}
const file: MutableWorkspaceDiffFile = {
path: filePath,
oldPath,
status,
staged: false,
unstaged: false,
untracked: false,
binary: false,
oversized: false,
truncated: false,
additions: 0,
deletions: 0,
sizeBytes: null,
patches: [],
warnings: [],
patchScopes: [],
};
files.set(filePath, file);
return file;
}
function addStatusEntries(
files: Map<string, MutableWorkspaceDiffFile>,
entries: GitStatusEntry[],
scope: DiffScope,
) {
for (const entry of entries) {
const file = ensureFile(files, entry.path, entry.status, entry.oldPath);
if (scope === "staged") file.staged = true;
else if (scope === "unstaged") file.unstaged = true;
if (!file.patchScopes.includes(scope)) file.patchScopes.push(scope);
}
}
function parseNumstat(output: string) {
const line = output.split(/\r?\n/).find(Boolean);
if (!line) return { additions: 0, deletions: 0, binary: false };
const [additionsRaw, deletionsRaw] = line.split(/\t/);
if (additionsRaw === "-" || deletionsRaw === "-") {
return { additions: 0, deletions: 0, binary: true };
}
return {
additions: Number.parseInt(additionsRaw ?? "0", 10) || 0,
deletions: Number.parseInt(deletionsRaw ?? "0", 10) || 0,
binary: false,
};
}
async function readNumstat(cwd: string, scopeArgs: string[], filePath: string) {
const result = await runGit(cwd, [
"diff",
"--numstat",
"--no-ext-diff",
"--find-renames",
...scopeArgs,
"--",
filePath,
], 128 * 1024);
return parseNumstat(result.stdout);
}
async function statWorkspaceFile(repoRoot: string, filePath: string) {
const resolved = await resolveWorkspaceFilePath(repoRoot, filePath);
if (resolved.status !== "ok") return null;
let handle: Awaited<ReturnType<typeof fs.open>>;
try {
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
} catch {
return null;
}
try {
const stat = await handle.stat();
return stat.isFile() ? stat.size : null;
} catch {
return null;
} finally {
await handle.close();
}
}
async function resolveWorkspaceFilePath(repoRoot: string, filePath: string): Promise<
| { status: "ok"; realPath: string }
| { status: "missing" }
| { status: "outside_workspace" }
> {
const target = path.resolve(repoRoot, filePath);
if (!isWithinDirectory(target, repoRoot)) return { status: "outside_workspace" };
try {
const realPath = await fs.realpath(target);
if (!isWithinDirectory(realPath, repoRoot)) return { status: "outside_workspace" };
return { status: "ok", realPath };
} catch {
return { status: "missing" };
}
}
function isMaxBufferError(error: unknown) {
return typeof error === "object"
&& error !== null
&& "code" in error
&& (error as { code?: unknown }).code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
}
async function readPatchOutput(cwd: string, args: string[]) {
try {
return await execFileAsync("git", ["-C", cwd, ...args], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: WORKSPACE_DIFF_CAPS.maxPatchBytes + 64 * 1024,
});
} catch (error) {
if (isMaxBufferError(error)) {
return null;
}
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
? String((error as { stderr?: unknown }).stderr).trim()
: "";
throw workspaceDiffError("git_command_failed", stderr || toErrorMessage(error), { args });
}
}
function reservePatchBytes(
patch: string,
budget: PatchBudget,
filePath: string,
warnings: WorkspaceDiffWarning[],
) {
const patchBytes = Buffer.byteLength(patch, "utf8");
if (patchBytes > WORKSPACE_DIFF_CAPS.maxPatchBytes) {
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", filePath));
return null;
}
if (budget.totalPatchBytes + patchBytes > WORKSPACE_DIFF_CAPS.maxTotalPatchBytes) {
warnings.push(warning("patch_truncated", "Workspace diff exceeded the total patch cap.", filePath));
return null;
}
budget.totalPatchBytes += patchBytes;
return patch;
}
async function buildTrackedPatch(input: {
cwd: string;
repoRoot: string;
filePath: string;
kind: WorkspaceDiffPatchKind;
scopeArgs: string[];
budget: PatchBudget;
}): Promise<WorkspaceDiffFilePatch> {
const warnings: WorkspaceDiffWarning[] = [];
const numstat = await readNumstat(input.cwd, input.scopeArgs, input.filePath);
const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath);
if (numstat.binary) {
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
return {
kind: input.kind,
patch: null,
additions: 0,
deletions: 0,
binary: true,
oversized: false,
truncated: false,
warnings,
};
}
if (sizeBytes !== null && sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
warnings.push(warning("file_oversized", "File is too large to include a text patch.", input.filePath));
return {
kind: input.kind,
patch: null,
additions: numstat.additions,
deletions: numstat.deletions,
binary: false,
oversized: true,
truncated: false,
warnings,
};
}
const patchOutput = await readPatchOutput(input.cwd, [
"diff",
"--no-ext-diff",
"--find-renames",
"--unified=3",
...input.scopeArgs,
"--",
input.filePath,
]);
if (!patchOutput) {
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", input.filePath));
return {
kind: input.kind,
patch: null,
additions: numstat.additions,
deletions: numstat.deletions,
binary: false,
oversized: false,
truncated: true,
warnings,
};
}
const patch = reservePatchBytes(patchOutput.stdout, input.budget, input.filePath, warnings);
return {
kind: input.kind,
patch,
additions: numstat.additions,
deletions: numstat.deletions,
binary: false,
oversized: false,
truncated: patch === null,
warnings,
};
}
function isProbablyBinary(buffer: Buffer) {
return buffer.subarray(0, Math.min(buffer.length, 8_000)).includes(0);
}
function countAddedLines(content: string) {
if (content.length === 0) return 0;
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
}
function buildUntrackedPatch(filePath: string, content: string) {
const lines = content.length === 0 ? [] : content.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
const lineCount = countAddedLines(content);
const header = [
`diff --git a/${filePath} b/${filePath}`,
"new file mode 100644",
"--- /dev/null",
`+++ b/${filePath}`,
];
if (lineCount === 0) return `${header.join("\n")}\n`;
const hunkLines = lines.map((line) => `+${line}`).join("\n");
return [...header, `@@ -0,0 +1,${lineCount} @@`, hunkLines, ""].join("\n");
}
async function buildUntrackedFilePatch(input: {
repoRoot: string;
filePath: string;
budget: PatchBudget;
}): Promise<WorkspaceDiffFilePatch> {
const warnings: WorkspaceDiffWarning[] = [];
const resolved = await resolveWorkspaceFilePath(input.repoRoot, input.filePath);
if (resolved.status === "outside_workspace") {
warnings.push(warning(
"symlink_target_outside_workspace",
"Untracked file resolves outside the workspace and is summarized without reading target bytes.",
input.filePath,
));
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
if (resolved.status === "missing") {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
let handle: Awaited<ReturnType<typeof fs.open>>;
try {
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
} catch {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
let sizeBytes: number;
let buffer: Buffer | null = null;
try {
const stat = await handle.stat();
if (!stat.isFile()) {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
sizeBytes = stat.size;
if (sizeBytes <= WORKSPACE_DIFF_CAPS.maxFileBytes) {
buffer = await handle.readFile();
}
} finally {
await handle.close();
}
if (sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
warnings.push(warning("file_oversized", "Untracked file is too large to include a text patch.", input.filePath));
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: true,
truncated: false,
warnings,
};
}
if (!buffer) {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
if (isProbablyBinary(buffer)) {
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: true,
oversized: false,
truncated: false,
warnings,
};
}
const content = buffer.toString("utf8");
const patch = reservePatchBytes(buildUntrackedPatch(input.filePath, content), input.budget, input.filePath, warnings);
return {
kind: "untracked",
patch,
additions: countAddedLines(content),
deletions: 0,
binary: false,
oversized: false,
truncated: patch === null,
warnings,
};
}
function applyPatchToFile(file: MutableWorkspaceDiffFile, patch: WorkspaceDiffFilePatch, sizeBytes: number | null) {
file.patches.push(patch);
file.additions += patch.additions;
file.deletions += patch.deletions;
file.binary = file.binary || patch.binary;
file.oversized = file.oversized || patch.oversized;
file.truncated = file.truncated || patch.truncated;
file.warnings.push(...patch.warnings);
if (file.sizeBytes === null && sizeBytes !== null) file.sizeBytes = sizeBytes;
}
function finalizeStats(files: WorkspaceDiffFile[]) {
return {
fileCount: files.length,
stagedFileCount: files.filter((file) => file.staged).length,
unstagedFileCount: files.filter((file) => file.unstaged).length,
untrackedFileCount: files.filter((file) => file.untracked).length,
binaryFileCount: files.filter((file) => file.binary).length,
oversizedFileCount: files.filter((file) => file.oversized).length,
truncatedFileCount: files.filter((file) => file.truncated).length,
additions: files.reduce((sum, file) => sum + file.additions, 0),
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
};
}
async function resolveHeadSha(cwd: string) {
try {
return (await runGit(cwd, ["rev-parse", "HEAD"], 128 * 1024)).stdout.trim() || null;
} catch {
return null;
}
}
async function resolveVerifiedGitRef(cwd: string, refName: string) {
const trimmed = refName.trim();
if (!trimmed) return null;
try {
await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${trimmed}^{commit}`], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: 128 * 1024,
});
return trimmed;
} catch {
return null;
}
}
async function resolveGitUpstreamRef(cwd: string) {
try {
const upstream = (await execFileAsync(
"git",
["-C", cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
{
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: 128 * 1024,
},
)).stdout.trim();
return upstream ? await resolveVerifiedGitRef(cwd, upstream) : null;
} catch {
return null;
}
}
async function resolveInferredDefaultBaseRef(cwd: string) {
const upstream = await resolveGitUpstreamRef(cwd);
if (upstream) return upstream;
const candidates = ["origin/master", "origin/main", "master", "main"];
const resolvedCandidates = await Promise.all(
candidates.map((candidate) => resolveVerifiedGitRef(cwd, candidate)),
);
for (const resolved of resolvedCandidates) {
if (resolved) return resolved;
}
return null;
}
async function resolveDefaultDiffBaseRef(cwd: string, workspace: WorkspaceDiffTarget) {
return workspace.baseRef?.trim() || await resolveInferredDefaultBaseRef(cwd);
}
async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) {
const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null;
if (!resolvedBaseRef) {
throw workspaceDiffError(
"base_ref_missing",
"A baseRef query parameter or execution workspace baseRef is required for head diffs",
{ workspaceId: workspace.id },
);
}
try {
await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${resolvedBaseRef}^{commit}`], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: 128 * 1024,
});
} catch {
throw workspaceDiffError(
"base_ref_invalid",
`Could not resolve baseRef "${resolvedBaseRef}" in this workspace`,
{ workspaceId: workspace.id, baseRef: resolvedBaseRef },
);
}
return resolvedBaseRef;
}
async function collectFiles(input: {
cwd: string;
workspace: WorkspaceDiffTarget;
query: WorkspaceDiffQueryOptions;
paths: string[];
}) {
const files = new Map<string, MutableWorkspaceDiffFile>();
let baseRef: string | null = null;
if (input.query.view === "head") {
baseRef = await resolveBaseRef(input.cwd, input.query.baseRef, input.workspace);
addStatusEntries(
files,
await readDiffNameStatus(input.cwd, [`${baseRef}...HEAD`], input.paths),
"head",
);
} else {
addStatusEntries(files, await readDiffNameStatus(input.cwd, ["--cached"], input.paths), "staged");
addStatusEntries(files, await readDiffNameStatus(input.cwd, [], input.paths), "unstaged");
if (input.query.includeUntracked) {
for (const untrackedPath of await readUntrackedPaths(input.cwd, input.paths)) {
const file = ensureFile(files, untrackedPath, "untracked", null);
file.untracked = true;
if (!file.patchScopes.includes("unstaged")) file.patchScopes.push("unstaged");
}
}
}
return { files, baseRef };
}
export function workspaceDiffService() {
return {
async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
const defaultBaseRef = await resolveDefaultDiffBaseRef(cwd, workspace);
const workspaceWithDefaultBaseRef = { ...workspace, baseRef: defaultBaseRef };
const paths = normalizePathFilters(query.paths);
const warnings: WorkspaceDiffWarning[] = [];
const { files: filesByPath, baseRef } = await collectFiles({
cwd,
workspace: workspaceWithDefaultBaseRef,
query,
paths,
});
const allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path));
const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles);
if (allFiles.length > cappedFiles.length) {
warnings.push(warning(
"file_count_truncated",
`Workspace diff includes ${allFiles.length} files, so only the first ${WORKSPACE_DIFF_CAPS.maxFiles} are returned.`,
));
}
const patchBudget: PatchBudget = { totalPatchBytes: 0 };
for (const file of cappedFiles) {
if (query.view === "head") {
const patch = await buildTrackedPatch({
cwd,
repoRoot,
filePath: file.path,
kind: "head",
scopeArgs: [`${baseRef}...HEAD`],
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
continue;
}
if (file.staged) {
const patch = await buildTrackedPatch({
cwd,
repoRoot,
filePath: file.path,
kind: "staged",
scopeArgs: ["--cached"],
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
}
if (file.unstaged) {
const patch = await buildTrackedPatch({
cwd,
repoRoot,
filePath: file.path,
kind: "unstaged",
scopeArgs: [],
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
}
if (file.untracked) {
const patch = await buildUntrackedFilePatch({
repoRoot,
filePath: file.path,
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
}
}
const files = cappedFiles.map(({ patchScopes: _patchScopes, ...file }) => file);
const patchWarnings = files.flatMap((file) => file.warnings);
return {
workspaceId: workspace.id,
companyId: workspace.companyId,
view: query.view,
baseRef,
defaultBaseRef,
headSha: await resolveHeadSha(cwd),
includeUntracked: query.includeUntracked,
paths,
files,
stats: finalizeStats(files),
warnings: [...warnings, ...patchWarnings],
caps: WORKSPACE_DIFF_CAPS,
truncated: warnings.some((item) => item.code === "file_count_truncated")
|| files.some((file) => file.truncated),
};
},
};
}