Last active
March 31, 2026 13:10
-
-
Save AdamGagorik/7bc5a2852c84d923a4313a3ff07a89e7 to your computer and use it in GitHub Desktop.
Rsync wrapper with fzf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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