Repository: asottile/git-code-debt

Issue:
Add `exclude` pattern
Add the ability to remove checked in files from the pattern which are not part of the codebase.  For example:

```yaml
exclude: '^vendor/'
```

Discussion:

Patch:
diff --git a/git_code_debt/generate.py b/git_code_debt/generate.py
index 90d72da..283b26c 100644
--- a/git_code_debt/generate.py
+++ b/git_code_debt/generate.py
@@ -29,7 +29,7 @@ from git_code_debt.write_logic import insert_metric_values
 from git_code_debt.write_logic import update_has_data
 
 
-def get_metrics(commit, diff, metric_parsers):
+def get_metrics(commit, diff, metric_parsers, exclude):
     def get_all_metrics(file_diff_stats):
         for metric_parser_cls in metric_parsers:
             metric_parser = metric_parser_cls()
@@ -39,6 +39,10 @@ def get_metrics(commit, diff, metric_parsers):
                 yield metric
 
     file_diff_stats = get_file_diff_stats_from_output(diff)
+    file_diff_stats = tuple(
+        x for x in file_diff_stats
+        if not exclude.search(x.path)
+    )
     return tuple(get_all_metrics(file_diff_stats))
 
 
@@ -47,13 +51,13 @@ def increment_metric_values(metric_values, metrics):
         metric_values[metric.name] += metric.value
 
 
-def _get_metrics_inner(m_args):
-    compare_commit, commit, repo_parser, metric_parsers = m_args
+def _get_metrics_inner(mp_args):
+    compare_commit, commit, repo_parser, metric_parsers, exclude = mp_args
     if compare_commit is None:
         diff = repo_parser.get_original_commit(commit.sha)
     else:
         diff = repo_parser.get_commit_diff(compare_commit.sha, commit.sha)
-    return get_metrics(commit, diff, metric_parsers)
+    return get_metrics(commit, diff, metric_parsers, exclude)
 
 
 def mapper(jobs):
@@ -68,6 +72,7 @@ def load_data(
         repo,
         package_names,
         skip_defaults,
+        exclude,
         jobs,
 ):
     metric_parsers = get_metric_parsers_from_args(package_names, skip_defaults)
@@ -102,6 +107,7 @@ def load_data(
                 commits,
                 itertools.repeat(repo_parser),
                 itertools.repeat(metric_parsers),
+                itertools.repeat(exclude),
             )
             do_map = mapper(jobs)
             for commit, metrics in six.moves.zip(
@@ -176,6 +182,7 @@ def main(argv=None):
         args.repo,
         args.metric_package_names,
         args.skip_default_metrics,
+        args.exclude,
         parsed_args.jobs,
     )
 
diff --git a/git_code_debt/generate_config.py b/git_code_debt/generate_config.py
index ad9fce6..302a8ca 100644
--- a/git_code_debt/generate_config.py
+++ b/git_code_debt/generate_config.py
@@ -2,6 +2,7 @@ from __future__ import absolute_import
 from __future__ import unicode_literals
 
 import collections
+import re
 
 import jsonschema
 
@@ -17,6 +18,7 @@ GENERATE_OPTIONS_SCHEMA = {
         'metric_package_names': {'type': 'array', 'items': {'type': 'string'}},
         'repo': {'type': 'string'},
         'database': {'type': 'string'},
+        'exclude': {'type': 'string'},
     },
 }
 
@@ -28,6 +30,7 @@ class GenerateOptions(collections.namedtuple(
             'metric_package_names',
             'repo',
             'database',
+            'exclude',
         ),
 )):
     @classmethod
@@ -38,4 +41,5 @@ class GenerateOptions(collections.namedtuple(
             metric_package_names=yaml_dict.get('metric_package_names', []),
             repo=yaml_dict['repo'],
             database=yaml_dict['database'],
+            exclude=re.compile(yaml_dict.get('exclude', '^$').encode()),
         )
diff --git a/git_code_debt/server/servlets/widget.py b/git_code_debt/server/servlets/widget.py
index a023861..6264a09 100644
--- a/git_code_debt/server/servlets/widget.py
+++ b/git_code_debt/server/servlets/widget.py
@@ -35,7 +35,7 @@ def data():
     parsers = get_metric_parsers_from_args(
         metric_config.metric_package_names, skip_defaults=False,
     )
-    metrics = get_metrics(Commit.blank, diff, parsers)
+    metrics = get_metrics(Commit.blank, diff, parsers, metric_config.exclude)
     metrics = [
         metric for metric in metrics
         if metric.value and metric.name in metric_names


Test patch:
diff --git a/tests/generate_config_test.py b/tests/generate_config_test.py
index 889b074..3449297 100644
--- a/tests/generate_config_test.py
+++ b/tests/generate_config_test.py
@@ -1,6 +1,8 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
+import re
+
 import jsonschema.exceptions
 import pytest
 
@@ -18,12 +20,14 @@ def test_with_all_options_specified():
         'metric_package_names': ['my_package'],
         'repo': '.',
         'database': 'database.db',
+        'exclude': '^vendor/',
     })
     assert ret == GenerateOptions(
         skip_default_metrics=True,
         metric_package_names=['my_package'],
         repo='.',
         database='database.db',
+        exclude=re.compile(b'^vendor/'),
     )
 
 
@@ -34,17 +38,5 @@ def test_minimal_defaults():
         metric_package_names=[],
         repo='./',
         database='database.db',
-    )
-
-
-def test_none_for_tempdir_allowed():
-    ret = GenerateOptions.from_yaml({
-        'repo': 'repo',
-        'database': 'database.db',
-    })
-    assert ret == GenerateOptions(
-        skip_default_metrics=False,
-        metric_package_names=[],
-        repo='repo',
-        database='database.db',
+        exclude=re.compile(b'^$'),
     )
diff --git a/tests/generate_test.py b/tests/generate_test.py
index 2f05015..62eb1c6 100644
--- a/tests/generate_test.py
+++ b/tests/generate_test.py
@@ -3,12 +3,11 @@ from __future__ import unicode_literals
 
 import collections
 import io
-import os
 import os.path
+import re
 import sqlite3
 
 import pytest
-import yaml
 
 from git_code_debt.discovery import get_metric_parsers_from_args
 from git_code_debt.generate import _get_metrics_inner
@@ -43,7 +42,7 @@ def test_get_metrics_inner_first_commit(cloneable_with_commits):
     with repo_parser.repo_checked_out():
         metrics = _get_metrics_inner((
             None, cloneable_with_commits.commits[0],
-            repo_parser, [LinesOfCodeParser],
+            repo_parser, [LinesOfCodeParser], re.compile(b'^$'),
         ))
         assert Metric(name='TotalLinesOfCode', value=0) in metrics
 
@@ -54,7 +53,7 @@ def test_get_metrics_inner_nth_commit(cloneable_with_commits):
         metrics = _get_metrics_inner((
             cloneable_with_commits.commits[-2],
             cloneable_with_commits.commits[-1],
-            repo_parser, [LinesOfCodeParser],
+            repo_parser, [LinesOfCodeParser], re.compile(b'^$'),
         ))
         assert Metric(name='TotalLinesOfCode', value=2) in metrics
 
@@ -73,18 +72,6 @@ def test_generate_integration(sandbox, cloneable):
     main(('-C', sandbox.gen_config(repo=cloneable)))
 
 
-def test_generate_integration_config_file(sandbox, cloneable, tempdir_factory):
-    tmpdir = tempdir_factory.get()
-    config_filename = os.path.join(tmpdir, 'generate_config.yaml')
-    with io.open(config_filename, 'w') as config_file:
-        yaml.dump(
-            {'repo': cloneable, 'database': sandbox.db_path},
-            stream=config_file,
-        )
-    with cwd(tmpdir):
-        main([])
-
-
 def test_main_database_does_not_exist(sandbox, cloneable):
     new_db_path = os.path.join(sandbox.directory, 'new.db')
     cfg = sandbox.gen_config(database=new_db_path, repo=cloneable)
@@ -157,6 +144,25 @@ def test_moves_handled_properly(sandbox, cloneable):
     assert not main(('-C', sandbox.gen_config(repo=cloneable)))
 
 
+def test_exclude_pattern(sandbox, cloneable_with_commits):
+    cfg = sandbox.gen_config(
+        repo=cloneable_with_commits.path, exclude='\.tmpl$',
+    )
+    assert not main(('-C', cfg))
+    with sandbox.db() as db:
+        query = (
+            'SELECT running_value\n'
+            'FROM metric_data\n'
+            'INNER JOIN metric_names ON\n'
+            '    metric_data.metric_id == metric_names.id\n'
+            'WHERE sha = ? AND name = "TotalLinesOfCode"\n'
+        )
+        sha = cloneable_with_commits.commits[-1].sha
+        val, = db.execute(query, (sha,)).fetchone()
+        # 2 lines of code from test.py, 0 lines from foo.tmpl (2 lines)
+        assert val == 2
+
+
 def test_get_options_from_config_no_config_file():
     with pytest.raises(SystemExit):
         get_options_from_config('i-dont-exist')