Skip to content

Instantly share code, notes, and snippets.

@e3ntity
Last active March 4, 2026 14:48
Show Gist options
  • Select an option

  • Save e3ntity/e4e7c1d3fb4814ea0ed62ca1c15189ed to your computer and use it in GitHub Desktop.

Select an option

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