diff --git a/pre_commit/clientlib.py b/pre_commit/clientlib.py index da6ca2be..76f83fa9 100644 --- a/pre_commit/clientlib.py +++ b/pre_commit/clientlib.py @@ -80,6 +80,7 @@ MANIFEST_HOOK_DICT = cfgv.Map( cfgv.Optional('minimum_pre_commit_version', cfgv.check_string, '0'), cfgv.Optional('require_serial', cfgv.check_bool, False), cfgv.Optional('stages', cfgv.check_array(cfgv.check_one_of(C.STAGES)), []), + cfgv.Optional('tags', cfgv.check_array(cfgv.check_string), []), cfgv.Optional('verbose', cfgv.check_bool, False), ) MANIFEST_SCHEMA = cfgv.Array(MANIFEST_HOOK_DICT) diff --git a/pre_commit/commands/run.py b/pre_commit/commands/run.py index 429e04c6..a768cd4a 100644 --- a/pre_commit/commands/run.py +++ b/pre_commit/commands/run.py @@ -150,7 +150,7 @@ def _run_single_hook( ) -> tuple[bool, bytes]: filenames = classifier.filenames_for_hook(hook) - if hook.id in skips or hook.alias in skips: + if _hook_is_skipped(skips, hook): output.write( _full_msg( start=hook.name, @@ -323,6 +323,25 @@ def _has_unstaged_config(config_file: str) -> bool: # be explicit, other git errors don't mean it has an unstaged config. return retcode == 1 +def _hook_should_run(args: argparse.Namespace, hook: Hook) -> bool: + if args.hook_stage not in hook.stages: + return False + + if args.tags: + return len(set(hook.tags) & set(args.tags)) > 0 + + return ( + not args.hook + or hook.id == args.hook + or hook.alias == args.hook + ) + +def _hook_is_skipped(skips: Sequence[str], hook: Hook) -> bool: + return ( + hook.id in skips + or hook.alias in skips + or len(set(hook.tags) & set(skips)) > 0 + ) def run( config_file: str, @@ -409,8 +428,7 @@ def run( hooks = [ hook for hook in all_hooks(config, store) - if not args.hook or hook.id == args.hook or hook.alias == args.hook - if args.hook_stage in hook.stages + if _hook_should_run(args, hook) ] if args.hook and not hooks: @@ -418,12 +436,17 @@ def run( f'No hook with id `{args.hook}` in stage `{args.hook_stage}`', ) return 1 + if args.tags and not hooks: + output.write_line( + f'No hooks with tags matching `{args.tags}` in stage `{args.hook_stage}`' + ) + return 1 skips = _get_skips(environ) to_install = [ hook for hook in hooks - if hook.id not in skips and hook.alias not in skips + if not _hook_is_skipped(skips, hook) ] install_hook_envs(to_install, store) diff --git a/pre_commit/hook.py b/pre_commit/hook.py index 202abb35..512b9702 100644 --- a/pre_commit/hook.py +++ b/pre_commit/hook.py @@ -35,6 +35,7 @@ class Hook(NamedTuple): minimum_pre_commit_version: str require_serial: bool stages: Sequence[str] + tags: Sequence[str] verbose: bool @property diff --git a/pre_commit/main.py b/pre_commit/main.py index 3915993f..2e11c6b6 100644 --- a/pre_commit/main.py +++ b/pre_commit/main.py @@ -57,7 +57,9 @@ def _add_hook_type_option(parser: argparse.ArgumentParser) -> None: def _add_run_options(parser: argparse.ArgumentParser) -> None: - parser.add_argument('hook', nargs='?', help='A single hook-id to run') + hooks_mutex_group = parser.add_mutually_exclusive_group(required=False) + hooks_mutex_group.add_argument('hook', nargs='?', help='A single hook-id to run') + hooks_mutex_group.add_argument('--tags', nargs='+', default=[], help='Tag groups to run') parser.add_argument('--verbose', '-v', action='store_true', default=False) mutex_group = parser.add_mutually_exclusive_group(required=False) mutex_group.add_argument( diff --git a/setup.cfg b/setup.cfg index ab95cc04..3ffa01f4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pre_commit -version = 2.20.0 +version = 2.21.0 description = A framework for managing and maintaining multi-language pre-commit hooks. long_description = file: README.md long_description_content_type = text/markdown diff --git a/testing/resources/script_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/script_hooks_repo/.pre-commit-hooks.yaml index 21cad4a3..ec1f3fb6 100644 --- a/testing/resources/script_hooks_repo/.pre-commit-hooks.yaml +++ b/testing/resources/script_hooks_repo/.pre-commit-hooks.yaml @@ -3,3 +3,5 @@ entry: bin/hook.sh language: script files: '' + tags: + - foo diff --git a/testing/util.py b/testing/util.py index e807f048..2d8c59f5 100644 --- a/testing/util.py +++ b/testing/util.py @@ -67,6 +67,7 @@ def run_opts( color=False, verbose=False, hook=None, + tags=(), remote_branch='', local_branch='', from_ref='', @@ -84,12 +85,14 @@ def run_opts( ): # These are mutually exclusive assert not (all_files and files) + assert not (hook and tags) return auto_namedtuple( all_files=all_files, files=files, color=color, verbose=verbose, hook=hook, + tags=tags, remote_branch=remote_branch, local_branch=local_branch, from_ref=from_ref, diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py index 03d741e0..37fbef95 100644 --- a/tests/commands/run_test.py +++ b/tests/commands/run_test.py @@ -144,12 +144,14 @@ def _do_run(cap_out, store, repo, args, environ={}, config_file=C.CONFIG_FILE): def _test_run( cap_out, store, repo, opts, expected_outputs, expected_ret, stage, - config_file=C.CONFIG_FILE, + config_file=C.CONFIG_FILE, environ_override=None ): if stage: stage_a_file() + if environ_override is None: + environ_override = {} args = run_opts(**opts) - ret, printed = _do_run(cap_out, store, repo, args, config_file=config_file) + ret, printed = _do_run(cap_out, store, repo, args, config_file=config_file, environ=environ_override) assert ret == expected_ret, (ret, expected_ret, printed) for expected_output_part in expected_outputs: @@ -352,6 +354,7 @@ def test_show_diff_on_failure( ({}, (b'Bash hook', b'Passed'), 0, True), ({'verbose': True}, (b'foo.py\nHello World',), 0, True), ({'hook': 'bash_hook'}, (b'Bash hook', b'Passed'), 0, True), + ({'tags': ['foo']}, (b'Bash hook', b'Passed'), 0, True), ( {'hook': 'nope'}, (b'No hook with id `nope` in stage `commit`',), @@ -364,6 +367,18 @@ def test_show_diff_on_failure( 1, True, ), + ( + {'tags': ['bar', 'baz']}, + (b'No hooks with tags matching `[\'bar\', \'baz\']` in stage `commit`',), + 1, + True, + ), + ( + {'tags': ['bar', 'baz'], 'hook_stage': 'push'}, + (b'No hooks with tags matching `[\'bar\', \'baz\']` in stage `push`',), + 1, + True, + ), ( {'all_files': True, 'verbose': True}, (b'foo.py',), @@ -618,6 +633,18 @@ def test_skip_aliased_hook(cap_out, store, aliased_repo): for msg in (b'Bash hook', b'Skipped'): assert printed.count(msg) == 1 +def test_skip_tag(cap_out, store, repo_with_passing_hook): + _test_run( + cap_out, + store, + repo_with_passing_hook, + {}, + (b'Bash hook', b'Skipped'), + 0, + True, + environ_override={'SKIP': 'foo'} + ) + def test_skip_bypasses_installation(cap_out, store, repo_with_passing_hook): config = {