domodwyer: Running `git checkout-index --all --force` is not equivalent, so changed to using the env var to skip hooks triggered by the `git checkout` call.
asottile: needs a test as well
domodwyer: > needs a test as well

As I said, I'm trying to help without really knowing python - the tests are particularly hard to pick apart.
asottile: oh ok, I can help wiht the test
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/resources/empty_template_go.mod b/pre_commit/resources/empty_template_go.mod
deleted file mode 100644
index e69de29..0000000
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
diff --git a/pre_commit/store.py b/pre_commit/store.py
index 8bcbf99..760b37a 100644
--- a/pre_commit/store.py
+++ b/pre_commit/store.py
@@ -182,9 +182,9 @@ class Store:
         return self._new_repo(repo, ref, deps, clone_strategy)
 
     LOCAL_RESOURCES = (
-        'Cargo.toml', 'main.go', 'go.mod', 'main.rs', '.npmignore',
-        'package.json', 'pre_commit_dummy_package.gemspec', 'setup.py',
-        'environment.yml', 'Makefile.PL',
+        'Cargo.toml', 'main.go', 'main.rs', '.npmignore', 'package.json',
+        'pre_commit_dummy_package.gemspec', 'setup.py', 'environment.yml',
+        'Makefile.PL',
     )
 
     def make_local(self, deps: Sequence[str]) -> str:

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.
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

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest
3.9
requirements-dev.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
cfgv==3.4.0
covdefaults==2.3.0
coverage==7.8.0
distlib==0.3.9
exceptiongroup==1.2.2
filelock==3.18.0
identify==2.6.9
iniconfig==2.1.0
nodeenv==1.9.1
packaging==24.2
platformdirs==4.3.7
pluggy==1.5.0
-e git+https://github.com/pre-commit/pre-commit.git@5ed3f5649bce212ddf93ac741c1378cb82b13ccc#egg=pre_commit
pytest==8.3.5
pytest-env==1.1.5
PyYAML==6.0.2
toml==0.10.2
tomli==2.2.1
virtualenv==20.29.3

name: pre-commit
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - cfgv==3.4.0
      - covdefaults==2.3.0
      - coverage==7.8.0
      - distlib==0.3.9
      - exceptiongroup==1.2.2
      - filelock==3.18.0
      - identify==2.6.9
      - iniconfig==2.1.0
      - nodeenv==1.9.1
      - packaging==24.2
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pytest==8.3.5
      - pytest-env==1.1.5
      - pyyaml==6.0.2
      - toml==0.10.2
      - tomli==2.2.1
      - virtualenv==20.29.3
prefix: /opt/conda/envs/pre-commit

tests/commands/install_uninstall_test.py::test_skips_post_checkout_unstaged_changes tests/commands/run_test.py::test_skipped_without_any_setup_for_post_checkout
tests/commands/install_uninstall_test.py::test_install_in_submodule_and_run tests/commands/install_uninstall_test.py::test_pre_merge_commit_integration
tests/commands/install_uninstall_test.py::test_is_not_script tests/commands/install_uninstall_test.py::test_is_script tests/commands/install_uninstall_test.py::test_is_previous_pre_commit tests/commands/install_uninstall_test.py::test_shebang_windows tests/commands/install_uninstall_test.py::test_shebang_posix_not_on_path tests/commands/install_uninstall_test.py::test_shebang_posix_on_path tests/commands/install_uninstall_test.py::test_install_pre_commit tests/commands/install_uninstall_test.py::test_install_hooks_directory_not_present tests/commands/install_uninstall_test.py::test_install_multiple_hooks_at_once tests/commands/install_uninstall_test.py::test_install_refuses_core_hookspath tests/commands/install_uninstall_test.py::test_install_hooks_dead_symlink tests/commands/install_uninstall_test.py::test_uninstall_does_not_blow_up_when_not_there tests/commands/install_uninstall_test.py::test_uninstall tests/commands/install_uninstall_test.py::test_install_pre_commit_and_run tests/commands/install_uninstall_test.py::test_install_pre_commit_and_run_custom_path tests/commands/install_uninstall_test.py::test_install_in_worktree_and_run tests/commands/install_uninstall_test.py::test_commit_am tests/commands/install_uninstall_test.py::test_unicode_merge_commit_message tests/commands/install_uninstall_test.py::test_install_idempotent tests/commands/install_uninstall_test.py::test_environment_not_sourced tests/commands/install_uninstall_test.py::test_failing_hooks_returns_nonzero tests/commands/install_uninstall_test.py::test_install_existing_hooks_no_overwrite tests/commands/install_uninstall_test.py::test_legacy_overwriting_legacy_hook tests/commands/install_uninstall_test.py::test_install_existing_hook_no_overwrite_idempotent tests/commands/install_uninstall_test.py::test_failing_existing_hook_returns_1 tests/commands/install_uninstall_test.py::test_install_overwrite_no_existing_hooks tests/commands/install_uninstall_test.py::test_install_overwrite tests/commands/install_uninstall_test.py::test_uninstall_restores_legacy_hooks tests/commands/install_uninstall_test.py::test_replace_old_commit_script tests/commands/install_uninstall_test.py::test_uninstall_doesnt_remove_not_our_hooks tests/commands/install_uninstall_test.py::test_installs_hooks_with_hooks_True tests/commands/install_uninstall_test.py::test_install_hooks_command tests/commands/install_uninstall_test.py::test_installed_from_venv tests/commands/install_uninstall_test.py::test_pre_push_integration_failing tests/commands/install_uninstall_test.py::test_pre_push_integration_accepted tests/commands/install_uninstall_test.py::test_pre_push_force_push_without_fetch tests/commands/install_uninstall_test.py::test_pre_push_new_upstream tests/commands/install_uninstall_test.py::test_pre_push_environment_variables tests/commands/install_uninstall_test.py::test_pre_push_integration_empty_push tests/commands/install_uninstall_test.py::test_pre_push_legacy tests/commands/install_uninstall_test.py::test_commit_msg_integration_failing tests/commands/install_uninstall_test.py::test_commit_msg_integration_passing tests/commands/install_uninstall_test.py::test_commit_msg_legacy tests/commands/install_uninstall_test.py::test_post_commit_integration tests/commands/install_uninstall_test.py::test_post_checkout_integration tests/commands/install_uninstall_test.py::test_prepare_commit_msg_integration_failing tests/commands/install_uninstall_test.py::test_prepare_commit_msg_integration_passing tests/commands/install_uninstall_test.py::test_prepare_commit_msg_legacy tests/commands/install_uninstall_test.py::test_install_disallow_missing_config tests/commands/install_uninstall_test.py::test_install_allow_missing_config tests/commands/install_uninstall_test.py::test_install_temporarily_allow_mising_config tests/commands/run_test.py::test_start_msg tests/commands/run_test.py::test_full_msg tests/commands/run_test.py::test_full_msg_with_cjk tests/commands/run_test.py::test_full_msg_with_color tests/commands/run_test.py::test_full_msg_with_postfix tests/commands/run_test.py::test_full_msg_postfix_not_colored tests/commands/run_test.py::test_run[options3-outputs3-1-True] tests/commands/run_test.py::test_run[options4-outputs4-1-True] tests/commands/run_test.py::test_run[options7-outputs7-0-False] tests/commands/run_test.py::test_from_ref_to_ref_error_msg_error[master-] tests/commands/run_test.py::test_from_ref_to_ref_error_msg_error[-master] tests/commands/run_test.py::test_all_push_options_ok tests/commands/run_test.py::test_checkout_type tests/commands/run_test.py::test_has_unmerged_paths tests/commands/run_test.py::test_merge_conflict tests/commands/run_test.py::test_merge_conflict_modified tests/commands/run_test.py::test_compute_cols[hooks0-80] tests/commands/run_test.py::test_compute_cols[hooks1-81] tests/commands/run_test.py::test_compute_cols[hooks2-82] tests/commands/run_test.py::test_get_skips[environ0-expected_output0] tests/commands/run_test.py::test_get_skips[environ1-expected_output1] tests/commands/run_test.py::test_get_skips[environ2-expected_output2] tests/commands/run_test.py::test_get_skips[environ3-expected_output3] tests/commands/run_test.py::test_get_skips[environ4-expected_output4] tests/commands/run_test.py::test_get_skips[environ5-expected_output5] tests/commands/run_test.py::test_get_skips[environ6-expected_output6] tests/commands/run_test.py::test_skip_hook tests/commands/run_test.py::test_skip_aliased_hook tests/commands/run_test.py::test_hook_id_not_in_non_verbose_output tests/commands/run_test.py::test_hook_id_in_verbose_output tests/commands/run_test.py::test_non_ascii_hook_id tests/commands/run_test.py::test_stdout_write_bug_py26 tests/commands/run_test.py::test_lots_of_files tests/commands/run_test.py::test_stages tests/commands/run_test.py::test_post_checkout_hook tests/commands/run_test.py::test_meta_hook_passes tests/commands/run_test.py::test_error_with_unstaged_config tests/commands/run_test.py::test_commit_msg_missing_filename tests/commands/run_test.py::test_files_running_subdir tests/commands/run_test.py::test_classifier_removes_dne tests/commands/run_test.py::test_classifier_normalizes_filenames_on_windows_to_forward_slashes tests/commands/run_test.py::test_classifier_does_not_normalize_backslashes_non_windows tests/commands/run_test.py::test_include_exclude_base_case tests/commands/run_test.py::test_matches_broken_symlink tests/commands/run_test.py::test_include_exclude_total_match tests/commands/run_test.py::test_include_exclude_does_search_instead_of_match tests/commands/run_test.py::test_include_exclude_exclude_removes_files tests/commands/run_test.py::test_args_hook_only
pre_commit/store.py pre_commit/resources/empty_template_go.mod pre_commit/staged_files_only.py pre_commit/commands/run.py
pre_commit/store.py pre_commit/resources/empty_template_go.mod pre_commit/staged_files_only.py pre_commit/commands/run.py