Post

Git Hooks for Scala in Milliseconds: Lefthook, Native Scalafmt, and Betterleaks

Git Hooks for Scala in Milliseconds: Lefthook, Native Scalafmt, and Betterleaks

Almost nobody uses git hooks in Scala projects. I understand why: everything in the Scala world traditionally goes through sbt, and sbt means booting a JVM, loading plugins, and resolving a build — fifteen to twenty-five seconds before a single line of your code is checked. Nobody wants to pay that tax on every commit, so the usual verdict is to skip pre-commit checks entirely and let CI catch formatting issues ten minutes later.

This week I rebuilt the git-hook setup for one of my pet projects, and the verdict turned out to be obsolete. A formatting check on a typical commit now takes 44 milliseconds. Here is the full configuration, the measurements behind it, and two traps I stepped into along the way.

The starting point

The project is a Scala 3 backend living next to legacy Python code in the same repository. The hooks had been managed by pre-commit — a fine tool, but it drags a Python toolchain into every repository that uses it. Since the project is migrating away from Python, keeping pipx and pre-commit around just to run git hooks felt backwards.

The first decision was to switch the hook manager to Lefthook: a single Go binary, YAML configuration, parallel execution, no runtime dependencies. It installs cleanly through mise, which I already use as the task runner and toolchain manager everywhere (I wrote about that setup earlier).

My initial Lefthook configuration deliberately left Scala formatting out of the pre-commit stage. The comment in the config said it plainly: “Scala formatting is too slow for per-commit checks.” The full sbt scalafmtCheckAll ran only on pre-push, and only when Scala files were actually being pushed.

That comment survived less than a day.

The discovery: scalafmt ships native binaries

Reading through the scalafmt installation docs, I noticed something I had managed to overlook for years: scalafmt ships pre-built native binaries with instant startup, no JVM involved. The sbt plugin is only one way to run the formatter — and for short-lived runs like a git hook, it is the worst one.

The numbers on my project (fourteen Scala source files):

How you run itTime
Native CLI, typical commit (2 staged files)0.044 s
Native CLI, every Scala file in the project, cold1.02 s
sbt scalafmtCheckAll with a warm sbt server3.5 s
sbt scalafmtCheckAll, cold15–25 s

Three orders of magnitude between the native CLI and a cold sbt run. At 44 milliseconds, the formatting check is free — you will not notice it between typing git commit and seeing your editor close the COMMIT_EDITMSG buffer.

Trap one: the native binary does not dispatch versions

The scalafmt documentation makes a promise: if the version in .scalafmt.conf differs from the CLI version, the CLI downloads and runs the right one. I pinned the latest CLI, ran it, and got an error instead of a download.

It turns out the promise holds only for the JVM launcher. The native binary cannot re-exec itself into a different version, and tells you so — politely, but only after you hit it:

NOTE: this error happens only when running a native Scalafmt binary. Scalafmt automatically installs and invokes the correct version of Scalafmt when running on the JVM.

The consequence: the CLI version you install must exactly match the version declared in .scalafmt.conf. I encode that coupling in mise.toml with a comment, so the next person (usually future me) bumps both together:

1
2
3
[tools]
lefthook = "latest"
scalafmt = "3.11.1"           # native CLI; MUST match version in .scalafmt.conf

The good news: the sbt plugin reads the same .scalafmt.conf, downloads the matching core, and agrees with the native CLI byte for byte. One config file, two runners, no drift.

Trap two: **/ does not match files in the root

My first glob for the hook was backend/**/*.scala — and then I extended it to cover build.sbt, because sbt build definitions are Scala too and scalafmtCheckAll checks them. The obvious pattern backend/**/*.{scala,sbt} silently missed backend/build.sbt: in Lefthook’s matcher (v2.1.9), **/ insists on at least one intermediate directory.

The pattern that matches both nested sources and top-level build files there:

1
glob: "backend/**.{scala,sbt}"

I only caught this because I tested the hook by actually staging a .sbt file and watching which jobs Lefthook dispatched. And here is the kicker: glob semantics are not portable. Different libraries disagree on whether a bare ** crosses directory separators and on whether **/ matches zero directories — when this article was reviewed, a code-review bot confidently cited textbook doublestar semantics to argue this exact pattern could not match nested files; staging a nested file and watching the hook fire settled it in under a minute. Globs are exactly the kind of code that looks correct in review and fails silently in production — test them with real files, against the binary you actually run.

