Git Hooks and Rust!
I have started writing a lot of Rust recently, but that is a story for another time. Like with all my open source projects my Rust repositories have CI pipelines. These CI pipelines run for every pull request; enforcing basic code quality by checking the code is properly formatted etc.
As part of my development workflow, I usually have formatting and other basic code quality issues automatically fixed or flagged. However, sometimes issues slip through.
Having pipeline failures for issues that otherwise could have quickly been checked and fixed locally, is a problem because of the lengthy feedback loop. Therefore ideally, you want to stop wasting time by not committing code that does not pass basic code quality checks.
Enter Git Hooks, which is essentially event-driven execution upon scripts before or after events within Git’s lifecycle. Git Hooks are part of core Git, so no additional installation is necessary. For a more in-depth introduction to Git Hooks, you can visit https://githooks.com/.
The only requirement for a Git Hook is that it is executable, but Shell/Bash are the most commonly used languages.
To react to a specific event you place an executable inside the .git/hooks/
directory with the event’s name, e.g. .git/hooks/pre-commit
.
So we can utilise Git hooks to either perform code quality checks locally before creating the commits or pushing them remotely. To provide the best developer experience we want to fix or flag a commit before it is created, to avoid rebasing/commit editing headaches.
The pre-commit
hook is fired after the commit message and content have been provided; but before the commit has been created.
So using this event we can alter the contents of a commit, or if the hook exits with a non-zero status code stop it from being created.
Beneath I will show you multiple examples of using Git hooks being used to enforce formatting. The examples can easily be modified to automate other tasks such as code linting etc. Hopefully, afterwards, you will see the power in Git hooks and begin to use them to help you deliver better quality software.
Examples⌗
Formatting⌗
Formatting - Single Project⌗
Checking code formatted is very quick, which makes it one of many ideal code quality checks that can preemptively be reviewed locally. Using the pre-commit event when we find incorrectly formatted code we have two choices. Either we can stop the commit from being created, or alter the content so it is correctly formatted. I prefer to stop the commit being created, otherwise, you are never sure what has actually been committed.
.git/hooks/pre-commit
#!/usr/bin/env bash
# Exit immediately on first non-zero status from a command in the script.
set -o errexit
# Ensure all tools being used are available.
command -v 'cargo' >/dev/null 2>&1 || { echo >&2 "The 'cargo' command is not installed. Aborting."; exit 1; }
command -v 'rustfmt' >/dev/null 2>&1 || { echo >&2 "The 'rustfmt' command is not installed. Aborting."; exit 1; }
# Check the formatting of all Rust code.
cargo fmt --all -- --check
Using the above as a Git hook it will stop you from committing unformatted code, because cargo fmt --all -- --check
will exit with a non-zero status code cancelling the commit.
E.g. with src/main.rs
incorrectly formatted as follows,
src/main.rs
fn main() {
println!("Hello, world!");
}
The Git hook will print out that src/main.rs
is incorrectly formatted, indicating which lines need to be corrected.
Diff in /home/rust/hello-world/src/main.rs at line 1:
-fn main() {
-println!("Hello, world!");
- }
+fn main() {
+ println!("Hello, world!");
+}
You can then format it correctly before attempting to commit it again.
Formatting - Monorepo⌗
The Git hook example above falls short when monorepos are introduced which contain multiple packages. We can perform checks upon each package by programmatically getting each package’s manifest and then checking each package independently. Sorting the list of package’s manifest before working upon it will also improve usability by providing a consistent output.
.git/hooks/pre-commit
#!/usr/bin/env bash
# Exit immediately on first non-zero status from a command in the script.
set -o errexit
# Ensure all tools being used are available.
command -v 'cargo' >/dev/null 2>&1 || { echo >&2 "The 'cargo' command is not installed. Aborting."; exit 1; }
command -v 'rustfmt' >/dev/null 2>&1 || { echo >&2 "The 'rustfmt' command is not installed. Aborting."; exit 1; }
# Check the formatting of all Rust code for every project, if in monorepo.
for project in $(du -a | grep "Cargo[.]toml$" | awk '{print $2}' | sort); do
# Check project is correctly formatted.
cargo fmt --all --manifest-path "${project}" -- --check
done
The above example works for both monorepos and non-monorepos alike. However there is a subtle usability issue, the Git hook will fail and exit on the first incorrectly formatted project. This may mean multiple rounds of attempting to commit before learning of another project that needs formatted. It would be more useful to list every package that needs formatting the first time. To make it more usable we can remove exiting on the first error and store the status code, only returning it at the end.
.git/hooks/pre-commit
#!/usr/bin/env bash
# Ensure all tools being used are available.
command -v 'cargo' >/dev/null 2>&1 || { echo >&2 "The 'cargo' command is not installed. Aborting."; exit 1; }
command -v 'rustfmt' >/dev/null 2>&1 || { echo >&2 "The 'rustfmt' command is not installed. Aborting."; exit 1; }
status=0
# Check the formatting of all Rust code for every project, if in monorepo.
for project in $(du -a | grep "Cargo.toml$" | awk '{print $2}' | sort); do
# Check project is correctly formatted.
cargo fmt --all --manifest-path "${project}" -- --check
project_is_formatted=$?
if [ "${project_is_formatted}" -ne 0 ]; then
status=${project_is_formatted}
fi
done
exit $status
A lot of noise may be generated by all the packages within a monorepo when the formatting issues are reported in detail.
To reduce the noise you can instead list just the files which need formatted, by changing to cargo fmt --all --manifest-path "${project}" --message-format short -- --check
.
Formatting - Monorepo & Workspaces⌗
The above examples all work fine for separate Rust packages, however, Rust has the idea of workspaces. The Rust documentation describes a workspace as
A workspace is a collection of one or more packages that share common dependency resolution (with a shared Cargo.lock), output directory, and various settings such as profiles.
see https://doc.rust-lang.org/cargo/reference/workspaces.html for additional details about workspaces.
.git/hooks/pre-commit
#!/usr/bin/env bash
# Ensure all tools being used are available.
command -v 'cargo' >/dev/null 2>&1 || { echo >&2 "The 'cargo' command is not installed. Aborting."; exit 1; }
command -v 'rustfmt' >/dev/null 2>&1 || { echo >&2 "The 'rustfmt' command is not installed. Aborting."; exit 1; }
status=0
# Check the formatting of all Rust code for every project, if in monorepo.
for project in $(du -a | grep "Cargo.toml$" | awk '{print $2}' | xargs -I {} sh -c 'cargo metadata --manifest-path {} | jq ".workspace_root"' | sort | uniq); do
# Check project is correctly formatted.
cargo fmt --all --manifest-path "${project}" -- --check
project_is_formatted=$?
if [ "${project_is_formatted}" -ne 0 ]; then
status=${project_is_formatted}
fi
done
exit $status