Skip to content

Instantly share code, notes, and snippets.

@wk8
Created March 19, 2020 17:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save wk8/85b3843b67b504e5a6776bf55960fd20 to your computer and use it in GitHub Desktop.
Save wk8/85b3843b67b504e5a6776bf55960fd20 to your computer and use it in GitHub Desktop.
sy
#!/bin/bash
## Wrapper around rsync or unison
## Assumes the target has the relevant bits installed
POST_SYNC_SCRIPT_NAME='.sy/post.sh'
POST_SYNC_WIN_SCRIPT_NAME='.sy/post.ps1'
POST_SYNC_LINUX_SCRIPT_NAME='.sy/post.sh'
SYNC_CONFIG_NAME='.sy/config.json'
usage() {
echo "$0 -h|--host? DEST_HOST -p|--path DEST_PATH (--type rsync|unison|git) (-w|--watch) (-s|--src SRC/PATH) (-e|--exclude PATTERN1) (-e|--exclude PATTERN2...) (-n|--dry-run) (-c|--callback FUN_NAME) (--exclude-symlinks) (-t|--talk) -- EXTRA ARGS PASSED AS IS TO THE SYNC TOOL"
echo ' Defaults to git method, current dir, no excludes'
echo ' If --win is set, that means the target is a Windows machine (should be set automatically if it actually is)'
echo ' If --watch is set, runs the sync every time a change is detected'
echo " If a --callback is provided, it should be the name of a bash function that will be LOCALLY run after each sync (should have been exported) - if this is not set and a $POST_SYNC_SCRIPT_NAME is present in SRC, then it runs that script REMOTELY after each sync"
echo " If a $SYNC_CONFIG_NAME file is present in SRC, it can set default for that directory"
exit 1
}
main() {
local SRC='.'
local IS_WIN=false
local EXCLUDES=('*unison*')
# other options, alphabetical order
local CALLBACK DEST_HOST DEST_PATH DRY_RUN EXCLUDE_SYMLINKS EXTRA_ARGS TALK TYPE WATCH
# parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
# long options, please maintain alphabetical order
--exclude-symlinks)
EXCLUDE_SYMLINKS=true && shift ;;
--type)
TYPE="$2" && shift ;;
--win)
IS_WIN=true ;;
# short options, please maintain alphabetical order
-c|--callback)
CALLBACK="$2" && shift ;;
-e|--exclude)
EXCLUDES+=("$2") && shift ;;
-h|--host)
[ "$DEST_HOST" ] && usage
DEST_HOST="$2" && shift ;;
-n|--dry-run)
DRY_RUN=true ;;
-p|--path)
DEST_PATH="$2" && shift ;;
-s|--src)
SRC="$2" && shift ;;
-t|--talk)
TALK=true ;;
-w|--watch)
WATCH=true ;;
--)
# beginning of extra args
shift && EXTRA_ARGS="$@" && break ;;
-*)
fatal_error "Unknown option: $1" && usage ;;
*)
[ "$DEST_HOST" ] && usage
DEST_HOST="$1"
;;
esac
shift
done
# cd to $SRC, simplifies a few things below
cd "$SRC" || return $?
local SYNC_CONFIG
[ -r "$SYNC_CONFIG_NAME" ] && SYNC_CONFIG="$(cat "$SYNC_CONFIG_NAME")"
# get the host from the config, if needed
[ "$DEST_HOST" ] || DEST_HOST="$(get_from_config 'host' "$SYNC_CONFIG")" || usage
# establish the SSH master connection's socket
local SSH_SOCKET="/tmp/sy-ssh-socket.$DEST_HOST"
[ -f "$SSH_SOCKET" ] && do_ssh "$DEST_HOST" "$SSH_SOCKET" -O exit
do_ssh "$DEST_HOST" "$SSH_SOCKET" -f -N -M
# auto-detect windows
if ! $IS_WIN; then
local OUTPUT
OUTPUT="$(do_ssh "$DEST_HOST" "$SSH_SOCKET" echo %HOME%)" || return $?
if [[ "$OUTPUT" == '%HOME%' ]]; then
echo_info 'Detected Linux'
else
echo_info 'Detected Windows'
IS_WIN=true
fi
fi
# get the path from the config, if needed
if [ ! "$DEST_PATH" ]; then
if $IS_WIN; then
DEST_PATH="$(get_from_config 'path_windows' "$SYNC_CONFIG")"
else
DEST_PATH="$(get_from_config 'path_linux' "$SYNC_CONFIG")"
fi
[ "$DEST_PATH" ] || DEST_PATH="$(get_from_config 'path' "$SYNC_CONFIG")" || usage
fi
# get the rest from the config, if relevant - alphabetical order
if [ "$SYNC_CONFIG" ]; then
[ "$CALLBACK" ] || CALLBACK="$(get_from_config 'callback' "$SYNC_CONFIG")"
[ "$DRY_RUN" ] || WATCH=$(get_from_config 'dry_run' "$SYNC_CONFIG") || DRY_RUN=false
[ "$EXCLUDE_SYMLINKS" ] || EXCLUDE_SYMLINKS=$(get_from_config 'exclude_symlinks' "$SYNC_CONFIG") || EXCLUDE_SYMLINKS=false
[ "$EXTRA_ARGS" ] || CALLBACK="$(get_from_config 'extra_args' "$SYNC_CONFIG")"
[ "$TALK" ] || WATCH=$(get_from_config 'talk' "$SYNC_CONFIG") || TALK=false
[ "$TYPE" ] || TYPE="$(get_from_config 'type' "$SYNC_CONFIG")" || TYPE=git
[ "$WATCH" ] || WATCH=$(get_from_config 'watch' "$SYNC_CONFIG") || WATCH=false
# excludes are a bit special - they're additive
local EXTRA_EXCLUDES=()
readarray -t EXTRA_EXCLUDES < <(echo "$SYNC_CONFIG" | jq -r '.excludes[]' 2> /dev/null)
EXCLUDES+=("${EXTRA_EXCLUDES[@]}")
fi
# exclude symlinks, if relevant
if $EXCLUDE_SYMLINKS; then
local SYMLINK_EXCLUDES=()
# courtesy of https://stackoverflow.com/questions/2596462/how-to-strip-leading-in-unix-find/2596736#2596736
readarray -t SYMLINK_EXCLUDES < <(find . -type l -printf '%P\n')
EXCLUDES+=("${SYMLINK_EXCLUDES[@]}")
fi
# build the sync command
local CMD EXCLUDE
case "$TYPE" in
rsync)
# if the destination path starts with eg 'C:/', we're syncing to a windows
# host, and we need to rewrite to eg '/cygdrive/c'
# note that this is NOT case-sensitive
local RSYNC_PATH="$DEST_PATH"
if [[ "${DEST_PATH:1:2}" == ':/' ]]; then
local DRIVE="${DEST_PATH:0:1}"
local REST="${DEST_PATH:2}"
RSYNC_PATH="/cygdrive/$DRIVE$REST"
fi
CMD="rsync --perms --acls --delete --archive --compress --progress --rsh='ssh -S \"$SSH_SOCKET\"' . $DEST_HOST:$RSYNC_PATH"
# see https://github.com/wk8/vagrant-instant-rsync-auto/blob/309f1ef2a4c03f1cfa8fb73cea0dbfc6a9c48243/lib/vagrant-instant-rsync-auto/helper.rb#L196-L204
if $IS_WIN; then
CMD+=' --chmod=ugo=rwX'
fi
for EXCLUDE in "${EXCLUDES[@]}"; do
CMD+=" --exclude '$EXCLUDE'"
done
;;
unison)
CMD="unison -killserver -force . -batch . ssh://$DEST_HOST/$DEST_PATH"
for EXCLUDE in "${EXCLUDES[@]}"; do
# see http://www.cis.upenn.edu/~bcpierce/unison/download/releases/stable/unison-manual.html#pathspec
CMD+=" -ignore 'Regex $(pattern_to_regex "$EXCLUDE")'"
done
;;
git)
echo_warn "excludes are ignored for gsync, it relies on the repo's .gitignore file(s) instead"
# but they are not ignored for fswatch!
EXCLUDES=('.git/*')
CMD="git sy --host $DEST_HOST --path $DEST_PATH --master-ssh-socket $SSH_SOCKET --remote-shell "
$IS_WIN && CMD+='batch' || CMD+='bash'
;;
*)
fatal_error "Unknown sync type: $TYPE" && usage
;;
esac
[ "$EXTRA_ARGS" ] && CMD+=" $EXTRA_ARGS"
echo "$CMD"
# show time
if ! $DRY_RUN; then
echo_info 'Initial sync...'
perform_sync "$DEST_HOST" "$DEST_PATH" "$CMD" "$SSH_SOCKET" "$IS_WIN" "$CALLBACK" || fatal_error "Error in initial sync, exit code: $?"
$TALK && say 'Initial sync done'
if $WATCH; then
local FSWATCH_CMD='fswatch --one-per-batch .'
for EXCLUDE in "${EXCLUDES[@]}"; do
FSWATCH_CMD+=" --exclude '$(pattern_to_regex "$EXCLUDE" '/')'"
done
echo_info "Watching for changes in $(readlink -f .)..."
echo "$FSWATCH_CMD"
local IN
eval "$FSWATCH_CMD" | while read IN; do
echo_info "$IN files changed, syncing"
# the < /dev/null part is important!
# see https://stackoverflow.com/questions/19895185/bash-shell-read-error-0-resource-temporarily-unavailable
perform_sync "$DEST_HOST" "$DEST_PATH" "$CMD" "$SSH_SOCKET" "$IS_WIN" "$CALLBACK" < /dev/null || echo_warn "Error when syncing, exit status: $?"
$TALK && say 'Synced'
done
fi
fi
}
get_from_config() {
local KEY="$1"
local SYNC_CONFIG="$2"
[ "$SYNC_CONFIG" ] || return 1
local VALUE="$(echo "$SYNC_CONFIG" | jq -r ".$KEY")"
[ "$VALUE" ] && [[ "$VALUE" != 'null' ]] && echo "$VALUE" && return
return 2
}
# turns an rsync/globbing pattern into a regex
pattern_to_regex() {
local PATTERN="$1"
local START_DELIMITER="$2"
[ "$START_DELIMITER" ] || START_DELIMITER='^'
[ "${PATTERN:0:1}" == '*' ] || PATTERN="$START_DELIMITER$PATTERN"
echo "${PATTERN//\*/.*}"
}
perform_sync() {
local DEST_HOST="$1"
local DEST_PATH="$2"
local CMD="$3"
local SSH_SOCKET="$4"
local IS_WIN="$5"
local CALLBACK="$6"
local START=$(millisecs)
eval "$CMD" || return $?
echo_info "Synced in $(( $(millisecs) - START )) milliseconds"
local EXIT_STATUS=''
if [ "$CALLBACK" ]; then
echo_info 'Running user-provided callback'
START=$(millisecs)
$CALLBACK
EXIT_STATUS=$?
else
local SCRIPT_NAMES=()
$IS_WIN && SCRIPT_NAMES+=("$POST_SYNC_WIN_SCRIPT_NAME") || SCRIPT_NAMES+=("$POST_SYNC_LINUX_SCRIPT_NAME")
SCRIPT_NAMES+=("$POST_SYNC_SCRIPT_NAME")
local SCRIPT_NAME
for SCRIPT_NAME in "${SCRIPT_NAMES[@]}"; do
if [ -r "$SCRIPT_NAME" ]; then
echo_info "Running $SCRIPT_NAME at $DEST_HOST:$DEST_PATH"
local EXTENSION="${SCRIPT_NAME##*.}"
local INTERPRETER
case "$EXTENSION" in
sh)
INTERPRETER='bash' ;;
ps1)
INTERPRETER='powershell -file' ;;
*)
warn "Unknown extension for a post-sy script: $EXTENSION" && return 1 ;;
esac
START=$(millisecs)
do_ssh "$DEST_HOST" "$SSH_SOCKET" "cd \"$DEST_PATH\" && $INTERPRETER $SCRIPT_NAME"
EXIT_STATUS=$?
break
fi
done
fi
if [ "$EXIT_STATUS" ]; then
# something ran
[[ "$EXIT_STATUS" == 0 ]] && echo_info "Post sync ran in $(( $(millisecs) - START )) milliseconds"
fi
return $EXIT_STATUS
}
do_ssh() {
local DEST_HOST="$1"
local SSH_SOCKET="$2"
shift 2
ssh -S "$SSH_SOCKET" "$DEST_HOST" "$@"
}
millisecs() {
echo $(( $(date +%s%N) / 1000000 ))
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment