thunderbird-patch-review
Simple email patch review tool for Thunderbird
git clone git://mccd.space/thunderbird-patch-reviewcommit 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:
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");