Last active
December 22, 2025 15:38
-
-
Save gadgetmies/8781714f17bea2d073cca55321060930 to your computer and use it in GitHub Desktop.
Script for syncing all folders with .sync file (tested only on MacOS with brew rsync)
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 | |
| RSYNC_CMD="/opt/homebrew/bin/rsync" | |
| # Usage: sync_by_dot_sync.sh [--skip-missing-check] SOURCE_DIR DEST_DIR | |
| # Finds top-level folders (w.r.t. source) that contain a file named ".sync" | |
| # (ignoring any folder that is a subfolder of another .sync folder), | |
| # checks for files that exist only on destination (warns / lists them), | |
| # prompts to continue, and then rsyncs each selected folder WITHOUT deleting destination-only files. | |
| # | |
| # Behavior: | |
| # * If missing files are found and --skip-missing-check is NOT given, the script prints a grouped | |
| # list of destination-only files and prompts once for whether to continue the sync. | |
| # * If the user answers 'y' or 'Y', syncing proceeds. Any other answer aborts. | |
| # * Actual rsync does not use --delete; it copies/upates files from source to destination only. | |
| PRINT_USAGE() { | |
| cat <<EOF | |
| Usage: $0 [--skip-missing-check] [--rsync-opts "OPTS"] SOURCE_DIR DEST_DIR | |
| Options: | |
| --skip-missing-check Skip checking for destination-only files (and skip the prompt). | |
| --rsync-opts "OPTS" Extra options passed verbatim to rsync | |
| Example: --rsync-opts "--exclude=.git --numeric-ids" | |
| EOF | |
| } | |
| # parse args | |
| SKIP_MISSING_CHECK=false | |
| RSYNC_EXTRA_OPTS=() | |
| while [ "$#" -gt 0 ]; do | |
| case "$1" in | |
| --skip-missing-check) | |
| SKIP_MISSING_CHECK=true | |
| shift | |
| ;; | |
| --rsync-opts) | |
| [ "$#" -lt 2 ] && { PRINT_USAGE >&2; exit 1; } | |
| RSYNC_EXTRA_OPTS=$($2) | |
| shift 2 | |
| ;; | |
| --*) | |
| PRINT_USAGE >&2 | |
| exit 1 | |
| ;; | |
| *) | |
| break | |
| ;; | |
| esac | |
| done | |
| [ "$#" -ne 2 ] && { PRINT_USAGE >&2; exit 1; } | |
| SOURCE="$1" | |
| DEST="$2" | |
| if [ ! -d "$SOURCE" ]; then | |
| echo "Source directory does not exist: $SOURCE" >&2 | |
| exit 2 | |
| fi | |
| mkdir -p "$DEST" | |
| SOURCE="$(cd "$SOURCE" && pwd -P)" | |
| DEST="$(cd "$DEST" && pwd -P)" | |
| echo "Finding folders to sync under: $SOURCE" | |
| echo "Syncing to: $DEST" | |
| echo | |
| # collect candidate directories that contain .sync | |
| candidates=() | |
| while IFS= read -r -d '' f; do | |
| dir="$(cd "$(dirname "$f")" && pwd -P)" | |
| candidates+=("$dir") | |
| done < <(find "$SOURCE" -type f -name '.sync' -print0) | |
| # nothing to do | |
| if [ "${#candidates[@]}" -eq 0 ]; then | |
| echo No folders found for syncing | |
| exit 0 | |
| fi | |
| # deduplicate and sort by path length (shorter first so parents come before children) | |
| IFS=$'\n' sorted_unique=($(printf "%s\n" "${candidates[@]}" \ | |
| | awk '!seen[$0]++' \ | |
| | sed 's:/*$::' \ | |
| | awk '{ print length, $0 }' \ | |
| | sort -n \ | |
| | cut -d' ' -f2-)) | |
| # select only top-level .sync dirs (exclude those inside previously selected dirs) | |
| selected=() | |
| for dir in "${sorted_unique[@]}"; do | |
| skip=false | |
| if [[ -n ${selected:-} ]]; then | |
| for sel in "${selected[@]}"; do | |
| case "$dir" in | |
| "$sel"|"$sel"/*) skip=true; break ;; | |
| esac | |
| done | |
| fi | |
| if [ "$skip" = false ]; then | |
| selected+=("$dir") | |
| fi | |
| done | |
| if [ "${#selected[@]}" -gt 0 ]; then | |
| echo "Selected folders:" | |
| for dir in "${selected[@]}"; do | |
| echo " $dir" | |
| done | |
| echo | |
| else | |
| echo "No folders with .sync found" | |
| exit 0 | |
| fi | |
| echo "Syncing folders:" | |
| for dir in "${selected[@]}"; do | |
| if [ "$dir" = "$SOURCE" ]; then | |
| rel="." | |
| else | |
| rel="${dir#$SOURCE/}" | |
| fi | |
| echo " $SOURCE/$rel -> $DEST/$rel" | |
| done | |
| echo | |
| echo "Total folders: ${#selected[@]}" | |
| echo | |
| RSYNC_BASE_OPTS=(-av) | |
| DELETE_MODE="" # empty or --delete | |
| # Check for destination-only (would be deleted) files, collect by relative folder | |
| missing_map=() | |
| any_missing=false | |
| # missing-file check and decision | |
| if [ "$SKIP_MISSING_CHECK" = false ]; then | |
| any_missing=false | |
| echo "Finding files that exist in target but not in source..." | |
| for dir in "${selected[@]}"; do | |
| echo "${dir}" | |
| rel="${dir#$SOURCE/}" | |
| [ "$dir" = "$SOURCE" ] && rel="." | |
| target="$DEST/$rel" | |
| mkdir -p "$target" | |
| OPTS=( "${RSYNC_BASE_OPTS[@]}" ) | |
| if [ "${#RSYNC_EXTRA_OPTS[@]}" -gt 0 ]; then | |
| OPTS+=( "${RSYNC_EXTRA_OPTS[@]}" ) | |
| fi | |
| OPTS+=(--dry-run --delete "${dir}/" "${target}/") | |
| dry=$("${RSYNC_CMD}" "${OPTS[@]}") #2>/dev/null || true)" | |
| deletes="$(printf '%s\n' "$dry" | awk '/^deleting / { sub(/^deleting /,""); print }')" | |
| if [ -n "$deletes" ]; then | |
| any_missing=true | |
| missing_map["$rel"]="$deletes" | |
| fi | |
| done | |
| if [ "$any_missing" = true ]; then | |
| echo "Destination-only files detected:" >&2 | |
| for rel in "${!missing_map[@]}"; do | |
| echo "[$rel]" >&2 | |
| printf '%s\n' "${missing_map[$rel]}" | sed 's/^/ /' >&2 | |
| done | |
| printf "Choose action: (d)elete and sync / (k)eep and sync / (q)uit: " >&2 | |
| IFS= read -r choice | |
| case "$choice" in | |
| d|D) DELETE_MODE="--delete" ;; | |
| k|K) DELETE_MODE="" ;; | |
| q|Q) exit 0 ;; | |
| *) exit 0 ;; | |
| esac | |
| fi | |
| fi | |
| echo | |
| echo "Performing sync" | |
| # perform sync | |
| for dir in "${selected[@]}"; do | |
| echo | |
| echo "Syncing ${dir}" | |
| rel="${dir#$SOURCE/}" | |
| [ "$dir" = "$SOURCE" ] && rel="." | |
| target="$DEST/$rel" | |
| mkdir -p "$target" | |
| OPTS=( "${RSYNC_BASE_OPTS[@]}" ${DELETE_MODE} ) | |
| if [ "${#RSYNC_EXTRA_OPTS[@]}" -gt 0 ]; then | |
| OPTS+=( "${RSYNC_EXTRA_OPTS[@]}") | |
| fi | |
| OPTS+=( "${dir}/" "${target}/" ) | |
| "${RSYNC_CMD}" "${OPTS[@]}" | |
| done |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment