Repository: pre-commit/pre-commit

Issue:
Add way to temporarily/permanently disable hooks.
[overcommit](https://github.com/causes/overcommit) uses environment variables to do temporary skipping...

For instance:

`SKIP=foo git commit` will skip the `foo` hook

Whereas I've used a more-permanent switching with `git config hooks.foo false` in the past.

Considering both approaches, I think overcommit does this quite elegantly while focusing on only _temporarily_ disabling hooks.



Discussion:

Resolving patch:
diff --git a/pre_commit/commands.py b/pre_commit/commands.py
--- a/pre_commit/commands.py
+++ b/pre_commit/commands.py
@@ -144,7 +144,42 @@ def clean(runner):
     return 0
 
 
-def _run_single_hook(runner, repository, hook_id, args, write):
+def _get_skips(environ):
+    skips = environ.get('SKIP', '')
+    return set(skip.strip() for skip in skips.split(',') if skip.strip())
+
+
+def _print_no_files_skipped(hook, write, args):
+    no_files_msg = '(no files to check) '
+    skipped_msg = 'Skipped'
+    write(
+        '{0}{1}{2}{3}\n'.format(
+            hook['name'],
+            '.' * (
+                COLS -
+                len(hook['name']) -
+                len(no_files_msg) -
+                len(skipped_msg) -
+                6
+            ),
+            no_files_msg,
+            color.format_color(skipped_msg, color.TURQUOISE, args.color),
+        )
+    )
+
+
+def _print_user_skipped(hook, write, args):
+    skipped_msg = 'Skipped'
+    write(
+        '{0}{1}{2}\n'.format(
+            hook['name'],
+            '.' * (COLS - len(hook['name']) - len(skipped_msg) - 6),
+            color.format_color(skipped_msg, color.YELLOW, args.color),
+        ),
+    )
+
+
+def _run_single_hook(runner, repository, hook_id, args, write, skips=set()):
     if args.all_files:
         get_filenames = git.get_all_files_matching
     elif git.is_in_merge_conflict():
@@ -155,23 +190,11 @@ def _run_single_hook(runner, repository, hook_id, args, write):
     hook = repository.hooks[hook_id]
 
     filenames = get_filenames(hook['files'], hook['exclude'])
-    if not filenames:
-        no_files_msg = '(no files to check) '
-        skipped_msg = 'Skipped'
-        write(
-            '{0}{1}{2}{3}\n'.format(
-                hook['name'],
-                '.' * (
-                    COLS -
-                    len(hook['name']) -
-                    len(no_files_msg) -
-                    len(skipped_msg) -
-                    6
-                ),
-                no_files_msg,
-                color.format_color(skipped_msg, color.TURQUOISE, args.color),
-            )
-        )
+    if hook_id in skips:
+        _print_user_skipped(hook, write, args)
+        return 0
+    elif not filenames:
+        _print_no_files_skipped(hook, write, args)
         return 0
 
     # Print the hook and the dots first in case the hook takes hella long to
@@ -211,18 +234,23 @@ def _run_single_hook(runner, repository, hook_id, args, write):
     return retcode
 
 
-def _run_hooks(runner, args, write):
+def _run_hooks(runner, args, write, environ):
     """Actually run the hooks."""
     retval = 0
 
+    skips = _get_skips(environ)
+
     for repo in runner.repositories:
         for hook_id in repo.hooks:
-            retval |= _run_single_hook(runner, repo, hook_id, args, write=write)
+            retval |= _run_single_hook(
+                runner, repo, hook_id, args, write, skips=skips,
+            )
 
     return retval
 
 
-def _run_hook(runner, hook_id, args, write):
+def _run_hook(runner, args, write):
+    hook_id = args.hook
     for repo in runner.repositories:
         if hook_id in repo.hooks:
             return _run_single_hook(runner, repo, hook_id, args, write=write)
@@ -236,7 +264,7 @@ def _has_unmerged_paths(runner):
     return bool(stdout.strip())
 
 
-def run(runner, args, write=sys.stdout.write):
+def run(runner, args, write=sys.stdout.write, environ=os.environ):
     # Set up our logging handler
     logger.addHandler(LoggingHandler(args.color, write=write))
     logger.setLevel(logging.INFO)
@@ -253,6 +281,6 @@ def run(runner, args, write=sys.stdout.write):
 
     with ctx:
         if args.hook:
-            return _run_hook(runner, args.hook, args, write=write)
+            return _run_hook(runner, args, write=write)
         else:
-            return _run_hooks(runner, args, write=write)
+            return _run_hooks(runner, args, write=write, environ=environ)


Test patch:
diff --git a/tests/commands_test.py b/tests/commands_test.py
--- a/tests/commands_test.py
+++ b/tests/commands_test.py
@@ -201,10 +201,10 @@ def _get_opts(all_files=False, color=False, verbose=False, hook=None, no_stash=F
     )
 
 
-def _do_run(repo, args):
+def _do_run(repo, args, environ={}):
     runner = Runner(repo)
     write_mock = mock.Mock()
-    ret = commands.run(runner, args, write=write_mock)
+    ret = commands.run(runner, args, write=write_mock, environ=environ)
     printed = get_write_mock_output(write_mock)
     return ret, printed
 
@@ -298,3 +298,35 @@ def test_merge_conflict_modified(in_merge_conflict):
     ret, printed = _do_run(in_merge_conflict, _get_opts())
     assert ret == 1
     assert 'Unmerged files.  Resolve before committing.' in printed
+
+
+def test_merge_conflict_resolved(in_merge_conflict):
+    local['git']['add', '.']()
+    ret, printed = _do_run(in_merge_conflict, _get_opts())
+    for msg in ('Checking merge-conflict files only.', 'Bash hook', 'Passed'):
+        assert msg in printed
+
+
+@pytest.mark.parametrize(
+    ('environ', 'expected_output'),
+    (
+        ({}, set([])),
+        ({'SKIP': ''}, set([])),
+        ({'SKIP': ','}, set([])),
+        ({'SKIP': ',foo'}, set(['foo'])),
+        ({'SKIP': 'foo'}, set(['foo'])),
+        ({'SKIP': 'foo,bar'}, set(['foo', 'bar'])),
+        ({'SKIP': ' foo , bar'}, set(['foo', 'bar'])),
+    ),
+)
+def test_get_skips(environ, expected_output):
+    ret = commands._get_skips(environ)
+    assert ret == expected_output
+
+
+def test_skip_hook(repo_with_passing_hook):
+    ret, printed = _do_run(
+        repo_with_passing_hook, _get_opts(), {'SKIP': 'bash_hook'},
+    )
+    for msg in ('Bash hook', 'Skipped'):
+        assert msg in printed