Replacing the secret scanner along the way

The hook suite also includes secret scanning. I had started with gitleaks, but while reading its documentation I noticed a warning at the top of the README: gitleaks is feature-complete, future releases will be security patches only, and the author is shifting his focus to a successor project called Betterleaks.

A word of caution that applies to everyone: when a popular security tool suddenly has a “newer, better successor”, do not take the link at face value. A secret scanner reads your secrets by design — a malicious lookalike is a perfect exfiltration vector. Before adopting it, I verified three things:

  1. The succession claim appears in the official gitleaks README itself, not in a random blog post or issue comment.
  2. The top contributor of Betterleaks is zricethezav — the author of gitleaks.
  3. The license is the same MIT, and the project ships through the same distribution channels (it is already in the mise registry).

Adopting the successor at introduction time beats migrating off a frozen tool later. And since trust requires verification, I tested the hook by staging a file with a format-valid fake GitHub token: leaks found: 1, exit code 1, commit blocked. My first test attempt, amusingly, “failed” — I had planted the canonical AWS example key from the documentation, which scanners correctly allowlist. The tool was smarter than my test.

One deliberate choice: Betterleaks supports validating candidate secrets with HTTP requests to the issuing services. I leave that off in hooks — detection works offline, and nothing a hook finds should ever leave the machine.

The resulting configuration

Three tiers, each sized to its latency budget:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# lefthook.yml
pre-commit:
  parallel: true
  jobs:
    # Secret scanning: abort if any staged diff leaks a credential
    - name: betterleaks
      run: betterleaks git --pre-commit --redact --staged

    # Native scalafmt CLI: ~44ms per commit. The pinned mise version must
    # match .scalafmt.conf (the native binary does not dispatch versions).
    - name: scalafmt
      glob: "backend/**.{scala,sbt}"
      run: scalafmt --config backend/.scalafmt.conf --test {staged_files}

# Pre-push is a hermetic one-second safety net, not a test gate: a
# full-project format check with no Docker and no network involved.
pre-push:
  jobs:
    - name: scalafmt-all
      glob: "backend/**.{scala,sbt}"
      run: scalafmt --config backend/.scalafmt.conf --test backend

One more line makes the directory scan safe. By default the CLI walks everything under the target path — including target/ with its generated sources when they exist. Add this to .scalafmt.conf so only git-tracked files are considered:

1
project.git = true

(I verified the failure mode by planting a misformatted file under target/: without the setting it fails the check, with it the plant is invisible.)

  • Pre-commit, milliseconds: formatting and secret scanning on staged files only.
  • Pre-push, one second: the same native formatter across the whole module. It exists to catch what the per-commit hook structurally cannot: commits made with --no-verify, rebase and merge fallout, files touched by tools outside the commit flow. On its very first live run it also flagged project/plugins.sbt — a file sbt scalafmtCheckAll had silently never covered.
  • CI, minutes: the full gate — compile, tests against a real PostgreSQL in testcontainers, coverage threshold — on every pull request.

My first draft of the pre-push stage ran the entire test suite. I removed it after checking what practitioners actually report, and the arguments stack up quickly. Hooks slower than a minute or so train the --no-verify reflex, and a hook people bypass is worse than no hook. Integration tests need infrastructure — mine require a running Docker daemon for testcontainers and network access to resolve artifacts, which means a push from a train, or with the container runtime stopped, fails for reasons that have nothing to do with the code. And pushing is not releasing: shoving a work-in-progress branch to the remote as a backup is a legitimate move that red tests should never block. The full validation still runs before every pull request — mise run check locally as a habit, and CI as the enforcement that does not depend on anyone’s discipline.

A pleasant detail about Lefthook worth knowing: {staged_files} already excludes deleted files, and a job whose glob matches nothing is skipped entirely. A code-review bot confidently told me the configuration above would crash on deleted files; staging an actual git rm and running the hook took thirty seconds and settled the question. Empiricism is cheap — use it on your reviewers too.

Closing thought

The “Scala is too slow for git hooks” folklore is a statement about sbt startup, not about Scala tooling. The formatter itself was always fast; it just needed to escape the JVM boot sequence. One pinned native binary later, the gap between “commit” and “feedback” is forty-four milliseconds — and the comment in my config that said it could not be done is gone from the file, but preserved here as a reminder.

This post is licensed under CC BY 4.0 by the author.