thunderbird-patch-review
Easy-to-use patch review interface for Thunderbird
Contribute: ~marcc/thunderbird-review-plugin@lists.sr.ht
git clone git://mccd.space/thunderbird-patch-review
| Log | Files | Refs | README | LICENSE |
diff-parse.js (5752B)
1 // Parse the body of a git format-patch email into commit message, diffstat,
2 // and a structured diff. Every parsed element remembers the index of its
3 // source line in the email body ("bodyLine") so replies can re-quote the
4 // original text verbatim.
5
6 const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
7 const DIFF_GIT_RE = /^diff --git (?:"?a\/(.*?)"?) (?:"?b\/(.*?)"?)$/;
8
9 /**
10 * @param {string} text email body
11 * @returns {{
12 * commitMessage: string,
13 * diffstat: string,
14 * files: Array<{
15 * oldPath: string, newPath: string, displayPath: string,
16 * isBinary: boolean, isNew: boolean, isDeleted: boolean, isRename: boolean,
17 * bodyLine: number,
18 * hunks: Array<{
19 * header: string, section: string, bodyLine: number,
20 * oldStart: number, oldCount: number, newStart: number, newCount: number,
21 * lines: Array<{origin: "add"|"del"|"ctx", text: string, raw: string,
22 * bodyLine: number, oldLine: number|null, newLine: number|null}>
23 * }>
24 * }>,
25 * bodyLines: string[]
26 * }}
27 */
28 export function parsePatchEmail(text) {
29 const bodyLines = (text || "").split(/\r?\n/);
30 const result = { commitMessage: "", diffstat: "", files: [], bodyLines };
31
32 let firstDiff = bodyLines.findIndex((l) => DIFF_GIT_RE.test(l) || HUNK_RE.test(l));
33 if (firstDiff === -1) {
34 result.commitMessage = text || "";
35 return result;
36 }
37
38 // The commit message ends at the "---" scissors line when present,
39 // otherwise at the first diff line. Between the scissors and the diff
40 // sits the diffstat.
41 let scissors = -1;
42 for (let i = 0; i < firstDiff; i++) {
43 if (/^---\s*$/.test(bodyLines[i])) {
44 scissors = i;
45 }
46 }
47 const messageEnd = scissors === -1 ? firstDiff : scissors;
48 // git format-patch prepends an mbox-style header block to the body
49 // ("From <sha> Mon Sep 17 ...", From:, Date:, Subject:, then a blank
50 // line). The UI shows author and subject separately, so drop that
51 // block to keep only the real commit message text. reply-format does
52 // not use commitMessage, so stripping is safe.
53 let messageStart = 0;
54 if (messageEnd > 0 && /^From .*Mon Sep 17 00:00:00 2001$/.test(bodyLines[0])) {
55 let blank = 1;
56 while (blank < messageEnd && bodyLines[blank] !== "") blank++;
57 messageStart = Math.min(blank + 1, messageEnd);
58 }
59 result.commitMessage = bodyLines.slice(messageStart, messageEnd).join("\n").trim();
60 if (scissors !== -1) {
61 result.diffstat = bodyLines.slice(scissors + 1, firstDiff).join("\n").trim();
62 }
63
64 let file = null;
65 let hunk = null;
66 let oldLine = 0;
67 let newLine = 0;
68
69 const startFile = (oldPath, newPath, bodyLine) => {
70 file = {
71 oldPath,
72 newPath,
73 displayPath: newPath !== "/dev/null" && newPath ? newPath : oldPath,
74 isBinary: false,
75 isNew: false,
76 isDeleted: false,
77 isRename: false,
78 bodyLine,
79 hunks: [],
80 };
81 hunk = null;
82 result.files.push(file);
83 };
84
85 for (let i = firstDiff; i < bodyLines.length; i++) {
86 const line = bodyLines[i];
87
88 // git format-patch signature trailer terminates the diff.
89 if (/^-- ?$/.test(line) && hunk) {
90 break;
91 }
92
93 const dg = DIFF_GIT_RE.exec(line);
94 if (dg) {
95 startFile(dg[1], dg[2], i);
96 continue;
97 }
98
99 const h = HUNK_RE.exec(line);
100 if (h) {
101 if (!file) {
102 startFile("", "", i);
103 }
104 hunk = {
105 header: line,
106 section: h[5] || "",
107 bodyLine: i,
108 oldStart: parseInt(h[1], 10),
109 oldCount: h[2] === undefined ? 1 : parseInt(h[2], 10),
110 newStart: parseInt(h[3], 10),
111 newCount: h[4] === undefined ? 1 : parseInt(h[4], 10),
112 consumedOld: 0,
113 consumedNew: 0,
114 lines: [],
115 };
116 oldLine = hunk.oldStart;
117 newLine = hunk.newStart;
118 file.hunks.push(hunk);
119 continue;
120 }
121
122 if (file && !hunk) {
123 // Extended header lines between "diff --git" and the first hunk.
124 if (/^new file mode /.test(line)) file.isNew = true;
125 else if (/^deleted file mode /.test(line)) file.isDeleted = true;
126 else if (/^rename (from|to) /.test(line)) file.isRename = true;
127 else if (/^Binary files /.test(line) || /^GIT binary patch/.test(line)) file.isBinary = true;
128 else if (/^--- /.test(line)) file.oldPath = line.slice(4).replace(/^"?a\//, "").replace(/"$/, "");
129 else if (/^\+\+\+ /.test(line)) {
130 file.newPath = line.slice(4).replace(/^"?b\//, "").replace(/"$/, "");
131 if (file.newPath !== "/dev/null") file.displayPath = file.newPath;
132 }
133 continue;
134 }
135
136 if (hunk) {
137 const c = line[0];
138 if (c === "+") {
139 hunk.lines.push({ origin: "add", text: line.slice(1), raw: line, bodyLine: i, oldLine: null, newLine: newLine++ });
140 hunk.consumedNew++;
141 } else if (c === "-") {
142 hunk.lines.push({ origin: "del", text: line.slice(1), raw: line, bodyLine: i, oldLine: oldLine++, newLine: null });
143 hunk.consumedOld++;
144 } else if (c === " " || line === "") {
145 hunk.lines.push({ origin: "ctx", text: line.slice(1), raw: line, bodyLine: i, oldLine: oldLine++, newLine: newLine++ });
146 hunk.consumedOld++;
147 hunk.consumedNew++;
148 } else if (c === "\\") {
149 // "\ No newline at end of file" — attach as context-like metadata line.
150 hunk.lines.push({ origin: "ctx", text: line, raw: line, bodyLine: i, oldLine: null, newLine: null });
151 } else {
152 // Anything else ends the current hunk (trailing chatter, next header).
153 hunk = null;
154 }
155
156 if (hunk && hunk.consumedOld >= hunk.oldCount && hunk.consumedNew >= hunk.newCount) {
157 hunk = null; // hunk complete
158 }
159 }
160 }
161
162 return result;
163 }