#tech
Manage git hooks with babashka tasks

November 30, 2022

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. 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 (don't read it, just take a glance).

#!/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 --

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.

  1. Install babashka.For Mac OS and brew just run:
    brew install borkdude/brew/babashka
    

For everything else follow official installation instructions.

  1. Create a bb.edn file and a bb 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])  // [!code focus]
         :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*)}}}  // [!code focus]

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*)}}}  // [!code --]
         :task (apply gh/hooks *command-line-args*)}}}  // [!code ++]

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.


Are you looking for a comments section? I would love to hear your feedback, but managing a comments section is a separate job. You can reach me on Mastodon or send me an email to public@tarka.dev

2022-2024 © Attribution-ShareAlike 4.0 International