fix: rscp can now handle *wildcards and remote to remote

This commit is contained in:
Honney OC
2026-09-18 18:33:53 +00:00
parent 60bf635a0c
commit 5cfbb620fb
+265 -26
View File
@@ -1,55 +1,294 @@
function rscp
function rscp --description "Copy SOURCE... to DESTINATION via rsync (local or NAS)"
# rscp: rsync-based copy that understands the local NAS mounts.
#
# Usage: rscp [-y|--yes] [--] SOURCE... DESTINATION
#
# Only the LAST argument is the destination; everything before it is a
# source. That way wildcards just work: `rscp *.mkv /dest/` copies every
# match instead of silently dropping all but the first one.
#
# Paths under /home/honney/mount/<dataset> are transferred directly as
# NAS:/mnt/tank/<dataset>, so rsync talks to the NAS instead of going
# through the local mount. Purely local (PC -> PC) copies use rsync with
# the same flags as rcp (-avh --progress).
# NAS -> NAS copies run on the NAS itself via `ssh <host> ... rsync`,
# keeping the flags of the "to remote" direction (sudo + chown).
set -l force 0
# Only leading flags are options; everything else (even a source starting
# with "-") is a path. Use `--` to end option parsing explicitly.
while test (count $argv) -gt 0
switch $argv[1]
case -y --yes
set force 1
set -e argv[1]
case -h --help
echo "Usage: rscp [-y|--yes] [--] SOURCE... DESTINATION"
echo ""
echo "Copy one or more SOURCEs to DESTINATION with rsync."
echo "Only the last argument is the destination; everything before it is a source,"
echo "so wildcards at the source (e.g. rscp *.mkv /dest/) work as expected."
echo ""
echo "Options:"
echo " -y, --yes skip the overwrite confirmation prompt"
echo " -h, --help show this help"
return 0
case --
set -e argv[1]
break
case '-*'
echo "rscp: unknown option: $argv[1]" >&2
echo "Usage: rscp [-y|--yes] [--] SOURCE... DESTINATION" >&2
return 1
case '*'
break
end
end
if test (count $argv) -lt 2
echo "Usage: rscp SOURCE DESTINATION"
echo "Usage: rscp [-y|--yes] [--] SOURCE... DESTINATION" >&2
return 1
end
if not command -q rsync
echo "rscp: 'rsync' is not installed. Install it via your package manager."
echo "rscp: 'rsync' is not installed. Install it via your package manager." >&2
return 1
end
set -l src $argv[1]
set -l dst $argv[2]
# Mount <-> NAS dataset mapping (keep both lists in the same order)
set -l mounts /home/honney/mount/Media /home/honney/mount/Data /home/honney/mount/Ripping /home/honney/mount/Apps
set -l remotes NAS:/mnt/tank/Media NAS:/mnt/tank/Data NAS:/mnt/tank/Ripping NAS:/mnt/tank/Apps
set -l src_remote 0
set -l dst_remote 0
# Only the last argument is the destination; everything before it is a
# source, so any number of (wildcard-expanded) sources is supported.
set -l n (count $argv)
set -l last_src (math $n - 1)
set -l dst_raw $argv[$n]
set -l srcs_raw $argv[1..$last_src]
for i in (seq (count $mounts))
if string match -q -- $mounts[$i] $src; or string match -q -- "$mounts[$i]/*" $src
set src (string replace -f -- $mounts[$i] $remotes[$i] -- $src)
set src_remote 1
break
# Translate sources: local mount path -> NAS:<path>. For each source keep
# the translated path, a 1/0 remote flag, the remote host and the path
# as seen on the remote side (without the "HOST:" prefix).
set -l srcs
set -l src_remote_flags
set -l src_hosts
set -l src_naslocals
set -l hosts
for raw in $srcs_raw
set -l s $raw
set -l is_remote 0
for i in (seq (count $mounts))
if string match -q -- $mounts[$i] $s; or string match -q -- "$mounts[$i]/*" $s
set s (string replace -f -- $mounts[$i] $remotes[$i] $s)
set is_remote 1
break
end
end
# Also accept an explicit HOST:/path given directly.
if test $is_remote -eq 0
if string match -qr '^[A-Za-z0-9_.@-]+:/' -- $s
set is_remote 1
end
end
set -l host ""
set -l naslocal $s
if test $is_remote -eq 1
set -l parts (string split -m1 : -- $s)
set host $parts[1]
set naslocal (string join : -- $parts[2..-1])
if not contains -- $host $hosts
set -a hosts $host
end
end
set -a srcs $s
set -a src_remote_flags $is_remote
set -a src_hosts $host
set -a src_naslocals $naslocal
end
# Translate the destination the same way (it is always $argv[-1]).
set -l dst $dst_raw
set -l dst_remote 0
for i in (seq (count $mounts))
if string match -q -- $mounts[$i] $dst; or string match -q -- "$mounts[$i]/*" $dst
set dst (string replace -f -- $mounts[$i] $remotes[$i] -- $dst)
set dst (string replace -f -- $mounts[$i] $remotes[$i] $dst)
set dst_remote 1
break
end
end
if test $dst_remote -eq 0
if string match -qr '^[A-Za-z0-9_.@-]+:/' -- $dst
set dst_remote 1
end
end
set -l dst_host ""
set -l dst_naslocal $dst
if test $dst_remote -eq 1
set -l dparts (string split -m1 : -- $dst)
set dst_host $dparts[1]
set dst_naslocal (string join : -- $dparts[2..-1])
if not contains -- $dst_host $hosts
set -a hosts $dst_host
end
end
if test $src_remote -eq 0 -a $dst_remote -eq 0
echo "cp -av -- $src $dst"
command cp -av -- $src $dst
return $status
set -l n_src_remote 0
for f in $src_remote_flags
if test $f -eq 1
set n_src_remote (math $n_src_remote + 1)
end
end
set -l any_remote 0
if test $dst_remote -eq 1; or test $n_src_remote -gt 0
set any_remote 1
end
# Failsafe: when a remote side is involved, verify ssh actually knows the
# host (e.g. via ~/.ssh/config) before copying anything. The probe is
# non-interactive; the real transfer below still uses your normal ssh
# config/auth (password prompt included).
if test $any_remote -eq 1
if not command -q ssh
echo "rscp: 'ssh' is not installed, but a remote copy was requested." >&2
return 1
end
if test (count $hosts) -gt 1
echo "rscp: copies between different remote hosts are not supported: $hosts" >&2
return 1
end
for h in $hosts
set -l probe_err (ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null $h true 2>&1)
set -l probe_st $status
if test $probe_st -ne 0
if string match -qi -- "*could not resolve*" $probe_err; or string match -qi -- "*name or service not known*" $probe_err; or string match -qi -- "*no address associated*" $probe_err; or string match -qi -- "*nodename nor servname*" $probe_err
echo "rscp: ssh host '$h' is unknown (check the 'Host $h' entry in ~/.ssh/config)." >&2
return 1
else if string match -qi -- "*permission denied*" $probe_err; or string match -qi -- "*authentication fail*" $probe_err
echo "rscp: note: key auth for '$h' failed; continuing, ssh may ask for a password." >&2
else
echo "rscp: cannot reach ssh host '$h': $probe_err" >&2
return 1
end
end
end
end
# Failsafe: if the destination already exists, ask before overwriting.
set -l dst_disp $dst_raw
if test "$dst" != "$dst_raw"
set dst_disp "$dst_raw ($dst)"
end
set -l dst_exists 0
set -l dst_is_dir 0
if test $dst_remote -eq 1
set -l qdst (__rscp_shquote $dst_naslocal)
if ssh $dst_host test -e $qdst
set dst_exists 1
else if ssh $dst_host test -L $qdst
set dst_exists 1
end
if test $dst_exists -eq 1
if ssh $dst_host test -d $qdst
set dst_is_dir 1
end
end
else
if test -e "$dst"; or test -L "$dst"
set dst_exists 1
end
if test $dst_exists -eq 1
if test -d "$dst"
set dst_is_dir 1
end
end
end
if test $dst_exists -eq 1
if test (count $srcs) -gt 1; and test $dst_is_dir -eq 0
echo "rscp: destination '$dst_disp' exists and is not a directory, but multiple sources were given." >&2
return 1
end
if test $force -eq 0
if not isatty stdin
echo "rscp: destination '$dst_disp' already exists. Re-run interactively or pass -y/--yes to overwrite." >&2
return 1
end
if not read -l -P "rscp: destination '$dst_disp' already exists. Overwrite/merge? [y/N] " confirm_raw
echo "rscp: aborted." >&2
return 1
end
switch (string lower -- (string trim -- "$confirm_raw"))
case y yes
# proceed
case '*'
echo "rscp: aborted."
return 1
end
end
end
# --no-perms: required - TrueNAS ZFS datasets use SMB ACLs and reject unix perms preservation
set -l opts -avh --no-perms --info=progress2 --partial
if test $dst_remote -eq 1
set -a opts --rsync-path="sudo -n rsync" --chown=1000:0
end
if test $src_remote -eq 1
set -a opts --rsync-path="sudo -n rsync"
set -l base_opts -avh --no-perms --info=progress2 --partial
# Flags of the "to remote" direction; remote-to-remote via ssh on the NAS
# keeps these same flags (sudo for the chown, which needs root).
set -l to_remote_opts --rsync-path="sudo -n rsync" --chown=1000:0
if test $any_remote -eq 0
# PC -> PC goes through rsync with the same flags as rcp (no NAS-only
# --no-perms/--chown permission workarounds needed locally).
set -l local_opts -avh --progress
echo rsync $local_opts -- $srcs $dst
command rsync $local_opts -- $srcs $dst
return $status
end
echo rsync $opts -- $src $dst
command rsync $opts -- $src $dst
if test $n_src_remote -eq (count $srcs); and test $dst_remote -eq 1
# NAS -> NAS: run rsync directly on the NAS itself. Only one remote
# host is possible here (checked above), so $hosts[1] is that host.
set -l rsrcs
for nl in $src_naslocals
set -a rsrcs (__rscp_shquote $nl)
end
set -l rdst (__rscp_shquote $dst_naslocal)
set -l ssh_tty
if isatty stdin
set ssh_tty -t
end
echo ssh $ssh_tty $hosts[1] sudo -n rsync $base_opts --chown=1000:0 -- $rsrcs $rdst
command ssh $ssh_tty $hosts[1] sudo -n rsync $base_opts --chown=1000:0 -- $rsrcs $rdst
return $status
end
if test $n_src_remote -gt 0; and test $n_src_remote -lt (count $srcs)
# Mixed local and remote sources cannot go into a single rsync call,
# so copy source by source. The overwrite question above was already
# asked once, hence -y here (each call still re-checks everything else).
for k in (seq (count $srcs))
rscp -y -- $srcs_raw[$k] $dst_raw
set -l st $status
if test $st -ne 0
return $st
end
end
return 0
end
# Single-sided copy: local -> NAS, NAS -> local, or one source of any kind.
set -l call_opts $base_opts
if test $dst_remote -eq 1
set -a call_opts $to_remote_opts
else if test $n_src_remote -gt 0
set -a call_opts --rsync-path="sudo -n rsync"
end
echo rsync $call_opts -- $srcs $dst
command rsync $call_opts -- $srcs $dst
return $status
end
function __rscp_shquote --description "Single-quote strings for a POSIX remote shell"
# Usage: __rscp_shquote STRING... ; prints each string single-quoted.
for s in $argv
set -l e (string replace -a -- "'" "'\"'\"'" $s)
echo "'$e'"
end
end