Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 64 additions & 47 deletions extra/git/hooks.nix
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,56 @@ let
};
};

# All of the hook types supported by this module.
allHooks = filterAttrs (k: v: k != "enable") cfg;

# Only keep all the hooks that have a value set.
hooksWithData = filterAttrs (k: v: k != "enable" && v.text != "") cfg;
hooksWithData = filterAttrs (k: v: v.text != "") allHooks;

# Shims for all the hooks that this module supports. The shims cause git
# hooks to be ignored:
#
# 1. Outside of the devshell, or
# 2. When the current devshell doesn't define/enable *any* git hooks, or
# 3. When the current devshell doesn't define/enable the specific git hook
# in question.
#
# The idea here is to support scenarios like switching between multiple git
# worktrees without having to reinstall the hook symlinks. Instead, the hook
# shims read the correct "real" shim (directory) from DEVSHELL_GIT_HOOKS_DIR,
# which points to the directory containing git hooks for the current
# devshell.
hookShimsDir = pkgs.runCommand "git.hook.shims" {} ''
mkdir -p $out/bin

${lib.concatMapStringsSep "\n" (k: ''
cat <<'WRAPPER' > $out/bin/${k}
#!${pkgs.bash}/bin/bash
set -euo pipefail

if [[ -z "''${DEVSHELL_DIR:-}" ]]; then
echo "${k}: ignoring git hook outside of devshell"; >&2
exit;
elif [[ -z "''${DEVSHELL_GIT_HOOKS_DIR:-}" ]]; then
echo "${k}: git hooks are not activated in this environment"; >&2
exit;
elif ! [[ -x "''${DEVSHELL_GIT_HOOKS_DIR}/bin/${k}" ]]; then
echo "${k}: the ${k} git hook is not activated in this environment"; >&2
exit;
fi

exec "''${DEVSHELL_GIT_HOOKS_DIR}/bin/${k}" "$@"
WRAPPER

# Mark as executable
chmod +x "$out/bin/${k}"
'') (builtins.attrNames allHooks)}
'';

# A collection of all the git hooks in the /bin folder
hooksDir =
let
mkHookScript = k: hook:
pkgs.runCommand k
{
text = hook.text;
passAsFile = [ "text" ];
}
''
mkdir -p $out/bin

cp "$textPath" "$out/bin/.${k}-wrapped"

# Add a wrapper so that the hooks are ignored outside of the
# devshell.
cat <<'WRAPPER' > $out/bin/${k}
#!${pkgs.bash}/bin/bash
set -euo pipefail

if [[ -z "''${DEVSHELL_DIR:-}" ]]; then
echo "${k}: ignoring git hook outside of devshell"; >&2
exit;
fi
exec "@out@/bin/.${k}-wrapped" "$@"
WRAPPER
sed -e "s|@out@|$out|g" -i "$out/bin/${k}"

# Mark as executable
chmod +x "$out/bin/.${k}-wrapped" "$out/bin/${k}"
'';
mkHookScript = k: hook: pkgs.writeShellScriptBin k hook.text;
in
pkgs.buildEnv {
name = "git.hooks";
Expand All @@ -70,6 +86,10 @@ let
fi
}

git_path_absolute() {
${pkgs.gitMinimal}/bin/git rev-parse --path-format=absolute "$@"
}

# Add `readlink -f` for macOS
export PATH=${pkgs.coreutils}/bin:$PATH

Expand All @@ -79,23 +99,20 @@ let
log "skipping as we can't find any .git folder, we are probably not in a git repository" >&2
exit
fi
git_dir=$(${pkgs.gitMinimal}/bin/git rev-parse --absolute-git-dir)
source_hook_dir=${hooksDir}/bin
target_hook_dir=$git_dir/hooks

if [[ "$git_dir" != "$git_work_tree"/.git ]]; then
# There are cases where the '.git' folder lives in other places. For
# example the `git worktree` command. In these cases, don't touch the
# git hooks because they are shared between the various checkouts.
log "skipping as this worktree doen't contain the .git folder" >&2
exit
fi

# Respect GIT_COMMON_DIR on git clients that support it
git_dir=$(git_path_absolute --git-common-dir 2>/dev/null) || git_dir=$(git_path_absolute --git-dir)

source_hook_dir=${hookShimsDir}/bin

# Respect setups that define core.hooksPath
target_hook_dir=$(git_path_absolute --git-path hooks/ 2>/dev/null) || target_hook_dir=$git_dir/hooks

# Just in case it doesn't exist
mkdir -pv "$target_hook_dir"

