aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--duet.el52
-rw-r--r--tests/test-duet-transfer.el52
2 files changed, 87 insertions, 17 deletions
diff --git a/duet.el b/duet.el
index 28d45f2..1fe3d31 100644
--- a/duet.el
+++ b/duet.el
@@ -271,12 +271,14 @@ normalizer."
(defun duet--command-spec-executable-p (spec)
"Return non-nil when process SPEC has a recognized, runnable execution shape.
A SPEC is runnable when it is a non-empty plist carrying either a non-empty
-:argv whose elements are all strings (a CLI backend) or an explicit
-in-process mode such as :tramp (a backend that copies in process). A nil
-spec, a bare nil argv, or a shell string is not runnable."
+:argv whose elements are all strings (a CLI backend) or an explicit non-argv
+mode -- :tramp for an in-process copy, or :exec-mode for a route that Phase 6
+orchestrates (a both-remote rsync). A nil spec, a bare nil argv, or a shell
+string is not runnable."
(and (listp spec) spec
(not (plist-get spec :shell-command))
(or (plist-get spec :tramp)
+ (plist-get spec :exec-mode)
(let ((argv (plist-get spec :argv)))
(and (consp argv) (cl-every #'stringp argv))))))
@@ -420,23 +422,34 @@ A local endpoint uses its localname; a remote one uses rsync's native
(let ((port (or (plist-get src :port) (plist-get dst :port))))
(list "-e" (if port (format "ssh -p %s" port) "ssh"))))
-(defun duet--rsync-command (src dst opts)
- "Build an rsync process spec for SRC to DST with OPTS.
-OPTS carries :sources (the source paths) and :destination (the destination
-directory). Sources and destination become rsync path arguments, and a
-remote endpoint adds an ssh transport. File names reach rsync as argv
-elements, never interpolated into a shell string."
- (let* ((sources (plist-get opts :sources))
- (remote (or (duet--ssh-endpoint-p src) (duet--ssh-endpoint-p dst)))
- (src-args (mapcar (lambda (p)
- (duet--rsync-endpoint-arg (duet--classify-path p)))
- sources)))
+(defun duet--rsync-local-command (src dst sources)
+ "Build a single-invocation rsync spec where at most one endpoint is remote.
+SOURCES are the source paths; SRC and DST are the classified representative
+endpoints. A remote endpoint adds an ssh transport. File names reach rsync
+as argv elements, never interpolated into a shell string."
+ (let ((remote (or (duet--ssh-endpoint-p src) (duet--ssh-endpoint-p dst)))
+ (src-args (mapcar (lambda (p)
+ (duet--rsync-endpoint-arg (duet--classify-path p)))
+ sources)))
(list :argv (append '("rsync" "-a" "--partial" "--info=progress2")
(when remote (duet--rsync-ssh-transport src dst))
src-args
(list (duet--rsync-endpoint-arg dst)))
:default-directory "/")))
+(defun duet--rsync-command (src dst opts)
+ "Build an rsync process spec for SRC to DST with OPTS.
+rsync moves data in one invocation only when at most one endpoint is remote
+\(routes :local and :local-remote). It refuses a source and destination that
+are both remote, so a both-remote pair yields a deferred spec carrying the
+route and an :exec-mode marker; Phase 6 runs rsync on a host or routes through
+the local machine per the route. OPTS carries :sources and :route."
+ (if (and (duet--remote-endpoint-p src) (duet--remote-endpoint-p dst))
+ (list :argv nil
+ :exec-mode 'rsync-remote-to-remote
+ :route (plist-get opts :route))
+ (duet--rsync-local-command src dst (plist-get opts :sources))))
+
(defun duet--tramp-handles (_src _dst)
"Score TRAMP: the universal fallback, costlier than a native transport."
100)
@@ -499,17 +512,22 @@ backend's command builder. Return nil when no backend handles the pair."
(dst (duet--classify-path destination-directory))
(backend (duet--select-backend src dst)))
(when backend
- (let* ((bopts (append (list :sources sources
- :destination destination-directory)
+ (let* ((route (duet--transfer-route src dst opts))
+ (bopts (append (list :sources sources
+ :destination destination-directory
+ :route route)
opts))
(cmd (funcall (duet-backend-command backend) src dst bopts)))
(list :sources sources
:destination-directory destination-directory
:destination-name (plist-get opts :destination-name)
:backend (duet-backend-name backend)
- :route (duet--transfer-route src dst opts)
+ :route route
+ :src-endpoint src
+ :dst-endpoint dst
:argv (plist-get cmd :argv)
:tramp (plist-get cmd :tramp)
+ :exec-mode (plist-get cmd :exec-mode)
:default-directory (plist-get cmd :default-directory)
:process-environment (plist-get cmd :process-environment)
:async (if (plist-member opts :async) (plist-get opts :async) t))))))
diff --git a/tests/test-duet-transfer.el b/tests/test-duet-transfer.el
index ebe5a2c..84cf678 100644
--- a/tests/test-duet-transfer.el
+++ b/tests/test-duet-transfer.el
@@ -128,6 +128,58 @@
(should (eq t (plist-get spec :tramp)))
(should (null (plist-get spec :argv))))))
+;;; Route-specific rsync specs (a single argv only when <=1 endpoint is remote)
+
+(ert-deftest test-duet-transfer-spec-local-to-remote-argv ()
+ "local->remote builds one rsync argv with an ssh transport and a remote dest."
+ (test-duet-transfer--with-builtins
+ (let ((spec (duet--transfer-spec '("/tmp/a/f") "/ssh:user@host:/b" nil)))
+ (should (eq :local-remote (plist-get spec :route)))
+ (should (member "-e" (plist-get spec :argv)))
+ (should (member "user@host:/b" (plist-get spec :argv)))
+ (should (member "/tmp/a/f" (plist-get spec :argv))))))
+
+(ert-deftest test-duet-transfer-spec-local-to-remote-honors-port ()
+ "A remote endpoint port travels in the rsync ssh transport flag."
+ (test-duet-transfer--with-builtins
+ (let ((spec (duet--transfer-spec '("/tmp/a/f") "/ssh:host#2222:/b" nil)))
+ (should (member "ssh -p 2222" (plist-get spec :argv))))))
+
+(ert-deftest test-duet-transfer-spec-remote-to-local-argv ()
+ "remote->local builds one rsync argv with the remote source."
+ (test-duet-transfer--with-builtins
+ (let ((spec (duet--transfer-spec '("/ssh:host:/a/f") "/tmp/b" nil)))
+ (should (eq :local-remote (plist-get spec :route)))
+ (should (member "host:/a/f" (plist-get spec :argv)))
+ (should (member "/tmp/b" (plist-get spec :argv))))))
+
+(ert-deftest test-duet-transfer-spec-same-host-remote-is-deferred ()
+ "A same-host remote pair yields a deferred spec, not a direct rsync argv."
+ (test-duet-transfer--with-builtins
+ (let ((spec (duet--transfer-spec '("/ssh:user@host:/a/f") "/ssh:user@host:/b" nil)))
+ (should (eq :remote-same-host (plist-get spec :route)))
+ (should (null (plist-get spec :argv)))
+ (should (eq 'rsync-remote-to-remote (plist-get spec :exec-mode)))
+ (should (plist-get spec :src-endpoint))
+ (should (plist-get spec :dst-endpoint)))))
+
+(ert-deftest test-duet-transfer-spec-different-host-roundtrip-is-deferred ()
+ "Different remote hosts default to a deferred round-trip, no direct argv."
+ (test-duet-transfer--with-builtins
+ (let ((spec (duet--transfer-spec '("/ssh:hostA:/a/f") "/ssh:hostB:/b" nil)))
+ (should (eq :remote-roundtrip (plist-get spec :route)))
+ (should (null (plist-get spec :argv)))
+ (should (eq 'rsync-remote-to-remote (plist-get spec :exec-mode))))))
+
+(ert-deftest test-duet-transfer-spec-direct-override-route ()
+ "The direct override is recorded in the route; the spec stays deferred."
+ (test-duet-transfer--with-builtins
+ (let ((spec (duet--transfer-spec '("/ssh:hostA:/a/f") "/ssh:hostB:/b"
+ '(:direct-remote-to-remote t))))
+ (should (eq :remote-direct (plist-get spec :route)))
+ (should (null (plist-get spec :argv)))
+ (should (eq 'rsync-remote-to-remote (plist-get spec :exec-mode))))))
+
;;; Conflict planning — pure, prompt-free
(ert-deftest test-duet-plan-conflicts-no-collisions-all-copy ()