Subject: 30.0.50; ERC 5.6: Reuse query buffers for round-trip nick changes in ERC

From: "J.P." <[email-masked]>
Date: Mon, 13 May 2024 18:00:11 -0700

>From emacs -Q

  1. Start a new session, connecting as nick A
  2. Start another session, connecting as nick B
  3. As nick B, open a query with A, say something, and quit
  4. Start another session, connecting as nick C
  5. As nick C, open a query with A, say something, then issue a /nick B
  6. As B, open a query with A, say something
  7. In A's session, notice B's new query buffer is named B<2>

To put it another way: someone you have a query with disconnects and
reconnects under a new nick, perhaps due to their client appending a
backtick or an underscore. They then engage you in another query before
renicking to their original nick. Weechat and others deal with this by
reusing existing query buffers, but ERC has always added a uniquifying
suffix of the form A<2> to the new, renicked query buffer [1].

While it's true we can't technically be certain revived queries are
being piloted by the same individuals (at least not until ERC becomes
account aware), enough folks have complained about this over the years
that I think it's legitimate to disregard that uncertainty by default.
And though we're not obliged to address this for this release in
particular, I do think it's worth doing so soonish, especially given
that bug#48598 (ERC 5.5) was supposed to do away with these buffer
association issues. The second of the attached patches attempts to get
us closer to making good on that.

Thanks.

[1] Note that in the scenario above, attempting to "merge" buffers B and
    C, like ERC does after changing its own nick, may seem desirable,
    but it's tricky business because timelines may be interleaved rather
    than simply overlap at a single boundary interval. This is one of
    the many things a "backing store" or "replay" module could help
    solve, at least in cases brought about by unwanted backtick
    suffixing. A suitable back end would allow us to perform the merge
    in-store, perhaps ephemerally, and re-ingest it as clean, simulated
    input.

In GNU Emacs 30.0.50 (build 3, x86_64-pc-linux-gnu, GTK+ Version
 3.24.41, cairo version 1.18.0) of 2024-05-08 built on localhost
Repository revision: 36c68e7e34df996bbde4cc82c04ea1619349b64a
Repository branch: master
Windowing system distributor 'The X.Org Foundation', version 11.0.12302004
System Description: Fedora Linux 39 (Workstation Edition)

Configured using:
 'configure --enable-check-lisp-object-type --enable-checking=yes,glyphs
 'CFLAGS=-O0 -g3'
 PKG_CONFIG_PATH=:/usr/lib64/pkgconfig:/usr/share/pkgconfig'

Configured features:
ACL CAIRO DBUS FREETYPE GIF GLIB GMP GNUTLS GPM GSETTINGS HARFBUZZ JPEG
LCMS2 LIBOTF LIBSELINUX LIBSYSTEMD LIBXML2 M17N_FLT MODULES NATIVE_COMP
NOTIFY INOTIFY PDUMPER PNG RSVG SECCOMP SOUND SQLITE3 THREADS TIFF
TOOLKIT_SCROLL_BARS WEBP X11 XDBE XIM XINPUT2 XPM GTK3 ZLIB

Important settings:
  value of $LANG: en_US.UTF-8
  value of $XMODIFIERS: @im=ibus
  locale-coding-system: utf-8-unix

Major mode: Lisp Interaction

Minor modes in effect:
  tooltip-mode: t
  global-eldoc-mode: t
  eldoc-mode: t
  show-paren-mode: t
  electric-indent-mode: t
  mouse-wheel-mode: t
  tool-bar-mode: t
  menu-bar-mode: t
  file-name-shadow-mode: t
  global-font-lock-mode: t
  font-lock-mode: t
  blink-cursor-mode: t
  minibuffer-regexp-mode: t
  line-number-mode: t
  indent-tabs-mode: t
  transient-mark-mode: t
  auto-composition-mode: t
  auto-encryption-mode: t
  auto-compression-mode: t

Load-path shadows:
None found.

Features:
(shadow sort mail-extr emacsbug message mailcap yank-media puny dired
dired-loaddefs rfc822 mml mml-sec epa derived epg rfc6068 epg-config
gnus-util time-date mm-decode mm-bodies mm-encode mail-parse rfc2231
mailabbrev gmm-utils mailheader sendmail rfc2047 rfc2045 ietf-drums
mm-util mail-prsvr mail-utils compile text-property-search comint
ansi-osc ansi-color ring comp-run comp-common rx erc auth-source cl-seq
eieio eieio-core cl-macs icons password-cache json subr-x map
format-spec cl-loaddefs cl-lib erc-backend erc-networks byte-opt gv
bytecomp byte-compile erc-common erc-compat compat erc-loaddefs rmc
iso-transl tooltip cconv eldoc paren electric uniquify ediff-hook
vc-hooks lisp-float-type elisp-mode mwheel term/x-win x-win
term/common-win x-dnd touch-screen tool-bar dnd fontset image regexp-opt
fringe tabulated-list replace newcomment text-mode lisp-mode prog-mode
register page tab-bar menu-bar rfn-eshadow isearch easymenu timer select
scroll-bar mouse jit-lock font-lock syntax font-core term/tty-colors
frame minibuffer nadvice seq simple cl-generic indonesian philippine
cham georgian utf-8-lang misc-lang vietnamese tibetan thai tai-viet lao
korean japanese eucjp-ms cp51932 hebrew greek romanian slovak czech
european ethiopic indian cyrillic chinese composite emoji-zwj charscript
charprop case-table epa-hook jka-cmpr-hook help abbrev obarray oclosure
cl-preloaded button loaddefs theme-loaddefs faces cus-face macroexp
files window text-properties overlay sha1 md5 base64 format env
code-pages mule custom widget keymap hashtable-print-readable backquote
threads dbusbind inotify lcms2 dynamic-setting system-font-setting
font-render-setting cairo gtk x-toolkit xinput2 x multi-tty move-toolbar
make-network-process native-compile emacs)

Memory information:
((conses 16 91614 10878) (symbols 48 9861 0) (strings 32 26671 5011)
 (string-bytes 1 770713) (vectors 16 17713)
 (vector-slots 8 189327 10467) (floats 8 26 13) (intervals 56 323 0)
 (buffers 984 11))

>From 94b5f02dc92b6600f14ab207464558c5de8969d8 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
Date: Wed, 8 May 2024 19:03:58 -0700
Subject: [PATCH 1/2] [5.6] Reconcile erc-stamp--date-stamps when merging
 buffers

* etc/ERC-NEWS: Mention new face `erc-info'.
* lisp/erc/erc-button.el (erc-button-add-buttons): Skip buttonization
when the "msg prop" `erc--skip' is present and contains the symbol
`button'.
* lisp/erc/erc-networks.el (erc--insert-admin-message-before): Forward
declaration.
(erc-networks--insert-transplanted-content)
(erc-networks--transplant-buffer-content): Rename former to latter.
Change signature to take source and destination buffers as parameters.
(erc-networks--transplant-target-buffer-function): New function-valued
variable.
(erc-networks--reclaim-orphaned-target-buffers): Call
`erc-networks--transplant-target-buffer-function' to handle transplant
business.
(erc-networks--copy-over-server-buffer-contents): Pass old and new
buffers to `erc-networks--insert-transplanted-content'.
* lisp/erc/erc-stamp.el
(erc-stamp--defer-date-insertion-on-post-modify): Set `fn' slot of
`erc-stamp--date' instance to `ignore' when running the actual
callback in order to conserve a little space.
(erc-stamp--date-mode): Add and remove hook members for
`erc-networks--copy-server-buffer-functions' and
`erc-networks--transplant-target-buffer-function'.
(erc-insert-timestamp-left-and-right): Always clear
`erc-timestamp-last-inserted-right' to ensure a right stamp
accompanies every date stamp.
(erc-stamp--dedupe-date-stamps)
(erc-stamp--dedupe-date-stamps-from-buffer)
(erc-stamp--dedupe-date-stamps-from-target-buffer): New functions.
Date stamps were revamped as part of bug#60936.
* lisp/erc/erc.el (erc-informational): New face.
(erc--insert-admin-message-before): New function to hide some "msg
prop" complexity from "upstream" libraries, like erc-networks and thus
avoid more forward-declarations.  A less smelly approach would be to
devise a general interface owned by such libraries that `erc-mode'
could then hook into on init.
(erc-display-message-highlight): Make face-matching more limber.
(erc-message-english-grafted): New variable.
* test/lisp/erc/erc-networks-tests.el
(erc-networks--rename-server-buffer--no-existing--orphan)
(erc-networks--rename-server-buffer--existing--reuse)
(erc-networks--rename-server-buffer--local-match)
(erc-networks--rename-server-buffer--local-nomatch): Use helper to
initialize markers.
* test/lisp/erc/erc-stamp-tests.el
(erc-stamp--dedupe-date-stamps): New test.
---
 etc/ERC-NEWS                        |   7 ++
 lisp/erc/erc-button.el              |   4 +-
 lisp/erc/erc-networks.el            |  91 ++++++++++++----------
 lisp/erc/erc-stamp.el               |  60 ++++++++++++++
 lisp/erc/erc.el                     |  22 +++++-
 test/lisp/erc/erc-networks-tests.el |   4 +
 test/lisp/erc/erc-stamp-tests.el    | 116 ++++++++++++++++++++++++++++
 7 files changed, 259 insertions(+), 45 deletions(-)

diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index b66ea6a7a02..4562e8a8117 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -339,6 +339,13 @@ Also available as the library functions 'erc-cmd-AME', 'erc-cmd-GME',
 and 'erc-cmd-GMSG', these new slash commands can prove handy in test
 environments.
 
+** New face 'erc-informational' for local administrative messages.
+Messages that don't originate from a server have historically been
+shown in a combination of 'erc-notice-face' and 'erc-error-face',
+which can be distracting for those of low to moderate importance.
+Such messages now look more subdued and retain the familiar
+'erc-notice-prefix' stars.
+
 ** Miscellaneous UX changes.
 Some minor quality-of-life niceties have finally made their way to
 ERC.  For example, fool visibility has become togglable with the new
diff --git a/lisp/erc/erc-button.el b/lisp/erc/erc-button.el
index 1f9d6fd64c0..8cf8991e57c 100644
--- a/lisp/erc/erc-button.el
+++ b/lisp/erc/erc-button.el
@@ -309,7 +309,9 @@ erc-button-add-buttons
             regexp)
         (erc-button-remove-old-buttons)
         (unless (or erc-button--has-nickname-entry
-                    (not erc-button-buttonize-nicks))
+                    (not erc-button-buttonize-nicks)
+                    (and (erc--memq-msg-prop 'erc--skip 'button)
+                         (not (setq alist nil))))
           (erc-button-add-nickname-buttons
            `(_ _ erc-button--modify-nick-function
                ,erc-button-nickname-callback-function)))
diff --git a/lisp/erc/erc-networks.el b/lisp/erc/erc-networks.el
index 1b26afa1164..9c8cdf3b0e4 100644
--- a/lisp/erc/erc-networks.el
+++ b/lisp/erc/erc-networks.el
@@ -50,6 +50,7 @@ erc-server-connected
 (defvar erc-server-process)
 
 (declare-function erc--get-isupport-entry "erc-backend" (key &optional single))
+(declare-function erc--insert-admin-message-before "erc" (&rest args))
 (declare-function erc-buffer-filter "erc" (predicate &optional proc))
 (declare-function erc-current-nick "erc" nil)
 (declare-function erc-display-error-notice "erc" (parsed string))
@@ -1345,24 +1346,35 @@ erc-unset-network-name
   (setq erc-network nil)
   nil)
 
-;; TODO add note in Commentary saying that this module is considered a
-;; core module and that it's as much about buffer naming and network
-;; identity as anything else.
-
-(defun erc-networks--insert-transplanted-content (content)
-  (let ((inhibit-read-only t)
-        (buffer-undo-list t))
-    (save-excursion
-      (save-restriction
-        (widen)
-        (goto-char (point-min))
-        (insert-before-markers content)))))
+(defun erc-networks--transplant-buffer-content (src dest)
+  "Insert buffer SRC's contents into DEST, above its contents."
+  (with-silent-modifications
+    (let ((content (with-current-buffer src
+                     (cl-assert (not (buffer-narrowed-p)))
+                     (erc--insert-admin-message-before 'grafted ?n dest ?o src)
+                     (buffer-substring (point-min) erc-insert-marker))))
+      (with-current-buffer dest
+        (save-excursion
+          (save-restriction
+            (cl-assert (not (buffer-narrowed-p)))
+            (goto-char (point-min))
+            (while (and (eql ?\n (char-after (point)))
+                        (null (text-properties-at (point))))
+              (delete-char 1))
+            (insert-before-markers content)))))))
+
+(defvar erc-networks--transplant-target-buffer-function
+  #'erc-networks--transplant-buffer-content
+  "Function to rename and merge the contents of two target buffers.
+Called with the donating buffer to be killed and buffer to receive the
+transplant.  Consuming modules can leave a marker at the beginning of
+the latter buffer to access the insertion point, if needing to do things
+like adjust invisibility properties, etc.")
 
 ;; This should run whenever a network identity is updated.
-
 (defun erc-networks--reclaim-orphaned-target-buffers (new-proc nid announced)
   "Visit disowned buffers for same NID and associate with NEW-PROC.
-ANNOUNCED is the server's reported host name."
+Expect ANNOUNCED to be the server's reported host name."
   (erc-buffer-filter
    (lambda ()
      (when (and erc--target
@@ -1372,20 +1384,25 @@ erc-networks--reclaim-orphaned-target-buffers
                     (string= erc-server-announced-name announced)))
        ;; If a target buffer exists for the current process, kill this
        ;; stale one after transplanting its content; else reinstate.
-       (if-let ((existing (erc-get-buffer
-                           (erc--target-string erc--target) new-proc)))
+       (if-let ((actual (erc-get-buffer (erc--target-string erc--target)
+                                        new-proc)))
            (progn
-             (widen)
-             (let ((content (buffer-substring (point-min)
-                                              erc-insert-marker)))
-               (kill-buffer) ; allow target-buf renaming hook to run
-               (with-current-buffer existing
-                 (erc-networks--ensure-unique-target-buffer-name)
-                 (erc-networks--insert-transplanted-content content))))
+             (funcall erc-networks--transplant-target-buffer-function
+                      (current-buffer) actual)
+             (kill-buffer (current-buffer))
+             (with-current-buffer actual
+               (erc-networks--ensure-unique-target-buffer-name)))
          (setq erc-server-process new-proc
                erc-server-connected t
                erc-networks--id nid))))))
 
+;; For existing buffers, `erc-open' reinitializes a core set of local
+;; variables in addition to some text, such as the prompt.  It expects
+;; module activation functions to do the same for assets they manage.
+;; However, "stateful" modules, whose functionality depends on the
+;; evolution of a buffer's content, may need to reconcile state during
+;; a merge.  An example might be a module that provides consistent
+;; timestamps: it should ensure time values don't decrease.
 (defvar erc-networks--copy-server-buffer-functions nil
   "Abnormal hook run in new server buffers when deduping.
 Passed the existing buffer to be killed, whose contents have
@@ -1393,26 +1410,18 @@ erc-networks--copy-server-buffer-functions
 
 (defun erc-networks--copy-over-server-buffer-contents (existing name)
   "Kill off existing server buffer after copying its contents.
-Must be called from the replacement buffer."
+Expect to be called from the replacement buffer."
   (defvar erc-kill-buffer-hook)
   (defvar erc-kill-server-hook)
-  ;; ERC expects `erc-open' to be idempotent when setting up local
-  ;; vars and other context properties for a new identity.  Thus, it's
-  ;; unlikely we'll have to copy anything else over besides text.  And
-  ;; no reconciling of user tables, etc. happens during a normal
-  ;; reconnect, so we should be fine just sticking to text. (Right?)
-  (let ((text (with-current-buffer existing
-                ;; This `erc-networks--id' should be
-                ;; `erc-networks--id-equal-p' to caller's network
-                ;; identity and older if not eq.
-                ;;
-                ;; `erc-server-process' should be set but dead
-                ;; and eq `get-buffer-process' unless latter nil
-                (delete-process erc-server-process)
-                (buffer-substring (point-min) erc-insert-marker)))
-        erc-kill-server-hook
-        erc-kill-buffer-hook)
-    (erc-networks--insert-transplanted-content text)
+  ;; The following observations from ERC 5.5 regarding the buffer
+  ;; `existing' were thought at the time to be invariants:
+  ;; - `erc-networks--id' is `erc-networks--id-equal-p' to the
+  ;;    caller's network identity and older if not `eq'.
+  ;; - `erc-server-process' should be set (local) but dead and `eq' to
+  ;;    the result of `get-buffer-process' unless the latter is nil.
+  (delete-process (buffer-local-value 'erc-server-process existing))
+  (erc-networks--transplant-buffer-content existing (current-buffer))
+  (let (erc-kill-server-hook erc-kill-buffer-hook)
     (run-hook-with-args 'erc-networks--copy-server-buffer-functions existing)
     (kill-buffer name)))
 
diff --git a/lisp/erc/erc-stamp.el b/lisp/erc/erc-stamp.el
index fd137c0548a..8571c836ba2 100644
--- a/lisp/erc/erc-stamp.el
+++ b/lisp/erc/erc-stamp.el
@@ -730,6 +730,7 @@ erc-stamp--defer-date-insertion-on-post-modify
     (fset symbol
           (lambda (&rest _)
             (remove-hook hook-var symbol)
+            (setf (erc-stamp--date-fn data) #'ignore)
             (when (buffer-live-p buffer)
               (with-current-buffer buffer
                 (setq erc-stamp--date-stamps
@@ -773,11 +774,20 @@ erc-stamp--date-mode
   :interactive nil
   (if erc-stamp--date-mode
       (progn
+        (add-function :around
+                      (local 'erc-networks--transplant-target-buffer-function)
+                      #'erc-stamp--dedupe-date-stamps-from-target-buffer)
+        (add-hook 'erc-networks--copy-server-buffer-functions
+                  #'erc-stamp--dedupe-date-stamps-from-buffer 0 t)
         (add-hook 'erc-insert-post-hook
                   #'erc-stamp--defer-date-insertion-on-post-insert 0 t)
         (add-hook 'erc-send-post-hook
                   #'erc-stamp--defer-date-insertion-on-post-send 0 t))
     (kill-local-variable 'erc-timestamp-last-inserted-left)
+    (remove-function (local 'erc-networks--transplant-target-buffer-function)
+                     #'erc-stamp--dedupe-date-stamps-from-target-buffer)
+    (remove-hook 'erc-networks--copy-server-buffer-functions
+                 #'erc-stamp--dedupe-date-stamps-from-buffer t)
     (remove-hook 'erc-insert-post-hook
                  #'erc-stamp--defer-date-insertion-on-post-insert t)
     (remove-hook 'erc-send-post-hook
@@ -841,6 +851,8 @@ erc-insert-timestamp-left-and-right
            ((not (string-equal rendered erc-timestamp-last-inserted-left)))
            ((null (cl-find rendered erc-stamp--date-stamps
                            :test #'string= :key #'erc-stamp--date-str))))
+        ;; Force `erc-insert-timestamp-right' to stamp this message.
+        (setq erc-timestamp-last-inserted-right nil)
         (setq erc-stamp--deferred-date-stamp
               (make-erc-stamp--date :ts ct :str rendered))))
     ;; insert right timestamp
@@ -1040,6 +1052,54 @@ erc-stamp--reset-on-clear
           erc-timestamp-last-inserted-left nil
           erc-timestamp-last-inserted-right nil)))
 
