Skip to content

Instantly share code, notes, and snippets.

@netj
Last active May 21, 2018
Embed
What would you like to do?
MOVED TO: https://github.com/netj/remocon since 2018-05
#!/usr/bin/env bash
# remocon -- run given command remotely, replicating local git work tree on a remote host, and downloading remote changes if needed
#
# Author: Jaeho Shin <netj@sparcs.org>
# Created: 2018-03-08
##
set -euo pipefail
error() { echo >&2 "📡 ‼️ " "$@"; false; }
warning() { echo >&2 "📡 ⚠️ " "$@"; }
info() { echo >&2 "📡" "$@"; }
################################################################################
# a nimble/simple way to use bash -x itself to get single-quoted escapes instead of backslashes given by printf %q
@q() {
local e
e=$(PS4= bash --norc -xc ': "$@"' -- "$@" 2>&1)
echo "${e:2}"
}
# a handy way to show what command is running
x() {
(
case ${1:-} in (builtin|command) shift;; esac
echo "$PS4$(@q "$@")"
) >&2
"$@"
}
mkdelegate() {
: echo 'generates an executable file for delegating with extra options'
: echo
: echo 'mkdelegate FILE ABS_PATH_TO_COMMAND [OPTION...]'
local file=$1; shift
mkdir -p "$(dirname "$file")"
local script=$(
echo '#!/bin/sh'
echo "$(@q exec "$@")" '"$@"'
)
diff -q <(echo "$script") "$file" &>/dev/null || echo "$script" >"$file"
chmod +x "$file"
}
# a handy way to patch remote
# See: https://github.com/netj/bpm/blob/master/plugin/git-helper
git-tether-remote() {
(
set -euo pipefail
hostpath=$1; shift
case $hostpath in
(*:*) host=${hostpath%%:*} dir=${hostpath#*:};;
(*) error "$hostpath: HOST[:PATH] required";;
esac
commit=$(git rev-parse HEAD)
branch=$(git symbolic-ref --short HEAD)
git remote add remocon/remote "$host:$dir" 2>/dev/null ||
git remote set-url remocon/remote "$host:$dir"
git push -q -f remocon/remote HEAD:"$branch" 2>/dev/null || {
ssh "$host" "
set -eu; PS4='++ '; # set -x
mkdir -p $(@q "$dir")
cd $(@q "$dir")
[[ -e .git ]] || git init
# lift some git config to allow push
git config receive.denyCurrentBranch ignore
"
git push -f remocon/remote HEAD:"$branch"
}
# TODO transfer git config to destination
git remote remove remocon/remote
# bring destination to the current commit
ssh "$host" "set -eu; PS4='++ '; # set -x
cd $(@q "$dir")
branch=$(@q "$branch")
commit=$(@q "$commit")
# reverse any previous patch for tethering
if [[ -s .git/tethered.patch ]]; then
git apply --binary -R <.git/tethered.patch || git stash
mv -f .git/tethered.patch{,~}
fi
if git rev-parse HEAD &>/dev/null; then
# preserve any outstanding/untethered changes
git diff --quiet --exit-code HEAD -- || git stash
# make sure we're on the tethered branch and commit
[[ \$(git symbolic-ref --short HEAD) = \$branch ]] || git checkout -f \$branch --
else
git checkout -f \$branch --
fi
git reset --hard \$commit
"
# send staged and unstaged changes
git diff --full-index --binary HEAD |
ssh "$host" "cat >$(@q "$dir")/.git/tethered.patch"
ssh "$host" "set -eu; PS4='++ '; # set -x
cd $(@q "$dir")
# with the same outstanding changes on top of the current commit
! [[ -s .git/tethered.patch ]] || git apply --binary --apply --stat --cached <.git/tethered.patch
git checkout --quiet .
git reset --quiet
"
# replicate staged changes AKA .git/index
git diff --full-index --binary --cached |
ssh "$host" "cat >$(@q "$dir")/.git/tethered-index.patch"
ssh "$host" "set -eu; PS4='++ '; # set -x
cd $(@q "$dir")
! [[ -s .git/tethered-index.patch ]] || git apply --binary --apply --cached <.git/tethered-index.patch
"
)
}
################################################################################
# common prep and sub-commands
{
# make sure we're in a git work tree
$(git rev-parse --is-inside-work-tree) ||
error "$PWD: Not inside a git work tree"
# find closest .remocon.conf
conf=$(
until [[ $PWD = / || -e .remocon.conf ]]; do cd ..; done
! [[ -e .remocon.conf ]] || echo "$PWD"/.remocon.conf
)
# defaults to not running things remotely
ssh_opts=(
)
bash_opts=(
bash
)
! [[ -e "$conf" ]] || source "$conf"
: ${remote:=localhost}
# parse remote
remote_host=${remote%%:*}
remote_repo_root=${remote#$remote_host}
remote_repo_root=${remote_repo_root#:}
# use local git work tree's basename and keep it under given remote_repo_root dir
remote_repo=${remote_repo_root:+$remote_repo_root/}$(basename "$(git rev-parse --show-toplevel)")
# determine remote workdir based on where in the git repo we're in
local_path_within_git=$(git rev-parse --show-prefix)
remote_workdir="${remote_repo}/${local_path_within_git#/}"
# override ssh options/config
sshBoosterOpts=(
# share an ssh connection across invocation
-o ControlMaster=auto
-o ControlPath="/tmp/remocon-$USER.sock-%r@%h:%p"
-o ControlPersist=600
# forward agent
-A
)
sshBoosterRoot=~/.cache/remocon/ssh
for cmd in scp ssh; do
mkdelegate "$sshBoosterRoot"/bin/"$cmd" "$(type -p "$cmd")" "${sshBoosterOpts[@]}"
done
PATH="$sshBoosterRoot"/bin:"$PATH"
} </dev/null >&2
# tether remote git repo to local one
remocon.put() {
[[ $# -eq 0 ]] || error "Cannot put partial changes under given paths: $(@q "$@")"
info "[$remote_host:$remote_repo/] 🛰 putting a replica of local git work tree on remote"
git-tether-remote "$remote_host:$remote_repo"
} </dev/null >&2
# put and run given command on remote from the same workdir relative to the git top-level (AKA git prefix)
remocon.run() {
remocon.put
[[ $# -gt 0 ]] || set -- bash -il
info "[$remote_host:$remote_workdir] ⚡️ running command: $(@q "$@")"
case $remote_host in
localhost)
# just run given command when remote is local
warning "[$remote_host] Not running remotely"
x "$@"
;;
*)
if [[ -t 0 && -t 1 && -t 2 ]]; then
# when I/O/Err is a fully functional terminal
ssh_opts+=(-t) # ask ssh for tty
bash_opts+=(-i) # ask bash for an interactive shell
fi
x ssh "$remote_host" \
"${ssh_opts[@]:---}" \
"$(@q "${bash_opts[@]}" -c "cd $(@q "$remote_workdir") && exec $(@q "$@")")"
esac
}
# get remote changes back to local
remocon.get() {
[[ $# -gt 0 ]] || set -- .
info "[$remote_host:$remote_workdir] 💎 getting remote files under $# paths: $(@q "$@")"
# TODO use git in case rsync is not available?
x rsync \
--verbose \
--archive \
--hard-links \
--omit-dir-times \
--checksum \
--copy-unsafe-links \
--exclude=.git \
--relative --rsync-path="$(printf 'cd %q &>/dev/null && rsync' "$remote_workdir")" \
"$remote_host":"$(@q "$@")" .
} </dev/null >&2
# programming round-trip mode (put-run-get)
remocon.prg() {
# find which paths to get from given args
# (NOTE path list can be terminated by a double-dash `--` to delinate the command to run)
local pathsToPull=
pathsToPull=()
while [[ $# -gt 0 ]]; do
local arg=$1; shift
case $arg in
--) break ;;
*) pathsToPull+=("$arg")
esac
done
# run command if any were given (after a dash-dash)
local exitStatus=0
[[ $# -eq 0 ]] || remocon.run "$@" || exitStatus=$?
# then get files
set --; [[ ${#pathsToPull[@]} -eq 0 ]] || set -- "${pathsToPull[@]}"
remocon.get "$@" || exitStatus=$?
return $exitStatus
}
################################################################################
# dispatch sub-commands
if ! [[ $# -gt 0 ]]; then
if [[ -t 0 && -t 1 && -t 2 ]]; then
# in a tty, defaults to replicating and opening an interactive/login shell on remote
set -- run
else
# otherwise, defaults to just replicating local git work tree to remote
set -- put
fi
fi
cmd=$1; shift
handler="remocon.$cmd"
type "$handler" &>/dev/null ||
error "$cmd: No such command. Command must be one of: get, put, run, prg"
"$handler" "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment