Skip to content
rusthacks.dev
CLI

Build a Git Hook Manager in Rust From Scratch Step by Step

Build a git hook manager in Rust: read a hooks config, install shims into .git/hooks, run commands on staged files in order, and report results with proper exit codes.

16 min read
Summarize in AI
Flat illustration for the post “Build a Git Hook Manager in Rust From Scratch Step by Step”, composed around tooling.

Every team has the same conversation after the second broken build: "someone should run the formatter before committing." Git hooks are the mechanism for making that automatic, and they are easy to misuse. A hook is a script Git runs at a fixed moment, such as before a commit is recorded, and if it exits non-zero Git stops. The script lives in .git/hooks, which Git does not version, so every clone starts with none and every team invents its own way of installing them.

A hook manager solves the three problems around that: where the hook definitions live (in the repository, reviewed like code), how they get installed (one command), and how they run (only on the files that changed, with readable output). This guide builds a small one called hk in Rust, runs it against a real repository, and then covers the failure modes that bite every hook manager. It compiled and ran with Rust 1.99, edition 2024, toml 1.x, serde 1.0, globset 0.4 and anyhow 1.0, on a machine with Git 2.55.

What Git guarantees about a hook

A hook is an executable file named after an event, placed in the hooks directory. Git runs it with a defined working directory and arguments, and reads its exit status. That is the whole contract, and three details from it matter for what we build.

The hook runs with the repository root as its working directory, so relative paths in your commands resolve there. The exit code decides everything: 0 lets the operation continue, anything else aborts it for hooks like pre-commit, commit-msg and pre-push. And the arguments depend on the hook: commit-msg receives the path of the file holding the message, pre-push receives the remote name and URL and reads the refs being pushed from standard input. A manager has to pass those through unchanged.

HookRunsTypical use
pre-commitbefore the commit is created, with the index stagedformat check, lint, no stray debug statements
commit-msgafter the message is written, before the commit completesenforce a message format
pre-pushbefore refs are sent to the remoterun the test suite
post-mergeafter a successful mergereinstall dependencies when the lockfile changed

Git has more than twenty hooks, and these four cover nearly all uses. The first design decision follows from that: a manager does not need to support everything, only a short list, and says so.

The design

hk has two commands. hk install writes a three-line shell script into the hooks directory for each supported hook. The script does nothing except call back into the manager: exec hk run pre-commit "$@". hk run <hook> reads hk.toml from the repository root, finds the steps configured for that hook, and runs them. Because the shim never changes, you update behavior by editing hk.toml and committing it. Nobody has to reinstall anything when a rule changes, and the rules are reviewed in pull requests like the rest of the code.

The configuration is a list of steps per hook. A step has a name, a command, and an optional glob that restricts it to certain files. The special token {files} in a command expands to the staged files that match the glob. That one feature is what makes hooks fast: a formatter that checks only the three files you changed runs in 30 ms, while one that checks the repository runs for seconds and gets bypassed with --no-verify.

The code

The whole manager fits in one file. Read it top to bottom once, and the sections after it take the decisions apart.

src/main.rsrust
use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::{Command, ExitCode};

use anyhow::{bail, Context, Result};
use globset::Glob;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
    hooks: BTreeMap<String, Vec<Step>>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Step {
    name: String,
    /// Command line. `{files}` expands to the matching staged files.
    run: String,
    /// Only run when a staged file matches this glob.
    glob: Option<String>,
}

const HOOKS: [&str; 3] = ["pre-commit", "commit-msg", "pre-push"];

