Subject: bug#70792: 30.0.50; [PATCH] Add Eshell support for expanding absolute file names within the current remote connection

From: Jim Porter <[email-masked]>
Date: Sun, 5 May 2024 13:58:55 -0700

One oddity of Eshell is that even when you're connected to a remote host 
(usually you just "cd" into a remote directory using Tramp syntax), 
absolute file names are still on your *local* host when running any Lisp 
commands. However, running *external* commands (programs on the remote 
host), absolute file names are on that remote host.

When you think about how it's implemented, this makes sense: Lisp 
commands always run in the local Emacs process, but external programs 
run on the remote. So naturally, "absolute" file names are relative to a 
different host in either case. This wouldn't be so bad except that it's 
not always obvious when you're running a Lisp command or not. Eshell 
provides Lisp implementations of some common commands, like "cat", but 
it also transparently falls back to the external program if it doesn't 
understand some option. This results in it being pretty hard to tell 
what's going to happen when you run a command.

There's an "elecslash" module for Eshell that helps with this, but it 
can't tell when you have a Lisp command that will actually fallback to 
the external program when you run it.

Instead, the attached patch provides a new way to handle this: if you 
enable 'eshell-connection-local-file-names', then "normal" absolute file 
names like "/foo/bar" or "~/user" are evaluated relative to the current 
remote connection (if any). Eshell does this by expanding the file name 
to a full remote name like "/ssh:remote:/foo/bar". If these strings get 
sent to an external program, Eshell will unexpand them back to a 
host-local name (it will make sure that the remote host is correct, too).

You can also keep Eshell from performing this expansion on a 
case-by-case basis by quoting the file name (like "this", 'this', or 
/:this) or escaping the leading / or ~.

From dd5a423c1d9f2c033e44abe5dae3f5a3a312802b Mon Sep 17 00:00:00 2001
From: Jim Porter <[email-masked]>
Date: Sat, 2 Sep 2023 22:29:22 -0700
Subject: [PATCH 1/2] Mark all backslash-escaped characters in Eshell as
 'escaped'

* lisp/eshell/esh-arg.el (eshell-parse-backslash): Mark all
backslash-escaped characters with the 'escaped' property, even if
they're non-special.

* test/lisp/eshell/esh-arg-tests.el
(esh-arg-test/escape/backslash-nonspecial)
(esh-arg-test/escape/backslash-nonspecial-unicode)
(esh-arg-test/escape/backslash-special)
(esh-arg-test/escape/backslash-newline)
(esh-arg-test/escape/backslash-newline-conditional)
(esh-arg-test/escape-quoted/backslash-nonspecial)
(esh-arg-test/escape-quoted/backslash-special)
(esh-arg-test/escape-quoted/backslash-newline): Rename tests, and
check string properties.
(esh-arg-test/escape-quoted/basic)
(esh-arg-test/escape-single-quoted/basic)
(esh-arg-test/escape-single-quoted/single-quote): New tests.
---
 lisp/eshell/esh-arg.el            |   7 +-
 test/lisp/eshell/esh-arg-tests.el | 124 +++++++++++++++++-------------
 2 files changed, 74 insertions(+), 57 deletions(-)

