emacs-patch-review
Port of Thunderbird Patch Review to mu4e.
git clone git://mccd.space/emacs-patch-reviewcommit 4fb28b5bc07c31c5476bb511edc4bdefba722e9e
parent f9d6e8ca8bc3cf51a1d67330b0e5bd0a9e60f905
Author: Pi Agent <agent@pi.local>
Date: Mon, 3 Aug 2026 10:20:19 +0200
Add patch-review-mode: review buffer, extraction, compose, apply
The review buffer is an editable diff-mode buffer holding the patch.
There is no comment command: inserted text anywhere is commentary.
On C-c C-c the buffer is diffed (-U0) against the pristine original;
pure insertions become comments anchored to the original line above
them (mapped through the parse tree to file:hunk:line locators),
insertions above the first hunk become the general remark, and
rewrites/deletions of patch text are counted and confirmed. The reply
is composed from the pristine text via message-mode with To/Subject/
In-Reply-To reconstructed from the source headers.
The header line carries the two primary buttons ([Send review],
[Apply]) plus [Refresh], the target project, and the apply status.
C-c C-a applies with git am (C-u suppresses hooks). Opening a
.patch/.diff file activates the mode via auto-mode-alist.
Diffstat:
| A | patch-review.el | | | 519 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | tests/patch-review-test.el | | | 186 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 705 insertions(+), 0 deletions(-)
diff --git a/patch-review.el b/patch-review.el
@@ -0,0 +1,519 @@
+;;; patch-review.el --- Review git patches from email -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Marc Coquand
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; Package-Requires: ((emacs "28.1"))
+;; Version: 0.1.0
+
+;;; Commentary:
+
+;; A major mode (derived from `diff-mode') for reviewing git patches
+;; received by email. The review buffer is an ordinary, editable diff
+;; buffer: type your commentary anywhere in it. On send (C-c C-c), the
+;; buffer is diffed against the pristine original; your insertions are
+;; extracted, anchored to the patch line above them, and formatted as an
+;; interleaved mailing-list reply. C-c C-a applies the patch to a local
+;; repository with git-am(1).
+;;
+;; Entry points:
+;; M-x patch-review-open-message-file — raw .eml / format-patch file
+;; patch-review-mu4e-review — from a mu4e view buffer
+;; opening a .patch/.diff file — via auto-mode-alist
+;;
+;; This is an Emacs port of the Thunderbird Patch Review add-on
+;; (thunderbird-review-ui).
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'subr-x)
+(require 'diff-mode)
+(require 'message)
+(require 'mailheader)
+(require 'project)
+(require 'patch-review-parse)
+(require 'patch-review-reply)
+(require 'patch-review-git)
+
+;;;; Customization
+
+(defgroup patch-review nil
+ "Review git patches from email."
+ :group 'tools
+ :prefix "patch-review-")
+
+(defcustom patch-review-project nil
+ "Repository to probe and apply patches against.
+When nil, fall back to the current project (per `project-current');
+\\[patch-review-set-project] sets it per buffer."
+ :type '(choice (const :tag "Ask" nil) directory)
+ :group 'patch-review)
+
+(defface patch-review-status-ok
+ '((t :inherit success))
+ "Face for a positive apply status (applies cleanly / already applied)."
+ :group 'patch-review)
+
+(defface patch-review-status-warn
+ '((t :inherit warning))
+ "Face for a warning apply status (dirty worktree, no project)."
+ :group 'patch-review)
+
+(defface patch-review-status-error
+ '((t :inherit error))
+ "Face for a negative apply status (conflicts, am in progress)."
+ :group 'patch-review)
+
+;;;; Buffer state
+
+(defvar-local patch-review--parsed nil
+ "The `patch-review-email' struct for this buffer.")
+
+(defvar-local patch-review--pristine nil
+ "The buffer text as opened. Comments are extracted by diffing the
+current buffer against this string.")
+
+(defvar-local patch-review--headers nil
+ "Alist of the source message's mail headers, per `mail-header-extract'.")
+
+(defvar-local patch-review--source-file nil
+ "File `git am' is run on for this buffer.")
+
+(defvar-local patch-review--project nil
+ "Repository this buffer's patch targets.")
+
+(defvar-local patch-review--status 'unknown
+ "Apply status: unknown, checking, applicable, applied, dirty,
+conflict, am-in-progress or no-project.")
+
+(defvar-local patch-review--status-detail nil
+ "Human-readable detail for `patch-review--status'.")
+
+(defvar patch-review--pending-init nil
+ "Dynamically bound initialization plist for `patch-review-mode'.
+`patch-review--open-text' binds this around the mode call so the
+mode's self-initialization can pick up headers, source file and
+project.")
+
+;;;; Mode
+
+(define-derived-mode patch-review-mode diff-mode "PatchReview"
+ "Major mode for reviewing a git patch.
+
+The buffer is editable: type comments anywhere. On \\[patch-review-send-review]
+the buffer is diffed against the pristine original and your
+insertions are sent as an interleaved review reply.
+
+\\{patch-review-mode-map}"
+ (setq buffer-read-only nil)
+ (setq-local header-line-format '(:eval (patch-review--header-line)))
+ (patch-review--ensure-initialized))
+
+(define-key patch-review-mode-map (kbd "C-c C-c") #'patch-review-send-review)
+(define-key patch-review-mode-map (kbd "C-c C-a") #'patch-review-apply)
+(define-key patch-review-mode-map (kbd "C-c C-p") #'patch-review-set-project)
+(define-key patch-review-mode-map (kbd "C-c C-r") #'patch-review-refresh-status)
+
+(defun patch-review--ensure-initialized ()
+ "Initialize the review buffer from its text unless already done."
+ (unless patch-review--parsed
+ (let* ((pending patch-review--pending-init)
+ (text (buffer-substring-no-properties (point-min) (point-max)))
+ (headers (or (plist-get pending :headers)
+ (car (patch-review--split-message text))))
+ (source (or (plist-get pending :source) buffer-file-name)))
+ (setq patch-review--pristine text
+ patch-review--parsed (patch-review-parse-email text)
+ patch-review--headers headers
+ patch-review--source-file source
+ patch-review--project (or (plist-get pending :project)
+ (patch-review--default-project)))
+ (if (and patch-review--project source)
+ (patch-review-refresh-status)
+ (setq patch-review--status 'no-project)))))
+
+(defun patch-review--default-project ()
+ "Default repository for new review buffers."
+ (or patch-review-project
+ (when-let* ((pr (project-current nil)))
+ (project-root pr))))
+
+;;;; Opening
+
+;;;###autoload
+(defun patch-review-open-message-file (file &optional project)
+ "Open raw patch email FILE (an .eml or format-patch file) for review."
+ (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."
+ (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*"
+ (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)))
+
+(defun patch-review--split-message (text)
+ "Split raw message TEXT into (HEADERS . BODY).
+HEADERS is nil when the text carries no mail header block (a bare
+.diff). A leading mbox \"From \" line is skipped."
+ (with-temp-buffer
+ (insert text)
+ (goto-char (point-min))
+ (when (looking-at "From .*Mon Sep 17 00:00:00 2001$")
+ (forward-line 1))
+ (if (not (looking-at "[A-Za-z][A-Za-z0-9-]*:"))
+ (cons nil text)
+ (let* ((hdr-end (save-excursion
+ (if (re-search-forward "^$" nil t)
+ (line-beginning-position)
+ (point-min))))
+ (headers (save-excursion
+ (save-restriction
+ (narrow-to-region (point-min) hdr-end)
+ (goto-char (point-min))
+ (mail-header-extract))))
+ (body-start (save-excursion
+ (goto-char hdr-end)
+ (forward-line 1)
+ (point))))
+ (cons headers
+ (buffer-substring-no-properties body-start (point-max)))))))
+
+(defun patch-review--decode (s)
+ "Decode encoded words in header string S, if possible."
+ (when s
+ (if (fboundp 'mail-decode-encoded-word-string)
+ (mail-decode-encoded-word-string s)
+ s)))
+
+;;;; Header line
+
+(defun patch-review--button (label help command)
+ "Return LABEL as a clickable header-line button running COMMAND."
+ (propertize label
+ 'keymap (let ((map (make-sparse-keymap)))
+ (define-key map [header-line mouse-1] command)
+ map)
+ 'mouse-face 'highlight
+ 'follow-link t
+ 'help-echo help))
+
+(defun patch-review--status-string ()
+ "Propertized status text for the header line."
+ (pcase patch-review--status
+ ('applicable (propertize "applies cleanly" 'face 'patch-review-status-ok))
+ ('applied (propertize "already applied" 'face 'patch-review-status-ok))
+ ('dirty (propertize "worktree dirty" 'face 'patch-review-status-warn))
+ ('conflict (propertize "conflicts" 'face 'patch-review-status-error))
+ ('am-in-progress (propertize "am in progress" 'face 'patch-review-status-error))
+ ('no-project (propertize "no project (C-c C-p)" 'face 'patch-review-status-warn))
+ ('checking (propertize "checking..." 'face 'shadow))
+ (_ (propertize "unknown" 'face 'shadow))))
+
+(defun patch-review--header-line ()
+ "Compute the review buffer's header line."
+ (let ((subject (or (patch-review--decode (cdr (assq 'subject
+ patch-review--headers)))
+ (buffer-name)))
+ (project (if patch-review--project
+ (abbreviate-file-name
+ (directory-file-name patch-review--project))
+ "—")))
+ (concat
+ " " (propertize subject 'face 'bold)
+ " | Project: " project
+ " | " (propertize (substring-no-properties (patch-review--status-string))
+ 'face (get-text-property
+ 0 'face (patch-review--status-string))
+ 'help-echo patch-review--status-detail)
+ " | "
+ (patch-review--button "[Send review]" "C-c C-c: compose the review reply"
+ #'patch-review-send-review)
+ " "
+ (patch-review--button "[Apply]" "C-c C-a: apply with git am"
+ #'patch-review-apply)
+ " "
+ (patch-review--button "[Refresh]" "C-c C-r: re-run the applicability probe"
+ #'patch-review-refresh-status))))
+
+;;;; Project and status
+
+(defun patch-review-set-project (dir)
+ "Set the repository DIR this patch targets and re-run the probe."
+ (interactive "DProject repository: " patch-review-mode)
+ (unless (patch-review-git-describe dir)
+ (user-error "%s is not a git working tree" dir))
+ (setq patch-review--project dir)
+ (patch-review-refresh-status))
+
+(defun patch-review-refresh-status ()
+ "Re-run the applicability probe for the current project."
+ (interactive nil patch-review-mode)
+ (cond
+ ((null patch-review--project)
+ (setq patch-review--status 'no-project
+ patch-review--status-detail "Set a project with C-c C-p."))
+ ((null patch-review--source-file)
+ (setq patch-review--status 'unknown
+ patch-review--status-detail "No source file to probe."))
+ (t
+ (setq patch-review--status 'checking)
+ (force-mode-line-update t)
+ (message "Probing %s..." patch-review--project)
+ (pcase-let ((`(,status . ,detail)
+ (patch-review-git-check patch-review--project
+ patch-review--source-file)))
+ (setq patch-review--status status
+ patch-review--status-detail detail)
+ (message "Status: %s" (substring-no-properties
+ (patch-review--status-string))))))
+ (force-mode-line-update t))
+
+;;;; Comment extraction
+
+(defun patch-review--write-temp (text)
+ "Write TEXT to a temporary file (final newline ensured); return it."
+ (let ((file (make-temp-file "patch-review")))
+ (with-temp-file file
+ (insert text)
+ (unless (or (zerop (buffer-size))
+ (eq (char-before (point-max)) ?\n))
+ (goto-char (point-max))
+ (insert "\n")))
+ file))
+
+(defun patch-review--locator-index (parsed)
+ "Map body-line indices of PARSED to locator strings."
+ (let ((index (make-hash-table :test #'eql)))
+ (cl-loop for fi from 0
+ for file in (patch-review-email-files parsed)
+ do (cl-loop for hi from 0
+ for hunk in (patch-review-file-hunks file)
+ do (cl-loop
+ for li from 0
+ for line in (patch-review-hunk-lines hunk)
+ do (puthash (patch-review-line-body-line line)
+ (patch-review-reply-locator fi hi li)
+ index))))
+ index))
+
+(defun patch-review--add-comment (comments anchor added locator-index)
+ "Record ADDED lines as a comment at ANCHOR (1-based pristine line).
+ANCHOR 0 or an anchor outside any hunk maps to the general comment.
+Return the updated COMMENTS alist."
+ (let ((text (string-trim (mapconcat #'identity added "\n"))))
+ (if (string-empty-p text)
+ comments
+ (let* ((locator (or (and (> anchor 0)
+ (gethash (1- anchor) locator-index))
+ patch-review-reply-general))
+ (existing (assoc locator comments)))
+ (if existing
+ (progn
+ (setcdr existing (concat (cdr existing) "\n\n" text))
+ comments)
+ (cons (cons locator text) comments))))))
+
+(defun patch-review--parse-diff (text locator-index)
+ "Parse `diff -U0' output TEXT against LOCATOR-INDEX.
+Return (COMMENTS DELETIONS CHANGES): an alist (LOCATOR . TEXT) and
+counts of original lines the user deleted or rewrote."
+ (let ((comments nil) (deletions 0) (changes 0))
+ (with-temp-buffer
+ (insert text)
+ (goto-char (point-min))
+ (while (re-search-forward
+ "^@@ -\\([0-9]+\\)\\(?:,\\([0-9]+\\)\\)? \\+\\([0-9]+\\)\\(?:,\\([0-9]+\\)\\)? @@"
+ nil t)
+ (let ((old-start (string-to-number (match-string 1)))
+ (old-count (if (match-string 2)
+ (string-to-number (match-string 2)) 1))
+ (new-count (if (match-string 4)
+ (string-to-number (match-string 4)) 1))
+ (added nil) (removed nil))
+ (forward-line 1)
+ (while (and (not (eobp)) (looking-at "^[-+\\\\]"))
+ (pcase (char-after)
+ (?+ (push (buffer-substring-no-properties
+ (1+ (point)) (line-end-position))
+ added))
+ (?- (push (buffer-substring-no-properties
+ (1+ (point)) (line-end-position))
+ removed)))
+ (forward-line 1))
+ (setq added (nreverse added)
+ removed (nreverse removed))
+ (cond
+ ;; Pure insertion: comment on the original line above.
+ ((= old-count 0)
+ (setq comments
+ (patch-review--add-comment comments old-start added
+ locator-index)))
+ ;; Pure deletion of original text.
+ ((= new-count 0)
+ (setq deletions (+ deletions (length removed))))
+ ;; Rewritten lines: the new text is a comment on the last
+ ;; replaced line — unless only the final newline changed,
+ ;; which diff reports as remove+add of identical lines.
+ ((not (equal added removed))
+ (setq changes (+ changes (length removed))
+ comments
+ (patch-review--add-comment
+ comments (+ old-start old-count -1) added
+ locator-index)))))))
+ (list comments deletions changes)))
+
+(defun patch-review-extract-comments (parsed pristine current)
+ "Diff PRISTINE against CURRENT and extract the user's comments.
+PARSED is the `patch-review-email' struct for PRISTINE. Return
+\(COMMENTS DELETIONS CHANGES); see `patch-review--parse-diff'."
+ (let ((a (patch-review--write-temp pristine))
+ (b (patch-review--write-temp current)))
+ (unwind-protect
+ (let ((output
+ (with-temp-buffer
+ (let ((exit (call-process "diff" nil t nil "-U0"
+ "--label" "pristine"
+ "--label" "current" a b)))
+ (pcase exit
+ (0 nil)
+ (1 (buffer-string))
+ (_ (error "diff failed: %s" (buffer-string))))))))
+ (if (null output)
+ (list nil 0 0)
+ (patch-review--parse-diff
+ output (patch-review--locator-index parsed))))
+ (delete-file a)
+ (delete-file b))))
+
+;;;; Sending the review
+
+(defun patch-review-send-review ()
+ "Extract comments from the buffer and compose the review reply.
+Text inserted above the first hunk becomes a general remark; text
+inserted anywhere else is a comment on the patch line above it."
+ (interactive nil patch-review-mode)
+ (unless patch-review--parsed
+ (user-error "Not a patch review buffer"))
+ (pcase-let ((`(,comments ,deletions ,changes)
+ (patch-review-extract-comments
+ patch-review--parsed
+ patch-review--pristine
+ (buffer-substring-no-properties (point-min) (point-max)))))
+ (when (or (> deletions 0) (> changes 0))
+ (unless (y-or-n-p
+ (format "Your edits rewrote or deleted %d patch line%s (quoted from the original). Send anyway? "
+ (+ deletions changes)
+ (if (= 1 (+ deletions changes)) "" "s")))
+ (user-error "Aborted")))
+ (unless comments
+ (when (y-or-n-p "No inline comments found; add a general comment? ")
+ (let ((text (read-string "General comment: ")))
+ (unless (string-empty-p (string-trim text))
+ (push (cons patch-review-reply-general text) comments))))
+ (unless comments
+ (user-error "Nothing to send")))
+ (patch-review--compose
+ (patch-review-reply-format patch-review--parsed comments))))
+
+(defun patch-review--compose (body)
+ "Open a `message-mode' reply buffer containing BODY."
+ (let* ((headers patch-review--headers)
+ (from (patch-review--decode (cdr (assq 'from headers))))
+ (to (or from (read-string "To: ")))
+ (subject (or (cdr (assq 'subject headers)) "patch review"))
+ (message-id (cdr (assq 'message-id headers)))
+ (cc (patch-review--cc-list headers))
+ (new-subject
+ (concat "Re: "
+ (replace-regexp-in-string
+ "^\\s-*\\(?:[Rr][Ee]\\s-*:\\s-*\\)*" "" subject)))
+ (other-headers
+ (append (unless (string-empty-p cc) `(("Cc" . ,cc)))
+ (when message-id
+ `(("In-Reply-To" . ,message-id)
+ ("References" . ,message-id))))))
+ (message-mail to new-subject other-headers)
+ (message-goto-body)
+ (delete-region (point) (point-max))
+ (insert body)
+ (message-goto-body)
+ (message "Review the draft, then C-c C-c to send.")))
+
+(defun patch-review--cc-list (headers)
+ "Join the original To and Cc for a wide reply."
+ (mapconcat #'identity
+ (delq nil (mapcar (lambda (s) (and s (string-trim s)))
+ (list (cdr (assq 'to headers))
+ (cdr (assq 'cc headers)))))
+ ", "))
+
+;;;; Applying
+
+(defun patch-review-apply (&optional no-hooks)
+ "Apply the reviewed patch to the current project with `git am'.
+With prefix argument NO-HOOKS, suppress the user's git hooks."
+ (interactive "P" patch-review-mode)
+ (unless patch-review--project
+ (user-error "No project set; use C-c C-p first"))
+ (unless patch-review--source-file
+ (user-error "No patch file to apply"))
+ (pcase-let ((`(,status . ,output)
+ (patch-review-git-apply patch-review--project
+ patch-review--source-file
+ no-hooks)))
+ (setq patch-review--status status
+ patch-review--status-detail output)
+ (force-mode-line-update t)
+ (pcase status
+ ('applied
+ (message "Applied to %s." (abbreviate-file-name
+ (directory-file-name
+ patch-review--project))))
+ (_
+ (with-current-buffer (get-buffer-create "*patch-review-apply*")
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert (format "git am in %s — %s\n\n%s"
+ (abbreviate-file-name
+ (directory-file-name patch-review--project))
+ (substring-no-properties
+ (patch-review--status-string))
+ output))
+ (goto-char (point-min))
+ (diff-mode)
+ (read-only-mode 1))
+ (pop-to-buffer (current-buffer)))))))
+
+;;;###autoload
+(add-to-list 'auto-mode-alist '("\\.\\(?:patch\\|diff\\)\\'" . patch-review-mode))
+
+(provide 'patch-review)
+;;; patch-review.el ends here
diff --git a/tests/patch-review-test.el b/tests/patch-review-test.el
@@ -4,6 +4,7 @@
(require 'patch-review-parse)
(require 'patch-review-reply)
(require 'patch-review-git)
+(require 'patch-review)
(defun patch-review-test-fixture (name)
"Return the contents of fixture NAME."
@@ -296,5 +297,190 @@ Return the path of a format-patch file for the patch commit."
(delete-file a)
(delete-file b))))
+;;;; Comment extraction
+
+(defun patch-review-test--insert-after-line (text prefix insertion)
+ "Return TEXT with INSERTION added on a new line after the first line
+starting with PREFIX."
+ (let ((lines (split-string text "\n")) (out nil) (done nil))
+ (dolist (l lines)
+ (push l out)
+ (when (and (not done) (string-prefix-p prefix l))
+ (push insertion out)
+ (setq done t)))
+ (unless done (error "No line starting with %S" prefix))
+ (mapconcat #'identity (nreverse out) "\n")))
+
+(defun patch-review-test--remove-line (text prefix)
+ "Return TEXT without its first line starting with PREFIX."
+ (let ((lines (split-string text "\n")) (out nil) (done nil))
+ (dolist (l lines)
+ (if (and (not done) (string-prefix-p prefix l))
+ (setq done t)
+ (push l out)))
+ (unless done (error "No line starting with %S" prefix))
+ (mapconcat #'identity (nreverse out) "\n")))
+
+(defun patch-review-test--extract (parsed pristine current)
+ (patch-review-extract-comments parsed pristine current))
+
+(ert-deftest patch-review-test-extract-no-edits ()
+ (let* ((body (patch-review-test-fixture "patch1.body"))
+ (parsed (patch-review-parse-email body)))
+ (should (equal '(nil 0 0)
+ (patch-review-test--extract parsed body body)))))
+
+(ert-deftest patch-review-test-extract-general ()
+ (let* ((body (patch-review-test-fixture "patch1.body"))
+ (parsed (patch-review-parse-email body))
+ (edited (concat "Ship it.\n" body)))
+ (pcase-let ((`(,comments ,del ,chg)
+ (patch-review-test--extract parsed body edited)))
+ (should (equal '(0 0) (list del chg)))
+ (should (equal "Ship it."
+ (cdr (assoc patch-review-reply-general comments)))))))
+
+(ert-deftest patch-review-test-extract-hunk-comment ()
+ (let* ((body (patch-review-test-fixture "patch1.body"))
+ (parsed (patch-review-parse-email body))
+ (edited (patch-review-test--insert-after-line
+ body "+#include <errno.h>" "Is this portable?"))
+ (hunk (car (patch-review-file-hunks
+ (car (patch-review-email-files parsed)))))
+ (target (cl-position-if
+ (lambda (l) (equal "#include <errno.h>"
+ (patch-review-line-text l)))
+ (patch-review-hunk-lines hunk))))
+ (pcase-let ((`(,comments ,del ,chg)
+ (patch-review-test--extract parsed body edited)))
+ (should (equal '(0 0) (list del chg)))
+ (should (equal "Is this portable?"
+ (cdr (assoc (patch-review-reply-locator 0 0 target)
+ comments)))))))
+
+(ert-deftest patch-review-test-extract-diffstat-is-general ()
+ (let* ((body (patch-review-test-fixture "patch1.body"))
+ (parsed (patch-review-parse-email body))
+ (edited (patch-review-test--insert-after-line
+ body " src/socket.c |" "Nice diffstat.")))
+ (pcase-let ((`(,comments ,_ ,_)
+ (patch-review-test--extract parsed body edited)))
+ (should (equal "Nice diffstat."
+ (cdr (assoc patch-review-reply-general comments)))))))
+
+(ert-deftest patch-review-test-extract-deletion ()
+ (let* ((body (patch-review-test-fixture "patch1.body"))
+ (parsed (patch-review-parse-email body))
+ (edited (patch-review-test--remove-line body " \tprintf(\"opening")))
+ (pcase-let ((`(,comments ,del ,chg)
+ (patch-review-test--extract parsed body edited)))
+ (should (null comments))
+ (should (equal '(1 0) (list del chg))))))
+
+(ert-deftest patch-review-test-extract-change-is-comment ()
+ (let* ((body (patch-review-test-fixture "patch1.body"))
+ (parsed (patch-review-parse-email body))
+ (edited (patch-review-test--insert-after-line
+ (patch-review-test--remove-line body "+#include <errno.h>")
+ " #include <stdio.h>" "Why not <errno.h>?")))
+ (pcase-let ((`(,comments ,del ,chg)
+ (patch-review-test--extract parsed body edited)))
+ ;; One context line was not rewritten; the removed + line counts
+ ;; as a change and the new text is a comment.
+ (should (= 1 chg))
+ (should (= 0 del))
+ (should (cl-some (lambda (e) (string-match-p "Why not" (cdr e)))
+ comments)))))
+
+(ert-deftest patch-review-test-extract-trailing-newline-deletion ()
+ ;; patch1.body ends with "2.55.0\n\n": dropping one trailing newline
+ ;; deletes the (empty) final line and is reported as a deletion.
+ (let* ((body (patch-review-test-fixture "patch1.body"))
+ (parsed (patch-review-parse-email body))
+ (edited (string-remove-suffix "\n" body)))
+ (should (equal '(nil 1 0)
+ (patch-review-test--extract parsed body edited)))))
+
+(ert-deftest patch-review-test-extract-final-newline-normalized ()
+ ;; A missing final newline in the edited buffer is normalized away
+ ;; before diffing: it produces neither comments nor deletion counts.
+ (let* ((parsed (patch-review-parse-email "l1\nl2\n"))
+ (pristine "l1\nl2\n")
+ (edited "l1\nl2"))
+ (should (equal '(nil 0 0)
+ (patch-review-test--extract parsed pristine edited)))))
+
+;;;; End-to-end: open, comment, send
+
+(ert-deftest patch-review-test-open-and-send ()
+ (let* ((eml (expand-file-name
+ "fixtures/patch1.eml"
+ (file-name-directory (or load-file-name buffer-file-name))))
+ (buf (patch-review-open-message-file eml)))
+ (unwind-protect
+ (with-current-buffer buf
+ (should (eq major-mode 'patch-review-mode))
+ (should (eq (lookup-key patch-review-mode-map (kbd "C-c C-c"))
+ #'patch-review-send-review))
+ (goto-char (point-min))
+ (re-search-forward "^\+#include <errno.h>$")
+ (end-of-line)
+ (insert "\nIs this portable?")
+ (patch-review-send-review)
+ ;; Now in the message-mode draft.
+ (let ((mail (buffer-string)))
+ (should (string-match-p
+ "^To: Aisha Developer <aisha@example.org>$" mail))
+ (should (string-match-p
+ (concat "^" (regexp-quote
+ "Subject: Re: [PATCH 1/3] Reject negative"))
+ mail))
+ (should (string-match-p
+ "^In-Reply-To: <patch1@example.org>$" mail))
+ (should (string-match-p
+ (regexp-quote "> +#include <errno.h>") mail))
+ (should (string-match-p
+ (concat "^" (regexp-quote "Is this portable?") "$")
+ mail))
+ (kill-buffer)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest patch-review-test-send-without-comments-aborts ()
+ (let* ((eml (expand-file-name
+ "fixtures/patch1.eml"
+ (file-name-directory (or load-file-name buffer-file-name))))
+ (buf (patch-review-open-message-file eml)))
+ (unwind-protect
+ (with-current-buffer buf
+ (cl-letf (((symbol-function #'y-or-n-p) (lambda (_) nil)))
+ (should-error (patch-review-send-review) :type 'user-error)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+;;;; End-to-end: status probe and apply
+
+(ert-deftest patch-review-test-status-and-apply ()
+ (patch-review-test--with-repo dir
+ (let* ((mbox (patch-review-test--make-patch dir))
+ (text (with-temp-buffer
+ (insert-file-contents mbox)
+ (buffer-string)))
+ (buf (patch-review--open-text text mbox dir)))
+ (unwind-protect
+ (with-current-buffer buf
+ (should (eq 'applicable patch-review--status))
+ (should (string-match-p "applies cleanly"
+ (patch-review--header-line)))
+ (patch-review-apply)
+ (should (eq 'applied patch-review--status))
+ (should (equal patch-review-test--new-content
+ (with-temp-buffer
+ (insert-file-contents
+ (expand-file-name "src/socket.c" dir))
+ (buffer-string))))
+ ;; And the probe agrees afterwards.
+ (patch-review-refresh-status)
+ (should (eq 'applied patch-review--status)))
+ (when (buffer-live-p buf) (kill-buffer buf))))))
+
(provide 'patch-review-test)
;;; patch-review-test.el ends here