Reject Ugly Commits with Server-Side Git Hooks
Published 2019-5-31Here's what we want:
git push
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 8 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 274 bytes | 274.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0)
remote:
remote: The following files did't pass the code formatting rules:
remote: junky.js
remote:
remote: error: hook declined to update refs/heads/master
When someone commits junk files that don't pass the code linter (i.e. go vet, jshint) or formatter (i.e. go fmt, Prettier), we want to hard reject their commits - on the git server.
A simple starter pack
Typically on your server you'll have a "bare" my-project.git
instead of a project/.git
.
The layout may look like this:
/srv/git-repositories/my-project.git
├── HEAD
├── branches
├── config
├── description
├── hooks
│ ├── post-receive
│ ├── pre-receive
│ └── update
├── index
├── info
│ └── exclude
├── logs
│ └── HEAD
├── objects
│ ├── 00
│ │ └── 00000000000000000000000000000000000000
│ ├── 4b
│ │ └── 825dc642cb6eb9a060e54bf8d69288fbee4904
│ ├── info
│ └── pack
└── refs
├── heads
│ ├── beta
│ └── master
└── tags
└── v1.0.0
The 3 files of particular interest are these server-side git hooks:
* post-receive
* pre-receive
* update
Although it's certainly a much easier term to search for, the pre-receive
hook is only useful
for accepting or rejecting all changes to all branches that are being pushed.
The update
hook, on the other hand, is more granual (and easier to use).
Here's a flawed, but useful, update
hook for server-side code formatting checks
that uses Prettier (though you could use anything), and has all of the pieces you need to get started:
(or skip ahead to the fuller, better script way down below, if you prefer)
ref_name=$1
new_rev=$3
# only check branches, not tags or bare commits
if [ -z $(echo $ref_name | grep "refs/heads/") ]; then
exit 0
fi
# don't check empty branches
if [ "$(expr "${new_rev}" : '0*$')" -ne 0 ]; then
exit 0
fi
# Checkout a copy of the branch (but also changes HEAD)
my_work_tree=$(mktemp -d -t git-work-tree.XXXXXXXX) 2>/dev/null
git --work-tree="${my_work_tree}" --git-dir="." checkout $new_rev -f >/dev/null
# Do the formatter check
echo "Checking code formatting..."
pushd ${my_work_tree} >/dev/null
prettier './**/*.{js,css,html,json,md}' --list-different
my_status=$?
popd >/dev/null
# reset HEAD to master, and cleanup
git --work-tree="${my_work_tree}" --git-dir="." checkout master -f >/dev/null
rm -rf "${my_work_tree}"
# handle error, if any
if [ "0" != "$my_status" ]; then
echo "Please format the files listed above and re-commit."
echo "(and don't forget your .prettierrc, if you have one)"
exit 1
fi
It's also very important that you set the exec
bit,
otherwise the script will be silently skipped:
chmod -R a+x /srv/git-repositories/my-project.git/hooks
If the script does run to completion, then any exit status other than 0 (success) will cause the push to be rejected.
A better version
Although the script above will work, there are a few things that could be improved:
- Leave
HEAD
as-is - Better user feedback
- Make easier to use with any formatter
- Better error detection
- Handle tags (not just branches)
I thought about doing a full play-by-play showing how I built this up... but I opted for just having better comments instead.
There's a lot of bash-fu here. Most of it I understand pretty well, but some of it came from good ol' fashioned StackOverflow-Driven-Development.
I tried to keep it as simple as reasonable, but no simpler - and there's some explanation down below.
#!/usr/bin/env bash
set -u
set -e
ref_name=$1
new_rev=$3
# Here you can use whatever formatter you like
# and set up the error (or success) output as you wish
format() {
local my_work_tree=$1
pushd $my_work_tree >/dev/null
# briefly allow non-zero exit status
set +e
local my_diffs=$(2>&1 /usr/local/bin/prettier './**/*.{js,css,html,json,md}' --list-different | grep -v 'No matching files')
set -e
# Show a friendly error message to the git user downstream (if there are files to fix)
if [ -n "$my_diffs" ]; then
>&2 echo ""
>&2 echo "Please run prettier on the following files (and double check your .prettierrc):"
echo "$my_diffs" | while read my_word; do
>&2 echo " $my_word"
done
>&2 echo ""
>&2 echo "Example:"
>&2 echo " npm install -g prettier"
>&2 echo " prettier './**/*.{js,css,html,json,md}' --write"
>&2 echo ""
fi
popd >/dev/null
# This is the "return" value
echo $my_diffs
}
# This is all pretty generic - just checking out files to lint/format and running said formatter
case "${ref_name}" in
refs/heads/*)
# match empty commit 0000000000000000000000000000000000000000
# all deletes should be successful (no files to check against)
if [ "$(expr "${new_rev}" : '0*$')" -ne 0 ]; then
exit 0
fi
# We don't want a race condition if multiple people push while a check is in progress
# For this reason we first checkout all of the files in HEAD and the copy over the changed files.
# We also need to remove any deleted files.
#
# ex: /tmp/git-work-tree.abc123xyz
my_work_tree=$(mktemp -d -t git-work-tree.XXXXXXXX) 2>/dev/null
git --work-tree="${my_work_tree}" --git-dir="." checkout -f >/dev/null
my_changes=$(git --work-tree="${my_work_tree}" --git-dir="." diff --name-status HEAD $new_rev)
if [ -n "$(echo "$my_changes" | grep -e "^A")" ]; then
echo "$my_changes" | grep -e "^A" | cut -f 2 | \
xargs git --work-tree="${my_work_tree}" --git-dir="." checkout $new_rev --
fi
if [ -n "$(echo "$my_changes" | grep -e "^M")" ]; then
echo "$my_changes" | grep -e "^M" | cut -f 2 | \
xargs git --work-tree="${my_work_tree}" --git-dir="." checkout $new_rev --
fi
if [ -n "$(echo "$my_changes" | grep -e "^D")" ]; then
echo "$my_changes" | grep -e "^D" | cut -f 2 | \
xargs git --work-tree="${my_work_tree}" --git-dir="." rm -rf -- >/dev/null
fi
# Now we run the formatter, do some cleanup, and error out if needed
my_failure=$(format $my_work_tree)
rm -rf "${my_work_tree}"
if [ -n "$my_failure" ]; then
exit 1
fi
;;
refs/tags/*)
# allowing tags to be made regardless of formatting (such as old versions)
exit 0
;;
*)
# allowing all other things (not sure what they are though, TBH)
exit 0
;;
esac
Here's the almanac:
set -u # don't allow unbound variables / guard against typos
set -e # immediately exit if any sub-command has a non-zero exit code
set +e # continue despite failing commands (default)
local foo # declare a variable local to a function
>&2 # redirect stdout to stderr
2>&1 # redirect stderr to stdout
pushd # like cd, but track a stack of directories
popd # like cd -, but can pop directory stack many times
mktemp -d -t git-work-tree.XXXXXXXX # create a temp directory using the template string,
# replacing Xs with random characters
xargs # for each of the preceding newline-delimited strings,
# tack one on to the following command as an argument, and repeat
prettier './**/*.{js,css,html,json,md}' # quote the file pattern glob to pass to prettier, not bash
Well, that's that and I hope you learned something. 🙂
One more thing
You might want to check out Automated Git Deploys and DIY Multi-User Git Deploys as well.
By AJ ONeal
Did I make your day?
Buy me a coffee
(you can learn about the bigger picture I'm working towards on my patreon page )