Repository: pre-commit/pre-commit

Issue:
Support the post-rewrite hook type
It would be useful to me for pre-commit to support the `post-rewrite` hook:

https://git-scm.com/docs/githooks

> post-rewrite
This hook is invoked by commands that rewrite commits (git-commit[1] when called with --amend and git-rebase[1]; however, full-history (re)writing tools like git-fast-import[1] or git-filter-repo typically do not call it!). Its first argument denotes the command it was invoked by: currently one of amend or rebase. Further command-dependent arguments may be passed in the future.

My use-case is automatically running `poetry install` for a project when merging, rebasing, or checking out a different branch.  Most of this is possible with the `post-checkout` and `post-merge` hooks, but when rebasing a branch the `post-merge` hook is run before the rebasing applies changes of my branch.

For example:

```
 $ git pull origin main
remote: Enumerating objects: 11, done.
remote: Counting objects: 100% (11/11), done.
remote: Compressing objects: 100% (7/7), done.
remote: Total 11 (delta 4), reused 11 (delta 4), pack-reused 0
Unpacking objects: 100% (11/11), 31.67 KiB | 5.28 MiB/s, done.
From REDACTED
 * branch            main       -> FETCH_HEAD
   9a12988..36da943  main       -> origin/main
First, rewinding head to replay your work on top of it...

###
### The pre-commit hook runs here
###

Install dependencies.....................................................Passed
- hook id: poetry install
- duration: 2.83s
Installing dependencies from lock file
Package operations: 0 installs, 2 updates, 0 removals
  • Updating pytest (6.2.4 -> 6.2.5)
  • Updating pre-commit (2.14.0 -> 2.14.1)
Installing the current project: REDACTED (0.0.1)
Install pre-commit hooks.................................................Passed
- hook id: pre-commit install
- duration: 0.45s
Applying: REDACTED
Applying: REDACTED

###
### I would like the pre-commit hook to run here
###
```

Discussion:

Patch:
diff --git a/pre_commit/commands/hook_impl.py b/pre_commit/commands/hook_impl.py
index c544167..90bb33b 100644
--- a/pre_commit/commands/hook_impl.py
+++ b/pre_commit/commands/hook_impl.py
@@ -78,6 +78,7 @@ def _ns(
         commit_msg_filename: Optional[str] = None,
         checkout_type: Optional[str] = None,
         is_squash_merge: Optional[str] = None,
+        rewrite_command: Optional[str] = None,
 ) -> argparse.Namespace:
     return argparse.Namespace(
         color=color,
@@ -92,6 +93,7 @@ def _ns(
         all_files=all_files,
         checkout_type=checkout_type,
         is_squash_merge=is_squash_merge,
+        rewrite_command=rewrite_command,
         files=(),
         hook=None,
         verbose=False,
@@ -166,6 +168,7 @@ _EXPECTED_ARG_LENGTH_BY_HOOK = {
     'pre-commit': 0,
     'pre-merge-commit': 0,
     'post-merge': 1,
+    'post-rewrite': 1,
     'pre-push': 2,
 }
 
@@ -209,6 +212,8 @@ def _run_ns(
         )
     elif hook_type == 'post-merge':
         return _ns(hook_type, color, is_squash_merge=args[0])
+    elif hook_type == 'post-rewrite':
+        return _ns(hook_type, color, rewrite_command=args[0])
     else:
         raise AssertionError(f'unexpected hook type: {hook_type}')
 
diff --git a/pre_commit/commands/run.py b/pre_commit/commands/run.py
index d906d5b..95ad5e9 100644
--- a/pre_commit/commands/run.py
+++ b/pre_commit/commands/run.py
@@ -245,7 +245,9 @@ def _compute_cols(hooks: Sequence[Hook]) -> int:
 
 def _all_filenames(args: argparse.Namespace) -> Collection[str]:
     # these hooks do not operate on files
-    if args.hook_stage in {'post-checkout', 'post-commit', 'post-merge'}:
+    if args.hook_stage in {
+        'post-checkout', 'post-commit', 'post-merge', 'post-rewrite',
+    }:
         return ()
     elif args.hook_stage in {'prepare-commit-msg', 'commit-msg'}:
         return (args.commit_msg_filename,)
@@ -386,6 +388,9 @@ def run(
     if args.is_squash_merge:
         environ['PRE_COMMIT_IS_SQUASH_MERGE'] = args.is_squash_merge
 
+    if args.rewrite_command:
+        environ['PRE_COMMIT_REWRITE_COMMAND'] = args.rewrite_command
+
     # Set pre_commit flag
     environ['PRE_COMMIT'] = '1'
 
diff --git a/pre_commit/constants.py b/pre_commit/constants.py
index 3dcbbac..1a69c90 100644
--- a/pre_commit/constants.py
+++ b/pre_commit/constants.py
@@ -19,6 +19,7 @@ VERSION = importlib_metadata.version('pre_commit')
 STAGES = (
     'commit', 'merge-commit', 'prepare-commit-msg', 'commit-msg',
     'post-commit', 'manual', 'post-checkout', 'push', 'post-merge',
+    'post-rewrite',
 )
 
 DEFAULT = 'default'
diff --git a/pre_commit/main.py b/pre_commit/main.py
index ad3d873..2b50c91 100644
--- a/pre_commit/main.py
+++ b/pre_commit/main.py
@@ -69,6 +69,7 @@ def _add_hook_type_option(parser: argparse.ArgumentParser) -> None:
         '-t', '--hook-type', choices=(
             'pre-commit', 'pre-merge-commit', 'pre-push', 'prepare-commit-msg',
             'commit-msg', 'post-commit', 'post-checkout', 'post-merge',
+            'post-rewrite',
         ),
         action=AppendReplaceDefault,
         default=['pre-commit'],
@@ -146,6 +147,13 @@ def _add_run_options(parser: argparse.ArgumentParser) -> None:
             'squash merge'
         ),
     )
+    parser.add_argument(
+        '--rewrite-command',
+        help=(
+            'During a post-rewrite hook, specifies the command that invoked '
+            'the rewrite'
+        ),
+    )
 
 
 def _adjust_args_and_chdir(args: argparse.Namespace) -> None:


Test patch:
diff --git a/testing/util.py b/testing/util.py
index 12f22b5..791a2b9 100644
--- a/testing/util.py
+++ b/testing/util.py
@@ -72,6 +72,7 @@ def run_opts(
         commit_msg_filename='',
         checkout_type='',
         is_squash_merge='',
+        rewrite_command='',
 ):
     # These are mutually exclusive
     assert not (all_files and files)
@@ -92,6 +93,7 @@ def run_opts(
         commit_msg_filename=commit_msg_filename,
         checkout_type=checkout_type,
         is_squash_merge=is_squash_merge,
+        rewrite_command=rewrite_command,
     )
 
 
diff --git a/tests/commands/hook_impl_test.py b/tests/commands/hook_impl_test.py
index c38b9ca..37b78bc 100644
--- a/tests/commands/hook_impl_test.py
+++ b/tests/commands/hook_impl_test.py
@@ -99,6 +99,7 @@ def test_run_legacy_recursive(tmpdir):
         ('post-commit', []),
         ('post-merge', ['1']),
         ('post-checkout', ['old_head', 'new_head', '1']),
+        ('post-rewrite', ['amend']),
         # multiple choices for commit-editmsg
         ('prepare-commit-msg', ['.git/COMMIT_EDITMSG']),
         ('prepare-commit-msg', ['.git/COMMIT_EDITMSG', 'message']),
@@ -166,6 +167,14 @@ def test_run_ns_post_merge():
     assert ns.is_squash_merge == '1'
 
 
+def test_run_ns_post_rewrite():
+    ns = hook_impl._run_ns('post-rewrite', True, ('amend',), b'')
+    assert ns is not None
+    assert ns.hook_stage == 'post-rewrite'
+    assert ns.color is True
+    assert ns.rewrite_command == 'amend'
+
+
 def test_run_ns_post_checkout():
     ns = hook_impl._run_ns('post-checkout', True, ('a', 'b', 'c'), b'')
     assert ns is not None
diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py
index 7076f82..3c07124 100644
--- a/tests/commands/install_uninstall_test.py
+++ b/tests/commands/install_uninstall_test.py
@@ -817,6 +817,35 @@ def test_post_merge_integration(tempdir_factory, store):
         assert os.path.exists('post-merge.tmp')
 
 
+def test_post_rewrite_integration(tempdir_factory, store):
+    path = git_dir(tempdir_factory)
+    config = [
+        {
+            'repo': 'local',
+            'hooks': [{
+                'id': 'post-rewrite',
+                'name': 'Post rewrite',
+                'entry': 'touch post-rewrite.tmp',
+                'language': 'system',
+                'always_run': True,
+                'verbose': True,
+                'stages': ['post-rewrite'],
+            }],
+        },
+    ]
+    write_config(path, config)
+    with cwd(path):
+        open('init', 'a').close()
+        cmd_output('git', 'add', '.')
+        install(C.CONFIG_FILE, store, hook_types=['post-rewrite'])
+        git_commit()
+
+        assert not os.path.exists('post-rewrite.tmp')
+
+        git_commit('--amend', '-m', 'ammended message')
+        assert os.path.exists('post-rewrite.tmp')
+
+
 def test_post_checkout_integration(tempdir_factory, store):
     path = git_dir(tempdir_factory)
     config = [
diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py
index da7569e..8c15395 100644
--- a/tests/commands/run_test.py
+++ b/tests/commands/run_test.py
@@ -504,6 +504,15 @@ def test_is_squash_merge(cap_out, store, repo_with_passing_hook):
     assert environ['PRE_COMMIT_IS_SQUASH_MERGE'] == '1'
 
 
+def test_rewrite_command(cap_out, store, repo_with_passing_hook):
+    args = run_opts(rewrite_command='amend')
+    environ: MutableMapping[str, str] = {}
+    ret, printed = _do_run(
+        cap_out, store, repo_with_passing_hook, args, environ,
+    )
+    assert environ['PRE_COMMIT_REWRITE_COMMAND'] == 'amend'
+
+
 def test_checkout_type(cap_out, store, repo_with_passing_hook):
     args = run_opts(from_ref='', to_ref='', checkout_type='1')
     environ: MutableMapping[str, str] = {}
diff --git a/tests/repository_test.py b/tests/repository_test.py
index e372519..4121fed 100644
--- a/tests/repository_test.py
+++ b/tests/repository_test.py
@@ -1002,6 +1002,7 @@ def test_manifest_hooks(tempdir_factory, store):
         stages=(
             'commit', 'merge-commit', 'prepare-commit-msg', 'commit-msg',
             'post-commit', 'manual', 'post-checkout', 'push', 'post-merge',
+            'post-rewrite',
         ),
         types=['file'],
         types_or=[],