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