Run and Test Git Hooks Safely with git hook
You will finish with a small, repeatable way to run a Git hook by name, pass it arguments or a file on standard input, and explain why Git did or did not find it. The examples match Git 2.43.0 from the Ubuntu git-man package installed here.
The route
Jump straight to the step you need, or tick off Done means at the end.
Allow about fifteen minutes. You need Git, a repository you can safely test, and permission to read its hooks directory. This guide runs hooks, so treat a hook as executable code: read it before testing it, and do not run an unfamiliar hook in a repository containing secrets or valuable uncommitted work. The examples do not need sudo.
1. Check the installed interface
Start with the local command and package version. This is read-only:
$ git --version
git version 2.43.0
$ git hook -h
usage: git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]
The only subcommand in this installed interface is run. It is a command interface for scripts that need to invoke a hook. It does not create a hook, enable a hook, or decide which lifecycle point is appropriate for your project.
Checkpoint
Confirm the version before copying examples into automation. Option details can differ in another Git release.
2. Put a harmless test hook in the right directory
Git looks for hooks in $GIT_DIR/hooks by default. In a normal working repository, that means the .git/hooks directory. A hook is ignored unless its executable bit is set.
In a disposable repository, create a hook that reports its working directory, arguments and standard input:
$ git init /tmp/hook-demo
$ cd /tmp/hook-demo
$ cat > .git/hooks/demo <<'EOF'
#!/bin/sh
printf 'hook cwd: %s\n' "$PWD"
printf 'arg 1: %s\n' "$1"
printf 'arg 2: %s\n' "$2"
printf 'stdin: '
cat
EOF
$ chmod +x .git/hooks/demo
The hook name is the filename, demo. Git does not require a filename extension. The shebang selects the interpreter, and chmod +x makes the file runnable. Read the file before running it: a real hook can edit files, reject commits, send data elsewhere, or run privileged commands.
Run it without input first:
$ git hook run demo
hook cwd: /tmp/hook-demo
arg 1:
arg 2:
stdin:
$ printf 'exit status: %s\n' "$?"
exit status: 0
Git changes the hook's working directory to the working-tree root for a non-bare repository. A hook in a bare repository runs from $GIT_DIR. Push-side hooks such as pre-receive, update and post-receive run in $GIT_DIR even when the repository has a working tree.
3. Pass hook arguments after the separator
Put positional arguments after a mandatory --. This separates arguments intended for the hook from options understood by git hook run:
$ git hook run demo -- first-value 'second value'
hook cwd: /tmp/hook-demo
arg 1: first-value
arg 2: second value
stdin:
Without the separator, a value beginning with a dash can be interpreted as a Git option. Quote values that contain spaces or shell punctuation. Do not build the whole command as one unquoted string when an argument came from a user or another process.
Git does not add the arguments for you. The hook receives exactly the values after the separator. The arguments depend on the lifecycle hook: for example, commit-msg receives the commit message file, while pre-commit receives no parameters. See the installed githooks(5) entry for the contract of the hook you are invoking.
4. Stream a file into standard input
Use --to-stdin=<path> when the hook should receive a complete file on standard input. The file is streamed from its beginning to EOF:
$ printf 'input supplied by the caller\n' > hook-input.txt
$ git hook run --to-stdin=hook-input.txt demo -- checked
hook cwd: /tmp/hook-demo
arg 1: checked
arg 2:
stdin: input supplied by the caller
The path in this example is relative to the directory where you run Git, not a path invented inside the hook. Use an absolute path when a script's current directory is uncertain. Check the input before running a hook that can make changes:
$ test -r hook-input.txt && wc -c < hook-input.txt
29
Do not assume that an ordinary lifecycle hook expects stdin merely because this option exists. Follow that hook's documented interface. Supplying stdin is useful for a hook with an input protocol or for a controlled test, but it does not turn arbitrary text into valid input.
5. Select a different hooks directory
core.hooksPath replaces the default $GIT_DIR/hooks location. Inspect the effective setting before troubleshooting a hook that appears to be missing:
$ git config --show-origin --get core.hooksPath
$ git rev-parse --git-dir
.git
No output from the first command means that the default directory is in use. A relative configured path is interpreted relative to the directory where Git normally resolves repository configuration. For a local test, set a repository-local path and create the executable there:
$ mkdir -p .githooks
$ printf '%s\n' '#!/bin/sh' 'echo custom-hook-ok' > .githooks/demo
$ chmod +x .githooks/demo
$ git config core.hooksPath .githooks
$ git hook run demo
custom-hook-ok
This changes the repository configuration. Undo it when the test is complete:
$ git config --unset core.hooksPath
$ git config --show-origin --get core.hooksPath
$ git hook run demo
hook cwd: /tmp/hook-demo
The final run works because Git has returned to .git/hooks/demo. If the setting was inherited from a global or system configuration, --unset may report that there is no local value. In that case, inspect all levels with git config --show-origin --show-scope --get-all core.hooksPath and remove only the setting you intentionally added. Do not delete a shared hooks directory to fix a lookup problem.
6. Handle missing and ignored hooks
A missing hook is normally an error:
$ git hook run not-installed
error: cannot find a hook named not-installed
$ printf 'exit status: %s\n' "$?"
exit status: 1
Use --ignore-missing only when the caller deliberately wants an optional hook. It returns zero without producing an error:
$ git hook run --ignore-missing not-installed
$ printf 'exit status: %s\n' "$?"
exit status: 0
That option does not ignore a hook that exists but fails. A non-executable hook is ignored by Git, so check both the file and its mode:
$ hook_path=.git/hooks/demo
$ test -f "$hook_path" && ls -l "$hook_path"
-rwxr-xr-x 1 you you 123 Sep 24 10:00 .git/hooks/demo
$ test -x "$hook_path" && echo executable
executable
Use the actual size, owner and timestamp from your machine. If a hook is present but not executable, fix the mode only after reviewing its contents: chmod u+x .git/hooks/name. A hook's non-zero exit status is passed back to Git, so a failing hook can be the expected result of a policy check rather than a failure of git hook run itself.
7. Know what the hook can see
Git exports repository-related variables such as GIT_DIR and GIT_WORK_TREE so commands inside a hook can find the current repository. If the hook invokes Git on another repository, clear Git's local environment first, for example:
$ unset $(git rev-parse --local-env-vars)
$ git -C /path/to/other-repository status --short
That command changes only the current shell's environment. Prefer a subshell in a script if later commands need the original repository context. Replace /path/to/other-repository with a repository you trust and can access; do not use a path supplied by an untrusted caller without validating it.
Hooks are ordinary programs. They can observe their environment, read files available to the invoking user, and change repository state. Run a hook as the account that normally runs the Git operation. Do not use sudo git hook run ... to make a permission error disappear: that changes ownership and environment assumptions and can make the test misleading.
Done means
- You confirmed the installed Git version and
git hook runsyntax. - You know whether the hook comes from
.git/hooksorcore.hooksPath. - You passed hook arguments after
--and streamed stdin only when the hook expects it. - You checked executable permissions and distinguished a missing hook from a failing hook.
- You tested with an ordinary user in a disposable or reviewed repository.
- You removed any temporary
core.hooksPathsetting and kept the test hook only if it is intentional.