Repository: pre-commit/pre-commit

Issue:
Question: Using hooks at different stages
1. In order to utilize hooks that run in different stages, pre-commit needs to install itself as the hook in each one (e.g. "pre-commit" and "commit-msg"). By default however, pre-commit only installs itself in "pre-commit" and you can't specify multiple types with the -t argument. It would be nice if pre-commit would see that a hook in the config can only be used in a specific stage (e.g. "commit-msg") and install itself there rather than requiring explicit knowledge and direction from the user.
1. Most hooks that don't explicitly limit themselves to a single stage, so if pre-commit is installed as hook multiple times (e.g. "pre-commit" and "commit-msg"), you'll get multiple runs of the unrestricted hooks for each commit. To avoid that, in your config file, you'd be required to specify a single stage for each hook. This can become pretty redundant.

For example:
```
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v1.3.0
  hooks:
  # Git state
  - id: check-merge-conflict
    stages: [commit]
  - id: check-added-large-files
    stages: [commit]
  # Sensitive information
  - id: detect-private-key
    stages: [commit]
  - id: detect-aws-credentials
    stages: [commit]
  # Generic file state
  - id: trailing-whitespace
    stages: [commit]
  - id: mixed-line-ending
    stages: [commit]
  - id: end-of-file-fixer
    stages: [commit]
  - id: check-executables-have-shebangs
    stages: [commit]
  # Language syntax/formatting
  - id: check-yaml
    stages: [commit]
  - id: check-json
    stages: [commit]
  - id: pretty-format-json
    stages: [commit]
- repo: https://github.com/mattlqx/pre-commit-sign
  rev: v1.1.1
  hooks:
  - id: sign-commit
```

Am I understanding the limitations of stages correctly? Can anything be improved from the framework side to make commit and commit-msg stages not duplicate work or be more intelligent about what runs where? Maybe keep track of if a hook ran in the commit stage and avoid running it in the commit-msg stage?


Discussion:
First: thanks for the issue! 🎉 

> In order to utilize hooks that run in different stages, pre-commit needs to install itself as the hook in each one (e.g. "pre-commit" and "commit-msg"). By default however, pre-commit only installs itself in "pre-commit" and you can't specify multiple types with the -t argument. It would be nice if pre-commit would see that a hook in the config can only be used in a specific stage (e.g. "commit-msg") and install itself there rather than requiring explicit knowledge and direction from the user.

The current suggestion is to just run `pre-commit install` more than once.  Some consumers prefer to install just for push or just for commit and I don't want to break that workflow.

> Am I understanding the limitations of stages correctly?

This is correct as written.

> Can anything be improved from the framework side to make commit and commit-msg stages not duplicate work or be more intelligent about what runs where? 

There is a bit of duplication in the configuration right now, true.  I plan at some point to add something like:

```yaml
default_stages: [commit]
````

to the top level so the duplication can be avoided (slightly) (though I'm still not 100% sold on this idea since it's nice and explicit right now).

>  Maybe keep track of if a hook ran in the commit stage and avoid running it in the commit-msg stage?

pre-commit currently stores zero state about runs, I don't really want to get into the business of managing that 😆 


Hope this clarifies some things :)
Thanks for the quick reply.

`default_stages` so you can specify what the default is for hooks that aren't explicit does sound like the easiest path and resolves my concerns. I hope for it in a future release.
yep, it's on my [open source TODO](https://github.com/asottile/scratch/wiki/OS-TODO)

Resolving patch:
diff --git a/pre_commit/clientlib.py b/pre_commit/clientlib.py
--- a/pre_commit/clientlib.py
+++ b/pre_commit/clientlib.py
@@ -224,6 +224,11 @@ def _entry(modname):
     cfgv.OptionalRecurse(
         'default_language_version', DEFAULT_LANGUAGE_VERSION, {},
     ),
+    cfgv.Optional(
+        'default_stages',
+        cfgv.check_array(cfgv.check_one_of(C.STAGES)),
+        C.STAGES,
+    ),
     cfgv.Optional('exclude', cfgv.check_regex, '^$'),
     cfgv.Optional('fail_fast', cfgv.check_bool, False),
 )
diff --git a/pre_commit/commands/run.py b/pre_commit/commands/run.py
--- a/pre_commit/commands/run.py
+++ b/pre_commit/commands/run.py
@@ -252,7 +252,7 @@ def run(config_file, store, args, environ=os.environ):
             hook
             for hook in all_hooks(config, store)
             if not args.hook or hook.id == args.hook or hook.alias == args.hook
-            if not hook.stages or args.hook_stage in hook.stages
+            if args.hook_stage in hook.stages
         ]
 
         if args.hook and not hooks:
diff --git a/pre_commit/repository.py b/pre_commit/repository.py
--- a/pre_commit/repository.py
+++ b/pre_commit/repository.py
@@ -135,6 +135,9 @@ def _hook(*hook_dicts, **kwargs):
     if ret['language_version'] == C.DEFAULT:
         ret['language_version'] = languages[lang].get_default_version()
 
+    if not ret['stages']:
+        ret['stages'] = root_config['default_stages']
+
     return ret
 
 


Test patch:
diff --git a/tests/repository_test.py b/tests/repository_test.py
--- a/tests/repository_test.py
+++ b/tests/repository_test.py
@@ -715,6 +715,7 @@ def test_local_python_repo(store, local_python_config):
 def test_default_language_version(store, local_python_config):
     config = {
         'default_language_version': {'python': 'fake'},
+        'default_stages': ['commit'],
         'repos': [local_python_config],
     }
 
@@ -728,6 +729,23 @@ def test_default_language_version(store, local_python_config):
     assert hook.language_version == 'fake2'
 
 
+def test_default_stages(store, local_python_config):
+    config = {
+        'default_language_version': {'python': C.DEFAULT},
+        'default_stages': ['commit'],
+        'repos': [local_python_config],
+    }
+
+    # `stages` was not set, should default
+    hook, = all_hooks(config, store)
+    assert hook.stages == ['commit']
+
+    # `stages` is set, should not default
+    config['repos'][0]['hooks'][0]['stages'] = ['push']
+    hook, = all_hooks(config, store)
+    assert hook.stages == ['push']
+
+
 def test_hook_id_not_present(tempdir_factory, store, fake_log_handler):
     path = make_repo(tempdir_factory, 'script_hooks_repo')
     config = make_config_from_repo(path)
@@ -786,13 +804,13 @@ def test_manifest_hooks(tempdir_factory, store):
         files='',
         id='bash_hook',
         language='script',
-        language_version=C.DEFAULT,
+        language_version='default',
         log_file='',
         minimum_pre_commit_version='0',
         name='Bash hook',
         pass_filenames=True,
         require_serial=False,
-        stages=[],
+        stages=('commit', 'commit-msg', 'manual', 'push'),
         types=['file'],
         verbose=False,
     )