1export interface TurnDiffFileSummary {
2 readonly path: string;
3 readonly previousPath?: string;
4 readonly additions: number;
5 readonly deletions: number;
6}
7
8/** Reads Git's NUL-delimited numstat output without decoding display paths. */
9export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<TurnDiffFileSummary> {
10 const records = numstat.split("\0");
11 const files: TurnDiffFileSummary[] = [];
12
13 for (let index = 0; index < records.length; index += 1) {
14 const record = records[index]!;
15 const counts = /^(\d+|-)\t(\d+|-)\t/.exec(record);
16 if (!counts) continue;
17
18 let path = record.slice(counts[0].length);
19 let previousPath: string | undefined;
20 if (path.length === 0) {
21 // Renames and copies use two more records: the source and destination.
22 previousPath = records[index + 1];
23 path = records[index + 2] ?? "";
24 index += 2;
25 }
26 if (path.length === 0) continue;
27
28 files.push({
29 path,
30 ...(previousPath === undefined ? {} : { previousPath }),
31 additions: counts[1] === "-" ? 0 : Number(counts[1]),
32 deletions: counts[2] === "-" ? 0 : Number(counts[2]),
33 });
34 }
35
36 return files.toSorted((left, right) => left.path.localeCompare(right.path));
37}
38
39/**
40 * True when Git brought this file in and no work in the range touched it.
41 * `authoredPaths` comes from `CheckpointStore.listAuthoredPaths`; null means nothing was imported.
42 * A rename stays visible when either side was authored, because the summary shows it as one file.
43 */
44export function isGitImport(
45 file: TurnDiffFileSummary,
46 authoredPaths: ReadonlySet<string> | null,
47): boolean {
48 return (
49 authoredPaths !== null &&
50 !authoredPaths.has(file.path) &&
51 (file.previousPath === undefined || !authoredPaths.has(file.previousPath))
52 );
53}