1import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2
3const ENTRY = "diff --git ";
4const QUOTE = '"';
5
6interface Entry {
7 oldPath: string | null;
8 newPath: string | null;
9 deleted: boolean;
10 revision: string | null;
11 /** Past the first hunk header every line is content, and content can start like a header. */
12 inBody: boolean;
13}
14
15/** Where a quoted name closes, given git escapes every quote the name itself holds. */
16function quotedEnd(rest: string): number {
17 for (let at = 1; at < rest.length; at += 1) {
18 const char = rest.charAt(at);
19 if (char === "\\") {
20 at += 1;
21 continue;
22 }
23 if (char === QUOTE) return at;
24 }
25 return -1;
26}
27
28/**
29 * `a/x`/`b/x` on a `---`/`+++` line; `/dev/null` marks the side that has no file. Git ends the
30 * name with a tab when it holds a space, and a name with a tab of its own arrives quoted with
31 * that tab escaped, so the first literal tab is never part of what the file is called.
32 */
33function sidePath(rest: string, prefix: string): string | null {
34 const tab = rest.indexOf("\t");
35 const token = tab === -1 ? rest : rest.slice(0, tab);
36 if (token === "/dev/null") return null;
37 const path = unquoteGitPatchPath(token);
38 return path.startsWith(prefix) ? path.slice(prefix.length) : path;
39}
40
41function headerSide(token: string, prefix: string): string | null {
42 const path = unquoteGitPatchPath(token);
43 return path.startsWith(prefix) ? path.slice(prefix.length) : null;
44}
45
46/**
47 * The two names on a `diff --git` line, written with no delimiter between them. `a/one two b/one
48 * two` can split in more than one place, so the split leaving both sides equal wins; a rename
49 * (the only case where sides differ) states its names on separate lines instead. A quoted name
50 * ends at its own closing quote and needs none of that guessing.
51 */
52function headerPaths(rest: string): readonly [string | null, string | null] {
53 if (rest.startsWith(QUOTE)) {
54 const end = quotedEnd(rest);
55 if (end === -1 || rest.charAt(end + 1) !== " ") return [null, null];
56 return [headerSide(rest.slice(0, end + 1), "a/"), headerSide(rest.slice(end + 2), "b/")];
57 }
58 if (rest.endsWith(QUOTE)) {
59 const opens = rest.indexOf(QUOTE);
60 if (opens < 1 || rest.charAt(opens - 1) !== " ") return [null, null];
61 return [headerSide(rest.slice(0, opens - 1), "a/"), headerSide(rest.slice(opens), "b/")];
62 }
63 if (!rest.startsWith("a/")) return [null, null];
64 const splits: Array<number> = [];
65 for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) splits.push(at);
66 const chosen =
67 splits.find((at) => rest.slice(2, at) === rest.slice(at + 3)) ??
68 (splits.length === 1 ? splits[0] : undefined);
69 return chosen === undefined ? [null, null] : [rest.slice(2, chosen), rest.slice(chosen + 3)];
70}
71
72/** The right-hand id of `index <before>..<after> <mode>`. */
73function headRevision(rest: string): string | null {
74 const gap = rest.indexOf("..");
75 if (gap === -1) return null;
76 const after = rest.slice(gap + 2);
77 const end = after.indexOf(" ");
78 const head = end === -1 ? after : after.slice(0, end);
79 return head.length === 0 ? null : head;
80}
81
82/**
83 * What the head has of each file, as the blob ids from a unified patch's `index <before>..<after>`
84 * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except
85 * for a deletion where only the old name exists. An entry with no `index` line (most often one
86 * Bitbucket excluded by pattern) is left out.
87 */
88export function parseDiffFileRevisions(patch: string): ReadonlyMap<string, string> {
89 const revisions = new Map<string, string>();
90 let entry: Entry | null = null;
91
92 const close = () => {
93 if (entry === null) return;
94 const path = entry.deleted ? entry.oldPath : (entry.newPath ?? entry.oldPath);
95 if (path !== null && path.length > 0 && entry.revision !== null) {
96 revisions.set(path, entry.revision);
97 }
98 entry = null;
99 };
100
101 for (const line of patch.split("\n")) {
102 if (line.startsWith(ENTRY)) {
103 close();
104 const [oldPath, newPath] = headerPaths(line.slice(ENTRY.length));
105 entry = { oldPath, newPath, deleted: false, revision: null, inBody: false };
106 continue;
107 }
108 if (entry === null || entry.inBody) continue;
109 if (line.startsWith("@@")) {
110 entry.inBody = true;
111 } else if (line.startsWith("index ")) {
112 entry.revision = headRevision(line.slice("index ".length));
113 } else if (line.startsWith("deleted file mode")) {
114 entry.deleted = true;
115 } else if (line.startsWith("rename from ")) {
116 entry.oldPath = unquoteGitPatchPath(line.slice("rename from ".length));
117 } else if (line.startsWith("rename to ")) {
118 entry.newPath = unquoteGitPatchPath(line.slice("rename to ".length));
119 } else if (line.startsWith("--- ")) {
120 entry.oldPath = sidePath(line.slice(4), "a/");
121 } else if (line.startsWith("+++ ")) {
122 const side = sidePath(line.slice(4), "b/");
123 entry.newPath = side;
124 if (side === null) entry.deleted = true;
125 }
126 }
127 close();
128 return revisions;
129}