fn git(args: &[&str]) -> Result<String> {
    let out = Command::new("git").args(args).output().context("could not run git")?;
    if !out.status.success() {
        bail!("git {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim());
    }
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

/// Write a tiny shim into .git/hooks that hands control back to this binary.
fn install() -> Result<()> {
    let hooks_dir = git(&["rev-parse", "--git-path", "hooks"])?;
    let hooks_dir = Path::new(hooks_dir.trim());
    fs::create_dir_all(hooks_dir)?;
    for name in HOOKS {
        let path = hooks_dir.join(name);
        let script = format!("#!/bin/sh\nexec hk run {name} \"$@\"\n");
        fs::write(&path, script)?;
        fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
        println!("installed {name}");
    }
    Ok(())
}

fn staged_files() -> Result<Vec<String>> {
    // ACM: added, copied, modified. Deleted files cannot be linted.
    let out = git(&["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"])?;
    Ok(out.split('\0').filter(|s| !s.is_empty()).map(str::to_string).collect())
}

fn run_hook(config: &Config, hook: &str) -> Result<bool> {
    let Some(steps) = config.hooks.get(hook) else { return Ok(true) };
    let staged = staged_files()?;
    let mut all_ok = true;

    for step in steps {
        let files: Vec<&String> = match &step.glob {
            Some(pattern) => {
                let matcher = Glob::new(pattern)?.compile_matcher();
                staged.iter().filter(|f| matcher.is_match(f)).collect()
            }
            None => staged.iter().collect(),
        };
        if step.glob.is_some() && files.is_empty() {
            println!("- {:<10} skipped (no matching files)", step.name);
            continue;
        }
        let joined = files.iter().map(|f| format!("'{f}'")).collect::<Vec<_>>().join(" ");
        let command = step.run.replace("{files}", &joined);
        let output = Command::new("sh").arg("-c").arg(&command).output()?;
        if output.status.success() {
            println!("- {:<10} ok", step.name);
        } else {
            all_ok = false;
            println!("- {:<10} FAILED", step.name);
            print!("{}", String::from_utf8_lossy(&output.stdout));
            eprint!("{}", String::from_utf8_lossy(&output.stderr));
        }
    }
    Ok(all_ok)
}

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let result = match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
        ["install"] => install().map(|()| true),
        ["run", hook, ..] => fs::read_to_string("hk.toml")
            .context("reading hk.toml")
            .and_then(|s| toml::from_str::<Config>(&s).context("parsing hk.toml"))
            .and_then(|config| run_hook(&config, hook)),
        _ => {
            eprintln!("usage: hk install | hk run <hook>");
            return ExitCode::from(2);
        }
    };
    match result {
        Ok(true) => ExitCode::SUCCESS,
        Ok(false) => ExitCode::from(1),
        Err(e) => {
            eprintln!("hk: {e:#}");
            ExitCode::from(2)
        }
    }
}
Cargo.tomlTOML
[dependencies]
anyhow = "1"
globset = "0.4"
serde = { version = "1", features = ["derive"] }
toml = "1"

A few choices in this file are deliberate and worth copying. #[serde(deny_unknown_fields)] on both structs turns a misspelled key into a parse error with a line number, so a typo like glob_ does not silently disable a check. The git helper returns the command's standard error in its error message, which makes failures like "not a git repository" readable. staged_files uses -z and splits on NUL, because with the default newline separator a file name containing a space or a quote is mangled, and Git quotes unusual names unless told otherwise. And --diff-filter=ACM restricts the list to files that were added, copied or modified: a deleted file is staged too, but handing it to a linter produces "no such file" errors.

Running it against a real repository

Here is hk.toml for a small Rust project. It has three steps: reject TODO comments in Rust files, check formatting with rustfmt, and reject trailing spaces in Markdown.

hk.tomlTOML
[[hooks.pre-commit]]
name = "no-todo"
run = "! grep -n 'TODO' {files}"
glob = "*.rs"

[[hooks.pre-commit]]
name = "fmt"
run = "rustfmt --check --edition 2024 {files}"
glob = "*.rs"

[[hooks.pre-commit]]
name = "prose"
run = "! grep -nE ' $' {files}"
glob = "*.md"

Installing and then trying to commit a file with a TODO in it produced this:

Text
installed pre-commit
installed commit-msg
installed pre-push
- no-todo    FAILED
2:    // TODO: handle errors
- fmt        ok
- prose      skipped (no matching files)

Git aborted the commit because the first step failed, and the grep output points at line 2. Note that the steps all ran even though the first failed, so the author sees every problem in one pass instead of fixing them one commit attempt at a time. That is a UX decision: running all steps and failing at the end costs a little time and saves several round trips. The prose step was skipped because no Markdown file was staged, and the output says so rather than reporting a misleading "ok".

After removing the comment but leaving the code badly formatted, the fmt step failed and showed the diff:

Text
- no-todo    ok
- fmt        FAILED
Diff in /private/tmp/hkdemo/main.rs:1:
-fn main(){println!("hi");}
+fn main() {
+    println!("hi");
+}

- prose      skipped (no matching files)

With the file formatted, all steps passed and the commit went through. A hook that tells you exactly what is wrong, and what to change, gets obeyed. One that prints "formatting check failed" gets bypassed.

The mistake that makes hooks lie: partially staged files

The design above has a flaw you should understand before you ship it. git add -p lets you stage part of a file. The hook then runs rustfmt --check on the file in the working tree, which includes the unstaged changes, not on what will actually be committed. The check can pass on a file whose committed version is broken, or fail because of an edit you deliberately left out.

There are two fixes, with different costs. The precise one checks the staged content: for each file, git show :path prints the version in the index, which you can pipe to a formatter that reads standard input (rustfmt --check --edition 2024 --config-path . <file). It is correct and needs a per-tool strategy. The blunt one stashes unstaged changes before the hooks run (git stash push --keep-index --include-untracked) and restores them afterward, which gives every tool a clean working tree that matches the index. It works with any tool, and it is what the pre-commit project does, but a crash in the middle leaves your changes in the stash, so a careful implementation writes a patch file first as a recovery path. For a simple manager, document the limitation and let people who use git add -p know that the hook checks the working tree.

Formatters that fix instead of check

Many teams prefer a hook that rewrites files (cargo fmt) over one that rejects them. It is friendlier, and it has a catch: the rewritten files are in the working tree but not in the index, so the commit does not contain the fix. The step has to re-stage them: rustfmt {files} && git add {files}. That is safe when the whole file was staged, and wrong for partially staged files, where it would stage the changes you meant to hold back. The check-only form never has this problem, which is why it is the default recommendation: let the hook fail, let the developer run the formatter, and let them stage what they intend.

Speed budget

A hook that takes more than a few seconds teaches developers to bypass it. A practical budget is one second for pre-commit and a minute for pre-push. Three techniques keep the manager inside it.

Run only on changed files, which the glob and {files} already do. Run independent steps in parallel with std::thread::scope, since formatting and a trailing-space check do not depend on each other. And keep expensive work, the full test suite or a release build, off pre-commit and on pre-push, where waiting is expected. The parallel version changes run_hook to spawn one scoped thread per step and join them before reporting:

src/parallel.rsrust
use std::thread;

let results: Vec<(String, std::io::Result<std::process::Output>)> = thread::scope(|scope| {
    let handles: Vec<_> = steps
        .iter()
        .map(|step| {
            let command = expand(step, &staged);
            scope.spawn(move || (step.name.clone(), Command::new("sh").arg("-c").arg(command).output()))
        })
        .collect();
    handles.into_iter().map(|h| h.join().unwrap()).collect()
});

Collect the results first and print them in configuration order afterward, so the output is stable even though the commands finished in a random order.

Where the pieces can break

The shim calls hk by name, so hk must be on the PATH of whatever launched Git. A terminal has your PATH. A GUI client or an IDE often does not, and the failure is a cryptic hk: command not found in a dialog box. Install the binary where non-interactive shells find it, such as /usr/local/bin, or write an absolute path into the shim at install time.

The hooks directory is not always .git/hooks. Worktrees share one, repositories can set core.hooksPath, and the code above asks Git with git rev-parse --git-path hooks rather than assuming. A related and common trick is to point core.hooksPath at a versioned directory (git config core.hooksPath .githooks), which removes the install step entirely at the cost of one configuration command per clone.