# Iterate over all the hooks we know of
for name in ${toString (filter (name: name != "enable") (attrNames cfg))}; do
# Iterate over all the hooks enabled for this environment
for name in ${toString (attrNames hooksWithData)}; do
# Resolve all the symlinks
src_hook=$(readlink -f "$source_hook_dir/$name" || true)
dst_hook=$(readlink -f "$target_hook_dir/$name" || true)
Expand All @@ -107,11 +124,6 @@ let
elif [[ -f "$src_hook" ]]; then
has_update
ln -sfv "$src_hook" "$target_hook_dir/$name"
# If the target hook is a store path, assume it's an old hook and
# remove. Don't touch other existing hooks.
elif [[ "$dst_hook" == ${builtins.storeDir}/* ]]; then
has_update
rm -v "$target_hook_dir/$name"
fi
done
if [[ $update != 0 ]]; then
Expand Down Expand Up @@ -148,4 +160,9 @@ in
$DEVSHELL_DIR/bin/install-git-hooks
";
};

config.env = optional cfg.enable {
name = "DEVSHELL_GIT_HOOKS_DIR";
value = hooksDir;
};
}
102 changes: 95 additions & 7 deletions tests/extra/git.hooks.nix
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,114 @@
devshell.name = "git-hooks-1b";
git.hooks.enable = true;
};

shell3 = devshell.mkShell {
devshell.name = "git-hooks-1c";
};

shell4 = devshell.mkShell {
imports = [ ../../extra/git/hooks.nix ];
devshell.name = "git-hooks-1d";
git.hooks.enable = true;
git.hooks.pre-commit.text = ''
#!${pkgs.bash}/bin/bash
echo "PRE-COMMIT-OF-ANOTHER-COLOR"
'';
git.hooks.pre-rebase.text = ''
#!${pkgs.bash}/bin/bash
echo "NOPE"
exit 1
'';
};
in
runTest "git-hooks-1" { nativeBuildInputs = [ pkgs.git ]; } ''
git init
mkdir worktree-1

cd worktree-1

git init -b git-hook-test

# Set up fake config values in order to make a commit
git config user.email test@ing.123
git config user.name "Test User"

# Make a commit in order to add worktrees
git commit --allow-empty -m init

git_dir=$(${pkgs.gitMinimal}/bin/git rev-parse --absolute-git-dir)
git_hooks_path=$(git rev-parse --path-format=absolute --git-path hooks/ 2>/dev/null) \
|| git_hooks_path="''${git_dir}/hooks"

git_pre_commit_hook="''${git_hooks_path}/pre-commit"

# The hook doesn't exist yet
assert_fail -L .git/hooks/pre-commit
assert_fail -L "$git_pre_commit_hook"

# Load the devshell
source ${shell1}/env.bash

# The hook has been install
assert -L .git/hooks/pre-commit
# The hook has been installed
assert -L "$git_pre_commit_hook"

# The hook outputs what we want
assert "$(.git/hooks/pre-commit)" == "PRE-COMMIT"
assert "$("$git_pre_commit_hook")" == "PRE-COMMIT"

# Load the new config
source ${shell2}/env.bash

# The hook should have been uninstalled
assert_fail -L .git/hooks/pre-commit
# This specific hook should complain that it is not activated
assert "$("$git_pre_commit_hook")" == "pre-commit: the pre-commit git hook is not activated in this environment"

# Load a config with no hooks defined
# NOTE need to unset the hooks dir environment variable as this profile
# does not enable git hooks and therefore does not (re)set the variable
unset DEVSHELL_GIT_HOOKS_DIR
source ${shell3}/env.bash

# The hook should complain that *no* hooks are activated
assert "$("$git_pre_commit_hook")" == "pre-commit: git hooks are not activated in this environment"

git worktree add ../worktree-2

cd ../worktree-2

# Now source initial profile
source ${shell1}/env.bash

# The hook has been reinstalled
assert -L "$git_pre_commit_hook"

# The hook outputs what we want
assert "$("$git_pre_commit_hook")" == "PRE-COMMIT"

# Stash current pre-commit hook link path for later testing
git_pre_commit_real="$(readlink -f "$git_pre_commit_hook")"

# Added by shell4
git_pre_rebase_hook="''${git_hooks_path}/pre-rebase"

# Only shell4 has this hook
assert_fail -L "$git_pre_rebase_hook"

# Now source the profile that defines a pre-rebase hook
source ${shell4}/env.bash

# Pre-rebase hook should now exist
assert -L "$git_pre_rebase_hook"

# Stash pre-rebase hook link path for later testing
git_pre_rebase_real="$(readlink -f "$git_pre_rebase_hook")"

# The hook outputs what we want
assert "$("$git_pre_commit_hook")" == "PRE-COMMIT-OF-ANOTHER-COLOR"

# Pre-commit link should not have changed
assert "$git_pre_commit_real" = "$(readlink -f "$git_pre_commit_hook")"

# Switch back to profile without pre-rebase hook
source ${shell1}/env.bash

# Pre-rebase link should not have changed
assert "$git_pre_rebase_real" = "$(readlink -f "$git_pre_rebase_hook")"
'';
}