diff options
Diffstat (limited to 'gitlint')
25 files changed, 339 insertions, 62 deletions
diff --git a/gitlint/__init__.py b/gitlint/__init__.py index 903e77c..5a313cc 100644 --- a/gitlint/__init__.py +++ b/gitlint/__init__.py @@ -1 +1 @@ -__version__ = "0.15.1" +__version__ = "0.16.0" diff --git a/gitlint/cli.py b/gitlint/cli.py index 9b16d47..19676b3 100644 --- a/gitlint/cli.py +++ b/gitlint/cli.py @@ -18,6 +18,7 @@ from gitlint.utils import LOG_FORMAT from gitlint.exception import GitlintError # Error codes +GITLINT_SUCCESS = 0 MAX_VIOLATION_ERROR_CODE = 252 USAGE_ERROR_CODE = 253 GIT_CONTEXT_ERROR_CODE = 254 @@ -61,7 +62,8 @@ def log_system_info(): def build_config( # pylint: disable=too-many-arguments - target, config_path, c, extra_path, ignore, contrib, ignore_stdin, staged, verbose, silent, debug + target, config_path, c, extra_path, ignore, contrib, ignore_stdin, staged, fail_without_commits, verbose, + silent, debug ): """ Creates a LintConfig object based on a set of commandline parameters. """ config_builder = LintConfigBuilder() @@ -102,6 +104,9 @@ def build_config( # pylint: disable=too-many-arguments if staged: config_builder.set_option('general', 'staged', staged) + if fail_without_commits: + config_builder.set_option('general', 'fail-without-commits', fail_without_commits) + config = config_builder.build() return config, config_builder @@ -139,7 +144,7 @@ def get_stdin_data(): return False -def build_git_context(lint_config, msg_filename, refspec): +def build_git_context(lint_config, msg_filename, commit_hash, refspec): """ Builds a git context based on passed parameters and order of precedence """ # Determine which GitContext method to use if a custom message is passed @@ -168,7 +173,11 @@ def build_git_context(lint_config, msg_filename, refspec): # 3. Fallback to reading from local repository LOG.debug("No --msg-filename flag, no or empty data passed to stdin. Using the local repo.") - return GitContext.from_local_repository(lint_config.target, refspec) + + if commit_hash and refspec: + raise GitLintUsageError("--commit and --commits are mutually exclusive, use one or the other.") + + return GitContext.from_local_repository(lint_config.target, refspec=refspec, commit_hash=commit_hash) def handle_gitlint_error(ctx, exc): @@ -187,9 +196,10 @@ def handle_gitlint_error(ctx, exc): class ContextObj: """ Simple class to hold data that is passed between Click commands via the Click context. """ - def __init__(self, config, config_builder, refspec, msg_filename, gitcontext=None): + def __init__(self, config, config_builder, commit_hash, refspec, msg_filename, gitcontext=None): self.config = config self.config_builder = config_builder + self.commit_hash = commit_hash self.refspec = refspec self.msg_filename = msg_filename self.gitcontext = gitcontext @@ -205,6 +215,7 @@ class ContextObj: @click.option('-c', multiple=True, help="Config flags in format <rule>.<option>=<value> (e.g.: -c T1.line-length=80). " + "Flag can be used multiple times to set multiple config values.") # pylint: disable=bad-continuation +@click.option('--commit', envvar='GITLINT_COMMIT', default=None, help="Hash (SHA) of specific commit to lint.") @click.option('--commits', envvar='GITLINT_COMMITS', default=None, help="The range of commits to lint. [default: HEAD]") @click.option('-e', '--extra-path', envvar='GITLINT_EXTRA_PATH', help="Path to a directory or python module with extra user-defined rules", @@ -217,6 +228,8 @@ class ContextObj: help="Ignore any stdin data. Useful for running in CI server.") @click.option('--staged', envvar='GITLINT_STAGED', is_flag=True, help="Read staged commit meta-info from the local repository.") +@click.option('--fail-without-commits', envvar='GITLINT_FAIL_WITHOUT_COMMITS', is_flag=True, + help="Hard fail when the target commit range is empty.") @click.option('-v', '--verbose', envvar='GITLINT_VERBOSITY', count=True, default=0, help="Verbosity, more v's for more verbose output (e.g.: -v, -vv, -vvv). [default: -vvv]", ) @click.option('-s', '--silent', envvar='GITLINT_SILENT', is_flag=True, @@ -225,8 +238,9 @@ class ContextObj: @click.version_option(version=gitlint.__version__) @click.pass_context def cli( # pylint: disable=too-many-arguments - ctx, target, config, c, commits, extra_path, ignore, contrib, - msg_filename, ignore_stdin, staged, verbose, silent, debug, + ctx, target, config, c, commit, commits, extra_path, ignore, contrib, + msg_filename, ignore_stdin, staged, fail_without_commits, verbose, + silent, debug, ): """ Git lint tool, checks your git commit messages for styling issues @@ -242,11 +256,11 @@ def cli( # pylint: disable=too-many-arguments # Get the lint config from the commandline parameters and # store it in the context (click allows storing an arbitrary object in ctx.obj). - config, config_builder = build_config(target, config, c, extra_path, ignore, contrib, - ignore_stdin, staged, verbose, silent, debug) + config, config_builder = build_config(target, config, c, extra_path, ignore, contrib, ignore_stdin, staged, + fail_without_commits, verbose, silent, debug) LOG.debug("Configuration\n%s", config) - ctx.obj = ContextObj(config, config_builder, commits, msg_filename) + ctx.obj = ContextObj(config, config_builder, commit, commits, msg_filename) # If no subcommand is specified, then just lint if ctx.invoked_subcommand is None: @@ -262,9 +276,10 @@ def lint(ctx): """ Lints a git repository [default command] """ lint_config = ctx.obj.config refspec = ctx.obj.refspec + commit_hash = ctx.obj.commit_hash msg_filename = ctx.obj.msg_filename - gitcontext = build_git_context(lint_config, msg_filename, refspec) + gitcontext = build_git_context(lint_config, msg_filename, commit_hash, refspec) # Set gitcontext in the click context, so we can use it in command that are ran after this # in particular, this is used by run-hook ctx.obj.gitcontext = gitcontext @@ -273,17 +288,20 @@ def lint(ctx): # Exit if we don't have commits in the specified range. Use a 0 exit code, since a popular use-case is one # where users are using --commits in a check job to check the commit messages inside a CI job. By returning 0, we # ensure that these jobs don't fail if for whatever reason the specified commit range is empty. + # This behavior can be overridden by using the --fail-without-commits flag. if number_of_commits == 0: - LOG.debug(u'No commits in range "%s"', refspec) - ctx.exit(0) + LOG.debug('No commits in range "%s"', refspec) + if lint_config.fail_without_commits: + raise GitLintUsageError(f'No commits in range "{refspec}"') + ctx.exit(GITLINT_SUCCESS) - LOG.debug(u'Linting %d commit(s)', number_of_commits) + LOG.debug('Linting %d commit(s)', number_of_commits) general_config_builder = ctx.obj.config_builder last_commit = gitcontext.commits[-1] # Let's get linting! first_violation = True - exit_code = 0 + exit_code = GITLINT_SUCCESS for commit in gitcontext.commits: # Build a config_builder taking into account the commit specific config (if any) config_builder = general_config_builder.clone() @@ -301,10 +319,8 @@ def lint(ctx): if violations: # Display the commit hash & new lines intelligently if number_of_commits > 1 and commit.sha: - linter.display.e("{0}Commit {1}:".format( - "\n" if not first_violation or commit is last_commit else "", - commit.sha[:10] - )) + commit_separator = "\n" if not first_violation or commit is last_commit else "" + linter.display.e(f"{commit_separator}Commit {commit.sha[:10]}:") linter.print_violations(violations) first_violation = False @@ -323,7 +339,7 @@ def install_hook(ctx): hooks.GitHookInstaller.install_commit_msg_hook(ctx.obj.config) hook_path = hooks.GitHookInstaller.commit_msg_hook_path(ctx.obj.config) click.echo(f"Successfully installed gitlint commit-msg hook in {hook_path}") - ctx.exit(0) + ctx.exit(GITLINT_SUCCESS) except hooks.GitHookInstallerError as e: click.echo(e, err=True) ctx.exit(GIT_CONTEXT_ERROR_CODE) @@ -337,7 +353,7 @@ def uninstall_hook(ctx): hooks.GitHookInstaller.uninstall_commit_msg_hook(ctx.obj.config) hook_path = hooks.GitHookInstaller.commit_msg_hook_path(ctx.obj.config) click.echo(f"Successfully uninstalled gitlint commit-msg hook from {hook_path}") - ctx.exit(0) + ctx.exit(GITLINT_SUCCESS) except hooks.GitHookInstallerError as e: click.echo(e, err=True) ctx.exit(GIT_CONTEXT_ERROR_CODE) @@ -361,7 +377,7 @@ def run_hook(ctx): sys.stdout.flush() exit_code = e.exit_code - if exit_code == 0: + if exit_code == GITLINT_SUCCESS: click.echo("gitlint: " + click.style("OK", fg='green') + " (no violations in commit message)") continue @@ -387,7 +403,7 @@ def run_hook(ctx): if value == "y": LOG.debug("run-hook: commit message accepted") - exit_code = 0 + exit_code = GITLINT_SUCCESS elif value == "e": LOG.debug("run-hook: editing commit message") msg_filename = ctx.obj.msg_filename @@ -428,7 +444,7 @@ def generate_config(ctx): LintConfigGenerator.generate_config(path) click.echo(f"Successfully generated {path}") - ctx.exit(0) + ctx.exit(GITLINT_SUCCESS) # Let's Party! diff --git a/gitlint/config.py b/gitlint/config.py index 1eeb35d..6d2ead2 100644 --- a/gitlint/config.py +++ b/gitlint/config.py @@ -41,6 +41,7 @@ class LintConfig: default_rule_classes = (rules.IgnoreByTitle, rules.IgnoreByBody, rules.IgnoreBodyLines, + rules.IgnoreByAuthorName, rules.TitleMaxLength, rules.TitleTrailingWhitespace, rules.TitleLeadingWhitespace, @@ -76,6 +77,8 @@ class LintConfig: ignore_stdin_description = "Ignore any stdin data. Useful for running in CI server." self._ignore_stdin = options.BoolOption('ignore-stdin', False, ignore_stdin_description) self._staged = options.BoolOption('staged', False, "Read staged commit meta-info from the local repository.") + self._fail_without_commits = options.BoolOption('fail-without-commits', False, + "Hard fail when the target commit range is empty") @property def target(self): @@ -171,6 +174,15 @@ class LintConfig: return self._staged.set(value) @property + def fail_without_commits(self): + return self._fail_without_commits.value + + @fail_without_commits.setter + @handle_option_error + def fail_without_commits(self, value): + return self._fail_without_commits.set(value) + + @property def extra_path(self): return self._extra_path.value if self._extra_path else None @@ -275,6 +287,7 @@ class LintConfig: self.ignore_revert_commits == other.ignore_revert_commits and \ self.ignore_stdin == other.ignore_stdin and \ self.staged == other.staged and \ + self.fail_without_commits == other.fail_without_commits and \ self.debug == other.debug and \ self.ignore == other.ignore and \ self._config_path == other._config_path # noqa @@ -292,6 +305,7 @@ class LintConfig: f"ignore-revert-commits: {self.ignore_revert_commits}\n" f"ignore-stdin: {self.ignore_stdin}\n" f"staged: {self.staged}\n" + f"fail-without-commits: {self.fail_without_commits}\n" f"verbosity: {self.verbosity}\n" f"debug: {self.debug}\n" f"target: {self.target}\n" diff --git a/gitlint/contrib/rules/conventional_commit.py b/gitlint/contrib/rules/conventional_commit.py index 71f6adf..9c9d5cb 100644 --- a/gitlint/contrib/rules/conventional_commit.py +++ b/gitlint/contrib/rules/conventional_commit.py @@ -3,7 +3,7 @@ import re from gitlint.options import ListOption from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation -RULE_REGEX = re.compile(r"[^(]+?(\([^)]+?\))?: .+") +RULE_REGEX = re.compile(r"([^(]+?)(\([^)]+?\))?!?: .+") class ConventionalCommit(LineRule): @@ -23,16 +23,15 @@ class ConventionalCommit(LineRule): def validate(self, line, _commit): violations = [] + match = RULE_REGEX.match(line) - for commit_type in self.options["types"].value: - if line.startswith(commit_type): - break - else: - msg = "Title does not start with one of {0}".format(', '.join(self.options['types'].value)) - violations.append(RuleViolation(self.id, msg, line)) - - if not RULE_REGEX.match(line): + if not match: msg = "Title does not follow ConventionalCommits.org format 'type(optional-scope): description'" violations.append(RuleViolation(self.id, msg, line)) + else: + line_commit_type = match.group(1) + if line_commit_type not in self.options["types"].value: + opt_str = ', '.join(self.options['types'].value) + violations.append(RuleViolation(self.id, f"Title does not start with one of {opt_str}", line)) return violations diff --git a/gitlint/files/gitlint b/gitlint/files/gitlint index e95bf9e..cbbae70 100644 --- a/gitlint/files/gitlint +++ b/gitlint/files/gitlint @@ -27,6 +27,12 @@ # commit message to gitlint via stdin or --commit-msg. Disabled by default. # staged=true +# Hard fail when the target commit range is empty. Note that gitlint will +# already fail by default on invalid commit ranges. This option is specifically +# to tell gitlint to fail on *valid but empty* commit ranges. +# Disabled by default. +# fail-without-commits=true + # Enable debug mode (prints more output). Disabled by default. # debug=true @@ -111,6 +117,15 @@ # E.g. Ignore all lines that start with 'Co-Authored-By' # regex=^Co-Authored-By +# [ignore-by-author-name] +# Ignore certain rules for commits of which the author name matches a regex +# E.g. Match commits made by dependabot +# regex=(.*)dependabot(.*) +# +# Ignore certain rules, you can reference them by their id or by their full name +# Use 'all' to ignore all rules +# ignore=T1,body-min-length + # This is a contrib rule - a community contributed rule. These are disabled by default. # You need to explicitly enable them one-by-one by adding them to the "contrib" option # under [general] section above. diff --git a/gitlint/git.py b/gitlint/git.py index a9609d0..773c7b2 100644 --- a/gitlint/git.py +++ b/gitlint/git.py @@ -364,22 +364,27 @@ class GitContext(PropertyCache): return context @staticmethod - def from_local_repository(repository_path, refspec=None): + def from_local_repository(repository_path, refspec=None, commit_hash=None): """ Retrieves the git context from a local git repository. :param repository_path: Path to the git repository to retrieve the context from - :param refspec: The commit(s) to retrieve + :param refspec: The commit(s) to retrieve (mutually exclusive with `commit_sha`) + :param commit_hash: Hash of the commit to retrieve (mutually exclusive with `refspec`) """ context = GitContext(repository_path=repository_path) - # If no refspec is defined, fallback to the last commit on the current branch - if refspec is None: + if refspec: + sha_list = _git("rev-list", refspec, _cwd=repository_path).split() + elif commit_hash: # Single commit, just pass it to `git log -1` + # Even though we have already been passed the commit hash, we ask git to retrieve this hash and + # return it to us. This way we verify that the passed hash is a valid hash for the target repo and we + # also convert it to the full hash format (we might have been passed a short hash). + sha_list = [_git("log", "-1", commit_hash, "--pretty=%H", _cwd=repository_path).replace("\n", "")] + else: # If no refspec is defined, fallback to the last commit on the current branch # We tried many things here e.g.: defaulting to e.g. HEAD or HEAD^... (incl. dealing with # repos that only have a single commit - HEAD^... doesn't work there), but then we still get into # problems with e.g. merge commits. Easiest solution is just taking the SHA from `git log -1`. sha_list = [_git("log", "-1", "--pretty=%H", _cwd=repository_path).replace("\n", "")] - else: - sha_list = _git("rev-list", refspec, _cwd=repository_path).split() for sha in sha_list: commit = LocalGitCommit(context, sha) diff --git a/gitlint/rules.py b/gitlint/rules.py index db21e56..1c5a618 100644 --- a/gitlint/rules.py +++ b/gitlint/rules.py @@ -141,7 +141,7 @@ class LineMustNotContainWord(LineRule): strings = self.options['words'].value violations = [] for string in strings: - regex = re.compile(r"\b%s\b" % string.lower(), re.IGNORECASE | re.UNICODE) + regex = re.compile(rf"\b{string.lower()}\b", re.IGNORECASE | re.UNICODE) match = regex.search(line.lower()) if match: violations.append(RuleViolation(self.id, self.violation_message.format(string), line)) @@ -416,3 +416,25 @@ class IgnoreBodyLines(ConfigurationRule): commit.message.body = new_body commit.message.full = "\n".join([commit.message.title] + new_body) + + +class IgnoreByAuthorName(ConfigurationRule): + name = "ignore-by-author-name" + id = "I4" + options_spec = [RegexOption('regex', None, "Regex matching the author name of commits this rule should apply to"), + StrOption('ignore', "all", "Comma-separated list of rules to ignore")] + + def apply(self, config, commit): + # If no regex is specified, immediately return + if not self.options['regex'].value: + return + + if self.options['regex'].value.match(commit.author_name): + config.ignore = self.options['ignore'].value + + message = (f"Commit Author Name '{commit.author_name}' matches the regex " + f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}") + + self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message) + # No need to check other lines if we found a match + return diff --git a/gitlint/shell.py b/gitlint/shell.py index 7f598ae..e05204a 100644 --- a/gitlint/shell.py +++ b/gitlint/shell.py @@ -11,8 +11,8 @@ from gitlint.utils import USE_SH_LIB, DEFAULT_ENCODING def shell(cmd): """ Convenience function that opens a given command in a shell. Does not use 'sh' library. """ - p = subprocess.Popen(cmd, shell=True) - p.communicate() + with subprocess.Popen(cmd, shell=True) as p: + p.communicate() if USE_SH_LIB: @@ -57,8 +57,8 @@ else: popen_kwargs['cwd'] = kwargs['_cwd'] try: - p = subprocess.Popen(args, **popen_kwargs) - result = p.communicate() + with subprocess.Popen(args, **popen_kwargs) as p: + result = p.communicate() except FileNotFoundError as e: raise CommandNotFound from e diff --git a/gitlint/tests/base.py b/gitlint/tests/base.py index 9406240..017122b 100644 --- a/gitlint/tests/base.py +++ b/gitlint/tests/base.py @@ -126,6 +126,10 @@ class BaseTestCase(unittest.TestCase): """ return super().assertRaisesRegex(expected_exception, re.escape(expected_regex), *args, **kwargs) + def clearlog(self): + """ Clears the log capture """ + self.logcapture.clear() + @contextlib.contextmanager def assertRaisesMessage(self, expected_exception, expected_msg): # pylint: disable=invalid-name """ Asserts an exception has occurred with a given error message """ @@ -182,3 +186,6 @@ class LogCapture(logging.Handler): def emit(self, record): self.messages.append(self.format(record)) + + def clear(self): + self.messages = [] diff --git a/gitlint/tests/cli/test_cli.py b/gitlint/tests/cli/test_cli.py index bf35e96..59ec7af 100644 --- a/gitlint/tests/cli/test_cli.py +++ b/gitlint/tests/cli/test_cli.py @@ -26,6 +26,7 @@ class CLITests(BaseTestCase): USAGE_ERROR_CODE = 253 GIT_CONTEXT_ERROR_CODE = 254 CONFIG_ERROR_CODE = 255 + GITLINT_SUCCESS_CODE = 0 def setUp(self): super(CLITests, self).setUp() @@ -180,6 +181,39 @@ class CLITests(BaseTestCase): self.assertEqual(stderr.getvalue(), expected) self.assertEqual(result.exit_code, 2) + @patch('gitlint.cli.get_stdin_data', return_value=False) + @patch('gitlint.git.sh') + def test_lint_commit(self, sh, _): + """ Test for --commit option """ + + sh.git.side_effect = [ + "6f29bf81a8322a04071bb794666e48c443a90360\n", # git log -1 <SHA> --pretty=%H + # git log --pretty <FORMAT> <SHA> + "test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" + "WIP: commït-title1\n\ncommït-body1", + "#", # git config --get core.commentchar + "commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha> + "commit-1/file-1\ncommit-1/file-2\n", # git diff-tree + ] + + with patch('gitlint.display.stderr', new=StringIO()) as stderr: + result = self.cli.invoke(cli.cli, ["--commit", "foo"]) + self.assertEqual(result.output, "") + + self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_lint_commit_1")) + self.assertEqual(result.exit_code, 2) + + @patch('gitlint.cli.get_stdin_data', return_value=False) + @patch('gitlint.git.sh') + def test_lint_commit_negative(self, sh, _): + """ Negative test for --commit option """ + + # Try using --commit and --commits at the same time (not allowed) + result = self.cli.invoke(cli.cli, ["--commit", "foo", "--commits", "foo...bar"]) + expected_output = "Error: --commit and --commits are mutually exclusive, use one or the other.\n" + self.assertEqual(result.output, expected_output) + self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE) + @patch('gitlint.cli.get_stdin_data', return_value=u'WIP: tïtle \n') def test_input_stream(self, _): """ Test for linting when a message is passed via stdin """ @@ -283,6 +317,30 @@ class CLITests(BaseTestCase): "'--msg-filename' or when piping data to gitlint via stdin.\n")) @patch('gitlint.cli.get_stdin_data', return_value=False) + @patch('gitlint.git.sh') + def test_fail_without_commits(self, sh, _): + """ Test for --debug option """ + + sh.git.side_effect = [ + "", # First invocation of git rev-list + "" # Second invocation of git rev-list + ] + + with patch('gitlint.display.stderr', new=StringIO()) as stderr: + # By default, gitlint should silently exit with code GITLINT_SUCCESS when there are no commits + result = self.cli.invoke(cli.cli, ["--commits", "foo..bar"]) + self.assertEqual(stderr.getvalue(), "") + self.assertEqual(result.exit_code, cli.GITLINT_SUCCESS) + self.assert_log_contains("DEBUG: gitlint.cli No commits in range \"foo..bar\"") + + # When --fail-without-commits is set, gitlint should hard fail with code USAGE_ERROR_CODE + self.clearlog() + result = self.cli.invoke(cli.cli, ["--commits", "foo..bar", "--fail-without-commits"]) + self.assertEqual(result.output, 'Error: No commits in range "foo..bar"\n') + self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE) + self.assert_log_contains("DEBUG: gitlint.cli No commits in range \"foo..bar\"") + + @patch('gitlint.cli.get_stdin_data', return_value=False) def test_msg_filename(self, _): expected_output = "3: B6 Body message is missing\n" @@ -405,7 +463,7 @@ class CLITests(BaseTestCase): result = self.cli.invoke(cli.cli, ["--contrib", "contrib-title-conventional-commits,CC1"]) expected_output = self.get_expected('cli/test_cli/test_contrib_1') self.assertEqual(stderr.getvalue(), expected_output) - self.assertEqual(result.exit_code, 3) + self.assertEqual(result.exit_code, 2) @patch('gitlint.cli.get_stdin_data', return_value="Test tïtle\n") def test_contrib_negative(self, _): @@ -475,7 +533,7 @@ class CLITests(BaseTestCase): def test_generate_config(self, generate_config): """ Test for the generate-config subcommand """ result = self.cli.invoke(cli.cli, ["generate-config"], input="tëstfile\n") - self.assertEqual(result.exit_code, 0) + self.assertEqual(result.exit_code, self.GITLINT_SUCCESS_CODE) expected_msg = "Please specify a location for the sample gitlint config file [.gitlint]: tëstfile\n" + \ f"Successfully generated {os.path.realpath('tëstfile')}\n" self.assertEqual(result.output, expected_msg) @@ -517,7 +575,7 @@ class CLITests(BaseTestCase): result = self.cli.invoke(cli.cli, ["--commits", "master...HEAD"]) self.assert_log_contains("DEBUG: gitlint.cli No commits in range \"master...HEAD\"") - self.assertEqual(result.exit_code, 0) + self.assertEqual(result.exit_code, self.GITLINT_SUCCESS_CODE) @patch('gitlint.cli.get_stdin_data', return_value="WIP: tëst tïtle") def test_named_rules(self, _): diff --git a/gitlint/tests/config/test_config.py b/gitlint/tests/config/test_config.py index 93e35de..c3fd78a 100644 --- a/gitlint/tests/config/test_config.py +++ b/gitlint/tests/config/test_config.py @@ -50,6 +50,7 @@ class LintConfigTests(BaseTestCase): self.assertFalse(config.ignore_stdin) self.assertFalse(config.staged) + self.assertFalse(config.fail_without_commits) self.assertFalse(config.debug) self.assertEqual(config.verbosity, 3) active_rule_classes = tuple(type(rule) for rule in config.rules) @@ -95,6 +96,10 @@ class LintConfigTests(BaseTestCase): config.set_general_option("staged", "true") self.assertTrue(config.staged) + # fail-without-commits + config.set_general_option("fail-without-commits", "true") + self.assertTrue(config.fail_without_commits) + # target config.set_general_option("target", self.SAMPLES_DIR) self.assertEqual(config.target, self.SAMPLES_DIR) @@ -227,7 +232,7 @@ class LintConfigTests(BaseTestCase): # splitting which means it it will accept just about everything # invalid boolean options - for attribute in ['debug', 'staged', 'ignore_stdin']: + for attribute in ['debug', 'staged', 'ignore_stdin', 'fail_without_commits']: option_name = attribute.replace("_", "-") with self.assertRaisesMessage(LintConfigError, f"Option '{option_name}' must be either 'true' or 'false'"): diff --git a/gitlint/tests/contrib/rules/test_conventional_commit.py b/gitlint/tests/contrib/rules/test_conventional_commit.py index fb492df..5da5cd5 100644 --- a/gitlint/tests/contrib/rules/test_conventional_commit.py +++ b/gitlint/tests/contrib/rules/test_conventional_commit.py @@ -29,12 +29,34 @@ class ContribConventionalCommitTests(BaseTestCase): violations = rule.validate("bår: foo", None) self.assertListEqual([expected_violation], violations) + # assert violation when use strange chars after correct type + expected_violation = RuleViolation("CT1", "Title does not start with one of fix, feat, chore, docs," + " style, refactor, perf, test, revert, ci, build", + "feat_wrong_chars: föo") + violations = rule.validate("feat_wrong_chars: föo", None) + self.assertListEqual([expected_violation], violations) + + # assert violation when use strange chars after correct type + expected_violation = RuleViolation("CT1", "Title does not start with one of fix, feat, chore, docs," + " style, refactor, perf, test, revert, ci, build", + "feat_wrong_chars(scope): föo") + violations = rule.validate("feat_wrong_chars(scope): föo", None) + self.assertListEqual([expected_violation], violations) + # assert violation on wrong format expected_violation = RuleViolation("CT1", "Title does not follow ConventionalCommits.org format " "'type(optional-scope): description'", "fix föo") violations = rule.validate("fix föo", None) self.assertListEqual([expected_violation], violations) + # assert no violation when use ! for breaking changes without scope + violations = rule.validate("feat!: föo", None) + self.assertListEqual([], violations) + + # assert no violation when use ! for breaking changes with scope + violations = rule.validate("fix(scope)!: föo", None) + self.assertListEqual([], violations) + # assert no violation when adding new type rule = ConventionalCommit({'types': ["föo", "bär"]}) for typ in ["föo", "bär"]: @@ -45,3 +67,9 @@ class ContribConventionalCommitTests(BaseTestCase): violations = rule.validate("fix: hür dur", None) expected_violation = RuleViolation("CT1", "Title does not start with one of föo, bär", "fix: hür dur") self.assertListEqual([expected_violation], violations) + + # assert no violation when adding new type named with numbers + rule = ConventionalCommit({'types': ["föo123", "123bär"]}) + for typ in ["föo123", "123bär"]: + violations = rule.validate(typ + ": hür dur", None) + self.assertListEqual([], violations) diff --git a/gitlint/tests/expected/cli/test_cli/test_contrib_1 b/gitlint/tests/expected/cli/test_cli/test_contrib_1 index ed21eca..b95433b 100644 --- a/gitlint/tests/expected/cli/test_cli/test_contrib_1 +++ b/gitlint/tests/expected/cli/test_cli/test_contrib_1 @@ -1,3 +1,2 @@ 1: CC1 Body does not contain a 'Signed-off-by' line -1: CT1 Title does not start with one of fix, feat, chore, docs, style, refactor, perf, test, revert, ci, build: "Test tïtle" 1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "Test tïtle" diff --git a/gitlint/tests/expected/cli/test_cli/test_debug_1 b/gitlint/tests/expected/cli/test_cli/test_debug_1 index a95a58d..fcd5d7e 100644 --- a/gitlint/tests/expected/cli/test_cli/test_debug_1 +++ b/gitlint/tests/expected/cli/test_cli/test_debug_1 @@ -17,6 +17,7 @@ ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: False +fail-without-commits: False verbosity: 1 debug: True target: {target} @@ -29,6 +30,9 @@ target: {target} regex=None I3: ignore-body-lines regex=None + I4: ignore-by-author-name + ignore=all + regex=None T1: title-max-length line-length=20 T2: title-trailing-whitespace diff --git a/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 b/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 index c05d147..7c94b45 100644 --- a/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 +++ b/gitlint/tests/expected/cli/test_cli/test_input_stream_debug_2 @@ -17,6 +17,7 @@ ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: False +fail-without-commits: False verbosity: 3 debug: True target: {target} @@ -29,6 +30,9 @@ target: {target} regex=None I3: ignore-body-lines regex=None + I4: ignore-by-author-name + ignore=all + regex=None T1: title-max-length line-length=72 T2: title-trailing-whitespace diff --git a/gitlint/tests/expected/cli/test_cli/test_lint_commit_1 b/gitlint/tests/expected/cli/test_cli/test_lint_commit_1 new file mode 100644 index 0000000..b9f0742 --- /dev/null +++ b/gitlint/tests/expected/cli/test_cli/test_lint_commit_1 @@ -0,0 +1,2 @@ +1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: commït-title1" +3: B5 Body message is too short (12<20): "commït-body1" diff --git a/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 b/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 index e8e9f33..f37ffa0 100644 --- a/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 +++ b/gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_2 @@ -17,6 +17,7 @@ ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: True +fail-without-commits: False verbosity: 3 debug: True target: {target} @@ -29,6 +30,9 @@ target: {target} regex=None I3: ignore-body-lines regex=None + I4: ignore-by-author-name + ignore=all + regex=None T1: title-max-length line-length=72 T2: title-trailing-whitespace diff --git a/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 b/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 index b822edc..1d1020a 100644 --- a/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 +++ b/gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_2 @@ -17,6 +17,7 @@ ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: True +fail-without-commits: False verbosity: 3 debug: True target: {target} @@ -29,6 +30,9 @@ target: {target} regex=None I3: ignore-body-lines regex=None + I4: ignore-by-author-name + ignore=all + regex=None T1: title-max-length line-length=72 T2: title-trailing-whitespace diff --git a/gitlint/tests/expected/cli/test_cli/test_named_rules_2 b/gitlint/tests/expected/cli/test_cli/test_named_rules_2 index 828e296..83c4bf2 100644 --- a/gitlint/tests/expected/cli/test_cli/test_named_rules_2 +++ b/gitlint/tests/expected/cli/test_cli/test_named_rules_2 @@ -17,6 +17,7 @@ ignore-squash-commits: True ignore-revert-commits: True ignore-stdin: False staged: False +fail-without-commits: False verbosity: 3 debug: True target: {target} @@ -29,6 +30,9 @@ target: {target} regex=None I3: ignore-body-lines regex=None + I4: ignore-by-author-name + ignore=all + regex=None T1: title-max-length line-length=72 T2: title-trailing-whitespace diff --git a/gitlint/tests/git/test_git_commit.py b/gitlint/tests/git/test_git_commit.py index 6bb545a..02c5795 100644 --- a/gitlint/tests/git/test_git_commit.py +++ b/gitlint/tests/git/test_git_commit.py @@ -75,11 +75,12 @@ class GitCommitTests(BaseTestCase): self.assertListEqual(sh.git.mock_calls, expected_calls) @patch('gitlint.git.sh') - def test_from_local_repository_specific_ref(self, sh): - sample_sha = "myspecialref" + def test_from_local_repository_specific_refspec(self, sh): + sample_refspec = "åbc123..def456" + sample_sha = "åbc123" sh.git.side_effect = [ - sample_sha, + sample_sha, # git rev-list <sample_refspec> "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" "cömmit-title\n\ncömmit-body", "#", # git config --get core.commentchar @@ -87,10 +88,10 @@ class GitCommitTests(BaseTestCase): "foöbar\n* hürdur\n" ] - context = GitContext.from_local_repository("fåke/path", sample_sha) + context = GitContext.from_local_repository("fåke/path", refspec=sample_refspec) # assert that commit info was read using git command expected_calls = [ - call("rev-list", sample_sha, **self.expected_sh_special_args), + call("rev-list", sample_refspec, **self.expected_sh_special_args), call("log", sample_sha, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args), call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args), call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_sha, @@ -128,6 +129,59 @@ class GitCommitTests(BaseTestCase): self.assertListEqual(sh.git.mock_calls, expected_calls) @patch('gitlint.git.sh') + def test_from_local_repository_specific_commit_hash(self, sh): + sample_hash = "åbc123" + + sh.git.side_effect = [ + sample_hash, # git log -1 <sample_hash> + "test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n" + "cömmit-title\n\ncömmit-body", + "#", # git config --get core.commentchar + "file1.txt\npåth/to/file2.txt\n", + "foöbar\n* hürdur\n" + ] + + context = GitContext.from_local_repository("fåke/path", commit_hash=sample_hash) + # assert that commit info was read using git command + expected_calls = [ + call("log", "-1", sample_hash, "--pretty=%H", **self.expected_sh_special_args), + call("log", sample_hash, "-1", "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B", **self.expected_sh_special_args), + call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args), + call('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sample_hash, + **self.expected_sh_special_args), + call('branch', '--contains', sample_hash, **self.expected_sh_special_args) + ] + + # Only first 'git log' call should've happened at this point + self.assertEqual(sh.git.mock_calls, expected_calls[:1]) + + last_commit = context.commits[-1] + self.assertIsInstance(last_commit, LocalGitCommit) + self.assertEqual(last_commit.sha, sample_hash) + self.assertEqual(last_commit.message.title, "cömmit-title") + self.assertEqual(last_commit.message.body, ["", "cömmit-body"]) + self.assertEqual(last_commit.author_name, "test åuthor") + self.assertEqual(last_commit.author_email, "test-emåil@foo.com") + self.assertEqual(last_commit.date, datetime.datetime(2016, 12, 3, 15, 28, 15, + tzinfo=dateutil.tz.tzoffset("+0100", 3600))) + self.assertListEqual(last_commit.parents, ["åbc"]) + self.assertFalse(last_commit.is_merge_commit) + self.assertFalse(last_commit.is_fixup_commit) + self.assertFalse(last_commit.is_squash_commit) + self.assertFalse(last_commit.is_revert_commit) + + # First 2 'git log' calls should've happened at this point + self.assertListEqual(sh.git.mock_calls, expected_calls[:3]) + + self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"]) + # 'git diff-tree' should have happened at this point + self.assertListEqual(sh.git.mock_calls, expected_calls[:4]) + + self.assertListEqual(last_commit.branches, ["foöbar", "hürdur"]) + # All expected calls should've happened at this point + self.assertListEqual(sh.git.mock_calls, expected_calls) + + @patch('gitlint.git.sh') def test_get_latest_commit_merge_commit(self, sh): sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9" diff --git a/gitlint/tests/rules/test_body_rules.py b/gitlint/tests/rules/test_body_rules.py index a268585..812c74a 100644 --- a/gitlint/tests/rules/test_body_rules.py +++ b/gitlint/tests/rules/test_body_rules.py @@ -101,13 +101,13 @@ class BodyRuleTests(BaseTestCase): expected_violation = rules.RuleViolation("B5", "Body message is too short (21<120)", "å" * 21, 3) rule = rules.BodyMinLength({'min-length': 120}) - commit = self.gitcommit("Title\n\n%s\n" % ("å" * 21)) + commit = self.gitcommit("Title\n\n{0}\n".format("å" * 21)) # pylint: disable=consider-using-f-string violations = rule.validate(commit) self.assertListEqual(violations, [expected_violation]) # Make sure we don't get the error if the body-length is exactly the min-length rule = rules.BodyMinLength({'min-length': 8}) - commit = self.gitcommit("Tïtle\n\n%s\n" % ("å" * 8)) + commit = self.gitcommit("Tïtle\n\n{0}\n".format("å" * 8)) # pylint: disable=consider-using-f-string violations = rule.validate(commit) self.assertIsNone(violations) @@ -182,7 +182,7 @@ class BodyRuleTests(BaseTestCase): expected_violation = rules.RuleViolation("B7", "Body does not mention changed file 'föo/test.py'", None, 4) self.assertEqual([expected_violation], violations) - # assert multiple errors if multiple files habe changed and are not mentioned + # assert multiple errors if multiple files have changed and are not mentioned commit_msg = "This is å test\n\nHere is a mention of\nAnd here is a mention of" commit = self.gitcommit(commit_msg, ["föo/test.py", "bar.txt"]) violations = rule.validate(commit) diff --git a/gitlint/tests/rules/test_configuration_rules.py b/gitlint/tests/rules/test_configuration_rules.py index 479d9c2..9302da5 100644 --- a/gitlint/tests/rules/test_configuration_rules.py +++ b/gitlint/tests/rules/test_configuration_rules.py @@ -71,6 +71,39 @@ class ConfigurationRuleTests(BaseTestCase): "Commit message line ' a relëase body' matches the regex '(.*)relëase(.*)', ignoring rules: T1,B2" self.assert_log_contains(expected_log_message) + def test_ignore_by_author_name(self): + commit = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line", author_name="Tëst nåme") + + # No regex specified -> Config shouldn't be changed + rule = rules.IgnoreByAuthorName() + config = LintConfig() + rule.apply(config, commit) + self.assertEqual(config, LintConfig()) + self.assert_logged([]) # nothing logged -> nothing ignored + + # Matching regex -> expect config to ignore all rules + rule = rules.IgnoreByAuthorName({"regex": "(.*)ëst(.*)"}) + expected_config = LintConfig() + expected_config.ignore = "all" + rule.apply(config, commit) + self.assertEqual(config, expected_config) + + expected_log_message = ("DEBUG: gitlint.rules Ignoring commit because of rule 'I4': " + "Commit Author Name 'Tëst nåme' matches the regex '(.*)ëst(.*)'," + " ignoring rules: all") + self.assert_log_contains(expected_log_message) + + # Matching regex with specific ignore + rule = rules.IgnoreByAuthorName({"regex": "(.*)nåme", "ignore": "T1,B2"}) + expected_config = LintConfig() + expected_config.ignore = "T1,B2" + rule.apply(config, commit) + self.assertEqual(config, expected_config) + + expected_log_message = ("DEBUG: gitlint.rules Ignoring commit because of rule 'I4': " + "Commit Author Name 'Tëst nåme' matches the regex '(.*)nåme', ignoring rules: T1,B2") + self.assert_log_contains(expected_log_message) + def test_ignore_body_lines(self): commit1 = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line") commit2 = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line") diff --git a/gitlint/tests/rules/test_title_rules.py b/gitlint/tests/rules/test_title_rules.py index e1be857..10b4aab 100644 --- a/gitlint/tests/rules/test_title_rules.py +++ b/gitlint/tests/rules/test_title_rules.py @@ -79,7 +79,7 @@ class TitleRuleTests(BaseTestCase): violations = rule.validate("This is å test", None) self.assertIsNone(violations) - # no violation if WIP occurs inside a wor + # no violation if WIP occurs inside a word violations = rule.validate("This is å wiping test", None) self.assertIsNone(violations) diff --git a/gitlint/tests/rules/test_user_rules.py b/gitlint/tests/rules/test_user_rules.py index 510a829..5bf9b77 100644 --- a/gitlint/tests/rules/test_user_rules.py +++ b/gitlint/tests/rules/test_user_rules.py @@ -97,7 +97,7 @@ class UserRuleTests(BaseTestCase): def test_assert_valid_rule_class(self): class MyLineRuleClass(rules.LineRule): id = 'UC1' - name = u'my-lïne-rule' + name = 'my-lïne-rule' target = rules.CommitMessageTitle def validate(self): @@ -105,14 +105,14 @@ class UserRuleTests(BaseTestCase): class MyCommitRuleClass(rules.CommitRule): id = 'UC2' - name = u'my-cömmit-rule' + name = 'my-cömmit-rule' def validate(self): pass class MyConfigurationRuleClass(rules.ConfigurationRule): id = 'UC3' - name = u'my-cönfiguration-rule' + name = 'my-cönfiguration-rule' def apply(self): pass diff --git a/gitlint/tests/test_options.py b/gitlint/tests/test_options.py index fc3ccc1..eabcfe1 100644 --- a/gitlint/tests/test_options.py +++ b/gitlint/tests/test_options.py @@ -197,7 +197,7 @@ class RuleOptionTests(BaseTestCase): self.assertEqual(option.value, self.get_sample_path()) # Expect exception if path type is invalid - option.type = u'föo' + option.type = 'föo' expected = "Option tëst-directory type must be one of: 'file', 'dir', 'both' (current: 'föo')" with self.assertRaisesMessage(RuleOptionError, expected): option.set("haha") |