Skip to content

Instantly share code, notes, and snippets.

@dale-c-anderson
Last active June 14, 2016 11:41
Show Gist options
  • Save dale-c-anderson/b791c79de07d604b76db to your computer and use it in GitHub Desktop.
Save dale-c-anderson/b791c79de07d604b76db to your computer and use it in GitHub Desktop.
Git Hook Chaining
#!/bin/bash -ue
# To use:
# wget -q -O - https://gist.githubusercontent.com/dale-c-anderson/b791c79de07d604b76db/raw/hook-chain-install.sh | bash
# @TODO: Allow use of either wget or curl (try the other if the first doesn't exist)
function main () {
DIRNAME=$(basename "$PWD")
if [ ! "$DIRNAME" = "hooks" ]; then
cerr "ERR: You need to be in a git hooks directory to install hook-chain."
exit 1
fi
type wget > /dev/null 2>&1 || {
cerr "ERR: Wget not installed or not in path; cannot install hook-chain."
exit 1
}
SAMPLEFILESCOUNT=$(find ./ -type f -executable -name '*.sample' | wc -l)
if [ "$SAMPLEFILESCOUNT" -gt 0 ]; then
echo "FYI: Executable hooks named '<hook-type>.sample' will be ignored by hook-chain."
echo "To reduce clutter and confusion, it is recommended to either remove the sample hooks, or unset their execute bits."
fi
( [ -f hook-chain ] || [ -L hook-chain ] || [ -f hook-chain.sh ] || [ -L hook-chain.sh ] ) && {
echo "Hook-chain appears to already be installed."
exit 0
}
wget -nv -O hook-chain https://gist.githubusercontent.com/dale-c-anderson/b791c79de07d604b76db/raw/hook-chain.sh || {
cerr "ERR: Download failed. Hook-chain was not installed."
exit 1
}
chmod +x hook-chain || {
cerr "ERR: Hook-chain was downloaded, but could not be made executable. You'll need to do this manually before hook-chain will work."
}
HOOKS="applypatch-msg pre-applypatch post-applypatch pre-commit prepare-commit-msg commit-msg post-commit pre-rebase post-checkout post-merge pre-receive update post-receive post-update pre-auto-gc post-rewrite pre-push"
MOVED_BY_INSTALLER=0
for HOOK in $HOOKS; do
# The order of these tests matter.
if test -L "./$HOOK"; then
echo "$HOOK skipped: it's already a symlink to something else."
continue
elif test -f "./$HOOK"; then
# This is hook with a standard name that, if executable, will need to be moved so a symlink
# can be created in its place. Stay in the loop so we can act on it in the next section.
true
elif test -f "./$HOOK.sample"; then
# Ignore sample hooks.
continue
elif other_suitable_hook_scripts_exist "$HOOK"; then
# This is already named in a way can be processed by hook-chain: i.e. 'post-receive.notify-developers'.
# It doesn't need to be moved, but if the file is executable, a symlink to hook-chain will need to be created for
# the hook. Stay in the loop so we can act on it in the next section.
true
else
# Ignore hooks that don't yet exist. We could theoretically make a link to hook
# chain for all the possible hooks, but that would be presumptuous, not to mention messy.
continue
fi
# This next bit here is the whole point of having an install script.
if test -x "./$HOOK"; then
# Standard filename. Move it and create a link.
( mv -v "$HOOK" "$HOOK.unspecified-action" && ln -s hook-chain "$HOOK" ) || {
cerr "ERR: Could not move and relink $HOOK. Check permissions and try again."
}
MOVED_BY_INSTALLER=$((MOVED_BY_INSTALLER+1))
elif other_suitable_hook_scripts_exist "$HOOK"; then
# Already named in a format that's usable by hook-chain. Just set up a link.
# shellcheck disable=SC2086
echo "Linking '$HOOK' because of" ./$HOOK.*
ln -s hook-chain "$HOOK" || cerr "ERR: Could not link $HOOK. Check permissions and try again."
else
echo "$HOOK skipped: it's not executable."
continue
fi
done
if git rev-parse --is-bare-repository > /dev/null; then
# Create symlinks for pre/and post receive, regardless of whether there are hooks to use or not.
# Since pre and post receive hooks occur frequently, this saves a little effort on behalf of person installing hook-chain.
# This is an installer, after all. The user is going to be annoyed if there's more work required of them after it's finished.
test -L "pre-receive" || ln -s "hook-chain" "pre-receive"
test -L "post-receive" || ln -s "hook-chain" "post-receive"
fi
echo "Hook-chain is installed."
if [ $MOVED_BY_INSTALLER -gt 0 ]; then
echo "You should now rename any '.unspecified-action' scripts so the filename reflects it's real purpose."
echo "For example: $ mv post-receive.unspecified-action post-receive.notify-developers"
fi
}
function cerr () {
>&2 echo "$@"
}
# Returns 0 for true, any other number for false.
function other_suitable_hook_scripts_exist () {
HOOKTYPE="$1"
RETVAL=999
for f in ./"$HOOKTYPE."*; do
# Thanks to http://stackoverflow.com/a/6364244/267455
## Check if the glob gets expanded to existing files.
## If not, f here will be exactly the pattern above
## and the exists test will evaluate to false.
if [ -e "$f" ]; then
RETVAL=0
else
RETVAL=1
fi
## This is all we needed to know, so we can break after the first iteration
break
done
return $RETVAL
}
main "$@" || exit 1
#!/bin/bash
###############################################################################
# Git Hook Chaining, Dale Anderson, 2015-02-17
# based on code found at: http://stackoverflow.com/a/8734391/267455
# and: https://github.com/henrik/dotfiles/blob/master/git_template/hooks/pre-commit
#------------------------------------------------------------------------------
#
# To install:
#
# 1) Copy this file in to your git hooks dir, and make it executable.
#
# 2) If you have an existing hook, rename it as "hook-type.action"
# e.g: $ mv post-receive post-receive.email-developers
#
# 3) Create your primary hook as a symlink to this file.
# e.g: $ ln -s hook-chain.sh post-receive
#
#
# 4) Optionally specify a location to log output of all hooks in git config:
# e.g: $ git config hookchain.log /path/to/writable/location/hookchain.log
#
# That's it. Now this script will execute every chained hook it finds.
#
# You can create as many more executable "hook-type.some-action" files you want,
# and they'll all get executed in alphabetical order.
#
# Repeat steps 2 and 3 for every hook you need to run multiple scripts for. All
# your hooks can be symlinked to the same master hook-chain script, and they'll
# all behave the same way.
#
#------------------------------------------------------------------------------
function get_verified_log_location () {
# Returns the location of a writable log file, or /dev/null
# See if it's been specified.
CHAINLOG="$(git config hookchain.log)" || {
echo "/dev/null"
return
}
# Ensure we can write to it. Failing to write to a bad location will
# cause a false failure for a chained hook, causing annoyance to the folks
# who actually want to get some work done.
touch "$CHAINLOG" || {
>&2 echo "invalid git config hookhookchain.log: $CHAINLOG"
echo "/dev/null"
return
}
# If we got this far, the log file is writable.
echo "$CHAINLOG"
}
# Whatever this script was called as (pre-receive, post-receive, update, etc),
# that's the pattern of file names we'll be looking for to execute
# (post-receive.something, pre-receive.something, etc)
hookname=$(basename "$0")
# Capture what came from STDIN so it can be passed through to our chained hooks
data=$(cat)
# Keep track of exit codes so we can return an error if any hook failed.
exitcodes=()
# Optionally log the output of hooks. If no location specified, or if not writable, will write to /dev/null.
CHAINLOG="$(get_verified_log_location)"
# Since we are piping the hook through tee for logging, make sure
# a failed hook exit code is still captured.
set -o pipefail
# Process all the 'hookname.action' files we find and store the exit codes.
for hook in "$GIT_DIR/hooks/$hookname."*; do
if test -x "$hook"; then
if [[ "$hook" == "$GIT_DIR/hooks/$hookname.sample" ]]; then
# Ignore all the 'hookname.sample' files, as those were created by git, and it's not likely someone has named their hook that way.
echo "Hook-chain: ignoring $hook"
else
# This next line is how we impersonate the hook.
# We pipe anything we got from stdin, for hooks like pre-receive and post-receive.
# We forward any command line arguments we got after it, for hooks like update.
# Between both of these, this file can successfully impersonate any type of hook.
echo "$data" | "$hook" "$@" 2>&1 | tee -a "$CHAINLOG"
STATUS=$?
exitcodes+=($STATUS)
# Let the user know if a chained hook exited with an error.
[ $STATUS -eq 0 ] || {
echo "### Warning ### $hookname chained hook '$hook' exited with status $STATUS" | tee -a "$CHAINLOG"
}
fi
fi
done
# If any exit code isn't 0, exit with that code.
for ((i=0; i < ${#exitcodes[@]}; i++)); do
CODE=${exitcodes[$i]}
[ "$CODE" -eq 0 ] || exit "$CODE"
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment