Git hooks for Rust/Cargo

I have not been very familiar with Git until now (I have been using Mercurial mostly). Planning to publish several Rust crates soon (and not wanting to use crates.io for a couple of reasons), I decided to use Git in future to manage the source code of my Rust projects, because apparently Cargo's support for other version control systems is limited (I can't specify non-local dependencies without using Git or a registry).

Apart from the need caused by Cargo, moving to Git might come with a few other benefits too.

Anyway, while figuring out a publication workflow that works well for me, I wondered what's the best way to ensure that commits (or merges) to a certain branch are always rustfmt'ed? Are there some good examples how do achieve that? Since Git will commit what's staged in the index (and not what's in the working directory), I guess simply running cargo fmt prior to committing isn't working. Is there somewhere a good collection of Git hooks that make working with Cargo easier?

You could use a pre-commit hook.

But I guess if I want to execute cargo fmt automatically, I'd have to write out the index somewhere, then execute cargo fmt, and then re-add the files to the index? I don't think you could operate on the index (i.e. what's --staged / --cached) directly?

I did some web searches too with some examples, but not sure what's the best way to do it, and if I can (or should) do more than just checking the formatting.

Some people consider automatic formatting a bad idea.

What's making things difficult is that what I'm going to commit is not in the working directory (but in the "index"). So maybe the right point in time to perform cargo fmt is when I git add a file?

I haven't used any git hooks so can not say much on that, but I do know that things can get put in the staged area without invoking 'git add' so if you do you 'rustfmt' when you do 'git add', it might miss those things.

For example IIRC, if you do a 'git stash pop' and it causes conflicts you can end up with stuff in the staging area. That might miss your rustfmt done at the 'git add'
Of course the following suggestion will not prevent this issue. 8-(

I would suggest you not even mentally commit to making a 'git commit' until you have done testing. And I would suggest if you use rustfmt, that is is done on the code before testing. Maybe add the rustfmt as a hook to your 'cargo test'

Instead of trying to automatically format the project when someone does a commit, I would run cargo fmt --all --check in the pre-commit hook to check whether the workspace has been formatted. Then, if the workspace isn't formatted it'll abort the commit and force the user to run cargo fmt manually. I prefer that sort of manual intervention over automagically formatting the code before each commit.

In practice, having a commit aborted due to unformatted code isn't a big issue because most IDEs will format on save.

1 Like

I just tested the following (primitive) hook in .git/hooks/pre-commit:

#!/bin/sh
cargo fmt --check || exit 1

But this only checks the working directory, and not the index / staging area.

So while I can't commit when my working directory is not properly formatted, after I run cargo fmt then, the working directory gets formatted (but not the staged files I'm about to commit!). Running then git commit will happily commit the non-formatted files. See the example below.


Introducing some bad syntax changes:

% vim src/main.rs

Let's see what we did:

% git diff
diff --git a/src/main.rs b/src/main.rs
index e7a11a9..f0fce65 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,3 @@
 fn main() {
-    println!("Hello, world!");
+println! ("Hello World!");
 }

Update the staging area:

% git add src/main.rs

We try to commit, and it fails (with the output of cargo fmt --check):

% git commit -m 'My commit'
Diff in /usr/home/jbe/mycrate/src/main.rs at line 1:
 fn main() {
-println! ("Hello World!");
+    println!("Hello World!");
 }

We supposedly fix it, but forget to update our index (i.e. we forget to do git add):

% cargo fmt

Now let's try to commit, and it works!

% git commit -m 'My commit'
[main d748a13] My commit
 1 file changed, 1 insertion(+), 1 deletion(-)

This is what we just committed:

% git log -1 -p
commit d748a1375c6d7d7e316a170cf90159413d847a69 (HEAD -> main)
Author: Jan Behrens <jbe@…>
Date:   Wed Apr 6 09:08:29 2022 +0200

    My commit

diff --git a/src/main.rs b/src/main.rs
index e7a11a9..f0fce65 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,3 @@
 fn main() {
-    println!("Hello, world!");
+println! ("Hello World!");
 }

:slightly_frowning_face:

I use pre-commit (https://pre-commit.com/) to handle running multiple pre-commit hooks. It also handles stashing of the checkout. You can use rustfmt as a system process.

You can configure it in the .pre-commit-config.yaml file:

repos:
  - repo: local
    hooks:
      - id: rustfmt
        name: rustfmt
        description: Check if all files follow the rustfmt style
        entry: cargo fmt --all -- --check --color always
        language: system
        pass_filenames: false

Depending on the code I'm working on, I sometimes like to make many very small commits. So I don't like pre-commit hooks that actually change the source code, since they get in the way of that workflow.

For formatting like this, I personally prefer pre-push hooks.

A pre-push hook I have used before for formatting worked like this:

  • Check to see if there are any staged files. If so, abort the commit with a message that suggests staging those changes.
  • Run the formatter
  • Check if there are any changes. If so, stage those changes with git add ., then make a new commit with a message like ran formatter
    • Pre-commit hooks may interfere with making a commit in the pre-push hook.
    • In the past, I've automatically amended the previous commit, to fold in the formatting, but that can get messy sometimes.
    • You can, of course, skip making a new commit in the hook, if you like.
  • Depending on how much you trust the formatter, you may want to abort the push if there were any changes, to allow checking it before pushing.

Making separate formatting commits like I've suggested does increase the number of overall commits. Some folks may prefer to avoid that for that reason.

I'll also mention that if you occasionally want to push without running the pre-push hook, the relevant flag is --no-verify.

1 Like