diff --git a/lisp/eshell/esh-arg.el b/lisp/eshell/esh-arg.el
index 78cf28d785a..b62f0893e89 100644
--- a/lisp/eshell/esh-arg.el
+++ b/lisp/eshell/esh-arg.el
@@ -477,15 +477,14 @@ eshell-parse-backslash
        ;; multiple lines.
        ((eq (char-before) ?\n)
         'eshell-empty-token)
-       ((memq (char-before) special-chars)
-        (list 'eshell-escape-arg (char-to-string (char-before))))
        ;; If the char is in a quote, backslash only has special
        ;; meaning if it is escaping a special char.  Otherwise, the
        ;; result is the literal string "\c".
-       (eshell-current-quoted
+       ((and eshell-current-quoted
+             (not (memq (char-before) special-chars)))
         (concat "\\" (char-to-string (char-before))))
        (t
-        (char-to-string (char-before)))))))
+        (list 'eshell-escape-arg (char-to-string (char-before))))))))
 
 (defun eshell-parse-literal-quote ()
   "Parse a literally quoted string.  Nothing has special meaning!"
diff --git a/test/lisp/eshell/esh-arg-tests.el b/test/lisp/eshell/esh-arg-tests.el
index b748c5ab4c0..8c139cee589 100644
--- a/test/lisp/eshell/esh-arg-tests.el
+++ b/test/lisp/eshell/esh-arg-tests.el
@@ -36,42 +36,40 @@ eshell-test-value
 
 ;;; Tests:
 
-(ert-deftest esh-arg-test/escape/nonspecial ()
-  "Test that \"\\c\" and \"c\" are equivalent when \"c\" is not a
-special character."
-  (with-temp-eshell
-   (eshell-match-command-output "echo he\\llo"
-                                "hello\n")))
-
-(ert-deftest esh-arg-test/escape/nonspecial-unicode ()
-  "Test that \"\\c\" and \"c\" are equivalent when \"c\" is a
-unicode character (unicode characters are nonspecial by
-definition)."
-  (with-temp-eshell
-   (eshell-match-command-output "echo Vid\\éos"
-                                "Vidéos\n")))
-
-(ert-deftest esh-arg-test/escape/special ()
-  "Test that the backslash is not preserved for escaped special
-chars."
-  (with-temp-eshell
-   (eshell-match-command-output "echo he\\\\llo"
-                                ;; Backslashes are doubled for regexp.
-                                "he\\\\llo\n")))
-
-(ert-deftest esh-arg-test/escape/newline ()
-  "Test that an escaped newline is equivalent to the empty string."
-  (with-temp-eshell
-   (eshell-match-command-output "echo hi\\\nthere"
-                                "hithere\n")))
-
-(ert-deftest esh-arg-test/escape/trailing-newline ()
-  "Test that an escaped newline is equivalent to the empty string."
+(ert-deftest esh-arg-test/escape/backslash-nonspecial ()
+  "Test that \"\\c\" expands to \"c\" when \"c\" is not a special character.
+It should mark \"c\" as being escaped, though."
+  (should (equal-including-properties
+           (eshell-test-command-result "echo he\\llo")
+           #("hello" 2 3 (escaped t)))))
+
+(ert-deftest esh-arg-test/escape/backslash-nonspecial-unicode ()
+  "Test that \"\\c\" expands to \"c\" when \"c\" is a Unicode character.
+Unicode characters are nonspecial by definition.  As above, this
+would mark \"c\" as escaped."
+  (should (equal-including-properties
+           (eshell-test-command-result "echo Vid\\éos")
+           #("Vidéos" 3 4 (escaped t)))))
+
+(ert-deftest esh-arg-test/escape/backslash-special ()
+  "Test that the backslash is removed for escaped special characters."
+  (should (equal-including-properties
+           (eshell-test-command-result "echo he\\\\llo")
+           #("he\\llo" 2 3 (escaped t)))))
+
+(ert-deftest esh-arg-test/escape/backslash-newline ()
+  "Test that an escaped newline expands to the empty string."
+  (should (equal-including-properties
+           (eshell-test-command-result "echo hi\\\nthere")
+           "hithere")))
+
+(ert-deftest esh-arg-test/escape/trailing-backslash-newline ()
+  "Test that an escaped newline expands to the empty string."
   (with-temp-eshell
    (eshell-match-command-output "echo hi\\\n"
                                 "hi\n")))
 
-(ert-deftest esh-arg-test/escape/newline-conditional ()
+(ert-deftest esh-arg-test/escape/backslash-newline-conditional ()
   "Test invocation of an if/else statement using line continuations."
   (let ((eshell-test-value t))
     (eshell-command-result-equal
@@ -82,27 +80,47 @@ esh-arg-test/escape/newline-conditional
      "if $eshell-test-value \\\n{echo yes} \\\n{echo no}"
      "no")))
 
-(ert-deftest esh-arg-test/escape-quoted/nonspecial ()
-  "Test that the backslash is preserved for escaped nonspecial
-chars."
-  (with-temp-eshell
-   (eshell-match-command-output "echo \"h\\i\""
-                                ;; Backslashes are doubled for regexp.
-                                "h\\\\i\n")))
-
-(ert-deftest esh-arg-test/escape-quoted/special ()
-  "Test that the backslash is not preserved for escaped special
-chars."
-  (with-temp-eshell
-   (eshell-match-command-output "echo \"\\\"hi\\\\\""
-                                ;; Backslashes are doubled for regexp.
-                                "\\\"hi\\\\\n")))
-
-(ert-deftest esh-arg-test/escape-quoted/newline ()
-  "Test that an escaped newline is equivalent to the empty string."
-  (with-temp-eshell
-   (eshell-match-command-output "echo \"hi\\\nthere\""
-                                "hithere\n")))
+(ert-deftest esh-arg-test/escape-quoted/basic ()
+  "Test that double-quoted text is marked as escaped."
+  (should (equal-including-properties
+           (eshell-test-command-result "echo \"hi\"")
+           #("hi" 0 2 (escaped t))))
+  (should (equal-including-properties
+           (eshell-test-command-result "echo \"hi\"there")
+           #("hithere" 0 2 (escaped t)))))
+
+(ert-deftest esh-arg-test/escape-quoted/backslash-nonspecial ()
+  "Test that in double-quotes, \"\\\" is preserved before nonspecial chars."
+  (should (equal-including-properties
+           (eshell-test-command-result "echo \"h\\i\"")
+           #("h\\i" 0 3 (escaped t)))))
+
+(ert-deftest esh-arg-test/escape-quoted/backslash-special ()
+  "Test that in double-quotes, \"\\\" is not preserved before special chars."
+  (should (equal-including-properties
+           (eshell-test-command-result "echo \"\\\"hi\\\\\"")
+           #("\"hi\\" 0 4 (escaped t)))))
+
+(ert-deftest esh-arg-test/escape-quoted/backslash-newline ()
+  "Test that in double-quotes, an escaped newline expands to the empty string."
+  (should (equal-including-properties
+           (eshell-test-command-result "echo \"hi\\\nthere\"")
+           #("hithere" 0 7 (escaped t)))))
+
+(ert-deftest esh-arg-test/escape-single-quoted/basic ()
+  "Test that single-quoted text is marked as escaped."
+  (should (equal-including-properties
+           (eshell-test-command-result "echo 'hi'")
+           #("hi" 0 2 (escaped t))))
+  (should (equal-including-properties
+           (eshell-test-command-result "echo 'hi'there")
+           #("hithere" 0 2 (escaped t)))))
+
+(ert-deftest esh-arg-test/escape-single-quoted/single-quote ()
+  "Test that a doubled single-quote inside single-quotes is one single-quote."
+  (should (equal-including-properties
+           (eshell-test-command-result "echo 'it''s me'")
+           #("it's me" 0 7 (escaped t)))))
 
 (ert-deftest esh-arg-test/special-reference/default ()
   "Test that \"#<buf>\" refers to the buffer \"buf\"."
-- 
2.25.1

From 9675e111f774c6e8748cf7226d970d16dbfa7f1f Mon Sep 17 00:00:00 2001
From: Jim Porter <[email-masked]>
Date: Sat, 9 Sep 2023 15:41:53 -0700
Subject: [PATCH 2/2] Let Eshell expand absolute file names via the current
 remote connection

* lisp/eshell/em-dirs.el (eshell-connection-local-file-names): New
option.
(eshell-parse-absolute-file): New function...
(eshell-dirs-initialize): ... use it.
(eshell-expand-absolute-file): New function...
(eshell-parse-user-reference): ... use it.
(eshell-file-external-name): New function.

* lisp/eshell/esh-ext.el (eshell--file-external-names): New function...
(eshell-external-command): ... use it.

* test/lisp/eshell/em-dirs-tests.el (tramp): Require.
(em-dirs-test/expand-user-reference/remote)
(em-dirs-test/expand-absolute-file/local)
(em-dirs-test/expand-absolute-file/remote)
(em-dirs-test/expand-absolute-file/quoted): New tests.

* test/lisp/eshell/esh-ext-tests.el (em-dirs): Require.
(em-ext-test/unexpand-remote-file/local)
(em-ext-test/unexpand-absolute-file/remote): New tests.

* test/lisp/eshell/eshell-tests-helpers.el
(eshell-command-result-equal): New argument IGNORE-ERRORS.

* doc/misc/eshell.texi (Remote Access): Document
'eshell-connection-local-file-names'.

* etc/NEWS: Announce this change.
---
 doc/misc/eshell.texi                     | 37 ++++++++++++--
 etc/NEWS                                 | 25 +++++----
 lisp/eshell/em-dirs.el                   | 64 ++++++++++++++++++++++++
 lisp/eshell/esh-ext.el                   | 16 +++++-
 test/lisp/eshell/em-dirs-tests.el        | 48 ++++++++++++++++++
 test/lisp/eshell/esh-ext-tests.el        | 43 ++++++++++++++++
 test/lisp/eshell/eshell-tests-helpers.el | 15 +++---
 7 files changed, 229 insertions(+), 19 deletions(-)

diff --git a/doc/misc/eshell.texi b/doc/misc/eshell.texi
index 30c85da795b..bbd570923f7 100644
--- a/doc/misc/eshell.texi
+++ b/doc/misc/eshell.texi
@@ -1527,9 +1527,40 @@ Remote Access
 built-in commands or Lisp functions from a remote directory, you must
 be careful about specifying absolute file names: @samp{cat
 /var/output.log} will always print the contents of your @emph{local}
-@file{/var/output.log}, even from a remote directory.  If you find
-this behavior annoying, you can enable the optional electric forward
-slash module (@pxref{Electric forward slash}).
+@file{/var/output.log}, even from a remote directory.
+
+@vindex eshell-connection-local-file-names
+If you find this behavior annoying, you can customize Eshell to help you
+refer to remote files when in a remote directory.  By setting
+@code{eshell-connection-local-file-names} to @code{t}, you can make
+Eshell expand absolute file names like @file{/var/output.log} or
+@file{~user} within the remote connection assocated with the current
+directory:
+
+@example
+/ssh:user@@remote:~ $ concat "log is at " /var/output.log
+log is at /ssh:user@@remote:/var/output.log
+@end example
+
+When using this option, you can avoid this expansion by
+quoting or escaping the initial @samp{/} or @samp{~}, or by explicitly
+typing the connection.  To explicitly refer to a @emph{local} file, you
+can quote the file name:
+
+@example
+/ssh:user@@remote:/etc $ cd ~
+/ssh:user@@remote:~ $ cd /:~
+~ $
+@end example
+
+When this option is enabled, Eshell will also ``unexpand'' any remote
+file names (e.g.@: @samp{/ssh:user@@remote:file.txt}) or before sending
+them to external commands, so those commands only see @file{file.txt} as
+they expect.
+
+If you instead prefer to keep the default logic but make it easier to
+type the full remote file names, you can enable the optional electric
+forward slash module (@pxref{Electric forward slash}).
 
 @vindex eshell-explicit-remote-commands
 When running commands, you can also make them explicitly remote by
diff --git a/etc/NEWS b/etc/NEWS
index 456f9b8f8b8..4c4b807b4f3 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -854,6 +854,22 @@ using this new option.  (Or set 'display-buffer-alist' directly.)
 
 ** Eshell
 
++++
+*** Eshell commands can now be explicitly-remote (or local).
+By prefixing a command name in Eshell with a remote identifier, like
+"/ssh:user@remote:whoami", you can now run commands on a particular
+host no matter your current directory.  Likewise, you can run a
+command on your local system no matter your current directory via
+"/:whoami".  For more information, see the "(eshell) Remote Access"
+node in the Eshell manual.
+
++++
+*** New option to expand absolute file names via the current remote connection.
+By setting 'eshell-connection-local-file-names' to a non-nil value,
+Eshell will expand absolute file names like "/foo/bar" or "~/user"
+within the current remote connection.  See "(eshell) Remote Access" for
+more details.
+
 +++
 *** New builtin Eshell command 'compile'.
 This command runs another command, sending its output to a compilation
@@ -909,15 +925,6 @@ or get a sublist of elements 2 through 4 with '$my-list[2..5]'.  For
 more information, see the "(eshell) Dollars Expansion" node in the
 Eshell manual.
 
-+++
-*** Eshell commands can now be explicitly-remote (or local).
-By prefixing a command name in Eshell with a remote identifier, like
-"/ssh:user@remote:whoami", you can now run commands on a particular
-host no matter your current directory.  Likewise, you can run a
-command on your local system no matter your current directory via
-"/:whoami".  For more information, see the "(eshell) Remote Access"
-node in the Eshell manual.
-
 +++
 *** Eshell's '$UID' and '$GID' variables are now connection-aware.
 Now, when expanding '$UID' or '$GID' in a remote directory, the value
diff --git a/lisp/eshell/em-dirs.el b/lisp/eshell/em-dirs.el
index 07063afc286..8642dee1855 100644
--- a/lisp/eshell/em-dirs.el
+++ b/lisp/eshell/em-dirs.el
@@ -65,6 +65,18 @@ eshell-dirs-load-hook
   :version "24.1"			; removed eshell-dirs-initialize
   :type 'hook)
 
+(defcustom eshell-connection-local-file-names nil
+  "If non-nil, expand absolute file name within the current remote connection.
+When this option is enabled, Eshell expands absolute file names like
+\"/foo/bar\" or \"~user\" within the remote connection associated with
+the current working directory.
+
+Additionally, when this option is enabled, Eshell will
+\"unexpand\" remote or quoted file names before passing them to
+external commands (see `eshell-file-external-name')."
+  :version "30.1"
+  :type 'boolean)
+
 (defcustom eshell-pwd-convert-function (if (eshell-under-windows-p)
 					   #'expand-file-name
 					 #'identity)
@@ -205,6 +217,9 @@ eshell-dirs-initialize
 
   (add-hook 'eshell-parse-argument-hook
 	    #'eshell-parse-user-reference nil t)
+  (when eshell-connection-local-file-names
+    (add-hook 'eshell-parse-argument-hook
+	      #'eshell-parse-absolute-file nil t))
   (if (eshell-under-windows-p)
       (add-hook 'eshell-parse-argument-hook
 		#'eshell-parse-drive-letter nil t))
@@ -267,9 +282,58 @@ eshell-parse-user-reference
     ;; Apply this modifier fairly early so it happens before things
     ;; like glob expansion.
     (add-hook 'eshell-current-modifiers #'eshell-expand-user-reference -50)
+    (when eshell-connection-local-file-names
+      (add-hook 'eshell-current-modifiers #'eshell-expand-absolute-file -40))
+    (forward-char)
+    (char-to-string (char-before))))
+
+(defun eshell-expand-absolute-file (file)
+  "Expand an absolute FILE like \"/foo/bar\" to be on the current remote host.
+This treats quoted file names like \"/:/foo/bar\" literally."
+  (if (or (file-name-quoted-p file t)
+          (file-remote-p file)
+          ;; Don't expand virtual targets; otherwise, we'd fail to
+          ;; find them later in `eshell-get-target'.
+          (assoc file eshell-virtual-targets))
+      file
+    (concat (file-remote-p default-directory) file)))
+
+(defun eshell-parse-absolute-file ()
+  "An argument beginning with / is a filename to be expanded."
+  (when (and (not eshell-current-argument)
+             (not eshell-current-quoted)
+             (eq (char-after) ?/))
+    (add-hook 'eshell-current-modifiers #'eshell-expand-absolute-file -50)
     (forward-char)
     (char-to-string (char-before))))
 
+(defun eshell-file-external-name (file)
+  "Simplify FILE so that external commands can understand it.
+This returns the unquoted local part of the file name.  If the
+remote connection associated with FILE doesn't match
+`default-directory', signal an error.
+
+When FILE doesn't start with a \"/\", or the leading \"/\" is
+escaped, just return FILE as-is."
+  (if (or (not (eq (aref file 0) ?/))
+          (get-text-property 0 'escaped file))
+      file
+    ;; Check that the file and cwd connections are the same.
+    (let ((file-connection (file-remote-p file))
+          (cwd-connection (file-remote-p default-directory)))
+      (cond
+       ((equal file-connection cwd-connection)) ; It's ok!
+       ((not file-connection)
+        (error "`%s' is local, but current directory is remote (`%s')"
+               file cwd-connection))
+       ((not cwd-connection)
+        (error "`%s' is remote, but current directory is local"
+               file))
+       (t
+        (error "`%s' does not match current connection `%s'"
+               file cwd-connection))))
+    (file-name-unquote (file-local-name file) t)))
+
 (defun eshell-parse-drive-letter ()
   "An argument beginning with X:[^/] is a drive letter reference."
   (when (and (not eshell-current-argument)
diff --git a/lisp/eshell/esh-ext.el b/lisp/eshell/esh-ext.el
index 44861c222b8..635114bcea4 100644
--- a/lisp/eshell/esh-ext.el
+++ b/lisp/eshell/esh-ext.el
@@ -229,9 +229,23 @@ eshell-remote-command
       (error "%s: not a remote command" command))
     (eshell-external-command command-localname args)))
 
+(defun eshell--file-external-names (args)
+  "Simplify ARGS so that external commands can understand any file names.
+If `eshell-connection-local-file-names' is nil or the `eshell-dirs'
+module is disabled, just return ARGS unchanged."
+  (declare-function eshell-file-external-name "em-dirs" (file))
+  (defvar eshell-connection-local-file-names)
+  (if (and (eshell-using-module 'eshell-dirs)
+           (bound-and-true-p eshell-connection-local-file-names))
+      (mapcar (lambda (arg)
+                (if (stringp arg) (eshell-file-external-name arg) arg))
+              args)
+    args))
+
 (defun eshell-external-command (command args)
   "Insert output from an external COMMAND, using ARGS."
-  (setq args (eshell-stringify-list (flatten-tree args)))
+  (setq args (eshell-stringify-list
+              (eshell--file-external-names (flatten-tree args))))
   (let ((interp (eshell-find-interpreter
 		 command
 		 args
diff --git a/test/lisp/eshell/em-dirs-tests.el b/test/lisp/eshell/em-dirs-tests.el
index 9789e519f4c..328cec2994d 100644
--- a/test/lisp/eshell/em-dirs-tests.el
+++ b/test/lisp/eshell/em-dirs-tests.el
@@ -23,6 +23,7 @@
 
 ;;; Code:
 
+(require 'tramp)
 (require 'ert)
 (require 'esh-mode)
 (require 'eshell)
@@ -112,12 +113,59 @@ em-dirs-test/expand-user-reference/local
    (format "echo ~%s" user-login-name)
    (expand-file-name (format "~%s" user-login-name))))
 
+(ert-deftest em-dirs-test/expand-user-reference/remote ()
+  "Test expansion of \"~USER\" references in remote directories."
+  (skip-unless (eshell-tests-remote-accessible-p))
+  (let* ((default-directory ert-remote-temporary-file-directory)
+         (remote (file-remote-p default-directory)))
+    (let ((eshell-connection-local-file-names t))
+      (eshell-command-result-equal
+       "echo ~"
+       (concat remote (expand-file-name "~")))
+      (eshell-command-result-equal
+       (format "echo ~%s" user-login-name)
+       (concat remote (expand-file-name (format "~%s" user-login-name)))))
+    (let ((eshell-connection-local-file-names nil))
+      (eshell-command-result-equal "echo ~" (expand-file-name "~"))
+      (eshell-command-result-equal
+       (format "echo ~%s" user-login-name)
+       (expand-file-name (format "~%s" user-login-name))))))
+
 (ert-deftest em-dirs-test/expand-user-reference/quoted ()
   "Test that a quoted \"~\" isn't expanded."
   (eshell-command-result-equal "echo \\~" "~")
   (eshell-command-result-equal "echo \"~\"" "~")
   (eshell-command-result-equal "echo '~'" "~"))
 
+(ert-deftest em-dirs-test/expand-absolute-file/local ()
+  "Test \"expansion\" of absolute files in local directories.
+This should always be a no-op."
+  (let ((eshell-connection-local-file-names t))
+    (eshell-command-result-equal "echo /bin/foo" "/bin/foo"))
+  (let ((eshell-connection-local-file-names nil))
+    (eshell-command-result-equal "echo /bin/foo" "/bin/foo")))
+
+(ert-deftest em-dirs-test/expand-absolute-file/remote ()
+  "Test expansion of absolute files in remote directories.
+This should be a file name on the remote host when
+`eshell-connection-local-file-names' is non-nil."
+  (skip-unless (eshell-tests-remote-accessible-p))
+  (let* ((default-directory ert-remote-temporary-file-directory)
+         (remote (file-remote-p default-directory)))
+    (let ((eshell-connection-local-file-names t))
+      (eshell-command-result-equal "echo /bin/foo" (concat remote "/bin/foo")))
+    (let ((eshell-connection-local-file-names nil))
+      (eshell-command-result-equal "echo /bin/foo" "/bin/foo"))))
+
+(ert-deftest em-dirs-test/expand-absolute-file/quoted ()
+  "Test that a quoted \"/\" for an absolute file isn't expanded."
+  (skip-unless (eshell-tests-remote-accessible-p))
+  (let ((default-directory ert-remote-temporary-file-directory)
+        (eshell-connection-local-file-names t))
+    (eshell-command-result-equal "echo \\/bin/foo" "/bin/foo")
+    (eshell-command-result-equal "echo \"/bin/foo\"" "/bin/foo")
+    (eshell-command-result-equal "echo '/bin/foo'" "/bin/foo")))
+
 
 ;; `cd'
 
diff --git a/test/lisp/eshell/esh-ext-tests.el b/test/lisp/eshell/esh-ext-tests.el
index 8abbd74f737..aac13c3d8ea 100644
--- a/test/lisp/eshell/esh-ext-tests.el
+++ b/test/lisp/eshell/esh-ext-tests.el
@@ -27,6 +27,7 @@
 (require 'ert)
 (require 'esh-mode)
 (require 'esh-ext)
+(require 'em-dirs)
 (require 'eshell)
 
 (require 'eshell-tests-helpers
@@ -74,6 +75,48 @@ esh-ext-test/addpath/set-locally
      (eshell-match-command-output "echo $PATH"
                                   (concat original-path "\n")))))
 
+(ert-deftest em-ext-test/unexpand-remote-file/local ()
+  "Test unexpansion of remote file names with a local, external command."
+  (skip-unless (and (eshell-tests-remote-accessible-p)
+                    (executable-find "echo")))
+  (let* ((default-directory ert-remote-temporary-file-directory)
+         (remote (file-remote-p default-directory)))
+    (let ((eshell-connection-local-file-names t))
+      (eshell-command-result-equal "*echo /bin/foo" "/bin/foo\n")
+      (eshell-command-result-equal (format "*echo %s/bin/foo" remote)
+                                   "/bin/foo\n")
+      (should-error (eshell-command-result-equal
+                     "*echo /ssh:nowhere.invalid:/bin/foo"
+                     "/bin/foo\n" t)))
+    (let ((eshell-connection-local-file-names nil))
+      (eshell-command-result-equal "*echo /bin/foo" "/bin/foo\n")
+      (eshell-command-result-equal (format "*echo %s/bin/foo" remote)
+                                   (concat remote "/bin/foo\n"))
+      (eshell-command-result-equal
+       "*echo /ssh:nowhere.invalid:/bin/foo"
+       "/ssh:nowhere.invalid:/bin/foo\n"))))
+
+(ert-deftest em-ext-test/unexpand-absolute-file/remote ()
+  "Test unexpansion of remote file names with a remote, external command."
+  (skip-unless (and (eshell-tests-remote-accessible-p)
+                    (executable-find "echo")))
+  (let* ((default-directory ert-remote-temporary-file-directory)
+         (remote (file-remote-p default-directory)))
+    (let ((eshell-connection-local-file-names t))
+      (eshell-command-result-equal "*echo /bin/foo" "/bin/foo\n")
+      (eshell-command-result-equal (format "*echo %s/bin/foo" remote)
+                                   "/bin/foo\n")
+      (should-error (eshell-command-result-equal
+                     "*echo /ssh:nowhere.invalid:/bin/foo"
+                     "/bin/foo\n" t)))
+    (let ((eshell-connection-local-file-names nil))
+      (eshell-command-result-equal "*echo /bin/foo" "/bin/foo\n")
+      (eshell-command-result-equal (format "*echo %s/bin/foo" remote)
+                                   (concat remote "/bin/foo\n"))
+      (eshell-command-result-equal
+       "*echo /ssh:nowhere.invalid:/bin/foo"
+       "/ssh:nowhere.invalid:/bin/foo\n"))))
+
 (ert-deftest esh-ext-test/explicitly-remote-command ()
   "Test that an explicitly-remote command is remote no matter the current dir."
   (skip-unless (and (eshell-tests-remote-accessible-p)
diff --git a/test/lisp/eshell/eshell-tests-helpers.el b/test/lisp/eshell/eshell-tests-helpers.el
index 652146fefcc..9db13cc617f 100644
--- a/test/lisp/eshell/eshell-tests-helpers.el
+++ b/test/lisp/eshell/eshell-tests-helpers.el
@@ -180,13 +180,16 @@ eshell-command-result--equal-explainer
 (put 'eshell-command-result--equal 'ert-explainer
      #'eshell-command-result--equal-explainer)
 
-(defun eshell-command-result-equal (command result)
-  "Execute COMMAND non-interactively and compare it to RESULT."
+(defun eshell-command-result-equal (command result &optional ignore-errors)
+  "Execute COMMAND non-interactively and compare it to RESULT.
+If IGNORE-ERRORS is non-nil, ignore any errors signaled when
+inserting the command."
   (ert-info (#'eshell-get-debug-logs :prefix "Command logs: ")
-    (should (eshell-command-result--equal
-             command
-             (eshell-test-command-result command)
-             result))))
+    (let ((debug-on-error (and (not ignore-errors) debug-on-error)))
+      (should (eshell-command-result--equal
+               command
+               (eshell-test-command-result command)
+               result)))))
 
 (provide 'eshell-tests-helpers)
 
-- 
2.25.1