Skip to content

Instantly share code, notes, and snippets.

@gadgetmies
Last active December 22, 2025 15:38
Show Gist options
  • Select an option

  • Save gadgetmies/8781714f17bea2d073cca55321060930 to your computer and use it in GitHub Desktop.

Select an option

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)
#!/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