emacs-patch-review

Port of Thunderbird Patch Review to mu4e.

git clone git://mccd.space/emacs-patch-review
commit f354486785675a1e3feda6f2751ef46a3e2bea39
parent 82473af65d88e39804504b03dbf5840b8365d002
Author: Pi Agent <agent@pi.local>
Date:   Mon,  3 Aug 2026 12:20:58 +0200

Take over the mu4e article buffer in place when reviewing

patch-review-mu4e-review, when invoked from a mu4e-view-mode (article)
buffer, now reuses that buffer instead of popping up a new one: the
buffer is renamed, its contents replaced with the patch body, and it
is switched to patch-review-mode.  The headers-buffer path keeps the
old pop-new-buffer behaviour.

This adds an &optional IN-PLACE flag threaded through
patch-review-open-message-file and patch-review--open-text, and a test
that the same buffer object is reused and switched to review mode.

Diffstat:
Mpatch-review-mu4e.el | 10+++++++---
Mpatch-review.el | 59++++++++++++++++++++++++++++++++++++++++-------------------
Mtests/patch-review-test.el | 26++++++++++++++++++++++++++
3 files changed, 73 insertions(+), 22 deletions(-)
diff --git a/patch-review-mu4e.el b/patch-review-mu4e.el
@@ -26,14 +26,18 @@
 
 ;;;###autoload
 (defun patch-review-mu4e-review ()
-  "Review the patch message at point (mu4e view or headers buffer)."
+  "Review the patch message at point (mu4e view or headers buffer).
+From a mu4e article buffer (`mu4e-view-mode') the review takes over
+that buffer in place instead of opening a new window; from a
+headers buffer it opens a new review buffer."
   (interactive nil mu4e-view-mode mu4e-headers-mode)
   (unless (require 'mu4e-message nil t)
     (user-error "mu4e is not available"))
-  (let ((path (mu4e-message-field (mu4e-message-at-point) :path)))
+  (let* ((in-place (derived-mode-p 'mu4e-view-mode))
+         (path (mu4e-message-field (mu4e-message-at-point) :path)))
     (unless (and path (file-readable-p path))
       (user-error "No message file at point"))
-    (patch-review-open-message-file path)))
+    (patch-review-open-message-file path nil in-place)))
 
 (provide 'patch-review-mu4e)
 ;;; patch-review-mu4e.el ends here
diff --git a/patch-review.el b/patch-review.el
@@ -146,38 +146,59 @@ insertions are sent as an interleaved review reply.
 ;;;; Opening
 
 ;;;###autoload
-(defun patch-review-open-message-file (file &optional project)
-  "Open raw patch email FILE (an .eml or format-patch file) for review."
+(defun patch-review-open-message-file (file &optional project in-place)
+  "Open raw patch email FILE (an .eml or format-patch file) for review.
+PROJECT overrides the target repository.  When IN-PLACE is non-nil,
+reuse the current buffer instead of popping up a new one (see
+`patch-review--open-text')."
   (interactive "fPatch message file: ")
   (patch-review--open-text
    (with-temp-buffer
      (insert-file-contents file)
      (buffer-string))
    (expand-file-name file)
-   project))
-
-(defun patch-review--open-text (text source-file project)
-  "Open TEXT (a raw message) in a new review buffer.
-SOURCE-FILE is what `git am' runs on; PROJECT overrides the
-target repository.  Return the review buffer."
+   project
+   in-place))
+
+(defun patch-review--open-text (text source-file project &optional in-place)
+  "Open TEXT (a raw message) in a review buffer.
+SOURCE-FILE is what `git am' runs on; PROJECT overrides the target
+repository.  Return the review buffer.
+
+When IN-PLACE is non-nil, reuse the current buffer instead of
+popping up a new one: the buffer is renamed to `*Review: SUBJECT*',
+its contents are replaced with the message body, and it is switched
+to `patch-review-mode'.  `patch-review-mu4e-review' uses this to
+take over a mu4e article buffer rather than opening a window."
   (pcase-let ((`(,headers . ,body) (patch-review--split-message text)))
     (unless (patch-review-patch-message-p (cdr (assq 'subject headers)) body)
       (unless (y-or-n-p "This message does not look like a patch; open anyway? ")
         (user-error "Aborted")))
-    (let ((buf (generate-new-buffer
-                (format "*Review: %s*"
+    (let ((name (format "*Review: %s*"
                         (or (patch-review--decode (cdr (assq 'subject headers)))
                             (and source-file
                                  (file-name-nondirectory source-file))
-                            "patch")))))
-      (with-current-buffer buf
-        (insert body)
-        (goto-char (point-min))
-        (let ((patch-review--pending-init
-               (list :headers headers :source source-file :project project)))
-          (patch-review-mode)))
-      (pop-to-buffer buf)
-      buf)))
+                            "patch")))
+          (init (list :headers headers :source source-file :project project)))
+      (if in-place
+          (let ((buf (current-buffer)))
+            (with-current-buffer buf
+              (rename-buffer name t)
+              (let ((inhibit-read-only t))
+                (erase-buffer)
+                (insert body)
+                (goto-char (point-min)))
+              (let ((patch-review--pending-init init))
+                (patch-review-mode)))
+            buf)
+        (let ((buf (generate-new-buffer name)))
+          (with-current-buffer buf
+            (insert body)
+            (goto-char (point-min))
+            (let ((patch-review--pending-init init))
+              (patch-review-mode)))
+          (pop-to-buffer buf)
+          buf)))))
 
 (defun patch-review--split-message (text)
   "Split raw message TEXT into (HEADERS . BODY).
diff --git a/tests/patch-review-test.el b/tests/patch-review-test.el
@@ -456,6 +456,32 @@ starting with PREFIX."
             (should-error (patch-review-send-review) :type 'user-error)))
       (when (buffer-live-p buf) (kill-buffer buf)))))
 
+(ert-deftest patch-review-test-open-in-place ()
+  "Opening with IN-PLACE reuses the current buffer rather than making one."
+  (let* ((eml (expand-file-name
+               "fixtures/patch1.eml"
+               (file-name-directory (or load-file-name buffer-file-name))))
+         (text (with-temp-buffer
+                 (insert-file-contents eml)
+                 (buffer-string)))
+         (view (get-buffer-create " *patch-review-fake-view* ")))
+    (unwind-protect
+        (let ((returned
+               (with-current-buffer view
+                 (insert "stale mu4e contents")
+                 (patch-review--open-text text eml nil t))))
+          (should (eq returned view))
+          (with-current-buffer view
+            (should (eq major-mode 'patch-review-mode))
+            (should (string-prefix-p "*Review:" (buffer-name)))
+            ;; The review body, not the discarded text, is now the buffer.
+            (should (string-match-p "diff --git" patch-review--pristine))
+            (should-not (string-match-p "stale mu4e contents"
+                                        patch-review--pristine))
+            ;; The on-disk message file is the git-am source.
+            (should (file-equal-p patch-review--source-file eml))))
+      (when (buffer-live-p view) (kill-buffer view)))))
+
 ;;;; End-to-end: status probe and apply
 
 (ert-deftest patch-review-test-status-and-apply ()