Repository: pre-commit/pre-commit

Issue:
Bug: base manifest value for 'exclude' is always ignored
I stumbled upon this bug while working on #226: the culprit is [`Repository.hooks`](https://github.com/pre-commit/pre-commit/blob/master/pre_commit/repository.py#L48).

A quick fix for this would be to simply remove the default value from `pre_commit/clientlib/validate_config.py`, but the root cause is that any default value defined for a field in this file will make the corresponding manifest field useless.

Basically here is what happens in `Repository.hooks`:
- all the hooks defined in the current repository are enumerated
- at this stage, a `hook` is a dict closely matching the Yaml the config file content, **plus** default values for fields not defined in the Yaml but having a JSON schema 'default'
- when doing the dict merge, **every** (key,value) pair in `hook` overrides the corresponding manifest entry. This includes default config value like `exclude: '$^'` overriding a base manifest value like `exclude: '.bak$'`

Hence I suggest either adding a test ensuring there will never be any 'default' defined in `CONFIG_JSON_SCHEMA`, or improving the merge logic.



Discussion:
Ah the fix in #185 makes more sense now! I just didn't connect 2 and 2 together.


Resolving patch:
diff --git a/pre_commit/clientlib/validate_config.py b/pre_commit/clientlib/validate_config.py
--- a/pre_commit/clientlib/validate_config.py
+++ b/pre_commit/clientlib/validate_config.py
@@ -33,7 +33,7 @@ class InvalidConfigError(FatalError):
                     'properties': {
                         'id': {'type': 'string'},
                         'files': {'type': 'string'},
-                        'exclude': {'type': 'string', 'default': '^$'},
+                        'exclude': {'type': 'string'},
                         'language_version': {'type': 'string'},
                         'args': {
                             'type': 'array',
@@ -71,7 +71,7 @@ def validate_config_extra(config):
             )
         for hook in repo['hooks']:
             try_regex(repo, hook['id'], hook.get('files', ''), 'files')
-            try_regex(repo, hook['id'], hook['exclude'], 'exclude')
+            try_regex(repo, hook['id'], hook.get('exclude', ''), 'exclude')
 
 
 load_config = get_validator(
diff --git a/pre_commit/clientlib/validate_manifest.py b/pre_commit/clientlib/validate_manifest.py
--- a/pre_commit/clientlib/validate_manifest.py
+++ b/pre_commit/clientlib/validate_manifest.py
@@ -20,6 +20,7 @@ class InvalidManifestError(ValueError):
             'name': {'type': 'string'},
             'description': {'type': 'string', 'default': ''},
             'entry': {'type': 'string'},
+            'exclude': {'type': 'string', 'default': '^$'},
             'language': {'type': 'string'},
             'language_version': {'type': 'string', 'default': 'default'},
             'files': {'type': 'string'},
@@ -52,8 +53,14 @@ def validate_files(hook_config):
     if not is_regex_valid(hook_config['files']):
         raise InvalidManifestError(
             'Invalid files regex at {0}: {1}'.format(
-                hook_config['id'],
-                hook_config['files'],
+                hook_config['id'], hook_config['files'],
+            )
+        )
+
+    if not is_regex_valid(hook_config.get('exclude', '')):
+        raise InvalidManifestError(
+            'Invalid exclude regex at {0}: {1}'.format(
+                hook_config['id'], hook_config['exclude'],
             )
         )
 


Test patch:
diff --git a/tests/clientlib/validate_config_test.py b/tests/clientlib/validate_config_test.py
--- a/tests/clientlib/validate_config_test.py
+++ b/tests/clientlib/validate_config_test.py
@@ -174,3 +174,23 @@ def test_config_with_local_hooks_definition_passes(config_obj):
     jsonschema.validate(config_obj, CONFIG_JSON_SCHEMA)
     config = apply_defaults(config_obj, CONFIG_JSON_SCHEMA)
     validate_config_extra(config)
+
+
+def test_does_not_contain_defaults():
+    """Due to the way our merging works, if this schema has any defaults they
+    will clobber potentially useful values in the backing manifest. #227
+    """
+    to_process = [(CONFIG_JSON_SCHEMA, ())]
+    while to_process:
+        schema, route = to_process.pop()
+        # Check this value
+        if isinstance(schema, dict):
+            if 'default' in schema:
+                raise AssertionError(
+                    'Unexpected default in schema at {0}'.format(
+                        ' => '.join(route),
+                    )
+                )
+
+            for key, value in schema.items():
+                to_process.append((value, route + (key,)))
diff --git a/tests/clientlib/validate_manifest_test.py b/tests/clientlib/validate_manifest_test.py
--- a/tests/clientlib/validate_manifest_test.py
+++ b/tests/clientlib/validate_manifest_test.py
@@ -46,6 +46,9 @@ def test_additional_manifest_check_passing(obj):
         [{'id': 'a', 'language': 'not a language', 'files': ''}],
         [{'id': 'a', 'language': 'python3', 'files': ''}],
         [{'id': 'a', 'language': 'python', 'files': 'invalid regex('}],
+        [{'id': 'a', 'language': 'not a language', 'files': ''}],
+        [{'id': 'a', 'language': 'python3', 'files': ''}],
+        [{'id': 'a', 'language': 'python', 'files': '', 'exclude': '('}],
     ),
 )
 def test_additional_manifest_failing(obj):
diff --git a/tests/manifest_test.py b/tests/manifest_test.py
--- a/tests/manifest_test.py
+++ b/tests/manifest_test.py
@@ -22,6 +22,7 @@ def test_manifest_contents(manifest):
         'args': [],
         'description': '',
         'entry': 'bin/hook.sh',
+        'exclude': '^$',
         'expected_return_value': 0,
         'files': '',
         'id': 'bash_hook',
@@ -36,6 +37,7 @@ def test_hooks(manifest):
         'args': [],
         'description': '',
         'entry': 'bin/hook.sh',
+        'exclude': '^$',
         'expected_return_value': 0,
         'files': '',
         'id': 'bash_hook',