Git Hooks: Respect Their Arguments, Working Directory, and Environment
Implement dependable Git hooks by following each hook's documented input, repository context, executable requirements, and failure contract.
Git hooks are ordinary executable programs that Git calls at specific points in repository operations. Their interface is determined by the hook being invoked: a hook may receive positional arguments, read records from standard input, inspect a message file, or only return a status. Git also chooses the hook’s working directory and exports repository-related environment variables. A script that assumes every hook runs from the same directory or accepts the same inputs can appear correct in one workflow and fail in another.
Treat each hook as a small command-line program with a documented lifecycle. Read the exact hook section in githooks(5), preserve its argument boundaries, and define what a nonzero exit should do to the surrounding Git command. Keep hooks fast and local; required validation must also run in CI because hooks are not a security boundary and are not guaranteed to be installed or enabled for every clone.
Know which hook is being called
Different hooks run at different phases. pre-commit runs before a commit is created and receives no arguments. commit-msg receives the path to the proposed message file and can reject or edit it. pre-push is given the remote name and location as arguments and receives ref update records on standard input. These are different interfaces, not interchangeable callbacks. Other hooks have their own documented arguments and whether their result can affect the operation.
Read structured input line by line and preserve fields. In a pre-push hook, do not parse git push output from human-formatted logs; consume the documented standard-input records. If any field can contain whitespace or unusual ref text, use the exact protocol format and avoid unsafe eval or unquoted expansions. Treat ref names and paths as data. Validate their format through Git’s own utilities where possible instead of assuming a branch name is a safe filename or command fragment.
A useful pre-commit check can validate the staged change without rewriting the developer’s entire working tree:
#!/bin/sh
set -eu
git diff --cached --check
The hook should return nonzero when the policy check fails; Git then stops the operation for hooks that are documented as veto-capable. Use git diff --cached when the policy concerns the index, not just git diff of unstaged worktree changes. A pre-commit check that formats files should either stage the intended result clearly or refuse and ask the developer to review and stage it; silently modifying files can make the index differ from what was validated.
Respect the repository context
Before running a hook, Git changes the current directory. For most hooks in a non-bare repository, that is the top level of the working tree; in a bare repository it is $GIT_DIR. Several push-related server hooks are an exception and run in $GIT_DIR even for a non-bare repository. Make path resolution explicit: use repository-root paths for client hooks and query Git for repository paths where server-side behavior differs.
Git exports variables such as GIT_DIR, GIT_WORK_TREE, and other local repository settings so Git commands launched by the hook find the correct repository. This is helpful for commands operating on the same repository, but those variables can point a Git subprocess away from a separate repository that the hook wants to inspect. The Git documentation shows clearing the repository-local environment variables before operating on a foreign repository. Do not indiscriminately clear all variables for normal in-repository commands; do so only at the foreign-repository boundary.
For diagnostics, record the hook name, current directory, and relevant arguments in a controlled test repository. Avoid logging credential-bearing environment variables or dumping the full environment. A hook may run from a commit, merge, receive, or push context where the worktree is not in the same state as an interactive shell. Test those exact operations in a throwaway repository rather than assuming the developer’s current branch represents the data Git will present.
Install hooks deliberately
By default Git looks in $GIT_DIR/hooks, but core.hooksPath can redirect the directory. A project may keep reviewed hook scripts in a versioned directory such as .githooks, then configure each clone to use it. Document the installation step and verify the effective setting; a tracked script in the worktree is not automatically active merely because the file exists.
Git ignores hook files that lack the executable bit. On systems where file mode is significant, set and review the executable mode in version control. On filesystems or configurations where executable-bit tracking differs, verify behavior on the supported platforms. Give the script an explicit shebang naming an interpreter that is actually installed on the host. Do not depend on an interactive shell’s aliases, functions, or startup files.
Hooks can be installed by templates, package managers, development tools, or a user’s global Git configuration. Inspect git config --show-origin --get core.hooksPath when an expected hook does not run. The setting’s source matters: a global configuration can override a project convention, while a repository-local value may not be present in a fresh clone. git rev-parse --git-path hooks helps identify Git’s configured hook path, but a custom hooks path still needs to be checked through the active configuration.
A repository-local hook installer should be explicit, repeatable, and safe to rerun. It should not overwrite a user’s existing hook directory without preserving or reporting what will change. If the project uses a bootstrap script to set local Git configuration, make that change transparent and provide a verification command. Do not assume a remote repository can force a local clone to execute its checked-in hook script.
Keep hooks fast, deterministic, and safe
A hook runs synchronously in the Git command that triggered it. Slow network checks make commits and pushes unreliable, and they can fail because a service is unavailable even though the local change is sound. Prefer deterministic local checks in hooks; put authoritative policy in a CI system or server-side receive hook that the organization controls. If a local hook offers an opt-out, document that it does not replace required CI validation.
Avoid recursively invoking the same Git operation from a hook. A pre-commit hook that runs git commit can re-enter itself, and a pre-push hook that launches another push can recurse or deadlock. Invoke narrow read-only commands or a dedicated test script instead. If a hook needs to create a commit or change refs, design the interaction explicitly and guard it against recursion.
A hook should not rewrite arbitrary files as a side effect of checking them. For format enforcement, run the formatter in check mode or compare the proposed result, then ask the user to stage the modification. When a hook does update a commit message, use the documented message-file interface and keep the edit scoped to that file. For hooks that process filenames, use NUL-delimited output and read it without word splitting where the underlying Git command supports -z.
Return statuses should have one meaning. Exit zero when the policy passes; return a nonzero code and concise actionable diagnostic when it fails. Do not rely on set -e as the only policy engine: conditional commands, pipelines, and tested failures can alter errexit behavior. Write the validation branch explicitly and test both a passing and failing case. Keep output readable in both a terminal and an IDE’s Git integration.
Test a hook as an integration boundary
Create a disposable repository, install the hook through the same configuration path used by developers, and trigger the actual Git operation. Verify the hook receives expected arguments, stdin, current directory, and environment. Test with staged and unstaged edits, unusual filenames, no changes, an invalid commit message, and a deliberately failing validator. Confirm that a veto-capable hook blocks the intended operation and that a notification-only hook does not falsely appear to enforce policy.
Test with a bare repository for server-side receive hooks and with a linked worktree if the project supports worktrees. Push-related hooks have different directory behavior and may receive a stream of refs; a local pre-commit test cannot validate them. Also verify that a fresh clone can install the hooks and that a user who has not completed installation still receives the required CI checks.
When a hook fails unexpectedly, inspect the effective hooks path, executable mode, shebang interpreter, Git version, working directory, arguments, stdin protocol, and relevant local Git environment. Do not debug it only by running the script manually from the repository root: that skips Git’s actual invocation contract. A reliable hook remains a small, testable adapter between Git’s documented event and the project’s own validation command.
Related:
Sources: