thunderbird-patch-review
Simple email patch review tool for Thunderbird
git clone git://mccd.space/thunderbird-patch-review| Log | Files | Refs | README | LICENSE | Mail | Artifacts |
implementation.js (24999B)
1 // Privileged WebExtension Experiment behind Apply series, Browse... and
2 // Open editor. It ships inside the .xpi, so installing the extension is the
3 // whole install — there is no separate native messaging host.
4 //
5 // This file runs with full chrome privileges in Thunderbird's parent
6 // process; everything privileged the add-on does lives here. It sticks to
7 // long-stable platform APIs — Subprocess to run git and the editor,
8 // nsIFilePicker for the directory chooser, IOUtils/PathUtils for the patch
9 // workspace — and the fallbacks below cover the differences between
10 // Thunderbird 115 and current.
11
12 "use strict";
13
14 /* globals ExtensionAPI, Services, IOUtils, PathUtils, AppConstants, ChromeUtils, Cc, Ci */
15
16 // Platform modules moved from .jsm to .sys.mjs across the supported
17 // Thunderbird range; try the ESM spelling first.
18 function importModule(name) {
19 try {
20 return ChromeUtils.importESModule(`resource://gre/modules/${name}.sys.mjs`);
21 } catch (e) {
22 return ChromeUtils.import(`resource://gre/modules/${name}.jsm`);
23 }
24 }
25
26 const { Subprocess } = importModule("Subprocess");
27
28 /** Run a command to completion, stderr folded into stdout. */
29 async function run(command, args) {
30 const proc = await Subprocess.call({
31 command,
32 arguments: args,
33 stderr: "stdout",
34 });
35 let output = "";
36 let chunk;
37 while ((chunk = await proc.stdout.readString())) {
38 output += chunk;
39 }
40 const { exitCode } = await proc.wait();
41 return { exitCode, output };
42 }
43
44 // Return a path to a freshly created empty directory git can be pointed at
45 // via `-c core.hooksPath=<path>` to silence every hook for a single
46 // invocation. git am fires applypatch-msg, pre-applypatch and
47 // post-applypatch; `git worktree add` fires post-checkout.
48 //
49 // The Apply button is a deliberate human act, so the user's own hooks run
50 // there as part of a normal apply — their workflow expects them. The
51 // "Apply without hooks" dropdown item (apply()'s noHooks arg) instead
52 // points git at an empty hooks dir, for applying untrusted content where
53 // even a deliberate apply shouldn't run user code on the patch text. The
54 // automated check() probe always suppresses (no human act at all).
55 //
56 // core.hooksPath is read per invocation (the -c value is never written to
57 // the user's config), and an empty existing dir has no hook files for git
58 // to find, on every platform. A purely nonexistent path would also work on
59 // POSIX but is less portable (Windows treats `/dev/null`-style paths
60 // oddly), so we make a real empty directory instead. Callers remove it in
61 // their finally.
62 async function emptyHooksDir() {
63 const dir = PathUtils.join(
64 PathUtils.tempDir,
65 `patch-review.hooks.${Date.now()}.${Math.floor(Math.random() * 1e9)}`
66 );
67 await IOUtils.makeDirectory(dir, { permissions: 0o700 });
68 return dir;
69 }
70
71 // Detect a `git am` already in progress in the user's repository: an
72 // unfinished am leaves a rebase-apply/ directory inside the git dir, and
73 // a fresh `git am` exits non-zero on it ("a previous am session is in
74 // progress"). apply()'s abort path would then tear that session down,
75 // discarding the user's in-progress conflict resolution; the guard before
76 // apply() refuses instead. Resolve the git dir absolutely so a linked
77 // worktree's own per-worktree git dir is checked (the path --git-path
78 // rebase-apply would return for the worktree, not the common .git).
79 // --absolute-git-dir lands in git 2.31 (2021); fall back to relative
80 // --git-dir (absolute for linked worktrees, ".git" for the main repo) on
81 // older builds, joined against repo. Returns the rebase-apply path if a
82 // session is in progress, else null.
83 async function amInProgress(git, repo) {
84 let gd = await run(git, ["-C", repo, "rev-parse", "--absolute-git-dir"]);
85 if (gd.exitCode !== 0 || !gd.output.trim()) {
86 gd = await run(git, ["-C", repo, "rev-parse", "--git-dir"]);
87 if (gd.exitCode !== 0) return null;
88 }
89 let dir = gd.output.trim();
90 if (!dir) return null;
91 if (!PathUtils.isAbsolute(dir)) dir = PathUtils.join(repo, dir);
92 const ra = PathUtils.join(dir, "rebase-apply");
93 return (await IOUtils.exists(ra)) ? ra : null;
94 }
95
96 async function findGit() {
97 try {
98 return await Subprocess.pathSearch("git", Subprocess.getEnvironment());
99 } catch (e) {
100 // Thunderbird launched from a desktop entry or .app bundle may see a
101 // minimal PATH; try the usual install locations before giving up.
102 for (const candidate of ["/usr/local/bin/git", "/opt/homebrew/bin/git", "/usr/bin/git"]) {
103 if (await IOUtils.exists(candidate)) {
104 return candidate;
105 }
106 }
107 throw new Error("git not found in PATH");
108 }
109 }
110
111 // Verify repo is a git worktree and return a short description for the
112 // toolbar. The name is the top-level dir basename (what the user picked);
113 // the branch is the checked-out branch or detached short SHA.
114 async function describeRepo(git, repo) {
115 if (!repo) {
116 throw new Error("no repository path given");
117 }
118 const check = await run(git, ["-C", repo, "rev-parse", "--is-inside-work-tree"]);
119 if (check.exitCode !== 0) {
120 throw new Error(`not a git repository: ${repo}`);
121 }
122 const showToplevel = await run(git, ["-C", repo, "rev-parse", "--show-toplevel"]);
123 const toplevel = showToplevel.output.trim();
124 const name = toplevel ? toplevel.replace(/[\\/]+$/, "").split(/[\\/]/).pop() : repo;
125 let branch = await run(git, ["-C", repo, "symbolic-ref", "--short", "HEAD"]);
126 if (branch.exitCode !== 0) {
127 // Detached HEAD: fall back to the short commit hash.
128 branch = await run(git, ["-C", repo, "rev-parse", "--short", "HEAD"]);
129 }
130 return { ok: true, repo: toplevel || repo, name, branch: branch.output.trim() };
131 }
132
133 async function ping() {
134 const git = await findGit();
135 const version = await run(git, ["--version"]);
136 return { ok: true, git, version: version.output.trim() };
137 }
138
139 async function describe(repo) {
140 const git = await findGit();
141 return describeRepo(git, repo);
142 }
143
144 async function apply(repo, strategy, patches, noHooks) {
145 const git = await findGit();
146 await describeRepo(git, repo);
147 if (!patches.length) {
148 throw new Error("no patches in request");
149 }
150
151 // Refuse before touching the repo if a previous `git am` is mid-series
152 // here: a fresh `git am` would fail immediately ("a previous am session
153 // is in progress") and the abort below would then reset the user's
154 // branch and discard their ongoing conflict resolution — exactly the
155 // session this add-on did not start. Surface git's own diagnostic
156 // location and leave the working tree untouched, so the user resolves it
157 // themselves (git am --continue / --skip / --abort) and applies again.
158 const inProgress = await amInProgress(git, repo);
159 if (inProgress) {
160 return {
161 ok: false,
162 output:
163 "A previous `git am` session is already in progress in this repository\n" +
164 ` (${inProgress}).\n` +
165 "Resolve it yourself — git am --continue, --skip, or --abort — then\n" +
166 "apply again. This add-on will not run git am --abort against a\n" +
167 "session it did not start, so your in-progress resolution is left\n" +
168 "untouched.\n",
169 };
170 }
171
172 const work = PathUtils.join(
173 PathUtils.tempDir,
174 `patch-review.${Date.now()}.${Math.floor(Math.random() * 1e9)}`
175 );
176 await IOUtils.makeDirectory(work, { permissions: 0o700 });
177 // Hooks: the Apply button is a deliberate human act, so the user's own
178 // hooks run as part of a normal apply (their workflow expects them).
179 // "Apply without hooks" (noHooks) instead points git at an empty hooks
180 // dir for both am and its rollback, for applying untrusted content
181 // where even a deliberate apply shouldn't run user code on the patch.
182 // The automated check() probe always suppresses; apply() defaults to
183 // running hooks. The -c value is per-invocation only, never written to
184 // the user's config, so their hooks are intact for normal git use.
185 const hooks = noHooks ? await emptyHooksDir() : null;
186 try {
187 const hp = hooks ? ["-c", `core.hooksPath=${hooks}`] : [];
188 const args = ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", ...hp, "am"];
189 if (strategy !== "am") {
190 args.push("--3way");
191 }
192 for (const [i, patch] of patches.entries()) {
193 const file = PathUtils.join(work, `${String(i + 1).padStart(4, "0")}.patch`);
194 await IOUtils.writeUTF8(file, patch);
195 args.push(file);
196 }
197 const am = await run(git, args);
198 if (am.exitCode === 0) {
199 return { ok: true, output: am.output };
200 }
201 const abort = await run(git, ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", ...hp, "am", "--abort"]);
202 return {
203 ok: false,
204 output:
205 am.output +
206 abort.output +
207 "(git am --abort ran; the repository is back to its previous state)\n",
208 };
209 } finally {
210 IOUtils.remove(work, { recursive: true }).catch(() => {});
211 if (hooks) IOUtils.remove(hooks, { recursive: true }).catch(() => {});
212 }
213 }
214
215 // Map each path a patch series touches to its FINAL post-series state,
216 // taken from the right side of the last `index <old>..<new>` line that
217 // carried the path. Last-write-wins across the multi-patch mbox (a
218 // sequel that re-touches a file overrides the earlier patch's target).
219 //
220 // Returns a Map<path, { blob: "<hex>" } | { deleted: true }>. Returns
221 // null when the input had no parseable path or when a path git rendered
222 // quoted was seen (paths with spaces or other special chars aren't
223 // handled — bail so the caller falls through to the git am worktree
224 // instead of silently misclassifying).
225 //
226 // We extract the path from `+++ b/path`/`--- a/path` rather than the
227 // `diff --git a/path b/path` header, so a future rename (a/old b/new)
228 // maps the target to the new name and the old name is recorded deleted.
229 function collectFinalBlobs(text) {
230 const finals = new Map();
231 // Mail transport commonly delivers CRLF (Thunderbird's getRaw does); a
232 // trailing \r would defeat the $-anchored path regexes below, since in
233 // JS `.` matches neither \r nor \n. A path genuinely ending in \r is
234 // git-quoted and hits the quoted-path bail, so stripping is safe.
235 const lines = text.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
236 let block = null;
237 let unsupported = false;
238 const flush = (b) => {
239 if (!b || !b.targetPath) return;
240 if (b.isDelete) {
241 finals.set(b.targetPath, { deleted: true });
242 } else if (b.targetBlob) {
243 finals.set(b.targetPath, { blob: b.targetBlob });
244 }
245 };
246 for (const line of lines) {
247 if (line.startsWith("diff --git ")) {
248 flush(block);
249 block = { targetPath: null, srcPath: null, targetBlob: null, isDelete: false };
250 continue;
251 }
252 if (!block) continue;
253 if (line.startsWith("deleted file mode ")) { block.isDelete = true; continue; }
254 if (line === "+++ /dev/null") { block.isDelete = true; continue; }
255 // git quotes paths with spaces or other special chars, either as
256 // `+++ "b/path with space"` or `+++ b/"path with space"` (and the
257 // analogous forms for ---). We don't unquote; bail so the caller
258 // falls through to git am instead of misclassifying.
259 const linePath = line.replace(/^(?:\+\+\+ (?:b\/)?|--- (?:a\/)?)/, "").replace(/^\/dev\/null$/, "");
260 if ((line.startsWith("+++") || line.startsWith("---")) && linePath.startsWith('"')) {
261 unsupported = true;
262 break;
263 }
264 let m;
265 if ((m = /^index ([0-9a-f]+)\.\.([0-9a-f]+)/.exec(line))) {
266 block.targetBlob = m[2];
267 continue;
268 }
269 if ((m = /^--- a\/(.+)$/.exec(line))) {
270 block.srcPath = m[1];
271 if (block.targetPath == null) block.targetPath = m[1];
272 continue;
273 }
274 if ((m = /^\+\+\+ b\/(.+)$/.exec(line))) {
275 const newPath = m[1];
276 // The post-patch path differs from the source => a rename. The
277 // source vanishes; the target takes the (likely unchanged) blob.
278 if (block.srcPath && block.srcPath !== newPath) {
279 finals.set(block.srcPath, { deleted: true });
280 }
281 block.targetPath = newPath;
282 block.isDelete = false;
283 continue;
284 }
285 }
286 flush(block);
287 if (unsupported) return null;
288 return finals.size ? finals : null;
289 }
290
291 // Match the per-path target blobs against `git ls-tree HEAD -- <paths>`.
292 // Returns true if every path in `finals` is already at its expected blob
293 // in HEAD (or absent for deletes). False otherwise. Calls git once with
294 // every path in one ls-tree to keep the spawn count down.
295 async function blobsMatchHead(git, repo, finals) {
296 const paths = [...finals.keys()];
297 const ls = await run(git, ["-C", repo, "ls-tree", "HEAD", "--", ...paths]);
298 // ls-tree prints rows like `<mode> blob <hash>\t<path>`; paths absent
299 // from HEAD print no row at all.
300 const byPath = new Map();
301 for (const row of ls.output.split("\n")) {
302 if (!row) continue;
303 const m = /^(\S+) (\S+) ([0-9a-f]+)\t(.+)$/.exec(row);
304 if (m) byPath.set(m[4], m[3]);
305 }
306 for (const [path, info] of finals) {
307 if (info.deleted) {
308 if (byPath.has(path)) return false;
309 } else {
310 const actual = byPath.get(path);
311 if (!actual) return false;
312 // git format-patch truncates blob hashes (default 7 hex). The
313 // ls-tree output is full-length, so a prefix-equal either way marks
314 // the same blob.
315 const expected = info.blob;
316 if (
317 actual !== expected &&
318 !actual.startsWith(expected) &&
319 !expected.startsWith(actual)
320 ) {
321 return false;
322 }
323 }
324 }
325 return true;
326 }
327
328 // Classify a patch series against a repository without writing anything
329 // to the user's working tree. apply() runs `git am`, so the most honest
330 // precheck is to run `git am` too and read what it would do — without
331 // dirtying the user's repo, by adding a throwaway --detach worktree at
332 // HEAD and force-removing it afterwards. (The user's branch is never
333 // touched; we never run am in the user's own worktree.)
334 //
335 // Three stages:
336 // 1. Cheap `git apply --check` against the working tree fast-paths the
337 // common "cleanly applicable" and "exactly already applied" cases
338 // without paying for a worktree checkout.
339 // 2. --check couldn't decide, but the series' END STATE per touched
340 // path already sits in HEAD. Patches committed with slightly tweaked
341 // intermediate content (a doubled-letter typo, mail-relay-mangled
342 // blank-context whitespace that the actual commit cleaned up mid-
343 // series) won't patch-id-match — but the FINAL tree state per file
344 // agrees. This catches the common "patchset already applied with
345 // minor diff divergence" case without a worktree checkout.
346 // 3. Failing the above, run `git am` itself in a fresh --detach worktree
347 // to read am's own tri-state.
348 //
349 // git am backs the tri-state with stable signals (stage 3):
350 // exit 0 + "Patch already applied" / "No changes" -> "applied"
351 // exit 0 (only "Applying: <subject>") -> "applicable"
352 // exit 128 (context mismatch, merge conflict, dirty-index refusal) -> "conflict"
353 async function check(repo, strategy, patches) {
354 const git = await findGit();
355 await describeRepo(git, repo);
356 if (!patches.length) {
357 throw new Error("no patches in request");
358 }
359
360 // Stage 0: dirty working tree. git am refuses to start on a tree with
361 // staged or unstaged tracked changes (untracked files don't block it),
362 // so any Apply would fail before the patches are even read — regardless
363 // of whether the series would otherwise apply. Surface it up front as a
364 // distinct "dirty" state so the UI can warn in yellow rather than let
365 // the user click Apply into a refusal. Mirror git's
366 // require_clean_work_tree: diff-index --quiet HEAD catches both staged
367 // and unstaged tracked changes in one call (exit 0 clean, 1 dirty, 128
368 // bad/absent HEAD); --ignore-submodules matches am's own check. The 128
369 // case falls through to the normal stages, which will report the real
370 // problem. A short `status -s` listing is folded into `output` for the
371 // tooltip so the user can see what's keeping the tree dirty.
372 const dirty = await run(git, ["-C", repo, "diff-index", "--quiet", "--ignore-submodules", "HEAD"]);
373 if (dirty.exitCode === 1) {
374 const why = await run(git, ["-C", repo, "status", "-s", "--untracked-files=no"]);
375 return {
376 ok: true,
377 status: "dirty",
378 output: why.output.trim() || "the working tree has staged or unstaged changes",
379 };
380 }
381
382 // Stage 1: cheap --check against the working tree — fast-path the common
383 // "cleanly applicable" and "exactly already applied" cases without
384 // paying for a worktree checkout. The probe holds the mboxes
385 // concatenated: separate per-file args are each checked against the
386 // *original* tree independently, so a sequel whose context depends on an
387 // earlier patch fails spuriously; concatenated, git parses the file as a
388 // series and the contexts line up, just as am does.
389 const probe = PathUtils.join(
390 PathUtils.tempDir,
391 `patch-review.check.${Date.now()}.${Math.floor(Math.random() * 1e9)}.patch`
392 );
393 // Unlike git am (whose mailinfo strips mail-transport CRLF), git apply
394 // takes the probe bytes literally, and a \r on every context line
395 // matches nothing. Fold transport CRLF to LF; a genuine content \r in
396 // a CRLF-file patch arrives as \r\r\n and keeps its single \r.
397 await IOUtils.writeUTF8(probe, patches.join("\n").replace(/\r\n/g, "\n"));
398 let fwd;
399 try {
400 fwd = await run(git, ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", "apply", "--check", probe]);
401 if (fwd.exitCode === 0) {
402 return { ok: true, status: "applicable", output: fwd.output };
403 }
404 const rev = await run(git, ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", "apply", "--check", "--reverse", probe]);
405 if (rev.exitCode === 0) {
406 return { ok: true, status: "applied", output: fwd.output };
407 }
408 } finally {
409 IOUtils.remove(probe).catch(() => {});
410 }
411
412 // Stage 1.5: end-state check. --check refused in both directions — but
413 // if every path the series touches is already at its final post-patch
414 // blob in HEAD (or absent, for deletes), the series has effectively
415 // landed. git am would still flag a conflict (it can't recreate a file
416 // that's already there, can't delete a file that's already gone); the
417 // button would glow red. Compare blob hashes instead — they line up.
418 //
419 // collectFinalBlobs returns null when it can't parse all the paths
420 // (e.g. git quotes paths with spaces), which falls through cleanly.
421 const finals = collectFinalBlobs(patches.join("\n"));
422 if (finals) {
423 const headMatch = await blobsMatchHead(git, repo, finals);
424 if (headMatch) {
425 return {
426 ok: true,
427 status: "applied",
428 output: "Series end-state matches HEAD (every touched file is at its target blob).",
429 };
430 }
431 }
432
433 // Stage 2: --check couldn't decide. Run git am for real in a fresh
434 // --detach worktree, mirroring apply()'s strategy (am or am3). apply()
435 // runs git am with the same arguments on the same patches, so the probe
436 // predicts the Apply button's outcome exactly, including the --3way
437 // reconstruction --check skipped. The user's branch is never touched
438 // (--detach forks off HEAD; am runs in the temporary worktree only) and
439 // the worktree is force-removed afterwards.
440 const work = PathUtils.join(
441 PathUtils.tempDir,
442 `patch-review.am.${Date.now()}.${Math.floor(Math.random() * 1e9)}`
443 );
444 // Empty hooks dir, created before the worktree so post-checkout fires
445 // nothing on the checkout, and reused for the am run and its abort so
446 // applypatch-msg/pre-applypatch/post-applypatch fire nothing either.
447 const hooks = await emptyHooksDir();
448 const hp = ["-c", `core.hooksPath=${hooks}`];
449 const make = await run(git, ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", ...hp, "worktree", "add", "--detach", work]);
450 if (make.exitCode !== 0) {
451 // Rare (bare repo, no worktree support, …). Don't trust --check's
452 // partial answer here — report "conflict" so the user clicks Apply
453 // and reads am's own error, instead of guessing.
454 IOUtils.remove(hooks, { recursive: true }).catch(() => {});
455 return { ok: true, status: "conflict", output: fwd.output + make.output };
456 }
457 try {
458 const file = PathUtils.join(work, "series.mbox");
459 await IOUtils.writeUTF8(file, patches.join("\n"));
460 const args = ["-C", work, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", ...hp, "am"];
461 if (strategy !== "am") {
462 args.push("--3way");
463 }
464 args.push(file);
465 const am = await run(git, args);
466 if (am.exitCode === 0) {
467 if (/Patch already applied|No changes --/.test(am.output)) {
468 return { ok: true, status: "applied", output: am.output };
469 }
470 return { ok: true, status: "applicable", output: am.output };
471 }
472 // am stopped mid-series (context mismatch, merge conflict, dirty
473 // tree). Roll the worktree back so its state isn't left dangling, then
474 // report conflict with am's diagnostic — it names the file and hunk.
475 await run(git, ["-C", work, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", ...hp, "am", "--abort"]);
476 return { ok: true, status: "conflict", output: am.output };
477 } finally {
478 // --force drops the worktree even if am left its working tree dirty.
479 await run(git, ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", "worktree", "remove", "--force", work]);
480 IOUtils.remove(work, { recursive: true }).catch(() => {});
481 IOUtils.remove(hooks, { recursive: true }).catch(() => {});
482 }
483 }
484
485 async function pickDir(start) {
486 const win = Services.wm.getMostRecentWindow(null);
487 if (!win) {
488 throw new Error("no window to attach the directory chooser to");
489 }
490 const picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
491 // nsIFilePicker.init took a window before Thunderbird ~125 and takes a
492 // BrowsingContext after; whichever the running version rejects, throws.
493 try {
494 picker.init(win.browsingContext, "Choose repository", Ci.nsIFilePicker.modeGetFolder);
495 } catch (e) {
496 picker.init(win, "Choose repository", Ci.nsIFilePicker.modeGetFolder);
497 }
498 if (start) {
499 try {
500 const dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
501 dir.initWithPath(start);
502 if (dir.exists() && dir.isDirectory()) {
503 picker.displayDirectory = dir;
504 }
505 } catch (e) {
506 // Not a usable start path; the picker opens at its default.
507 }
508 }
509 const rv = await new Promise((resolve) => picker.open(resolve));
510 if (rv !== Ci.nsIFilePicker.returnOK || !picker.file) {
511 return { ok: true, output: "", cancelled: true };
512 }
513 return { ok: true, output: picker.file.path };
514 }
515
516 async function openEditor(repo, editor, files) {
517 const git = await findGit();
518 await describeRepo(git, repo);
519 const env = Subprocess.getEnvironment();
520 let command = (editor || "").trim() || (env.EDITOR || "").trim();
521 if (!command) {
522 // Nothing configured anywhere: open the repository directory instead.
523 command = { macosx: "open", win: "start" }[AppConstants.platform] || "xdg-open";
524 files = ["."];
525 }
526
527 if (AppConstants.platform === "win") {
528 const line = [command, ...files.map((f) => `"${f}"`)].join(" ");
529 await Subprocess.call({
530 command: env.ComSpec || "C:\\Windows\\System32\\cmd.exe",
531 arguments: ["/s", "/c", line],
532 workdir: repo,
533 stderr: "ignore",
534 });
535 } else {
536 // The command may carry flags ("code -n"); let sh split it and append
537 // the files. Output goes to /dev/null so a chatty editor can never
538 // fill the pipe, and the editor is not waited on.
539 await Subprocess.call({
540 command: "/bin/sh",
541 arguments: ["-c", `exec ${command} "$@" >/dev/null 2>&1`, "patch-review-edit", ...files],
542 workdir: repo,
543 stderr: "ignore",
544 });
545 }
546 return { ok: true, output: `launched: ${command}` };
547 }
548
549 /** Errors become {ok: false, error} so callers get one uniform shape. */
550 async function guarded(fn) {
551 try {
552 return await fn();
553 } catch (e) {
554 return { ok: false, error: e.message || String(e) };
555 }
556 }
557
558 var patchHost = class extends ExtensionAPI {
559 getAPI() {
560 return {
561 patchHost: {
562 ping: () => guarded(ping),
563 describe: (repo) => guarded(() => describe(repo)),
564 apply: (repo, strategy, patches, noHooks) => guarded(() => apply(repo, strategy, patches, noHooks)),
565 check: (repo, strategy, patches) => guarded(() => check(repo, strategy, patches)),
566 pickDir: (start) => guarded(() => pickDir(start)),
567 openEditor: (repo, editor, files) => guarded(() => openEditor(repo, editor, files)),
568 },
569 };
570 }
571 };