aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-02 06:32:16 -0400
committerCraig Jennings <c@cjennings.net>2026-07-02 06:32:16 -0400
commit8357eed1e4753b142cdda0e57e00260f2341443e (patch)
treeceeba1aa00250ad6baf62e2ff9af0727c43b0f57
parent8ef30e38c18c48da547d7c75735c20a2efcca777 (diff)
downloaddotemacs-8357eed1e4753b142cdda0e57e00260f2341443e.tar.gz
dotemacs-8357eed1e4753b142cdda0e57e00260f2341443e.zip
feat(buffer-file): make the disk-changed diff review navigable
Pressing d in the C-x C-s conflict menu (and the save-some loop) now enters a modal review instead of a peek-and-return toggle: point lands on the first hunk, arrows and TAB move through the changes, and the menu keys act from inside the diff. difftastic gets --context 1 and an explicit --width, since as a subprocess it can't detect the terminal and wrapped at 80 columns. A new m choice resolves the conflict in ediff. I kept the post-merge save re-asking once, so an abandoned merge can't silently overwrite the disk version.
-rw-r--r--modules/custom-buffer-file.el196
-rw-r--r--tests/test-custom-buffer-file--diff-review.el261
2 files changed, 427 insertions, 30 deletions
diff --git a/modules/custom-buffer-file.el b/modules/custom-buffer-file.el
index 606188af..25555b53 100644
--- a/modules/custom-buffer-file.el
+++ b/modules/custom-buffer-file.el
@@ -397,34 +397,64 @@ stock `revert-buffer' prompt-every-time behavior on this map."
(declare-function ansi-color-apply-on-region "ansi-color")
+(defvar cj/diff-context-lines 1
+ "Lines of unchanged context shown around each change in buffer/file diffs.
+Passed to difftastic's --context and diff's -U, so the diff view shows what
+differs rather than pages of identical text.")
+
+(defun cj/--difft-args (file1 file2 context width)
+ "Build the difftastic argument list for FILE1 vs FILE2.
+CONTEXT is the number of unchanged lines shown around each change; WIDTH is the
+column budget for the side-by-side layout (difftastic cannot detect a terminal
+when run as a subprocess and would otherwise wrap at 80)."
+ (list "--color" "always"
+ "--display" "side-by-side-show-both"
+ "--context" (number-to-string context)
+ "--width" (number-to-string width)
+ file1 file2))
+
+(defun cj/--unified-diff-args (file1 file2 context)
+ "Build the diff(1) argument list for a unified diff of FILE1 vs FILE2.
+CONTEXT is the number of unchanged lines shown around each change (-U)."
+ (list (format "-U%d" context) file1 file2))
+
(defun cj/--diff-with-difftastic (file1 file2 buffer)
"Run difftastic on FILE1 and FILE2, output to BUFFER.
-Applies ANSI color and sets up special-mode for navigation."
+Applies ANSI color, sets up special-mode for navigation, and leaves point on
+the first hunk's content (past this header and difftastic's file header) so the
+window opens on the first difference."
(with-current-buffer buffer
(let ((inhibit-read-only t))
(erase-buffer)
(insert (format "Difftastic diff: %s (saved) vs buffer (modified)\n\n"
(file-name-nondirectory file1)))
- (call-process "difft" nil t nil
- "--color" "always"
- "--display" "side-by-side-show-both"
- file1 file2)
+ (apply #'call-process "difft" nil t nil
+ (cj/--difft-args file1 file2 cj/diff-context-lines (frame-width)))
(require 'ansi-color)
(ansi-color-apply-on-region (point-min) (point-max))
(special-mode)
- (goto-char (point-min)))))
+ (goto-char (point-min))
+ (forward-line 2) ; past this function's header
+ (when (looking-at-p ".* --- ") ; difftastic's own file header
+ (forward-line 1))
+ (while (and (eolp) (not (eobp))) ; any blank separator lines
+ (forward-line 1)))))
(defun cj/--diff-with-regular-diff (file1 file2 buffer)
"Run regular unified diff on FILE1 and FILE2, output to BUFFER.
-Sets up diff-mode for navigation."
+Sets up diff-mode for navigation and leaves point on the first @@ hunk so the
+window opens on the first difference."
(with-current-buffer buffer
(let ((inhibit-read-only t))
(erase-buffer)
(insert (format "Unified diff: %s (saved) vs buffer (modified)\n\n"
(file-name-nondirectory file1)))
- (call-process "diff" nil t nil "-u" file1 file2)
+ (apply #'call-process "diff" nil t nil
+ (cj/--unified-diff-args file1 file2 cj/diff-context-lines))
(diff-mode)
- (goto-char (point-min)))))
+ (goto-char (point-min))
+ (when (re-search-forward "^@@" nil t)
+ (goto-char (match-beginning 0))))))
(defun cj/--diff-buffer-renderer (ws-only difft-available)
"Choose the diff renderer symbol from WS-ONLY and DIFFT-AVAILABLE.
@@ -454,11 +484,13 @@ the question so the reader knows the difference is whitespace before choosing."
"Return the terse `read-multiple-choice' menu for the disk-changed save prompt.
Inline names are single words so the menu fits at a glance; the full meaning is
in each description (the ? help). s overwrites the file with the buffer; r
-discards the buffer's edits and rereads from disk."
+discards the buffer's edits and rereads from disk; m resolves the two versions
+side-by-side in ediff."
'((?s "save" "overwrite the file with this buffer")
- (?d "diff" "show what changed, then ask again")
+ (?d "diff" "review the diff; navigate and act from there")
(?w "clean" "clean whitespace and save")
(?r "revert" "discard edits and reread from disk")
+ (?m "merge" "resolve side-by-side in ediff, then save from there")
(?c "cancel" "leave the buffer as is")))
(defun cj/--buffer-changed-on-disk-p (buffer)
@@ -475,43 +507,143 @@ underneath has moved, so a plain save would silently overwrite the disk version.
(defun cj/--buffer-differs-action (key)
"Map a disk-changed-prompt KEY to an action symbol, or nil when unmapped.
`save' overwrites the file, `clean-save' cleans whitespace then saves, `revert'
-rereads from disk, `cancel' does nothing, and `diff' peeks (the caller re-prompts)."
+rereads from disk, `merge' resolves in ediff, `cancel' does nothing, and `diff'
+peeks (the caller re-prompts)."
(pcase key
(?s 'save)
(?w 'clean-save)
(?r 'revert)
+ (?m 'merge)
(?d 'diff)
(?c 'cancel)))
+(declare-function ediff-current-file "ediff")
+
(defun cj/--buffer-differs-dispatch (buffer action)
"Carry out ACTION for BUFFER after a disk-changed prompt.
`save' overwrites the file with the buffer; `clean-save' strips trailing
whitespace first; `revert' discards the buffer's edits and rereads the disk;
-`cancel' leaves the buffer untouched. Save updates the recorded modtime first so
-the stock `save-buffer' does not re-ask its own \"changed on disk\" question."
+`merge' launches `ediff-current-file' to resolve the buffer against the disk
+version hunk-by-hunk (the file's modtime is deliberately NOT marked as seen, so
+the save after merging re-asks once -- an abandoned merge can never silently
+overwrite the disk); `cancel' leaves the buffer untouched. Save updates the
+recorded modtime first so the stock `save-buffer' does not re-ask its own
+\"changed on disk\" question."
(with-current-buffer buffer
(pcase action
('save (set-visited-file-modtime) (save-buffer))
('clean-save (delete-trailing-whitespace) (set-visited-file-modtime) (save-buffer))
('revert (revert-buffer t t))
+ ('merge (ediff-current-file)
+ (message "Merge in ediff, quit it, then C-x C-s and choose save"))
('cancel (message "Save cancelled; buffer left as is"))
(_ nil))))
+(defvar cj/--diff-review-choice nil
+ "Key chosen inside the diff review, or nil when the review went back to the menu.")
+
+(declare-function diff-hunk-next "diff-mode")
+(declare-function diff-hunk-prev "diff-mode")
+
+(defun cj/--diff-section-next ()
+ "Move to the next hunk in the diff buffer.
+Uses diff-mode's hunk motion when available; otherwise the next
+blank-line-separated section (difftastic output)."
+ (interactive)
+ (if (derived-mode-p 'diff-mode)
+ (diff-hunk-next)
+ (forward-paragraph)
+ (skip-chars-forward "\n")))
+
+(defun cj/--diff-section-prev ()
+ "Move to the previous hunk in the diff buffer.
+Uses diff-mode's hunk motion when available; otherwise the previous
+blank-line-separated section (difftastic output)."
+ (interactive)
+ (if (derived-mode-p 'diff-mode)
+ (diff-hunk-prev)
+ (skip-chars-backward "\n")
+ (backward-paragraph)
+ (skip-chars-forward "\n")))
+
+(defun cj/--diff-review-choose (key)
+ "Record KEY as the review's choice and exit the review."
+ (setq cj/--diff-review-choice key)
+ (exit-recursive-edit))
+
+(defun cj/--diff-review-back ()
+ "Leave the diff review without a choice; the menu prompt returns."
+ (interactive)
+ (exit-recursive-edit))
+
+(defun cj/--diff-review-keymap (choices)
+ "Build the diff-review keymap from CHOICES (a `read-multiple-choice' list).
+Every menu key except d acts directly from the review. TAB / S-TAB (and n / p
+when the menu doesn't claim them) move between hunks. ESC always goes back to
+the menu prompt; q does too unless the menu claims q (the save-some loop)."
+ (let ((map (make-sparse-keymap)))
+ (dolist (entry choices)
+ (let ((key (car entry)))
+ (unless (eq key ?d)
+ (define-key map (vector key)
+ (lambda () (interactive) (cj/--diff-review-choose key))))))
+ (unless (assq ?q choices)
+ (define-key map "q" #'cj/--diff-review-back))
+ (unless (assq ?n choices)
+ (define-key map "n" #'cj/--diff-section-next))
+ (unless (assq ?p choices)
+ (define-key map "p" #'cj/--diff-section-prev))
+ (define-key map (kbd "<escape>") #'cj/--diff-review-back)
+ (define-key map (kbd "TAB") #'cj/--diff-section-next)
+ (define-key map (kbd "<backtab>") #'cj/--diff-section-prev)
+ map))
+
+(defun cj/--diff-review-hint (choices)
+ "One-line echo hint for the review built from CHOICES: actions, motion, exit."
+ (concat
+ (mapconcat (lambda (e) (format "%c:%s" (car e) (nth 1 e)))
+ (seq-remove (lambda (e) (eq (car e) ?d)) choices)
+ " ")
+ " TAB:hunks"
+ (if (assq ?q choices) " ESC:menu" " q:menu")))
+
+(defun cj/--diff-review (diff-buffer choices)
+ "Modal review of DIFF-BUFFER: navigate the diff, act with the menu keys.
+Selects DIFF-BUFFER's window and enters a recursive edit with the keymap from
+CHOICES layered on top, so point motion (arrows, scrolling, search) works while
+every menu key acts immediately. Returns the chosen key, or nil when the user
+went back to the menu (q, ESC, or an aborted recursive edit). Returns nil
+untouched when the buffer has no window to review in."
+ (let ((win (and (buffer-live-p diff-buffer) (get-buffer-window diff-buffer))))
+ (when win
+ (setq cj/--diff-review-choice nil)
+ (with-selected-window win
+ (let ((exit-fn (set-transient-map (cj/--diff-review-keymap choices)
+ (lambda () t))))
+ (unwind-protect
+ (progn
+ (message "%s" (cj/--diff-review-hint choices))
+ (condition-case nil (recursive-edit) (quit nil)))
+ (funcall exit-fn))))
+ cj/--diff-review-choice)))
+
(defun cj/--read-choice-with-diff (prompt choices show-diff-fn)
- "Read a `read-multiple-choice' key for PROMPT and CHOICES; d toggles a diff.
-SHOW-DIFF-FN displays the buffer/file diff and returns its buffer. The d key
-shows the diff, or hides it when it is already shown (a toggle). Any other key
--- a terminating choice -- closes a still-open diff window before returning that
-key, so the diff never lingers after the decision is made."
+ "Read a `read-multiple-choice' key for PROMPT and CHOICES; d reviews the diff.
+SHOW-DIFF-FN displays the buffer/file diff and returns its buffer. d shows the
+diff and enters a navigable review (`cj/--diff-review') where every other menu
+key acts directly; leaving the review (q, ESC) returns to this prompt. A
+terminating choice -- made at the prompt or inside the review -- closes a
+still-open diff window before it is returned, so the diff never lingers after
+the decision is made."
(let ((key nil) (diff-buf nil))
(while (not key)
(let ((k (car (read-multiple-choice prompt choices))))
- (if (eq k ?d)
- (let ((win (and (buffer-live-p diff-buf) (get-buffer-window diff-buf))))
- (if win
- (progn (quit-window nil win) (setq diff-buf nil))
- (setq diff-buf (funcall show-diff-fn))))
- (setq key k))))
+ (if (not (eq k ?d))
+ (setq key k)
+ (unless (and (buffer-live-p diff-buf) (get-buffer-window diff-buf))
+ (setq diff-buf (funcall show-diff-fn)))
+ (when diff-buf
+ (setq key (cj/--diff-review diff-buf choices))))))
(let ((win (and (buffer-live-p diff-buf) (get-buffer-window diff-buf))))
(when win (quit-window nil win)))
key))
@@ -519,7 +651,8 @@ key, so the diff never lingers after the decision is made."
(defun cj/--buffer-differs-read-key (buffer ws-only)
"Read a disk-changed-prompt key for BUFFER via `read-multiple-choice'.
WS-ONLY non-nil folds a terse \"(whitespace only)\" note into the prompt. d
-toggles the buffer/file diff; a terminating choice closes a still-open diff."
+opens the buffer/file diff in a navigable review where the menu keys act
+directly; a terminating choice closes a still-open diff."
(cj/--read-choice-with-diff
(cj/--buffer-differs-prompt-string (buffer-name buffer) ws-only)
(cj/--buffer-differs-choices)
@@ -530,8 +663,10 @@ toggles the buffer/file diff; a terminating choice closes a still-open diff."
A normal save falls straight through to `save-buffer' (ARG, the prefix argument,
is passed along so \\[universal-argument] \\[save-buffer] still marks for backup).
When the buffer has unsaved edits AND the file changed on disk since it was
-visited, offer a terse labeled menu -- save / diff / clean / revert / cancel --
-instead of the stock yes/no \"Save anyway?\" prompt. Bound to \\`C-x C-s'."
+visited, offer a terse labeled menu -- save / diff / clean / revert / merge /
+cancel -- instead of the stock yes/no \"Save anyway?\" prompt. d opens a
+navigable diff review (arrows and TAB move, the menu keys act from inside); m
+resolves the two versions in ediff. Bound to \\`C-x C-s'."
(interactive "P")
(if (not (cj/--buffer-changed-on-disk-p (current-buffer)))
(save-buffer arg)
@@ -562,7 +697,7 @@ in each description (the ? help)."
'((?y "save" "save this buffer")
(?n "skip" "do not save this buffer")
(?w "clean" "clean whitespace and save this buffer")
- (?d "diff" "show what changed, then ask again")
+ (?d "diff" "review the diff; navigate and act from there")
(?! "all" "save this and all remaining buffers")
(?. "only" "save this buffer, then skip the rest")
(?q "none" "stop; save no more buffers")))
@@ -607,7 +742,8 @@ skip the rest). KEY-FN is not consulted once a buffer triggers save-rest or sto
(defun cj/--save-some-buffers-read-key (buffer ws-only)
"Read a save-loop key for BUFFER via `read-multiple-choice'.
WS-ONLY non-nil folds a terse \"(whitespace only)\" note into the prompt. d
-toggles the buffer/file diff; a terminating choice closes a still-open diff."
+opens the buffer/file diff in a navigable review where the menu keys act
+directly; a terminating choice closes a still-open diff."
(cj/--read-choice-with-diff
(format "Save %s%s"
(if (buffer-file-name buffer)
diff --git a/tests/test-custom-buffer-file--diff-review.el b/tests/test-custom-buffer-file--diff-review.el
new file mode 100644
index 00000000..5418638d
--- /dev/null
+++ b/tests/test-custom-buffer-file--diff-review.el
@@ -0,0 +1,261 @@
+;;; test-custom-buffer-file--diff-review.el --- navigable diff review pieces -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Pure-logic tests for the navigable diff review added to the disk-changed save
+;; prompts: the diff command argument builders (context + width), first-hunk
+;; positioning, the generated review keymap (menu keys act inside the diff, q
+;; goes back, TAB/S-TAB navigate), the merge menu entry, and the reworked
+;; read-choice loop (d enters the review; a review choice terminates the
+;; prompt). The recursive-edit itself and the live window interaction are
+;; exercised manually, not here.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'custom-buffer-file)
+
+(declare-function cj/--difft-args "custom-buffer-file" (file1 file2 context width))
+(declare-function cj/--unified-diff-args "custom-buffer-file" (file1 file2 context))
+(declare-function cj/--diff-review-keymap "custom-buffer-file" (choices))
+(declare-function cj/--diff-review-back "custom-buffer-file" ())
+(declare-function cj/--diff-section-next "custom-buffer-file" ())
+(declare-function cj/--diff-section-prev "custom-buffer-file" ())
+(declare-function cj/--diff-review "custom-buffer-file" (diff-buffer choices))
+(declare-function cj/--diff-with-regular-diff "custom-buffer-file" (file1 file2 buffer))
+(declare-function cj/--diff-with-difftastic "custom-buffer-file" (file1 file2 buffer))
+(declare-function cj/--buffer-differs-choices "custom-buffer-file" ())
+(declare-function cj/--buffer-differs-action "custom-buffer-file" (key))
+(declare-function cj/--buffer-differs-dispatch "custom-buffer-file" (buffer action))
+(declare-function cj/--save-some-buffers-choices "custom-buffer-file" ())
+(declare-function cj/--read-choice-with-diff "custom-buffer-file" (prompt choices show-diff-fn))
+
+(defvar cj/--diff-review-choice)
+(defvar cj/diff-context-lines)
+
+;;; ------------------------------ arg builders --------------------------------
+
+(ert-deftest test-cbf-difft-args-context-and-width ()
+ "Normal: difft args carry the context and width knobs plus both files."
+ (let ((args (cj/--difft-args "/a.el" "/b.el" 1 143)))
+ (should (member "--context" args))
+ (should (member "1" args))
+ (should (member "--width" args))
+ (should (member "143" args))
+ (should (member "/a.el" args))
+ (should (member "/b.el" args))))
+
+(ert-deftest test-cbf-difft-args-color-and-display ()
+ "Normal: difft args keep forced color and the side-by-side display."
+ (let ((args (cj/--difft-args "/a" "/b" 3 80)))
+ (should (member "--color" args))
+ (should (member "always" args))
+ (should (member "side-by-side-show-both" args))))
+
+(ert-deftest test-cbf-difft-args-zero-context ()
+ "Boundary: context 0 is passed through, not dropped."
+ (let ((args (cj/--difft-args "/a" "/b" 0 80)))
+ (should (member "--context" args))
+ (should (member "0" args))))
+
+(ert-deftest test-cbf-unified-diff-args-context ()
+ "Normal: unified diff args use -U<context> and both files, in order."
+ (should (equal (cj/--unified-diff-args "/a" "/b" 1) '("-U1" "/a" "/b"))))
+
+(ert-deftest test-cbf-unified-diff-args-zero-context ()
+ "Boundary: context 0 produces -U0."
+ (should (equal (car (cj/--unified-diff-args "/a" "/b" 0)) "-U0")))
+
+;;; ------------------------- first-hunk positioning ---------------------------
+
+(defun test-cbf-review--two-files (line-changed body-fn)
+ "Make two 60-line temp files differing at LINE-CHANGED; call BODY-FN with both."
+ (let ((f1 (make-temp-file "cbf-rev-a-" nil ".txt"))
+ (f2 (make-temp-file "cbf-rev-b-" nil ".txt")))
+ (unwind-protect
+ (progn
+ (with-temp-file f1
+ (dotimes (i 60) (insert (format "line %d stays the same\n" (1+ i)))))
+ (with-temp-file f2
+ (dotimes (i 60)
+ (insert (if (= (1+ i) line-changed)
+ (format "line %d CHANGED in buffer\n" (1+ i))
+ (format "line %d stays the same\n" (1+ i))))))
+ (funcall body-fn f1 f2))
+ (when (file-exists-p f1) (delete-file f1))
+ (when (file-exists-p f2) (delete-file f2)))))
+
+(ert-deftest test-cbf-regular-diff-lands-on-first-hunk ()
+ "Normal: after rendering a unified diff, point sits on the first @@ hunk line."
+ (test-cbf-review--two-files
+ 30
+ (lambda (f1 f2)
+ (with-temp-buffer
+ (cj/--diff-with-regular-diff f1 f2 (current-buffer))
+ (should (looking-at "@@"))))))
+
+(ert-deftest test-cbf-difftastic-lands-past-headers ()
+ "Normal: after rendering difftastic output, point sits on hunk content, not the headers."
+ (skip-unless (executable-find "difft"))
+ (test-cbf-review--two-files
+ 30
+ (lambda (f1 f2)
+ (with-temp-buffer
+ (cj/--diff-with-difftastic f1 f2 (current-buffer))
+ (should (> (line-number-at-pos) 2)) ; past our two-line header
+ (should-not (looking-at-p ".* --- ")) ; past difft's file header
+ (should-not (eolp)))))) ; on a content line, not a blank
+
+;;; ------------------------------ review keymap -------------------------------
+
+(ert-deftest test-cbf-review-keymap-binds-menu-keys ()
+ "Normal: every buffer-differs menu key except d becomes a review binding."
+ (let ((map (cj/--diff-review-keymap (cj/--buffer-differs-choices))))
+ (dolist (key '(?s ?w ?r ?m ?c))
+ (should (commandp (lookup-key map (vector key)))))
+ (should-not (lookup-key map (vector ?d)))))
+
+(ert-deftest test-cbf-review-keymap-q-goes-back-when-free ()
+ "Normal: q maps to back-to-menu when the menu does not claim q."
+ (let ((map (cj/--diff-review-keymap (cj/--buffer-differs-choices))))
+ (should (eq (lookup-key map "q") #'cj/--diff-review-back))))
+
+(ert-deftest test-cbf-review-keymap-q-stays-menu-key-when-claimed ()
+ "Boundary: in the save-some loop q is a menu action, not back-to-menu."
+ (let ((map (cj/--diff-review-keymap (cj/--save-some-buffers-choices))))
+ (should (commandp (lookup-key map "q")))
+ (should-not (eq (lookup-key map "q") #'cj/--diff-review-back))))
+
+(ert-deftest test-cbf-review-keymap-navigation-bound ()
+ "Normal: TAB and S-TAB navigate hunks in every review."
+ (let ((map (cj/--diff-review-keymap (cj/--buffer-differs-choices))))
+ (should (eq (lookup-key map (kbd "TAB")) #'cj/--diff-section-next))
+ (should (eq (lookup-key map (kbd "<backtab>")) #'cj/--diff-section-prev))))
+
+(ert-deftest test-cbf-review-keymap-np-navigate-when-free ()
+ "Normal: n/p navigate hunks when the menu does not claim them."
+ (let ((map (cj/--diff-review-keymap (cj/--buffer-differs-choices))))
+ (should (eq (lookup-key map "n") #'cj/--diff-section-next))
+ (should (eq (lookup-key map "p") #'cj/--diff-section-prev))))
+
+(ert-deftest test-cbf-review-keymap-n-stays-menu-key-when-claimed ()
+ "Boundary: in the save-some loop n is the skip action, not navigation."
+ (let ((map (cj/--diff-review-keymap (cj/--save-some-buffers-choices))))
+ (should (commandp (lookup-key map "n")))
+ (should-not (eq (lookup-key map "n") #'cj/--diff-section-next))))
+
+(ert-deftest test-cbf-review-keymap-escape-always-back ()
+ "Normal: ESC goes back to the menu even when the menu claims q (save-some loop)."
+ (let ((map (cj/--diff-review-keymap (cj/--save-some-buffers-choices))))
+ (should (eq (lookup-key map (kbd "<escape>")) #'cj/--diff-review-back))))
+
+(ert-deftest test-cbf-review-choice-command-records-key ()
+ "Normal: a review action command records its key and exits the review."
+ (let ((map (cj/--diff-review-keymap (cj/--buffer-differs-choices)))
+ (cj/--diff-review-choice nil)
+ (exited nil))
+ (cl-letf (((symbol-function 'exit-recursive-edit)
+ (lambda (&rest _) (setq exited t))))
+ (funcall (lookup-key map "s"))
+ (should (eq cj/--diff-review-choice ?s))
+ (should exited))))
+
+(ert-deftest test-cbf-review-back-leaves-choice-nil ()
+ "Normal: back-to-menu exits the review without recording a choice."
+ (let ((cj/--diff-review-choice nil)
+ (exited nil))
+ (cl-letf (((symbol-function 'exit-recursive-edit)
+ (lambda (&rest _) (setq exited t))))
+ (cj/--diff-review-back)
+ (should-not cj/--diff-review-choice)
+ (should exited))))
+
+;;; ---------------------------- merge menu entry ------------------------------
+
+(ert-deftest test-cbf-buffer-differs-choices-include-merge ()
+ "Normal: the disk-changed menu offers m for merge, described via ediff."
+ (let ((entry (assq ?m (cj/--buffer-differs-choices))))
+ (should entry)
+ (should (string-match-p "ediff" (or (nth 2 entry) "")))))
+
+(ert-deftest test-cbf-buffer-differs-action-merge ()
+ "Normal: m maps to the merge action."
+ (should (eq (cj/--buffer-differs-action ?m) 'merge)))
+
+(ert-deftest test-cbf-buffer-differs-dispatch-merge-launches-ediff ()
+ "Normal: dispatching merge launches ediff-current-file in the conflicted buffer."
+ (let ((launched-in nil))
+ (cl-letf (((symbol-function 'ediff-current-file)
+ (lambda (&rest _) (setq launched-in (current-buffer)))))
+ (with-temp-buffer
+ (cj/--buffer-differs-dispatch (current-buffer) 'merge)
+ (should (eq launched-in (current-buffer)))))))
+
+(ert-deftest test-cbf-buffer-differs-dispatch-merge-keeps-buffer-modified ()
+ "Boundary: merge neither saves nor reverts -- the buffer's state is untouched."
+ (cl-letf (((symbol-function 'ediff-current-file) (lambda (&rest _) nil)))
+ (with-temp-buffer
+ (insert "content")
+ (set-buffer-modified-p t)
+ (cj/--buffer-differs-dispatch (current-buffer) 'merge)
+ (should (buffer-modified-p)))))
+
+;;; ------------------------- read-choice loop rework --------------------------
+
+(defun test-cbf-review--run-loop (rmc-keys review-results &optional diff-buffer)
+ "Drive cj/--read-choice-with-diff with scripted inputs.
+RMC-KEYS are successive read-multiple-choice answers; REVIEW-RESULTS are
+successive cj/--diff-review returns. DIFF-BUFFER (or a fresh one) is what the
+show-diff-fn yields. Returns (RESULT RMC-CALLS REVIEW-CALLS)."
+ (let ((rmc-calls 0) (review-calls 0) (keys rmc-keys) (reviews review-results))
+ (cl-letf (((symbol-function 'read-multiple-choice)
+ (lambda (&rest _)
+ (setq rmc-calls (1+ rmc-calls))
+ (list (pop keys))))
+ ((symbol-function 'cj/--diff-review)
+ (lambda (&rest _)
+ (setq review-calls (1+ review-calls))
+ (pop reviews))))
+ (let ((result (cj/--read-choice-with-diff
+ "Prompt" (cj/--buffer-differs-choices)
+ (lambda () diff-buffer))))
+ (list result rmc-calls review-calls)))))
+
+(ert-deftest test-cbf-read-choice-review-choice-terminates ()
+ "Normal: a key chosen inside the diff review terminates the prompt directly."
+ (with-temp-buffer
+ (pcase-let ((`(,result ,rmc-calls ,review-calls)
+ (test-cbf-review--run-loop '(?d) '(?s) (current-buffer))))
+ (should (eq result ?s))
+ (should (= rmc-calls 1))
+ (should (= review-calls 1)))))
+
+(ert-deftest test-cbf-read-choice-review-back-reprompts ()
+ "Normal: leaving the review without a choice re-shows the menu prompt."
+ (with-temp-buffer
+ (pcase-let ((`(,result ,rmc-calls ,review-calls)
+ (test-cbf-review--run-loop '(?d ?c) '(nil) (current-buffer))))
+ (should (eq result ?c))
+ (should (= rmc-calls 2))
+ (should (= review-calls 1)))))
+
+(ert-deftest test-cbf-read-choice-no-diff-skips-review ()
+ "Error: when the diff cannot render (no differences), d re-prompts without a review."
+ (pcase-let ((`(,result ,rmc-calls ,review-calls)
+ (test-cbf-review--run-loop '(?d ?c) '(?s) nil)))
+ (should (eq result ?c))
+ (should (= rmc-calls 2))
+ (should (= review-calls 0))))
+
+(ert-deftest test-cbf-read-choice-plain-key-never-reviews ()
+ "Boundary: a terminating menu key never enters the review."
+ (with-temp-buffer
+ (pcase-let ((`(,result ,rmc-calls ,review-calls)
+ (test-cbf-review--run-loop '(?s) '() (current-buffer))))
+ (should (eq result ?s))
+ (should (= rmc-calls 1))
+ (should (= review-calls 0)))))
+
+(provide 'test-custom-buffer-file--diff-review)
+;;; test-custom-buffer-file--diff-review.el ends here