Git hooks can be useful, tracking and managing them with Nix makes removes the barrier of using them.
Git hooks
Git hooks are very useful in theory e.g. to enforce style guidelines of code being pushed or doing arbitrary cleanup/analysis in response to various git events. Yet, if you are using nix, the way git hooks are set up and managed goes against the ideas of Nix.
The presented approach does not solve the underlying issue of mutability but makes git-hooks more trackable and easily appliable.
NOTE
Flake
An up-to-date version of the presented approach can be found on GitHub.
Please file issues or PRs if you like the project and want to contribute.
The big picture
Let me present a full-fledged example first, the functions are individually posted down in the appendix.
let
installGitHooks = hookTypes:
let mkHook = type: hooks: {
hook = pkgs.writeShellScript type
''
for hook in ${pkgs.symlinkJoin { name = "${type}-git-hooks"; paths = hooks; }}/bin/*; do
$hook
RESULT=$?
if [ $RESULT != 0 ]; then
echo "$hook returned non-zero: $RESULT, abort operation"
exit $RESULT
fi
done
echo "$INSTALLED_GIT_HOOKS $type"
exit 0
'';
inherit type;
};
installHookScript = { type, hook }: ''
if [[ -e .git/hooks/${type} ]]; then
echo "Warn: ${type} hook already present, skipping"
else
ln -s ${hook} $PWD/.git/hooks/${type}
INSTALLED_GIT_HOOKS+=(${type})
fi
'';
in
pkgs.writeShellScriptBin "install-git-hooks"
''
if [[ ! -d .git ]] || [[ ! -f flake.nix ]]; then
echo "Invocate \`nix develop\` from the project root directory."
exit 1
fi
if [[ -e .git/hooks/nix-installed-hooks ]]; then
echo "Hooks already installed, reinstalling"
${uninstallGitHooks}/bin/${uninstallGitHooks.name}
fi
mkdir -p ./.git/hooks
${pkgs.lib.concatStringsSep "\n" (pkgs.lib.mapAttrsToList (type: hooks: installHookScript (mkHook type hooks)) hookTypes )}
echo "Installed git hooks: $INSTALLED_GIT_HOOKS"
printf "%s\n" "''${INSTALLED_GIT_HOOKS[@]}" > .git/hooks/nix-installed-hooks
'';
uninstallGitHooks = pkgs.writeShellScriptBin "uninstall-git-hooks"
''
if [[ ! -e "$PWD/.git/hooks/nix-installed-hooks" ]]; then
echo "Error: could find list of installed hooks."
exit 1
fi
while read -r hook
do
echo "Uninstalling $hook"
rm "$PWD/.git/hooks/$hook"
done < "$PWD/.git/hooks/nix-installed-hooks"
rm "$PWD/.git/hooks/nix-installed-hooks"
'';
rustFormatHook = pkgs.writeShellScriptBin "check-rust-format-hook"
''
${pkgs.rustfmt}/bin/rustfmt --check
RESULT=$?
[ $RESULT != 0 ] && echo "Please run \`cargo fmt\` before"
exit $RESULT
'';
hookInstaller = installGitHooks { pre-commit = [rustFormatHook]; }
in
pkgs.mkShell {
packages = [ (installGitHooks { pre-commit = [rustFormatHook];) } uninstallGitHooks ];
inputsFrom = [ ];
shellHook = ''
echo "=== Development shell ==="
echo "Info: Git hooks can be installed using \`install-git-hooks\`"
# or run `install-git-hooks` automatically
'';
};
Explanation
The above code defines functions to build installation and uninstallation commands for git hooks.
To run a hook, create a derivation with the hook (or hooks) for one event located in the bin/
folder, i.e. using. writeShellScriptBin
:
rustFormatHook = pkgs.writeShellScriptBin "check-rust-format-hook" ''
${pkgs.rustfmt}/bin/rustfmt --check
'';
Then create a hookInstaller
by adding the derivation to a list of hooks for a specific event type:
hookInstaller = installGitHooks { pre-commit = [rustFormatHook]; }
This will run all commands under ${rustFormatHook}/bin/*
for pre-commit
events.
The event types can be arbitrary but have to comply with actual git hooks to be run.
Finally, add the installer (and optionally the uninstall-command) to your dev shell as input programs. You can choose to automatically run the installer at entrance to the shell as a shellHook
or manually by the user.
pkgs.mkShell {
packages = [ hookInstaller uninstallGitHooks ];
inputsFrom = [ ];
shellHook = ''
echo "=== Development shell ==="
echo "Info: Git hooks can be installed using \`install-git-hooks\`"
# or run `install-git-hooks` automatically
'';
};
Clarification
I am aware that there is much more advanced tools available with more advanced configuration systems etc. Yet, this approach is nix-native and sufficiently flexible for simple hook setups. It does still require you to write all hooks yourself but this way they can be tracked with nix and in theory make use of programs not even populated to the final environment.
Nonetheless, this might be of interest for someone and if so thanks for reading.
Appendix
Copy the respective functions here to include them into your project or checkout the repository on GitHub avoid copy-pasting and receive upstream bugfixes. ## Installer
installGitHooks = hookTypes:
let mkHook = type: hooks: {
hook = pkgs.writeShellScript type
''
for hook in ${pkgs.symlinkJoin { name = "${type}-git-hooks"; paths = hooks; }}/bin/*; do
$hook
RESULT=$?
if [ $RESULT != 0 ]; then
echo "$hook returned non-zero: $RESULT, abort operation"
exit $RESULT
fi
done
echo "$INSTALLED_GIT_HOOKS $type"
exit 0
'';
inherit type;
};
installHookScript = { type, hook }: ''
if [[ -e .git/hooks/${type} ]]; then
echo "Warn: ${type} hook already present, skipping"
else
ln -s ${hook} $PWD/.git/hooks/${type}
INSTALLED_GIT_HOOKS+=(${type})
fi
'';
in
pkgs.writeShellScriptBin "install-git-hooks"
''
if [[ ! -d .git ]] || [[ ! -f flake.nix ]]; then
echo "Invocate \`nix develop\` from the project root directory."
exit 1
fi
if [[ -e .git/hooks/nix-installed-hooks ]]; then
echo "Hooks already installed, reinstalling"
${uninstallGitHooks}/bin/${uninstallGitHooks.name}
fi
mkdir -p ./.git/hooks
${pkgs.lib.concatStringsSep "\n" (pkgs.lib.mapAttrsToList (type: hooks: installHookScript (mkHook type hooks)) hookTypes )}
echo "Installed git hooks: $INSTALLED_GIT_HOOKS"
printf "%s\n" "''${INSTALLED_GIT_HOOKS[@]}" > .git/hooks/nix-installed-hooks
'';
Uninstaller
uninstallGitHooks = pkgs.writeShellScriptBin "uninstall-git-hooks"
''
if [[ ! -e "$PWD/.git/hooks/nix-installed-hooks" ]]; then
echo "Error: could find list of installed hooks."
exit 1
fi
while read -r hook
do
echo "Uninstalling $hook"
rm "$PWD/.git/hooks/$hook"
done < "$PWD/.git/hooks/nix-installed-hooks"
rm "$PWD/.git/hooks/nix-installed-hooks"
'';