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

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 }