Hooks can be skipped: git commit --no-verify bypasses pre-commit and commit-msg. That is by design, and it is why a hook is a convenience and the CI pipeline is the enforcement. Run the same checks in CI; the hook just tells the developer sooner.

Testing a hook manager

The manager's behavior is defined by its interaction with Git, so test it with a real repository in a temporary directory. The test below creates one, installs a config with a failing step, stages a file and asserts that the run reports failure.

tests/hooks.rsrust
use std::process::Command;

fn git(dir: &std::path::Path, args: &[&str]) {
    let status = Command::new("git").current_dir(dir).args(args).status().unwrap();
    assert!(status.success(), "git {args:?} failed");
}

#[test]
fn a_failing_step_makes_the_run_fail() {
    let dir = tempfile::tempdir().unwrap();
    git(dir.path(), &["init", "-q"]);
    std::fs::write(dir.path().join("hk.toml"), "[[hooks.pre-commit]]\nname = \"never\"\nrun = \"false\"\n").unwrap();
    std::fs::write(dir.path().join("a.rs"), "fn main() {}\n").unwrap();
    git(dir.path(), &["add", "a.rs"]);

    let status = Command::new(env!("CARGO_BIN_EXE_hk"))
        .current_dir(dir.path())
        .args(["run", "pre-commit"])
        .status()
        .unwrap();
    assert_eq!(status.code(), Some(1));
}

Add cases for a config with an unknown key (expect exit code 2), a glob that matches nothing (expect a skipped step and exit 0), and a file name with a space in it, which is the case that breaks naive quoting.

One more safety note. The run strings in hk.toml are executed through sh -c, so the config is code. That is fine for a file in your own repository reviewed like any other change, and it is a reason never to read a hook config from a path an attacker controls, such as a downloaded archive.

A commit-msg hook is a different shape

pre-commit looks at files. commit-msg looks at one file: the message Git has just written, whose path arrives as the first argument. It cannot use {files} and it needs no glob, which is why a manager's run strings should also receive the hook's own arguments. The program below validates the first line against a type(scope): summary convention and is the kind of check you register as commit-msg. It has tests, because a message validator that wrongly rejects a merge commit makes git pull fail for everyone.

src/main.rsrust
use std::fs;
use std::process::ExitCode;

const TYPES: [&str; 7] = ["feat", "fix", "docs", "refactor", "test", "chore", "perf"];

/// Check the first line against `type(scope)?: summary`, at most 72 characters.
fn validate(message: &str) -> Result<(), String> {
    let first = message.lines().next().unwrap_or("").trim();
    if first.starts_with("Merge ") || first.starts_with("Revert ") {
        return Ok(()); // generated by git itself
    }
    let Some((head, summary)) = first.split_once(": ") else {
        return Err(format!("expected `type: summary`, got `{first}`"));
    };
    let kind = head.split('(').next().unwrap_or(head);
    if !TYPES.contains(&kind) {
        return Err(format!("unknown type `{kind}`; use one of {}", TYPES.join(", ")));
    }
    if summary.trim().is_empty() {
        return Err("summary after the colon is empty".into());
    }
    if first.chars().count() > 72 {
        return Err(format!("first line is {} characters; the limit is 72", first.chars().count()));
    }
    Ok(())
}

