UPDATED: to add note about the possibility to move to the push phase for pre-commit (the library) hooks. Thanks to @FelixHargreave and others for the remarks. An example is coming soon.

Intro

You’re starting out with a new Scala project. You lay down the groundwork:

build.sbt

.gitignore

.scalafmt.conf

However, you’ve been burned by the ever-more disorganized lists of unused imports in your Scala files, so you decide to be a little more orthodox here. You add the following to your build.sbt :

You write yourself a test class:

You try to run sbt compile :

Excellent, it works!

And then…​

…​after a couple of days/weeks you possibly become fed up with it. Such a strict check is nice to keep your repo clean and maintainable, but delivers considerable pain when actually writing code, especially when implementing new functionality and/or prototyping.

However, you don’t want to go back to the previous, chaotic status quo. Luckily, there’s a way around that.

Git hooks

In fact, Git provides callbacks for various flow elements, i.e. hooks. Hooks can be customized simply via placing any executable script in the .git/hooks directory (every git init call creates sample scripts for your perusal). There are several types of hooks, split into:

client-side: "local" actions such as committing, merging and rebasing,

server-side: various phases of receiving a push.

You can find more relevant information in the official Git docs.

Creating a hook for our purposes

What we want is to have a check for unused imports, but only when we’re actually done with — and ready to commit — a given unit of code. This means we’re looking for a client-side hook. And there’s one that fits our needs perfectly: pre-commit . It works exactly as implied by the name: it runs when you execute git commit , but before the actual commit has been performed.

As described before, we only need to add an executable script (in our example, Bash) to .git/hooks with an appropriate name:

.git/hooks/pre-commit

We don’t need to include anything else. Git will abort the commit if the hook will return a non-zero exit status, and this is exactly what SBT does when the compile task fails.

(Note that we’re using the new, unified setting syntax introduced into SBT a while ago.)

We also remove the following from build.sbt again:

(we don’t have to remove "-Xfatal-warnings" of course, we’re only doing this for the sake of the example)

Finally, you need to set .git/hooks/pre-commit as executable, otherwise you’ll get:

Let’s say you have already committed the project skeleton, and are now attempting to commit the TestClass from the intro.

OK, let’s remove the import statement and try again:

Success! Note the git commit statement at the end.

Are we done?

It’s probable that you, Dear Reader, have noticed a couple of problems with the current approach:

The check is run for all code in the repo, including untracked files. The check is platform-dependent, i.e. it relies on bash being there (so will not work on at least some Windows configs). The hook is local — it’s not persisted anywhere, and not shared among devs.

The first point is a non-issue in this case, thankfully — we almost always want the build to run on the complete codebase.

The two remaining issues can be handled by the somewhat confusingly-named pre-commit project.

Using pre-commit (the library)

After installing the library, we need to create our own hook. Even before that, we need to initialize a separate git project, since this is how pre-commit facilitates hook reuse.

So let’s do that:

Now, we create the actual hook in .pre-commit-hooks.yaml :

Let’s unpack:

the language is set to system , i.e. we’re running a pre-installed command;

is set to , i.e. we’re running a pre-installed command; always_run is set to true , since we’re running the hook "globally", not on specific, filtered files;

is set to , since we’re running the hook "globally", not on specific, filtered files; pass_filenames is set to false for a similar reason - we don’t care about individual files;

is set to for a similar reason - we don’t care about individual files; verbose being set to false means that the output of sbt will only be shown on error.

Before we go further, we need to remove the previous, “raw” hook:

rm .git/hooks/pre-compile

The next logical step is testing our “new” hook. Fortunately, pre-commit allows for that, without actually going forward with any commits:

When writing hook scripts for pre-commit, we don’t even have to commit the alterations — pre-commit will do that for us. Let’s try it out by changing verbose to true .

Note the creation of a temporary repo, enabling testing without a commit.

After re-adding import scala.collection.List to TestClass :

Alright, the hook itself works fine.

Now, we proceed to actually add our hook to the project. First, we need to create a .pre-commit-config.yaml config file on the top level:

Going through the parameters again:

repo is the path to the repository (using the file protocol in this case, but normally https , git , etc.).

is the path to the repository (using the protocol in this case, but normally , , etc.). rev determines the branch or tag for the hooks we want within the hook repo.

determines the branch or tag for the hooks we want within the hook repo. id is, of course, the identifier of the hook we want to use.

Next, we run pre-commit install and receive:

pre-commit installed at .git/hooks/pre-commit

To check whether the hooks are properly installed, we run:

Finally, we add the import statement again:

Alright.

Maintenance

There’s a couple of things to remember when using hooks via the pre-commit project:

of course, you can create any hook you want. Another usage example is adding exceptions for WartRemover rules;

similarly, you don’t have to have your pre-commit hook running in the actual pre-commit phase. If your build time is long and you commit often, you might want to move the checks to the push phase (or pre-push , actually). See e.g. here for an example config, and here for more information;

phase. If your build time is long and you commit often, you might want to move the checks to the phase (or , actually). See e.g. here for an example config, and here for more information; whenever you check out a new repo, you need to run pre-commit install . Unfortunate, but them’s the breaks (this is potentially solvable by an IDE or shell plugin, haven’t found anything yet 'though);

. Unfortunate, but them’s the breaks (this is potentially solvable by an IDE or shell plugin, haven’t found anything yet 'though); Whenever a hook repo updates, i.e. there’s a new version, you need to run pre-commit autoupdate ;

; IDEs provide an option to use the --no-verify parameter, which skips hook execution. You need to be aware of your defaults. Here’s how IDEA handles this, for example;

parameter, which skips hook execution. You need to be aware of your defaults. Here’s how IDEA handles this, for example; for the reason above, you should consider adding either a server-side hook to your CI server, or just add relevant options to the CI’s sbt call, so there’s no temptation of "quickly skipping" your check(s);

call, so there’s no temptation of "quickly skipping" your check(s); IDEs might not correctly output ANSI colors from git hook calls, resulting in garbage like [0m[0mDone updating.[0m . If this is the case, you can add the -no-colors option to your sbt call.

Closing thoughts

The interesting thing about the pre-commit library is that the system "language" that we used is an outlier. Normally, pre-commit provides virtual environments for the scripts, so that they can be run in a truly platform-independent manner.

If you’d take a look at the supported languages, you’ll see that they’re mostly either dynamic languages, or relatively “young” ones. Perhaps this can explain why pre-commit hooks remain comparatively rare in the JVM world — most JVM languages already have robust build systems that handle the majority of the pre- and post-processing tasks required.

Are you using git hooks in Scala or other JVM languages? Have any interesting hints, or experiences to share? If so, please leave a comment!