thunderbird-patch-review

Simple email patch review tool for Thunderbird

git clone git://mccd.space/thunderbird-patch-review
commit 5f606fe8d258da706b0db17c98f76ad036d242d2
parent 17193d1dc33d652e8ed1502aab3ad48178199a4a
Author: Pi Agent <agent@pi.local>
Date:   Sun, 19 Jul 2026 10:37:03 +0200

patchHost: add check() to classify a series against HEAD

git apply --check is the non-mutating sibling of git am. Concatenating
the mboxes into one file lets it verify the patches' contexts in order,
which matches git am's sequential application.

Forward exit 0 -> 'applicable'; otherwise reverse exit 0 -> 'applied'
(series already in HEAD); otherwise 'conflict'. The --3way path is not
simulated, so a recoverable merge is reported 'conflict' rather than
promising an apply that might silently bicker.

Diffstat:
Mextension/api/patchHost/implementation.js | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mextension/api/patchHost/schema.json | 20++++++++++++++++++++
Mtests/run.mjs | 2+-
3 files changed, 72 insertions(+), 1 deletion(-)
diff --git a/extension/api/patchHost/implementation.js b/extension/api/patchHost/implementation.js
@@ -128,6 +128,56 @@ async function apply(repo, strategy, patches) {
   }
 }
 
+// Classify a patch series against a repository without writing anything.
+//
+// `git apply --check` is the non-mutating sibling of `git am`, so we use it
+// here. It would also be the obvious building block for detecting
+// "already applied" — but its per-file mode checks each argument against
+// the *original* state independently, so a sequel whose context depends on
+// an earlier patch fails spuriously. Concatenate the mboxes into a single
+// file first: the concatenated file is parsed as a series and its hunks
+// verified in order, which matches `git am`'s sequential application.
+//
+// Detection, run against the cached index (= HEAD when the working tree
+// is clean, the only state `git am` will operate on):
+//   forward `git apply --check --cached` exits 0   -> "applicable"
+//   else reverse `git apply --check --cached --reverse` exits 0 -> "applied"
+//   else                                              -> "conflict"
+//
+// We don't simulate apply()'s --3way path: a conflict that --3way would
+// auto-merge into a commit is reported "conflict" instead. That's the
+// conservative call — better to leave the button glowing red and let the
+// user discover the recoverable merge than silently promise an apply.
+async function check(repo, strategy, patches) {
+  const git = await findGit();
+  await describeRepo(git, repo);
+  if (!patches.length) {
+    throw new Error("no patches in request");
+
+  }
+
+  const work = PathUtils.join(
+    PathUtils.tempDir,
+    `patch-review.check.${Date.now()}.${Math.floor(Math.random() * 1e9)}`
+  );
+  await IOUtils.makeDirectory(work, { permissions: 0o700 });
+  try {
+    const file = PathUtils.join(work, "series.patch");
+    await IOUtils.writeUTF8(file, patches.join("\n"));
+    const fwd = await run(git, ["-C", repo, "apply", "--check", "--cached", file]);
+    if (fwd.exitCode === 0) {
+      return { ok: true, status: "applicable", output: fwd.output };
+    }
+    const rev = await run(git, ["-C", repo, "apply", "--check", "--cached", "--reverse", file]);
+    if (rev.exitCode === 0) {
+      return { ok: true, status: "applied", output: fwd.output };
+    }
+    return { ok: true, status: "conflict", output: fwd.output + rev.output };
+  } finally {
+    IOUtils.remove(work, { recursive: true }).catch(() => {});
+  }
+}
+
 async function pickDir(start) {
   const win = Services.wm.getMostRecentWindow(null);
   if (!win) {
@@ -208,6 +258,7 @@ var patchHost = class extends ExtensionAPI {
         ping: () => guarded(ping),
         describe: (repo) => guarded(() => describe(repo)),
         apply: (repo, strategy, patches) => guarded(() => apply(repo, strategy, patches)),
+        check: (repo, strategy, patches) => guarded(() => check(repo, strategy, patches)),
         pickDir: (start) => guarded(() => pickDir(start)),
         openEditor: (repo, editor, files) => guarded(() => openEditor(repo, editor, files)),
       },
diff --git a/extension/api/patchHost/schema.json b/extension/api/patchHost/schema.json
@@ -40,6 +40,26 @@
         ]
       },
       {
+        "name": "check",
+        "type": "function",
+        "async": true,
+        "description": "Classify the patches against the repository without applying: resolves to {ok, status, output} where status is \"applicable\", \"applied\", or \"conflict\".",
+        "parameters": [
+          { "name": "repo", "type": "string" },
+          {
+            "name": "strategy",
+            "type": "string",
+            "description": "Mirrors apply()'s strategy argument; currently only documented for API symmetry."
+          },
+          {
+            "name": "patches",
+            "type": "array",
+            "items": { "type": "string" },
+            "description": "Raw RFC822 patch messages, in series order."
+          }
+        ]
+      },
+      {
         "name": "pickDir",
         "type": "function",
         "async": true,
diff --git a/tests/run.mjs b/tests/run.mjs
@@ -352,7 +352,7 @@ function check(name, cond, extra = "") {
 
   const schema = JSON.parse(readFileSync(join(root, experiment.schema), "utf8"));
   const names = schema[0].functions.map((f) => f.name).sort().join(",");
-  check("experiment: schema functions", names === "apply,describe,openEditor,pickDir,ping");
+  check("experiment: schema functions", names === "apply,check,describe,openEditor,pickDir,ping");
   check("experiment: schema functions async", schema[0].functions.every((f) => f.async));
 
   const source = readFileSync(join(root, experiment.parent.script), "utf8");