Ktlint: Git Hooked On It

A linting workflow leveraging on git hooks and Ktlint

Jason Low
6 min readSep 20, 2020

--

Preface

This article will talk about the linting workflow my Android team and I use to ensure new code entering into our code base adheres to the standards set by the team. Our linter of choice is Ktlint, considering that our code base is written entirely on Kotlin.

There are few other articles and blog posts that uses the same approach, but I found their information to either be outdated, or incomplete. So this article is meant to aggregate my findings of those articles, with a little extra sprinkled in.

The Problem

So, a couple of years back, when I first introduced Ktlint to my team as part of the tech-stack for a greenfield project, the idea was simple: delegate the tedious task of code-style checking to a bot so that human devs wouldn’t have to spend time pouring over PRs looking for minute style violations.

The idea was also that the bot would be able to tell the devs what went wrong, how to fix it, and the devs would then learn from their mistake and not repeat it again.

How naive we were.

Don’t get me wrong, Ktlint did its job flawlessly. Every time a build was made (specifically the gradle build task), Ktlint would plug along and notify us of the mistakes we made. But that’s where the magic got lost, for one specific reason:

We had to wait for Ktlint to do its thing every. single. build.

Which led to devs essentially just disabling Ktlint on their local dev environment during testing, and only enabling it and running the check when they are about to commit. But, what if we forgot to re-enable Ktlint? That’s when our CI/CD pipeline screamed at us, and we had to deal with the shame of pushing a Fix code styling commit, or, if you’re too prideful such as I, perform a git --amend, rewrite my branch’s history, and pretended that nothing went wrong. Eventually, we abandoned linting in general.

Tl;dr, it sucked.

The Solution

So, what were we to do? Well, at the time, we abandoned Ktlint after several attempts of tweaking our workflow (perform linting during CI/CD build phase and throw a warning, automate a new commit with formatted code during CI/CD, etc etc) because none of them worked for us due to time constraints, and the fact that we (actually, I, since I was assigned this task) were inexperienced on linting.

That brings us to the present day. With considerably more material to reference from, and my learning from past failures to steer me away from being trapped in digital molasses, I took another go at it.

The solution came in the form of git hooks. Now, I won’t go into too much detail about git hooks that you can’t read from here, but the gist of it is that you can define an action based on a .bash script that you can run just before a certain git action, and Ktlint is no exception.

This is ideal, because an action such as commit is not performed as often as say, an Android build task (which drove my team up the wall), and it is performed locally so that it doesn’t take up precious build time in a CI/CD pipeline, or pollute the git timeline with “sorry, I goofed” commit messages, because the linting happens before the commit gets pushed.

Tl;dr: the flow of it is:

Write code -> Commit in git -> Custom script runs Ktlint -> Code gets formatted/error gets thrown -> IF no error, code gets committed

This sounds like a local implementation. How on earth can we scale this?

This has been around for quite awhile as part of a plugin, how is this revolutionary?

A) I’ll show you what I got so far in the next section, fret not gang.

And B) yes, sorry for not knowing the existence of the plugin, nor of git hooks. Now, for the sake of those who want answers, let’s dive into the implementation shall we?

The Fun Part (Implementation)

Setup

The first thing we need to do is actually include Ktlint into our project, and so far, the most painless way to do this is by adding this plugin.

This can be done by adding it to your project’s build.gradle (not the “app” module’s) as detailed below:

repositories {
...
maven {
url "https://plugins.gradle.org/m2/"
}
}
dependencies {
...
classpath("org.jlleitschuh.gradle:ktlint-gradle:9.3.0")
}
allProjects {
...
apply plugin: "org.jlleitschuh.gradle.ktlint"
}

Now, by doing this, you essentially already have Ktlint in your project, and are able to run ktlintFormat and ktlintCheck gradle tasks manually. But, you’re here to automate your workflow, so let’s do exactly that!

Custom Gradle Task (Script Injection)

In the same file as above, you’ll be adding a custom gradle task as such:

repositories {
...
maven {
url "https://plugins.gradle.org/m2/"
}
}
dependencies {
...
classpath("org.jlleitschuh.gradle:ktlint-gradle:9.3.0")
}
allProjects {
...
apply plugin: "org.jlleitschuh.gradle.ktlint"
}
task installGitHook(type: Copy) {
def lintingConfigScript = new File(rootProject.rootDir, '.git/hooks/pre-commit')
if (!lintingConfigScript.exists()) {
from new File(rootProject.rootDir, '.githooks/pre-commit')
into { new File(rootProject.rootDir, '.git/hooks') }
fileMode 0777
}
}

tasks.getByPath('app:preBuild').dependsOn installGitHook

So, what this new block of code does, is essentially adding a custom gradle task called installGitHook and running it before the “preBuild” Android build phase.

But, what the installGitHook task does specifically, is check if a file (that we will define later) has already been injected into the project’s .git/hooks folder, and copy it over from a self-made directory called .githooks if it hasn’t. This folder is how git knows what custom action it should run during each stage of the commit process.

You’re probably itching to see what the pre-commit file is, so let’s just dive right in.

Linting Script (where the magic happens)

Full disclaimer, the bulk of this script is actually generated by the Ktlint plugin above, but, at the time of writing, the script has a bug in it that fails to format problematic code. So, I tweaked it to suit my needs. Here is the pre-commit file:

#!/bin/bash

# Stash the current index to be used in the event of failure
git stash -q --keep-index

echo "Running Ktlint before git commit is committed"

./gradlew ktlintFormat

RESULT=$?

git stash pop -q

# return 1 exit code if running checks fails
[ $RESULT -ne 0 ] && exit 1

######## KTLINT-GRADLE HOOK START ########

CHANGED_FILES="$(git --no-pager diff --name-status --no-color --cached | awk '$1 != "D" && $2 ~ /\.kts|\.kt/ { print $2}')"

if [ -z "$CHANGED_FILES" ]; then
echo "No Kotlin staged files."
exit 0
fi;

echo "Running ktlint over these files:"
echo "$CHANGED_FILES"

./gradlew --quiet ktlintFormat -PinternalKtlintGitFilter="$CHANGED_FILES"

echo "Completed ktlint run."
echo "$CHANGED_FILES" | while read -r file; do
if [
-f $file ]; then
git add $file
fi
done
echo "Completed ktlint hook."
######## KTLINT-GRADLE HOOK END ########

Obviously, the naming of the file is entirely up to you. Just make sure by the end of it all, that the script that is injected into the .git/hooks is named pre-commit.

The gist of this script is that Ktlint will attempt to format your code from the start. But, should there be an error, be it formatting or some other configuration error, the linter will throw an exception and your staged code will be popped from the stash. If everything goes well, the commit will proceed normally.

There is a caveat with this script, in that it doesn’t play nice when you stage part of a file to be committed. It’d instead just commit the entire file should that file have a formatting error. This is an issue I’ll address in the future, probably with a patch instead of a git add .

Wrap Up

Of course, there is a point to be made about entrusting this to a human vs automating it. A lot can go wrong by allowing the linter to decide what, and when, to format. But, on the other hand, we humans are forgetful creatures, and that doesn’t bode well if we were to entrust the developers to perform the linting by themselves.

From my experience, for the most part, the last thing a dev wants when they are done with a difficult task, is to go through a mental checklist of the things they should do before issuing a PR.

If we can automate it, why not?

To further supercharge your team’s linting workflow, consider writing custom linting rules! How to do it? Well, check out my other article that teaches you exactly how to get started with Ktlint + PsiViewer.

References

  1. Git hooks
  2. Ktlint plugin
  3. Ktlint gradle plugin setup

--

--

Jason Low

https://twitter.com/jasonlowdh Full-time Android developer. I write about anything Android/software development, tech, and life’s musings.