Last active
March 4, 2026 14:48
-
-
Save e3ntity/e4e7c1d3fb4814ea0ed62ca1c15189ed to your computer and use it in GitHub Desktop.
background file-sync daemon that watches a local directory for changes via inotifywait and automatically rsyncs them to a remote host over SSH
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
| #!/bin/bash | |
| set -euo pipefail | |
| DEFAULT_EXCLUDES=(.git) | |
| STATE_DIR="/tmp/sync-sessions" | |
| log() { echo "[$(date +%H:%M:%S)] $*"; } | |
| state_dir() { mkdir -p "$STATE_DIR"; echo "$STATE_DIR"; } | |
| session_hash() { echo -n "$1" | md5sum | cut -d' ' -f1; } | |
| write_state() { | |
| local file="$(state_dir)/$1.state" | |
| cat > "$file" <<EOF | |
| PID=$2 | |
| KEY=$3 | |
| PORT=$4 | |
| LOCAL_DIR=$5 | |
| REMOTE_HOST=$6 | |
| REMOTE_DIR=$7 | |
| EXCLUDES="$8" | |
| EOF | |
| } | |
| read_state() { | |
| local file="$1" | |
| # shellcheck disable=SC1090 | |
| source "$file" | |
| } | |
| find_session() { | |
| local cwd | |
| cwd="$(pwd)/" | |
| for f in "$(state_dir)"/*.state; do | |
| [[ -f "$f" ]] || return 1 | |
| local dir | |
| dir="$(grep '^LOCAL_DIR=' "$f" | cut -d= -f2-)" | |
| if [[ "$cwd" == "$dir"* ]]; then | |
| echo "$f" | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| usage() { | |
| cat <<EOF | |
| Usage: $0 <command> [options] | |
| Commands: | |
| start [options] LOCAL USER@HOST:REMOTE Start background sync daemon | |
| stop Stop daemon (auto-detects from CWD) | |
| pull PATH [PATH...] Pull paths from remote to local | |
| list List active sync sessions | |
| logs Tail the daemon log (Ctrl+C to stop) | |
| Start options: | |
| --key KEY SSH private key (optional) | |
| --port PORT SSH port (default: 22) | |
| --exclude PAT Exclude pattern (repeatable; defaults: ${DEFAULT_EXCLUDES[*]}) | |
| --no-gitignore Don't auto-load .gitignore patterns as excludes | |
| EOF | |
| exit 1 | |
| } | |
| cmd_start() { | |
| local key="" port=22 no_gitignore=false | |
| local -a excludes=() | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| --key) key="$2"; shift 2 ;; | |
| --port) port="$2"; shift 2 ;; | |
| --exclude) excludes+=("$2"); shift 2 ;; | |
| --no-gitignore) no_gitignore=true; shift ;; | |
| -*) echo "Unknown option: $1" >&2; usage ;; | |
| *) break ;; | |
| esac | |
| done | |
| [[ $# -eq 2 ]] || usage | |
| local local_dir | |
| local_dir="$(realpath "$1")/" | |
| local remote_host remote_dir | |
| if [[ "$2" =~ ^([^:]+):(.+)$ ]]; then | |
| remote_host="${BASH_REMATCH[1]}" | |
| remote_dir="${BASH_REMATCH[2]}" | |
| else | |
| echo "Error: remote must be USER@HOST:PATH" >&2 | |
| exit 1 | |
| fi | |
| command -v inotifywait &>/dev/null || { echo "Error: inotify-tools not installed" >&2; exit 1; } | |
| [[ ${#excludes[@]} -gt 0 ]] || excludes=("${DEFAULT_EXCLUDES[@]}") | |
| local gitignore="$local_dir.gitignore" | |
| if [[ "$no_gitignore" == false && -f "$gitignore" ]]; then | |
| while IFS= read -r line; do | |
| line="${line%%#*}" | |
| line="${line%"${line##*[![:space:]]}"}" | |
| line="${line#"${line%%[![:space:]]*}"}" | |
| [[ -n "$line" ]] && excludes+=("$line") | |
| done < "$gitignore" | |
| fi | |
| local hash | |
| hash="$(session_hash "$local_dir")" | |
| local log_file="$(state_dir)/$hash.log" | |
| local -a rsync_excludes=() inotify_parts=() | |
| for pat in "${excludes[@]}"; do | |
| rsync_excludes+=("--exclude=$pat") | |
| # Convert gitignore glob to POSIX ERE for inotifywait | |
| inotify_parts+=("$(printf '%s' "${pat%/}" | sed -e 's/[.+^${}()|\\]/\\&/g' -e 's/\*/.*/g' -e 's/?/./g')") | |
| done | |
| local inotify_pattern | |
| inotify_pattern=$(IFS='|'; echo "${inotify_parts[*]}") | |
| local ssh_opts="-p $port -o StrictHostKeyChecking=accept-new" | |
| [[ -z "$key" ]] || ssh_opts="-i $key $ssh_opts" | |
| local excludes_str | |
| excludes_str=$(IFS='|'; echo "${excludes[*]}") | |
| ( | |
| set +e | |
| [[ -f "$gitignore" && "$no_gitignore" == false ]] && log "Loaded ${gitignore} excludes" | |
| log "Excluding: ${excludes[*]}" | |
| do_sync() { | |
| rsync -avz --delete \ | |
| -e "ssh $ssh_opts" \ | |
| "${rsync_excludes[@]}" \ | |
| "$local_dir" \ | |
| "${remote_host}:${remote_dir}/" | |
| } | |
| log "Syncing $local_dir -> ${remote_host}:${remote_dir}/" | |
| do_sync | |
| log "Watching for changes" | |
| while inotifywait -r -qq \ | |
| -e modify,create,delete,move \ | |
| --exclude "($inotify_pattern)" \ | |
| "$local_dir"; do | |
| sleep 0.5 | |
| do_sync && log "Synced" || log "Sync failed ($?)" | |
| done | |
| ) >> "$log_file" 2>&1 & | |
| local pid=$! | |
| disown | |
| write_state "$hash" "$pid" "$key" "$port" "$local_dir" "$remote_host" "$remote_dir" "$excludes_str" | |
| echo "Sync daemon started (PID $pid)" | |
| echo " $local_dir -> ${remote_host}:${remote_dir}/" | |
| echo " Log: $log_file" | |
| } | |
| cmd_stop() { | |
| local session | |
| session="$(find_session)" || { echo "Error: no sync session found for $(pwd)" >&2; exit 1; } | |
| read_state "$session" | |
| if kill "$PID" 2>/dev/null; then | |
| echo "Stopped sync daemon (PID $PID)" | |
| else | |
| echo "Process $PID already dead, cleaning up" | |
| fi | |
| local hash | |
| hash="$(basename "$session" .state)" | |
| rm -f "$session" "$(state_dir)/$hash.log" | |
| } | |
| cmd_pull() { | |
| [[ $# -gt 0 ]] || { echo "Usage: $0 pull PATH [PATH...]" >&2; exit 1; } | |
| local session | |
| session="$(find_session)" || { echo "Error: no sync session found for $(pwd)" >&2; exit 1; } | |
| read_state "$session" | |
| local ssh_opts="-p $PORT -o StrictHostKeyChecking=accept-new" | |
| [[ -z "$KEY" ]] || ssh_opts="-i $KEY $ssh_opts" | |
| for path in "$@"; do | |
| path="${path%/}" | |
| local dest_parent="$LOCAL_DIR$(dirname "$path")" | |
| mkdir -p "$dest_parent" | |
| echo "Pulling $path ..." | |
| rsync -az -e "ssh $ssh_opts" "${REMOTE_HOST}:${REMOTE_DIR}/${path}" "$dest_parent/" | |
| done | |
| echo "Done" | |
| } | |
| cmd_logs() { | |
| local session | |
| session="$(find_session)" || { echo "Error: no sync session found for $(pwd)" >&2; exit 1; } | |
| local hash | |
| hash="$(basename "$session" .state)" | |
| exec tail -f "$(state_dir)/$hash.log" | |
| } | |
| cmd_list() { | |
| local found=false | |
| for f in "$(state_dir)"/*.state; do | |
| [[ -f "$f" ]] || break | |
| read_state "$f" | |
| if kill -0 "$PID" 2>/dev/null; then | |
| local status="running" | |
| else | |
| local status="dead" | |
| fi | |
| local id | |
| id="$(basename "$f" .state)" | |
| echo "[$status] $id PID $PID $LOCAL_DIR -> ${REMOTE_HOST}:${REMOTE_DIR}/" | |
| found=true | |
| done | |
| $found || echo "No active sync sessions" | |
| } | |
| [[ $# -gt 0 ]] || usage | |
| cmd="$1"; shift | |
| case "$cmd" in | |
| start) cmd_start "$@" ;; | |
| stop) cmd_stop "$@" ;; | |
| pull) cmd_pull "$@" ;; | |
| list) cmd_list "$@" ;; | |
| logs) cmd_logs "$@" ;; | |
| -h|--help) usage ;; | |
| *) echo "Unknown command: $cmd" >&2; usage ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment