Git hooks are small bash scripts that are run each time a certain git event happens. The most popular hooks in our projects are pre-commit
and pre-push
. Let us show you how to optimize and manage these using babashka
Small Intro to Git Hooks
Git hooks are scripts that Git executes before or after one of the following events: commit, push, and receive.
By default a git repository comes with a bunch of sample hooks, located in .git/hooks
directory. Let's look at a pre-commit.sample
hook.
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".
if git rev-parse --verify HEAD >/dev/null 2>&1
then
against=HEAD
else
# Initial commit: diff against an empty tree object
against=$(git hash-object -t tree /dev/null)
fi
# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --type=bool hooks.allownonascii)
# Redirect output to stderr.
exec 1>&2
# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
# Note that the use of brackets around a tr range is ok here, (it's
# even required, for portability to Solaris 10's /usr/bin/tr), since
# the square bracket bytes happen to fall in the designated range.
test $(git diff --cached --name-only --diff-filter=A -z $against |
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then
cat <<\EOF
Error: Attempt to add a non-ASCII file name.
This can cause problems if you want to work with people on other platforms.
To be portable it is advisable to rename the file.
If you know what you are doing you can disable this check using:
git config hooks.allownonascii true
EOF
exit 1
fi
# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --
This is a 50 line script that checks, that the files you're committing do not contain any non-ascii characters. Sound like a good idea, but fifty lines of bash don't look friendly.
And if you want to add more things to the pre-commit hook, like formatting all the modified files with a code formatter, you'd need to append to it, making your life even harder. After all, who wants to code with bash?
So, let's replace all this bash with babashka.
Project Setup
I'll assume we're working within a Clojure repo, but in fact this doesn't really matter. Babashka is a standalone binary that will work for a project in any language.
- Install babashka. For Mac OS and brew just run:
brew install borkdude/brew/babashka
For everything else follow official installation instructions.
- Create a
bb.edn
file and abb
folder.
Babashka Tasks
Babashka comes with an amazing and useful task runner, which aims to replace Makefile
, Justfile
, npm scripts
or lein
and for us it has already replaced all of these. So we'll create our first task.
For those of you who have never dealt with babashka tasks, let's make a really simple one. Put this in your bb.edn
:
{:paths ["bb"]
:tasks
{hello {:doc "Say hello"
:task (println "Hello from babashka tasks!")}}}
Now if you run bb tasks
from the root of your project babashka will list all available tasks:
$ bb tasks
The following tasks are available:
hello Say hello
And you already now what running bb hello
will do:
$ bb hello
Hello from babashka tasks!
So now that we know the tasks basics let's get back to hooks.
Basic Hooks Script
Create bb/git_hooks.clj
and open it in your editor of choice. And since it's a babashka script and in the wonderful world of Clojure we practice interactive (or REPL-driven) development, you can fire up a babashka REPL and play with the code while working.
Let's start with moving our hello
task to this file to understand how this works. Put this in your bb/git_hooks.clj
:
;; bb/git_hooks.clj
(ns git-hooks)
(defn hello [& args]
(println "Hello! My args are:" args))
Let's change the bb.edn
file to use the hello
function from the git-hooks
namespace.
;; bb.edn
{:paths ["bb"]
:tasks
{hello {:doc "Say hello"
:requires ([git-hooks :as gh])
:task (apply gh/hello *command-line-args*)}}}
There are a few important things to note. First the :requires
line:
;; bb.edn
{:paths ["bb"]
:tasks
{hello {:doc "Say hello"
:requires ([git-hooks :as gh])
:task (apply gh/hello *command-line-args*)}}}
It is slightly different from the traditional Clojure's :require
form (note the plural), but the idea is the same: we defined the source paths in the first line, and now we can require any namespace from it.
Second, note how we ran the task now:
;; bb.edn
{:paths ["bb"]
:tasks
{hello {:doc "Say hello"
:requires ([git-hooks :as gh])
:task (apply gh/hello *command-line-args*)}}}
If we didn't want to pass command line arguments into our function, we could just write:
:task gh/hello
And if we do, babashka gives us access to the args with *command-line-args*
variable which we can pass or apply to our function. Let's try it out:
$ bb hello world
Hello! My args are: (world)
Setting up Hooks
Since there are various tasks we'll be executing and it's quite trivial to accept commands in Clojure, let's make one task called hooks
which will accept and run commands, like pre-commit
, pre-push
, install
etc.
Let's edit bb/git_hooks.clj
like this:
;; bb/git_hooks.clj
(ns git-hooks)
(defmulti hooks (fn [& args] (first args)))
(defmethod hooks "pre-commit" [& _]
(println "Running pre-commit hook"))
(defmethod hooks :default [& args]
(println "Unknown command:" (first args)))
We're using a standard polymorphism tool in clojure — multimethod, which will treat the first command line argument as command.
And bb.edn
:
;; bb.edn
{:paths ["bb"]
:tasks
{hooks {:doc "Hook related commands"
:requires ([git-hooks :as gh])
:task (apply gh/hello *command-line-args*)}}}
:task (apply gh/hooks *command-line-args*)}}}
Not much changed here, and in fact we will not need to edit bb.edn
any more, since this already has us covered for implementing hooks of any complexity. We just changed the name of the function we're calling.
Installing Hooks (manually)
We already have the simplest possible hook — our hooks
function when called with pre-commit
argument prints "Running pre-commit hook":
$ bb hooks pre-commit
Running pre-commit hook
In order for the git to run our function each time we commit anything, let's add a following .git/hooks/pre-commit
file:
#!/bin/sh
bb hooks pre-commit
And make it executable:
$ chmod +x .git/hooks/pre-commit
Now if we run git add
and git commit
we should see the printed text:
$ git add .
$ git commit -m "Test simple hook"
Running pre-commit hook
[master (root-commit) c7dc8e4] Test simple hook
2 files changed, 16 insertions(+)
create mode 100644 bb.edn
create mode 100644 bb/git_hooks.clj
And indeed, here it is.
Once we have "installed" the hook this way, we can now modify it's behavior in bb/git_hooks.clj
. But before giving a couple of examples of doing that, let's automate the boring part: installing hooks.
Installing Hooks (bb task)
Let's add a couple of helper functions and add an install
implementation to our hooks
multimethod:
;; bb/git_hooks.clj
(ns git-hooks
(:require [babashka.fs :as fs]))
(defn hook-text
[hook]
(format "#!/bin/sh
# Installed by babashka task on %s
bb hooks %s" (java.util.Date.) hook))
(defn spit-hook
[hook]
(println "Installing hook: " hook)
(let [file (str ".git/hooks/" hook)]
(spit file (hook-text hook))
(fs/set-posix-file-permissions file "rwx------")
(assert (fs/executable? file))))
(defmulti hooks (fn [& args] (first args)))
(defmethod hooks "install" [& _]
(spit-hook "pre-commit"))
(defmethod hooks "pre-commit" [& _]
(println "Running pre-commit hook"))
(defmethod hooks :default [& args]
(println "Unknown command:" (first args)))
Let's go over this:
(defn hook-text
[hook]
(format "#!/bin/sh
# Installed by babashka task on %s
bb hooks %s" (java.util.Date.) hook))
This is pretty straightforward. It creates a bash script string for a hook of our choice and timestamps it.
(defn spit-hook
[hook]
(println "Installing hook: " hook)
(let [file (str ".git/hooks/" hook)]
(spit file (hook-text hook))
(fs/set-posix-file-permissions file "rwx------")
(assert (fs/executable? file))))
This part is slightly more interesting. It writes the output of hook-text
into the actual hook and uses babashka.fs
library to make it executable.
Let's run bb hooks install
and check that the hook file has in fact changed:
$ bb hooks install
Installing hook: pre-commit
$ cat .git/hooks/pre-commit
#!/bin/sh
# Installed by babashka task on Wed Nov 30 01:06:37 WET 2022
bb hooks pre-commit
Despite being so simple this little trick gives us an enormous advantage. You see, git hooks are user local. If we create a pre-commit
hook on our machine it will stay on our machine for security reasons.
So if we want our entire team to use the same hooks, normally we would need to make sure that they manually implement same hooks as us. The babashka way let's us simply mention in our documentation that you need to run bb hooks install
after checking out the project and we're done.
Listing Changed Files
After installing hooks this way we will never have to open the .git/hooks
directory or edit bash files. We control every aspect of our hooks from within the bb/git_hooks.clj
files, and can use babashka and Clojure to implement hooks of any complexity.
Before we move on to our first real hook, we need one last helper function:
;; bb/git_hooks.clj
(ns git-hooks
(:require [babashka.fs :as fs]
[clojure.string :as str]
[clojure.java.shell :refer [sh]]))
(defn changed-files []
(->> (sh "git" "diff" "--name-only" "--cached" "--diff-filter=ACM")
:out
str/split-lines
(filter seq)
seq))
...
For many real-life tasks (think code formatting), running something like cljstyle over your entire code base might take too much time. So to optimize that you'd want to only run it over staged files. That's where our changed-files
function comes in. It returns a list of staged files, excluding any deleted files — ready to be passed to whatever logic you want to run over them.
"Real" Hook
Let's add a step to our pre-commit hook that formats all staged file using cljstyle
:
(defmethod hooks "pre-commit" [& _]
(println "Running pre-commit hook")
(when-let [files (changed-files)]
(apply sh "cljstyle" "fix" files)))
Honestly, I strongly prefer this to any bash. And if you want to add any more logic to be performed over the changed files, you can do it right in the pre-commit
implementation of the hooks
function.
In order to make the formatting hook production ready we need to make sure it's only run over Clojure files. Let's make another predicated for that:
(def extensions #{"clj" "cljx" "cljc" "cljs" "edn"})
(defn clj?
[s]
(when s
(let [extension (last (str/split s #"\."))]
(extensions extension))))
And put it all together:
;; bb/git_hooks.clj
(ns git-hooks
(:require [babashka.fs :as fs]
[clojure.string :as str]
[clojure.java.shell :refer [sh]]))
(defn changed-files []
(->> (sh "git" "diff" "--name-only" "--cached" "--diff-filter=ACM")
:out
str/split-lines
(filter seq)
seq))
(def extensions #{"clj" "cljx" "cljc" "cljs" "edn"})
(defn clj?
[s]
(when s
(let [extension (last (str/split s #"\."))]
(extensions extension))))
(defn hook-text
[hook]
(format "#!/bin/sh
# Installed by babashka task on %s
bb hooks %s" (java.util.Date.) hook))
(defn spit-hook
[hook]
(println "Installing hook: " hook)
(let [file (str ".git/hooks/" hook)]
(spit file (hook-text hook))
(fs/set-posix-file-permissions file "rwx------")
(assert (fs/executable? file))))
(defmulti hooks (fn [& args] (first args)))
(defmethod hooks "install" [& _]
(spit-hook "pre-commit"))
(defmethod hooks "pre-commit" [& _]
(println "Running pre-commit hook")
(when-let [files (changed-files)]
(apply sh "cljstyle" "fix" (filter clj? files))))
(defmethod hooks :default [& args]
(println "Unknown command:" (first args)))
Now each time we run git commit
every Clojure file will be formatted with cljstyle
.
Now you can build from here, for example creating a pre-push
hook that will not allow pushing if clj-kondo finds any errors. Or building anything that you want with babashka.
All without writing a single line of bash.