+(defun erc-stamp--dedupe-date-stamps (old-stamps)
+  "Update `erc-stamp--date-stamps' from its counterpart OLD-STAMPS.
+Assume the contents of the buffer for OLD-STAMPS have just been inserted
+above the current buffer's and that the old buffer still exists so that
+markers still point somewhere.  For each duplicate, update the existing
+marker to match the transplanted timestamp with the same date.  Also
+copy non-duplicate `erc-stamp--date' objects from OLD-STAMPS to the
+current buffer's, maintaining order."
+  (let (need)
+    (dolist (old old-stamps)
+      (cl-assert (marker-position (erc-stamp--date-marker old)))
+      (if-let ((new (cl-find (erc-stamp--date-str old) erc-stamp--date-stamps
+                             :test #'string= :key #'erc-stamp--date-str))
+               (new-marker (erc-stamp--date-marker new)))
+          ;; The new buffer now has a duplicate stamp, so remove the
+          ;; "newer" one from the buffer.
+          (progn
+            (erc--delete-inserted-message-naively new-marker)
+            (set-marker new-marker (erc-stamp--date-marker old)))
+        ;; The new buffer doesn't have this stamp, so add its data
+        ;; object to the sorted list.
+        (push old need)
+        ;; Update the old marker position to point to the new buffer.
+        (set-marker (erc-stamp--date-marker old)
+                    (erc-stamp--date-marker old))))
+    ;; These *should* already be sorted (right?).
+    (setq erc-stamp--date-stamps
+          (nconc (nreverse need) erc-stamp--date-stamps))
+    (let ((last 0))
+      (dolist (stamp erc-stamp--date-stamps)
+        (cl-assert (eq (current-buffer)
+                       (marker-buffer (erc-stamp--date-marker stamp))))
+        (cl-assert (< last (erc-stamp--date-marker stamp)))
+        (setq last (erc-stamp--date-marker stamp))))))
+
+(defun erc-stamp--dedupe-date-stamps-from-buffer (old-buffer)
+  "Merge date stamps from OLD-BUFFER into in the current buffer."
+  (let ((old-stamps (buffer-local-value 'erc-stamp--date-stamps old-buffer)))
+    (erc-stamp--dedupe-date-stamps old-stamps)))
+
+(defun erc-stamp--dedupe-date-stamps-from-target-buffer (orig old-buffer
+                                                              new-buffer)
+  "Merge date stamps from OLD-BUFFER into NEW-BUFFER after calling ORIG."
+  (let ((old-stamps (buffer-local-value 'erc-stamp--date-stamps old-buffer)))
+    (prog1 (funcall orig old-buffer new-buffer)
+      (with-current-buffer new-buffer
+        (erc-stamp--dedupe-date-stamps old-stamps)))))
+
 (provide 'erc-stamp)
 
 ;;; erc-stamp.el ends here
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index c92fd42322a..7bc480c8a92 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1513,6 +1513,10 @@ erc-error-face
   "ERC face for errors."
   :group 'erc-faces)
 
+(defface erc-informational '((t :inherit shadow))
+  "Face for local administrative messages of low to moderate importance."
+  :group 'erc-faces)
+
 ;; same default color as `erc-input-face'
 (defface erc-my-nick-face '((t :weight bold :foreground "brown"))
   "ERC face for your current nickname in messages sent by you.
@@ -3518,6 +3522,14 @@ erc-display-line
         (push '(erc--msg . notice) erc--msg-prop-overrides)))
     (erc-display-message nil nil buffer string)))
 
+(defun erc--insert-admin-message-before (msg &rest args)
+  "Call `erc-display-message' with MSG and ARGS, inserting before point.
+Inhibit all buttonizing."
+  (let ((erc--msg-prop-overrides `((erc--skip . (stamp track button))
+                                   ,@erc--msg-prop-overrides)))
+    (apply #'erc-display-message nil '(notice informational)
+           (current-buffer) msg args)))
+
 (defvar erc--merge-text-properties-p nil
   "Non-nil when `erc-put-text-property' defers to `erc--merge-prop'.")
 
@@ -3724,9 +3736,12 @@ erc-display-message-highlight
         (t
          (erc-put-text-property
           0 (length string)
-          'font-lock-face (or (intern-soft
-			       (concat "erc-" (symbol-name type) "-face"))
-                              'erc-default-face)
+          'font-lock-face
+          (let* ((name (symbol-name type))
+                 (symbol (or (intern-soft (concat "erc-" name "-face"))
+                             (intern-soft (concat "erc-" name))
+                             type)))
+            (or (and (facep symbol) symbol) 'erc-default-face))
           string)
          string)))
 
@@ -9426,6 +9441,7 @@ english
    (finished . "\n\n*** ERC finished ***\n")
    (terminated . "\n\n*** ERC terminated: %e\n")
    (login . "Logging in as `%n'...")
+   (grafted . "Grafted buffer `%n' onto `%o'")
    (nick-in-use . "%n is in use. Choose new nickname: ")
    (nick-too-long
     . "WARNING: Nick length (%i) exceeds max NICKLEN(%l) defined by server")
diff --git a/test/lisp/erc/erc-networks-tests.el b/test/lisp/erc/erc-networks-tests.el
index 0d8861f2167..90d6f13f2f6 100644
--- a/test/lisp/erc/erc-networks-tests.el
+++ b/test/lisp/erc/erc-networks-tests.el
@@ -1243,6 +1243,7 @@ erc-networks--rename-server-buffer--no-existing--orphan
 
   (with-current-buffer (get-buffer-create "irc.foonet.org")
     (erc-mode)
+    (erc--initialize-markers (point) nil)
     (setq erc-network 'FooNet
           erc-server-current-nick "tester"
           erc-server-process (erc-networks-tests--create-live-proc)
@@ -1282,6 +1283,7 @@ erc-networks--rename-server-buffer--existing--reuse
     (ert-info ("New buffer steals name, content")
       (with-current-buffer (get-buffer-create "irc.foonet.org")
         (erc-mode)
+        (erc--initialize-markers (point) nil)
         (setq erc-network 'FooNet
               erc-server-current-nick "tester"
               erc-server-process (erc-networks-tests--create-live-proc)
@@ -1522,6 +1524,7 @@ erc-networks--rename-server-buffer--local-match
     (ert-info ("New server buffer steals name, content")
       (with-current-buffer (get-buffer-create "irc.foonet.org")
         (erc-mode)
+        (erc--initialize-markers (point) nil)
         (setq erc-network 'FooNet
               erc-server-current-nick "tester"
               erc-server-announced-name "us-east.foonet.org"
@@ -1574,6 +1577,7 @@ erc-networks--rename-server-buffer--local-nomatch
     (ert-info ("New server buffer steals name, content")
       (with-current-buffer (get-buffer-create "irc.foonet.org")
         (erc-mode)
+        (erc--initialize-markers (point) nil)
         (setq erc-network 'FooNet
               erc-server-current-nick "tester"
               erc-server-announced-name "us-east.foonet.org" ; east
diff --git a/test/lisp/erc/erc-stamp-tests.el b/test/lisp/erc/erc-stamp-tests.el
index 5fee21ec28f..f4209c5d668 100644
--- a/test/lisp/erc/erc-stamp-tests.el
+++ b/test/lisp/erc/erc-stamp-tests.el
@@ -349,4 +349,120 @@ erc--get-inserted-msg-bounds/readonly/stamp
    (lambda (arg)
      (should (equal '(3 . 19) (erc--get-inserted-msg-bounds arg))))))
 
+(ert-deftest erc-stamp--dedupe-date-stamps-from-target-buffer ()
+  (let ((erc-modules erc-modules)
+        (erc-stamp--tz t))
+    (erc-tests-common-make-server-buf)
+    (erc-stamp-mode +1)
+
+    ;; Create two buffers with an overlapping date stamp.
+    (with-current-buffer (erc--open-target "#chan@old")
+      (let ((erc-stamp--current-time '(1690761600001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer)
+                                          "2023-07-31T00:00:00.001Z"))
+      (let ((erc-stamp--current-time '(1690761601001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer) "0.0"))
+
+      (let ((erc-stamp--current-time '(1690848000001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer)
+                                          "2023-08-01T00:00:00.001Z"))
+      (let ((erc-stamp--current-time '(1690848001001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer) "1.0"))
+      (let ((erc-stamp--current-time '(1690848060001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer) "1.1"))
+
+      (let ((erc-stamp--current-time '(1690934400001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer)
+                                          "2023-08-02T00:00:00.001Z"))
+      (let ((erc-stamp--current-time '(1690934401001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer) "2.0"))
+      (let ((erc-stamp--current-time '(1690956000001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer) "2.6")))
+
+    (with-current-buffer (erc--open-target "#chan@new")
+      (let ((erc-stamp--current-time '(1690956001001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer)
+                                          "2023-08-02T06:00:01.001Z"))
+      (let ((erc-stamp--current-time '(1690963200001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer) "2.8"))
+
+      (let ((erc-stamp--current-time '(1691020800001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer)
+                                          "2023-08-03T00:00:00.001Z"))
+      (let ((erc-stamp--current-time '(1691020801001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer) "3.0"))
+      (let ((erc-stamp--current-time '(1691053200001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer) "3.9"))
+
+      (let ((erc-stamp--current-time '(1691107200001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer)
+                                          "2023-08-04T00:00:00.001Z"))
+      (let ((erc-stamp--current-time '(1691107201001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer) "4.0"))
+      (let ((erc-stamp--current-time '(1691110800001 . 1000)))
+        (erc-tests-common-display-message nil 'notice (current-buffer) "4.1")))
+
+    (erc-stamp--dedupe-date-stamps-from-target-buffer
+     #'erc-networks--transplant-buffer-content
+     (get-buffer "#chan@old")
+     (get-buffer "#chan@new"))
+
+    ;; Ensure the "model", `erc-stamp--date-stamps', matches reality
+    ;; in the buffer's contents.
+    (with-current-buffer "#chan@new"
+      (let ((stamps erc-stamp--date-stamps))
+        (goto-char 3)
+        (should (looking-at (rx "\n[Mon Jul 31 2023]")))
+        (should (= (erc--get-inserted-msg-beg (point))
+                   (erc-stamp--date-marker (pop stamps))))
+        (goto-char (1+ (match-end 0)))
+        (should (looking-at (rx "*** 2023-07-31T00:00:00.001Z")))
+        (forward-line 1)
+        (should (looking-at (rx "*** 0.0")))
+        (forward-line 1)
+
+        (should (looking-at (rx "\n[Tue Aug  1 2023]")))
+        (should (= (erc--get-inserted-msg-beg (point))
+                   (erc-stamp--date-marker (pop stamps))))
+        (goto-char (1+ (match-end 0)))
+        (should (looking-at (rx "*** 2023-08-01T00:00:00.001Z")))
+        (forward-line 1)
+        (should (looking-at (rx "*** 1.0")))
+        (forward-line 1)
+        (should (looking-at (rx "*** 1.1")))
+        (forward-line 1)
+
+        (should (looking-at (rx "\n[Wed Aug  2 2023]")))
+        (should (= (erc--get-inserted-msg-beg (point))
+                   (erc-stamp--date-marker (pop stamps))))
+        (goto-char (1+ (match-end 0)))
+        (should (looking-at (rx "*** 2023-08-02T00:00:00.001Z")))
+        (forward-line 1)
+        (should (looking-at (rx "*** 2.0")))
+        (forward-line 1)
+        (should (looking-at (rx "*** 2.6")))
+        (forward-line 1)
+        (should (looking-at
+                 (rx "*** Grafted buffer `#chan@new' onto `#chan@old'")))
+        (forward-line 1)
+        (should (looking-at (rx "*** 2023-08-02T06:00:01.001Z")))
+        (forward-line 1)
+        (should (looking-at (rx "*** 2.8")))
+        (forward-line 1)
+
+        (should (looking-at (rx "\n[Thu Aug  3 2023]")))
+        (should (= (erc--get-inserted-msg-beg (point))
+                   (erc-stamp--date-marker (pop stamps))))
+        (goto-char (1+ (match-end 0)))
+        (should (looking-at (rx "*** 2023-08-03T00:00:00.001Z")))
+        (forward-line 3) ; ...
+
+        (should (looking-at (rx "\n[Fri Aug  4 2023]")))
+        (should (= (erc--get-inserted-msg-beg (point))
+                   (erc-stamp--date-marker (pop stamps))))
+        (should-not stamps))))
+
+  (when noninteractive
+    (erc-tests-common-kill-buffers)))
+
 ;;; erc-stamp-tests.el ends here
-- 
2.44.0

>From 8e7ebca1ea1e23d4751cfa41e7856d68985a345d Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
Date: Wed, 8 May 2024 19:04:13 -0700
Subject: [PATCH 2/2] [5.6] Reuse old query buffers for round-trip renicks in
 ERC

* lisp/erc/erc-backend.el
(erc--wrangle-query-buffers-on-nick-change): New function for handling
the buffer-renaming and message-routing business that formerly resided
in `erc-server-NICK.  It stands apart so that modules overriding
`erc-server-NICK' can make use of it.  It now favors reusing an
existing query buffer when possible instead of creating a new,
<N>-suffixed buffer.  This addresses a missing aspect of the mission
originally pursued by bug#48598.
(erc-server-NICK): Fix erroneous call to `erc-update-user-nick' that
passed the sender's login as the INFO argument.  Factor out buffer
renaming logic and move to `erc--wrangle-query-buffers-on-nick-change'
for use by "NICK" handlers controlled by modules.
* lisp/erc/erc.el (erc-generate-new-buffer-name): Fix typo in doc
string.
(erc--open-target): Ensure a user for the client's current nick exists
in the target buffer's new `erc-channel-members' table so that query
buffers will be found when printing "QUIT" messages and similar.
* test/lisp/erc/erc-scenarios-base-renick.el
(erc-scenarios-base-renick-queries-solo): Revise slightly to use
updated helpers.
(erc-scenarios-base-renick-queries/round-trip/default): New test.
(erc-scenarios-base-renick-queries/round-trip/merge-query): New test.
* test/lisp/erc/resources/base/renick/queries/roundtrip.eld: New file.
* test/lisp/erc/resources/base/renick/self/manual.eld: Update timeouts.
* test/lisp/erc/resources/base/renick/self/merge-query-a.eld: New file.
* test/lisp/erc/resources/base/renick/self/merge-query-b.eld: New file.
---
 lisp/erc/erc-backend.el                       |  43 ++--
 lisp/erc/erc.el                               |  32 +--
 test/lisp/erc/erc-scenarios-base-renick.el    | 192 ++++++++++++++++--
 .../base/renick/queries/roundtrip.eld         |  64 ++++++
 .../erc/resources/base/renick/self/manual.eld |   8 +-
 .../base/renick/self/merge-query-a.eld        |  46 +++++
 .../base/renick/self/merge-query-b.eld        |  48 +++++
 7 files changed, 384 insertions(+), 49 deletions(-)
 create mode 100644 test/lisp/erc/resources/base/renick/queries/roundtrip.eld
 create mode 100644 test/lisp/erc/resources/base/renick/self/merge-query-a.eld
 create mode 100644 test/lisp/erc/resources/base/renick/self/merge-query-b.eld

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index ab419d2b018..55e67884da0 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -1829,6 +1829,35 @@ erc--server-determine-join-display-context
                                  ?h host ?t tgt ?m mode)))
       (erc-banlist-update proc parsed))))
 
+(defun erc--wrangle-query-buffers-on-nick-change (old new buffers)
+  "Create or reuse a query buffer for NEW nick after considering OLD nick.
+Return a (possibly updated) list of BUFFERS in which to announce the
+change."
+  (let ((new-buffer (erc-get-buffer new erc-server-process))
+        (old-buffer (erc-get-buffer old erc-server-process))
+        (selfp (string= old (erc-current-nick))))
+    (when new-buffer
+      (push new-buffer buffers))
+    (when old-buffer
+      (cl-pushnew old-buffer buffers)
+      ;; Ensure the new nick is absent from the old query.
+      (unless selfp
+        (erc-remove-channel-member old-buffer old))
+      (when (or selfp (null new-buffer))
+        (let ((target (erc--target-from-string new))
+              (id (erc-networks--id-given erc-networks--id)))
+          (with-current-buffer old-buffer
+            (setq erc-default-recipients (cons new
+                                               (cdr erc-default-recipients))
+                  erc--target target))
+          (setq new-buffer (erc-get-buffer-create erc-session-server
+                                                  erc-session-port
+                                                  nil target id)))))
+    (when new-buffer
+      (with-current-buffer new-buffer
+        (erc-update-mode-line)))
+    buffers))
+
 (define-erc-response-handler (NICK)
   "Handle nick change messages." nil
   (let ((nn (erc-response.contents parsed))
@@ -1843,18 +1872,8 @@ erc--server-determine-join-display-context
       ;; erc-channel-users won't contain it
       ;;
       ;; Possibly still relevant: bug#12002
-      (when-let ((buf (erc-get-buffer nick erc-server-process))
-                 (tgt (erc--target-from-string nn)))
-        (with-current-buffer buf
-          (setq erc-default-recipients (cons nn (cdr erc-default-recipients))
-                erc--target tgt))
-        (with-current-buffer (erc-get-buffer-create erc-session-server
-                                                    erc-session-port nil tgt
-                                                    (erc-networks--id-given
-                                                     erc-networks--id))
-          ;; Current buffer is among bufs
-          (erc-update-mode-line)))
-      (erc-update-user-nick nick nn host nil nil login)
+      (setq bufs (erc--wrangle-query-buffers-on-nick-change nick nn bufs))
+      (erc-update-user-nick nick nn host login)
       (cond
        ((string= nick (erc-current-nick))
         (cl-pushnew (erc-server-buffer) bufs)
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 7bc480c8a92..49debef7154 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1982,7 +1982,7 @@ erc-generate-new-buffer-name
 `erc-server-connect-function's.
 
 When TGT-INFO is non-nil, expect its string field to match the redundant
-param TARGET (retained for compatibility).  Whenever possibly, prefer
+param TARGET (retained for compatibility).  Whenever possible, prefer
 returning TGT-INFO's string unmodified.  But when a case-insensitive
 collision prevents that, return target@ID when ID is non-nil or
 target@network otherwise after renaming the conflicting buffer in the
@@ -5894,19 +5894,23 @@ erc-debug-missing-hooks
   nil)
 
 (defun erc--open-target (target)
-  "Open an ERC buffer on TARGET."
-  (erc-open erc-session-server
-            erc-session-port
-            (erc-current-nick)
-            erc-session-user-full-name
-            nil
-            nil
-            (list target)
-            target
-            erc-server-process
-            nil
-            erc-session-username
-            (erc-networks--id-given erc-networks--id)))
+  "Open an ERC buffer on TARGET and return the buffer.
+Ensure own nick is present in the buffer's `erc-channel-members'."
+  (let ((buffer (erc-open erc-session-server
+                          erc-session-port
+                          (erc-current-nick)
+                          erc-session-user-full-name
+                          nil
+                          nil
+                          (list target)
+                          target
+                          erc-server-process
+                          nil
+                          erc-session-username
+                          (erc-networks--id-given erc-networks--id))))
+    (prog1 buffer
+      (when (erc-query-buffer-p buffer)
+        (erc-update-channel-member target (erc-current-nick) nil t)))))
 
 (defun erc-query (target server-buffer)
   "Open a query buffer on TARGET using SERVER-BUFFER.
diff --git a/test/lisp/erc/erc-scenarios-base-renick.el b/test/lisp/erc/erc-scenarios-base-renick.el
index 3001fde6da0..290230259cb 100644
--- a/test/lisp/erc/erc-scenarios-base-renick.el
+++ b/test/lisp/erc/erc-scenarios-base-renick.el
@@ -160,6 +160,7 @@ erc-scenarios-base-renick-queries-solo
        (erc-server-flood-penalty 0.1)
        (erc-server-flood-margin 20)
        (dumb-server (erc-d-run "localhost" t 'solo))
+       (expect (erc-d-t-make-expecter))
        (port (process-contact dumb-server :service))
        erc-autojoin-channels-alist
        erc-server-buffer-foo)
@@ -175,33 +176,186 @@ erc-scenarios-base-renick-queries-solo
 
     (erc-d-t-wait-for 10 (get-buffer "foonet"))
 
-    (ert-info ("Joined by bouncer to #foo, pal persent")
+    (ert-info ("Joined by bouncer to #foo, pal Lal is present")
       (with-current-buffer (erc-d-t-wait-for 1 (get-buffer "#foo"))
-        (erc-d-t-search-for 5 "On Thursday")
+        (funcall expect 10 "<bob> alice: On Thursday")
         (erc-scenarios-common-say "hi")))
 
-    (erc-d-t-wait-for 10 "Query buffer appears with message from pal"
-      (get-buffer "Lal"))
-
-    (ert-info ("Chat with pal, who changes name")
-      (with-current-buffer "Lal"
-        (erc-d-t-search-for 3 "hello")
+    (ert-info ("Query buffer appears from Lal, who renicks")
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "Lal"))
+        (funcall expect 10 "<Lal> hello")
         (erc-scenarios-common-say "hi")
-        (erc-d-t-search-for 10 "is now known as Linguo")
-        (should-not (search-forward "is now known as Linguo" nil t))))
-
-    (erc-d-t-wait-for 1 (get-buffer "Linguo"))
-    (should-not (get-buffer "Lal"))
-
-    (with-current-buffer "Linguo" (erc-scenarios-common-say "howdy Linguo"))
+        (funcall expect 10 "is now known as Linguo")
+        ;; No duplicate message.
+        (funcall expect -0.1 "is now known as Linguo")
+        ;; No duplicate buffer.
+        (erc-d-t-wait-for 1 (equal (buffer-name) "Linguo"))
+        (should-not (get-buffer "Lal"))
+        (erc-scenarios-common-say "howdy Linguo")))
 
     (with-current-buffer "#foo"
-      (erc-d-t-search-for 10 "is now known as Linguo")
-      (should-not (search-forward "is now known as Linguo" nil t))
-      (erc-cmd-PART ""))
+      (funcall expect 10 "is now known as Linguo")
+      (funcall expect -0.1 "is now known as Linguo")
+      (erc-scenarios-common-say "/part"))
 
     (with-current-buffer "Linguo"
-      (erc-d-t-search-for 10 "get along"))))
+      (funcall expect 10 "get along"))))
+
+;; Someone you have a query with disconnects and reconnects under a
+;; new nick (perhaps due to their client appending a backtick or
+;; underscore).  They then engage you in another query before
+;; renicking to their original nick.  Prior to 5.5, ERC would add a
+;; uniquifying suffix of the form bob<2> to the new, post-renick
+;; query.  ERC 5.6+ acts differently.  It mimics popular standalone
+;; clients in reusing existing query buffers.
+(ert-deftest erc-scenarios-base-renick-queries/round-trip/default ()
+  :tags '(:expensive-test)
+
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "base/renick/queries")
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'roundtrip))
+       (port (process-contact dumb-server :service))
+       (expect (erc-d-t-make-expecter))
+       (erc-autojoin-channels-alist '((foonet "#chan"))))
+
+    (ert-info ("Connect to foonet")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "tester")
+        (funcall expect 10 "This server is in debug mode")))
+
+    (ert-info ("User dummy opens a query with you")
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "dummy"))
+        (funcall expect 10 "hi")))
+
+    (ert-info ("User dummy quits, reconnects as user warwick")
+      (with-current-buffer "#chan"
+        (funcall expect 10 "has quit")
+        (should-not (erc-get-channel-member "dummy"))
+        (with-current-buffer "dummy"
+          (should-not (erc-get-channel-member "dummy")))
+        (funcall expect 10 "<bob> Alas! sir")
+        (funcall expect 10 "<bob> warwick, welcome")
+        (funcall expect 10 "<warwick> hola")))
+
+    (ert-info ("User warwick queries you, creating a new buffer")
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "warwick"))
+        (should (get-buffer "dummy")) ; not reused
+        (funcall expect 10 "<warwick> howdy")
+        (funcall expect 10 "is now known as dummy")
+        (should-not (erc-get-channel-member "warwick"))
+        (should-not (erc-get-channel-member "dummy"))))
+
+    (ert-info ("User warwick renicks as user dummy")
+      (with-current-buffer "#chan"
+        (funcall expect 10 "is now known as dummy")
+        (should-not (erc-get-channel-member "warwick"))))
+
+    (with-current-buffer "dummy"
+      (should-not (get-buffer "dummy<2>"))
+      (funcall expect 10 "has quit" (point-min))
+      (funcall expect -0.1 "merging buffer")
+      (funcall expect 10 "is now known as dummy")
+      (should (erc-get-channel-member "dummy"))
+      (funcall expect 10 "<dummy> hey"))
+
+    (with-current-buffer "#chan"
+      (funcall expect 10 "<alice> bob: Than those that"))))
+
+;; This test asserts behavior for the other side of the conversation
+;; described by `erc-scenarios-base-renick-queries/round-trip/default'
+;; above.  After speaking with someone in a query, you disconnect and
+;; reconnect under a new nick.  You then open a new query with the
+;; same person before changing your nick back to the previous one.
+;; The buffers for the two session should then be merged with the help
+;; of `erc-networks--transplant-target-buffer-function' and
+;; `erc-networks--copy-server-buffer-functions'.
+(ert-deftest erc-scenarios-base-renick-self/merge-query ()
+  :tags '(:expensive-test)
+
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "base/renick/self")
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'merge-query-a 'merge-query-b))
+       (port (process-contact dumb-server :service))
+       (expect (erc-d-t-make-expecter))
+       (erc-autojoin-channels-alist '((foonet "#chan"))))
+
+    (ert-info ("Connect to foonet as tester")
+      (with-current-buffer (erc :server "127.0.0.1" :port port :nick "tester")
+        (funcall expect 10 "This server is in debug mode")))
+
+    (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "#chan"))
+      (funcall expect 10 "<alice> bob: Speak to the people")
+      (erc-scenarios-common-say "/query observer"))
+
+    (with-current-buffer "observer"
+      (erc-scenarios-common-say "hi")
+      (funcall expect 10 "<observer> hi?"))
+
+    (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "#chan"))
+      (erc-scenarios-common-say "/quit"))
+
+    (with-current-buffer "foonet"
+      (funcall expect 10 "*** ERC finished ***"))
+
+    (ert-info ("Reconnect to foonet as dummy")
+      (with-current-buffer (erc :server "127.0.0.1" :port port :nick "dummy")
+        (funcall expect 10 "This server is in debug mode")))
+
+    (with-current-buffer
+        (erc-d-t-wait-for 10 (get-buffer "#chan@foonet/dummy"))
+      ;; Uniquification has been performed.
+      (should-not (get-buffer "#chan"))
+      (should (get-buffer "#chan@foonet/tester"))
+      (should-not (get-buffer "foonet"))
+      (should (get-buffer "foonet/tester"))
+      (should (get-buffer "foonet/dummy"))
+      (funcall expect 10 "<alice> bob: Pray you")
+      (erc-scenarios-common-say "/query observer"))
+
+    (with-current-buffer "observer@foonet/dummy"
+      (should-not (get-buffer "observer"))
+      (should (get-buffer "observer@foonet/tester"))
+      (erc-scenarios-common-say "hola")
+      (funcall expect 10 "<observer> whodis?"))
+
+    (with-current-buffer
+        (erc-d-t-wait-for 10 (get-buffer "#chan@foonet/dummy"))
+      (erc-scenarios-common-say "/nick tester"))
+
+    ;; All buffers have been merged.
+    (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "observer"))
+      (should-not (get-buffer "observer@foonet/dummy"))
+      (should-not (get-buffer "observer@foonet/tester"))
+      ;; Goto last message from previous session.
+      (funcall expect 10 "has quit" (point-min))
+      (funcall expect -0.01 "\n\n[") ; duplicate date stamp removed
+      (funcall expect 1 (concat "*** Grafted buffer `observer@foonet/dummy'"
+                                " onto `observer@foonet/tester'"))
+      (funcall expect 1 "<dummy> hola")
+      (funcall expect 1 "<observer> whodis?")
+      (funcall expect 1 "*** Your new nickname is tester"))
+
+    (with-current-buffer "foonet"
+      (should-not (get-buffer "foonet/dummy"))
+      (should-not (get-buffer "foonet/tester"))
+      ;; Goto last assertion.
+      (funcall expect 10 "*** ERC finished ***" (point-min))
+      (funcall expect -0.01 "\n\n[") ; duplicate date stamp removed
+      (funcall expect 10 "Grafted buffer `foonet/dummy' onto `foonet/tester'"))
+
+    (with-current-buffer "#chan"
+      (should-not (get-buffer "#chan@foonet/dummy"))
+      (should-not (get-buffer "#chan@foonet/tester"))
+      (funcall expect 10 "has quit" (point-min))
+      (funcall expect -0.01 "\n\n[") ; duplicate date stamp removed
+      (funcall expect 1 (concat "*** Grafted buffer `#chan@foonet/dummy'"
+                                " onto `#chan@foonet/tester'"))
+      (funcall expect 1 "You have joined channel #chan")
+      (funcall expect 1 "<bob> alice: Have here bereft")
+      (funcall expect 1 "*** Your new nickname is tester"))))
 
 ;; You share a channel and a query buffer with a user on two different
 ;; networks (through a proxy).  The user changes their nick on both
diff --git a/test/lisp/erc/resources/base/renick/queries/roundtrip.eld b/test/lisp/erc/resources/base/renick/queries/roundtrip.eld
new file mode 100644
index 00000000000..50764a143b6
--- /dev/null
+++ b/test/lisp/erc/resources/base/renick/queries/roundtrip.eld
@@ -0,0 +1,64 @@
+;; -*- mode: lisp-data; -*-
+((nick 10 "NICK tester"))
+((user 10 "USER user 0 * :unknown")
+ (0.00 ":irc.foonet.org 001 tester :Welcome to the foonet IRC Network tester")
+ (0.00 ":irc.foonet.org 002 tester :Your host is irc.foonet.org, running version ergo-v2.11.1")
+ (0.00 ":irc.foonet.org 003 tester :This server was created Thu, 09 May 2024 05:19:24 UTC")
+ (0.00 ":irc.foonet.org 004 tester irc.foonet.org ergo-v2.11.1 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.00 ":irc.foonet.org 005 tester AWAYLEN=390 BOT=B CASEMAPPING=ascii CHANLIMIT=#:100 CHANMODES=Ibe,k,fl,CEMRUimnstu CHANNELLEN=64 CHANTYPES=# CHATHISTORY=25 ELIST=U EXCEPTS EXTBAN=,m FORWARD=f INVEX :are supported by this server")
+ (0.00 ":irc.foonet.org 005 tester KICKLEN=390 MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8ONLY WHOX :are supported by this server")
+ (0.01 ":irc.foonet.org 005 tester draft/CHATHISTORY=25 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 tester :There are 0 users and 6 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 tester 0 :IRC Operators online")
+ (0.00 ":irc.foonet.org 253 tester 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 tester 2 :channels formed")
+ (0.00 ":irc.foonet.org 255 tester :I have 6 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 tester 6 6 :Current local users 6, max 6")
+ (0.00 ":irc.foonet.org 266 tester 6 6 :Current global users 6, max 6")
+ (0.00 ":irc.foonet.org 422 tester :MOTD File is missing"))
+
+((mode-user 10 "MODE tester +i")
+ (0.00 ":irc.foonet.org 221 tester +i")
+ (0.00 ":irc.foonet.org NOTICE tester :This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect."))
+
+((join 10 "JOIN #chan")
+ (0.03 ":irc.foonet.org 221 tester +i") ; dupe
+ (0.00 ":[email-masked] JOIN #chan")
+ (0.04 ":irc.foonet.org 353 tester = #chan :@fsbot bob alice dummy tester")
+ (0.00 ":irc.foonet.org 366 tester #chan :End of NAMES list")
+ (0.00 ":[email-masked] PRIVMSG #chan :tester, welcome!")
+ (0.00 ":[email-masked] PRIVMSG #chan :tester, welcome!")
+ (0.03 ":[email-masked] PRIVMSG #chan :That eye that told you so look'd but a-squint."))
+
+((mode-chan 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester #chan +Cnt")
+ (0.01 ":irc.foonet.org 329 tester #chan 1715231970")
+
+ ;; existing query with dummy
+ (0.05 ":[email-masked] PRIVMSG tester :hi")
+ (0.02 ":[email-masked] PRIVMSG #chan :alice: Villains, forbear! we are the empress' sons.")
+ (0.01 ":[email-masked] PRIVMSG #chan :bob: This matter of marrying his king's daughter,wherein he must be weighed rather by her value than his own,words him, I doubt not, a great deal from the matter.")
+
+ ;; dummy quits
+ (0.07 ":[email-masked] QUIT :Quit: \2ERC\2 5.5.0.29.1 (IRC client for GNU Emacs 29.3.50)")
+ (0.03 ":[email-masked] PRIVMSG #chan :We will afflict the emperor in his pride.")
+ (0.03 ":[email-masked] PRIVMSG #chan :bob: Why, then, is my pump well flowered.")
+ (0.05 ":[email-masked] PRIVMSG #chan :Alas! sir, I know not Jupiter; I never drank with him in all my life.")
+
+ ;; rejoins as warwick
+ (0.03 ":[email-masked] JOIN #chan")
+ (0.00 ":[email-masked] PRIVMSG #chan :warwick, welcome!")
+ (0.00 ":[email-masked] PRIVMSG #chan :warwick, welcome!")
+ (0.03 ":[email-masked] PRIVMSG #chan :hola")
+ (0.03 ":[email-masked] PRIVMSG #chan :bob: And stint thou too, I pray thee, nurse, say I.")
+
+ ;; Makes contact in a query
+ (0.02 ":[email-masked] PRIVMSG tester :howdy")
+ (0.03 ":[email-masked] PRIVMSG #chan :bob: Nor more willingly leaves winter; such summer-birds are men. Gentlemen, our dinner will not recompense this long stay: feast your ears with the music awhile, if they will fare so harshly o' the trumpet's sound; we shall to 't presently.")
+ (0.03 ":[email-masked] PRIVMSG #chan :If it please your honour, I know not well what they are; but precise villains they are, that I am sure of, and void of all profanation in the world that good Christians ought to have.")
+
+ ;; warwick renicks back to dummy
+ (0.08 ":[email-masked] NICK dummy")
+ (0.04 ":[email-masked] PRIVMSG #chan :Pleasure and action make the hours seem short.")
+ (0.01 ":[email-masked] PRIVMSG tester :hey")
+ (0.02 ":[email-masked] PRIVMSG #chan :bob: Than those that have more cunning to be strange."))
diff --git a/test/lisp/erc/resources/base/renick/self/manual.eld b/test/lisp/erc/resources/base/renick/self/manual.eld
index dd107b806d5..a6220ffc2e6 100644
--- a/test/lisp/erc/resources/base/renick/self/manual.eld
+++ b/test/lisp/erc/resources/base/renick/self/manual.eld
@@ -1,5 +1,5 @@
 ;; -*- mode: lisp-data; -*-
-((pass 1 "PASS :foonet:changeme"))
+((pass 10 "PASS :foonet:changeme"))
 ((nick 1 "NICK tester"))
 ((user 1 "USER user 0 * :tester")
  (0 ":irc.foonet.org 001 tester :Welcome to the FooNet Internet Relay Chat Network tester")
@@ -24,7 +24,7 @@
  (0 ":irc.foonet.org 372 tester :- Please visit us in #libera for questions and support.")
  (0 ":irc.foonet.org 376 tester :End of /MOTD command."))
 
-((mode-user 1.2 "MODE tester +i")
+((mode-user 10 "MODE tester +i")
  (0 ":[email-masked] MODE tester :+RZi")
  (0 ":irc.znc.in 306 tester :You have been marked as being away")
  (0 ":[email-masked] JOIN #foo")
@@ -38,13 +38,13 @@
  (0 ":irc.foonet.org NOTICE tester :[09:56:57] This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect.")
  (0 ":irc.foonet.org 305 tester :You are no longer marked as being away"))
 
-((mode 1 "MODE #foo")
+((mode-foo 10 "MODE #foo")
  (0 ":irc.foonet.org 324 tester #foo +nt")
  (0 ":irc.foonet.org 329 tester #foo 1622454985")
  (0.1 ":[email-masked] PRIVMSG #foo :bob: Farewell, pretty lady: you must hold the credit of your father.")
  (0.1 ":[email-masked] PRIVMSG #foo :alice: On Thursday, sir ? the time is very short."))
 
-((nick 2 "NICK dummy")
+((nick 10 "NICK dummy")
  (0 ":[email-masked] NICK :dummy")
  (0.1 ":[email-masked] MODE dummy :+RZi")
  (0.1 ":[email-masked] PRIVMSG #foo :dummy: Hi."))
diff --git a/test/lisp/erc/resources/base/renick/self/merge-query-a.eld b/test/lisp/erc/resources/base/renick/self/merge-query-a.eld
new file mode 100644
index 00000000000..27ef7ecd2ff
--- /dev/null
+++ b/test/lisp/erc/resources/base/renick/self/merge-query-a.eld
@@ -0,0 +1,46 @@
+;; -*- mode: lisp-data; -*-
+((nick 10 "NICK tester"))
+((user 10 "USER user 0 * :unknown")
+ (0.00 ":irc.foonet.org 001 tester :Welcome to the foonet IRC Network tester")
+ (0.01 ":irc.foonet.org 002 tester :Your host is irc.foonet.org, running version ergo-v2.11.1")
+ (0.00 ":irc.foonet.org 003 tester :This server was created Sun, 12 May 2024 00:41:10 UTC")
+ (0.00 ":irc.foonet.org 004 tester irc.foonet.org ergo-v2.11.1 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.00 ":irc.foonet.org 005 tester AWAYLEN=390 BOT=B CASEMAPPING=ascii CHANLIMIT=#:100 CHANMODES=Ibe,k,fl,CEMRUimnstu CHANNELLEN=64 CHANTYPES=# CHATHISTORY=25 ELIST=U EXCEPTS EXTBAN=,m FORWARD=f INVEX :are supported by this server")
+ (0.01 ":irc.foonet.org 005 tester KICKLEN=390 MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8ONLY WHOX :are supported by this server")
+ (0.01 ":irc.foonet.org 005 tester draft/CHATHISTORY=25 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 tester :There are 0 users and 6 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 tester 0 :IRC Operators online")
+ (0.02 ":irc.foonet.org 253 tester 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 tester 2 :channels formed")
+ (0.01 ":irc.foonet.org 255 tester :I have 6 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 tester 6 6 :Current local users 6, max 6")
+ (0.00 ":irc.foonet.org 266 tester 6 6 :Current global users 6, max 6")
+ (0.00 ":irc.foonet.org 422 tester :MOTD File is missing")
+ (0.00 ":irc.foonet.org 221 tester +i")
+ (0.00 ":irc.foonet.org NOTICE tester :This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect."))
+
+((mode-user 10 "MODE tester +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":irc.foonet.org 221 tester +i")
+ (0.00 ":[email-masked] JOIN #chan")
+ (0.02 ":irc.foonet.org 353 tester = #chan :someone tester @fsbot alice bob observer")
+ (0.01 ":irc.foonet.org 366 tester #chan :End of NAMES list")
+ (0.00 ":[email-masked] PRIVMSG #chan :tester, welcome!")
+ (0.01 ":[email-masked] PRIVMSG #chan :tester, welcome!"))
+
+((mode-chan 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester #chan +Cnt")
+ (0.02 ":irc.foonet.org 329 tester #chan 1715474476")
+ (0.09 ":[email-masked] PRIVMSG #chan :alice: And, uncle, so will I, an if I live.")
+ (0.03 ":[email-masked] PRIVMSG #chan :bob: Speak to the people, and they pity her."))
+
+((privmsg-observer 10 "PRIVMSG observer :hi")
+ (0.04 ":[email-masked] PRIVMSG tester :hi?")
+ (0.07 ":[email-masked] PRIVMSG #chan :To ask of whence you are: report it."))
+
+((quit 10 "QUIT :\2ERC\2")
+ (0.03 ":[email-masked] QUIT :Quit: \2ERC\2 5.6-git (IRC client for GNU Emacs 30.0.50)")
+ (0.03 "ERROR :Quit: \2ERC\2 5.6-git (IRC client for GNU Emacs 30.0.50)"))
+
+((drop 0 DROP))
diff --git a/test/lisp/erc/resources/base/renick/self/merge-query-b.eld b/test/lisp/erc/resources/base/renick/self/merge-query-b.eld
new file mode 100644
index 00000000000..4d7581b3884
--- /dev/null
+++ b/test/lisp/erc/resources/base/renick/self/merge-query-b.eld
@@ -0,0 +1,48 @@
+;; -*- mode: lisp-data; -*-
+((nick 10 "NICK dummy"))
+((user 10 "USER user 0 * :unknown")
+ (0.01 ":irc.foonet.org 001 dummy :Welcome to the foonet IRC Network dummy")
+ (0.01 ":irc.foonet.org 002 dummy :Your host is irc.foonet.org, running version ergo-v2.11.1")
+ (0.01 ":irc.foonet.org 003 dummy :This server was created Sun, 12 May 2024 00:41:10 UTC")
+ (0.00 ":irc.foonet.org 004 dummy irc.foonet.org ergo-v2.11.1 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.03 ":irc.foonet.org 005 dummy AWAYLEN=390 BOT=B CASEMAPPING=ascii CHANLIMIT=#:100 CHANMODES=Ibe,k,fl,CEMRUimnstu CHANNELLEN=64 CHANTYPES=# CHATHISTORY=25 ELIST=U EXCEPTS EXTBAN=,m FORWARD=f INVEX :are supported by this server")
+ (0.03 ":irc.foonet.org 005 dummy KICKLEN=390 MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8ONLY WHOX :are supported by this server")
+ (0.01 ":irc.foonet.org 005 dummy draft/CHATHISTORY=25 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 dummy :There are 0 users and 6 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 dummy 0 :IRC Operators online")
+ (0.00 ":irc.foonet.org 253 dummy 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 dummy 2 :channels formed")
+ (0.00 ":irc.foonet.org 255 dummy :I have 6 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 dummy 6 6 :Current local users 6, max 6")
+ (0.00 ":irc.foonet.org 266 dummy 6 6 :Current global users 6, max 6")
+ (0.03 ":irc.foonet.org 422 dummy :MOTD File is missing")
+ (0.00 ":irc.foonet.org 221 dummy +i")
+ (0.00 ":irc.foonet.org NOTICE dummy :This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect."))
+
+((mode-user 10 "MODE dummy +i"))
+
+((join-chan 10 "JOIN #chan")
+ (0.01 ":irc.foonet.org 221 dummy +i")
+ (0.00 ":[email-masked] JOIN #chan")
+ (0.02 ":irc.foonet.org 353 dummy = #chan :@fsbot alice bob observer someone dummy")
+ (0.01 ":irc.foonet.org 366 dummy #chan :End of NAMES list")
+ (0.00 ":[email-masked] PRIVMSG #chan :dummy, welcome!")
+ (0.01 ":[email-masked] PRIVMSG #chan :dummy, welcome!"))
+
+((mode-chan 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 dummy #chan +Cnt")
+ (0.02 ":irc.foonet.org 329 dummy #chan 1715474476")
+ (0.09 ":[email-masked] PRIVMSG #chan :alice: Indeed, sir, he that sleeps feels not the toothache; but a man that were to sleep your sleep, and a hangman to help him to bed, I think he would change places with his officer; for look you, sir, you know not which way you shall go.")
+ (0.03 ":[email-masked] PRIVMSG #chan :bob: Pray you, sir, deliver me this paper."))
+
+((privmsg-observer 10 "PRIVMSG observer :hola")
+ (0.01 ":[email-masked] PRIVMSG #chan :alice: In manner and form following, sir; all those three: I was seen with her in the manor-house, sitting with her upon the form, and taken following her into the park; which, put together, is, in manner and form following. Now, sir, for the manner,it is the manner of a man to speak to a woman, for the form,in some form.")
+ (0.05 ":[email-masked] PRIVMSG #chan :In Isbel's case and mine own. Service is no heritage; and I think I shall never have the blessing of God till I have issue o' my body, for they say barnes are blessings.")
+ (0.01 ":[email-masked] PRIVMSG dummy :whodis?")
+ (0.02 ":[email-masked] PRIVMSG #chan :alice: Have here bereft my brother of his life."))
+
+((nick-tester 10 "NICK tester")
+ (0.02 ":[email-masked] NICK tester")
+
+ (0.04 ":[email-masked] PRIVMSG #chan :bob: You have too courtly a wit for me: I'll rest.")
+ (0.07 ":[email-masked] PRIVMSG #chan :alice: And abstinence engenders maladies."))
-- 
2.44.0