Skip to content

Instantly share code, notes, and snippets.

@AdamGagorik
Created October 22, 2025 14:02
Show Gist options
  • Save AdamGagorik/7bc5a2852c84d923a4313a3ff07a89e7 to your computer and use it in GitHub Desktop.
Save AdamGagorik/7bc5a2852c84d923a4313a3ff07a89e7 to your computer and use it in GitHub Desktop.
Rsync wrapper with fzf
#!/usr/bin/env bash
set -euo pipefail
EXCLUDE_PATTERNS=()
SEARCH_ROOT="${SEARCH_ROOT:-$HOME}"
SEARCH_DEPTH="${SEARCH_DEPTH:-3}"
NO_PROMPT="${NO_PROMPT:-false}"
SRC_PATH=""
DST_PATH=""
declare -a FZF_ARGS=()
usage() {
cat <<EOF
Usage: $0 [options]
Options:
-i, --src <path> Source directory path
-o, --dst <path> Destination directory path
-s, --search <path> Search this directory for destination
-e, --exclude <pattern> Exclude pattern for rsync (can be used multiple times)
-f, --no-prompt Skip confirmation prompt before copying
-d, --depth <int> The --max-depth to use when searching
-u, --unrestricted Display all the files when searching
-I, --no-ignore Display ignored files when searching
-H, --no-hidden Display hidden files when searching
-h, --help Show this help message and exit
EOF
}
parse_args() {
local parsed_options
parsed_options=$(getopt -o s:fi:o:e:d:uIHh --long search:,no-prompt,src:,dst:,exclude:,depth:,unrestricted,no-ignore,no-hidden,help -- "$@")
if [[ $? -ne 0 ]]; then
echo "Error: Failed to parse options." >&2
exit 1
fi
eval set -- "$parsed_options"
while true; do
case "$1" in
-h|--help)
usage
exit 0
;;
-s|--search)
SEARCH_ROOT="$2"
shift 2
;;
-f|--no-prompt)
NO_PROMPT=true
shift
;;
-e|--exclude)
EXCLUDE_PATTERNS+=("$2")
shift 2
;;
-i|--src)
SRC_PATH="$2"
shift 2
;;
-o|--dst)
DST_PATH="$2"
shift 2
;;
-d|--depth)
SEARCH_DEPTH="$2"
shift 2
;;
-u|--unrestricted)
FZF_ARGS+=("-u")
shift
;;
-I|--unrestricted)
FZF_ARGS+=("-I")
shift
;;
-H|--no-hidden)
FZF_ARGS+=("-H")
shift
;;
--)
shift
break
;;
*)
echo "Error: Parsing arguments: $1" >&2
exit 1
;;
esac
done
if [[ "$#" -gt 0 ]]; then
echo "Error: Unrecognized arguments: $*" >&2
exit 1
fi
}
enforce_tooling() {
local cmd
for cmd in fd fzf rsync realpath getopt; do
if ! command -v "$cmd" &> /dev/null; then
echo "Error: Required command '$cmd' is not installed." >&2
exit 1
fi
done
}
handle_src_path() {
if [[ -z "${SRC_PATH}" ]]; then
HEADER="Choose Source Directory : run with -u if files are not shown!"
SRC_PATH="$(fd . "${FZF_ARGS[@]}" --type d --max-depth 1 | fzf --header "${HEADER}" --height 50%)"}
fi
if [[ ! -z "${SRC_PATH}" ]]; then
SRC_PATH="$(realpath "${SRC_PATH}")"
fi
echo "SRC_PATH: ${SRC_PATH}"
if [ ! -d "${SRC_PATH}" ]; then
echo "missing SRC_PATH!"
exit 1
fi
}
handle_dst_path() {
if [[ -z "${DST_PATH}" ]]; then
HEADER="Choose Output Directory : run with -u if files are not shown!"
DST_PATH="$(fd . "${SEARCH_ROOT}" "${FZF_ARGS[@]}" --type d --max-depth "${SEARCH_DEPTH}" | fzf --header "${HEADER}" --height 50%)"
fi
if [[ ! -z "${DST_PATH}" ]]; then
DST_PATH="$(realpath "${DST_PATH}")"
fi
echo "DST_PATH: ${DST_PATH}"
if [ ! -d "${DST_PATH}" ]; then
echo "missing DST_PATH!"
exit 1
fi
}
do_rsync_action() {
local pattern
declare -a exclude
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
exclude+=("--exclude=${pattern}")
done
local confirm="n"
if [ "${NO_PROMPT}" != true ]; then
read -r -p "Are you sure you want to copy SRC into DST? (y/n): " confirm
fi
if [[ "${confirm}" =~ ^[Yy]$ ]] || [ "${NO_PROMPT}" = true ]; then
mkdir -p "${DST_PATH}"
rsync -avz --delete --delete-before --info=progress2 "${exclude[@]}" "${SRC_PATH}" "${DST_PATH}"
chmod -Rv a+rwx "${DST_PATH}/$(basename "${SRC_PATH}")"
exit 0
else
echo "Copy cancelled."
exit 1
fi
}
parse_args "$@"
enforce_tooling
handle_src_path
handle_dst_path
do_rsync_action
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment