diff --git a/extra/git/hooks.nix b/extra/git/hooks.nix index 9fd66f24..44b97a95 100644 --- a/extra/git/hooks.nix +++ b/extra/git/hooks.nix @@ -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"; @@ -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 @@ -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) @@ -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 @@ -148,4 +160,9 @@ in $DEVSHELL_DIR/bin/install-git-hooks "; }; + + config.env = optional cfg.enable { + name = "DEVSHELL_GIT_HOOKS_DIR"; + value = hooksDir; + }; } diff --git a/tests/extra/git.hooks.nix b/tests/extra/git.hooks.nix index de67f7e5..367ea358 100644 --- a/tests/extra/git.hooks.nix +++ b/tests/extra/git.hooks.nix @@ -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")" ''; }