Ktlint: Git Hooked On It

A linting workflow leveraging on git hooks and Ktlint

Preface

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

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

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

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)

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

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. Ktlint plugin
  2. Ktlint gradle plugin setup

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

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store