Repository: pre-commit/pre-commit

Issue:
post-checkout hook prevents pre-commit hooks from working with unstaged files
Hi there!

Most important thing first: pre-commit is great, it makes managing git hooks trivial and when combined with static analysis hooks, helps our team immeasurably - **thanks for all the hard work**.

We have pre-commit hooks that run a bunch of checks, and thanks to #1339,  a post-checkout hook that does some workspace analysis and prints a few warnings if needed. 

Unfortunately this seems to have broken the "stash unstaged changes" behaviour when committing - the stash [runs a git checkout](https://github.com/pre-commit/pre-commit/blob/5a625013071877a5b5dc8eba5b25c791c9c9c6ec/pre_commit/staged_files_only.py#L60) to clear the working directory before running the hooks, and that fails because of the post-checkout hook. I'd expect that the working directory to be cleared as normal, commit OK and then the working directory to be restored as it does without the post-checkout hook, as the actual mechanism behind removing the unstaged changes and restoring them is hidden from the user.

Below is a quick test to reproduce it:

```bash
#!/usr/bin/env bash

mkdir test
cd test || exit
git init

# Configure a pre-commit and post-checkout hook
cat << EOF > .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: pre-commit-ok
        name: Pre-commit hook that always passes
        stages: [commit]
        language: system
        entry: sh -c 'exit 0'
        always_run: true

  - repo: local
    hooks:
      - id: post-checkout-fail
        name: Post-checkout hook that fails
        stages: [post-checkout]
        language: system
        entry: sh -c 'exit 1'
        always_run: true

EOF

echo "bananas" > great-things.txt
git add .
git commit -m "init"

# Install the hooks
pre-commit install -t pre-commit
pre-commit install -t post-checkout

# Do a checkout - this is expected to print an error from the lint
git checkout -b test

# Change the original file
echo "platanos" >> great-things.txt

# Add another file and stage it
echo "42" > answer.txt
git add answer.txt

# Attempt to commit the new, staged file.
#
# Without the post-checkout hook, this causes the unstaged changes to be
# stashed and the commit works as normal.
echo ""
echo ""
echo "    Attempting to commit with unstaged changes"
echo "    This will fail when attempting to checkout as part of the commit hook:"
echo ""
echo ""
git commit -m "more info"
```

Unfortunately there seems to be no easy way to skip the post-checkout hook when clearing the unstaged files with `git checkout`.

Happy to open a PR to help fix this, just unsure what to do - I could swap the `git checkout` for a `git stash save --keep-index --include-untracked` but I assume it is using checkout for reason.

Comments:

Patch:
diff --git a/pre_commit/commands/run.py b/pre_commit/commands/run.py
index 8a9352d..c2dab6f 100644
--- a/pre_commit/commands/run.py
+++ b/pre_commit/commands/run.py
@@ -324,6 +324,12 @@ def run(
             f'`--hook-stage {args.hook_stage}`',
         )
         return 1
+    # prevent recursive post-checkout hooks (#1418)
+    if (
+            args.hook_stage == 'post-checkout' and
+            environ.get('_PRE_COMMIT_SKIP_POST_CHECKOUT')
+    ):
+        return 0
 
     # Expose from-ref / to-ref as environment variables for hooks to consume
     if args.from_ref and args.to_ref:
diff --git a/pre_commit/staged_files_only.py b/pre_commit/staged_files_only.py
index 09d323d..6179301 100644
--- a/pre_commit/staged_files_only.py
+++ b/pre_commit/staged_files_only.py
@@ -56,8 +56,10 @@ def _unstaged_changes_cleared(patch_dir: str) -> Generator[None, None, None]:
         with open(patch_filename, 'wb') as patch_file:
             patch_file.write(diff_stdout_binary)
 
-        # Clear the working directory of unstaged changes
-        cmd_output_b('git', 'checkout', '--', '.')
+        # prevent recursive post-checkout hooks (#1418)
+        no_checkout_env = dict(os.environ, _PRE_COMMIT_SKIP_POST_CHECKOUT='1')
+        cmd_output_b('git', 'checkout', '--', '.', env=no_checkout_env)
+
         try:
             yield
         finally:
@@ -72,8 +74,9 @@ def _unstaged_changes_cleared(patch_dir: str) -> Generator[None, None, None]:
                 # We failed to apply the patch, presumably due to fixes made
                 # by hooks.
                 # Roll back the changes made by hooks.
-                cmd_output_b('git', 'checkout', '--', '.')
+                cmd_output_b('git', 'checkout', '--', '.', env=no_checkout_env)
                 _git_apply(patch_filename)
+
             logger.info(f'Restored changes from {patch_filename}.')
     else:
         # There weren't any staged files so we don't need to do anything


Test patch:
diff --git a/testing/util.py b/testing/util.py
index 439bee7..19500f6 100644
--- a/testing/util.py
+++ b/testing/util.py
@@ -103,10 +103,12 @@ def cwd(path):
         os.chdir(original_cwd)
 
 
-def git_commit(*args, fn=cmd_output, msg='commit!', **kwargs):
+def git_commit(*args, fn=cmd_output, msg='commit!', all_files=True, **kwargs):
     kwargs.setdefault('stderr', subprocess.STDOUT)
 
-    cmd = ('git', 'commit', '--allow-empty', '--no-gpg-sign', '-a') + args
+    cmd = ('git', 'commit', '--allow-empty', '--no-gpg-sign', *args)
+    if all_files:  # allow skipping `-a` with `all_files=False`
+        cmd += ('-a',)
     if msg is not None:  # allow skipping `-m` with `msg=None`
         cmd += ('-m', msg)
     ret, out, _ = fn(*cmd, **kwargs)
diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py
index 6d75e68..5809a3f 100644
--- a/tests/commands/install_uninstall_test.py
+++ b/tests/commands/install_uninstall_test.py
@@ -789,6 +789,37 @@ def test_post_checkout_integration(tempdir_factory, store):
         assert 'some_file' not in stderr
 
 
+def test_skips_post_checkout_unstaged_changes(tempdir_factory, store):
+    path = git_dir(tempdir_factory)
+    config = {
+        'repo': 'local',
+        'hooks': [{
+            'id': 'fail',
+            'name': 'fail',
+            'entry': 'fail',
+            'language': 'fail',
+            'always_run': True,
+            'stages': ['post-checkout'],
+        }],
+    }
+    write_config(path, config)
+    with cwd(path):
+        cmd_output('git', 'add', '.')
+        _get_commit_output(tempdir_factory)
+
+        install(C.CONFIG_FILE, store, hook_types=['pre-commit'])
+        install(C.CONFIG_FILE, store, hook_types=['post-checkout'])
+
+        # make an unstaged change so staged_files_only fires
+        open('file', 'a').close()
+        cmd_output('git', 'add', 'file')
+        with open('file', 'w') as f:
+            f.write('unstaged changes')
+
+        retc, out = _get_commit_output(tempdir_factory, all_files=False)
+        assert retc == 0
+
+
 def test_prepare_commit_msg_integration_failing(
         failing_prepare_commit_msg_repo, tempdir_factory, store,
 ):
diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py
index c51bcff..2fffdb9 100644
--- a/tests/commands/run_test.py
+++ b/tests/commands/run_test.py
@@ -1022,3 +1022,9 @@ def test_args_hook_only(cap_out, store, repo_with_passing_hook):
         run_opts(hook='do_not_commit'),
     )
     assert b'identity-copy' not in printed
+
+
+def test_skipped_without_any_setup_for_post_checkout(in_git_dir, store):
+    environ = {'_PRE_COMMIT_SKIP_POST_CHECKOUT': '1'}
+    opts = run_opts(hook_stage='post-checkout')
+    assert run(C.CONFIG_FILE, store, opts, environ=environ) == 0