Created
March 19, 2020 17:23
-
-
Save wk8/85b3843b67b504e5a6776bf55960fd20 to your computer and use it in GitHub Desktop.
sy
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 | |
## 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