Skip to content

Instantly share code, notes, and snippets.

@AdamGagorik
Last active March 31, 2026 13:10
Show Gist options
  • Select an option

  • Save AdamGagorik/7bc5a2852c84d923a4313a3ff07a89e7 to your computer and use it in GitHub Desktop.

Select an option

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=()
SRC_SEARCH_ROOT="${SRC_SEARCH_ROOT:-$PWD}"
DST_SEARCH_ROOT="${DST_SEARCH_ROOT:-$HOME}"
SEARCH_DEPTH="${SEARCH_DEPTH:-3}"
NO_PROMPT="${NO_PROMPT:-false}"
SWAP_MODE="${SWAP_MODE:-false}"
RM_SRC="${RM_SRC:-false}"
SRC_PATH=""
DST_PATH=""
declare -a FZF_ARGS=()
function die() {
printf '\e[31mERROR:\e[0m %s\n' "$*" >&2
exit 1
}
function usage() {
cat <<EOF
Usage: $0 [options]
Options:
-i, --src <path> Source directory path
-o, --dst <path> Destination directory path
-S, --src-search <path> Search this directory for source
-D, --dst-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
-s, --swap Swap source and destination for copy direction
--rm-src Remove effective source directory after successful copy
-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
}
function parse_args() {
local parsed_options
parsed_options=$(getopt -o S:D:fi:o:e:sd:uIHh --long src-search:,dst-search:,no-prompt,src:,dst:,exclude:,swap,rm-src,depth:,unrestricted,no-ignore,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|--src-search)
SRC_SEARCH_ROOT="$2"
shift 2
;;
-D|--dst-search)
DST_SEARCH_ROOT="$2"
shift 2
;;
-f|--no-prompt)
NO_PROMPT=true
shift
;;
-s|--swap)
SWAP_MODE=true
shift
;;
--rm-src)
RM_SRC=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|--no-ignore)
FZF_ARGS+=("-I")
shift
;;
-H|--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
}
function 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
}
function handle_src_path() {
if [[ -z "${SRC_PATH}" ]]; then
HEADER="Choose Source Directory : run with -u if files are not shown!"
SRC_PATH="$(fd . "${SRC_SEARCH_ROOT}" "${FZF_ARGS[@]}" --type d --max-depth "${SEARCH_DEPTH}" | 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
}
function handle_dst_path() {
if [[ -z "${DST_PATH}" ]]; then
HEADER="Choose Output Directory : run with -u if files are not shown!"
DST_PATH="$(fd . "${DST_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
}
function do_rsync_action() {
local pattern
local effective_src="${SRC_PATH}"
local effective_dst="${DST_PATH}"
declare -a exclude
if [ "${SWAP_MODE}" = true ]; then
effective_src="${DST_PATH}"
effective_dst="${SRC_PATH}"
fi
if [ "${effective_src}" = "${effective_dst}" ]; then
echo "source and destination resolve to the same path!"
exit 1
fi
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
exclude+=("--exclude=${pattern}")
done
echo "SRC: ${effective_src}"
echo "DST: ${effective_dst}"
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 "${effective_dst}"
rsync -avz --delete --delete-before --info=progress2 "${exclude[@]}" "${effective_src}" "${effective_dst}"
chmod -Rv a+rwx "${effective_dst}/$(basename "${effective_src}")"
if [ "${RM_SRC}" = true ]; then
remove_src_path "${effective_src}"
fi
exit 0
else
echo "Copy cancelled."
exit 1
fi
}
function remove_src_path() {
local to_remove
local confirm='n'
local temporary_path
local prompt_for_removal
prompt_for_removal='true'
to_remove="$1"
validate_removal "${to_remove}"
if [[ "${prompt_for_removal}" == 'true' ]]; then
echo "To remove: ${to_remove}"
read -r -p "Are you sure you want to remove this directory? (y/n): " confirm
fi
if [[ "${confirm}" =~ ^[Yy]$ ]] || [[ "${prompt_for_removal}" == 'false' ]]; then
temporary_path="$(mktemp -d)" || die 'Failed to create temporary directory.'
if ! rsync -av --prune-empty-dirs --info=progress2 --delete "${temporary_path}/" "${to_remove}/"; then
rm -rf -- "${temporary_path}"
die "Failed to empty '${to_remove}' with rsync."
fi
rm -rf -- "${temporary_path}"
rmdir -- "${to_remove}" || die "Failed to remove '${to_remove}' (directory may not be empty)."
echo "Removed ${to_remove}."
return 0
fi
echo 'Removal cancelled.'
return 1
}
function validate_removal() {
local to_remove
local current_directory
local home_directory
to_remove="$1"
current_directory="$(pwd -P)"
home_directory="$(readlink -f "${HOME:-/}" 2>/dev/null || true)"
[[ -n "${to_remove}" ]] || die 'Removal target is empty.'
[[ -d "${to_remove}" ]] || die "Removal target '${to_remove}' is not a directory."
[[ "${to_remove}" == /* ]] || die 'Removal target must be an absolute path.'
case "${to_remove}" in
/|/home|/root|/usr|/var|/etc|/bin|/sbin|/opt|/tmp)
die "Refusing to remove protected directory '${to_remove}'."
;;
*)
;;
esac
[[ "${to_remove}" != "${current_directory}" ]] || die "Refusing to remove current directory '${to_remove}'."
[[ "${current_directory}" != "${to_remove}/"* ]] || die "Refusing to remove ancestor '${to_remove}'."
[[ -z "${home_directory}" || "${to_remove}" != "${home_directory}" ]] || die "Refusing to remove HOME '${to_remove}'."
}
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