Managing your Git repositories with Git Hooks

Every serious software project I’ve contributed to had a series of rules that govern the content that goes in. Whether that’s enforcing a security policy, style guidelnes, commit message structure or safeguarding branches, they usually boil down to best practices. If users wish to contribute, they must adhere to the policies and maintain code quality.

Recently, I’ve been reflecting on my personal projects where own policy enforcement has been laxed. There’s never a bad time to turn over a new leaf. Automated scripting with Git can help you apply a few best practices with little effort thanks to Git Hooks.

What are Git Hooks?

Git allows users to implement hooks as a way to run scripts when certain actions occur. They’re called Git Hooks and they have two distinct groups: client-side and server-side. The client-side hooks are triggered by committing and merging while the server-side hooks are triggered by over the network by pushed commits. Both client and server hooks can be used to manage a Git repository by performing actions like linting, commit message formatting, rebasing, policy enforcement, notifications and more.

When a new Git repository is initialized with git init, a subdirectory is created containing a collection of sample hooks. These sample hooks are found at .git/hooks and document the their purpose inside the file. Each script ends with a .sample extension by default and can be triggered with it’s corresponding action by removing this extension. Hooks can be any executable script including Bash, Ruby, Go, Python, Perl or JavaScript with the strict requirement that the script is executable (chmod +x) and follow the naming conventions.

Client-Side Hooks

Client-side hooks are triggered before the commit travels over the network. There’s hooks for committing workflows, email workflows and other Git based actions like rebase, merge and pushes. We won’t cover everything, but we’ll touch on a few you may find most useful.

Client-side hooks are not copied when clone a repository. If you wish to apply your hooks across multiple repositories you can use a global hooks directory or by tracking a directory like .githooks and configuring Git to use it.

The pre-commit hook is used to inspect the snapshot that is about to be committed. It’s useful for actions like running tests or linting your code for style or whitespace. If the hook exits with a non-zero status, the commit is aborted.

The commit-msg hook inspects the temporary file containing the commit message. It’s useful for matching the commit message against a pattern like fix:, feat:, refactor(id):, etc., which is valuable for maintainers looking to classify changes going into a repository or to track progress with ticketing systems. A common approach is to use a regex pattern inside the script to match against the commit message. If the regex and the commit message don’t match, the commit is aborted.

The post-checkout hook runs after the worktree is updated by the git checkout or git switch command. It’s useful for setting up your project environment, performing actions like clearing caches, installing dependencies or generating docs. The exit status from this hook is the status from the preceding checkout or switch command.

The pre-push hook runs after the git push command but before the commit has traveled over the network. It’s useful for triggering tests and protecting branches from commits without a formal pull request. It’s important to note that this shouldn’t be the sole mechanism to protect branches from commits as users can bypass this hook with the --no-verify flag. If this hook exits with a non-zero status, the push will be aborted.

Server-Side Hooks

As the name implies, server-side hooks run on the server when a push has been received. They serve an important role in enforcing security policies, branch rules and code quality checks so that commits meet the maintainers standards. Server-side hooks are outside the scope of this post, but if you host your own Git server, I recommend reading through the githooks documentation.

Writing your own custom Git Hooks

Now that we have a better understanding of what Git Hooks are, let’s walk through some examples. I’ll show you how you can write your own hooks to check staged files for secrets, validate commit message format for conventional commits, and protect the main branch against pushes without a pull request.

pre-commit hook to check for secrets

If your project works with API keys or secrets its essential that you don’t expose them. Checking for secrets or credentials is a useful task that can be performed in the pre-commit hook with pattern matching to ensure they don’t make it into the codebase. Take a look at the flow of this simplified hook to parse staged files in Go.

func main() {
  // Collect all the files staged for commit
  files, err := getStagedFiles()
  if err != nil {
    fmt.Printf("Error: %v\n", err)
    os.Exit(1)
  }
  
  // Scan the collection of staged files for secrets and add any file names 
  // matching defined patterns to the list of findings
  var findings []string
  for _, file := range files {
    findings = append(findings, scanFile(file)...)
  }
  
  // Abort the commit if any secrets are found
  if len(findings) > 0 {
    fmt.Println("Potential secrets detected in staged files:")
    for _, f := range findings {
      fmt.Println(f)
    }
    fmt.Println("\nCommit aborted. Please remove secrets before committing.")
    os.Exit(1)
  }
}

commit-msg hook to validate commit messages

Creating a detailed commit history for changes made to the code is good practice. This could include adding prefixes like fix or build to indicate the type of change that’s happened. Following the Conventional Commits specification is a great way to ensure your commit message history is explicit by laying out a simple convention for commit messages. Let’s take a look at an example written in Go.

func main() {
  if len(os.Args) < 2 {
    fmt.Println("Error: No file name provided");
    os.Exit(1);
  }

  // The first argument is the name of the file holding the commit message
  filename := os.Args[1];

  // Open up the file and read the contents into memory so we can validate it 
  // follows our pattern
  msg, err := os.ReadFile(filename);
  if err != nil {
    fmt.Printf("Failed to read %v, %v", filename, err);
    os.Exit(1);
  }

  // Match all commit messages that follow common convention types
  pattern := `^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test): .+`;

  // Match the commit message against the regex pattern
  isMatch, err := regexp.MatchString(pattern, string(msg));
  if err != nil {
    fmt.Printf("Failed to match commit message, %v\n", err);
    os.Exit(1);
  } else if !isMatch {
    fmt.Printf("Commit message does not match expected pattern\n %v", string(msg));
    os.Exit(1);
  }
}

pre-push hook to guard branches

As a project maintainer you probably want to protect your main production branch from changes until they’ve had a chance to make it through a code review. The pre-push hook can perform that task on the client side by parsing the branch receiving the push and rejecting it if needed. Here’s an example of how can be implemented.

const (
  protectedBranch    = "refs/heads/main"
  expectedRefFields  = 4
)

func main() {
  scanner := bufio.NewScanner(os.Stdin)

  for scanner.Scan() {
    line := scanner.Text()
    if line == "" {
      continue
    }

    // Parse the stdin format: <local-ref> <local-sha> <remote-ref> <remote-sha>
    parts := strings.Fields(line)
    if len(parts) < expectedRefFields {
      continue
    }

    remoteRef := parts[2]

    // Check if pushing to protected branch
    if remoteRef == protectedBranch {
      fmt.Printf("Failed to push to '%s'. Please create a new branch and open up a pull request.\n", protectedBranch)
      os.Exit(1)
    }
  }

  if err := scanner.Err(); err != nil {
    fmt.Printf("Error reading stdin: %v\n", err)
    os.Exit(1)
  }
}

Saving Git Hooks Across Repositories

By default, git hooks live in .git/hooks which is not tracked by version control, meaning your hooks are lost when you clone a repository on a new machine. To persist hooks across repositories, you can configure a global hooks directory that git will reference automatically:

git config --global core.hooksPath ~/.git-hooks

Any hooks placed in ~/.git-hooks will be picked up by every repository on your machine without any per-project setup. To share hooks across a team, you can commit them to a directory in your repository (e.g. .githooks) and have developers point their global config at it after cloning:

git config --global core.hooksPath .githooks

This approach keeps hooks version controlled, reviewable, and consistent across your team without relying on each developer to manually copy files into .git/hooks.

Conclusion

Using git hooks effectively support the shift-left approach in software development, moving the focus of error detection to earlier stages in the development cycle. Everything we covered in this post can be handled in CI pipelines but this requires cloud resources and places the sole focus of error detection in one area of the SDLC.

For the full examples with supporting test cases, check out my git-hooks repository — a good starting point for building out your own suite of hooks tailored to your team’s workflow.