From ad70a6dbc740e4317ffda74a7a8b4be7574a7a1c Mon Sep 17 00:00:00 2001 From: Yuriy VG Date: Thu, 5 Feb 2026 14:19:33 +0300 Subject: [PATCH 1/8] Add support for read-only buffers as reference snapshots Extend undo-propose to work with read-only buffers (e.g., magit-diff) that have undo disabled. This enables a useful secondary workflow: creating a temporary reference copy of any buffer. For read-only source buffers, no undo history is available to navigate, but the buffer copy can still be useful as a reference. Changes: - Handle buffer-undo-list = t by using empty undo list - Use inhibit-read-only when copying buffer content, as some modes (like magit-diff-mode) set the buffer read-only during init --- Readme.org | 7 +++++++ undo-propose.el | 34 ++++++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/Readme.org b/Readme.org index 443c024..3724e86 100644 --- a/Readme.org +++ b/Readme.org @@ -53,6 +53,13 @@ individual undo events (squashing them into a single edit event in the undo history). To cancel, type ~C-c C-k~. You can also ediff the proposed chain of undo's by typing ~C-c C-d~. +*** Read-only buffers + +The ~undo-propose~ also works on read-only buffers (e.g., =magit-diff=, +=help-mode=). This provides a handy way to create a temporary +reference copy of any buffer. The snapshot can be kept open as a +reference while continuing to work in other buffers. + ** Configuration *** Adding commands (e.g. redo) diff --git a/undo-propose.el b/undo-propose.el index 07f3a26..82b2317 100644 --- a/undo-propose.el +++ b/undo-propose.el @@ -2,7 +2,7 @@ ;; Author: Jack Kamm ;; Maintainer: Jack Kamm -;; Version: 4.0.0 +;; Version: 4.1.0 ;; Package-Requires: ((emacs "24.3")) ;; Homepage: https://github.com/jackkamm/undo-propose.el ;; Keywords: convenience, files, undo, redo, history @@ -70,7 +70,8 @@ The default window behavior has also changed. Use :type 'list :group 'undo-propose) -(defvar undo-propose-parent nil "Parent buffer of ‘undo-propose’ buffer.") +(defvar undo-propose-parent nil "Parent buffer of 'undo-propose' buffer.") +(defvar undo-propose-read-only-source nil "Non-nil if source buffer had no undo history.") (defun undo-propose--message (content) "Message CONTENT, possibly with prefix \"undo-propose: \"." @@ -97,24 +98,35 @@ If already inside an `undo-propose' buffer, this will simply call `undo'." (undo) (let ((mode major-mode) (orig-buffer (current-buffer)) - (list-copy (undo-copy-list buffer-undo-list)) + (no-undo-history (eq buffer-undo-list t)) + (list-copy (if (eq buffer-undo-list t) + nil + (undo-copy-list buffer-undo-list))) (pos (point)) (win-start (window-start)) (tmp-buffer (generate-new-buffer (concat "*Undo Propose: " (buffer-name) "*")))) (pop-to-buffer tmp-buffer) - (funcall mode) - (insert-buffer-substring orig-buffer 1 (1+ (buffer-size orig-buffer))) - (goto-char pos) + (if no-undo-history + (special-mode) + (funcall mode)) + (let ((inhibit-read-only t)) + (insert-buffer-substring orig-buffer 1 (1+ (buffer-size orig-buffer))) + (goto-char pos)) (set-window-start (selected-window) win-start) (setq-local buffer-undo-list list-copy) (setq-local buffer-read-only t) (setq-local undo-propose-parent orig-buffer) + (setq-local undo-propose-read-only-source no-undo-history) (undo-propose-mode 1) - (undo-propose-copy-markers) + (unless no-undo-history + (undo-propose-copy-markers)) (run-hooks 'undo-propose-entry-hook) - (undo-propose--message "C-c C-c to commit, C-c C-s to squash commit, C-c C-k to cancel, C-c C-d to diff")))) + (undo-propose--message + (if no-undo-history + "Reference snapshot. C-c C-k to close" + "C-c C-c to commit, C-c C-s to squash commit, C-c C-k to cancel, C-c C-d to diff"))))) (define-minor-mode undo-propose-mode "Minor mode for `undo-propose'." @@ -192,9 +204,11 @@ buffer contents are copied." (run-hooks 'undo-propose-done-hook)) (defun undo-propose-diff () - "View differences between ‘undo-propose’ buffer and its parent using `ediff'." + "View differences between 'undo-propose' buffer and its parent using `ediff'." (interactive) - (ediff-buffers undo-propose-parent (current-buffer))) + (if undo-propose-read-only-source + (undo-propose--message "Diff not available for reference snapshots") + (ediff-buffers undo-propose-parent (current-buffer)))) (defvar-local undo-propose-marker-map nil) From ba0d0628cbb7d405bb43c2c412ebd1fe9a2415bd Mon Sep 17 00:00:00 2001 From: Yuriy VG Date: Thu, 5 Feb 2026 19:01:17 +0300 Subject: [PATCH 2/8] Add ediff support for reference snapshots For read-only source buffers, undo-propose-diff now compared snapshots, e.g. two magit-diff commits. --- undo-propose.el | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/undo-propose.el b/undo-propose.el index 82b2317..8ddc07b 100644 --- a/undo-propose.el +++ b/undo-propose.el @@ -2,7 +2,7 @@ ;; Author: Jack Kamm ;; Maintainer: Jack Kamm -;; Version: 4.1.0 +;; Version: 4.2.0 ;; Package-Requires: ((emacs "24.3")) ;; Homepage: https://github.com/jackkamm/undo-propose.el ;; Keywords: convenience, files, undo, redo, history @@ -125,7 +125,7 @@ If already inside an `undo-propose' buffer, this will simply call `undo'." (run-hooks 'undo-propose-entry-hook) (undo-propose--message (if no-undo-history - "Reference snapshot. C-c C-k to close" + "Reference snapshot. C-c C-d to diff, C-c C-k to close" "C-c C-c to commit, C-c C-s to squash commit, C-c C-k to cancel, C-c C-d to diff"))))) (define-minor-mode undo-propose-mode @@ -204,11 +204,16 @@ buffer contents are copied." (run-hooks 'undo-propose-done-hook)) (defun undo-propose-diff () - "View differences between 'undo-propose' buffer and its parent using `ediff'." + "View differences between 'undo-propose' buffer and another buffer using `ediff'. +For reference snapshots, compares with the buffer in the other window. +Otherwise, compares with the parent buffer." (interactive) - (if undo-propose-read-only-source - (undo-propose--message "Diff not available for reference snapshots") - (ediff-buffers undo-propose-parent (current-buffer)))) + (let ((compare-buffer (if undo-propose-read-only-source + (window-buffer (next-window)) + undo-propose-parent))) + (if (or (null compare-buffer) (eq compare-buffer (current-buffer))) + (undo-propose--message "No buffer to compare with") + (ediff-buffers compare-buffer (current-buffer))))) (defvar-local undo-propose-marker-map nil) From 2eb9339f6bf21b43110d09a23008e7d99a3717bb Mon Sep 17 00:00:00 2001 From: Yuriy VG Date: Wed, 18 Mar 2026 18:21:22 +0300 Subject: [PATCH 3/8] Use keyword arguments in define-minor-mode Replace deprecated positional arguments with :lighter and :keymap keywords to resolve Emacs warning about obsolete define-minor-mode syntax. --- undo-propose.el | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/undo-propose.el b/undo-propose.el index 8ddc07b..2ae495e 100644 --- a/undo-propose.el +++ b/undo-propose.el @@ -2,7 +2,7 @@ ;; Author: Jack Kamm ;; Maintainer: Jack Kamm -;; Version: 4.2.0 +;; Version: 4.2.1 ;; Package-Requires: ((emacs "24.3")) ;; Homepage: https://github.com/jackkamm/undo-propose.el ;; Keywords: convenience, files, undo, redo, history @@ -130,7 +130,8 @@ If already inside an `undo-propose' buffer, this will simply call `undo'." (define-minor-mode undo-propose-mode "Minor mode for `undo-propose'." - nil " UndoP" (make-sparse-keymap)) + :lighter " UndoP" + :keymap (make-sparse-keymap)) (define-key undo-propose-mode-map (kbd "C-c C-c") 'undo-propose-commit) (define-key undo-propose-mode-map (kbd "C-c C-s") 'undo-propose-squash-commit) (define-key undo-propose-mode-map (kbd "C-c C-d") 'undo-propose-diff) From 63c9f9ddaec778f544b5262641b46a524d5e0ba5 Mon Sep 17 00:00:00 2001 From: Yuriy VG Date: Wed, 18 Mar 2026 18:30:37 +0300 Subject: [PATCH 4/8] Enhance Makefile and relocate test file. --- Makefile | 61 ++++++++++++++++++- .../undo-propose-tests.el | 1 + 2 files changed, 60 insertions(+), 2 deletions(-) rename undo-propose-test.el => tests/undo-propose-tests.el (98%) diff --git a/Makefile b/Makefile index 6e963a8..ffe65d6 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,60 @@ -.PHONY: test +.PHONY: help test compile checkdoc ci clean + +.DEFAULT_GOAL := help + +# Package information +PACKAGE := undo-propose.el +VERSION := $(shell perl -ne 'if (/^;;\s*Version:\s*(\S+)/) {print $$1; last}' $(PACKAGE)) +TEST_COUNT := $(shell grep -c 'ert-deftest' tests/undo-propose-tests.el 2>/dev/null || echo 0) + +# Emacs command +EMACS ?= emacs +BATCH := $(EMACS) --batch + +help: + @echo "undo-propose v$(VERSION) - Makefile targets" + @echo "" + @echo " make test Run ERT unit tests" + @echo " make compile Byte-compile the package" + @echo " make checkdoc Check documentation strings" + @echo " make ci Run all checks (compile + checkdoc + test)" + @echo " make clean Remove generated files" + test: - emacs -batch -l ert -l undo-propose.el -l undo-propose-test.el -f ert-run-tests-batch-and-exit + @if [ -f tests/undo-propose-tests.el ]; then \ + $(BATCH) -l ert -l $(PACKAGE) \ + -l tests/undo-propose-tests.el \ + -f ert-run-tests-batch-and-exit; \ + else \ + echo "No tests found."; \ + fi + +compile: + @echo "Byte-compiling $(PACKAGE)..." + @$(BATCH) -f batch-byte-compile $(PACKAGE) + @echo "✓ Compilation successful" + +checkdoc: + @echo "Running checkdoc..." + @$(BATCH) --eval "\ + (progn \ + (require 'checkdoc) \ + (let ((checkdoc-diagnostic-buffer \"*chk*\")) \ + (checkdoc-file \"$(PACKAGE)\") \ + (when (get-buffer \"*chk*\") \ + (with-current-buffer \"*chk*\" \ + (unless (zerop (buffer-size)) \ + (message \"%s\" (buffer-string)) \ + (kill-emacs 1))))))" + +ci: clean compile checkdoc test + @echo "" + @echo "✓ All CI checks passed!" + @echo " - Byte compilation: OK" + @echo " - Documentation: OK" + @echo " - Tests: $(TEST_COUNT)/$(TEST_COUNT) passing" + +clean: + @echo "Cleaning generated files..." + @rm -f *.elc *~ + @echo "✓ Clean complete" diff --git a/undo-propose-test.el b/tests/undo-propose-tests.el similarity index 98% rename from undo-propose-test.el rename to tests/undo-propose-tests.el index 4519ae5..8ce950d 100644 --- a/undo-propose-test.el +++ b/tests/undo-propose-tests.el @@ -1,5 +1,6 @@ ;;; undo-propose-test.el --- Tests for undo-propose +(require 'ert) (require 'undo-propose) (defmacro with-undoable-temp-buffer (&rest body) From ef96dee137d3f8aa07d9b1be8f3df1e8e8261c0b Mon Sep 17 00:00:00 2001 From: Yuriy VG Date: Fri, 20 Mar 2026 09:51:22 +0300 Subject: [PATCH 5/8] fix: correct defcustom type, docstrings, and diff edge case * undo-propose.el: - Fix ':type 'list' -> '(repeat symbol)' in undo-propose-marker-list (bare 'list' is invalid without element type arguments) - Fix single-quoted symbols in docstrings to use backtick convention - Add trailing period to defgroup doc-string, edit other descriptions - Clarify undo-propose-done-hook runs on cancel too - Guard undo-propose-diff against single-window frame (reference snapshots) * Makefile: - Expand Makefile with lint target, byte-compile deps, and CI summary --- Makefile | 97 ++++++++++++++++++++++++++++++++++++------------- undo-propose.el | 32 +++++++++------- 2 files changed, 91 insertions(+), 38 deletions(-) diff --git a/Makefile b/Makefile index ffe65d6..020677e 100644 --- a/Makefile +++ b/Makefile @@ -1,58 +1,105 @@ -.PHONY: help test compile checkdoc ci clean +.PHONY: help compile test checkdoc lint ci clean .DEFAULT_GOAL := help # Package information -PACKAGE := undo-propose.el -VERSION := $(shell perl -ne 'if (/^;;\s*Version:\s*(\S+)/) {print $$1; last}' $(PACKAGE)) -TEST_COUNT := $(shell grep -c 'ert-deftest' tests/undo-propose-tests.el 2>/dev/null || echo 0) +PACKAGE := undo-propose +VERSION := $(shell perl -ne 'if (/^;;\s*Version:\s*(\S+)/) {print $$1; last}' $(PACKAGE).el) +TEST_COUNT := $(shell grep -c 'ert-deftest' tests/$(PACKAGE)-tests.el 2>/dev/null || echo 0) # Emacs command EMACS ?= emacs BATCH := $(EMACS) --batch +# All Elisp source files +EL_FILES := $(PACKAGE).el $(wildcard tests/*.el) + help: - @echo "undo-propose v$(VERSION) - Makefile targets" + @echo "$(PACKAGE) v$(VERSION) - Makefile targets" @echo "" - @echo " make test Run ERT unit tests" + @echo "Development:" @echo " make compile Byte-compile the package" - @echo " make checkdoc Check documentation strings" - @echo " make ci Run all checks (compile + checkdoc + test)" @echo " make clean Remove generated files" + @echo "" + @echo "Quality checks:" + @echo " make test Run ERT unit tests ($(TEST_COUNT) tests)" + @echo " make checkdoc Check documentation strings" + @echo " make lint Run package-lint (requires network)" + @echo "" + @echo "CI:" + @echo " make ci Run all" + @echo "" + +$(PACKAGE).elc: $(PACKAGE).el + @echo "Byte-compiling $(PACKAGE)..." + @$(BATCH) --eval "(setq byte-compile-error-on-warn t)" \ + -f batch-byte-compile $(PACKAGE).el + @echo "✓ Compilation successful" -test: - @if [ -f tests/undo-propose-tests.el ]; then \ - $(BATCH) -l ert -l $(PACKAGE) \ - -l tests/undo-propose-tests.el \ +compile: $(PACKAGE).elc + +test: $(PACKAGE).elc + @if [ -f tests/$(PACKAGE)-tests.el ]; then \ + $(BATCH) -l ert -l $(PACKAGE).el \ + -l tests/$(PACKAGE)-tests.el \ -f ert-run-tests-batch-and-exit; \ else \ echo "No tests found."; \ fi -compile: - @echo "Byte-compiling $(PACKAGE)..." - @$(BATCH) -f batch-byte-compile $(PACKAGE) - @echo "✓ Compilation successful" - -checkdoc: - @echo "Running checkdoc..." +checkdoc: $(PACKAGE).elc + @echo "Running checkdoc on $(PACKAGE).el..." @$(BATCH) --eval "\ (progn \ (require 'checkdoc) \ - (let ((checkdoc-diagnostic-buffer \"*chk*\")) \ - (checkdoc-file \"$(PACKAGE)\") \ + (let ((checkdoc-diagnostic-buffer \"*chk*\") \ + (issues 0) (output \"\")) \ + (checkdoc-file \"$(PACKAGE).el\") \ (when (get-buffer \"*chk*\") \ (with-current-buffer \"*chk*\" \ (unless (zerop (buffer-size)) \ - (message \"%s\" (buffer-string)) \ - (kill-emacs 1))))))" + (setq issues (count-lines (point-min) (point-max))) \ + (setq output (buffer-string))))) \ + (when (get-buffer \"*Warnings*\") \ + (with-current-buffer \"*Warnings*\" \ + (unless (zerop (buffer-size)) \ + (setq issues (+ issues (count-lines (point-min) (point-max)))) \ + (setq output (concat output (buffer-string)))))) \ + (if (> issues 0) \ + (progn \ + (message \"checkdoc: %d issue(s) found in $(PACKAGE).el:\" issues) \ + (message \"%s\" output) \ + (kill-emacs 1)) \ + (message \"checkdoc: $(PACKAGE).el OK (no issues)\"))))" + +lint: $(PACKAGE).elc + @echo "Running package-lint checks..." + @$(BATCH) \ + --eval "(require 'package)" \ + --eval "(push '(\"melpa\" . \"https://melpa.org/packages/\") package-archives)" \ + --eval "(package-initialize)" \ + --eval "(condition-case err \ + (unless (package-installed-p 'package-lint) \ + (package-refresh-contents) \ + (package-install 'package-lint)) \ + (error \ + (message \"ERROR: Failed to install package-lint: %s\" (error-message-string err)) \ + (message \"Hint: check your network connection and MELPA availability.\") \ + (kill-emacs 1)))" \ + --eval "(require 'package-lint)" \ + -f package-lint-batch-and-exit $(PACKAGE).el + @echo "✓ package-lint passed" -ci: clean compile checkdoc test + +ci: clean compile checkdoc lint test @echo "" @echo "✓ All CI checks passed!" @echo " - Byte compilation: OK" - @echo " - Documentation: OK" + @echo " - Documentation (checkdoc): OK" + @echo " - Lint (package-lint): OK" @echo " - Tests: $(TEST_COUNT)/$(TEST_COUNT) passing" + @echo "" + @echo "Package $(PACKAGE) v$(VERSION) - ready for release" clean: @echo "Cleaning generated files..." diff --git a/undo-propose.el b/undo-propose.el index 2ae495e..da41d5b 100644 --- a/undo-propose.el +++ b/undo-propose.el @@ -2,7 +2,7 @@ ;; Author: Jack Kamm ;; Maintainer: Jack Kamm -;; Version: 4.2.1 +;; Version: 4.2.2 ;; Package-Requires: ((emacs "24.3")) ;; Homepage: https://github.com/jackkamm/undo-propose.el ;; Keywords: convenience, files, undo, redo, history @@ -45,11 +45,11 @@ (require 'cl-lib) (defgroup undo-propose nil - "Simple and safe undo navigation" + "Simple and safe undo navigation." :group 'convenience) (defcustom undo-propose-done-hook nil - "Hook runs when leaving the temporal buffer." + "Hook runs when leaving the temporal buffer, including on cancel." :type 'hook :group 'undo-propose) @@ -67,10 +67,10 @@ The default window behavior has also changed. Use (defcustom undo-propose-marker-list '(org-clock-marker org-clock-hd-marker) "List of quoted markers to update after running undo-propose." - :type 'list + :type '(repeat symbol) :group 'undo-propose) -(defvar undo-propose-parent nil "Parent buffer of 'undo-propose' buffer.") +(defvar undo-propose-parent nil "Parent buffer of `undo-propose' buffer.") (defvar undo-propose-read-only-source nil "Non-nil if source buffer had no undo history.") (defun undo-propose--message (content) @@ -86,11 +86,12 @@ The default window behavior has also changed. Use (defun undo-propose () "Navigate undo history in a new temporary buffer. \\ -Copies 'current-buffer' and 'buffer-undo-list' to a new temporary buffer, +Copies `current-buffer' and `buffer-undo-list' to a new temporary buffer, which is read-only except for undo commands. After finished undoing, type \\[undo-propose-commit] to accept the chain of undos, -or \\[undo-propose-squash-commit] to copy the buffer but squash the undo's into a single edit event event. To cancel, type \\[undo-propose-cancel], and -to view an ediff type \\[undo-propose-diff]. +or \\[undo-propose-squash-commit] to copy the buffer but squash the undo's into a +single edit event. To cancel, type \\[undo-propose-cancel], and to view an +ediff type \\[undo-propose-diff]. If already inside an `undo-propose' buffer, this will simply call `undo'." (interactive) @@ -205,15 +206,20 @@ buffer contents are copied." (run-hooks 'undo-propose-done-hook)) (defun undo-propose-diff () - "View differences between 'undo-propose' buffer and another buffer using `ediff'. + "View differences between `undo-propose' buffer and another buffer using `ediff'. For reference snapshots, compares with the buffer in the other window. Otherwise, compares with the parent buffer." (interactive) - (let ((compare-buffer (if undo-propose-read-only-source - (window-buffer (next-window)) - undo-propose-parent))) + (let* ((other-win (next-window nil nil 'visible)) + (compare-buffer (if undo-propose-read-only-source + (and (not (eq other-win (selected-window))) + (window-buffer other-win)) + undo-propose-parent))) (if (or (null compare-buffer) (eq compare-buffer (current-buffer))) - (undo-propose--message "No buffer to compare with") + (undo-propose--message + (if undo-propose-read-only-source + "No other window to compare with" + "No buffer to compare with")) (ediff-buffers compare-buffer (current-buffer))))) (defvar-local undo-propose-marker-map nil) From a42408dace3057b4af08ceaaef2bb469c20c3d52 Mon Sep 17 00:00:00 2001 From: Yuriy VG Date: Fri, 20 Mar 2026 09:55:57 +0300 Subject: [PATCH 6/8] test: add ERT test suite for core undo-propose functionality Covers commit, squash-commit, cancel, nested calls, hooks, and read-only source (reference snapshot) mode. --- tests/undo-propose-tests.el | 221 ++++++++++++++++++++++++++++++++---- 1 file changed, 200 insertions(+), 21 deletions(-) diff --git a/tests/undo-propose-tests.el b/tests/undo-propose-tests.el index 8ce950d..698f3e4 100644 --- a/tests/undo-propose-tests.el +++ b/tests/undo-propose-tests.el @@ -1,32 +1,211 @@ -;;; undo-propose-test.el --- Tests for undo-propose +;;; undo-propose-tests.el --- Tests for undo-propose -*- lexical-binding: t -*- (require 'ert) (require 'undo-propose) +;;; Code: + +;;; Helpers + (defmacro with-undoable-temp-buffer (&rest body) - "Like `with-temp-buffer', but doesn't disable `undo'." + "Like `with-temp-buffer', but doesn't disable `undo'. +Executes BODY in the temporary buffer." `(let ((temp-buffer (generate-new-buffer - ;; this must NOT start with a space, otherwise - ;; undo won't work. See `get-buffer-create' - "*temp*"))) + ;; this must NOT start with a space, otherwise + ;; undo won't work. See `get-buffer-create' + "*temp*"))) + (unwind-protect + (with-current-buffer temp-buffer + ,@body) + (kill-buffer temp-buffer)))) + +(defmacro with-undo-propose-test (initial &rest body) + "Set up a temp buffer with INITIAL text, open undo-propose, run BODY. +BODY runs inside the undo-propose buffer. The original buffer is +bound to `orig-buf'." + (declare (indent 1)) + `(with-undoable-temp-buffer + (insert ,initial) + (undo-boundary) + (let ((orig-buf (current-buffer))) + (undo-propose) + ,@body))) + +;;; commit + +(ert-deftest undo-propose-commit-copies-buffer () + "Committing copies the proposed buffer content back to the parent." + (with-undo-propose-test "hello" + (let ((inhibit-read-only t)) + (erase-buffer) + (insert "world")) + (undo-propose-commit) + (should (equal (buffer-string) "world")))) + +(ert-deftest undo-propose-commit-copies-undo-ring () + "Committing copies the undo-ring back so changes can be undone." + (with-undo-propose-test "first" + (call-interactively (command-remapping 'undo)) + (undo-propose-commit) + ;; after commit, parent should be undoable back to empty + (undo) + (should (equal (buffer-string) "")))) + +(ert-deftest undo-propose-commit-restores-point () + "Committing restores point to the position in the propose buffer." + (with-undo-propose-test "hello world" + (goto-char 6) + (undo-propose-commit) + (should (= (point) 6)))) + +(ert-deftest undo-propose-commit-kills-propose-buffer () + "Committing kills the temporary undo-propose buffer." + (with-undo-propose-test "hello" + (let ((propose-buf (current-buffer))) + (undo-propose-commit) + (should-not (buffer-live-p propose-buf))))) + +;;; squash-commit + +(ert-deftest undo-propose-squash-commit-copies-buffer () + "Squash-committing copies buffer content to the parent." + (with-undo-propose-test "hello" + (call-interactively (command-remapping 'undo)) + (undo-propose-squash-commit) + (should (equal (buffer-string) "")))) + +(ert-deftest undo-propose-squash-commit-does-not-copy-undo-ring () + "Squash-committing does NOT copy the propose buffer's undo-ring to the parent." + (with-undo-propose-test "first" + (call-interactively (command-remapping 'undo)) + ;; capture propose buffer's undo-list after undoing + (let ((propose-undo-list buffer-undo-list)) + (undo-propose-squash-commit) + ;; parent undo-list must differ from propose buffer's undo-list + (with-current-buffer orig-buf + (should-not (equal buffer-undo-list propose-undo-list)))))) + +(ert-deftest undo-propose-squash-commit-noop-when-unchanged () + "Squash-committing without undoing leaves the parent buffer unchanged." + (with-undo-propose-test "hello" + (let ((undo-list-before buffer-undo-list)) + (undo-propose-squash-commit) + (should (equal (buffer-string) "hello")) + (should (equal buffer-undo-list undo-list-before))))) + +;;; cancel + +(ert-deftest undo-propose-cancel-leaves-parent-unchanged () + "Cancelling does not modify the parent buffer content." + (with-undo-propose-test "original" + (let ((inhibit-read-only t)) + (erase-buffer) + (insert "discarded")) + (undo-propose-cancel) + (with-current-buffer orig-buf + (should (equal (buffer-string) "original"))))) + +(ert-deftest undo-propose-cancel-kills-propose-buffer () + "Cancelling kills the temporary undo-propose buffer." + (with-undo-propose-test "hello" + (let ((propose-buf (current-buffer))) + (undo-propose-cancel) + (should-not (buffer-live-p propose-buf))))) + +;;; nested call + +(ert-deftest undo-propose-nested-call-runs-undo () + "Calling undo-propose inside an undo-propose buffer runs undo instead." + (with-undo-propose-test "hello" + (let ((inhibit-read-only t)) + (insert " world") + (undo-boundary)) + ;; second call should undo the " world" insertion + (undo-propose) + (should (equal (buffer-string) "hello")) + (undo-propose-cancel))) + +;;; hooks + +(ert-deftest undo-propose-entry-hook-runs-on-entry () + "`undo-propose-entry-hook' fires when entering the propose buffer." + (with-undoable-temp-buffer + (let ((fired nil)) + (add-hook 'undo-propose-entry-hook (lambda () (setq fired t))) + (unwind-protect + (progn + (undo-propose) + (should fired) + (undo-propose-cancel)) + (remove-hook 'undo-propose-entry-hook (lambda () (setq fired t))))))) + +(ert-deftest undo-propose-done-hook-runs-on-commit () + "`undo-propose-done-hook' fires after committing." + (with-undo-propose-test "hello" + (let ((fired nil)) + (add-hook 'undo-propose-done-hook (lambda () (setq fired t))) + (unwind-protect + (progn + (undo-propose-commit) + (should fired)) + (remove-hook 'undo-propose-done-hook (lambda () (setq fired t))))))) + +(ert-deftest undo-propose-done-hook-runs-on-cancel () + "`undo-propose-done-hook' fires even when cancelling." + (with-undo-propose-test "hello" + (let ((fired nil)) + (add-hook 'undo-propose-done-hook (lambda () (setq fired t))) + (unwind-protect + (progn + (undo-propose-cancel) + (should fired)) + (remove-hook 'undo-propose-done-hook (lambda () (setq fired t))))))) + +;;; read-only source (reference snapshot) + +(ert-deftest undo-propose-readonly-source-sets-flag () + "Opening undo-propose on a buffer with no undo history sets the snapshot flag." + (let ((buf (generate-new-buffer "*ro-test*"))) + (unwind-protect + (with-current-buffer buf + (setq buffer-undo-list t) + (undo-propose) + (should undo-propose-read-only-source) + (undo-propose-cancel)) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(ert-deftest undo-propose-readonly-source-allows-undo-in-propose () + "In snapshot mode the propose buffer has its own undo history." + (let ((buf (generate-new-buffer "*ro-test*"))) (unwind-protect - (with-current-buffer temp-buffer - ,@body) - (kill-buffer temp-buffer)))) + (with-current-buffer buf + (setq buffer-undo-list t) + (undo-propose) + (let ((inhibit-read-only t)) + (insert "typed in propose") + (undo-boundary)) + (call-interactively (command-remapping 'undo)) + (should (equal (buffer-string) "")) + (undo-propose-cancel)) + (when (buffer-live-p buf) (kill-buffer buf))))) + +;;; org-clock marker integration (existing test, kept for regression) (ert-deftest undo-propose-test-org-clock () (with-undoable-temp-buffer - (org-mode) - (insert "* test\n") - (undo-boundary) - (org-clock-in) - (undo-boundary) - (goto-char (point-max)) - (insert "\nfoobar") - (undo-boundary) - (undo-propose) - (call-interactively (command-remapping 'undo)) - (undo-propose-commit) - (org-clock-out))) + (org-mode) + (insert "* test\n") + (undo-boundary) + (org-clock-in) + (undo-boundary) + (goto-char (point-max)) + (insert "\nfoobar") + (undo-boundary) + (undo-propose) + (call-interactively (command-remapping 'undo)) + (undo-propose-commit) + (org-clock-out))) + +(provide 'undo-propose-tests) -;;; undo-propose-test.el ends here +;;; undo-propose-tests.el ends here From d4eedcf81b0a4a9d0ae8c10d228b634ffe3729b7 Mon Sep 17 00:00:00 2001 From: Yuriy VG Date: Tue, 24 Mar 2026 10:45:01 +0300 Subject: [PATCH 7/8] fix: window and message handling Without this fix `undo-propose-diff' for read-only buffers will be incorrect. --- undo-propose.el | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/undo-propose.el b/undo-propose.el index da41d5b..bac2e65 100644 --- a/undo-propose.el +++ b/undo-propose.el @@ -2,7 +2,7 @@ ;; Author: Jack Kamm ;; Maintainer: Jack Kamm -;; Version: 4.2.2 +;; Version: 4.2.3 ;; Package-Requires: ((emacs "24.3")) ;; Homepage: https://github.com/jackkamm/undo-propose.el ;; Keywords: convenience, files, undo, redo, history @@ -210,16 +210,11 @@ buffer contents are copied." For reference snapshots, compares with the buffer in the other window. Otherwise, compares with the parent buffer." (interactive) - (let* ((other-win (next-window nil nil 'visible)) - (compare-buffer (if undo-propose-read-only-source - (and (not (eq other-win (selected-window))) - (window-buffer other-win)) - undo-propose-parent))) + (let ((compare-buffer (if undo-propose-read-only-source + (window-buffer (next-window)) + undo-propose-parent))) (if (or (null compare-buffer) (eq compare-buffer (current-buffer))) - (undo-propose--message - (if undo-propose-read-only-source - "No other window to compare with" - "No buffer to compare with")) + (undo-propose--message "No buffer to compare with") (ediff-buffers compare-buffer (current-buffer))))) (defvar-local undo-propose-marker-map nil) From 8c8d15dcafac667eed49f71cd9de54347583f745 Mon Sep 17 00:00:00 2001 From: Yuriy VG Date: Tue, 24 Mar 2026 10:28:46 +0300 Subject: [PATCH 8/8] Bump minimum Emacs to 29.1, add transient 0.3.0 dependency - Add undo-propose-dispatch transient menu, add ? and h keybindings - Refactor keybindings - Refactor of command and function description - Rename Readme.org -> README.org --- Readme.org => README.org | 7 +++++ undo-propose.el | 67 +++++++++++++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 15 deletions(-) rename Readme.org => README.org (96%) diff --git a/Readme.org b/README.org similarity index 96% rename from Readme.org rename to README.org index 3724e86..ec77419 100644 --- a/Readme.org +++ b/README.org @@ -142,6 +142,13 @@ bugs that corrupt the undo history. - [[https://www.reddit.com/r/emacs/comments/6yzwic/how_emacs_undo_works/][reddit.com/r/emacs: How Emacs undo works]] ** Changes +*** 5.0.0 + +Requires Emacs 29.1+ and adds a new dependency on =transient= (0.3.0+). + +A transient help menu is now available in the undo-propose buffer via +~?~ or ~h~, listing all available commands. + *** 4.0.0 Switched to using the ~display-buffer~ framework to configure window diff --git a/undo-propose.el b/undo-propose.el index bac2e65..18d90f2 100644 --- a/undo-propose.el +++ b/undo-propose.el @@ -2,8 +2,8 @@ ;; Author: Jack Kamm ;; Maintainer: Jack Kamm -;; Version: 4.2.3 -;; Package-Requires: ((emacs "24.3")) +;; Version: 5.0.0 +;; Package-Requires: ((emacs "29.1") (transient "0.3.0")) ;; Homepage: https://github.com/jackkamm/undo-propose.el ;; Keywords: convenience, files, undo, redo, history @@ -43,6 +43,7 @@ ;;; Code: (require 'cl-lib) +(require 'transient) (defgroup undo-propose nil "Simple and safe undo navigation." @@ -126,20 +127,14 @@ If already inside an `undo-propose' buffer, this will simply call `undo'." (run-hooks 'undo-propose-entry-hook) (undo-propose--message (if no-undo-history - "Reference snapshot. C-c C-d to diff, C-c C-k to close" - "C-c C-c to commit, C-c C-s to squash commit, C-c C-k to cancel, C-c C-d to diff"))))) + "Reference snapshot. Type ? for help" + "Undo propose. Type ? for help"))))) -(define-minor-mode undo-propose-mode - "Minor mode for `undo-propose'." - :lighter " UndoP" - :keymap (make-sparse-keymap)) -(define-key undo-propose-mode-map (kbd "C-c C-c") 'undo-propose-commit) -(define-key undo-propose-mode-map (kbd "C-c C-s") 'undo-propose-squash-commit) -(define-key undo-propose-mode-map (kbd "C-c C-d") 'undo-propose-diff) -(define-key undo-propose-mode-map (kbd "C-c C-k") 'undo-propose-cancel) +(defvar undo-propose-mode-map (make-sparse-keymap) + "Keymap for `undo-propose-mode'.") (defmacro undo-propose-wrap (command) - "Wrap COMMAND so it is useable within the ‘undo-propose’ buffer." + "Wrap COMMAND so it is useable within the `undo-propose' buffer." `(define-key undo-propose-mode-map [remap ,command] (lambda () (interactive) @@ -150,7 +145,7 @@ If already inside an `undo-propose' buffer, this will simply call `undo'." (undo-propose-wrap undo-only) (defun undo-propose-commit () - "Quit and copy ‘undo-propose’ buffer and undo-ring back to the parent buffer." + "Quit and copy `undo-propose' buffer and undo-ring back to the parent buffer." (interactive) (let ((win (selected-window)) (orig-buffer undo-propose-parent) @@ -199,7 +194,7 @@ buffer contents are copied." 'undo-propose-squash-commit "3.0.0") (defun undo-propose-cancel () - "Kill ‘undo-propose’ buffer without copying back to its parent." + "Kill `undo-propose' buffer without copying back to its parent." (interactive) (quit-restore-window (selected-window) 'kill) (undo-propose--message "cancel") @@ -245,6 +240,48 @@ Otherwise, compares with the parent buffer." (move-marker orig-marker (marker-position new-marker) undo-propose-parent))))) +;;;; Help mode + +;;;###autoload (autoload 'git-manager-dispatch "git-manager-mode" nil t) +(transient-define-prefix undo-propose-dispatch () + "Transient menu for undo-propose operations." + ["undo-propose Actions commands" + [("c" "Commit" undo-propose-commit) + ("s" "Squash commit" undo-propose-squash-commit) + ("k" "Cancel" undo-propose-cancel)]] + [["View" + ("d" "Diff/Ediff" undo-propose-diff) + ("?" "Help" undo-propose-dispatch :transient t)]] + [["Exit" + ("q" "Quit menu" transient-quit-one)]]) + +;;;; Keymap + +(defvar undo-propose-mode-map (make-sparse-keymap) + "Keymap for `undo-propose-mode'.") + +(let ((map undo-propose-mode-map)) + ;; -- Actions -- + (define-key map (kbd "C-c C-c") #'undo-propose-commit) + (define-key map (kbd "C-c C-s") #'undo-propose-squash-commit) + (define-key map (kbd "C-c C-d") #'undo-propose-diff) + (define-key map (kbd "C-c C-k") #'undo-propose-cancel) + ;; -- Suppress special-mode's q binding -- + (define-key map (kbd "q") #'undo-propose-dispatch) + ;; -- Transient / Help -- + (define-key map (kbd "?") #'undo-propose-dispatch) + (define-key map (kbd "h") #'undo-propose-dispatch) + (define-key map [remap self-insert-command] #'undo-propose-dispatch)) + +;;; Minor mode + +;;;###autoload +(define-minor-mode undo-propose-mode + "Minor mode for `undo-propose'." + :lighter " UndoP" + :keymap (make-sparse-keymap)) + + (provide 'undo-propose) ;;; undo-propose.el ends here