From bbd355d27bea86625f96bb3f23ed75ca7fe86c32 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 21 Jul 2026 13:08:02 +0300 Subject: [PATCH] Add projectile-replace-undo [#2115] Applying a replace across a whole project is the most destructive thing Projectile does, and the reviewer already knows exactly what it wrote, so recording that and offering an undo is cheap insurance. The apply path now returns the edits it actually wrote (skipped files contribute nothing) and stashes them in a single global record. Undo reverts them bottom-up, per file, and only when the written text is still byte-for-byte where it was put - a file edited, deleted or rewritten by a branch switch is reported and left alone. Open buffers are edited in place rather than written behind, exactly as applying handles them. Reverted files drop out of the record, so a second undo can't revert anything twice. --- CHANGELOG.md | 3 + doc/modules/ROOT/pages/cheatsheet.adoc | 3 + doc/modules/ROOT/pages/usage.adoc | 22 +++ projectile.el | 202 ++++++++++++++++++++++- test/projectile-replace-review-test.el | 218 +++++++++++++++++++++++++ 5 files changed, 441 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b84f16e7..43f8b6575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ ### New features +- [#2115](https://github.com/bbatsov/projectile/pull/2115): Add `projectile-replace-undo` (`s-p u`), which reverts the last replace applied from the reviewable replace buffer. + - Only the edits that were actually written are recorded, and a file is reverted only if the replaced text is still exactly where the replace put it - anything changed since is reported and left alone. + - Open buffers are edited in place instead of being written behind, mirroring what applying does; the record covers just the last apply and doesn't survive an Emacs restart. - [#2114](https://github.com/bbatsov/projectile/pull/2114): Add project-scoped bookmarks - `projectile-bookmark-set` (`s-p B s`), `projectile-bookmark-jump` (`s-p B j`) and `projectile-bookmark-delete` (`s-p B d`). - They're plain Emacs bookmarks, so they show up in `list-bookmarks` and are persisted by `bookmark.el` itself; Projectile only scopes the completion to the current project and suggests a project-prefixed name. - A bookmark counts as the project's when its file lives under the project root or its name starts with the project's name - see `projectile-bookmark-scope`. diff --git a/doc/modules/ROOT/pages/cheatsheet.adoc b/doc/modules/ROOT/pages/cheatsheet.adoc index 2f3c70369..c1c6e564f 100644 --- a/doc/modules/ROOT/pages/cheatsheet.adoc +++ b/doc/modules/ROOT/pages/cheatsheet.adoc @@ -112,6 +112,9 @@ Here's a list of the interactive Emacs Lisp functions, provided by Projectile: | kbd:[s-p R] | Reviewable replace: gather all matches in a results buffer where you can preview and toggle them before applying (see below). +| kbd:[s-p u] +| Undo the last replace applied from the reviewable replace buffer. + | kbd:[s-p i] | Invalidates the project cache (if existing). diff --git a/doc/modules/ROOT/pages/usage.adoc b/doc/modules/ROOT/pages/usage.adoc index 13aab4430..a8bbb0275 100644 --- a/doc/modules/ROOT/pages/usage.adoc +++ b/doc/modules/ROOT/pages/usage.adoc @@ -328,6 +328,28 @@ Emacs 31's `grep-edit-mode`. wgrep is an optional integration, not a dependency; after exporting, Projectile tells you which workflow is available based on what you have installed. +==== Undoing an applied replace + +Applying a replace across a whole project is the most destructive thing +Projectile does, so `projectile-replace-undo` (kbd:[s-p u]) puts it back. +It reverts exactly the edits kbd:[!] wrote - files that applying skipped +were never touched and aren't part of the record. + +Undo is deliberately paranoid. Before reverting a file it checks that the +text the replace wrote is still there, byte for byte, at the position it +was written to. A file that has since been edited, reverted, deleted, or +rewritten by a branch switch is reported and left alone; the rest of the +files are still reverted. Open buffers are handled the same way applying +handles them: the buffer is edited in place rather than the file written +behind its back, a clean buffer is saved, and one with unsaved changes of +its own is edited but left for you to save. + +Only the last applied replace is undoable, it is not per project, and the +record lives in memory - restarting Emacs forgets it. Files that were +reverted drop out of the record, so undoing twice can never revert +anything twice; files that were skipped stay undoable once you've sorted +out whatever changed under them. + === Reviewing search matches When you want to look through every match for a term across the project diff --git a/projectile.el b/projectile.el index 29c6e6146..3ec7b776c 100644 --- a/projectile.el +++ b/projectile.el @@ -9060,8 +9060,61 @@ text and applying them would corrupt unrelated bytes." (format "skipping %s (%s)" name reason))) 'skipped) +(cl-defstruct (projectile-replace--undo-edit + (:constructor projectile-replace--undo-edit-create) + (:copier nil)) + "One replacement that was actually written, recorded so it can be reverted." + beg ; position of the written text, with every earlier edit in the file in + old ; the text that was there before + new) ; the text the replace wrote + +(cl-defstruct (projectile-replace--undo-record + (:constructor projectile-replace--undo-record-create) + (:copier nil)) + "Everything needed to revert one applied project-wide replace." + root ; project root the replace ran in + term ; the search term it ran with + replacement ; the replacement it wrote + files) ; alist of (FILE . list of `projectile-replace--undo-edit') + +(defvar projectile-replace--last-apply nil + "Record of the most recent applied replace, or nil when there is none. +Holds a `projectile-replace--undo-record' describing exactly what +`projectile-replace--apply' wrote - files it skipped contribute nothing - +which is what `projectile-replace-undo' reverts. + +Deliberately one global record rather than a per-project stack: this is +insurance against the last replace going wrong, not a history. Every +apply that writes something supersedes it, a fully successful undo clears +it, and it lives only for the current Emacs session.") + +(defun projectile-replace--undo-edits (matches replacement literal) + "Return the undo edits produced by applying MATCHES with REPLACEMENT. +MATCHES all belong to one file or buffer. Each edit records where its +written text ends up once every edit before it in the same file has been +made, so reverting them from the bottom up needs no rescan. LITERAL +selects verbatim vs. capture-group expansion, so the recorded text is +exactly what `projectile-replace--do-one' writes." + (let ((ascending (sort (copy-sequence matches) + (lambda (a b) + (< (projectile-replace--match-beg a) + (projectile-replace--match-beg b))))) + (offset 0) + (edits nil)) + (dolist (m ascending) + (let ((old (projectile-replace--match-string m)) + (new (projectile-replace--expand + replacement (projectile-replace--match-groups m) literal))) + (push (projectile-replace--undo-edit-create + :beg (+ (projectile-replace--match-beg m) offset) + :old old + :new new) + edits) + (setq offset (+ offset (- (length new) (length old)))))) + (nreverse edits))) + (defun projectile-replace--apply-file (file matches replacement literal) - "Apply MATCHES in FILE and return the count, or the symbol `skipped'. + "Apply MATCHES in FILE and return its undo edits, or the symbol `skipped'. Edits run from the highest buffer position downwards so earlier edits don't shift later matches. The live buffer visiting FILE (if any) is re-resolved now rather than trusted from scan time, so a file opened @@ -9070,7 +9123,8 @@ disk, and a scan-time buffer that has since been killed is handled. In either case the recorded positions are verified to still span the matched text; if not (the file or buffer changed since the scan) the file is skipped rather than corrupted. A clean buffer is saved; a buffer with -unsaved changes is edited but left for the user to save." +unsaved changes is edited but left for the user to save. The returned +edits describe what was really written, and feed `projectile-replace-undo'." (let* ((descending (sort (copy-sequence matches) (lambda (a b) (> (projectile-replace--match-beg a) @@ -9091,7 +9145,7 @@ unsaved changes is edited but left for the user to save." (unless was-modified (let ((require-final-newline nil)) (save-buffer))) - (length matches)))) + (projectile-replace--undo-edits matches replacement literal)))) (with-temp-buffer (insert-file-contents file) (let ((coding last-coding-system-used)) @@ -9102,18 +9156,23 @@ unsaved changes is edited but left for the user to save." (projectile-replace--do-one m replacement literal)) (let ((coding-system-for-write coding)) (write-region (point-min) (point-max) file nil 'no-message)) - (length matches))))))) + (projectile-replace--undo-edits matches replacement literal))))))) (defun projectile-replace--apply () - "Apply every enabled match, grouped by file, then re-run the search." + "Apply every enabled match, grouped by file, then re-run the search. +What actually got written is recorded in `projectile-replace--last-apply' +so `projectile-replace-undo' can revert it." (interactive) (projectile-replace--ensure-not-scanning) (let ((enabled (cl-remove-if-not #'projectile-replace--match-enabled projectile-replace--matches)) (replacement projectile-replace--replacement) (literal projectile-replace--literal) + (root projectile-replace--root) + (term projectile-replace--term) (groups (make-hash-table :test 'equal)) (order nil) + (applied nil) (nfiles 0) (nrepl 0) (skipped 0)) @@ -9137,8 +9196,16 @@ unsaved changes is edited but left for the user to save." (error-message-string err)))))) (if (eq result 'skipped) (cl-incf skipped) + (push (cons file result) applied) (cl-incf nfiles) - (cl-incf nrepl result)))) + (cl-incf nrepl (length result))))) + ;; an apply that wrote nothing leaves the previous record alone - there's + ;; nothing new to undo, and dropping it would lose a still-valid undo + (when applied + (setq projectile-replace--last-apply + (projectile-replace--undo-record-create + :root root :term term :replacement replacement + :files (nreverse applied)))) (message "%s" (projectile-prepend-project-name (format "Replaced %d occurrence%s in %d file%s%s" @@ -9150,6 +9217,123 @@ unsaved changes is edited but left for the user to save." "")))) (projectile-replace--refresh))) +;;; Undoing the last applied replace + +(defun projectile-replace--undo-valid-p (edits) + "Return non-nil when EDITS still span exactly the text the replace wrote. +Checked against the current buffer. This is the guard that keeps an undo +from corrupting a file that moved on since the replace: unless every +recorded span still holds the written text, verbatim, the file is left +alone." + (cl-every (lambda (e) + (let* ((beg (projectile-replace--undo-edit-beg e)) + (new (projectile-replace--undo-edit-new e)) + (end (+ beg (length new)))) + (and (<= (point-min) beg end (point-max)) + (string= (buffer-substring-no-properties beg end) new)))) + edits)) + +(defun projectile-replace--undo-one (e) + "Put back the text edit E replaced, in the current buffer." + (let ((beg (projectile-replace--undo-edit-beg e))) + (goto-char beg) + (delete-region beg (+ beg (length (projectile-replace--undo-edit-new e)))) + (insert (projectile-replace--undo-edit-old e)))) + +(defun projectile-replace--undo-file (file edits) + "Revert EDITS in FILE and return the count, or the symbol `skipped'. +Mirrors `projectile-replace--apply-file' in every respect: edits are +reverted from the highest position downwards, the live buffer visiting +FILE is re-resolved now (so an undo never writes a file behind the back +of a buffer visiting it), a clean buffer is saved and a modified one is +left for the user, and closed files are rewritten with their own coding +system. A file is reverted only if all of its edits still verify, so it +comes back whole or not at all." + (let ((descending (sort (copy-sequence edits) + (lambda (a b) + (> (projectile-replace--undo-edit-beg a) + (projectile-replace--undo-edit-beg b))))) + (buffer (get-file-buffer file))) + (if (buffer-live-p buffer) + (with-current-buffer buffer + (if (not (projectile-replace--undo-valid-p descending)) + (projectile-replace--skip (buffer-name buffer) + "changed since the replace") + (let ((was-modified (buffer-modified-p))) + (save-excursion + (save-restriction + (widen) + (atomic-change-group + (mapc #'projectile-replace--undo-one descending)))) + ;; same rule as applying: a buffer that had unsaved changes of + ;; its own is edited but not saved on the user's behalf + (unless was-modified + (let ((require-final-newline nil)) + (save-buffer))) + (length edits)))) + (with-temp-buffer + (insert-file-contents file) + (let ((coding last-coding-system-used)) + (if (not (projectile-replace--undo-valid-p descending)) + (projectile-replace--skip (file-name-nondirectory file) + "changed on disk since the replace") + (mapc #'projectile-replace--undo-one descending) + (let ((coding-system-for-write coding)) + (write-region (point-min) (point-max) file nil 'no-message)) + (length edits))))))) + +;;;###autoload +(defun projectile-replace-undo () + "Revert the last project-wide replace applied from the replace reviewer. + +Only replaces applied with \\\\[projectile-replace--apply] in a +`*projectile-replace*' buffer are recorded, and only the most recent one: +this is a safety net for the single most destructive thing Projectile +does, not an edit history. The record lives in memory, so it is gone +after restarting Emacs. + +Each file is reverted only when the text the replace wrote is still +exactly there; a file edited, reverted, deleted or rewritten by a branch +switch in the meantime is reported and left alone rather than corrupted. +Files that were reverted are dropped from the record, so undoing twice +can never apply anything twice, while files that were skipped stay +undoable once you have sorted them out." + (interactive) + (let ((record projectile-replace--last-apply)) + (unless record + (user-error "No applied project-wide replace to undo")) + (let ((nedits 0) + (nfiles 0) + (remaining nil)) + (dolist (entry (projectile-replace--undo-record-files record)) + (let* ((file (car entry)) + (result (condition-case err + (projectile-replace--undo-file file (cdr entry)) + (error + (projectile-replace--skip + (file-name-nondirectory file) + (error-message-string err)))))) + (if (eq result 'skipped) + (push entry remaining) + (cl-incf nfiles) + (cl-incf nedits result)))) + (setq remaining (nreverse remaining)) + (setf (projectile-replace--undo-record-files record) remaining) + (unless remaining + (setq projectile-replace--last-apply nil)) + (message "%s" + (format "[%s] Reverted %d replacement%s of %s in %d file%s%s" + (projectile-project-name + (projectile-replace--undo-record-root record)) + nedits (if (= nedits 1) "" "s") + (projectile-replace--undo-record-term record) + nfiles (if (= nfiles 1) "" "s") + (if remaining + (format " (skipped %d changed file%s)" + (length remaining) + (if (= (length remaining) 1) "" "s")) + "")))))) + ;;; Exporting to a grep-mode buffer for wgrep / grep-edit-mode (defvar projectile--grep-export-buffer-name "*projectile-grep*" @@ -13119,6 +13303,7 @@ Magit that don't trigger `find-file-hook'." (define-key map (kbd "q") #'projectile-switch-open-project) (define-key map (kbd "r") #'projectile-replace) (define-key map (kbd "R") #'projectile-replace-review) + (define-key map (kbd "u") #'projectile-replace-undo) (define-key map (kbd "s s") #'projectile-search) (define-key map (kbd "s g") #'projectile-grep) (define-key map (kbd "s r") #'projectile-ripgrep) @@ -13451,7 +13636,8 @@ search/replace case-sensitive, `--word' makes it match whole words, ("st" "todos" projectile-todos) ("o" "multi-occur" projectile-multi-occur) ("r" "replace" projectile-replace) - ("R" "replace (review)" projectile-dispatch-replace-review)]] + ("R" "replace (review)" projectile-dispatch-replace-review) + ("u" "undo last replace" projectile-replace-undo)]] [["Project" ("p" "switch project" projectile-dispatch-switch-project) ("q" "switch open project" projectile-switch-open-project) @@ -13578,6 +13764,8 @@ search/replace case-sensitive, `--word' makes it match whole words, ["Replace in project" projectile-replace] ["Replace in project (review)" projectile-replace-review] ["Replace regexp in project (review)" projectile-replace-regexp-review] + ["Undo last project-wide replace" projectile-replace-undo + :enable projectile-replace--last-apply] ["Multi-occur in project" projectile-multi-occur] ["Find references in project" projectile-find-references]) ("Run..." diff --git a/test/projectile-replace-review-test.el b/test/projectile-replace-review-test.el index a9b168014..6582f9475 100644 --- a/test/projectile-replace-review-test.el +++ b/test/projectile-replace-review-test.el @@ -621,4 +621,222 @@ REGEXP-P selects `projectile-replace-regexp-review'." (expect msg :to-match "read-only") (expect msg :to-match "MELPA"))))) +(describe "projectile-replace-undo" + (before-each + ;; the record is global; never let one spec's replace leak into the next + (setq projectile-replace--last-apply nil)) + + (after-each + (setq projectile-replace--last-apply nil)) + + (it "errors friendly when there is nothing to undo" + (expect (projectile-replace-undo) :to-throw 'user-error)) + + (it "restores every file byte for byte after a plain replace" + (projectile-test-with-project + (("a.txt" . "foo one foo\n") + ("lib/b.txt" . "start foo end\n") + ("c.txt" . "no hits here\n")) + (projectile-test-use-plain-grep) + (let ((before (list (projectile-test-disk "a.txt") + (projectile-test-disk "lib/b.txt"))) + (buf (projectile-replace-review-test--run "foo" "quux"))) + (projectile-replace-review-test--apply buf) + (expect (projectile-test-disk "a.txt") :to-equal "quux one quux\n") + (projectile-replace-undo) + (expect (projectile-test-disk "a.txt") :to-equal (nth 0 before)) + (expect (projectile-test-disk "lib/b.txt") :to-equal (nth 1 before)) + (expect (projectile-test-disk "c.txt") :to-equal "no hits here\n") + ;; a fully successful undo clears the record + (expect projectile-replace--last-apply :to-be nil)))) + + (it "restores matches whose replacement shifted the ones after them" + (projectile-test-with-project + ;; three hits on one line, replacement longer than the match, so + ;; every recorded position but the first is shifted + (("m.txt" . "xx mid xx tail xx\n")) + (projectile-test-use-plain-grep) + (let ((buf (projectile-replace-review-test--run "xx" "yyyy"))) + (projectile-replace-review-test--apply buf) + (expect (projectile-test-disk "m.txt") + :to-equal "yyyy mid yyyy tail yyyy\n") + (projectile-replace-undo) + (expect (projectile-test-disk "m.txt") + :to-equal "xx mid xx tail xx\n")))) + + (it "restores a regexp replace that expanded capture groups" + (projectile-test-with-project + (("c.txt" . "foo_bar and baz_qux\n")) + (let ((buf (projectile-replace-review-test--run + "\\([a-z]+\\)_\\([a-z]+\\)" "\\2-\\1" 'regexp))) + (projectile-replace-review-test--apply buf) + (expect (projectile-test-disk "c.txt") + :to-equal "bar-foo and qux-baz\n") + (projectile-replace-undo) + (expect (projectile-test-disk "c.txt") + :to-equal "foo_bar and baz_qux\n")))) + + (it "preserves CRLF line endings when reverting on disk" + (projectile-test-with-project + (("crlf.txt" . "foo\r\nbar\r\n")) + (projectile-test-use-plain-grep) + (let ((buf (projectile-replace-review-test--run "foo" "baz"))) + (projectile-replace-review-test--apply buf) + (projectile-replace-undo) + (expect (projectile-test-disk-raw "crlf.txt") + :to-equal "foo\r\nbar\r\n")))) + + (it "refuses to revert a file that changed on disk since the replace" + (projectile-test-with-project + (("keep.txt" . "foo here\n") + ("moved.txt" . "foo there\n")) + (projectile-test-use-plain-grep) + (let ((buf (projectile-replace-review-test--run "foo" "bar"))) + (projectile-replace-review-test--apply buf) + ;; somebody (a branch switch, another editor) rewrites the file + (with-temp-file (expand-file-name "moved.txt") + (insert "PREPENDED\nbar there\n")) + (let ((msgs nil)) + (spy-on 'message :and-call-fake (lambda (fmt &rest args) + (push (apply #'format fmt args) msgs))) + (projectile-replace-undo) + (let ((all (string-join msgs "\n"))) + (expect all :to-match "skipping moved\\.txt") + (expect all :to-match "changed on disk since the replace") + (expect all :to-match "skipped 1 changed file"))) + ;; the untouched file came back; the changed one was left alone + (expect (projectile-test-disk "keep.txt") :to-equal "foo here\n") + (expect (projectile-test-disk "moved.txt") + :to-equal "PREPENDED\nbar there\n") + ;; only the skipped file stays undoable + (expect (mapcar (lambda (e) (file-name-nondirectory (car e))) + (projectile-replace--undo-record-files + projectile-replace--last-apply)) + :to-equal '("moved.txt"))))) + + (it "refuses when the replaced text itself was edited away" + (projectile-test-with-project + (("e.txt" . "foo tail\n")) + (projectile-test-use-plain-grep) + (let ((buf (projectile-replace-review-test--run "foo" "bar"))) + (projectile-replace-review-test--apply buf) + ;; same length, same position - only a byte-exact check catches this + (with-temp-file (expand-file-name "e.txt") + (insert "BAZ tail\n")) + (projectile-replace-undo) + (expect (projectile-test-disk "e.txt") :to-equal "BAZ tail\n")))) + + (it "reverts an open clean buffer and saves it, like applying did" + (projectile-test-with-project + (("open.txt" . "first foo\nlast foo\n")) + (projectile-test-use-plain-grep) + (find-file-noselect (expand-file-name "open.txt")) + (let ((buf (projectile-replace-review-test--run "foo" "bar"))) + (projectile-replace-review-test--apply buf) + (projectile-replace-undo) + (with-current-buffer (get-file-buffer (expand-file-name "open.txt")) + (expect (buffer-string) :to-equal "first foo\nlast foo\n") + (expect (buffer-modified-p) :to-be nil)) + (expect (projectile-test-disk "open.txt") + :to-equal "first foo\nlast foo\n")))) + + (it "reverts inside a modified buffer without writing the file behind it" + (projectile-test-with-project + (("u.txt" . "foo mid\n")) + (projectile-test-use-plain-grep) + (find-file-noselect (expand-file-name "u.txt")) + (with-current-buffer (get-file-buffer (expand-file-name "u.txt")) + (goto-char (point-max)) + (insert "EXTRA\n")) + (let ((buf (projectile-replace-review-test--run "foo" "bar"))) + ;; applying edits the modified buffer but leaves it unsaved + (projectile-replace-review-test--apply buf) + (projectile-replace-undo) + (with-current-buffer (get-file-buffer (expand-file-name "u.txt")) + ;; the user's own unsaved edit survives the undo + (expect (buffer-string) :to-equal "foo mid\nEXTRA\n") + (expect (buffer-modified-p) :to-be-truthy)) + ;; disk was never touched by either the apply or the undo + (expect (projectile-test-disk "u.txt") :to-equal "foo mid\n")))) + + (it "reverts in a buffer opened after the replace instead of on disk" + (projectile-test-with-project + (("late.txt" . "foo tail\n")) + (projectile-test-use-plain-grep) + (let ((buf (projectile-replace-review-test--run "foo" "bar"))) + (projectile-replace-review-test--apply buf) + ;; the file gets opened and edited *after* the match only after apply + (let ((fb (find-file-noselect (expand-file-name "late.txt")))) + (with-current-buffer fb + (goto-char (point-max)) + (insert "UNSAVED\n")) + (projectile-replace-undo) + (with-current-buffer fb + (expect (buffer-string) :to-equal "foo tail\nUNSAVED\n") + (expect (buffer-modified-p) :to-be-truthy)) + (expect (projectile-test-disk "late.txt") :to-equal "bar tail\n"))))) + + (it "does not apply anything twice when undone twice" + (projectile-test-with-project + (("d.txt" . "foo and foo\n")) + (projectile-test-use-plain-grep) + (let ((buf (projectile-replace-review-test--run "foo" "bar"))) + (projectile-replace-review-test--apply buf) + (projectile-replace-undo) + (expect (projectile-test-disk "d.txt") :to-equal "foo and foo\n") + (expect (projectile-replace-undo) :to-throw 'user-error) + (expect (projectile-test-disk "d.txt") :to-equal "foo and foo\n")))) + + (it "keeps refusing a stale file on a repeated undo" + (projectile-test-with-project + (("s.txt" . "foo tail\n")) + (projectile-test-use-plain-grep) + (let ((buf (projectile-replace-review-test--run "foo" "bar"))) + (projectile-replace-review-test--apply buf) + (with-temp-file (expand-file-name "s.txt") + (insert "rewritten\n")) + (projectile-replace-undo) + (projectile-replace-undo) + (expect (projectile-test-disk "s.txt") :to-equal "rewritten\n")))) + + (it "records only what the apply actually wrote" + (projectile-test-with-project + (("ok.txt" . "foo one\n") + ("stale.txt" . "foo two\n")) + (projectile-test-use-plain-grep) + (let ((buf (projectile-replace-review-test--run "foo" "bar"))) + ;; stale.txt moves under the scan, so applying skips it entirely + (with-temp-file (expand-file-name "stale.txt") + (insert "PREPENDED\nfoo two\n")) + (projectile-replace-review-test--apply buf) + (expect (mapcar (lambda (e) (file-name-nondirectory (car e))) + (projectile-replace--undo-record-files + projectile-replace--last-apply)) + :to-equal '("ok.txt")) + (projectile-replace-undo) + (expect (projectile-test-disk "ok.txt") :to-equal "foo one\n") + (expect (projectile-test-disk "stale.txt") + :to-equal "PREPENDED\nfoo two\n")))) + + (it "leaves an earlier undoable replace alone when an apply writes nothing" + (projectile-test-with-project + (("first.txt" . "foo one\n") + ("second.txt" . "zap two\n")) + (projectile-test-use-plain-grep) + (let ((buf (projectile-replace-review-test--run "foo" "bar"))) + (projectile-replace-review-test--apply buf)) + (let ((buf (projectile-replace-review-test--run "zap" "pow"))) + ;; the second replace's only file moves, so it writes nothing at all + (with-temp-file (expand-file-name "second.txt") + (insert "PREPENDED\nzap two\n")) + (projectile-replace-review-test--apply buf)) + (expect (projectile-replace--undo-record-term projectile-replace--last-apply) + :to-equal "foo") + (projectile-replace-undo) + (expect (projectile-test-disk "first.txt") :to-equal "foo one\n"))) + + (it "is reachable from the Projectile command map" + (expect (lookup-key projectile-command-map (kbd "u")) + :to-be #'projectile-replace-undo))) + ;;; projectile-replace-review-test.el ends here