fn main() -> ExitCode {
    // git passes the path of the message file as the first argument.
    let Some(path) = std::env::args().nth(1) else {
        eprintln!("usage: commit-msg <file>");
        return ExitCode::from(2);
    };
    let message = fs::read_to_string(&path).unwrap_or_default();
    match validate(&message) {
        Ok(()) => ExitCode::SUCCESS,
        Err(why) => {
            eprintln!("commit message rejected: {why}");
            ExitCode::from(1)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::validate;

    #[test]
    fn accepts_typed_summaries() {
        assert!(validate("feat(parser): accept trailing commas\n\nbody").is_ok());
        assert!(validate("fix: handle empty input").is_ok());
    }

    #[test]
    fn rejects_the_usual_mistakes() {
        assert!(validate("updated stuff").is_err());
        assert!(validate("feature: add thing").is_err());
        assert!(validate("fix: ").is_err());
        assert!(validate(&format!("fix: {}", "x".repeat(80))).is_err());
    }

    #[test]
    fn lets_git_generated_messages_through() {
        assert!(validate("Merge branch 'main' into dev").is_ok());
    }
}

Feeding it three messages from the shell gives the behavior a developer will see:

Text
commit message rejected: expected `type: summary`, got `updated stuff`
(exit status 1)
commit message rejected: unknown type `feature`; use one of feat, fix, docs, refactor, test, chore, perf
(exit status 1)
(no output, exit status 0 for "fix(parser): handle empty input")

The rejection messages name the rule that failed and, for the unknown type, list the valid ones. A hook that says "invalid commit message" produces a developer who opens the wiki; one that says what to type produces a developer who retypes it. The Merge and Revert early return matters because Git writes those messages itself and runs commit-msg on them too in some flows. Test that exemption explicitly, as the third test does.

pre-push and the refs on standard input

pre-push is where the expensive checks belong, and it is the one hook that passes data on standard input rather than in arguments. Git writes one line per ref being pushed: the local ref, the local object name, the remote ref and the remote object name. A manager that wants to run tests only when something other than documentation changed can read those lines, ask git diff --name-only <remote-sha>..<local-sha> for the changed files, and skip the test run for a push that touches only docs/. A new branch has an all-zero remote object name, so handle that case by diffing against the merge base with the default branch instead. This is the sort of behavior that makes a custom manager worth having, and the sort of corner case that makes the mature ones worth using.

Rebases, merges and amends

Hooks fire in places people do not expect. During git rebase, pre-commit does not run for each replayed commit, but commit-msg runs when you reword one, so a strict message hook can interrupt a rebase halfway. git commit --amend runs pre-commit again against the new index. git merge runs commit-msg for the merge commit but not pre-commit unless the merge needed manual resolution. None of this is a bug, and all of it surprises someone the first time. Keep hooks idempotent and quick, make them tolerate a message they did not expect, and never let a hook modify history or fetch from the network, since a hook that talks to a server fails when the train goes through a tunnel.

Environment details that break hooks quietly

Git sets variables like GIT_DIR and GIT_INDEX_FILE in the hook's environment. Commands the hook runs inherit them, so a test script that creates its own temporary repository without clearing GIT_DIR ends up operating on the real one. The result is a test suite that modifies the repository it is supposed to be guarding, which is a confusing bug to chase. In integration tests like the one above, run git with the environment variables stripped (Command::env_remove("GIT_DIR")), and in hook commands that call git, do the same.

Another quiet failure is line endings. A shim written with Windows line endings, #!/bin/sh\r, fails with "bad interpreter" on Linux and macOS and prints a message about a file that exists. Write the shim from the manager as the code above does, with \n only, and add *.sh text eol=lf to .gitattributes for any hand-written scripts. And on Windows itself, Git for Windows ships its own sh, so a #!/bin/sh shim works, but a run string that uses Unix-only commands like grep does not unless the user has them installed.

Choosing between building and adopting

You can build this in an afternoon, and you probably should not run it for years. The pre-commit framework (Python), lefthook (Go) and husky (Node) are mature, handle the partial staging problem, run steps in parallel and have large catalogs of ready-made hooks. For a Rust project, cargo-husky and rusty-hook install hooks from Cargo.toml or a build script. The honest reasons to write your own are to learn how hooks work, to keep a project dependency-free, or to build behavior no one ships. If your team only wants cargo fmt --check and cargo clippy on commit, a three-line script in a versioned .githooks directory and one git config command does it with no manager at all. For what to put in the CI half of the same checks, see continuous integration for Rust.

Also inTooling
Taggedproject
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.