diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-06 11:28:45 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-06 11:28:45 -0500 |
| commit | be0feb2b0d0070f23bc9cd348c877aa3ad6c8b7b (patch) | |
| tree | a374f1a4865e8cbcedcc488ad2eee5335b917e74 | |
| parent | 7757f6909bfcc20211e8ae1f4ad364082ca924f5 (diff) | |
| download | duet-be0feb2b0d0070f23bc9cd348c877aa3ad6c8b7b.tar.gz duet-be0feb2b0d0070f23bc9cd348c877aa3ad6c8b7b.zip | |
fix: build route-specific rsync specs and defer both-remote to Phase 6
duet--rsync-command built one direct rsync argv for every pair, rendering each remote endpoint as host:path. That is right when at most one endpoint is remote, but rsync refuses a source and destination that are both remote, so the argv was unexecutable for every remote-to-remote route, not just the round-trip.
Now rsync builds a single argv only for :local and :local-remote, the routes it can run in one invocation. A both-remote pair returns a deferred spec: nil argv, an :exec-mode marker, and the route. Phase 6 reads the route to run rsync on a host (same-host), route through this machine (round-trip), or go direct host-to-host when the override is set.
transfer-spec now carries the classified :src-endpoint and :dst-endpoint alongside the route, so execution has the structured endpoints and never has to reinterpret an argv string. The runnable-shape check from the in-process-mode fix extends to accept :exec-mode, so a deferred spec reads as a real plan rather than a broken nil-argv command.
Tests cover local-to-remote and remote-to-local argv shapes including a port, and the same-host, round-trip, and direct routes each producing a deferred spec.
| -rw-r--r-- | duet.el | 52 | ||||
| -rw-r--r-- | tests/test-duet-transfer.el | 52 |
2 files changed, 87 insertions, 17 deletions
@@ -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 () |
