summaryrefslogtreecommitdiffstats
path: root/gitlint
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2021-10-13 05:34:54 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2021-10-13 05:34:54 +0000
commitb8d423e7d13686d6627571d6c4adf12661d82147 (patch)
tree11d64ff26fb53c3c01ee35d062ca0c51fb883550 /gitlint
parentAdding upstream version 0.15.1. (diff)
downloadgitlint-b8d423e7d13686d6627571d6c4adf12661d82147.tar.xz
gitlint-b8d423e7d13686d6627571d6c4adf12661d82147.zip
Adding upstream version 0.16.0.upstream/0.16.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'gitlint')
-rw-r--r--gitlint/__init__.py2
-rw-r--r--gitlint/cli.py62
-rw-r--r--gitlint/config.py14
-rw-r--r--gitlint/contrib/rules/conventional_commit.py17
-rw-r--r--gitlint/files/gitlint15
-rw-r--r--gitlint/git.py17
-rw-r--r--gitlint/rules.py24
-rw-r--r--gitlint/shell.py8
-rw-r--r--gitlint/tests/base.py7
-rw-r--r--gitlint/tests/cli/test_cli.py64
-rw-r--r--gitlint/tests/config/test_config.py7
-rw-r--r--gitlint/tests/contrib/rules/test_conventional_commit.py28
-rw-r--r--gitlint/tests/expected/cli/test_cli/test_contrib_11
-rw-r--r--gitlint/tests/expected/cli/test_cli/test_debug_14
-rw-r--r--gitlint/tests/expected/cli/test_cli/test_input_stream_debug_24
-rw-r--r--gitlint/tests/expected/cli/test_cli/test_lint_commit_12
-rw-r--r--gitlint/tests/expected/cli/test_cli/test_lint_staged_msg_filename_24
-rw-r--r--gitlint/tests/expected/cli/test_cli/test_lint_staged_stdin_24
-rw-r--r--gitlint/tests/expected/cli/test_cli/test_named_rules_24
-rw-r--r--gitlint/tests/git/test_git_commit.py64
-rw-r--r--gitlint/tests/rules/test_body_rules.py6
-rw-r--r--gitlint/tests/rules/test_configuration_rules.py33
-rw-r--r--gitlint/tests/rules/test_title_rules.py2
-rw-r--r--gitlint/tests/rules/test_user_rules.py6
-rw-r--r--gitlint/tests/test_options.py2
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")