diff options
author | Joris Roovers <jroovers@cisco.com> | 2018-03-30 17:47:12 +0200 |
---|---|---|
committer | Joris Roovers <jroovers@cisco.com> | 2018-03-31 00:58:17 +0200 |
commit | 7d447959e4220428d929c4aa1310675bb9e3bee1 (patch) | |
tree | c70ac581fcc1cc97696f92caa341ca6da9038ed0 | |
parent | 18a892e2f706bb0c9100bd4448b410cd2dd46f4b (diff) |
New IgnoreByTitle rule
Added a new IgnoreByTitle rule that is based on a new more generic rule
construct called ConfigurationRule.
ConfigurationRules can modify gitlint configuration (and therefore
behavior) on a per commit basis.
Added some tests and docs, more docs and integration tests to follow in
additional commits.
This is part of #54 and #57.
-rw-r--r-- | docs/rules.md | 18 | ||||
-rw-r--r-- | gitlint/cli.py | 10 | ||||
-rw-r--r-- | gitlint/config.py | 3 | ||||
-rw-r--r-- | gitlint/lint.py | 20 | ||||
-rw-r--r-- | gitlint/rules.py | 27 | ||||
-rw-r--r-- | gitlint/tests/expected/debug_configuration_output1 | 3 | ||||
-rw-r--r-- | gitlint/tests/test_cli.py | 35 | ||||
-rw-r--r-- | gitlint/tests/test_configuration_rules.py | 38 | ||||
-rw-r--r-- | gitlint/tests/test_lint.py | 23 | ||||
-rw-r--r-- | qa/expected/debug_output1 | 3 |
10 files changed, 169 insertions, 11 deletions
diff --git a/docs/rules.md b/docs/rules.md index e3895aa..47e9483 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -180,4 +180,20 @@ regex | >= 0.9.0 | ```[^@ ]+@[^@ ]+\.[^@ ]+``` | Rege !!! note - An often recurring use-case is to only allow email addresses from a certain domain. The following regular expression achieves this: ```[^@]+@foo.com```
\ No newline at end of file + An often recurring use-case is to only allow email addresses from a certain domain. The following regular expression achieves this: ```[^@]+@foo.com``` + + + +## I1: ignore-by-title ## + +ID | Name | gitlint version | Description +------|-----------------------------|-----------------|------------------------------------------- +I1 | ignore-by-title | >= 0.10.0 | Ignore a commit based on matching its title. + + +### Options ### + +Name | gitlint version | Default | Description +----------------------|-------------------|------------------------------|---------------------------------- +regex | >= 0.10.0 | None | Regex to match against commit title. On match, the commit will be ignored. +ignore | >= 0.10.0 | all | Comma-seperated list of rule names or ids to ignore when this rule is matched. diff --git a/gitlint/cli.py b/gitlint/cli.py index 86ea5cd..8f57090 100644 --- a/gitlint/cli.py +++ b/gitlint/cli.py @@ -1,5 +1,6 @@ # pylint: disable=bad-option-value,wrong-import-position # We need to disable the import position checks because of the windows check that we need to do below +import copy import logging import os import platform @@ -185,13 +186,16 @@ def lint(ctx): first_violation = True exit_code = 0 for commit in gitcontext.commits: - # Build a config_builder and linter taking into account the commit specific config (if any) + # Build a config_builder taking into account the commit specific config (if any) config_builder = general_config_builder.clone() config_builder.set_config_from_commit(commit) - lint_config = config_builder.build(lint_config) - linter = GitLinter(lint_config) + + # Create a deepcopy from the original config, so we have a unique config object per commit + # This is important for configuration rules to be able to modifying the config on a per commit basis + commit_config = config_builder.build(copy.deepcopy(lint_config)) # Actually do the linting + linter = GitLinter(commit_config) violations = linter.lint(commit) # exit code equals the total number of violations in all commits exit_code += len(violations) diff --git a/gitlint/config.py b/gitlint/config.py index 56a46f6..5637d06 100644 --- a/gitlint/config.py +++ b/gitlint/config.py @@ -46,7 +46,8 @@ class LintConfig(object): """ # Default tuple of rule classes (tuple because immutable). - default_rule_classes = (rules.TitleMaxLength, + default_rule_classes = (rules.IgnoreByTitle, + rules.TitleMaxLength, rules.TitleTrailingWhitespace, rules.TitleLeadingWhitespace, rules.TitleTrailingPunctuation, diff --git a/gitlint/lint.py b/gitlint/lint.py index 109960a..362d8ae 100644 --- a/gitlint/lint.py +++ b/gitlint/lint.py @@ -15,25 +15,31 @@ class GitLinter(object): self.display = display.Display(config) - def ignore_rule(self, rule): + def should_ignore_rule(self, rule): + """ Determines whether a rule should be ignored based on the general list of commits to ignore """ return rule.id in self.config.ignore or rule.name in self.config.ignore @property + def configuration_rules(self): + return [rule for rule in self.config.rules if + isinstance(rule, gitlint_rules.ConfigurationRule) and not self.should_ignore_rule(rule)] + + @property def title_line_rules(self): return [rule for rule in self.config.rules if isinstance(rule, gitlint_rules.LineRule) and - rule.target == gitlint_rules.CommitMessageTitle and not self.ignore_rule(rule)] + rule.target == gitlint_rules.CommitMessageTitle and not self.should_ignore_rule(rule)] @property def body_line_rules(self): return [rule for rule in self.config.rules if isinstance(rule, gitlint_rules.LineRule) and - rule.target == gitlint_rules.CommitMessageBody and not self.ignore_rule(rule)] + rule.target == gitlint_rules.CommitMessageBody and not self.should_ignore_rule(rule)] @property def commit_rules(self): return [rule for rule in self.config.rules if isinstance(rule, gitlint_rules.CommitRule) and - not self.ignore_rule(rule)] + not self.should_ignore_rule(rule)] @staticmethod def _apply_line_rules(lines, commit, rules, line_nr_start): @@ -61,10 +67,14 @@ class GitLinter(object): return all_violations def lint(self, commit): - """ Lint the last commit in a given git context by applying all title, body and general rules. """ + """ Lint the last commit in a given git context by applying all ignore, title, body and commit rules. """ LOG.debug("Linting commit %s", commit.sha or "[SHA UNKNOWN]") LOG.debug("Commit Object\n" + ustr(commit)) + # Apply config rules + for rule in self.configuration_rules: + rule.apply(self.config, commit) + # Skip linting if this is a special commit type that is configured to be ignored ignore_commit_types = ["merge", "squash", "fixup"] for commit_type in ignore_commit_types: diff --git a/gitlint/rules.py b/gitlint/rules.py index 9ce2351..aa341f5 100644 --- a/gitlint/rules.py +++ b/gitlint/rules.py @@ -1,9 +1,13 @@ import copy +import logging import re from gitlint.options import IntOption, BoolOption, StrOption, ListOption from gitlint.utils import sstr +LOG = logging.getLogger(__name__) +logging.basicConfig() + class Rule(object): """ Class representing gitlint rules. """ @@ -36,6 +40,11 @@ class Rule(object): return self.__str__() # pragma: no cover +class ConfigurationRule(Rule): + """ Class representing rules that can dynamically change the configuration of gitlint during runtime. """ + pass + + class CommitRule(Rule): """ Class representing rules that act on an entire commit at once """ pass @@ -301,3 +310,21 @@ class AuthorValidEmail(CommitRule): if commit.author_email and not email_regex.match(commit.author_email): return [RuleViolation(self.id, "Author email for commit is invalid", commit.author_email)] + + +class IgnoreByTitle(ConfigurationRule): + name = "ignore-by-title" + id = "I1" + options_spec = [StrOption('regex', None, "Regex that matches the titles of commits this rule should apply to"), + StrOption('ignore', "all", "Comman-seperate list of rules to ignore")] + + def apply(self, config, commit): + title_regex = re.compile(self.options['regex'].value, re.UNICODE) + + if title_regex.match(commit.message.title): + config.ignore = self.options['ignore'].value + + message = u"Commit title '{0}' matches the regex '{1}', ignoring rules: {2}" + message = message.format(commit.message.title, self.options['regex'].value, self.options['ignore'].value) + + LOG.debug("Ignoring commit because of rule '%s': %s", self.id, message) diff --git a/gitlint/tests/expected/debug_configuration_output1 b/gitlint/tests/expected/debug_configuration_output1 index 3ff5423..382181d 100644 --- a/gitlint/tests/expected/debug_configuration_output1 +++ b/gitlint/tests/expected/debug_configuration_output1 @@ -10,6 +10,9 @@ verbosity: 1 debug: True target: {target} [RULES] + I1: ignore-by-title + regex=None + ignore=all T1: title-max-length line-length=20 T2: title-trailing-whitespace diff --git a/gitlint/tests/test_cli.py b/gitlint/tests/test_cli.py index 217fa89..f2d548d 100644 --- a/gitlint/tests/test_cli.py +++ b/gitlint/tests/test_cli.py @@ -124,7 +124,7 @@ class CLITests(BaseTestCase): u"commït-title2.\n\ncommït-body2\ngitlint-ignore: T3\n", u"file4.txt\npåth/to/file5.txt\n", u"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 01:00\x00åbc\n" - u"commït-title3\n\ncommït-body3", + u"commït-title3.\n\ncommït-body3", u"file6.txt\npåth/to/file7.txt\n"] with patch('gitlint.display.stderr', new=StringIO()) as stderr: @@ -133,6 +133,39 @@ class CLITests(BaseTestCase): expected = (u"Commit 6f29bf81a8:\n" u'3: B5 Body message is too short (12<20): "commït-body1"\n\n' u"Commit 4da2656b0d:\n" + u'1: T3 Title has trailing punctuation (.): "commït-title3."\n' + u'3: B5 Body message is too short (12<20): "commït-body3"\n') + self.assertEqual(stderr.getvalue(), expected) + self.assertEqual(result.exit_code, 3) + + @patch('gitlint.cli.stdin_has_data', return_value=False) + @patch('gitlint.git.sh') + def test_lint_multiple_commits_configuration_rules(self, sh, _): + """ Test for --commits option where some of the commits have gitlint config in the commit message """ + + # Note that the second commit title has a trailing period that is being ignored by gitlint-ignore: T3 + sh.git.side_effect = ["6f29bf81a8322a04071bb794666e48c443a90360\n" + # git rev-list <SHA> + "25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" + + "4da2656b0dadc76c7ee3fd0243a96cb64007f125\n", + # git log --pretty <FORMAT> <SHA> + u"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 01:00\x00åbc\n" + u"commït-title1\n\ncommït-body1", + u"file1.txt\npåth/to/file2.txt\n", # git diff-tree <SHA> + u"test åuthor2\x00test-email3@föo.com\x002016-12-04 15:28:15 01:00\x00åbc\n" + u"commït-title2.\n\ncommït-body2\n", + u"file4.txt\npåth/to/file5.txt\n", + u"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 01:00\x00åbc\n" + u"commït-title3\n\ncommït-body3", + u"file6.txt\npåth/to/file7.txt\n"] + + with patch('gitlint.display.stderr', new=StringIO()) as stderr: + result = self.cli.invoke(cli.cli, ["--commits", "foo...bar", "-c", "I1.regex=^commït-title2(.*)"]) + # We expect that the second commit has no failures because of it matching against I1.regex + # Because we do test for the 3th commit to return violations, this test also ensures that a unique + # config object is passed to each commit lint call + expected = (u"Commit 6f29bf81a8:\n" + u'3: B5 Body message is too short (12<20): "commït-body1"\n\n' + u"Commit 4da2656b0d:\n" u'3: B5 Body message is too short (12<20): "commït-body3"\n') self.assertEqual(stderr.getvalue(), expected) self.assertEqual(result.exit_code, 2) diff --git a/gitlint/tests/test_configuration_rules.py b/gitlint/tests/test_configuration_rules.py new file mode 100644 index 0000000..e1b6882 --- /dev/null +++ b/gitlint/tests/test_configuration_rules.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +from gitlint.tests.base import BaseTestCase +from gitlint import rules +from gitlint.config import LintConfig + + +class ConfigurationRuleTests(BaseTestCase): + def test_ignore_by_title(self): + commit = self.gitcommit(u"Releäse\n\nThis is the secōnd body line") + + # No regex specified -> Config shouldn't be changed + rule = rules.IgnoreByTitle() + config = LintConfig() + rule.apply(config, commit) + self.assertEqual(config, LintConfig()) + self.assert_logged([]) + + # Matching regex -> expect config to ignore all rules + rule = rules.IgnoreByTitle({"regex": "^Releäse(.*)"}) + expected_config = LintConfig() + expected_config.ignore = "all" + rule.apply(config, commit) + self.assertEqual(config, expected_config) + + expected_log_message = u"DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \ + u"Commit title 'Releäse' matches the regex '^Releäse(.*)', ignoring rules: all" + self.assert_log_contains(expected_log_message) + + # Matching regex with specific ignore + rule = rules.IgnoreByTitle({"regex": "^Releäse(.*)", + "ignore": "T1,B2"}) + expected_config = LintConfig() + expected_config.ignore = "T1,B2" + rule.apply(config, commit) + self.assertEqual(config, expected_config) + + expected_log_message = u"DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \ + u"Commit title 'Releäse' matches the regex '^Releäse(.*)', ignoring rules: T1,B2" diff --git a/gitlint/tests/test_lint.py b/gitlint/tests/test_lint.py index e52ea84..4d7e5f5 100644 --- a/gitlint/tests/test_lint.py +++ b/gitlint/tests/test_lint.py @@ -127,6 +127,29 @@ class LintTests(BaseTestCase): self.assertListEqual(violations, expected) + def test_lint_configuration_rule(self): + # Test that all rules are ignored because of matching regex + lint_config = LintConfig() + lint_config.set_rule_option("I1", "regex", "^Just a title(.*)") + + linter = GitLinter(lint_config) + violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample2"))) + self.assertListEqual(violations, []) + + # Test ignoring only certain rules + lint_config = LintConfig() + lint_config.set_rule_option("I1", "regex", "^Just a title(.*)") + lint_config.set_rule_option("I1", "ignore", "B6") + + linter = GitLinter(lint_config) + violations = linter.lint(self.gitcommit(self.get_sample("commit_message/sample2"))) + + # Normally we'd expect a B6 violation, but that one is skipped because of the specific ignore set above + expected = [RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)", + u"Just a title contåining WIP", 1)] + + self.assertListEqual(violations, expected) + def test_lint_special_commit(self): for commit_type in ["merge", "squash", "fixup"]: commit = self.gitcommit(self.get_sample("commit_message/{0}".format(commit_type))) diff --git a/qa/expected/debug_output1 b/qa/expected/debug_output1 index 9815be4..80bee74 100644 --- a/qa/expected/debug_output1 +++ b/qa/expected/debug_output1 @@ -14,6 +14,9 @@ verbosity: 2 debug: True target: {target} [RULES] + I1: ignore-by-title + regex=None + ignore=all T1: title-max-length line-length=20 T2: title-trailing-whitespace |