Skip to content
Merged
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
85 changes: 85 additions & 0 deletions src/core/diffFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { getFiletypeFromFileName, type FileDiffMetadata } from "@pierre/diffs";
import { findAgentFileContext } from "./agent";
import { patchLooksBinary } from "./binary";
import { normalizeDiffMetadataPaths, normalizeDiffPath } from "./diffPaths";
import type { AgentContext, DiffFile } from "./types";

/** Count visible additions and deletions from parsed diff metadata. */
export function countDiffStats(metadata: FileDiffMetadata) {
let additions = 0;
let deletions = 0;

for (const hunk of metadata.hunks) {
for (const content of hunk.hunkContent) {
if (content.type === "change") {
additions += content.additions;
deletions += content.deletions;
}
}
}

return { additions, deletions };
}

export interface BuildDiffFileOptions {
isUntracked?: boolean;
previousPath?: string;
isBinary?: boolean;
isTooLarge?: boolean;
stats?: DiffFile["stats"];
statsTruncated?: boolean;
}

/** Build the normalized per-file model used by the UI regardless of input mode. */
export function buildDiffFile(
metadata: FileDiffMetadata,
patch: string,
index: number,
sourcePrefix: string,
agentContext: AgentContext | null,
{
isUntracked,
previousPath,
isBinary,
isTooLarge,
stats,
statsTruncated,
}: BuildDiffFileOptions = {},
): DiffFile {
const normalizedMetadata = normalizeDiffMetadataPaths(metadata);
const path = normalizedMetadata.name;
const resolvedPreviousPath = normalizeDiffPath(previousPath) ?? normalizedMetadata.prevName;

return {
id: `${sourcePrefix}:${index}:${path}`,
path,
previousPath: resolvedPreviousPath,
patch,
language: getFiletypeFromFileName(path) ?? undefined,
stats: stats ?? countDiffStats(normalizedMetadata),
metadata: normalizedMetadata,
agent: findAgentFileContext(agentContext, path, resolvedPreviousPath),
isUntracked,
isBinary: isBinary ?? patchLooksBinary(patch),
isTooLarge,
statsTruncated,
};
}

/** Build placeholder metadata for a file whose full diff would be too expensive. */
export function createSkippedLargeMetadata(
filePath: string,
type: FileDiffMetadata["type"],
): FileDiffMetadata {
return {
name: filePath,
type,
hunks: [],
splitLineCount: 0,
unifiedLineCount: 0,
isPartial: true,
additionLines: [],
deletionLines: [],
cacheKey: `${filePath}:large-diff-skipped`,
};
}
34 changes: 34 additions & 0 deletions src/core/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,40 @@ export function listGitUntrackedFiles(
);
}

/** Escape only the filename characters that break unified-diff header parsing. */
function escapeUntrackedPatchPath(path: string) {
return path
.replaceAll("\\", "\\\\")
.replaceAll("\t", "\\t")
.replaceAll("\n", "\\n")
.replaceAll("\r", "\\r");
}

/** Rewrite Git's quoted untracked-file headers into parser-friendly paths. */
export function normalizeUntrackedPatchHeaders(patchText: string, filePath: string) {
const safePath = escapeUntrackedPatchPath(filePath);

return patchText
.replaceAll("\r\n", "\n")
.split("\n")
.map((line) => {
if (line.startsWith("diff --git ")) {
return `diff --git a/${safePath} b/${safePath}`;
}

if (line.startsWith("+++ ")) {
return `+++ b/${safePath}`;
}

if (line.startsWith("Binary files /dev/null and ")) {
return `Binary files /dev/null and b/${safePath} differ`;
}

return line;
})
.join("\n");
}

/** Return the raw Git patch text for one untracked file using `git diff --no-index`. */
export function runGitUntrackedFileDiffText(
input: VcsCommandInput,
Expand Down
Loading
Loading