Merging upstream version 0.17.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
c90d4ccbc4
commit
435cb3a48d
128 changed files with 72 additions and 100 deletions
1
gitlint-core/LICENSE
Symbolic link
1
gitlint-core/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
|||
../LICENSE
|
3
gitlint-core/MANIFEST.in
Normal file
3
gitlint-core/MANIFEST.in
Normal file
|
@ -0,0 +1,3 @@
|
|||
include README.md
|
||||
include LICENSE
|
||||
recursive-exclude gitlint/tests *
|
1
gitlint-core/README.md
Symbolic link
1
gitlint-core/README.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../README.md
|
1
gitlint-core/gitlint/__init__.py
Normal file
1
gitlint-core/gitlint/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
__version__ = "0.17.0"
|
53
gitlint-core/gitlint/cache.py
Normal file
53
gitlint-core/gitlint/cache.py
Normal file
|
@ -0,0 +1,53 @@
|
|||
class PropertyCache:
|
||||
""" Mixin class providing a simple cache. """
|
||||
|
||||
def __init__(self):
|
||||
self._cache = {}
|
||||
|
||||
def _try_cache(self, cache_key, cache_populate_func):
|
||||
""" Tries to get a value from the cache identified by `cache_key`.
|
||||
If no value is found in the cache, do a function call to `cache_populate_func` to populate the cache
|
||||
and then return the value from the cache. """
|
||||
if cache_key not in self._cache:
|
||||
cache_populate_func()
|
||||
return self._cache[cache_key]
|
||||
|
||||
|
||||
def cache(original_func=None, cachekey=None): # pylint: disable=unused-argument
|
||||
""" Cache decorator. Caches function return values.
|
||||
Requires the parent class to extend and initialize PropertyCache.
|
||||
Usage:
|
||||
# Use function name as cache key
|
||||
@cache
|
||||
def myfunc(args):
|
||||
...
|
||||
|
||||
# Specify cache key
|
||||
@cache(cachekey="foobar")
|
||||
def myfunc(args):
|
||||
...
|
||||
"""
|
||||
|
||||
# Decorators with optional arguments are a bit convoluted in python, see some of the links below for details.
|
||||
|
||||
def cache_decorator(func):
|
||||
# Use 'nonlocal' keyword to access parent function variable:
|
||||
# https://stackoverflow.com/a/14678445/381010
|
||||
nonlocal cachekey
|
||||
if not cachekey:
|
||||
cachekey = func.__name__
|
||||
|
||||
def wrapped(*args):
|
||||
def cache_func_result():
|
||||
# Call decorated function and store its result in the cache
|
||||
args[0]._cache[cachekey] = func(*args)
|
||||
return args[0]._try_cache(cachekey, cache_func_result)
|
||||
|
||||
return wrapped
|
||||
|
||||
# To support optional kwargs for decorators, we need to check if a function is passed as first argument or not.
|
||||
# https://stackoverflow.com/a/24617244/381010
|
||||
if original_func:
|
||||
return cache_decorator(original_func)
|
||||
|
||||
return cache_decorator
|
454
gitlint-core/gitlint/cli.py
Normal file
454
gitlint-core/gitlint/cli.py
Normal file
|
@ -0,0 +1,454 @@
|
|||
# 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
|
||||
import stat
|
||||
import sys
|
||||
import click
|
||||
|
||||
import gitlint
|
||||
from gitlint.lint import GitLinter
|
||||
from gitlint.config import LintConfigBuilder, LintConfigError, LintConfigGenerator
|
||||
from gitlint.git import GitContext, GitContextError, git_version
|
||||
from gitlint import hooks
|
||||
from gitlint.shell import shell
|
||||
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
|
||||
CONFIG_ERROR_CODE = 255
|
||||
|
||||
DEFAULT_CONFIG_FILE = ".gitlint"
|
||||
# -n: disable swap files. This fixes a vim error on windows (E303: Unable to open swap file for <path>)
|
||||
DEFAULT_COMMIT_MSG_EDITOR = "vim -n"
|
||||
|
||||
# Since we use the return code to denote the amount of errors, we need to change the default click usage error code
|
||||
click.UsageError.exit_code = USAGE_ERROR_CODE
|
||||
|
||||
# We don't use logging.getLogger(__main__) here because that will cause DEBUG output to be lost
|
||||
# when invoking gitlint as a python module (python -m gitlint.cli)
|
||||
LOG = logging.getLogger("gitlint.cli")
|
||||
|
||||
|
||||
class GitLintUsageError(GitlintError):
|
||||
""" Exception indicating there is an issue with how gitlint is used. """
|
||||
pass
|
||||
|
||||
|
||||
def setup_logging():
|
||||
""" Setup gitlint logging """
|
||||
root_log = logging.getLogger("gitlint")
|
||||
root_log.propagate = False # Don't propagate to child loggers, the gitlint root logger handles everything
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(LOG_FORMAT)
|
||||
handler.setFormatter(formatter)
|
||||
root_log.addHandler(handler)
|
||||
root_log.setLevel(logging.ERROR)
|
||||
|
||||
|
||||
def log_system_info():
|
||||
LOG.debug("Platform: %s", platform.platform())
|
||||
LOG.debug("Python version: %s", sys.version)
|
||||
LOG.debug("Git version: %s", git_version())
|
||||
LOG.debug("Gitlint version: %s", gitlint.__version__)
|
||||
LOG.debug("GITLINT_USE_SH_LIB: %s", os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]"))
|
||||
LOG.debug("DEFAULT_ENCODING: %s", gitlint.utils.DEFAULT_ENCODING)
|
||||
|
||||
|
||||
def build_config( # pylint: disable=too-many-arguments
|
||||
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()
|
||||
# Config precedence:
|
||||
# First, load default config or config from configfile
|
||||
if config_path:
|
||||
config_builder.set_from_config_file(config_path)
|
||||
elif os.path.exists(DEFAULT_CONFIG_FILE):
|
||||
config_builder.set_from_config_file(DEFAULT_CONFIG_FILE)
|
||||
|
||||
# Then process any commandline configuration flags
|
||||
config_builder.set_config_from_string_list(c)
|
||||
|
||||
# Finally, overwrite with any convenience commandline flags
|
||||
if ignore:
|
||||
config_builder.set_option('general', 'ignore', ignore)
|
||||
|
||||
if contrib:
|
||||
config_builder.set_option('general', 'contrib', contrib)
|
||||
|
||||
if ignore_stdin:
|
||||
config_builder.set_option('general', 'ignore-stdin', ignore_stdin)
|
||||
|
||||
if silent:
|
||||
config_builder.set_option('general', 'verbosity', 0)
|
||||
elif verbose > 0:
|
||||
config_builder.set_option('general', 'verbosity', verbose)
|
||||
|
||||
if extra_path:
|
||||
config_builder.set_option('general', 'extra-path', extra_path)
|
||||
|
||||
if target:
|
||||
config_builder.set_option('general', 'target', target)
|
||||
|
||||
if debug:
|
||||
config_builder.set_option('general', 'debug', debug)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def get_stdin_data():
|
||||
""" Helper function that returns data send to stdin or False if nothing is send """
|
||||
# STDIN can only be 3 different types of things ("modes")
|
||||
# 1. An interactive terminal device (i.e. a TTY -> sys.stdin.isatty() or stat.S_ISCHR)
|
||||
# 2. A (named) pipe (stat.S_ISFIFO)
|
||||
# 3. A regular file (stat.S_ISREG)
|
||||
# Technically, STDIN can also be other device type like a named unix socket (stat.S_ISSOCK), but we don't
|
||||
# support that in gitlint (at least not today).
|
||||
#
|
||||
# Now, the behavior that we want is the following:
|
||||
# If someone sends something directly to gitlint via a pipe or a regular file, read it. If not, read from the
|
||||
# local repository.
|
||||
# Note that we don't care about whether STDIN is a TTY or not, we only care whether data is via a pipe or regular
|
||||
# file.
|
||||
# However, in case STDIN is not a TTY, it HAS to be one of the 2 other things (pipe or regular file), even if
|
||||
# no-one is actually sending anything to gitlint over them. In this case, we still want to read from the local
|
||||
# repository.
|
||||
# To support this use-case (which is common in CI runners such as Jenkins and Gitlab), we need to actually attempt
|
||||
# to read from STDIN in case it's a pipe or regular file. In case that fails, then we'll fall back to reading
|
||||
# from the local repo.
|
||||
|
||||
mode = os.fstat(sys.stdin.fileno()).st_mode
|
||||
stdin_is_pipe_or_file = stat.S_ISFIFO(mode) or stat.S_ISREG(mode)
|
||||
if stdin_is_pipe_or_file:
|
||||
input_data = sys.stdin.read()
|
||||
# Only return the input data if there's actually something passed
|
||||
# i.e. don't consider empty piped data
|
||||
if input_data:
|
||||
return str(input_data)
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
from_commit_msg = GitContext.from_commit_msg
|
||||
if lint_config.staged:
|
||||
LOG.debug("Fetching additional meta-data from staged commit")
|
||||
from_commit_msg = lambda message: GitContext.from_staged_commit(message, lint_config.target) # noqa
|
||||
|
||||
# Order of precedence:
|
||||
# 1. Any data specified via --msg-filename
|
||||
if msg_filename:
|
||||
LOG.debug("Using --msg-filename.")
|
||||
return from_commit_msg(str(msg_filename.read()))
|
||||
|
||||
# 2. Any data sent to stdin (unless stdin is being ignored)
|
||||
if not lint_config.ignore_stdin:
|
||||
stdin_input = get_stdin_data()
|
||||
if stdin_input:
|
||||
LOG.debug("Stdin data: '%s'", stdin_input)
|
||||
LOG.debug("Stdin detected and not ignored. Using as input.")
|
||||
return from_commit_msg(stdin_input)
|
||||
|
||||
if lint_config.staged:
|
||||
raise GitLintUsageError("The 'staged' option (--staged) can only be used when using '--msg-filename' or "
|
||||
"when piping data to gitlint via stdin.")
|
||||
|
||||
# 3. Fallback to reading from local repository
|
||||
LOG.debug("No --msg-filename flag, no or empty data passed to stdin. Using the local repo.")
|
||||
|
||||
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):
|
||||
""" Helper function to handle exceptions """
|
||||
if isinstance(exc, GitContextError):
|
||||
click.echo(exc)
|
||||
ctx.exit(GIT_CONTEXT_ERROR_CODE)
|
||||
elif isinstance(exc, GitLintUsageError):
|
||||
click.echo(f"Error: {exc}")
|
||||
ctx.exit(USAGE_ERROR_CODE)
|
||||
elif isinstance(exc, LintConfigError):
|
||||
click.echo(f"Config Error: {exc}")
|
||||
ctx.exit(CONFIG_ERROR_CODE)
|
||||
|
||||
|
||||
class ContextObj:
|
||||
""" Simple class to hold data that is passed between Click commands via the Click context. """
|
||||
|
||||
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
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True, context_settings={'max_content_width': 120},
|
||||
epilog="When no COMMAND is specified, gitlint defaults to 'gitlint lint'.")
|
||||
@click.option('--target', envvar='GITLINT_TARGET',
|
||||
type=click.Path(exists=True, resolve_path=True, file_okay=False, readable=True),
|
||||
help="Path of the target git repository. [default: current working directory]")
|
||||
@click.option('-C', '--config', type=click.Path(exists=True, dir_okay=False, readable=True, resolve_path=True),
|
||||
help=f"Config file location [default: {DEFAULT_CONFIG_FILE}]")
|
||||
@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",
|
||||
type=click.Path(exists=True, resolve_path=True, readable=True))
|
||||
@click.option('--ignore', envvar='GITLINT_IGNORE', default="", help="Ignore rules (comma-separated by id or name).")
|
||||
@click.option('--contrib', envvar='GITLINT_CONTRIB', default="",
|
||||
help="Contrib rules to enable (comma-separated by id or name).")
|
||||
@click.option('--msg-filename', type=click.File(), help="Path to a file containing a commit-msg.")
|
||||
@click.option('--ignore-stdin', envvar='GITLINT_IGNORE_STDIN', is_flag=True,
|
||||
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,
|
||||
help="Silent mode (no output). Takes precedence over -v, -vv, -vvv.")
|
||||
@click.option('-d', '--debug', envvar='GITLINT_DEBUG', help="Enable debugging output.", is_flag=True)
|
||||
@click.version_option(version=gitlint.__version__)
|
||||
@click.pass_context
|
||||
def cli( # pylint: disable=too-many-arguments
|
||||
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
|
||||
|
||||
Documentation: http://jorisroovers.github.io/gitlint
|
||||
"""
|
||||
|
||||
try:
|
||||
if debug:
|
||||
logging.getLogger("gitlint").setLevel(logging.DEBUG)
|
||||
LOG.debug("To report issues, please visit https://github.com/jorisroovers/gitlint/issues")
|
||||
|
||||
log_system_info()
|
||||
|
||||
# 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,
|
||||
fail_without_commits, verbose, silent, debug)
|
||||
LOG.debug("Configuration\n%s", config)
|
||||
|
||||
ctx.obj = ContextObj(config, config_builder, commit, commits, msg_filename)
|
||||
|
||||
# If no subcommand is specified, then just lint
|
||||
if ctx.invoked_subcommand is None:
|
||||
ctx.invoke(lint)
|
||||
|
||||
except GitlintError as e:
|
||||
handle_gitlint_error(ctx, e)
|
||||
|
||||
|
||||
@cli.command("lint")
|
||||
@click.pass_context
|
||||
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, 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
|
||||
|
||||
number_of_commits = len(gitcontext.commits)
|
||||
# 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('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('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 = 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()
|
||||
config_builder.set_config_from_commit(commit)
|
||||
|
||||
# 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)
|
||||
if violations:
|
||||
# Display the commit hash & new lines intelligently
|
||||
if number_of_commits > 1 and commit.sha:
|
||||
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
|
||||
|
||||
# cap actual max exit code because bash doesn't like exit codes larger than 255:
|
||||
# http://tldp.org/LDP/abs/html/exitcodes.html
|
||||
exit_code = min(MAX_VIOLATION_ERROR_CODE, exit_code)
|
||||
LOG.debug("Exit Code = %s", exit_code)
|
||||
ctx.exit(exit_code)
|
||||
|
||||
|
||||
@cli.command("install-hook")
|
||||
@click.pass_context
|
||||
def install_hook(ctx):
|
||||
""" Install gitlint as a git commit-msg hook. """
|
||||
try:
|
||||
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(GITLINT_SUCCESS)
|
||||
except hooks.GitHookInstallerError as e:
|
||||
click.echo(e, err=True)
|
||||
ctx.exit(GIT_CONTEXT_ERROR_CODE)
|
||||
|
||||
|
||||
@cli.command("uninstall-hook")
|
||||
@click.pass_context
|
||||
def uninstall_hook(ctx):
|
||||
""" Uninstall gitlint commit-msg hook. """
|
||||
try:
|
||||
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(GITLINT_SUCCESS)
|
||||
except hooks.GitHookInstallerError as e:
|
||||
click.echo(e, err=True)
|
||||
ctx.exit(GIT_CONTEXT_ERROR_CODE)
|
||||
|
||||
|
||||
@cli.command("run-hook")
|
||||
@click.pass_context
|
||||
def run_hook(ctx):
|
||||
""" Runs the gitlint commit-msg hook. """
|
||||
|
||||
exit_code = 1
|
||||
while exit_code > 0:
|
||||
try:
|
||||
click.echo("gitlint: checking commit message...")
|
||||
ctx.invoke(lint)
|
||||
except GitlintError as e:
|
||||
handle_gitlint_error(ctx, e)
|
||||
except click.exceptions.Exit as e:
|
||||
# Flush stderr andstdout, this resolves an issue with output ordering in Cygwin
|
||||
sys.stderr.flush()
|
||||
sys.stdout.flush()
|
||||
|
||||
exit_code = e.exit_code
|
||||
if exit_code == GITLINT_SUCCESS:
|
||||
click.echo("gitlint: " + click.style("OK", fg='green') + " (no violations in commit message)")
|
||||
continue
|
||||
|
||||
click.echo("-----------------------------------------------")
|
||||
click.echo("gitlint: " + click.style("Your commit message contains violations.", fg='red'))
|
||||
|
||||
value = None
|
||||
while value not in ["y", "n", "e"]:
|
||||
click.echo("Continue with commit anyways (this keeps the current commit message)? "
|
||||
"[y(es)/n(no)/e(dit)] ", nl=False)
|
||||
|
||||
# Ideally, we'd want to use click.getchar() or click.prompt() to get user's input here instead of
|
||||
# input(). However, those functions currently don't support getting answers from stdin.
|
||||
# This wouldn't be a huge issue since this is unlikely to occur in the real world,
|
||||
# were it not that we use a stdin to pipe answers into gitlint in our integration tests.
|
||||
# If that ever changes, we can revisit this.
|
||||
# Related click pointers:
|
||||
# - https://github.com/pallets/click/issues/1370
|
||||
# - https://github.com/pallets/click/pull/1372
|
||||
# - From https://click.palletsprojects.com/en/7.x/utils/#getting-characters-from-terminal
|
||||
# Note that this function will always read from the terminal, even if stdin is instead a pipe.
|
||||
value = input()
|
||||
|
||||
if value == "y":
|
||||
LOG.debug("run-hook: commit message accepted")
|
||||
exit_code = GITLINT_SUCCESS
|
||||
elif value == "e":
|
||||
LOG.debug("run-hook: editing commit message")
|
||||
msg_filename = ctx.obj.msg_filename
|
||||
if msg_filename:
|
||||
msg_filename.seek(0)
|
||||
editor = os.environ.get("EDITOR", DEFAULT_COMMIT_MSG_EDITOR)
|
||||
msg_filename_path = os.path.realpath(msg_filename.name)
|
||||
LOG.debug("run-hook: %s %s", editor, msg_filename_path)
|
||||
shell(editor + " " + msg_filename_path)
|
||||
else:
|
||||
click.echo("Editing only possible when --msg-filename is specified.")
|
||||
ctx.exit(exit_code)
|
||||
elif value == "n":
|
||||
LOG.debug("run-hook: commit message declined")
|
||||
click.echo("Commit aborted.")
|
||||
click.echo("Your commit message: ")
|
||||
click.echo("-----------------------------------------------")
|
||||
click.echo(ctx.obj.gitcontext.commits[0].message.full)
|
||||
click.echo("-----------------------------------------------")
|
||||
ctx.exit(exit_code)
|
||||
|
||||
ctx.exit(exit_code)
|
||||
|
||||
|
||||
@cli.command("generate-config")
|
||||
@click.pass_context
|
||||
def generate_config(ctx):
|
||||
""" Generates a sample gitlint config file. """
|
||||
path = click.prompt('Please specify a location for the sample gitlint config file', default=DEFAULT_CONFIG_FILE)
|
||||
path = os.path.realpath(path)
|
||||
dir_name = os.path.dirname(path)
|
||||
if not os.path.exists(dir_name):
|
||||
click.echo(f"Error: Directory '{dir_name}' does not exist.", err=True)
|
||||
ctx.exit(USAGE_ERROR_CODE)
|
||||
elif os.path.exists(path):
|
||||
click.echo(f"Error: File \"{path}\" already exists.", err=True)
|
||||
ctx.exit(USAGE_ERROR_CODE)
|
||||
|
||||
LintConfigGenerator.generate_config(path)
|
||||
click.echo(f"Successfully generated {path}")
|
||||
ctx.exit(GITLINT_SUCCESS)
|
||||
|
||||
|
||||
# Let's Party!
|
||||
setup_logging()
|
||||
if __name__ == "__main__":
|
||||
# pylint: disable=no-value-for-parameter
|
||||
cli() # pragma: no cover
|
528
gitlint-core/gitlint/config.py
Normal file
528
gitlint-core/gitlint/config.py
Normal file
|
@ -0,0 +1,528 @@
|
|||
from configparser import ConfigParser, Error as ConfigParserError
|
||||
|
||||
import copy
|
||||
import io
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from collections import OrderedDict
|
||||
from gitlint.utils import DEFAULT_ENCODING
|
||||
from gitlint import rules # For some weird reason pylint complains about this, pylint: disable=unused-import
|
||||
from gitlint import options
|
||||
from gitlint import rule_finder
|
||||
from gitlint.contrib import rules as contrib_rules
|
||||
from gitlint.exception import GitlintError
|
||||
|
||||
|
||||
def handle_option_error(func):
|
||||
""" Decorator that calls given method/function and handles any RuleOptionError gracefully by converting it to a
|
||||
LintConfigError. """
|
||||
|
||||
def wrapped(*args):
|
||||
try:
|
||||
return func(*args)
|
||||
except options.RuleOptionError as e:
|
||||
raise LintConfigError(str(e)) from e
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class LintConfigError(GitlintError):
|
||||
pass
|
||||
|
||||
|
||||
class LintConfig:
|
||||
""" Class representing gitlint configuration.
|
||||
Contains active config as well as number of methods to easily get/set the config.
|
||||
"""
|
||||
|
||||
# Default tuple of rule classes (tuple because immutable).
|
||||
default_rule_classes = (rules.IgnoreByTitle,
|
||||
rules.IgnoreByBody,
|
||||
rules.IgnoreBodyLines,
|
||||
rules.IgnoreByAuthorName,
|
||||
rules.TitleMaxLength,
|
||||
rules.TitleTrailingWhitespace,
|
||||
rules.TitleLeadingWhitespace,
|
||||
rules.TitleTrailingPunctuation,
|
||||
rules.TitleHardTab,
|
||||
rules.TitleMustNotContainWord,
|
||||
rules.TitleRegexMatches,
|
||||
rules.TitleMinLength,
|
||||
rules.BodyMaxLineLength,
|
||||
rules.BodyMinLength,
|
||||
rules.BodyMissing,
|
||||
rules.BodyTrailingWhitespace,
|
||||
rules.BodyHardTab,
|
||||
rules.BodyFirstLineEmpty,
|
||||
rules.BodyChangedFileMention,
|
||||
rules.BodyRegexMatches,
|
||||
rules.AuthorValidEmail)
|
||||
|
||||
def __init__(self):
|
||||
self.rules = RuleCollection(self.default_rule_classes)
|
||||
self._verbosity = options.IntOption('verbosity', 3, "Verbosity")
|
||||
self._ignore_merge_commits = options.BoolOption('ignore-merge-commits', True, "Ignore merge commits")
|
||||
self._ignore_fixup_commits = options.BoolOption('ignore-fixup-commits', True, "Ignore fixup commits")
|
||||
self._ignore_squash_commits = options.BoolOption('ignore-squash-commits', True, "Ignore squash commits")
|
||||
self._ignore_revert_commits = options.BoolOption('ignore-revert-commits', True, "Ignore revert commits")
|
||||
self._debug = options.BoolOption('debug', False, "Enable debug mode")
|
||||
self._extra_path = None
|
||||
target_description = "Path of the target git repository (default=current working directory)"
|
||||
self._target = options.PathOption('target', os.path.realpath(os.getcwd()), target_description)
|
||||
self._ignore = options.ListOption('ignore', [], 'List of rule-ids to ignore')
|
||||
self._contrib = options.ListOption('contrib', [], 'List of contrib-rules to enable')
|
||||
self._config_path = None
|
||||
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):
|
||||
return self._target.value if self._target else None
|
||||
|
||||
@target.setter
|
||||
@handle_option_error
|
||||
def target(self, value):
|
||||
return self._target.set(value)
|
||||
|
||||
@property
|
||||
def verbosity(self):
|
||||
return self._verbosity.value
|
||||
|
||||
@verbosity.setter
|
||||
@handle_option_error
|
||||
def verbosity(self, value):
|
||||
self._verbosity.set(value)
|
||||
if self.verbosity < 0 or self.verbosity > 3:
|
||||
raise LintConfigError("Option 'verbosity' must be set between 0 and 3")
|
||||
|
||||
@property
|
||||
def ignore_merge_commits(self):
|
||||
return self._ignore_merge_commits.value
|
||||
|
||||
@ignore_merge_commits.setter
|
||||
@handle_option_error
|
||||
def ignore_merge_commits(self, value):
|
||||
return self._ignore_merge_commits.set(value)
|
||||
|
||||
@property
|
||||
def ignore_fixup_commits(self):
|
||||
return self._ignore_fixup_commits.value
|
||||
|
||||
@ignore_fixup_commits.setter
|
||||
@handle_option_error
|
||||
def ignore_fixup_commits(self, value):
|
||||
return self._ignore_fixup_commits.set(value)
|
||||
|
||||
@property
|
||||
def ignore_squash_commits(self):
|
||||
return self._ignore_squash_commits.value
|
||||
|
||||
@ignore_squash_commits.setter
|
||||
@handle_option_error
|
||||
def ignore_squash_commits(self, value):
|
||||
return self._ignore_squash_commits.set(value)
|
||||
|
||||
@property
|
||||
def ignore_revert_commits(self):
|
||||
return self._ignore_revert_commits.value
|
||||
|
||||
@ignore_revert_commits.setter
|
||||
@handle_option_error
|
||||
def ignore_revert_commits(self, value):
|
||||
return self._ignore_revert_commits.set(value)
|
||||
|
||||
@property
|
||||
def debug(self):
|
||||
return self._debug.value
|
||||
|
||||
@debug.setter
|
||||
@handle_option_error
|
||||
def debug(self, value):
|
||||
return self._debug.set(value)
|
||||
|
||||
@property
|
||||
def ignore(self):
|
||||
return self._ignore.value
|
||||
|
||||
@ignore.setter
|
||||
def ignore(self, value):
|
||||
if value == "all":
|
||||
value = [rule.id for rule in self.rules]
|
||||
return self._ignore.set(value)
|
||||
|
||||
@property
|
||||
def ignore_stdin(self):
|
||||
return self._ignore_stdin.value
|
||||
|
||||
@ignore_stdin.setter
|
||||
@handle_option_error
|
||||
def ignore_stdin(self, value):
|
||||
return self._ignore_stdin.set(value)
|
||||
|
||||
@property
|
||||
def staged(self):
|
||||
return self._staged.value
|
||||
|
||||
@staged.setter
|
||||
@handle_option_error
|
||||
def staged(self, value):
|
||||
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
|
||||
|
||||
@extra_path.setter
|
||||
def extra_path(self, value):
|
||||
try:
|
||||
if self.extra_path:
|
||||
self._extra_path.set(value)
|
||||
else:
|
||||
self._extra_path = options.PathOption(
|
||||
'extra-path', value,
|
||||
"Path to a directory or module with extra user-defined rules",
|
||||
type='both'
|
||||
)
|
||||
|
||||
# Make sure we unload any previously loaded extra-path rules
|
||||
self.rules.delete_rules_by_attr("is_user_defined", True)
|
||||
|
||||
# Find rules in the new extra-path and add them to the existing rules
|
||||
rule_classes = rule_finder.find_rule_classes(self.extra_path)
|
||||
self.rules.add_rules(rule_classes, {'is_user_defined': True})
|
||||
|
||||
except (options.RuleOptionError, rules.UserRuleError) as e:
|
||||
raise LintConfigError(str(e)) from e
|
||||
|
||||
@property
|
||||
def contrib(self):
|
||||
return self._contrib.value
|
||||
|
||||
@contrib.setter
|
||||
def contrib(self, value):
|
||||
try:
|
||||
self._contrib.set(value)
|
||||
|
||||
# Make sure we unload any previously loaded contrib rules when re-setting the value
|
||||
self.rules.delete_rules_by_attr("is_contrib", True)
|
||||
|
||||
# Load all classes from the contrib directory
|
||||
contrib_dir_path = os.path.dirname(os.path.realpath(contrib_rules.__file__))
|
||||
rule_classes = rule_finder.find_rule_classes(contrib_dir_path)
|
||||
|
||||
# For each specified contrib rule, check whether it exists among the contrib classes
|
||||
for rule_id_or_name in self.contrib:
|
||||
rule_class = next((rc for rc in rule_classes if
|
||||
rule_id_or_name in (rc.id, rc.name)), False)
|
||||
|
||||
# If contrib rule exists, instantiate it and add it to the rules list
|
||||
if rule_class:
|
||||
self.rules.add_rule(rule_class, rule_class.id, {'is_contrib': True})
|
||||
else:
|
||||
raise LintConfigError(f"No contrib rule with id or name '{rule_id_or_name}' found.")
|
||||
|
||||
except (options.RuleOptionError, rules.UserRuleError) as e:
|
||||
raise LintConfigError(str(e)) from e
|
||||
|
||||
def _get_option(self, rule_name_or_id, option_name):
|
||||
rule = self.rules.find_rule(rule_name_or_id)
|
||||
if not rule:
|
||||
raise LintConfigError(f"No such rule '{rule_name_or_id}'")
|
||||
|
||||
option = rule.options.get(option_name)
|
||||
if not option:
|
||||
raise LintConfigError(f"Rule '{rule_name_or_id}' has no option '{option_name}'")
|
||||
|
||||
return option
|
||||
|
||||
def get_rule_option(self, rule_name_or_id, option_name):
|
||||
""" Returns the value of a given option for a given rule. LintConfigErrors will be raised if the
|
||||
rule or option don't exist. """
|
||||
option = self._get_option(rule_name_or_id, option_name)
|
||||
return option.value
|
||||
|
||||
def set_rule_option(self, rule_name_or_id, option_name, option_value):
|
||||
""" Attempts to set a given value for a given option for a given rule.
|
||||
LintConfigErrors will be raised if the rule or option don't exist or if the value is invalid. """
|
||||
option = self._get_option(rule_name_or_id, option_name)
|
||||
try:
|
||||
option.set(option_value)
|
||||
except options.RuleOptionError as e:
|
||||
msg = f"'{option_value}' is not a valid value for option '{rule_name_or_id}.{option_name}'. {e}."
|
||||
raise LintConfigError(msg) from e
|
||||
|
||||
def set_general_option(self, option_name, option_value):
|
||||
attr_name = option_name.replace("-", "_")
|
||||
# only allow setting general options that exist and don't start with an underscore
|
||||
if not hasattr(self, attr_name) or attr_name[0] == "_":
|
||||
raise LintConfigError(f"'{option_name}' is not a valid gitlint option")
|
||||
|
||||
# else:
|
||||
setattr(self, attr_name, option_value)
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, LintConfig) and \
|
||||
self.rules == other.rules and \
|
||||
self.verbosity == other.verbosity and \
|
||||
self.target == other.target and \
|
||||
self.extra_path == other.extra_path and \
|
||||
self.contrib == other.contrib and \
|
||||
self.ignore_merge_commits == other.ignore_merge_commits and \
|
||||
self.ignore_fixup_commits == other.ignore_fixup_commits and \
|
||||
self.ignore_squash_commits == other.ignore_squash_commits and \
|
||||
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
|
||||
|
||||
def __str__(self):
|
||||
# config-path is not a user exposed variable, so don't print it under the general section
|
||||
return (f"config-path: {self._config_path}\n"
|
||||
f"[GENERAL]\n"
|
||||
f"extra-path: {self.extra_path}\n"
|
||||
f"contrib: {self.contrib}\n"
|
||||
f"ignore: {','.join(self.ignore)}\n"
|
||||
f"ignore-merge-commits: {self.ignore_merge_commits}\n"
|
||||
f"ignore-fixup-commits: {self.ignore_fixup_commits}\n"
|
||||
f"ignore-squash-commits: {self.ignore_squash_commits}\n"
|
||||
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"
|
||||
f"[RULES]\n{self.rules}")
|
||||
|
||||
|
||||
class RuleCollection:
|
||||
""" Class representing an ordered list of rules. Methods are provided to easily retrieve, add or delete rules. """
|
||||
|
||||
def __init__(self, rule_classes=None, rule_attrs=None):
|
||||
# Use an ordered dict so that the order in which rules are applied is always the same
|
||||
self._rules = OrderedDict()
|
||||
if rule_classes:
|
||||
self.add_rules(rule_classes, rule_attrs)
|
||||
|
||||
def find_rule(self, rule_id_or_name):
|
||||
rule = self._rules.get(rule_id_or_name)
|
||||
# if not found, try finding rule by name
|
||||
if not rule:
|
||||
rule = next((rule for rule in self._rules.values() if rule.name == rule_id_or_name), None)
|
||||
return rule
|
||||
|
||||
def add_rule(self, rule_class, rule_id, rule_attrs=None):
|
||||
""" Instantiates and adds a rule to RuleCollection.
|
||||
Note: There can be multiple instantiations of the same rule_class in the RuleCollection, as long as the
|
||||
rule_id is unique.
|
||||
:param rule_class python class representing the rule
|
||||
:param rule_id unique identifier for the rule. If not unique, it will
|
||||
overwrite the existing rule with that id
|
||||
:param rule_attrs dictionary of attributes to set on the instantiated rule obj
|
||||
"""
|
||||
rule_obj = rule_class()
|
||||
rule_obj.id = rule_id
|
||||
if rule_attrs:
|
||||
for key, val in rule_attrs.items():
|
||||
setattr(rule_obj, key, val)
|
||||
self._rules[rule_obj.id] = rule_obj
|
||||
|
||||
def add_rules(self, rule_classes, rule_attrs=None):
|
||||
""" Convenience method to add multiple rules at once based on a list of rule classes. """
|
||||
for rule_class in rule_classes:
|
||||
self.add_rule(rule_class, rule_class.id, rule_attrs)
|
||||
|
||||
def delete_rules_by_attr(self, attr_name, attr_val):
|
||||
""" Deletes all rules from the collection that match a given attribute name and value """
|
||||
# Create a new list based on _rules.values() because in python 3, values() is a ValuesView as opposed to a list
|
||||
# This means you can't modify the ValueView while iterating over it.
|
||||
for rule in [r for r in self._rules.values()]: # pylint: disable=unnecessary-comprehension
|
||||
if hasattr(rule, attr_name) and (getattr(rule, attr_name) == attr_val):
|
||||
del self._rules[rule.id]
|
||||
|
||||
def __iter__(self):
|
||||
for rule in self._rules.values():
|
||||
yield rule
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, RuleCollection) and self._rules == other._rules
|
||||
|
||||
def __len__(self):
|
||||
return len(self._rules)
|
||||
|
||||
def __str__(self):
|
||||
return_str = ""
|
||||
for rule in self._rules.values():
|
||||
return_str += f" {rule.id}: {rule.name}\n"
|
||||
for option_name, option_value in sorted(rule.options.items()):
|
||||
if option_value.value is None:
|
||||
option_val_repr = None
|
||||
elif isinstance(option_value.value, list):
|
||||
option_val_repr = ",".join(option_value.value)
|
||||
elif isinstance(option_value, options.RegexOption):
|
||||
option_val_repr = option_value.value.pattern
|
||||
else:
|
||||
option_val_repr = option_value.value
|
||||
return_str += f" {option_name}={option_val_repr}\n"
|
||||
return return_str
|
||||
|
||||
|
||||
class LintConfigBuilder:
|
||||
""" Factory class that can build gitlint config.
|
||||
This is primarily useful to deal with complex configuration scenarios where configuration can be set and overridden
|
||||
from various sources (typically according to certain precedence rules) before the actual config should be
|
||||
normalized, validated and build. Example usage can be found in gitlint.cli.
|
||||
"""
|
||||
|
||||
RULE_QUALIFIER_SYMBOL = ":"
|
||||
|
||||
def __init__(self):
|
||||
self._config_blueprint = OrderedDict()
|
||||
self._config_path = None
|
||||
|
||||
def set_option(self, section, option_name, option_value):
|
||||
if section not in self._config_blueprint:
|
||||
self._config_blueprint[section] = OrderedDict()
|
||||
self._config_blueprint[section][option_name] = option_value
|
||||
|
||||
def set_config_from_commit(self, commit):
|
||||
""" Given a git commit, applies config specified in the commit message.
|
||||
Supported:
|
||||
- gitlint-ignore: all
|
||||
"""
|
||||
for line in commit.message.body:
|
||||
pattern = re.compile(r"^gitlint-ignore:\s*(.*)")
|
||||
matches = pattern.match(line)
|
||||
if matches and len(matches.groups()) == 1:
|
||||
self.set_option('general', 'ignore', matches.group(1))
|
||||
|
||||
def set_config_from_string_list(self, config_options):
|
||||
""" Given a list of config options of the form "<rule>.<option>=<value>", parses out the correct rule and option
|
||||
and sets the value accordingly in this factory object. """
|
||||
for config_option in config_options:
|
||||
try:
|
||||
config_name, option_value = config_option.split("=", 1)
|
||||
if not option_value:
|
||||
raise ValueError()
|
||||
rule_name, option_name = config_name.split(".", 1)
|
||||
self.set_option(rule_name, option_name, option_value)
|
||||
except ValueError as e: # raised if the config string is invalid
|
||||
raise LintConfigError(
|
||||
f"'{config_option}' is an invalid configuration option. Use '<rule>.<option>=<value>'") from e
|
||||
|
||||
def set_from_config_file(self, filename):
|
||||
""" Loads lint config from an ini-style config file """
|
||||
if not os.path.exists(filename):
|
||||
raise LintConfigError(f"Invalid file path: {filename}")
|
||||
self._config_path = os.path.realpath(filename)
|
||||
try:
|
||||
parser = ConfigParser()
|
||||
|
||||
with io.open(filename, encoding=DEFAULT_ENCODING) as config_file:
|
||||
parser.read_file(config_file, filename)
|
||||
|
||||
for section_name in parser.sections():
|
||||
for option_name, option_value in parser.items(section_name):
|
||||
self.set_option(section_name, option_name, str(option_value))
|
||||
|
||||
except ConfigParserError as e:
|
||||
raise LintConfigError(str(e)) from e
|
||||
|
||||
def _add_named_rule(self, config, qualified_rule_name):
|
||||
""" Adds a Named Rule to a given LintConfig object.
|
||||
IMPORTANT: This method does *NOT* overwrite existing Named Rules with the same canonical id.
|
||||
"""
|
||||
|
||||
# Split up named rule in its parts: the name/id that specifies the parent rule,
|
||||
# And the name of the rule instance itself
|
||||
rule_name_parts = qualified_rule_name.split(self.RULE_QUALIFIER_SYMBOL, 1)
|
||||
rule_name = rule_name_parts[1].strip()
|
||||
parent_rule_specifier = rule_name_parts[0].strip()
|
||||
|
||||
# assert that the rule name is valid:
|
||||
# - not empty
|
||||
# - no whitespace or colons
|
||||
if rule_name == "" or bool(re.search("\\s|:", rule_name, re.UNICODE)):
|
||||
msg = f"The rule-name part in '{qualified_rule_name}' cannot contain whitespace, colons or be empty"
|
||||
raise LintConfigError(msg)
|
||||
|
||||
# find parent rule
|
||||
parent_rule = config.rules.find_rule(parent_rule_specifier)
|
||||
if not parent_rule:
|
||||
msg = f"No such rule '{parent_rule_specifier}' (named rule: '{qualified_rule_name}')"
|
||||
raise LintConfigError(msg)
|
||||
|
||||
# Determine canonical id and name by recombining the parent id/name and instance name parts.
|
||||
canonical_id = parent_rule.__class__.id + self.RULE_QUALIFIER_SYMBOL + rule_name
|
||||
canonical_name = parent_rule.__class__.name + self.RULE_QUALIFIER_SYMBOL + rule_name
|
||||
|
||||
# Add the rule to the collection of rules if it's not there already
|
||||
if not config.rules.find_rule(canonical_id):
|
||||
config.rules.add_rule(parent_rule.__class__, canonical_id, {'is_named': True, 'name': canonical_name})
|
||||
|
||||
return canonical_id
|
||||
|
||||
def build(self, config=None):
|
||||
""" Build a real LintConfig object by normalizing and validating the options that were previously set on this
|
||||
factory. """
|
||||
# If we are passed a config object, then rebuild that object instead of building a new lintconfig object from
|
||||
# scratch
|
||||
if not config:
|
||||
config = LintConfig()
|
||||
|
||||
config._config_path = self._config_path
|
||||
|
||||
# Set general options first as this might change the behavior or validity of the other options
|
||||
general_section = self._config_blueprint.get('general')
|
||||
if general_section:
|
||||
for option_name, option_value in general_section.items():
|
||||
config.set_general_option(option_name, option_value)
|
||||
|
||||
for section_name, section_dict in self._config_blueprint.items():
|
||||
for option_name, option_value in section_dict.items():
|
||||
# Skip over the general section, as we've already done that above
|
||||
if section_name != "general":
|
||||
|
||||
# If the section name contains a colon (:), then this section is defining a Named Rule
|
||||
# Which means we need to instantiate that Named Rule in the config.
|
||||
if self.RULE_QUALIFIER_SYMBOL in section_name:
|
||||
section_name = self._add_named_rule(config, section_name)
|
||||
|
||||
config.set_rule_option(section_name, option_name, option_value)
|
||||
|
||||
return config
|
||||
|
||||
def clone(self):
|
||||
""" Creates an exact copy of a LintConfigBuilder. """
|
||||
builder = LintConfigBuilder()
|
||||
builder._config_blueprint = copy.deepcopy(self._config_blueprint)
|
||||
builder._config_path = self._config_path
|
||||
return builder
|
||||
|
||||
|
||||
GITLINT_CONFIG_TEMPLATE_SRC_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "files/gitlint")
|
||||
|
||||
|
||||
class LintConfigGenerator:
|
||||
@staticmethod
|
||||
def generate_config(dest):
|
||||
""" Generates a gitlint config file at the given destination location.
|
||||
Expects that the given ```dest``` points to a valid destination. """
|
||||
shutil.copyfile(GITLINT_CONFIG_TEMPLATE_SRC_PATH, dest)
|
0
gitlint-core/gitlint/contrib/__init__.py
Normal file
0
gitlint-core/gitlint/contrib/__init__.py
Normal file
0
gitlint-core/gitlint/contrib/rules/__init__.py
Normal file
0
gitlint-core/gitlint/contrib/rules/__init__.py
Normal file
37
gitlint-core/gitlint/contrib/rules/conventional_commit.py
Normal file
37
gitlint-core/gitlint/contrib/rules/conventional_commit.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import re
|
||||
|
||||
from gitlint.options import ListOption
|
||||
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
||||
|
||||
RULE_REGEX = re.compile(r"([^(]+?)(\([^)]+?\))?!?: .+")
|
||||
|
||||
|
||||
class ConventionalCommit(LineRule):
|
||||
""" This rule enforces the spec at https://www.conventionalcommits.org/. """
|
||||
|
||||
name = "contrib-title-conventional-commits"
|
||||
id = "CT1"
|
||||
target = CommitMessageTitle
|
||||
|
||||
options_spec = [
|
||||
ListOption(
|
||||
"types",
|
||||
["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert", "ci", "build"],
|
||||
"Comma separated list of allowed commit types.",
|
||||
)
|
||||
]
|
||||
|
||||
def validate(self, line, _commit):
|
||||
violations = []
|
||||
match = 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
|
18
gitlint-core/gitlint/contrib/rules/signedoff_by.py
Normal file
18
gitlint-core/gitlint/contrib/rules/signedoff_by.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
|
||||
from gitlint.rules import CommitRule, RuleViolation
|
||||
|
||||
|
||||
class SignedOffBy(CommitRule):
|
||||
""" This rule will enforce that each commit body contains a "Signed-off-by" line.
|
||||
We keep things simple here and just check whether the commit body contains a line that starts with "Signed-off-by".
|
||||
"""
|
||||
|
||||
name = "contrib-body-requires-signed-off-by"
|
||||
id = "CC1"
|
||||
|
||||
def validate(self, commit):
|
||||
for line in commit.message.body:
|
||||
if line.lower().startswith("signed-off-by"):
|
||||
return []
|
||||
|
||||
return [RuleViolation(self.id, "Body does not contain a 'Signed-off-by' line", line_nr=1)]
|
36
gitlint-core/gitlint/display.py
Normal file
36
gitlint-core/gitlint/display.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
from sys import stdout, stderr
|
||||
|
||||
|
||||
class Display:
|
||||
""" Utility class to print stuff to an output stream (stdout by default) based on the config's verbosity """
|
||||
|
||||
def __init__(self, lint_config):
|
||||
self.config = lint_config
|
||||
|
||||
def _output(self, message, verbosity, exact, stream):
|
||||
""" Output a message if the config's verbosity is >= to the given verbosity. If exact == True, the message
|
||||
will only be outputted if the given verbosity exactly matches the config's verbosity. """
|
||||
if exact:
|
||||
if self.config.verbosity == verbosity:
|
||||
stream.write(message + "\n")
|
||||
else:
|
||||
if self.config.verbosity >= verbosity:
|
||||
stream.write(message + "\n")
|
||||
|
||||
def v(self, message, exact=False): # pylint: disable=invalid-name
|
||||
self._output(message, 1, exact, stdout)
|
||||
|
||||
def vv(self, message, exact=False): # pylint: disable=invalid-name
|
||||
self._output(message, 2, exact, stdout)
|
||||
|
||||
def vvv(self, message, exact=False): # pylint: disable=invalid-name
|
||||
self._output(message, 3, exact, stdout)
|
||||
|
||||
def e(self, message, exact=False): # pylint: disable=invalid-name
|
||||
self._output(message, 1, exact, stderr)
|
||||
|
||||
def ee(self, message, exact=False): # pylint: disable=invalid-name
|
||||
self._output(message, 2, exact, stderr)
|
||||
|
||||
def eee(self, message, exact=False): # pylint: disable=invalid-name
|
||||
self._output(message, 3, exact, stderr)
|
4
gitlint-core/gitlint/exception.py
Normal file
4
gitlint-core/gitlint/exception.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
class GitlintError(Exception):
|
||||
""" Based Exception class for all gitlint exceptions """
|
||||
pass
|
35
gitlint-core/gitlint/files/commit-msg
Normal file
35
gitlint-core/gitlint/files/commit-msg
Normal file
|
@ -0,0 +1,35 @@
|
|||
#!/bin/sh
|
||||
### gitlint commit-msg hook start ###
|
||||
|
||||
# Determine whether we have a tty available by trying to access it.
|
||||
# This allows us to deal with UI based gitclient's like Atlassian SourceTree.
|
||||
# NOTE: "exec < /dev/tty" sets stdin to the keyboard
|
||||
stdin_available=1
|
||||
(exec < /dev/tty) 2> /dev/null || stdin_available=0
|
||||
|
||||
if [ $stdin_available -eq 1 ]; then
|
||||
# Now that we know we have a functional tty, set stdin to it so we can ask the user questions :-)
|
||||
exec < /dev/tty
|
||||
|
||||
# On Windows, we need to explicitly set our stdout to the tty to make terminal editing work (e.g. vim)
|
||||
# See SO for windows detection in bash (slight modified to work on plain shell (not bash)):
|
||||
# https://stackoverflow.com/questions/394230/how-to-detect-the-os-from-a-bash-script
|
||||
if [ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ] || [ "$OSTYPE" = "win32" ]; then
|
||||
exec > /dev/tty
|
||||
fi
|
||||
fi
|
||||
|
||||
gitlint --staged --msg-filename "$1" run-hook
|
||||
exit_code=$?
|
||||
|
||||
# If we fail to find the gitlint binary (command not found), let's retry by executing as a python module.
|
||||
# This is the case for Atlassian SourceTree, where $PATH deviates from the user's shell $PATH.
|
||||
if [ $exit_code -eq 127 ]; then
|
||||
echo "Fallback to python module execution"
|
||||
python -m gitlint.cli --staged --msg-filename "$1" run-hook
|
||||
exit_code=$?
|
||||
fi
|
||||
|
||||
exit $exit_code
|
||||
|
||||
### gitlint commit-msg hook end ###
|
134
gitlint-core/gitlint/files/gitlint
Normal file
134
gitlint-core/gitlint/files/gitlint
Normal file
|
@ -0,0 +1,134 @@
|
|||
# Edit this file as you like.
|
||||
#
|
||||
# All these sections are optional. Each section with the exception of [general] represents
|
||||
# one rule and each key in it is an option for that specific rule.
|
||||
#
|
||||
# Rules and sections can be referenced by their full name or by id. For example
|
||||
# section "[body-max-line-length]" could also be written as "[B1]". Full section names are
|
||||
# used in here for clarity.
|
||||
#
|
||||
# [general]
|
||||
# Ignore certain rules, this example uses both full name and id
|
||||
# ignore=title-trailing-punctuation, T3
|
||||
|
||||
# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
|
||||
# verbosity = 2
|
||||
|
||||
# By default gitlint will ignore merge, revert, fixup and squash commits.
|
||||
# ignore-merge-commits=true
|
||||
# ignore-revert-commits=true
|
||||
# ignore-fixup-commits=true
|
||||
# ignore-squash-commits=true
|
||||
|
||||
# Ignore any data send to gitlint via stdin
|
||||
# ignore-stdin=true
|
||||
|
||||
# Fetch additional meta-data from the local repository when manually passing a
|
||||
# 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
|
||||
|
||||
# Enable community contributed rules
|
||||
# See http://jorisroovers.github.io/gitlint/contrib_rules for details
|
||||
# contrib=contrib-title-conventional-commits,CC1
|
||||
|
||||
# Set the extra-path where gitlint will search for user defined rules
|
||||
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
||||
# extra-path=examples/
|
||||
|
||||
# This is an example of how to configure the "title-max-length" rule and
|
||||
# set the line-length it enforces to 50
|
||||
# [title-max-length]
|
||||
# line-length=50
|
||||
|
||||
# Conversely, you can also enforce minimal length of a title with the
|
||||
# "title-min-length" rule:
|
||||
# [title-min-length]
|
||||
# min-length=5
|
||||
|
||||
# [title-must-not-contain-word]
|
||||
# Comma-separated list of words that should not occur in the title. Matching is case
|
||||
# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING"
|
||||
# will not cause a violation, but "WIP: my title" will.
|
||||
# words=wip
|
||||
|
||||
# [title-match-regex]
|
||||
# python-style regex that the commit-msg title must match
|
||||
# Note that the regex can contradict with other rules if not used correctly
|
||||
# (e.g. title-must-not-contain-word).
|
||||
# regex=^US[0-9]*
|
||||
|
||||
# [body-max-line-length]
|
||||
# line-length=72
|
||||
|
||||
# [body-min-length]
|
||||
# min-length=5
|
||||
|
||||
# [body-is-missing]
|
||||
# Whether to ignore this rule on merge commits (which typically only have a title)
|
||||
# default = True
|
||||
# ignore-merge-commits=false
|
||||
|
||||
# [body-changed-file-mention]
|
||||
# List of files that need to be explicitly mentioned in the body when they are changed
|
||||
# This is useful for when developers often erroneously edit certain files or git submodules.
|
||||
# By specifying this rule, developers can only change the file when they explicitly reference
|
||||
# it in the commit message.
|
||||
# files=gitlint-core/gitlint/rules.py,README.md
|
||||
|
||||
# [body-match-regex]
|
||||
# python-style regex that the commit-msg body must match.
|
||||
# E.g. body must end in My-Commit-Tag: foo
|
||||
# regex=My-Commit-Tag: foo$
|
||||
|
||||
# [author-valid-email]
|
||||
# python-style regex that the commit author email address must match.
|
||||
# For example, use the following regex if you only want to allow email addresses from foo.com
|
||||
# regex=[^@]+@foo.com
|
||||
|
||||
# [ignore-by-title]
|
||||
# Ignore certain rules for commits of which the title matches a regex
|
||||
# E.g. Match commit titles that start with "Release"
|
||||
# regex=^Release(.*)
|
||||
|
||||
# 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
|
||||
|
||||
# [ignore-by-body]
|
||||
# Ignore certain rules for commits of which the body has a line that matches a regex
|
||||
# E.g. Match bodies that have a line that that contain "release"
|
||||
# regex=(.*)release(.*)
|
||||
#
|
||||
# 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
|
||||
|
||||
# [ignore-body-lines]
|
||||
# Ignore certain lines in a commit body that match a regex.
|
||||
# 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.
|
||||
# [contrib-title-conventional-commits]
|
||||
# Specify allowed commit types. For details see: https://www.conventionalcommits.org/
|
||||
# types = bugfix,user-story,epic
|
398
gitlint-core/gitlint/git.py
Normal file
398
gitlint-core/gitlint/git.py
Normal file
|
@ -0,0 +1,398 @@
|
|||
import logging
|
||||
import os
|
||||
|
||||
import arrow
|
||||
|
||||
from gitlint import shell as sh
|
||||
# import exceptions separately, this makes it a little easier to mock them out in the unit tests
|
||||
from gitlint.shell import CommandNotFound, ErrorReturnCode
|
||||
|
||||
from gitlint.cache import PropertyCache, cache
|
||||
from gitlint.exception import GitlintError
|
||||
|
||||
# For now, the git date format we use is fixed, but technically this format is determined by `git config log.date`
|
||||
# We should fix this at some point :-)
|
||||
GIT_TIMEFORMAT = "YYYY-MM-DD HH:mm:ss Z"
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitContextError(GitlintError):
|
||||
""" Exception indicating there is an issue with the git context """
|
||||
pass
|
||||
|
||||
|
||||
class GitNotInstalledError(GitContextError):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"'git' command not found. You need to install git to use gitlint on a local repository. " +
|
||||
"See https://git-scm.com/book/en/v2/Getting-Started-Installing-Git on how to install git.")
|
||||
|
||||
|
||||
class GitExitCodeError(GitContextError):
|
||||
def __init__(self, command, stderr):
|
||||
self.command = command
|
||||
self.stderr = stderr
|
||||
super().__init__(f"An error occurred while executing '{command}': {stderr}")
|
||||
|
||||
|
||||
def _git(*command_parts, **kwargs):
|
||||
""" Convenience function for running git commands. Automatically deals with exceptions and unicode. """
|
||||
git_kwargs = {'_tty_out': False}
|
||||
git_kwargs.update(kwargs)
|
||||
try:
|
||||
LOG.debug(command_parts)
|
||||
result = sh.git(*command_parts, **git_kwargs) # pylint: disable=unexpected-keyword-arg
|
||||
# If we reach this point and the result has an exit_code that is larger than 0, this means that we didn't
|
||||
# get an exception (which is the default sh behavior for non-zero exit codes) and so the user is expecting
|
||||
# a non-zero exit code -> just return the entire result
|
||||
if hasattr(result, 'exit_code') and result.exit_code > 0:
|
||||
return result
|
||||
return str(result)
|
||||
except CommandNotFound as e:
|
||||
raise GitNotInstalledError from e
|
||||
except ErrorReturnCode as e: # Something went wrong while executing the git command
|
||||
error_msg = e.stderr.strip()
|
||||
error_msg_lower = error_msg.lower()
|
||||
if '_cwd' in git_kwargs and b"not a git repository" in error_msg_lower:
|
||||
raise GitContextError(f"{git_kwargs['_cwd']} is not a git repository.") from e
|
||||
|
||||
if (b"does not have any commits yet" in error_msg_lower or
|
||||
b"ambiguous argument 'head': unknown revision" in error_msg_lower):
|
||||
msg = "Current branch has no commits. Gitlint requires at least one commit to function."
|
||||
raise GitContextError(msg) from e
|
||||
|
||||
raise GitExitCodeError(e.full_cmd, error_msg) from e
|
||||
|
||||
|
||||
def git_version():
|
||||
""" Determine the git version installed on this host by calling git --version"""
|
||||
return _git("--version").replace("\n", "")
|
||||
|
||||
|
||||
def git_commentchar(repository_path=None):
|
||||
""" Shortcut for retrieving comment char from git config """
|
||||
commentchar = _git("config", "--get", "core.commentchar", _cwd=repository_path, _ok_code=[0, 1])
|
||||
# git will return an exit code of 1 if it can't find a config value, in this case we fall-back to # as commentchar
|
||||
if hasattr(commentchar, 'exit_code') and commentchar.exit_code == 1: # pylint: disable=no-member
|
||||
commentchar = "#"
|
||||
return commentchar.replace("\n", "")
|
||||
|
||||
|
||||
def git_hooks_dir(repository_path):
|
||||
""" Determine hooks directory for a given target dir """
|
||||
hooks_dir = _git("rev-parse", "--git-path", "hooks", _cwd=repository_path)
|
||||
hooks_dir = hooks_dir.replace("\n", "")
|
||||
return os.path.realpath(os.path.join(repository_path, hooks_dir))
|
||||
|
||||
|
||||
class GitCommitMessage:
|
||||
""" Class representing a git commit message. A commit message consists of the following:
|
||||
- context: The `GitContext` this commit message is part of
|
||||
- original: The actual commit message as returned by `git log`
|
||||
- full: original, but stripped of any comments
|
||||
- title: the first line of full
|
||||
- body: all lines following the title
|
||||
"""
|
||||
def __init__(self, context, original=None, full=None, title=None, body=None):
|
||||
self.context = context
|
||||
self.original = original
|
||||
self.full = full
|
||||
self.title = title
|
||||
self.body = body
|
||||
|
||||
@staticmethod
|
||||
def from_full_message(context, commit_msg_str):
|
||||
""" Parses a full git commit message by parsing a given string into the different parts of a commit message """
|
||||
all_lines = commit_msg_str.splitlines()
|
||||
cutline = f"{context.commentchar} ------------------------ >8 ------------------------"
|
||||
try:
|
||||
cutline_index = all_lines.index(cutline)
|
||||
except ValueError:
|
||||
cutline_index = None
|
||||
lines = [line for line in all_lines[:cutline_index] if not line.startswith(context.commentchar)]
|
||||
full = "\n".join(lines)
|
||||
title = lines[0] if lines else ""
|
||||
body = lines[1:] if len(lines) > 1 else []
|
||||
return GitCommitMessage(context=context, original=commit_msg_str, full=full, title=title, body=body)
|
||||
|
||||
def __str__(self):
|
||||
return self.full
|
||||
|
||||
def __eq__(self, other):
|
||||
return (isinstance(other, GitCommitMessage) and self.original == other.original
|
||||
and self.full == other.full and self.title == other.title and self.body == other.body) # noqa
|
||||
|
||||
|
||||
class GitCommit:
|
||||
""" Class representing a git commit.
|
||||
A commit consists of: context, message, author name, author email, date, list of parent commit shas,
|
||||
list of changed files, list of branch names.
|
||||
In the context of gitlint, only the git context and commit message are required.
|
||||
"""
|
||||
|
||||
def __init__(self, context, message, sha=None, date=None, author_name=None, # pylint: disable=too-many-arguments
|
||||
author_email=None, parents=None, changed_files=None, branches=None):
|
||||
self.context = context
|
||||
self.message = message
|
||||
self.sha = sha
|
||||
self.date = date
|
||||
self.author_name = author_name
|
||||
self.author_email = author_email
|
||||
self.parents = parents or [] # parent commit hashes
|
||||
self.changed_files = changed_files or []
|
||||
self.branches = branches or []
|
||||
|
||||
@property
|
||||
def is_merge_commit(self):
|
||||
return self.message.title.startswith("Merge")
|
||||
|
||||
@property
|
||||
def is_fixup_commit(self):
|
||||
return self.message.title.startswith("fixup!")
|
||||
|
||||
@property
|
||||
def is_squash_commit(self):
|
||||
return self.message.title.startswith("squash!")
|
||||
|
||||
@property
|
||||
def is_revert_commit(self):
|
||||
return self.message.title.startswith("Revert")
|
||||
|
||||
def __str__(self):
|
||||
date_str = arrow.get(self.date).format(GIT_TIMEFORMAT) if self.date else None
|
||||
return (f"--- Commit Message ----\n{self.message}\n"
|
||||
"--- Meta info ---------\n"
|
||||
f"Author: {self.author_name} <{self.author_email}>\n"
|
||||
f"Date: {date_str}\n"
|
||||
f"is-merge-commit: {self.is_merge_commit}\n"
|
||||
f"is-fixup-commit: {self.is_fixup_commit}\n"
|
||||
f"is-squash-commit: {self.is_squash_commit}\n"
|
||||
f"is-revert-commit: {self.is_revert_commit}\n"
|
||||
f"Branches: {self.branches}\n"
|
||||
f"Changed Files: {self.changed_files}\n"
|
||||
"-----------------------")
|
||||
|
||||
def __eq__(self, other):
|
||||
# skip checking the context as context refers back to this obj, this will trigger a cyclic dependency
|
||||
return (isinstance(other, GitCommit) and self.message == other.message
|
||||
and self.sha == other.sha and self.author_name == other.author_name
|
||||
and self.author_email == other.author_email
|
||||
and self.date == other.date and self.parents == other.parents
|
||||
and self.is_merge_commit == other.is_merge_commit and self.is_fixup_commit == other.is_fixup_commit
|
||||
and self.is_squash_commit == other.is_squash_commit and self.is_revert_commit == other.is_revert_commit
|
||||
and self.changed_files == other.changed_files and self.branches == other.branches) # noqa
|
||||
|
||||
|
||||
class LocalGitCommit(GitCommit, PropertyCache):
|
||||
""" Class representing a git commit that exists in the local git repository.
|
||||
This class uses lazy loading: it defers reading information from the local git repository until the associated
|
||||
property is accessed for the first time. Properties are then cached for subsequent access.
|
||||
|
||||
This approach ensures that we don't do 'expensive' git calls when certain properties are not actually used.
|
||||
In addition, reading the required info when it's needed rather than up front avoids adding delay during gitlint
|
||||
startup time and reduces gitlint's memory footprint.
|
||||
"""
|
||||
def __init__(self, context, sha): # pylint: disable=super-init-not-called
|
||||
PropertyCache.__init__(self)
|
||||
self.context = context
|
||||
self.sha = sha
|
||||
|
||||
def _log(self):
|
||||
""" Does a call to `git log` to determine a bunch of information about the commit. """
|
||||
long_format = "--pretty=%aN%x00%aE%x00%ai%x00%P%n%B"
|
||||
raw_commit = _git("log", self.sha, "-1", long_format, _cwd=self.context.repository_path).split("\n")
|
||||
|
||||
(name, email, date, parents), commit_msg = raw_commit[0].split('\x00'), "\n".join(raw_commit[1:])
|
||||
|
||||
commit_parents = parents.split(" ")
|
||||
commit_is_merge_commit = len(commit_parents) > 1
|
||||
|
||||
# "YYYY-MM-DD HH:mm:ss Z" -> ISO 8601-like format
|
||||
# Use arrow for datetime parsing, because apparently python is quirky around ISO-8601 dates:
|
||||
# http://stackoverflow.com/a/30696682/381010
|
||||
commit_date = arrow.get(date, GIT_TIMEFORMAT).datetime
|
||||
|
||||
# Create Git commit object with the retrieved info
|
||||
commit_msg_obj = GitCommitMessage.from_full_message(self.context, commit_msg)
|
||||
|
||||
self._cache.update({'message': commit_msg_obj, 'author_name': name, 'author_email': email, 'date': commit_date,
|
||||
'parents': commit_parents, 'is_merge_commit': commit_is_merge_commit})
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return self._try_cache("message", self._log)
|
||||
|
||||
@property
|
||||
def author_name(self):
|
||||
return self._try_cache("author_name", self._log)
|
||||
|
||||
@property
|
||||
def author_email(self):
|
||||
return self._try_cache("author_email", self._log)
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
return self._try_cache("date", self._log)
|
||||
|
||||
@property
|
||||
def parents(self):
|
||||
return self._try_cache("parents", self._log)
|
||||
|
||||
@property
|
||||
def branches(self):
|
||||
def cache_branches():
|
||||
# We have to parse 'git branch --contains <sha>' instead of 'git for-each-ref' to be compatible with
|
||||
# git versions < 2.7.0
|
||||
# https://stackoverflow.com/questions/45173979/can-i-force-git-branch-contains-tag-to-not-print-the-asterisk
|
||||
branches = _git("branch", "--contains", self.sha, _cwd=self.context.repository_path).split("\n")
|
||||
|
||||
# This means that we need to remove any leading * that indicates the current branch. Note that we can
|
||||
# safely do this since git branches cannot contain '*' anywhere, so if we find an '*' we know it's output
|
||||
# from the git CLI and not part of the branch name. See https://git-scm.com/docs/git-check-ref-format
|
||||
# We also drop the last empty line from the output.
|
||||
self._cache['branches'] = [branch.replace("*", "").strip() for branch in branches[:-1]]
|
||||
|
||||
return self._try_cache("branches", cache_branches)
|
||||
|
||||
@property
|
||||
def is_merge_commit(self):
|
||||
return self._try_cache("is_merge_commit", self._log)
|
||||
|
||||
@property
|
||||
def changed_files(self):
|
||||
def cache_changed_files():
|
||||
self._cache['changed_files'] = _git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root",
|
||||
self.sha, _cwd=self.context.repository_path).split()
|
||||
|
||||
return self._try_cache("changed_files", cache_changed_files)
|
||||
|
||||
|
||||
class StagedLocalGitCommit(GitCommit, PropertyCache):
|
||||
""" Class representing a git commit that has been staged, but not committed.
|
||||
|
||||
Other than the commit message itself (and changed files), a lot of information is actually not known at staging
|
||||
time, since the commit hasn't happened yet. However, we can make educated guesses based on existing repository
|
||||
information.
|
||||
"""
|
||||
|
||||
def __init__(self, context, commit_message): # pylint: disable=super-init-not-called
|
||||
PropertyCache.__init__(self)
|
||||
self.context = context
|
||||
self.message = commit_message
|
||||
self.sha = None
|
||||
self.parents = [] # Not really possible to determine before a commit
|
||||
|
||||
@property
|
||||
@cache
|
||||
def author_name(self):
|
||||
try:
|
||||
return _git("config", "--get", "user.name", _cwd=self.context.repository_path).strip()
|
||||
except GitExitCodeError as e:
|
||||
raise GitContextError("Missing git configuration: please set user.name") from e
|
||||
|
||||
@property
|
||||
@cache
|
||||
def author_email(self):
|
||||
try:
|
||||
return _git("config", "--get", "user.email", _cwd=self.context.repository_path).strip()
|
||||
except GitExitCodeError as e:
|
||||
raise GitContextError("Missing git configuration: please set user.email") from e
|
||||
|
||||
@property
|
||||
@cache
|
||||
def date(self):
|
||||
# We don't know the actual commit date yet, but we make a pragmatic trade-off here by providing the current date
|
||||
# We get current date from arrow, reformat in git date format, then re-interpret it as a date.
|
||||
# This ensure we capture the same precision and timezone information that git does.
|
||||
return arrow.get(arrow.now().format(GIT_TIMEFORMAT), GIT_TIMEFORMAT).datetime
|
||||
|
||||
@property
|
||||
@cache
|
||||
def branches(self):
|
||||
# We don't know the branch this commit will be part of yet, but we're pragmatic here and just return the
|
||||
# current branch, as for all intents and purposes, this will be what the user is looking for.
|
||||
return [self.context.current_branch]
|
||||
|
||||
@property
|
||||
def changed_files(self):
|
||||
return _git("diff", "--staged", "--name-only", "-r", _cwd=self.context.repository_path).split()
|
||||
|
||||
|
||||
class GitContext(PropertyCache):
|
||||
""" Class representing the git context in which gitlint is operating: a data object storing information about
|
||||
the git repository that gitlint is linting.
|
||||
"""
|
||||
|
||||
def __init__(self, repository_path=None):
|
||||
PropertyCache.__init__(self)
|
||||
self.commits = []
|
||||
self.repository_path = repository_path
|
||||
|
||||
@property
|
||||
@cache
|
||||
def commentchar(self):
|
||||
return git_commentchar(self.repository_path)
|
||||
|
||||
@property
|
||||
@cache
|
||||
def current_branch(self):
|
||||
current_branch = _git("rev-parse", "--abbrev-ref", "HEAD", _cwd=self.repository_path).strip()
|
||||
return current_branch
|
||||
|
||||
@staticmethod
|
||||
def from_commit_msg(commit_msg_str):
|
||||
""" Determines git context based on a commit message.
|
||||
:param commit_msg_str: Full git commit message.
|
||||
"""
|
||||
context = GitContext()
|
||||
commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str)
|
||||
commit = GitCommit(context, commit_msg_obj)
|
||||
context.commits.append(commit)
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def from_staged_commit(commit_msg_str, repository_path):
|
||||
""" Determines git context based on a commit message that is a staged commit for a local git repository.
|
||||
:param commit_msg_str: Full git commit message.
|
||||
:param repository_path: Path to the git repository to retrieve the context from
|
||||
"""
|
||||
context = GitContext(repository_path=repository_path)
|
||||
commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str)
|
||||
commit = StagedLocalGitCommit(context, commit_msg_obj)
|
||||
context.commits.append(commit)
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
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 (mutually exclusive with `commit_hash`)
|
||||
:param commit_hash: Hash of the commit to retrieve (mutually exclusive with `refspec`)
|
||||
"""
|
||||
|
||||
context = GitContext(repository_path=repository_path)
|
||||
|
||||
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", "")]
|
||||
|
||||
for sha in sha_list:
|
||||
commit = LocalGitCommit(context, sha)
|
||||
context.commits.append(commit)
|
||||
|
||||
return context
|
||||
|
||||
def __eq__(self, other):
|
||||
return (isinstance(other, GitContext) and self.commits == other.commits
|
||||
and self.repository_path == other.repository_path
|
||||
and self.commentchar == other.commentchar and self.current_branch == other.current_branch) # noqa
|
63
gitlint-core/gitlint/hooks.py
Normal file
63
gitlint-core/gitlint/hooks.py
Normal file
|
@ -0,0 +1,63 @@
|
|||
import io
|
||||
import shutil
|
||||
import os
|
||||
import stat
|
||||
|
||||
from gitlint.utils import DEFAULT_ENCODING
|
||||
from gitlint.git import git_hooks_dir
|
||||
from gitlint.exception import GitlintError
|
||||
|
||||
COMMIT_MSG_HOOK_SRC_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "files", "commit-msg")
|
||||
COMMIT_MSG_HOOK_DST_PATH = "commit-msg"
|
||||
GITLINT_HOOK_IDENTIFIER = "### gitlint commit-msg hook start ###\n"
|
||||
|
||||
|
||||
class GitHookInstallerError(GitlintError):
|
||||
pass
|
||||
|
||||
|
||||
class GitHookInstaller:
|
||||
""" Utility class that provides methods for installing and uninstalling the gitlint commitmsg hook. """
|
||||
|
||||
@staticmethod
|
||||
def commit_msg_hook_path(lint_config):
|
||||
return os.path.join(git_hooks_dir(lint_config.target), COMMIT_MSG_HOOK_DST_PATH)
|
||||
|
||||
@staticmethod
|
||||
def _assert_git_repo(target):
|
||||
""" Asserts that a given target directory is a git repository """
|
||||
hooks_dir = git_hooks_dir(target)
|
||||
if not os.path.isdir(hooks_dir):
|
||||
raise GitHookInstallerError(f"{target} is not a git repository.")
|
||||
|
||||
@staticmethod
|
||||
def install_commit_msg_hook(lint_config):
|
||||
GitHookInstaller._assert_git_repo(lint_config.target)
|
||||
dest_path = GitHookInstaller.commit_msg_hook_path(lint_config)
|
||||
if os.path.exists(dest_path):
|
||||
raise GitHookInstallerError(
|
||||
f"There is already a commit-msg hook file present in {dest_path}.\n" +
|
||||
"gitlint currently does not support appending to an existing commit-msg file.")
|
||||
|
||||
# copy hook file
|
||||
shutil.copy(COMMIT_MSG_HOOK_SRC_PATH, dest_path)
|
||||
# make hook executable
|
||||
st = os.stat(dest_path)
|
||||
os.chmod(dest_path, st.st_mode | stat.S_IEXEC)
|
||||
|
||||
@staticmethod
|
||||
def uninstall_commit_msg_hook(lint_config):
|
||||
GitHookInstaller._assert_git_repo(lint_config.target)
|
||||
dest_path = GitHookInstaller.commit_msg_hook_path(lint_config)
|
||||
if not os.path.exists(dest_path):
|
||||
raise GitHookInstallerError(f"There is no commit-msg hook present in {dest_path}.")
|
||||
|
||||
with io.open(dest_path, encoding=DEFAULT_ENCODING) as fp:
|
||||
lines = fp.readlines()
|
||||
if len(lines) < 2 or lines[1] != GITLINT_HOOK_IDENTIFIER:
|
||||
msg = f"The commit-msg hook in {dest_path} was not installed by gitlint (or it was modified).\n" + \
|
||||
"Uninstallation of 3th party or modified gitlint hooks is not supported."
|
||||
raise GitHookInstallerError(msg)
|
||||
|
||||
# If we are sure it's a gitlint hook, go ahead and remove it
|
||||
os.remove(dest_path)
|
106
gitlint-core/gitlint/lint.py
Normal file
106
gitlint-core/gitlint/lint.py
Normal file
|
@ -0,0 +1,106 @@
|
|||
# pylint: disable=logging-not-lazy
|
||||
import logging
|
||||
from gitlint import rules as gitlint_rules
|
||||
from gitlint import display
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
logging.basicConfig()
|
||||
|
||||
|
||||
class GitLinter:
|
||||
""" Main linter class. This is where rules actually get applied. See the lint() method. """
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
self.display = display.Display(config)
|
||||
|
||||
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.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.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.should_ignore_rule(rule)]
|
||||
|
||||
@staticmethod
|
||||
def _apply_line_rules(lines, commit, rules, line_nr_start):
|
||||
""" Iterates over the lines in a given list of lines and validates a given list of rules against each line """
|
||||
all_violations = []
|
||||
line_nr = line_nr_start
|
||||
for line in lines:
|
||||
for rule in rules:
|
||||
violations = rule.validate(line, commit)
|
||||
if violations:
|
||||
for violation in violations:
|
||||
violation.line_nr = line_nr
|
||||
all_violations.append(violation)
|
||||
line_nr += 1
|
||||
return all_violations
|
||||
|
||||
@staticmethod
|
||||
def _apply_commit_rules(rules, commit):
|
||||
""" Applies a set of rules against a given commit and gitcontext """
|
||||
all_violations = []
|
||||
for rule in rules:
|
||||
violations = rule.validate(commit)
|
||||
if violations:
|
||||
all_violations.extend(violations)
|
||||
return all_violations
|
||||
|
||||
def lint(self, commit):
|
||||
""" 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" + str(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", "revert"]
|
||||
for commit_type in ignore_commit_types:
|
||||
if getattr(commit, f"is_{commit_type}_commit") and \
|
||||
getattr(self.config, f"ignore_{commit_type}_commits"):
|
||||
return []
|
||||
|
||||
violations = []
|
||||
# determine violations by applying all rules
|
||||
violations.extend(self._apply_line_rules([commit.message.title], commit, self.title_line_rules, 1))
|
||||
violations.extend(self._apply_line_rules(commit.message.body, commit, self.body_line_rules, 2))
|
||||
violations.extend(self._apply_commit_rules(self.commit_rules, commit))
|
||||
|
||||
# Sort violations by line number and rule_id. If there's no line nr specified (=common certain commit rules),
|
||||
# we replace None with -1 so that it always get's placed first. Note that we need this to do this to support
|
||||
# python 3, as None is not allowed in a list that is being sorted.
|
||||
violations.sort(key=lambda v: (-1 if v.line_nr is None else v.line_nr, v.rule_id))
|
||||
return violations
|
||||
|
||||
def print_violations(self, violations):
|
||||
""" Print a given set of violations to the standard error output """
|
||||
for v in violations:
|
||||
line_nr = v.line_nr if v.line_nr else "-"
|
||||
self.display.e(f"{line_nr}: {v.rule_id}", exact=True)
|
||||
self.display.ee(f"{line_nr}: {v.rule_id} {v.message}", exact=True)
|
||||
if v.content:
|
||||
self.display.eee(f"{line_nr}: {v.rule_id} {v.message}: \"{v.content}\"", exact=True)
|
||||
else:
|
||||
self.display.eee(f"{line_nr}: {v.rule_id} {v.message}", exact=True)
|
149
gitlint-core/gitlint/options.py
Normal file
149
gitlint-core/gitlint/options.py
Normal file
|
@ -0,0 +1,149 @@
|
|||
from abc import abstractmethod
|
||||
import os
|
||||
import re
|
||||
|
||||
from gitlint.exception import GitlintError
|
||||
|
||||
|
||||
def allow_none(func):
|
||||
""" Decorator that sets option value to None if the passed value is None, otherwise calls the regular set method """
|
||||
|
||||
def wrapped(obj, value):
|
||||
if value is None:
|
||||
obj.value = None
|
||||
else:
|
||||
func(obj, value)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class RuleOptionError(GitlintError):
|
||||
pass
|
||||
|
||||
|
||||
class RuleOption:
|
||||
""" Base class representing a configurable part (i.e. option) of a rule (e.g. the max-length of the title-max-line
|
||||
rule).
|
||||
This class should not be used directly. Instead, use on the derived classes like StrOption, IntOption to set
|
||||
options of a particular type like int, str, etc.
|
||||
"""
|
||||
|
||||
def __init__(self, name, value, description):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.value = None
|
||||
self.set(value)
|
||||
|
||||
@abstractmethod
|
||||
def set(self, value):
|
||||
""" Validates and sets the option's value """
|
||||
pass # pragma: no cover
|
||||
|
||||
def __str__(self):
|
||||
return f"({self.name}: {self.value} ({self.description}))"
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.name == other.name and self.description == other.description and self.value == other.value
|
||||
|
||||
|
||||
class StrOption(RuleOption):
|
||||
@allow_none
|
||||
def set(self, value):
|
||||
self.value = str(value)
|
||||
|
||||
|
||||
class IntOption(RuleOption):
|
||||
def __init__(self, name, value, description, allow_negative=False):
|
||||
self.allow_negative = allow_negative
|
||||
super().__init__(name, value, description)
|
||||
|
||||
def _raise_exception(self, value):
|
||||
if self.allow_negative:
|
||||
error_msg = f"Option '{self.name}' must be an integer (current value: '{value}')"
|
||||
else:
|
||||
error_msg = f"Option '{self.name}' must be a positive integer (current value: '{value}')"
|
||||
raise RuleOptionError(error_msg)
|
||||
|
||||
@allow_none
|
||||
def set(self, value):
|
||||
try:
|
||||
self.value = int(value)
|
||||
except ValueError:
|
||||
self._raise_exception(value)
|
||||
|
||||
if not self.allow_negative and self.value < 0:
|
||||
self._raise_exception(value)
|
||||
|
||||
|
||||
class BoolOption(RuleOption):
|
||||
|
||||
# explicit choice to not annotate with @allow_none: Booleans must be False or True, they cannot be unset.
|
||||
def set(self, value):
|
||||
value = str(value).strip().lower()
|
||||
if value not in ['true', 'false']:
|
||||
raise RuleOptionError(f"Option '{self.name}' must be either 'true' or 'false'")
|
||||
self.value = value == 'true'
|
||||
|
||||
|
||||
class ListOption(RuleOption):
|
||||
""" Option that is either a given list or a comma-separated string that can be split into a list when being set.
|
||||
"""
|
||||
|
||||
@allow_none
|
||||
def set(self, value):
|
||||
if isinstance(value, list):
|
||||
the_list = value
|
||||
else:
|
||||
the_list = str(value).split(",")
|
||||
|
||||
self.value = [str(item.strip()) for item in the_list if item.strip() != ""]
|
||||
|
||||
|
||||
class PathOption(RuleOption):
|
||||
""" Option that accepts either a directory or both a directory and a file. """
|
||||
|
||||
def __init__(self, name, value, description, type="dir"):
|
||||
self.type = type
|
||||
super().__init__(name, value, description)
|
||||
|
||||
@allow_none
|
||||
def set(self, value):
|
||||
value = str(value)
|
||||
|
||||
error_msg = ""
|
||||
|
||||
if self.type == 'dir':
|
||||
if not os.path.isdir(value):
|
||||
error_msg = f"Option {self.name} must be an existing directory (current value: '{value}')"
|
||||
elif self.type == 'file':
|
||||
if not os.path.isfile(value):
|
||||
error_msg = f"Option {self.name} must be an existing file (current value: '{value}')"
|
||||
elif self.type == 'both':
|
||||
if not os.path.isdir(value) and not os.path.isfile(value):
|
||||
error_msg = (f"Option {self.name} must be either an existing directory or file "
|
||||
f"(current value: '{value}')")
|
||||
else:
|
||||
error_msg = f"Option {self.name} type must be one of: 'file', 'dir', 'both' (current: '{self.type}')"
|
||||
|
||||
if error_msg:
|
||||
raise RuleOptionError(error_msg)
|
||||
|
||||
self.value = os.path.realpath(value)
|
||||
|
||||
|
||||
class RegexOption(RuleOption):
|
||||
|
||||
@allow_none
|
||||
def set(self, value):
|
||||
try:
|
||||
self.value = re.compile(value, re.UNICODE)
|
||||
except (re.error, TypeError) as exc:
|
||||
raise RuleOptionError(f"Invalid regular expression: '{exc}'") from exc
|
||||
|
||||
def __deepcopy__(self, _):
|
||||
# copy.deepcopy() - used in rules.py - doesn't support copying regex objects prior to Python 3.7
|
||||
# To work around this, we have to implement this __deepcopy__ magic method
|
||||
# Relevant SO thread:
|
||||
# https://stackoverflow.com/questions/6279305/typeerror-cannot-deepcopy-this-pattern-object
|
||||
value = None if self.value is None else self.value.pattern
|
||||
return RegexOption(self.name, value, self.description)
|
145
gitlint-core/gitlint/rule_finder.py
Normal file
145
gitlint-core/gitlint/rule_finder.py
Normal file
|
@ -0,0 +1,145 @@
|
|||
import fnmatch
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
|
||||
from gitlint import rules, options
|
||||
|
||||
|
||||
def find_rule_classes(extra_path):
|
||||
"""
|
||||
Searches a given directory or python module for rule classes. This is done by
|
||||
adding the directory path to the python path, importing the modules and then finding
|
||||
any Rule class in those modules.
|
||||
|
||||
:param extra_path: absolute directory or file path to search for rule classes
|
||||
:return: The list of rule classes that are found in the given directory or module
|
||||
"""
|
||||
|
||||
files = []
|
||||
modules = []
|
||||
|
||||
if os.path.isfile(extra_path):
|
||||
files = [os.path.basename(extra_path)]
|
||||
directory = os.path.dirname(extra_path)
|
||||
elif os.path.isdir(extra_path):
|
||||
files = os.listdir(extra_path)
|
||||
directory = extra_path
|
||||
else:
|
||||
raise rules.UserRuleError(f"Invalid extra-path: {extra_path}")
|
||||
|
||||
# Filter out files that are not python modules
|
||||
for filename in files:
|
||||
if fnmatch.fnmatch(filename, '*.py'):
|
||||
# We have to treat __init__ files a bit special: add the parent dir instead of the filename, and also
|
||||
# add their parent dir to the sys.path (this fixes import issues with pypy2).
|
||||
if filename == "__init__.py":
|
||||
modules.append(os.path.basename(directory))
|
||||
sys.path.append(os.path.dirname(directory))
|
||||
else:
|
||||
modules.append(os.path.splitext(filename)[0])
|
||||
|
||||
# No need to continue if there are no modules specified
|
||||
if not modules:
|
||||
return []
|
||||
|
||||
# Append the extra rules path to python path so that we can import them
|
||||
sys.path.append(directory)
|
||||
|
||||
# Find all the rule classes in the found python files
|
||||
rule_classes = []
|
||||
for module in modules:
|
||||
# Import the module
|
||||
try:
|
||||
importlib.import_module(module)
|
||||
|
||||
except Exception as e:
|
||||
raise rules.UserRuleError(f"Error while importing extra-path module '{module}': {e}")
|
||||
|
||||
# Find all rule classes in the module. We do this my inspecting all members of the module and checking
|
||||
# 1) is it a class, if not, skip
|
||||
# 2) is the parent path the current module. If not, we are dealing with an imported class, skip
|
||||
# 3) is it a subclass of rule
|
||||
rule_classes.extend([clazz for _, clazz in inspect.getmembers(sys.modules[module])
|
||||
if
|
||||
inspect.isclass(clazz) and # check isclass to ensure clazz.__module__ exists
|
||||
clazz.__module__ == module and # ignore imported classes
|
||||
(issubclass(clazz, rules.LineRule) or
|
||||
issubclass(clazz, rules.CommitRule) or
|
||||
issubclass(clazz, rules.ConfigurationRule))])
|
||||
|
||||
# validate that the rule classes are valid user-defined rules
|
||||
for rule_class in rule_classes:
|
||||
assert_valid_rule_class(rule_class)
|
||||
|
||||
return rule_classes
|
||||
|
||||
|
||||
def assert_valid_rule_class(clazz, rule_type="User-defined"): # pylint: disable=too-many-branches
|
||||
"""
|
||||
Asserts that a given rule clazz is valid by checking a number of its properties:
|
||||
- Rules must extend from LineRule, CommitRule or ConfigurationRule
|
||||
- Rule classes must have id and name string attributes.
|
||||
The options_spec is optional, but if set, it must be a list of gitlint Options.
|
||||
- Rule classes must have a validate method. In case of a CommitRule, validate must take a single commit parameter.
|
||||
In case of LineRule, validate must take line and commit as first and second parameters.
|
||||
- LineRule classes must have a target class attributes that is set to either
|
||||
- ConfigurationRule classes must have an apply method that take `config` and `commit` as parameters.
|
||||
CommitMessageTitle or CommitMessageBody.
|
||||
- Rule id's cannot start with R, T, B, M or I as these rule ids are reserved for gitlint itself.
|
||||
"""
|
||||
|
||||
# Rules must extend from LineRule, CommitRule or ConfigurationRule
|
||||
if not (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule)
|
||||
or issubclass(clazz, rules.ConfigurationRule)):
|
||||
msg = f"{rule_type} rule class '{clazz.__name__}' " + \
|
||||
f"must extend from {rules.CommitRule.__module__}.{rules.LineRule.__name__}, " + \
|
||||
f"{rules.CommitRule.__module__}.{rules.CommitRule.__name__} or " + \
|
||||
f"{rules.CommitRule.__module__}.{rules.ConfigurationRule.__name__}"
|
||||
raise rules.UserRuleError(msg)
|
||||
|
||||
# Rules must have an id attribute
|
||||
if not hasattr(clazz, 'id') or clazz.id is None or not clazz.id:
|
||||
raise rules.UserRuleError(f"{rule_type} rule class '{clazz.__name__}' must have an 'id' attribute")
|
||||
|
||||
# Rule id's cannot start with gitlint reserved letters
|
||||
if clazz.id[0].upper() in ['R', 'T', 'B', 'M', 'I']:
|
||||
msg = f"The id '{clazz.id[0]}' of '{clazz.__name__}' is invalid. Gitlint reserves ids starting with R,T,B,M,I"
|
||||
raise rules.UserRuleError(msg)
|
||||
|
||||
# Rules must have a name attribute
|
||||
if not hasattr(clazz, 'name') or clazz.name is None or not clazz.name:
|
||||
raise rules.UserRuleError(f"{rule_type} rule class '{clazz.__name__}' must have a 'name' attribute")
|
||||
|
||||
# if set, options_spec must be a list of RuleOption
|
||||
if not isinstance(clazz.options_spec, list):
|
||||
msg = f"The options_spec attribute of {rule_type.lower()} rule class '{clazz.__name__}' " + \
|
||||
f"must be a list of {options.RuleOption.__module__}.{options.RuleOption.__name__}"
|
||||
raise rules.UserRuleError(msg)
|
||||
|
||||
# check that all items in options_spec are actual gitlint options
|
||||
for option in clazz.options_spec:
|
||||
if not isinstance(option, options.RuleOption):
|
||||
msg = f"The options_spec attribute of {rule_type.lower()} rule class '{clazz.__name__}' " + \
|
||||
f"must be a list of {options.RuleOption.__module__}.{options.RuleOption.__name__}"
|
||||
raise rules.UserRuleError(msg)
|
||||
|
||||
# Line/Commit rules must have a `validate` method
|
||||
# We use isroutine() as it's both python 2 and 3 compatible. Details: http://stackoverflow.com/a/17019998/381010
|
||||
if (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule)):
|
||||
if not hasattr(clazz, 'validate') or not inspect.isroutine(clazz.validate):
|
||||
raise rules.UserRuleError(f"{rule_type} rule class '{clazz.__name__}' must have a 'validate' method")
|
||||
# Configuration rules must have an `apply` method
|
||||
elif issubclass(clazz, rules.ConfigurationRule):
|
||||
if not hasattr(clazz, 'apply') or not inspect.isroutine(clazz.apply):
|
||||
msg = f"{rule_type} Configuration rule class '{clazz.__name__}' must have an 'apply' method"
|
||||
raise rules.UserRuleError(msg)
|
||||
|
||||
# LineRules must have a valid target: rules.CommitMessageTitle or rules.CommitMessageBody
|
||||
if issubclass(clazz, rules.LineRule):
|
||||
if clazz.target not in [rules.CommitMessageTitle, rules.CommitMessageBody]:
|
||||
msg = f"The target attribute of the {rule_type.lower()} LineRule class '{clazz.__name__}' " + \
|
||||
f"must be either {rules.CommitMessageTitle.__module__}.{rules.CommitMessageTitle.__name__} " + \
|
||||
f"or {rules.CommitMessageTitle.__module__}.{rules.CommitMessageBody.__name__}"
|
||||
raise rules.UserRuleError(msg)
|
440
gitlint-core/gitlint/rules.py
Normal file
440
gitlint-core/gitlint/rules.py
Normal file
|
@ -0,0 +1,440 @@
|
|||
# pylint: disable=inconsistent-return-statements
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
|
||||
from gitlint.options import IntOption, BoolOption, StrOption, ListOption, RegexOption
|
||||
from gitlint.exception import GitlintError
|
||||
|
||||
|
||||
class Rule:
|
||||
""" Class representing gitlint rules. """
|
||||
options_spec = []
|
||||
id = None
|
||||
name = None
|
||||
target = None
|
||||
_log = None
|
||||
|
||||
def __init__(self, opts=None):
|
||||
if not opts:
|
||||
opts = {}
|
||||
self.options = {}
|
||||
for op_spec in self.options_spec:
|
||||
self.options[op_spec.name] = copy.deepcopy(op_spec)
|
||||
actual_option = opts.get(op_spec.name)
|
||||
if actual_option is not None:
|
||||
self.options[op_spec.name].set(actual_option)
|
||||
|
||||
@property
|
||||
def log(self):
|
||||
if not self._log:
|
||||
self._log = logging.getLogger(__name__)
|
||||
logging.basicConfig()
|
||||
return self._log
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.id == other.id and self.name == other.name and \
|
||||
self.options == other.options and self.target == other.target # noqa
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.id} {self.name}" # 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
|
||||
|
||||
|
||||
class LineRule(Rule):
|
||||
""" Class representing rules that act on a line by line basis """
|
||||
pass
|
||||
|
||||
|
||||
class LineRuleTarget:
|
||||
""" Base class for LineRule targets. A LineRuleTarget specifies where a given rule will be applied
|
||||
(e.g. commit message title, commit message body).
|
||||
Each LineRule MUST have a target specified. """
|
||||
pass
|
||||
|
||||
|
||||
class CommitMessageTitle(LineRuleTarget):
|
||||
""" Target class used for rules that apply to a commit message title """
|
||||
pass
|
||||
|
||||
|
||||
class CommitMessageBody(LineRuleTarget):
|
||||
""" Target class used for rules that apply to a commit message body """
|
||||
pass
|
||||
|
||||
|
||||
class RuleViolation:
|
||||
""" Class representing a violation of a rule. I.e.: When a rule is broken, the rule will instantiate this class
|
||||
to indicate how and where the rule was broken. """
|
||||
|
||||
def __init__(self, rule_id, message, content=None, line_nr=None):
|
||||
self.rule_id = rule_id
|
||||
self.line_nr = line_nr
|
||||
self.message = message
|
||||
self.content = content
|
||||
|
||||
def __eq__(self, other):
|
||||
equal = self.rule_id == other.rule_id and self.message == other.message
|
||||
equal = equal and self.content == other.content and self.line_nr == other.line_nr
|
||||
return equal
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.line_nr}: {self.rule_id} {self.message}: \"{self.content}\""
|
||||
|
||||
|
||||
class UserRuleError(GitlintError):
|
||||
""" Error used to indicate that an error occurred while trying to load a user rule """
|
||||
pass
|
||||
|
||||
|
||||
class MaxLineLength(LineRule):
|
||||
name = "max-line-length"
|
||||
id = "R1"
|
||||
options_spec = [IntOption('line-length', 80, "Max line length")]
|
||||
violation_message = "Line exceeds max length ({0}>{1})"
|
||||
|
||||
def validate(self, line, _commit):
|
||||
max_length = self.options['line-length'].value
|
||||
if len(line) > max_length:
|
||||
return [RuleViolation(self.id, self.violation_message.format(len(line), max_length), line)]
|
||||
|
||||
|
||||
class TrailingWhiteSpace(LineRule):
|
||||
name = "trailing-whitespace"
|
||||
id = "R2"
|
||||
violation_message = "Line has trailing whitespace"
|
||||
pattern = re.compile(r"\s$", re.UNICODE)
|
||||
|
||||
def validate(self, line, _commit):
|
||||
if self.pattern.search(line):
|
||||
return [RuleViolation(self.id, self.violation_message, line)]
|
||||
|
||||
|
||||
class HardTab(LineRule):
|
||||
name = "hard-tab"
|
||||
id = "R3"
|
||||
violation_message = "Line contains hard tab characters (\\t)"
|
||||
|
||||
def validate(self, line, _commit):
|
||||
if "\t" in line:
|
||||
return [RuleViolation(self.id, self.violation_message, line)]
|
||||
|
||||
|
||||
class LineMustNotContainWord(LineRule):
|
||||
""" Violation if a line contains one of a list of words (NOTE: using a word in the list inside another word is not
|
||||
a violation, e.g: WIPING is not a violation if 'WIP' is a word that is not allowed.) """
|
||||
name = "line-must-not-contain"
|
||||
id = "R5"
|
||||
options_spec = [ListOption('words', [], "Comma separated list of words that should not be found")]
|
||||
violation_message = "Line contains {0}"
|
||||
|
||||
def validate(self, line, _commit):
|
||||
strings = self.options['words'].value
|
||||
violations = []
|
||||
for string in strings:
|
||||
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))
|
||||
return violations if violations else None
|
||||
|
||||
|
||||
class LeadingWhiteSpace(LineRule):
|
||||
name = "leading-whitespace"
|
||||
id = "R6"
|
||||
violation_message = "Line has leading whitespace"
|
||||
|
||||
def validate(self, line, _commit):
|
||||
pattern = re.compile(r"^\s", re.UNICODE)
|
||||
if pattern.search(line):
|
||||
return [RuleViolation(self.id, self.violation_message, line)]
|
||||
|
||||
|
||||
class TitleMaxLength(MaxLineLength):
|
||||
name = "title-max-length"
|
||||
id = "T1"
|
||||
target = CommitMessageTitle
|
||||
options_spec = [IntOption('line-length', 72, "Max line length")]
|
||||
violation_message = "Title exceeds max length ({0}>{1})"
|
||||
|
||||
|
||||
class TitleTrailingWhitespace(TrailingWhiteSpace):
|
||||
name = "title-trailing-whitespace"
|
||||
id = "T2"
|
||||
target = CommitMessageTitle
|
||||
violation_message = "Title has trailing whitespace"
|
||||
|
||||
|
||||
class TitleTrailingPunctuation(LineRule):
|
||||
name = "title-trailing-punctuation"
|
||||
id = "T3"
|
||||
target = CommitMessageTitle
|
||||
|
||||
def validate(self, title, _commit):
|
||||
punctuation_marks = '?:!.,;'
|
||||
for punctuation_mark in punctuation_marks:
|
||||
if title.endswith(punctuation_mark):
|
||||
return [RuleViolation(self.id, f"Title has trailing punctuation ({punctuation_mark})", title)]
|
||||
|
||||
|
||||
class TitleHardTab(HardTab):
|
||||
name = "title-hard-tab"
|
||||
id = "T4"
|
||||
target = CommitMessageTitle
|
||||
violation_message = "Title contains hard tab characters (\\t)"
|
||||
|
||||
|
||||
class TitleMustNotContainWord(LineMustNotContainWord):
|
||||
name = "title-must-not-contain-word"
|
||||
id = "T5"
|
||||
target = CommitMessageTitle
|
||||
options_spec = [ListOption('words', ["WIP"], "Must not contain word")]
|
||||
violation_message = "Title contains the word '{0}' (case-insensitive)"
|
||||
|
||||
|
||||
class TitleLeadingWhitespace(LeadingWhiteSpace):
|
||||
name = "title-leading-whitespace"
|
||||
id = "T6"
|
||||
target = CommitMessageTitle
|
||||
violation_message = "Title has leading whitespace"
|
||||
|
||||
|
||||
class TitleRegexMatches(LineRule):
|
||||
name = "title-match-regex"
|
||||
id = "T7"
|
||||
target = CommitMessageTitle
|
||||
options_spec = [RegexOption('regex', None, "Regex the title should match")]
|
||||
|
||||
def validate(self, title, _commit):
|
||||
# If no regex is specified, immediately return
|
||||
if not self.options['regex'].value:
|
||||
return
|
||||
|
||||
if not self.options['regex'].value.search(title):
|
||||
violation_msg = f"Title does not match regex ({self.options['regex'].value.pattern})"
|
||||
return [RuleViolation(self.id, violation_msg, title)]
|
||||
|
||||
|
||||
class TitleMinLength(LineRule):
|
||||
name = "title-min-length"
|
||||
id = "T8"
|
||||
target = CommitMessageTitle
|
||||
options_spec = [IntOption('min-length', 5, "Minimum required title length")]
|
||||
|
||||
def validate(self, title, _commit):
|
||||
min_length = self.options['min-length'].value
|
||||
actual_length = len(title)
|
||||
if actual_length < min_length:
|
||||
violation_message = f"Title is too short ({actual_length}<{min_length})"
|
||||
return [RuleViolation(self.id, violation_message, title, 1)]
|
||||
|
||||
|
||||
class BodyMaxLineLength(MaxLineLength):
|
||||
name = "body-max-line-length"
|
||||
id = "B1"
|
||||
target = CommitMessageBody
|
||||
|
||||
|
||||
class BodyTrailingWhitespace(TrailingWhiteSpace):
|
||||
name = "body-trailing-whitespace"
|
||||
id = "B2"
|
||||
target = CommitMessageBody
|
||||
|
||||
|
||||
class BodyHardTab(HardTab):
|
||||
name = "body-hard-tab"
|
||||
id = "B3"
|
||||
target = CommitMessageBody
|
||||
|
||||
|
||||
class BodyFirstLineEmpty(CommitRule):
|
||||
name = "body-first-line-empty"
|
||||
id = "B4"
|
||||
|
||||
def validate(self, commit):
|
||||
if len(commit.message.body) >= 1:
|
||||
first_line = commit.message.body[0]
|
||||
if first_line != "":
|
||||
return [RuleViolation(self.id, "Second line is not empty", first_line, 2)]
|
||||
|
||||
|
||||
class BodyMinLength(CommitRule):
|
||||
name = "body-min-length"
|
||||
id = "B5"
|
||||
options_spec = [IntOption('min-length', 20, "Minimum body length")]
|
||||
|
||||
def validate(self, commit):
|
||||
min_length = self.options['min-length'].value
|
||||
body_message_no_newline = "".join([line for line in commit.message.body if line is not None])
|
||||
actual_length = len(body_message_no_newline)
|
||||
if 0 < actual_length < min_length:
|
||||
violation_message = f"Body message is too short ({actual_length}<{min_length})"
|
||||
return [RuleViolation(self.id, violation_message, body_message_no_newline, 3)]
|
||||
|
||||
|
||||
class BodyMissing(CommitRule):
|
||||
name = "body-is-missing"
|
||||
id = "B6"
|
||||
options_spec = [BoolOption('ignore-merge-commits', True, "Ignore merge commits")]
|
||||
|
||||
def validate(self, commit):
|
||||
# ignore merges when option tells us to, which may have no body
|
||||
if self.options['ignore-merge-commits'].value and commit.is_merge_commit:
|
||||
return
|
||||
if len(commit.message.body) < 2 or not ''.join(commit.message.body).strip():
|
||||
return [RuleViolation(self.id, "Body message is missing", None, 3)]
|
||||
|
||||
|
||||
class BodyChangedFileMention(CommitRule):
|
||||
name = "body-changed-file-mention"
|
||||
id = "B7"
|
||||
options_spec = [ListOption('files', [], "Files that need to be mentioned")]
|
||||
|
||||
def validate(self, commit):
|
||||
violations = []
|
||||
for needs_mentioned_file in self.options['files'].value:
|
||||
# if a file that we need to look out for is actually changed, then check whether it occurs
|
||||
# in the commit msg body
|
||||
if needs_mentioned_file in commit.changed_files:
|
||||
if needs_mentioned_file not in " ".join(commit.message.body):
|
||||
violation_message = f"Body does not mention changed file '{needs_mentioned_file}'"
|
||||
violations.append(RuleViolation(self.id, violation_message, None, len(commit.message.body) + 1))
|
||||
return violations if violations else None
|
||||
|
||||
|
||||
class BodyRegexMatches(CommitRule):
|
||||
name = "body-match-regex"
|
||||
id = "B8"
|
||||
options_spec = [RegexOption('regex', None, "Regex the body should match")]
|
||||
|
||||
def validate(self, commit):
|
||||
# If no regex is specified, immediately return
|
||||
if not self.options['regex'].value:
|
||||
return
|
||||
|
||||
# We intentionally ignore the first line in the body as that's the empty line after the title,
|
||||
# which most users are not going to expect to be part of the body when matching a regex.
|
||||
# If this causes contention, we can always introduce an option to change the behavior in a backward-
|
||||
# compatible way.
|
||||
body_lines = commit.message.body[1:] if len(commit.message.body) > 1 else []
|
||||
|
||||
# Similarly, the last line is often empty, this has to do with how git returns commit messages
|
||||
# User's won't expect this, so prune it off by default
|
||||
if body_lines and body_lines[-1] == "":
|
||||
body_lines.pop()
|
||||
|
||||
full_body = "\n".join(body_lines)
|
||||
|
||||
if not self.options['regex'].value.search(full_body):
|
||||
violation_msg = f"Body does not match regex ({self.options['regex'].value.pattern})"
|
||||
return [RuleViolation(self.id, violation_msg, None, len(commit.message.body) + 1)]
|
||||
|
||||
|
||||
class AuthorValidEmail(CommitRule):
|
||||
name = "author-valid-email"
|
||||
id = "M1"
|
||||
options_spec = [RegexOption('regex', r"[^@ ]+@[^@ ]+\.[^@ ]+", "Regex that author email address should match")]
|
||||
|
||||
def validate(self, commit):
|
||||
# If no regex is specified, immediately return
|
||||
if not self.options['regex'].value:
|
||||
return
|
||||
|
||||
if commit.author_email and not self.options['regex'].value.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 = [RegexOption('regex', None, "Regex matching the titles 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.message.title):
|
||||
config.ignore = self.options['ignore'].value
|
||||
|
||||
message = f"Commit title '{commit.message.title}' 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)
|
||||
|
||||
|
||||
class IgnoreByBody(ConfigurationRule):
|
||||
name = "ignore-by-body"
|
||||
id = "I2"
|
||||
options_spec = [RegexOption('regex', None, "Regex matching lines of the body 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
|
||||
|
||||
for line in commit.message.body:
|
||||
if self.options['regex'].value.match(line):
|
||||
config.ignore = self.options['ignore'].value
|
||||
|
||||
message = f"Commit message line '{line}' matches the regex '{self.options['regex'].value.pattern}'," + \
|
||||
f" 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
|
||||
|
||||
|
||||
class IgnoreBodyLines(ConfigurationRule):
|
||||
name = "ignore-body-lines"
|
||||
id = "I3"
|
||||
options_spec = [RegexOption('regex', None, "Regex matching lines of the body that should be ignored")]
|
||||
|
||||
def apply(self, _, commit):
|
||||
# If no regex is specified, immediately return
|
||||
if not self.options['regex'].value:
|
||||
return
|
||||
|
||||
new_body = []
|
||||
for line in commit.message.body:
|
||||
if self.options['regex'].value.match(line):
|
||||
debug_msg = "Ignoring line '%s' because it matches '%s'"
|
||||
self.log.debug(debug_msg, line, self.options['regex'].value.pattern)
|
||||
else:
|
||||
new_body.append(line)
|
||||
|
||||
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
|
77
gitlint-core/gitlint/shell.py
Normal file
77
gitlint-core/gitlint/shell.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
|
||||
"""
|
||||
This module implements a shim for the 'sh' library, mainly for use on Windows (sh is not supported on Windows).
|
||||
We might consider removing the 'sh' dependency altogether in the future, but 'sh' does provide a few
|
||||
capabilities wrt dealing with more edge-case environments on *nix systems that are useful.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
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. """
|
||||
with subprocess.Popen(cmd, shell=True) as p:
|
||||
p.communicate()
|
||||
|
||||
|
||||
if USE_SH_LIB:
|
||||
from sh import git # pylint: disable=unused-import,import-error
|
||||
# import exceptions separately, this makes it a little easier to mock them out in the unit tests
|
||||
from sh import CommandNotFound, ErrorReturnCode # pylint: disable=import-error
|
||||
else:
|
||||
|
||||
class CommandNotFound(Exception):
|
||||
""" Exception indicating a command was not found during execution """
|
||||
pass
|
||||
|
||||
class ShResult:
|
||||
""" Result wrapper class. We use this to more easily migrate from using https://amoffat.github.io/sh/ to using
|
||||
the builtin subprocess module """
|
||||
|
||||
def __init__(self, full_cmd, stdout, stderr='', exitcode=0):
|
||||
self.full_cmd = full_cmd
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.exit_code = exitcode
|
||||
|
||||
def __str__(self):
|
||||
return self.stdout
|
||||
|
||||
class ErrorReturnCode(ShResult, Exception):
|
||||
""" ShResult subclass for unexpected results (acts as an exception). """
|
||||
pass
|
||||
|
||||
def git(*command_parts, **kwargs):
|
||||
""" Git shell wrapper.
|
||||
Implemented as separate function here, so we can do a 'sh' style imports:
|
||||
`from shell import git`
|
||||
"""
|
||||
args = ['git'] + list(command_parts)
|
||||
return _exec(*args, **kwargs)
|
||||
|
||||
def _exec(*args, **kwargs):
|
||||
pipe = subprocess.PIPE
|
||||
popen_kwargs = {'stdout': pipe, 'stderr': pipe, 'shell': kwargs.get('_tty_out', False)}
|
||||
if '_cwd' in kwargs:
|
||||
popen_kwargs['cwd'] = kwargs['_cwd']
|
||||
|
||||
try:
|
||||
with subprocess.Popen(args, **popen_kwargs) as p:
|
||||
result = p.communicate()
|
||||
except FileNotFoundError as e:
|
||||
raise CommandNotFound from e
|
||||
|
||||
exit_code = p.returncode
|
||||
stdout = result[0].decode(DEFAULT_ENCODING)
|
||||
stderr = result[1] # 'sh' does not decode the stderr bytes to unicode
|
||||
full_cmd = '' if args is None else ' '.join(args)
|
||||
|
||||
# If not _ok_code is specified, then only a 0 exit code is allowed
|
||||
ok_exit_codes = kwargs.get('_ok_code', [0])
|
||||
|
||||
if exit_code in ok_exit_codes:
|
||||
return ShResult(full_cmd, stdout, stderr, exit_code)
|
||||
|
||||
# Unexpected error code => raise ErrorReturnCode
|
||||
raise ErrorReturnCode(full_cmd, stdout, stderr, p.returncode)
|
0
gitlint-core/gitlint/tests/__init__.py
Normal file
0
gitlint-core/gitlint/tests/__init__.py
Normal file
191
gitlint-core/gitlint/tests/base.py
Normal file
191
gitlint-core/gitlint/tests/base.py
Normal file
|
@ -0,0 +1,191 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import unittest
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from gitlint.git import GitContext
|
||||
from gitlint.utils import LOG_FORMAT, DEFAULT_ENCODING
|
||||
|
||||
|
||||
class BaseTestCase(unittest.TestCase):
|
||||
""" Base class of which all gitlint unit test classes are derived. Provides a number of convenience methods. """
|
||||
|
||||
# In case of assert failures, print the full error message
|
||||
maxDiff = None
|
||||
|
||||
SAMPLES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "samples")
|
||||
EXPECTED_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected")
|
||||
GITLINT_USE_SH_LIB = os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]")
|
||||
|
||||
def setUp(self):
|
||||
self.logcapture = LogCapture()
|
||||
self.logcapture.setFormatter(logging.Formatter(LOG_FORMAT))
|
||||
logging.getLogger('gitlint').setLevel(logging.DEBUG)
|
||||
logging.getLogger('gitlint').handlers = [self.logcapture]
|
||||
|
||||
# Make sure we don't propagate anything to child loggers, we need to do this explicitly here
|
||||
# because if you run a specific test file like test_lint.py, we won't be calling the setupLogging() method
|
||||
# in gitlint.cli that normally takes care of this
|
||||
logging.getLogger('gitlint').propagate = False
|
||||
|
||||
@staticmethod
|
||||
@contextlib.contextmanager
|
||||
def tempdir():
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
yield tmpdir
|
||||
finally:
|
||||
shutil.rmtree(tmpdir)
|
||||
|
||||
@staticmethod
|
||||
def get_sample_path(filename=""):
|
||||
# Don't join up empty files names because this will add a trailing slash
|
||||
if filename == "":
|
||||
return BaseTestCase.SAMPLES_DIR
|
||||
|
||||
return os.path.join(BaseTestCase.SAMPLES_DIR, filename)
|
||||
|
||||
@staticmethod
|
||||
def get_sample(filename=""):
|
||||
""" Read and return the contents of a file in gitlint/tests/samples """
|
||||
sample_path = BaseTestCase.get_sample_path(filename)
|
||||
with io.open(sample_path, encoding=DEFAULT_ENCODING) as content:
|
||||
sample = content.read()
|
||||
return sample
|
||||
|
||||
@staticmethod
|
||||
def patch_input(side_effect):
|
||||
""" Patches the built-in input() with a provided side-effect """
|
||||
module_path = "builtins.input"
|
||||
patched_module = patch(module_path, side_effect=side_effect)
|
||||
return patched_module
|
||||
|
||||
@staticmethod
|
||||
def get_expected(filename="", variable_dict=None):
|
||||
""" Utility method to read an expected file from gitlint/tests/expected and return it as a string.
|
||||
Optionally replace template variables specified by variable_dict. """
|
||||
expected_path = os.path.join(BaseTestCase.EXPECTED_DIR, filename)
|
||||
with io.open(expected_path, encoding=DEFAULT_ENCODING) as content:
|
||||
expected = content.read()
|
||||
|
||||
if variable_dict:
|
||||
expected = expected.format(**variable_dict)
|
||||
return expected
|
||||
|
||||
@staticmethod
|
||||
def get_user_rules_path():
|
||||
return os.path.join(BaseTestCase.SAMPLES_DIR, "user_rules")
|
||||
|
||||
@staticmethod
|
||||
def gitcontext(commit_msg_str, changed_files=None, ):
|
||||
""" Utility method to easily create gitcontext objects based on a given commit msg string and an optional set of
|
||||
changed files"""
|
||||
with patch("gitlint.git.git_commentchar") as comment_char:
|
||||
comment_char.return_value = "#"
|
||||
gitcontext = GitContext.from_commit_msg(commit_msg_str)
|
||||
commit = gitcontext.commits[-1]
|
||||
if changed_files:
|
||||
commit.changed_files = changed_files
|
||||
return gitcontext
|
||||
|
||||
@staticmethod
|
||||
def gitcommit(commit_msg_str, changed_files=None, **kwargs):
|
||||
""" Utility method to easily create git commit given a commit msg string and an optional set of changed files"""
|
||||
gitcontext = BaseTestCase.gitcontext(commit_msg_str, changed_files)
|
||||
commit = gitcontext.commits[-1]
|
||||
for attr, value in kwargs.items():
|
||||
setattr(commit, attr, value)
|
||||
return commit
|
||||
|
||||
def assert_logged(self, expected):
|
||||
""" Asserts that the logs match an expected string or list.
|
||||
This method knows how to compare a passed list of log lines as well as a newline concatenated string
|
||||
of all loglines. """
|
||||
if isinstance(expected, list):
|
||||
self.assertListEqual(self.logcapture.messages, expected)
|
||||
else:
|
||||
self.assertEqual("\n".join(self.logcapture.messages), expected)
|
||||
|
||||
def assert_log_contains(self, line):
|
||||
""" Asserts that a certain line is in the logs """
|
||||
self.assertIn(line, self.logcapture.messages)
|
||||
|
||||
def assertRaisesRegex(self, expected_exception, expected_regex, *args, **kwargs):
|
||||
""" Pass-through method to unittest.TestCase.assertRaisesRegex that applies re.escape() to the passed
|
||||
`expected_regex`. This is useful to automatically escape all file paths that might be present in the regex.
|
||||
"""
|
||||
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 """
|
||||
try:
|
||||
yield
|
||||
except expected_exception as exc:
|
||||
exception_msg = str(exc)
|
||||
if exception_msg != expected_msg:
|
||||
error = f"Right exception, wrong message:\n got: {exception_msg}\n expected: {expected_msg}"
|
||||
raise self.fail(error)
|
||||
# else: everything is fine, just return
|
||||
return
|
||||
except Exception as exc:
|
||||
raise self.fail(f"Expected '{expected_exception.__name__}' got '{exc.__class__.__name__}'")
|
||||
|
||||
# No exception raised while we expected one
|
||||
raise self.fail(f"Expected to raise {expected_exception.__name__}, didn't get an exception at all")
|
||||
|
||||
def object_equality_test(self, obj, attr_list, ctor_kwargs=None):
|
||||
""" Helper function to easily implement object equality tests.
|
||||
Creates an object clone for every passed attribute and checks for (in)equality
|
||||
of the original object with the clone based on those attributes' values.
|
||||
This function assumes all attributes in `attr_list` can be passed to the ctor of `obj.__class__`.
|
||||
"""
|
||||
if not ctor_kwargs:
|
||||
ctor_kwargs = {}
|
||||
|
||||
attr_kwargs = {}
|
||||
for attr in attr_list:
|
||||
attr_kwargs[attr] = getattr(obj, attr)
|
||||
|
||||
# For every attr, clone the object and assert the clone and the original object are equal
|
||||
# Then, change the current attr and assert objects are unequal
|
||||
for attr in attr_list:
|
||||
attr_kwargs_copy = copy.deepcopy(attr_kwargs)
|
||||
attr_kwargs_copy.update(ctor_kwargs)
|
||||
clone = obj.__class__(**attr_kwargs_copy)
|
||||
self.assertEqual(obj, clone)
|
||||
|
||||
# Change attribute and assert objects are different (via both attribute set and ctor)
|
||||
setattr(clone, attr, "föo")
|
||||
self.assertNotEqual(obj, clone)
|
||||
attr_kwargs_copy[attr] = "föo"
|
||||
|
||||
self.assertNotEqual(obj, obj.__class__(**attr_kwargs_copy))
|
||||
|
||||
|
||||
class LogCapture(logging.Handler):
|
||||
""" Mock logging handler used to capture any log messages during tests."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
logging.Handler.__init__(self, *args, **kwargs)
|
||||
self.messages = []
|
||||
|
||||
def emit(self, record):
|
||||
self.messages.append(self.format(record))
|
||||
|
||||
def clear(self):
|
||||
self.messages = []
|
593
gitlint-core/gitlint/tests/cli/test_cli.py
Normal file
593
gitlint-core/gitlint/tests/cli/test_cli.py
Normal file
|
@ -0,0 +1,593 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
|
||||
import arrow
|
||||
|
||||
from io import StringIO
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from gitlint.shell import CommandNotFound
|
||||
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint import cli
|
||||
from gitlint import __version__
|
||||
from gitlint.utils import DEFAULT_ENCODING
|
||||
|
||||
|
||||
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()
|
||||
self.cli = CliRunner()
|
||||
|
||||
# Patch gitlint.cli.git_version() so that we don't have to patch it separately in every test
|
||||
self.git_version_path = patch('gitlint.cli.git_version')
|
||||
cli.git_version = self.git_version_path.start()
|
||||
cli.git_version.return_value = "git version 1.2.3"
|
||||
|
||||
def tearDown(self):
|
||||
self.git_version_path.stop()
|
||||
|
||||
@staticmethod
|
||||
def get_system_info_dict():
|
||||
""" Returns a dict with items related to system values logged by `gitlint --debug` """
|
||||
return {'platform': platform.platform(), "python_version": sys.version, 'gitlint_version': __version__,
|
||||
'GITLINT_USE_SH_LIB': BaseTestCase.GITLINT_USE_SH_LIB, 'target': os.path.realpath(os.getcwd()),
|
||||
'DEFAULT_ENCODING': DEFAULT_ENCODING}
|
||||
|
||||
def test_version(self):
|
||||
""" Test for --version option """
|
||||
result = self.cli.invoke(cli.cli, ["--version"])
|
||||
self.assertEqual(result.output.split("\n")[0], f"cli, version {__version__}")
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=False)
|
||||
@patch('gitlint.git.sh')
|
||||
def test_lint(self, sh, _):
|
||||
""" Test for basic simple linting functionality """
|
||||
sh.git.side_effect = [
|
||||
"6f29bf81a8322a04071bb794666e48c443a90360",
|
||||
"test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
|
||||
"commït-title\n\ncommït-body",
|
||||
"#", # git config --get core.commentchar
|
||||
"commit-1-branch-1\ncommit-1-branch-2\n",
|
||||
"file1.txt\npåth/to/file2.txt\n"
|
||||
]
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli)
|
||||
self.assertEqual(stderr.getvalue(), u'3: B5 Body message is too short (11<20): "commït-body"\n')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=False)
|
||||
@patch('gitlint.git.sh')
|
||||
def test_lint_multiple_commits(self, sh, _):
|
||||
""" Test for --commits option """
|
||||
|
||||
sh.git.side_effect = [
|
||||
"6f29bf81a8322a04071bb794666e48c443a90360\n" + # git rev-list <SHA>
|
||||
"25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" +
|
||||
"4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
|
||||
# git log --pretty <FORMAT> <SHA>
|
||||
"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
|
||||
"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
|
||||
# git log --pretty <FORMAT> <SHA>
|
||||
"test åuthor2\x00test-email3@föo.com\x002016-12-04 15:28:15 +0100\x00åbc\n"
|
||||
"commït-title2\n\ncommït-body2",
|
||||
"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
|
||||
"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
|
||||
# git log --pretty <FORMAT> <SHA>
|
||||
"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00åbc\n"
|
||||
"commït-title3\n\ncommït-body3",
|
||||
"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
|
||||
"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
|
||||
]
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--commits", "foo...bar"])
|
||||
self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_lint_multiple_commits_1"))
|
||||
self.assertEqual(result.exit_code, 3)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=False)
|
||||
@patch('gitlint.git.sh')
|
||||
def test_lint_multiple_commits_config(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>
|
||||
"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
|
||||
"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
|
||||
# git log --pretty <FORMAT> <SHA>
|
||||
"test åuthor2\x00test-email2@föo.com\x002016-12-04 15:28:15 +0100\x00åbc\n"
|
||||
"commït-title2.\n\ncommït-body2\ngitlint-ignore: T3\n",
|
||||
"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
|
||||
"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
|
||||
# git log --pretty <FORMAT> <SHA>
|
||||
"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00åbc\n"
|
||||
"commït-title3.\n\ncommït-body3",
|
||||
"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
|
||||
"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
|
||||
]
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--commits", "foo...bar"])
|
||||
# We expect that the second commit has no failures because of 'gitlint-ignore: T3' in its commit msg body
|
||||
self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_lint_multiple_commits_config_1"))
|
||||
self.assertEqual(result.exit_code, 3)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=False)
|
||||
@patch('gitlint.git.sh')
|
||||
def test_lint_multiple_commits_configuration_rules(self, sh, _):
|
||||
""" Test for --commits option where where we have configured gitlint to ignore certain rules for certain commits
|
||||
"""
|
||||
|
||||
# Note that the second commit
|
||||
sh.git.side_effect = [
|
||||
"6f29bf81a8322a04071bb794666e48c443a90360\n" + # git rev-list <SHA>
|
||||
"25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n" +
|
||||
"4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
|
||||
# git log --pretty <FORMAT> <SHA>
|
||||
"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
|
||||
"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
|
||||
# git log --pretty <FORMAT> <SHA>
|
||||
"test åuthor2\x00test-email3@föo.com\x002016-12-04 15:28:15 +0100\x00åbc\n"
|
||||
# Normally T3 violation (trailing punctuation), but this commit is ignored because of
|
||||
# config below
|
||||
"commït-title2.\n\ncommït-body2\n",
|
||||
"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
|
||||
"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
|
||||
# git log --pretty <FORMAT> <SHA>
|
||||
"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00åbc\n"
|
||||
# Normally T1 and B5 violations, now only T1 because we're ignoring B5 in config below
|
||||
"commït-title3.\n\ncommït-body3 foo",
|
||||
"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
|
||||
"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
|
||||
]
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--commits", "foo...bar", "-c", "I1.regex=^commït-title2(.*)",
|
||||
"-c", "I2.regex=^commït-body3(.*)", "-c", "I2.ignore=B5"])
|
||||
# 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 = ("Commit 6f29bf81a8:\n"
|
||||
u'3: B5 Body message is too short (12<20): "commït-body1"\n\n'
|
||||
"Commit 4da2656b0d:\n"
|
||||
u'1: T3 Title has trailing punctuation (.): "commït-title3."\n')
|
||||
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 """
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli)
|
||||
self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_input_stream_1"))
|
||||
self.assertEqual(result.exit_code, 3)
|
||||
self.assertEqual(result.output, "")
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=u'WIP: tïtle \n')
|
||||
def test_input_stream_debug(self, _):
|
||||
""" Test for linting when a message is passed via stdin, and debug is enabled.
|
||||
This tests specifically that git commit meta is not fetched when not passing --staged """
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--debug"])
|
||||
self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_input_stream_debug_1"))
|
||||
self.assertEqual(result.exit_code, 3)
|
||||
self.assertEqual(result.output, "")
|
||||
expected_kwargs = self.get_system_info_dict()
|
||||
expected_logs = self.get_expected('cli/test_cli/test_input_stream_debug_2', expected_kwargs)
|
||||
self.assert_logged(expected_logs)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="Should be ignored\n")
|
||||
@patch('gitlint.git.sh')
|
||||
def test_lint_ignore_stdin(self, sh, stdin_data):
|
||||
""" Test for ignoring stdin when --ignore-stdin flag is enabled"""
|
||||
sh.git.side_effect = [
|
||||
"6f29bf81a8322a04071bb794666e48c443a90360",
|
||||
"test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
|
||||
"commït-title\n\ncommït-body",
|
||||
"#", # git config --get core.commentchar
|
||||
"commit-1-branch-1\ncommit-1-branch-2\n", # git branch --contains <sha>
|
||||
"file1.txt\npåth/to/file2.txt\n" # git diff-tree
|
||||
]
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--ignore-stdin"])
|
||||
self.assertEqual(stderr.getvalue(), u'3: B5 Body message is too short (11<20): "commït-body"\n')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
|
||||
# Assert that we didn't even try to get the stdin data
|
||||
self.assertEqual(stdin_data.call_count, 0)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=u'WIP: tïtle \n')
|
||||
@patch('arrow.now', return_value=arrow.get("2020-02-19T12:18:46.675182+01:00"))
|
||||
@patch('gitlint.git.sh')
|
||||
def test_lint_staged_stdin(self, sh, _, __):
|
||||
""" Test for ignoring stdin when --ignore-stdin flag is enabled"""
|
||||
|
||||
sh.git.side_effect = [
|
||||
"#", # git config --get core.commentchar
|
||||
"föo user\n", # git config --get user.name
|
||||
"föo@bar.com\n", # git config --get user.email
|
||||
"my-branch\n", # git rev-parse --abbrev-ref HEAD (=current branch)
|
||||
"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, ["--debug", "--staged"])
|
||||
self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_lint_staged_stdin_1"))
|
||||
self.assertEqual(result.exit_code, 3)
|
||||
self.assertEqual(result.output, "")
|
||||
|
||||
expected_kwargs = self.get_system_info_dict()
|
||||
expected_logs = self.get_expected('cli/test_cli/test_lint_staged_stdin_2', expected_kwargs)
|
||||
self.assert_logged(expected_logs)
|
||||
|
||||
@patch('arrow.now', return_value=arrow.get("2020-02-19T12:18:46.675182+01:00"))
|
||||
@patch('gitlint.git.sh')
|
||||
def test_lint_staged_msg_filename(self, sh, _):
|
||||
""" Test for ignoring stdin when --ignore-stdin flag is enabled"""
|
||||
|
||||
sh.git.side_effect = [
|
||||
"#", # git config --get core.commentchar
|
||||
"föo user\n", # git config --get user.name
|
||||
"föo@bar.com\n", # git config --get user.email
|
||||
"my-branch\n", # git rev-parse --abbrev-ref HEAD (=current branch)
|
||||
"commit-1/file-1\ncommit-1/file-2\n", # git diff-tree
|
||||
]
|
||||
|
||||
with self.tempdir() as tmpdir:
|
||||
msg_filename = os.path.join(tmpdir, "msg")
|
||||
with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
|
||||
f.write("WIP: msg-filename tïtle\n")
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--debug", "--staged", "--msg-filename", msg_filename])
|
||||
self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_lint_staged_msg_filename_1"))
|
||||
self.assertEqual(result.exit_code, 2)
|
||||
self.assertEqual(result.output, "")
|
||||
|
||||
expected_kwargs = self.get_system_info_dict()
|
||||
expected_logs = self.get_expected('cli/test_cli/test_lint_staged_msg_filename_2', expected_kwargs)
|
||||
self.assert_logged(expected_logs)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=False)
|
||||
def test_lint_staged_negative(self, _):
|
||||
result = self.cli.invoke(cli.cli, ["--staged"])
|
||||
self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
|
||||
self.assertEqual(result.output, ("Error: The 'staged' option (--staged) can only be used when using "
|
||||
"'--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"
|
||||
|
||||
with self.tempdir() as tmpdir:
|
||||
msg_filename = os.path.join(tmpdir, "msg")
|
||||
with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
|
||||
f.write("Commït title\n")
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename])
|
||||
self.assertEqual(stderr.getvalue(), expected_output)
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertEqual(result.output, "")
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="WIP: tïtle \n")
|
||||
def test_silent_mode(self, _):
|
||||
""" Test for --silent option """
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--silent"])
|
||||
self.assertEqual(stderr.getvalue(), "")
|
||||
self.assertEqual(result.exit_code, 3)
|
||||
self.assertEqual(result.output, "")
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="WIP: tïtle \n")
|
||||
def test_verbosity(self, _):
|
||||
""" Test for --verbosity option """
|
||||
# We only test -v and -vv, more testing is really not required here
|
||||
# -v
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["-v"])
|
||||
self.assertEqual(stderr.getvalue(), "1: T2\n1: T5\n3: B6\n")
|
||||
self.assertEqual(result.exit_code, 3)
|
||||
self.assertEqual(result.output, "")
|
||||
|
||||
# -vv
|
||||
expected_output = "1: T2 Title has trailing whitespace\n" + \
|
||||
"1: T5 Title contains the word 'WIP' (case-insensitive)\n" + \
|
||||
"3: B6 Body message is missing\n"
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["-vv"], input="WIP: tïtle \n")
|
||||
self.assertEqual(stderr.getvalue(), expected_output)
|
||||
self.assertEqual(result.exit_code, 3)
|
||||
self.assertEqual(result.output, "")
|
||||
|
||||
# -vvvv: not supported -> should print a config error
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["-vvvv"], input=u'WIP: tïtle \n')
|
||||
self.assertEqual(stderr.getvalue(), "")
|
||||
self.assertEqual(result.exit_code, CLITests.CONFIG_ERROR_CODE)
|
||||
self.assertEqual(result.output, "Config Error: Option 'verbosity' must be set between 0 and 3\n")
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=False)
|
||||
@patch('gitlint.git.sh')
|
||||
def test_debug(self, sh, _):
|
||||
""" Test for --debug option """
|
||||
|
||||
sh.git.side_effect = [
|
||||
"6f29bf81a8322a04071bb794666e48c443a90360\n" # git rev-list <SHA>
|
||||
"25053ccec5e28e1bb8f7551fdbb5ab213ada2401\n"
|
||||
"4da2656b0dadc76c7ee3fd0243a96cb64007f125\n",
|
||||
# git log --pretty <FORMAT> <SHA>
|
||||
"test åuthor1\x00test-email1@föo.com\x002016-12-03 15:28:15 +0100\x00abc\n"
|
||||
"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
|
||||
"test åuthor2\x00test-email2@föo.com\x002016-12-04 15:28:15 +0100\x00abc\n"
|
||||
"commït-title2.\n\ncommït-body2",
|
||||
"commit-2-branch-1\ncommit-2-branch-2\n", # git branch --contains <sha>
|
||||
"commit-2/file-1\ncommit-2/file-2\n", # git diff-tree
|
||||
"test åuthor3\x00test-email3@föo.com\x002016-12-05 15:28:15 +0100\x00abc\n"
|
||||
"föobar\nbar",
|
||||
"commit-3-branch-1\ncommit-3-branch-2\n", # git branch --contains <sha>
|
||||
"commit-3/file-1\ncommit-3/file-2\n", # git diff-tree
|
||||
]
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
config_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
|
||||
result = self.cli.invoke(cli.cli, ["--config", config_path, "--debug", "--commits",
|
||||
"foo...bar"])
|
||||
|
||||
expected = "Commit 6f29bf81a8:\n3: B5\n\n" + \
|
||||
"Commit 25053ccec5:\n1: T3\n3: B5\n\n" + \
|
||||
"Commit 4da2656b0d:\n2: B4\n3: B5\n3: B6\n"
|
||||
|
||||
self.assertEqual(stderr.getvalue(), expected)
|
||||
self.assertEqual(result.exit_code, 6)
|
||||
|
||||
expected_kwargs = self.get_system_info_dict()
|
||||
expected_kwargs.update({'config_path': config_path})
|
||||
expected_logs = self.get_expected('cli/test_cli/test_debug_1', expected_kwargs)
|
||||
self.assert_logged(expected_logs)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="Test tïtle\n")
|
||||
def test_extra_path(self, _):
|
||||
""" Test for --extra-path flag """
|
||||
# Test extra-path pointing to a directory
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
extra_path = self.get_sample_path("user_rules")
|
||||
result = self.cli.invoke(cli.cli, ["--extra-path", extra_path])
|
||||
expected_output = "1: UC1 Commit violåtion 1: \"Contënt 1\"\n" + \
|
||||
"3: B6 Body message is missing\n"
|
||||
self.assertEqual(stderr.getvalue(), expected_output)
|
||||
self.assertEqual(result.exit_code, 2)
|
||||
|
||||
# Test extra-path pointing to a file
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
extra_path = self.get_sample_path(os.path.join("user_rules", "my_commit_rules.py"))
|
||||
result = self.cli.invoke(cli.cli, ["--extra-path", extra_path])
|
||||
expected_output = "1: UC1 Commit violåtion 1: \"Contënt 1\"\n" + \
|
||||
"3: B6 Body message is missing\n"
|
||||
self.assertEqual(stderr.getvalue(), expected_output)
|
||||
self.assertEqual(result.exit_code, 2)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="Test tïtle\n\nMy body that is long enough")
|
||||
def test_contrib(self, _):
|
||||
# Test enabled contrib rules
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
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, 2)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="Test tïtle\n")
|
||||
def test_contrib_negative(self, _):
|
||||
result = self.cli.invoke(cli.cli, ["--contrib", "föobar,CC1"])
|
||||
self.assertEqual(result.output, "Config Error: No contrib rule with id or name 'föobar' found.\n")
|
||||
self.assertEqual(result.exit_code, self.CONFIG_ERROR_CODE)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="WIP: tëst")
|
||||
def test_config_file(self, _):
|
||||
""" Test for --config option """
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
config_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
|
||||
result = self.cli.invoke(cli.cli, ["--config", config_path])
|
||||
self.assertEqual(result.output, "")
|
||||
self.assertEqual(stderr.getvalue(), "1: T5\n3: B6\n")
|
||||
self.assertEqual(result.exit_code, 2)
|
||||
|
||||
def test_config_file_negative(self):
|
||||
""" Negative test for --config option """
|
||||
# Directory as config file
|
||||
config_path = self.get_sample_path("config")
|
||||
result = self.cli.invoke(cli.cli, ["--config", config_path])
|
||||
expected_string = f"Error: Invalid value for '-C' / '--config': File '{config_path}' is a directory."
|
||||
self.assertEqual(result.output.split("\n")[3], expected_string)
|
||||
self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
|
||||
|
||||
# Non existing file
|
||||
config_path = self.get_sample_path("föo")
|
||||
result = self.cli.invoke(cli.cli, ["--config", config_path])
|
||||
expected_string = f"Error: Invalid value for '-C' / '--config': File '{config_path}' does not exist."
|
||||
self.assertEqual(result.output.split("\n")[3], expected_string)
|
||||
self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
|
||||
|
||||
# Invalid config file
|
||||
config_path = self.get_sample_path(os.path.join("config", "invalid-option-value"))
|
||||
result = self.cli.invoke(cli.cli, ["--config", config_path])
|
||||
self.assertEqual(result.exit_code, self.CONFIG_ERROR_CODE)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=False)
|
||||
def test_target(self, _):
|
||||
""" Test for the --target option """
|
||||
with self.tempdir() as tmpdir:
|
||||
tmpdir_path = os.path.realpath(tmpdir)
|
||||
os.environ["LANGUAGE"] = "C" # Force language to english so we can check for error message
|
||||
result = self.cli.invoke(cli.cli, ["--target", tmpdir_path])
|
||||
# We expect gitlint to tell us that /tmp is not a git repo (this proves that it takes the target parameter
|
||||
# into account).
|
||||
self.assertEqual(result.output, "%s is not a git repository.\n" % tmpdir_path)
|
||||
self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
|
||||
|
||||
def test_target_negative(self):
|
||||
""" Negative test for the --target option """
|
||||
# try setting a non-existing target
|
||||
result = self.cli.invoke(cli.cli, ["--target", "/föo/bar"])
|
||||
self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
|
||||
expected_msg = "Error: Invalid value for '--target': Directory '/föo/bar' does not exist."
|
||||
self.assertEqual(result.output.split("\n")[3], expected_msg)
|
||||
|
||||
# try setting a file as target
|
||||
target_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
|
||||
result = self.cli.invoke(cli.cli, ["--target", target_path])
|
||||
self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
|
||||
expected_msg = f"Error: Invalid value for '--target': Directory '{target_path}' is a file."
|
||||
self.assertEqual(result.output.split("\n")[3], expected_msg)
|
||||
|
||||
@patch('gitlint.config.LintConfigGenerator.generate_config')
|
||||
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, 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)
|
||||
generate_config.assert_called_once_with(os.path.realpath("tëstfile"))
|
||||
|
||||
def test_generate_config_negative(self):
|
||||
""" Negative test for the generate-config subcommand """
|
||||
# Non-existing directory
|
||||
fake_dir = os.path.abspath("/föo")
|
||||
fake_path = os.path.join(fake_dir, "bar")
|
||||
result = self.cli.invoke(cli.cli, ["generate-config"], input=fake_path)
|
||||
self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
|
||||
expected_msg = f"Please specify a location for the sample gitlint config file [.gitlint]: {fake_path}\n" + \
|
||||
f"Error: Directory '{fake_dir}' does not exist.\n"
|
||||
self.assertEqual(result.output, expected_msg)
|
||||
|
||||
# Existing file
|
||||
sample_path = self.get_sample_path(os.path.join("config", "gitlintconfig"))
|
||||
result = self.cli.invoke(cli.cli, ["generate-config"], input=sample_path)
|
||||
self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
|
||||
expected_msg = "Please specify a location for the sample gitlint " + \
|
||||
f"config file [.gitlint]: {sample_path}\n" + \
|
||||
f"Error: File \"{sample_path}\" already exists.\n"
|
||||
self.assertEqual(result.output, expected_msg)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=False)
|
||||
@patch('gitlint.git.sh')
|
||||
def test_git_error(self, sh, _):
|
||||
""" Tests that the cli handles git errors properly """
|
||||
sh.git.side_effect = CommandNotFound("git")
|
||||
result = self.cli.invoke(cli.cli)
|
||||
self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=False)
|
||||
@patch('gitlint.git.sh')
|
||||
def test_no_commits_in_range(self, sh, _):
|
||||
""" Test for --commits with the specified range being empty. """
|
||||
sh.git.side_effect = lambda *_args, **_kwargs: ""
|
||||
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, self.GITLINT_SUCCESS_CODE)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="WIP: tëst tïtle")
|
||||
def test_named_rules(self, _):
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
config_path = self.get_sample_path(os.path.join("config", "named-rules"))
|
||||
result = self.cli.invoke(cli.cli, ["--config", config_path, "--debug"])
|
||||
self.assertEqual(result.output, "")
|
||||
self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli/test_named_rules_1"))
|
||||
self.assertEqual(result.exit_code, 4)
|
||||
|
||||
# Assert debug logs are correct
|
||||
expected_kwargs = self.get_system_info_dict()
|
||||
expected_kwargs.update({'config_path': config_path})
|
||||
expected_logs = self.get_expected('cli/test_cli/test_named_rules_2', expected_kwargs)
|
||||
self.assert_logged(expected_logs)
|
281
gitlint-core/gitlint/tests/cli/test_cli_hooks.py
Normal file
281
gitlint-core/gitlint/tests/cli/test_cli_hooks.py
Normal file
|
@ -0,0 +1,281 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import io
|
||||
from io import StringIO
|
||||
import os
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint import cli
|
||||
from gitlint import hooks
|
||||
from gitlint import config
|
||||
from gitlint.shell import ErrorReturnCode
|
||||
|
||||
from gitlint.utils import DEFAULT_ENCODING
|
||||
|
||||
|
||||
class CLIHookTests(BaseTestCase):
|
||||
USAGE_ERROR_CODE = 253
|
||||
GIT_CONTEXT_ERROR_CODE = 254
|
||||
CONFIG_ERROR_CODE = 255
|
||||
|
||||
def setUp(self):
|
||||
super(CLIHookTests, self).setUp()
|
||||
self.cli = CliRunner()
|
||||
|
||||
# Patch gitlint.cli.git_version() so that we don't have to patch it separately in every test
|
||||
self.git_version_path = patch('gitlint.cli.git_version')
|
||||
cli.git_version = self.git_version_path.start()
|
||||
cli.git_version.return_value = "git version 1.2.3"
|
||||
|
||||
def tearDown(self):
|
||||
self.git_version_path.stop()
|
||||
|
||||
@patch('gitlint.hooks.GitHookInstaller.install_commit_msg_hook')
|
||||
@patch('gitlint.hooks.git_hooks_dir', return_value=os.path.join("/hür", "dur"))
|
||||
def test_install_hook(self, _, install_hook):
|
||||
""" Test for install-hook subcommand """
|
||||
result = self.cli.invoke(cli.cli, ["install-hook"])
|
||||
expected_path = os.path.join("/hür", "dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
|
||||
expected = f"Successfully installed gitlint commit-msg hook in {expected_path}\n"
|
||||
self.assertEqual(result.output, expected)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
expected_config = config.LintConfig()
|
||||
expected_config.target = os.path.realpath(os.getcwd())
|
||||
install_hook.assert_called_once_with(expected_config)
|
||||
|
||||
@patch('gitlint.hooks.GitHookInstaller.install_commit_msg_hook')
|
||||
@patch('gitlint.hooks.git_hooks_dir', return_value=os.path.join("/hür", "dur"))
|
||||
def test_install_hook_target(self, _, install_hook):
|
||||
""" Test for install-hook subcommand with a specific --target option specified """
|
||||
# Specified target
|
||||
result = self.cli.invoke(cli.cli, ["--target", self.SAMPLES_DIR, "install-hook"])
|
||||
expected_path = os.path.join("/hür", "dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
|
||||
expected = "Successfully installed gitlint commit-msg hook in %s\n" % expected_path
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
self.assertEqual(result.output, expected)
|
||||
|
||||
expected_config = config.LintConfig()
|
||||
expected_config.target = self.SAMPLES_DIR
|
||||
install_hook.assert_called_once_with(expected_config)
|
||||
|
||||
@patch('gitlint.hooks.GitHookInstaller.install_commit_msg_hook', side_effect=hooks.GitHookInstallerError("tëst"))
|
||||
def test_install_hook_negative(self, install_hook):
|
||||
""" Negative test for install-hook subcommand """
|
||||
result = self.cli.invoke(cli.cli, ["install-hook"])
|
||||
self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
|
||||
self.assertEqual(result.output, "tëst\n")
|
||||
expected_config = config.LintConfig()
|
||||
expected_config.target = os.path.realpath(os.getcwd())
|
||||
install_hook.assert_called_once_with(expected_config)
|
||||
|
||||
@patch('gitlint.hooks.GitHookInstaller.uninstall_commit_msg_hook')
|
||||
@patch('gitlint.hooks.git_hooks_dir', return_value=os.path.join("/hür", "dur"))
|
||||
def test_uninstall_hook(self, _, uninstall_hook):
|
||||
""" Test for uninstall-hook subcommand """
|
||||
result = self.cli.invoke(cli.cli, ["uninstall-hook"])
|
||||
expected_path = os.path.join("/hür", "dur", hooks.COMMIT_MSG_HOOK_DST_PATH)
|
||||
expected = f"Successfully uninstalled gitlint commit-msg hook from {expected_path}\n"
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
self.assertEqual(result.output, expected)
|
||||
expected_config = config.LintConfig()
|
||||
expected_config.target = os.path.realpath(os.getcwd())
|
||||
uninstall_hook.assert_called_once_with(expected_config)
|
||||
|
||||
@patch('gitlint.hooks.GitHookInstaller.uninstall_commit_msg_hook', side_effect=hooks.GitHookInstallerError("tëst"))
|
||||
def test_uninstall_hook_negative(self, uninstall_hook):
|
||||
""" Negative test for uninstall-hook subcommand """
|
||||
result = self.cli.invoke(cli.cli, ["uninstall-hook"])
|
||||
self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
|
||||
self.assertEqual(result.output, "tëst\n")
|
||||
expected_config = config.LintConfig()
|
||||
expected_config.target = os.path.realpath(os.getcwd())
|
||||
uninstall_hook.assert_called_once_with(expected_config)
|
||||
|
||||
def test_run_hook_no_tty(self):
|
||||
""" Test for run-hook subcommand.
|
||||
When no TTY is available (like is the case for this test), the hook will abort after the first check.
|
||||
"""
|
||||
|
||||
# No need to patch git as we're passing a msg-filename to run-hook, so no git calls are made.
|
||||
# Note that this is the case when passing --staged as well, but that's tested as part of the integration tests
|
||||
# (=end-to-end scenario).
|
||||
|
||||
# Ideally we'd be able to assert that run-hook internally calls the lint cli command, but couldn't make
|
||||
# that work. Have tried many different variatons of mocking and patching without avail. For now, we just
|
||||
# check the output which indirectly proves the same thing.
|
||||
|
||||
with self.tempdir() as tmpdir:
|
||||
msg_filename = os.path.join(tmpdir, "hür")
|
||||
with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
|
||||
f.write("WIP: tïtle\n")
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename, "run-hook"])
|
||||
self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_no_tty_1_stdout'))
|
||||
self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli_hooks/test_hook_no_tty_1_stderr"))
|
||||
|
||||
# exit code is 1 because aborted (no stdin available)
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
|
||||
@patch('gitlint.cli.shell')
|
||||
def test_run_hook_edit(self, shell):
|
||||
""" Test for run-hook subcommand, answering 'e(dit)' after commit-hook """
|
||||
|
||||
set_editors = [None, "myeditor"]
|
||||
expected_editors = ["vim -n", "myeditor"]
|
||||
commit_messages = ["WIP: höok edit 1", "WIP: höok edit 2"]
|
||||
|
||||
for i in range(0, len(set_editors)):
|
||||
if set_editors[i]:
|
||||
os.environ['EDITOR'] = set_editors[i]
|
||||
|
||||
with self.patch_input(['e', 'e', 'n']):
|
||||
with self.tempdir() as tmpdir:
|
||||
msg_filename = os.path.realpath(os.path.join(tmpdir, "hür"))
|
||||
with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
|
||||
f.write(commit_messages[i] + "\n")
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename, "run-hook"])
|
||||
self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_edit_1_stdout',
|
||||
{"commit_msg": commit_messages[i]}))
|
||||
expected = self.get_expected("cli/test_cli_hooks/test_hook_edit_1_stderr",
|
||||
{"commit_msg": commit_messages[i]})
|
||||
self.assertEqual(stderr.getvalue(), expected)
|
||||
|
||||
# exit code = number of violations
|
||||
self.assertEqual(result.exit_code, 2)
|
||||
|
||||
shell.assert_called_with(expected_editors[i] + " " + msg_filename)
|
||||
self.assert_log_contains("DEBUG: gitlint.cli run-hook: editing commit message")
|
||||
self.assert_log_contains(f"DEBUG: gitlint.cli run-hook: {expected_editors[i]} {msg_filename}")
|
||||
|
||||
def test_run_hook_no(self):
|
||||
""" Test for run-hook subcommand, answering 'n(o)' after commit-hook """
|
||||
|
||||
with self.patch_input(['n']):
|
||||
with self.tempdir() as tmpdir:
|
||||
msg_filename = os.path.join(tmpdir, "hür")
|
||||
with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
|
||||
f.write("WIP: höok no\n")
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename, "run-hook"])
|
||||
self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_no_1_stdout'))
|
||||
self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli_hooks/test_hook_no_1_stderr"))
|
||||
|
||||
# We decided not to keep the commit message: hook returns number of violations (>0)
|
||||
# This will cause git to abort the commit
|
||||
self.assertEqual(result.exit_code, 2)
|
||||
self.assert_log_contains("DEBUG: gitlint.cli run-hook: commit message declined")
|
||||
|
||||
def test_run_hook_yes(self):
|
||||
""" Test for run-hook subcommand, answering 'y(es)' after commit-hook """
|
||||
with self.patch_input(['y']):
|
||||
with self.tempdir() as tmpdir:
|
||||
msg_filename = os.path.join(tmpdir, "hür")
|
||||
with io.open(msg_filename, 'w', encoding=DEFAULT_ENCODING) as f:
|
||||
f.write("WIP: höok yes\n")
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--msg-filename", msg_filename, "run-hook"])
|
||||
self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_yes_1_stdout'))
|
||||
self.assertEqual(stderr.getvalue(), self.get_expected("cli/test_cli_hooks/test_hook_yes_1_stderr"))
|
||||
|
||||
# Exit code is 0 because we decide to keep the commit message
|
||||
# This will cause git to keep the commit
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
self.assert_log_contains("DEBUG: gitlint.cli run-hook: commit message accepted")
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=False)
|
||||
@patch('gitlint.git.sh')
|
||||
def test_run_hook_negative(self, sh, _):
|
||||
""" Negative test for the run-hook subcommand: testing whether exceptions are correctly handled when
|
||||
running `gitlint run-hook`.
|
||||
"""
|
||||
# GIT_CONTEXT_ERROR_CODE: git error
|
||||
error_msg = b"fatal: not a git repository (or any of the parent directories): .git"
|
||||
sh.git.side_effect = ErrorReturnCode("full command", b"stdout", error_msg)
|
||||
result = self.cli.invoke(cli.cli, ["run-hook"])
|
||||
expected = self.get_expected('cli/test_cli_hooks/test_run_hook_negative_1', {'git_repo': os.getcwd()})
|
||||
self.assertEqual(result.output, expected)
|
||||
self.assertEqual(result.exit_code, self.GIT_CONTEXT_ERROR_CODE)
|
||||
|
||||
# USAGE_ERROR_CODE: incorrect use of gitlint
|
||||
result = self.cli.invoke(cli.cli, ["--staged", "run-hook"])
|
||||
self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_run_hook_negative_2'))
|
||||
self.assertEqual(result.exit_code, self.USAGE_ERROR_CODE)
|
||||
|
||||
# CONFIG_ERROR_CODE: incorrect config. Note that this is handled before the hook even runs
|
||||
result = self.cli.invoke(cli.cli, ["-c", "föo.bár=1", "run-hook"])
|
||||
self.assertEqual(result.output, "Config Error: No such rule 'föo'\n")
|
||||
self.assertEqual(result.exit_code, self.CONFIG_ERROR_CODE)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="WIP: Test hook stdin tïtle\n")
|
||||
def test_run_hook_stdin_violations(self, _):
|
||||
""" Test for passing stdin data to run-hook, expecting some violations. Equivalent of:
|
||||
$ echo "WIP: Test hook stdin tïtle" | gitlint run-hook
|
||||
"""
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["run-hook"])
|
||||
expected_stderr = self.get_expected('cli/test_cli_hooks/test_hook_stdin_violations_1_stderr')
|
||||
self.assertEqual(stderr.getvalue(), expected_stderr)
|
||||
self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_stdin_violations_1_stdout'))
|
||||
# Hook will auto-abort because we're using stdin. Abort = exit code 1
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="Test tïtle\n\nTest bödy that is long enough")
|
||||
def test_run_hook_stdin_no_violations(self, _):
|
||||
""" Test for passing stdin data to run-hook, expecting *NO* violations, Equivalent of:
|
||||
$ echo -e "Test tïtle\n\nTest bödy that is long enough" | gitlint run-hook
|
||||
"""
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["run-hook"])
|
||||
self.assertEqual(stderr.getvalue(), "") # no errors = no stderr output
|
||||
expected_stdout = self.get_expected('cli/test_cli_hooks/test_hook_stdin_no_violations_1_stdout')
|
||||
self.assertEqual(result.output, expected_stdout)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="WIP: Test hook config tïtle\n")
|
||||
def test_run_hook_config(self, _):
|
||||
""" Test that gitlint still respects config when running run-hook, equivalent of:
|
||||
$ echo "WIP: Test hook config tïtle" | gitlint -c title-max-length.line-length=5 --ignore B6 run-hook
|
||||
"""
|
||||
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["-c", "title-max-length.line-length=5", "--ignore", "B6", "run-hook"])
|
||||
self.assertEqual(stderr.getvalue(), self.get_expected('cli/test_cli_hooks/test_hook_config_1_stderr'))
|
||||
self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_config_1_stdout'))
|
||||
# Hook will auto-abort because we're using stdin. Abort = exit code 1
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value=False)
|
||||
@patch('gitlint.git.sh')
|
||||
def test_run_hook_local_commit(self, sh, _):
|
||||
""" Test running the hook on the last commit-msg from the local repo, equivalent of:
|
||||
$ gitlint run-hook
|
||||
and then choosing 'e'
|
||||
"""
|
||||
sh.git.side_effect = [
|
||||
"6f29bf81a8322a04071bb794666e48c443a90360",
|
||||
"test åuthor\x00test-email@föo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
|
||||
"WIP: commït-title\n\ncommït-body",
|
||||
"#", # git config --get core.commentchar
|
||||
"commit-1-branch-1\ncommit-1-branch-2\n",
|
||||
"file1.txt\npåth/to/file2.txt\n"
|
||||
]
|
||||
|
||||
with self.patch_input(['e']):
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["run-hook"])
|
||||
expected = self.get_expected('cli/test_cli_hooks/test_hook_local_commit_1_stderr')
|
||||
self.assertEqual(stderr.getvalue(), expected)
|
||||
self.assertEqual(result.output, self.get_expected('cli/test_cli_hooks/test_hook_local_commit_1_stdout'))
|
||||
# If we can't edit the message, run-hook follows regular gitlint behavior and exit code = # violations
|
||||
self.assertEqual(result.exit_code, 2)
|
287
gitlint-core/gitlint/tests/config/test_config.py
Normal file
287
gitlint-core/gitlint/tests/config/test_config.py
Normal file
|
@ -0,0 +1,287 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from gitlint import rules
|
||||
from gitlint.config import LintConfig, LintConfigError, LintConfigGenerator, GITLINT_CONFIG_TEMPLATE_SRC_PATH
|
||||
from gitlint import options
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
|
||||
|
||||
class LintConfigTests(BaseTestCase):
|
||||
|
||||
def test_set_rule_option(self):
|
||||
config = LintConfig()
|
||||
|
||||
# assert default title line-length
|
||||
self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 72)
|
||||
|
||||
# change line length and assert it is set
|
||||
config.set_rule_option('title-max-length', 'line-length', 60)
|
||||
self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 60)
|
||||
|
||||
def test_set_rule_option_negative(self):
|
||||
config = LintConfig()
|
||||
|
||||
# non-existing rule
|
||||
expected_error_msg = "No such rule 'föobar'"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_error_msg):
|
||||
config.set_rule_option(u'föobar', u'lïne-length', 60)
|
||||
|
||||
# non-existing option
|
||||
expected_error_msg = "Rule 'title-max-length' has no option 'föobar'"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_error_msg):
|
||||
config.set_rule_option('title-max-length', u'föobar', 60)
|
||||
|
||||
# invalid option value
|
||||
expected_error_msg = "'föo' is not a valid value for option 'title-max-length.line-length'. " + \
|
||||
"Option 'line-length' must be a positive integer (current value: 'föo')."
|
||||
with self.assertRaisesMessage(LintConfigError, expected_error_msg):
|
||||
config.set_rule_option('title-max-length', 'line-length', "föo")
|
||||
|
||||
def test_set_general_option(self):
|
||||
config = LintConfig()
|
||||
|
||||
# Check that default general options are correct
|
||||
self.assertTrue(config.ignore_merge_commits)
|
||||
self.assertTrue(config.ignore_fixup_commits)
|
||||
self.assertTrue(config.ignore_squash_commits)
|
||||
self.assertTrue(config.ignore_revert_commits)
|
||||
|
||||
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)
|
||||
self.assertTupleEqual(active_rule_classes, config.default_rule_classes)
|
||||
|
||||
# ignore - set by string
|
||||
config.set_general_option("ignore", "title-trailing-whitespace, B2")
|
||||
self.assertEqual(config.ignore, ["title-trailing-whitespace", "B2"])
|
||||
|
||||
# ignore - set by list
|
||||
config.set_general_option("ignore", ["T1", "B3"])
|
||||
self.assertEqual(config.ignore, ["T1", "B3"])
|
||||
|
||||
# verbosity
|
||||
config.set_general_option("verbosity", 1)
|
||||
self.assertEqual(config.verbosity, 1)
|
||||
|
||||
# ignore_merge_commit
|
||||
config.set_general_option("ignore-merge-commits", "false")
|
||||
self.assertFalse(config.ignore_merge_commits)
|
||||
|
||||
# ignore_fixup_commit
|
||||
config.set_general_option("ignore-fixup-commits", "false")
|
||||
self.assertFalse(config.ignore_fixup_commits)
|
||||
|
||||
# ignore_squash_commit
|
||||
config.set_general_option("ignore-squash-commits", "false")
|
||||
self.assertFalse(config.ignore_squash_commits)
|
||||
|
||||
# ignore_revert_commit
|
||||
config.set_general_option("ignore-revert-commits", "false")
|
||||
self.assertFalse(config.ignore_revert_commits)
|
||||
|
||||
# debug
|
||||
config.set_general_option("debug", "true")
|
||||
self.assertTrue(config.debug)
|
||||
|
||||
# ignore-stdin
|
||||
config.set_general_option("ignore-stdin", "true")
|
||||
self.assertTrue(config.debug)
|
||||
|
||||
# staged
|
||||
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)
|
||||
|
||||
# extra_path has its own test: test_extra_path and test_extra_path_negative
|
||||
# contrib has its own tests: test_contrib and test_contrib_negative
|
||||
|
||||
def test_contrib(self):
|
||||
config = LintConfig()
|
||||
contrib_rules = ["contrib-title-conventional-commits", "CC1"]
|
||||
config.set_general_option("contrib", ",".join(contrib_rules))
|
||||
self.assertEqual(config.contrib, contrib_rules)
|
||||
|
||||
# Check contrib-title-conventional-commits contrib rule
|
||||
actual_rule = config.rules.find_rule("contrib-title-conventional-commits")
|
||||
self.assertTrue(actual_rule.is_contrib)
|
||||
|
||||
self.assertEqual(str(type(actual_rule)), "<class 'conventional_commit.ConventionalCommit'>")
|
||||
self.assertEqual(actual_rule.id, 'CT1')
|
||||
self.assertEqual(actual_rule.name, u'contrib-title-conventional-commits')
|
||||
self.assertEqual(actual_rule.target, rules.CommitMessageTitle)
|
||||
|
||||
expected_rule_option = options.ListOption(
|
||||
"types",
|
||||
["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert", "ci", "build"],
|
||||
"Comma separated list of allowed commit types.",
|
||||
)
|
||||
|
||||
self.assertListEqual(actual_rule.options_spec, [expected_rule_option])
|
||||
self.assertDictEqual(actual_rule.options, {'types': expected_rule_option})
|
||||
|
||||
# Check contrib-body-requires-signed-off-by contrib rule
|
||||
actual_rule = config.rules.find_rule("contrib-body-requires-signed-off-by")
|
||||
self.assertTrue(actual_rule.is_contrib)
|
||||
|
||||
self.assertEqual(str(type(actual_rule)), "<class 'signedoff_by.SignedOffBy'>")
|
||||
self.assertEqual(actual_rule.id, 'CC1')
|
||||
self.assertEqual(actual_rule.name, u'contrib-body-requires-signed-off-by')
|
||||
|
||||
# reset value (this is a different code path)
|
||||
config.set_general_option("contrib", "contrib-body-requires-signed-off-by")
|
||||
self.assertEqual(actual_rule, config.rules.find_rule("contrib-body-requires-signed-off-by"))
|
||||
self.assertIsNone(config.rules.find_rule("contrib-title-conventional-commits"))
|
||||
|
||||
# empty value
|
||||
config.set_general_option("contrib", "")
|
||||
self.assertListEqual(config.contrib, [])
|
||||
|
||||
def test_contrib_negative(self):
|
||||
config = LintConfig()
|
||||
# non-existent contrib rule
|
||||
with self.assertRaisesMessage(LintConfigError, "No contrib rule with id or name 'föo' found."):
|
||||
config.contrib = "contrib-title-conventional-commits,föo"
|
||||
|
||||
# UserRuleError, RuleOptionError should be re-raised as LintConfigErrors
|
||||
side_effects = [rules.UserRuleError("üser-rule"), options.RuleOptionError("rüle-option")]
|
||||
for side_effect in side_effects:
|
||||
with patch('gitlint.config.rule_finder.find_rule_classes', side_effect=side_effect):
|
||||
with self.assertRaisesMessage(LintConfigError, str(side_effect)):
|
||||
config.contrib = "contrib-title-conventional-commits"
|
||||
|
||||
def test_extra_path(self):
|
||||
config = LintConfig()
|
||||
|
||||
config.set_general_option("extra-path", self.get_user_rules_path())
|
||||
self.assertEqual(config.extra_path, self.get_user_rules_path())
|
||||
actual_rule = config.rules.find_rule('UC1')
|
||||
self.assertTrue(actual_rule.is_user_defined)
|
||||
self.assertEqual(str(type(actual_rule)), "<class 'my_commit_rules.MyUserCommitRule'>")
|
||||
self.assertEqual(actual_rule.id, 'UC1')
|
||||
self.assertEqual(actual_rule.name, u'my-üser-commit-rule')
|
||||
self.assertEqual(actual_rule.target, None)
|
||||
expected_rule_option = options.IntOption('violation-count', 1, "Number of violåtions to return")
|
||||
self.assertListEqual(actual_rule.options_spec, [expected_rule_option])
|
||||
self.assertDictEqual(actual_rule.options, {'violation-count': expected_rule_option})
|
||||
|
||||
# reset value (this is a different code path)
|
||||
config.set_general_option("extra-path", self.SAMPLES_DIR)
|
||||
self.assertEqual(config.extra_path, self.SAMPLES_DIR)
|
||||
self.assertIsNone(config.rules.find_rule("UC1"))
|
||||
|
||||
def test_extra_path_negative(self):
|
||||
config = LintConfig()
|
||||
regex = "Option extra-path must be either an existing directory or file (current value: 'föo/bar')"
|
||||
# incorrect extra_path
|
||||
with self.assertRaisesMessage(LintConfigError, regex):
|
||||
config.extra_path = "föo/bar"
|
||||
|
||||
# extra path contains classes with errors
|
||||
with self.assertRaisesMessage(LintConfigError,
|
||||
"User-defined rule class 'MyUserLineRule' must have a 'validate' method"):
|
||||
config.extra_path = self.get_sample_path("user_rules/incorrect_linerule")
|
||||
|
||||
def test_set_general_option_negative(self):
|
||||
config = LintConfig()
|
||||
|
||||
# Note that we shouldn't test whether we can set unicode because python just doesn't allow unicode attributes
|
||||
with self.assertRaisesMessage(LintConfigError, "'foo' is not a valid gitlint option"):
|
||||
config.set_general_option("foo", "bår")
|
||||
|
||||
# try setting _config_path, this is a real attribute of LintConfig, but the code should prevent it from
|
||||
# being set
|
||||
with self.assertRaisesMessage(LintConfigError, "'_config_path' is not a valid gitlint option"):
|
||||
config.set_general_option("_config_path", "bår")
|
||||
|
||||
# invalid verbosity
|
||||
incorrect_values = [-1, "föo"]
|
||||
for value in incorrect_values:
|
||||
expected_msg = f"Option 'verbosity' must be a positive integer (current value: '{value}')"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_msg):
|
||||
config.verbosity = value
|
||||
|
||||
incorrect_values = [4]
|
||||
for value in incorrect_values:
|
||||
with self.assertRaisesMessage(LintConfigError, "Option 'verbosity' must be set between 0 and 3"):
|
||||
config.verbosity = value
|
||||
|
||||
# invalid ignore_xxx_commits
|
||||
ignore_attributes = ["ignore_merge_commits", "ignore_fixup_commits", "ignore_squash_commits",
|
||||
"ignore_revert_commits"]
|
||||
incorrect_values = [-1, 4, "föo"]
|
||||
for attribute in ignore_attributes:
|
||||
for value in incorrect_values:
|
||||
option_name = attribute.replace("_", "-")
|
||||
with self.assertRaisesMessage(LintConfigError,
|
||||
f"Option '{option_name}' must be either 'true' or 'false'"):
|
||||
setattr(config, attribute, value)
|
||||
|
||||
# invalid ignore -> not here because ignore is a ListOption which converts everything to a string before
|
||||
# splitting which means it it will accept just about everything
|
||||
|
||||
# invalid boolean options
|
||||
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'"):
|
||||
setattr(config, attribute, "föobar")
|
||||
|
||||
# extra-path has its own negative test
|
||||
|
||||
# invalid target
|
||||
with self.assertRaisesMessage(LintConfigError,
|
||||
"Option target must be an existing directory (current value: 'föo/bar')"):
|
||||
config.target = "föo/bar"
|
||||
|
||||
def test_ignore_independent_from_rules(self):
|
||||
# Test that the lintconfig rules are not modified when setting config.ignore
|
||||
# This was different in the past, this test is mostly here to catch regressions
|
||||
config = LintConfig()
|
||||
original_rules = config.rules
|
||||
config.ignore = ["T1", "T2"]
|
||||
self.assertEqual(config.ignore, ["T1", "T2"])
|
||||
self.assertSequenceEqual(config.rules, original_rules)
|
||||
|
||||
def test_config_equality(self):
|
||||
self.assertEqual(LintConfig(), LintConfig())
|
||||
self.assertNotEqual(LintConfig(), LintConfigGenerator())
|
||||
|
||||
# Ensure LintConfig are not equal if they differ on their attributes
|
||||
attrs = [("verbosity", 1), ("rules", []), ("ignore_stdin", True), ("debug", True),
|
||||
("ignore", ["T1"]), ("staged", True), ("_config_path", self.get_sample_path()),
|
||||
("ignore_merge_commits", False), ("ignore_fixup_commits", False),
|
||||
("ignore_squash_commits", False), ("ignore_revert_commits", False),
|
||||
("extra_path", self.get_sample_path("user_rules")), ("target", self.get_sample_path()),
|
||||
("contrib", ["CC1"])]
|
||||
for attr, val in attrs:
|
||||
config = LintConfig()
|
||||
setattr(config, attr, val)
|
||||
self.assertNotEqual(LintConfig(), config)
|
||||
|
||||
# Other attributes don't matter
|
||||
config1 = LintConfig()
|
||||
config2 = LintConfig()
|
||||
config1.foo = "bår"
|
||||
self.assertEqual(config1, config2)
|
||||
config2.foo = "dūr"
|
||||
self.assertEqual(config1, config2)
|
||||
|
||||
|
||||
class LintConfigGeneratorTests(BaseTestCase):
|
||||
@staticmethod
|
||||
@patch('gitlint.config.shutil.copyfile')
|
||||
def test_install_commit_msg_hook_negative(copy):
|
||||
LintConfigGenerator.generate_config("föo/bar/test")
|
||||
copy.assert_called_with(GITLINT_CONFIG_TEMPLATE_SRC_PATH, "föo/bar/test")
|
264
gitlint-core/gitlint/tests/config/test_config_builder.py
Normal file
264
gitlint-core/gitlint/tests/config/test_config_builder.py
Normal file
|
@ -0,0 +1,264 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import copy
|
||||
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
|
||||
from gitlint.config import LintConfig, LintConfigBuilder, LintConfigError
|
||||
|
||||
from gitlint import rules
|
||||
|
||||
|
||||
class LintConfigBuilderTests(BaseTestCase):
|
||||
def test_set_option(self):
|
||||
config_builder = LintConfigBuilder()
|
||||
config = config_builder.build()
|
||||
|
||||
# assert some defaults
|
||||
self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 72)
|
||||
self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 80)
|
||||
self.assertListEqual(config.get_rule_option('title-must-not-contain-word', 'words'), ["WIP"])
|
||||
self.assertEqual(config.verbosity, 3)
|
||||
|
||||
# Make some changes and check blueprint
|
||||
config_builder.set_option('title-max-length', 'line-length', 100)
|
||||
config_builder.set_option('general', 'verbosity', 2)
|
||||
config_builder.set_option('title-must-not-contain-word', 'words', ["foo", "bar"])
|
||||
expected_blueprint = {'title-must-not-contain-word': {'words': ['foo', 'bar']},
|
||||
'title-max-length': {'line-length': 100}, 'general': {'verbosity': 2}}
|
||||
self.assertDictEqual(config_builder._config_blueprint, expected_blueprint)
|
||||
|
||||
# Build config and verify that the changes have occurred and no other changes
|
||||
config = config_builder.build()
|
||||
self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 100)
|
||||
self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 80) # should be unchanged
|
||||
self.assertListEqual(config.get_rule_option('title-must-not-contain-word', 'words'), ["foo", "bar"])
|
||||
self.assertEqual(config.verbosity, 2)
|
||||
|
||||
def test_set_from_commit_ignore_all(self):
|
||||
config = LintConfig()
|
||||
original_rules = config.rules
|
||||
original_rule_ids = [rule.id for rule in original_rules]
|
||||
|
||||
config_builder = LintConfigBuilder()
|
||||
|
||||
# nothing gitlint
|
||||
config_builder.set_config_from_commit(self.gitcommit("tëst\ngitlint\nfoo"))
|
||||
config = config_builder.build()
|
||||
self.assertSequenceEqual(config.rules, original_rules)
|
||||
self.assertListEqual(config.ignore, [])
|
||||
|
||||
# ignore all rules
|
||||
config_builder.set_config_from_commit(self.gitcommit("tëst\ngitlint-ignore: all\nfoo"))
|
||||
config = config_builder.build()
|
||||
self.assertEqual(config.ignore, original_rule_ids)
|
||||
|
||||
# ignore all rules, no space
|
||||
config_builder.set_config_from_commit(self.gitcommit("tëst\ngitlint-ignore:all\nfoo"))
|
||||
config = config_builder.build()
|
||||
self.assertEqual(config.ignore, original_rule_ids)
|
||||
|
||||
# ignore all rules, more spacing
|
||||
config_builder.set_config_from_commit(self.gitcommit("tëst\ngitlint-ignore: \t all\nfoo"))
|
||||
config = config_builder.build()
|
||||
self.assertEqual(config.ignore, original_rule_ids)
|
||||
|
||||
def test_set_from_commit_ignore_specific(self):
|
||||
# ignore specific rules
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_config_from_commit(self.gitcommit("tëst\ngitlint-ignore: T1, body-hard-tab"))
|
||||
config = config_builder.build()
|
||||
self.assertEqual(config.ignore, ["T1", "body-hard-tab"])
|
||||
|
||||
def test_set_from_config_file(self):
|
||||
# regular config file load, no problems
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_from_config_file(self.get_sample_path("config/gitlintconfig"))
|
||||
config = config_builder.build()
|
||||
|
||||
# Do some assertions on the config
|
||||
self.assertEqual(config.verbosity, 1)
|
||||
self.assertFalse(config.debug)
|
||||
self.assertFalse(config.ignore_merge_commits)
|
||||
self.assertIsNone(config.extra_path)
|
||||
self.assertEqual(config.ignore, ["title-trailing-whitespace", "B2"])
|
||||
|
||||
self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 20)
|
||||
self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 30)
|
||||
|
||||
def test_set_from_config_file_negative(self):
|
||||
config_builder = LintConfigBuilder()
|
||||
|
||||
# bad config file load
|
||||
foo_path = self.get_sample_path("föo")
|
||||
expected_error_msg = f"Invalid file path: {foo_path}"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_error_msg):
|
||||
config_builder.set_from_config_file(foo_path)
|
||||
|
||||
# error during file parsing
|
||||
path = self.get_sample_path("config/no-sections")
|
||||
expected_error_msg = "File contains no section headers."
|
||||
# We only match the start of the message here, since the exact message can vary depending on platform
|
||||
with self.assertRaisesRegex(LintConfigError, expected_error_msg):
|
||||
config_builder.set_from_config_file(path)
|
||||
|
||||
# non-existing rule
|
||||
path = self.get_sample_path("config/nonexisting-rule")
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_from_config_file(path)
|
||||
expected_error_msg = "No such rule 'föobar'"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_error_msg):
|
||||
config_builder.build()
|
||||
|
||||
# non-existing general option
|
||||
path = self.get_sample_path("config/nonexisting-general-option")
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_from_config_file(path)
|
||||
expected_error_msg = "'foo' is not a valid gitlint option"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_error_msg):
|
||||
config_builder.build()
|
||||
|
||||
# non-existing option
|
||||
path = self.get_sample_path("config/nonexisting-option")
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_from_config_file(path)
|
||||
expected_error_msg = "Rule 'title-max-length' has no option 'föobar'"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_error_msg):
|
||||
config_builder.build()
|
||||
|
||||
# invalid option value
|
||||
path = self.get_sample_path("config/invalid-option-value")
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_from_config_file(path)
|
||||
expected_error_msg = "'föo' is not a valid value for option 'title-max-length.line-length'. " + \
|
||||
"Option 'line-length' must be a positive integer (current value: 'föo')."
|
||||
with self.assertRaisesMessage(LintConfigError, expected_error_msg):
|
||||
config_builder.build()
|
||||
|
||||
def test_set_config_from_string_list(self):
|
||||
config = LintConfig()
|
||||
|
||||
# change and assert changes
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_config_from_string_list(['general.verbosity=1', 'title-max-length.line-length=60',
|
||||
'body-max-line-length.line-length=120',
|
||||
"title-must-not-contain-word.words=håha"])
|
||||
|
||||
config = config_builder.build()
|
||||
self.assertEqual(config.get_rule_option('title-max-length', 'line-length'), 60)
|
||||
self.assertEqual(config.get_rule_option('body-max-line-length', 'line-length'), 120)
|
||||
self.assertListEqual(config.get_rule_option('title-must-not-contain-word', 'words'), ["håha"])
|
||||
self.assertEqual(config.verbosity, 1)
|
||||
|
||||
def test_set_config_from_string_list_negative(self):
|
||||
config_builder = LintConfigBuilder()
|
||||
|
||||
# assert error on incorrect rule - this happens at build time
|
||||
config_builder.set_config_from_string_list(["föo.bar=1"])
|
||||
with self.assertRaisesMessage(LintConfigError, "No such rule 'föo'"):
|
||||
config_builder.build()
|
||||
|
||||
# no equal sign
|
||||
expected_msg = "'föo.bar' is an invalid configuration option. Use '<rule>.<option>=<value>'"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_msg):
|
||||
config_builder.set_config_from_string_list(["föo.bar"])
|
||||
|
||||
# missing value
|
||||
expected_msg = "'föo.bar=' is an invalid configuration option. Use '<rule>.<option>=<value>'"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_msg):
|
||||
config_builder.set_config_from_string_list(["föo.bar="])
|
||||
|
||||
# space instead of equal sign
|
||||
expected_msg = "'föo.bar 1' is an invalid configuration option. Use '<rule>.<option>=<value>'"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_msg):
|
||||
config_builder.set_config_from_string_list(["föo.bar 1"])
|
||||
|
||||
# no period between rule and option names
|
||||
expected_msg = "'föobar=1' is an invalid configuration option. Use '<rule>.<option>=<value>'"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_msg):
|
||||
config_builder.set_config_from_string_list([u'föobar=1'])
|
||||
|
||||
def test_rebuild_config(self):
|
||||
# normal config build
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_option('general', 'verbosity', 3)
|
||||
lint_config = config_builder.build()
|
||||
self.assertEqual(lint_config.verbosity, 3)
|
||||
|
||||
# check that existing config gets overwritten when we pass it to a configbuilder with different options
|
||||
existing_lintconfig = LintConfig()
|
||||
existing_lintconfig.verbosity = 2
|
||||
lint_config = config_builder.build(existing_lintconfig)
|
||||
self.assertEqual(lint_config.verbosity, 3)
|
||||
self.assertEqual(existing_lintconfig.verbosity, 3)
|
||||
|
||||
def test_clone(self):
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_option('general', 'verbosity', 2)
|
||||
config_builder.set_option('title-max-length', 'line-length', 100)
|
||||
expected = {'title-max-length': {'line-length': 100}, 'general': {'verbosity': 2}}
|
||||
self.assertDictEqual(config_builder._config_blueprint, expected)
|
||||
|
||||
# Clone and verify that the blueprint is the same as the original
|
||||
cloned_builder = config_builder.clone()
|
||||
self.assertDictEqual(cloned_builder._config_blueprint, expected)
|
||||
|
||||
# Modify the original and make sure we're not modifying the clone (i.e. check that the copy is a deep copy)
|
||||
config_builder.set_option('title-max-length', 'line-length', 120)
|
||||
self.assertDictEqual(cloned_builder._config_blueprint, expected)
|
||||
|
||||
def test_named_rules(self):
|
||||
# Store a copy of the default rules from the config, so we can reference it later
|
||||
config_builder = LintConfigBuilder()
|
||||
config = config_builder.build()
|
||||
default_rules = copy.deepcopy(config.rules)
|
||||
self.assertEqual(default_rules, config.rules) # deepcopy should be equal
|
||||
|
||||
# Add a named rule by setting an option in the config builder that follows the named rule pattern
|
||||
# Assert that whitespace in the rule name is stripped
|
||||
rule_qualifiers = [u'T7:my-extra-rüle', u' T7 : my-extra-rüle ', u'\tT7:\tmy-extra-rüle\t',
|
||||
u'T7:\t\n \tmy-extra-rüle\t\n\n', "title-match-regex:my-extra-rüle"]
|
||||
for rule_qualifier in rule_qualifiers:
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_option(rule_qualifier, 'regex', "föo")
|
||||
|
||||
expected_rules = copy.deepcopy(default_rules)
|
||||
my_rule = rules.TitleRegexMatches({'regex': "föo"})
|
||||
my_rule.id = rules.TitleRegexMatches.id + ":my-extra-rüle"
|
||||
my_rule.name = rules.TitleRegexMatches.name + ":my-extra-rüle"
|
||||
expected_rules._rules[u'T7:my-extra-rüle'] = my_rule
|
||||
self.assertEqual(config_builder.build().rules, expected_rules)
|
||||
|
||||
# assert that changing an option on the newly added rule is passed correctly to the RuleCollection
|
||||
# we try this with all different rule qualifiers to ensure they all are normalized and map
|
||||
# to the same rule
|
||||
for other_rule_qualifier in rule_qualifiers:
|
||||
cb = config_builder.clone()
|
||||
cb.set_option(other_rule_qualifier, 'regex', other_rule_qualifier + "bōr")
|
||||
# before setting the expected rule option value correctly, the RuleCollection should be different
|
||||
self.assertNotEqual(cb.build().rules, expected_rules)
|
||||
# after setting the option on the expected rule, it should be equal
|
||||
my_rule.options['regex'].set(other_rule_qualifier + "bōr")
|
||||
self.assertEqual(cb.build().rules, expected_rules)
|
||||
my_rule.options['regex'].set("wrong")
|
||||
|
||||
def test_named_rules_negative(self):
|
||||
# T7 = title-match-regex
|
||||
# Invalid rule name
|
||||
for invalid_name in ["", " ", " ", "\t", "\n", "å b", "å:b", "åb:", ":åb"]:
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_option(f"T7:{invalid_name}", 'regex', "tëst")
|
||||
expected_msg = f"The rule-name part in 'T7:{invalid_name}' cannot contain whitespace, colons or be empty"
|
||||
with self.assertRaisesMessage(LintConfigError, expected_msg):
|
||||
config_builder.build()
|
||||
|
||||
# Invalid parent rule name
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_option("Ž123:foöbar", "fåke-option", "fåke-value")
|
||||
with self.assertRaisesMessage(LintConfigError, "No such rule 'Ž123' (named rule: 'Ž123:foöbar')"):
|
||||
config_builder.build()
|
||||
|
||||
# Invalid option name (this is the same as with regular rules)
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_option("T7:foöbar", "blå", "my-rëgex")
|
||||
with self.assertRaisesMessage(LintConfigError, "Rule 'T7:foöbar' has no option 'blå'"):
|
||||
config_builder.build()
|
98
gitlint-core/gitlint/tests/config/test_config_precedence.py
Normal file
98
gitlint-core/gitlint/tests/config/test_config_precedence.py
Normal file
|
@ -0,0 +1,98 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from io import StringIO
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint import cli
|
||||
from gitlint.config import LintConfigBuilder
|
||||
|
||||
|
||||
class LintConfigPrecedenceTests(BaseTestCase):
|
||||
def setUp(self):
|
||||
self.cli = CliRunner()
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="WIP:fö\n\nThis is å test message\n")
|
||||
def test_config_precedence(self, _):
|
||||
# TODO(jroovers): this test really only test verbosity, we need to do some refactoring to gitlint.cli
|
||||
# to more easily test everything
|
||||
# Test that the config precedence is followed:
|
||||
# 1. commandline convenience flags
|
||||
# 2. environment variables
|
||||
# 3. commandline -c flags
|
||||
# 4. config file
|
||||
# 5. default config
|
||||
config_path = self.get_sample_path("config/gitlintconfig")
|
||||
|
||||
# 1. commandline convenience flags
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["-vvv", "-c", "general.verbosity=2", "--config", config_path])
|
||||
self.assertEqual(result.output, "")
|
||||
self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP:fö\"\n")
|
||||
|
||||
# 2. environment variables
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["-c", "general.verbosity=2", "--config", config_path],
|
||||
env={"GITLINT_VERBOSITY": "3"})
|
||||
self.assertEqual(result.output, "")
|
||||
self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP:fö\"\n")
|
||||
|
||||
# 3. commandline -c flags
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["-c", "general.verbosity=2", "--config", config_path])
|
||||
self.assertEqual(result.output, "")
|
||||
self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive)\n")
|
||||
|
||||
# 4. config file
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli, ["--config", config_path])
|
||||
self.assertEqual(result.output, "")
|
||||
self.assertEqual(stderr.getvalue(), "1: T5\n")
|
||||
|
||||
# 5. default config
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
result = self.cli.invoke(cli.cli)
|
||||
self.assertEqual(result.output, "")
|
||||
self.assertEqual(stderr.getvalue(), "1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP:fö\"\n")
|
||||
|
||||
@patch('gitlint.cli.get_stdin_data', return_value="WIP: This is å test")
|
||||
def test_ignore_precedence(self, get_stdin_data):
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
# --ignore takes precedence over -c general.ignore
|
||||
result = self.cli.invoke(cli.cli, ["-c", "general.ignore=T5", "--ignore", "B6"])
|
||||
self.assertEqual(result.output, "")
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
# We still expect the T5 violation, but no B6 violation as --ignore overwrites -c general.ignore
|
||||
self.assertEqual(stderr.getvalue(),
|
||||
"1: T5 Title contains the word 'WIP' (case-insensitive): \"WIP: This is å test\"\n")
|
||||
|
||||
# test that we can also still configure a rule that is first ignored but then not
|
||||
with patch('gitlint.display.stderr', new=StringIO()) as stderr:
|
||||
get_stdin_data.return_value = "This is å test"
|
||||
# --ignore takes precedence over -c general.ignore
|
||||
result = self.cli.invoke(cli.cli, ["-c", "general.ignore=title-max-length",
|
||||
"-c", "title-max-length.line-length=5",
|
||||
"--ignore", "B6"])
|
||||
self.assertEqual(result.output, "")
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
|
||||
# We still expect the T1 violation with custom config,
|
||||
# but no B6 violation as --ignore overwrites -c general.ignore
|
||||
self.assertEqual(stderr.getvalue(), "1: T1 Title exceeds max length (14>5): \"This is å test\"\n")
|
||||
|
||||
def test_general_option_after_rule_option(self):
|
||||
# We used to have a bug where we didn't process general options before setting specific options, this would
|
||||
# lead to errors when e.g.: trying to configure a user rule before the rule class was loaded by extra-path
|
||||
# This test is here to test for regressions against this.
|
||||
|
||||
config_builder = LintConfigBuilder()
|
||||
config_builder.set_option(u'my-üser-commit-rule', 'violation-count', 3)
|
||||
user_rules_path = self.get_sample_path("user_rules")
|
||||
config_builder.set_option('general', 'extra-path', user_rules_path)
|
||||
config = config_builder.build()
|
||||
|
||||
self.assertEqual(config.extra_path, user_rules_path)
|
||||
self.assertEqual(config.get_rule_option(u'my-üser-commit-rule', 'violation-count'), 3)
|
64
gitlint-core/gitlint/tests/config/test_rule_collection.py
Normal file
64
gitlint-core/gitlint/tests/config/test_rule_collection.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from collections import OrderedDict
|
||||
from gitlint import rules
|
||||
from gitlint.config import RuleCollection
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
|
||||
|
||||
class RuleCollectionTests(BaseTestCase):
|
||||
|
||||
def test_add_rule(self):
|
||||
collection = RuleCollection()
|
||||
collection.add_rule(rules.TitleMaxLength, "my-rüle", {"my_attr": "föo", "my_attr2": 123})
|
||||
|
||||
expected = rules.TitleMaxLength()
|
||||
expected.id = "my-rüle"
|
||||
expected.my_attr = "föo"
|
||||
expected.my_attr2 = 123
|
||||
|
||||
self.assertEqual(len(collection), 1)
|
||||
self.assertDictEqual(collection._rules, OrderedDict({"my-rüle": expected}))
|
||||
# Need to explicitly compare expected attributes as the rule.__eq__ method does not compare these attributes
|
||||
self.assertEqual(collection._rules[expected.id].my_attr, expected.my_attr)
|
||||
self.assertEqual(collection._rules[expected.id].my_attr2, expected.my_attr2)
|
||||
|
||||
def test_add_find_rule(self):
|
||||
collection = RuleCollection()
|
||||
collection.add_rules([rules.TitleMaxLength, rules.TitleTrailingWhitespace], {"my_attr": "föo"})
|
||||
|
||||
# find by id
|
||||
expected = rules.TitleMaxLength()
|
||||
rule = collection.find_rule('T1')
|
||||
self.assertEqual(rule, expected)
|
||||
self.assertEqual(rule.my_attr, "föo")
|
||||
|
||||
# find by name
|
||||
expected2 = rules.TitleTrailingWhitespace()
|
||||
rule = collection.find_rule('title-trailing-whitespace')
|
||||
self.assertEqual(rule, expected2)
|
||||
self.assertEqual(rule.my_attr, "föo")
|
||||
|
||||
# find non-existing
|
||||
rule = collection.find_rule(u'föo')
|
||||
self.assertIsNone(rule)
|
||||
|
||||
def test_delete_rules_by_attr(self):
|
||||
collection = RuleCollection()
|
||||
collection.add_rules([rules.TitleMaxLength, rules.TitleTrailingWhitespace], {"foo": "bår"})
|
||||
collection.add_rules([rules.BodyHardTab], {"hur": "dûr"})
|
||||
|
||||
# Assert all rules are there as expected
|
||||
self.assertEqual(len(collection), 3)
|
||||
for expected_rule in [rules.TitleMaxLength(), rules.TitleTrailingWhitespace(), rules.BodyHardTab()]:
|
||||
self.assertEqual(collection.find_rule(expected_rule.id), expected_rule)
|
||||
|
||||
# Delete rules by attr, assert that we still have the right rules in the collection
|
||||
collection.delete_rules_by_attr("foo", "bår")
|
||||
self.assertEqual(len(collection), 1)
|
||||
self.assertIsNone(collection.find_rule(rules.TitleMaxLength.id), None)
|
||||
self.assertIsNone(collection.find_rule(rules.TitleTrailingWhitespace.id), None)
|
||||
|
||||
found = collection.find_rule(rules.BodyHardTab.id)
|
||||
self.assertEqual(found, rules.BodyHardTab())
|
||||
self.assertEqual(found.hur, "dûr")
|
0
gitlint-core/gitlint/tests/contrib/__init__.py
Normal file
0
gitlint-core/gitlint/tests/contrib/__init__.py
Normal file
0
gitlint-core/gitlint/tests/contrib/rules/__init__.py
Normal file
0
gitlint-core/gitlint/tests/contrib/rules/__init__.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
|
||||
# -*- coding: utf-8 -*-
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint.rules import RuleViolation
|
||||
from gitlint.contrib.rules.conventional_commit import ConventionalCommit
|
||||
from gitlint.config import LintConfig
|
||||
|
||||
|
||||
class ContribConventionalCommitTests(BaseTestCase):
|
||||
|
||||
def test_enable(self):
|
||||
# Test that rule can be enabled in config
|
||||
for rule_ref in ['CT1', 'contrib-title-conventional-commits']:
|
||||
config = LintConfig()
|
||||
config.contrib = [rule_ref]
|
||||
self.assertIn(ConventionalCommit(), config.rules)
|
||||
|
||||
def test_conventional_commits(self):
|
||||
rule = ConventionalCommit()
|
||||
|
||||
# No violations when using a correct type and format
|
||||
for type in ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert", "ci", "build"]:
|
||||
violations = rule.validate(type + ": föo", None)
|
||||
self.assertListEqual([], violations)
|
||||
|
||||
# assert violation on wrong type
|
||||
expected_violation = RuleViolation("CT1", "Title does not start with one of fix, feat, chore, docs,"
|
||||
" style, refactor, perf, test, revert, ci, build", "bår: foo")
|
||||
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"]:
|
||||
violations = rule.validate(typ + ": hür dur", None)
|
||||
self.assertListEqual([], violations)
|
||||
|
||||
# assert violation when using incorrect type when types have been reconfigured
|
||||
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)
|
|
@ -0,0 +1,32 @@
|
|||
|
||||
# -*- coding: utf-8 -*-
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint.rules import RuleViolation
|
||||
from gitlint.contrib.rules.signedoff_by import SignedOffBy
|
||||
|
||||
from gitlint.config import LintConfig
|
||||
|
||||
|
||||
class ContribSignedOffByTests(BaseTestCase):
|
||||
|
||||
def test_enable(self):
|
||||
# Test that rule can be enabled in config
|
||||
for rule_ref in ['CC1', 'contrib-body-requires-signed-off-by']:
|
||||
config = LintConfig()
|
||||
config.contrib = [rule_ref]
|
||||
self.assertIn(SignedOffBy(), config.rules)
|
||||
|
||||
def test_signedoff_by(self):
|
||||
# No violations when 'Signed-off-by' line is present
|
||||
rule = SignedOffBy()
|
||||
violations = rule.validate(self.gitcommit("Föobar\n\nMy Body\nSigned-off-by: John Smith"))
|
||||
self.assertListEqual([], violations)
|
||||
|
||||
# Assert violation when no 'Signed-off-by' line is present
|
||||
violations = rule.validate(self.gitcommit("Föobar\n\nMy Body"))
|
||||
expected_violation = RuleViolation("CC1", "Body does not contain a 'Signed-off-by' line", line_nr=1)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# Assert violation when no 'Signed-off-by' in title but not in body
|
||||
violations = rule.validate(self.gitcommit("Signed-off-by\n\nFöobar"))
|
||||
self.assertListEqual(violations, [expected_violation])
|
69
gitlint-core/gitlint/tests/contrib/test_contrib_rules.py
Normal file
69
gitlint-core/gitlint/tests/contrib/test_contrib_rules.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint.contrib import rules as contrib_rules
|
||||
from gitlint.tests.contrib import rules as contrib_tests
|
||||
from gitlint import rule_finder, rules
|
||||
|
||||
|
||||
class ContribRuleTests(BaseTestCase):
|
||||
|
||||
CONTRIB_DIR = os.path.dirname(os.path.realpath(contrib_rules.__file__))
|
||||
|
||||
def test_contrib_tests_exist(self):
|
||||
""" Tests that every contrib rule file has an associated test file.
|
||||
While this doesn't guarantee that every contrib rule has associated tests (as we don't check the content
|
||||
of the tests file), it's a good leading indicator. """
|
||||
|
||||
contrib_tests_dir = os.path.dirname(os.path.realpath(contrib_tests.__file__))
|
||||
contrib_test_files = os.listdir(contrib_tests_dir)
|
||||
|
||||
# Find all python files in the contrib dir and assert there's a corresponding test file
|
||||
for filename in os.listdir(self.CONTRIB_DIR):
|
||||
if filename.endswith(".py") and filename not in ["__init__.py"]:
|
||||
expected_test_file = "test_" + filename
|
||||
error_msg = "Every Contrib Rule must have associated tests. " + \
|
||||
f"Expected test file {os.path.join(contrib_tests_dir, expected_test_file)} not found."
|
||||
self.assertIn(expected_test_file, contrib_test_files, error_msg)
|
||||
|
||||
def test_contrib_rule_naming_conventions(self):
|
||||
""" Tests that contrib rules follow certain naming conventions.
|
||||
We can test for this at test time (and not during runtime like rule_finder.assert_valid_rule_class does)
|
||||
because these are contrib rules: once they're part of gitlint they can't change unless they pass this test
|
||||
again.
|
||||
"""
|
||||
rule_classes = rule_finder.find_rule_classes(self.CONTRIB_DIR)
|
||||
|
||||
for clazz in rule_classes:
|
||||
# Contrib rule names start with "contrib-"
|
||||
self.assertTrue(clazz.name.startswith("contrib-"))
|
||||
|
||||
# Contrib line rules id's start with "CL"
|
||||
if issubclass(clazz, rules.LineRule):
|
||||
if clazz.target == rules.CommitMessageTitle:
|
||||
self.assertTrue(clazz.id.startswith("CT"))
|
||||
elif clazz.target == rules.CommitMessageBody:
|
||||
self.assertTrue(clazz.id.startswith("CB"))
|
||||
|
||||
def test_contrib_rule_uniqueness(self):
|
||||
""" Tests that all contrib rules have unique identifiers.
|
||||
We can test for this at test time (and not during runtime like rule_finder.assert_valid_rule_class does)
|
||||
because these are contrib rules: once they're part of gitlint they can't change unless they pass this test
|
||||
again.
|
||||
"""
|
||||
rule_classes = rule_finder.find_rule_classes(self.CONTRIB_DIR)
|
||||
|
||||
# Not very efficient way of checking uniqueness, but it works :-)
|
||||
class_names = [rule_class.name for rule_class in rule_classes]
|
||||
class_ids = [rule_class.id for rule_class in rule_classes]
|
||||
self.assertEqual(len(set(class_names)), len(class_names))
|
||||
self.assertEqual(len(set(class_ids)), len(class_ids))
|
||||
|
||||
def test_contrib_rule_instantiated(self):
|
||||
""" Tests that all contrib rules can be instantiated without errors. """
|
||||
rule_classes = rule_finder.find_rule_classes(self.CONTRIB_DIR)
|
||||
|
||||
# No exceptions = what we want :-)
|
||||
for rule_class in rule_classes:
|
||||
rule_class()
|
|
@ -0,0 +1,2 @@
|
|||
1: CC1 Body does not contain a 'Signed-off-by' line
|
||||
1: CT1 Title does not follow ConventionalCommits.org format 'type(optional-scope): description': "Test tïtle"
|
124
gitlint-core/gitlint/tests/expected/cli/test_cli/test_debug_1
Normal file
124
gitlint-core/gitlint/tests/expected/cli/test_cli/test_debug_1
Normal file
|
@ -0,0 +1,124 @@
|
|||
DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
|
||||
DEBUG: gitlint.cli Platform: {platform}
|
||||
DEBUG: gitlint.cli Python version: {python_version}
|
||||
DEBUG: gitlint.cli Git version: git version 1.2.3
|
||||
DEBUG: gitlint.cli Gitlint version: {gitlint_version}
|
||||
DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
|
||||
DEBUG: gitlint.cli DEFAULT_ENCODING: {DEFAULT_ENCODING}
|
||||
DEBUG: gitlint.cli Configuration
|
||||
config-path: {config_path}
|
||||
[GENERAL]
|
||||
extra-path: None
|
||||
contrib: []
|
||||
ignore: title-trailing-whitespace,B2
|
||||
ignore-merge-commits: False
|
||||
ignore-fixup-commits: True
|
||||
ignore-squash-commits: True
|
||||
ignore-revert-commits: True
|
||||
ignore-stdin: False
|
||||
staged: False
|
||||
fail-without-commits: False
|
||||
verbosity: 1
|
||||
debug: True
|
||||
target: {target}
|
||||
[RULES]
|
||||
I1: ignore-by-title
|
||||
ignore=all
|
||||
regex=None
|
||||
I2: ignore-by-body
|
||||
ignore=all
|
||||
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
|
||||
T6: title-leading-whitespace
|
||||
T3: title-trailing-punctuation
|
||||
T4: title-hard-tab
|
||||
T5: title-must-not-contain-word
|
||||
words=WIP,bögus
|
||||
T7: title-match-regex
|
||||
regex=None
|
||||
T8: title-min-length
|
||||
min-length=5
|
||||
B1: body-max-line-length
|
||||
line-length=30
|
||||
B5: body-min-length
|
||||
min-length=20
|
||||
B6: body-is-missing
|
||||
ignore-merge-commits=True
|
||||
B2: body-trailing-whitespace
|
||||
B3: body-hard-tab
|
||||
B4: body-first-line-empty
|
||||
B7: body-changed-file-mention
|
||||
files=
|
||||
B8: body-match-regex
|
||||
regex=None
|
||||
M1: author-valid-email
|
||||
regex=[^@ ]+@[^@ ]+\.[^@ ]+
|
||||
|
||||
DEBUG: gitlint.cli No --msg-filename flag, no or empty data passed to stdin. Using the local repo.
|
||||
DEBUG: gitlint.git ('rev-list', 'foo...bar')
|
||||
DEBUG: gitlint.cli Linting 3 commit(s)
|
||||
DEBUG: gitlint.git ('log', '6f29bf81a8322a04071bb794666e48c443a90360', '-1', '--pretty=%aN%x00%aE%x00%ai%x00%P%n%B')
|
||||
DEBUG: gitlint.git ('config', '--get', 'core.commentchar')
|
||||
DEBUG: gitlint.lint Linting commit 6f29bf81a8322a04071bb794666e48c443a90360
|
||||
DEBUG: gitlint.git ('branch', '--contains', '6f29bf81a8322a04071bb794666e48c443a90360')
|
||||
DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '6f29bf81a8322a04071bb794666e48c443a90360')
|
||||
DEBUG: gitlint.lint Commit Object
|
||||
--- Commit Message ----
|
||||
commït-title1
|
||||
|
||||
commït-body1
|
||||
--- Meta info ---------
|
||||
Author: test åuthor1 <test-email1@föo.com>
|
||||
Date: 2016-12-03 15:28:15 +0100
|
||||
is-merge-commit: False
|
||||
is-fixup-commit: False
|
||||
is-squash-commit: False
|
||||
is-revert-commit: False
|
||||
Branches: ['commit-1-branch-1', 'commit-1-branch-2']
|
||||
Changed Files: ['commit-1/file-1', 'commit-1/file-2']
|
||||
-----------------------
|
||||
DEBUG: gitlint.git ('log', '25053ccec5e28e1bb8f7551fdbb5ab213ada2401', '-1', '--pretty=%aN%x00%aE%x00%ai%x00%P%n%B')
|
||||
DEBUG: gitlint.lint Linting commit 25053ccec5e28e1bb8f7551fdbb5ab213ada2401
|
||||
DEBUG: gitlint.git ('branch', '--contains', '25053ccec5e28e1bb8f7551fdbb5ab213ada2401')
|
||||
DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '25053ccec5e28e1bb8f7551fdbb5ab213ada2401')
|
||||
DEBUG: gitlint.lint Commit Object
|
||||
--- Commit Message ----
|
||||
commït-title2.
|
||||
|
||||
commït-body2
|
||||
--- Meta info ---------
|
||||
Author: test åuthor2 <test-email2@föo.com>
|
||||
Date: 2016-12-04 15:28:15 +0100
|
||||
is-merge-commit: False
|
||||
is-fixup-commit: False
|
||||
is-squash-commit: False
|
||||
is-revert-commit: False
|
||||
Branches: ['commit-2-branch-1', 'commit-2-branch-2']
|
||||
Changed Files: ['commit-2/file-1', 'commit-2/file-2']
|
||||
-----------------------
|
||||
DEBUG: gitlint.git ('log', '4da2656b0dadc76c7ee3fd0243a96cb64007f125', '-1', '--pretty=%aN%x00%aE%x00%ai%x00%P%n%B')
|
||||
DEBUG: gitlint.lint Linting commit 4da2656b0dadc76c7ee3fd0243a96cb64007f125
|
||||
DEBUG: gitlint.git ('branch', '--contains', '4da2656b0dadc76c7ee3fd0243a96cb64007f125')
|
||||
DEBUG: gitlint.git ('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '4da2656b0dadc76c7ee3fd0243a96cb64007f125')
|
||||
DEBUG: gitlint.lint Commit Object
|
||||
--- Commit Message ----
|
||||
föobar
|
||||
bar
|
||||
--- Meta info ---------
|
||||
Author: test åuthor3 <test-email3@föo.com>
|
||||
Date: 2016-12-05 15:28:15 +0100
|
||||
is-merge-commit: False
|
||||
is-fixup-commit: False
|
||||
is-squash-commit: False
|
||||
is-revert-commit: False
|
||||
Branches: ['commit-3-branch-1', 'commit-3-branch-2']
|
||||
Changed Files: ['commit-3/file-1', 'commit-3/file-2']
|
||||
-----------------------
|
||||
DEBUG: gitlint.cli Exit Code = 6
|
|
@ -0,0 +1,3 @@
|
|||
1: T2 Title has trailing whitespace: "WIP: tïtle "
|
||||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: tïtle "
|
||||
3: B6 Body message is missing
|
|
@ -0,0 +1,3 @@
|
|||
1: T2 Title has trailing whitespace: "WIP: tïtle "
|
||||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: tïtle "
|
||||
3: B6 Body message is missing
|
|
@ -0,0 +1,83 @@
|
|||
DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
|
||||
DEBUG: gitlint.cli Platform: {platform}
|
||||
DEBUG: gitlint.cli Python version: {python_version}
|
||||
DEBUG: gitlint.cli Git version: git version 1.2.3
|
||||
DEBUG: gitlint.cli Gitlint version: {gitlint_version}
|
||||
DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
|
||||
DEBUG: gitlint.cli DEFAULT_ENCODING: {DEFAULT_ENCODING}
|
||||
DEBUG: gitlint.cli Configuration
|
||||
config-path: None
|
||||
[GENERAL]
|
||||
extra-path: None
|
||||
contrib: []
|
||||
ignore:
|
||||
ignore-merge-commits: True
|
||||
ignore-fixup-commits: True
|
||||
ignore-squash-commits: True
|
||||
ignore-revert-commits: True
|
||||
ignore-stdin: False
|
||||
staged: False
|
||||
fail-without-commits: False
|
||||
verbosity: 3
|
||||
debug: True
|
||||
target: {target}
|
||||
[RULES]
|
||||
I1: ignore-by-title
|
||||
ignore=all
|
||||
regex=None
|
||||
I2: ignore-by-body
|
||||
ignore=all
|
||||
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
|
||||
T6: title-leading-whitespace
|
||||
T3: title-trailing-punctuation
|
||||
T4: title-hard-tab
|
||||
T5: title-must-not-contain-word
|
||||
words=WIP
|
||||
T7: title-match-regex
|
||||
regex=None
|
||||
T8: title-min-length
|
||||
min-length=5
|
||||
B1: body-max-line-length
|
||||
line-length=80
|
||||
B5: body-min-length
|
||||
min-length=20
|
||||
B6: body-is-missing
|
||||
ignore-merge-commits=True
|
||||
B2: body-trailing-whitespace
|
||||
B3: body-hard-tab
|
||||
B4: body-first-line-empty
|
||||
B7: body-changed-file-mention
|
||||
files=
|
||||
B8: body-match-regex
|
||||
regex=None
|
||||
M1: author-valid-email
|
||||
regex=[^@ ]+@[^@ ]+\.[^@ ]+
|
||||
|
||||
DEBUG: gitlint.cli Stdin data: 'WIP: tïtle
|
||||
'
|
||||
DEBUG: gitlint.cli Stdin detected and not ignored. Using as input.
|
||||
DEBUG: gitlint.git ('config', '--get', 'core.commentchar')
|
||||
DEBUG: gitlint.cli Linting 1 commit(s)
|
||||
DEBUG: gitlint.lint Linting commit [SHA UNKNOWN]
|
||||
DEBUG: gitlint.lint Commit Object
|
||||
--- Commit Message ----
|
||||
WIP: tïtle
|
||||
--- Meta info ---------
|
||||
Author: None <None>
|
||||
Date: None
|
||||
is-merge-commit: False
|
||||
is-fixup-commit: False
|
||||
is-squash-commit: False
|
||||
is-revert-commit: False
|
||||
Branches: []
|
||||
Changed Files: []
|
||||
-----------------------
|
||||
DEBUG: gitlint.cli Exit Code = 3
|
|
@ -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"
|
|
@ -0,0 +1,8 @@
|
|||
Commit 6f29bf81a8:
|
||||
3: B5 Body message is too short (12<20): "commït-body1"
|
||||
|
||||
Commit 25053ccec5:
|
||||
3: B5 Body message is too short (12<20): "commït-body2"
|
||||
|
||||
Commit 4da2656b0d:
|
||||
3: B5 Body message is too short (12<20): "commït-body3"
|
|
@ -0,0 +1,6 @@
|
|||
Commit 6f29bf81a8:
|
||||
3: B5 Body message is too short (12<20): "commït-body1"
|
||||
|
||||
Commit 4da2656b0d:
|
||||
1: T3 Title has trailing punctuation (.): "commït-title3."
|
||||
3: B5 Body message is too short (12<20): "commït-body3"
|
|
@ -0,0 +1,2 @@
|
|||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: msg-filename tïtle"
|
||||
3: B6 Body message is missing
|
|
@ -0,0 +1,86 @@
|
|||
DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
|
||||
DEBUG: gitlint.cli Platform: {platform}
|
||||
DEBUG: gitlint.cli Python version: {python_version}
|
||||
DEBUG: gitlint.cli Git version: git version 1.2.3
|
||||
DEBUG: gitlint.cli Gitlint version: {gitlint_version}
|
||||
DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
|
||||
DEBUG: gitlint.cli DEFAULT_ENCODING: {DEFAULT_ENCODING}
|
||||
DEBUG: gitlint.cli Configuration
|
||||
config-path: None
|
||||
[GENERAL]
|
||||
extra-path: None
|
||||
contrib: []
|
||||
ignore:
|
||||
ignore-merge-commits: True
|
||||
ignore-fixup-commits: True
|
||||
ignore-squash-commits: True
|
||||
ignore-revert-commits: True
|
||||
ignore-stdin: False
|
||||
staged: True
|
||||
fail-without-commits: False
|
||||
verbosity: 3
|
||||
debug: True
|
||||
target: {target}
|
||||
[RULES]
|
||||
I1: ignore-by-title
|
||||
ignore=all
|
||||
regex=None
|
||||
I2: ignore-by-body
|
||||
ignore=all
|
||||
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
|
||||
T6: title-leading-whitespace
|
||||
T3: title-trailing-punctuation
|
||||
T4: title-hard-tab
|
||||
T5: title-must-not-contain-word
|
||||
words=WIP
|
||||
T7: title-match-regex
|
||||
regex=None
|
||||
T8: title-min-length
|
||||
min-length=5
|
||||
B1: body-max-line-length
|
||||
line-length=80
|
||||
B5: body-min-length
|
||||
min-length=20
|
||||
B6: body-is-missing
|
||||
ignore-merge-commits=True
|
||||
B2: body-trailing-whitespace
|
||||
B3: body-hard-tab
|
||||
B4: body-first-line-empty
|
||||
B7: body-changed-file-mention
|
||||
files=
|
||||
B8: body-match-regex
|
||||
regex=None
|
||||
M1: author-valid-email
|
||||
regex=[^@ ]+@[^@ ]+\.[^@ ]+
|
||||
|
||||
DEBUG: gitlint.cli Fetching additional meta-data from staged commit
|
||||
DEBUG: gitlint.cli Using --msg-filename.
|
||||
DEBUG: gitlint.git ('config', '--get', 'core.commentchar')
|
||||
DEBUG: gitlint.cli Linting 1 commit(s)
|
||||
DEBUG: gitlint.lint Linting commit [SHA UNKNOWN]
|
||||
DEBUG: gitlint.git ('config', '--get', 'user.name')
|
||||
DEBUG: gitlint.git ('config', '--get', 'user.email')
|
||||
DEBUG: gitlint.git ('rev-parse', '--abbrev-ref', 'HEAD')
|
||||
DEBUG: gitlint.git ('diff', '--staged', '--name-only', '-r')
|
||||
DEBUG: gitlint.lint Commit Object
|
||||
--- Commit Message ----
|
||||
WIP: msg-filename tïtle
|
||||
--- Meta info ---------
|
||||
Author: föo user <föo@bar.com>
|
||||
Date: 2020-02-19 12:18:46 +0100
|
||||
is-merge-commit: False
|
||||
is-fixup-commit: False
|
||||
is-squash-commit: False
|
||||
is-revert-commit: False
|
||||
Branches: ['my-branch']
|
||||
Changed Files: ['commit-1/file-1', 'commit-1/file-2']
|
||||
-----------------------
|
||||
DEBUG: gitlint.cli Exit Code = 2
|
|
@ -0,0 +1,3 @@
|
|||
1: T2 Title has trailing whitespace: "WIP: tïtle "
|
||||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: tïtle "
|
||||
3: B6 Body message is missing
|
|
@ -0,0 +1,88 @@
|
|||
DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
|
||||
DEBUG: gitlint.cli Platform: {platform}
|
||||
DEBUG: gitlint.cli Python version: {python_version}
|
||||
DEBUG: gitlint.cli Git version: git version 1.2.3
|
||||
DEBUG: gitlint.cli Gitlint version: {gitlint_version}
|
||||
DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
|
||||
DEBUG: gitlint.cli DEFAULT_ENCODING: {DEFAULT_ENCODING}
|
||||
DEBUG: gitlint.cli Configuration
|
||||
config-path: None
|
||||
[GENERAL]
|
||||
extra-path: None
|
||||
contrib: []
|
||||
ignore:
|
||||
ignore-merge-commits: True
|
||||
ignore-fixup-commits: True
|
||||
ignore-squash-commits: True
|
||||
ignore-revert-commits: True
|
||||
ignore-stdin: False
|
||||
staged: True
|
||||
fail-without-commits: False
|
||||
verbosity: 3
|
||||
debug: True
|
||||
target: {target}
|
||||
[RULES]
|
||||
I1: ignore-by-title
|
||||
ignore=all
|
||||
regex=None
|
||||
I2: ignore-by-body
|
||||
ignore=all
|
||||
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
|
||||
T6: title-leading-whitespace
|
||||
T3: title-trailing-punctuation
|
||||
T4: title-hard-tab
|
||||
T5: title-must-not-contain-word
|
||||
words=WIP
|
||||
T7: title-match-regex
|
||||
regex=None
|
||||
T8: title-min-length
|
||||
min-length=5
|
||||
B1: body-max-line-length
|
||||
line-length=80
|
||||
B5: body-min-length
|
||||
min-length=20
|
||||
B6: body-is-missing
|
||||
ignore-merge-commits=True
|
||||
B2: body-trailing-whitespace
|
||||
B3: body-hard-tab
|
||||
B4: body-first-line-empty
|
||||
B7: body-changed-file-mention
|
||||
files=
|
||||
B8: body-match-regex
|
||||
regex=None
|
||||
M1: author-valid-email
|
||||
regex=[^@ ]+@[^@ ]+\.[^@ ]+
|
||||
|
||||
DEBUG: gitlint.cli Fetching additional meta-data from staged commit
|
||||
DEBUG: gitlint.cli Stdin data: 'WIP: tïtle
|
||||
'
|
||||
DEBUG: gitlint.cli Stdin detected and not ignored. Using as input.
|
||||
DEBUG: gitlint.git ('config', '--get', 'core.commentchar')
|
||||
DEBUG: gitlint.cli Linting 1 commit(s)
|
||||
DEBUG: gitlint.lint Linting commit [SHA UNKNOWN]
|
||||
DEBUG: gitlint.git ('config', '--get', 'user.name')
|
||||
DEBUG: gitlint.git ('config', '--get', 'user.email')
|
||||
DEBUG: gitlint.git ('rev-parse', '--abbrev-ref', 'HEAD')
|
||||
DEBUG: gitlint.git ('diff', '--staged', '--name-only', '-r')
|
||||
DEBUG: gitlint.lint Commit Object
|
||||
--- Commit Message ----
|
||||
WIP: tïtle
|
||||
--- Meta info ---------
|
||||
Author: föo user <föo@bar.com>
|
||||
Date: 2020-02-19 12:18:46 +0100
|
||||
is-merge-commit: False
|
||||
is-fixup-commit: False
|
||||
is-squash-commit: False
|
||||
is-revert-commit: False
|
||||
Branches: ['my-branch']
|
||||
Changed Files: ['commit-1/file-1', 'commit-1/file-2']
|
||||
-----------------------
|
||||
DEBUG: gitlint.cli Exit Code = 3
|
|
@ -0,0 +1,4 @@
|
|||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: tëst tïtle"
|
||||
1: T5:even-more-wörds Title contains the word 'tïtle' (case-insensitive): "WIP: tëst tïtle"
|
||||
1: T5:extra-wörds Title contains the word 'tëst' (case-insensitive): "WIP: tëst tïtle"
|
||||
3: B6 Body message is missing
|
|
@ -0,0 +1,86 @@
|
|||
DEBUG: gitlint.cli To report issues, please visit https://github.com/jorisroovers/gitlint/issues
|
||||
DEBUG: gitlint.cli Platform: {platform}
|
||||
DEBUG: gitlint.cli Python version: {python_version}
|
||||
DEBUG: gitlint.cli Git version: git version 1.2.3
|
||||
DEBUG: gitlint.cli Gitlint version: {gitlint_version}
|
||||
DEBUG: gitlint.cli GITLINT_USE_SH_LIB: {GITLINT_USE_SH_LIB}
|
||||
DEBUG: gitlint.cli DEFAULT_ENCODING: {DEFAULT_ENCODING}
|
||||
DEBUG: gitlint.cli Configuration
|
||||
config-path: {config_path}
|
||||
[GENERAL]
|
||||
extra-path: None
|
||||
contrib: []
|
||||
ignore:
|
||||
ignore-merge-commits: True
|
||||
ignore-fixup-commits: True
|
||||
ignore-squash-commits: True
|
||||
ignore-revert-commits: True
|
||||
ignore-stdin: False
|
||||
staged: False
|
||||
fail-without-commits: False
|
||||
verbosity: 3
|
||||
debug: True
|
||||
target: {target}
|
||||
[RULES]
|
||||
I1: ignore-by-title
|
||||
ignore=all
|
||||
regex=None
|
||||
I2: ignore-by-body
|
||||
ignore=all
|
||||
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
|
||||
T6: title-leading-whitespace
|
||||
T3: title-trailing-punctuation
|
||||
T4: title-hard-tab
|
||||
T5: title-must-not-contain-word
|
||||
words=WIP,bögus
|
||||
T7: title-match-regex
|
||||
regex=None
|
||||
T8: title-min-length
|
||||
min-length=5
|
||||
B1: body-max-line-length
|
||||
line-length=80
|
||||
B5: body-min-length
|
||||
min-length=20
|
||||
B6: body-is-missing
|
||||
ignore-merge-commits=True
|
||||
B2: body-trailing-whitespace
|
||||
B3: body-hard-tab
|
||||
B4: body-first-line-empty
|
||||
B7: body-changed-file-mention
|
||||
files=
|
||||
B8: body-match-regex
|
||||
regex=None
|
||||
M1: author-valid-email
|
||||
regex=[^@ ]+@[^@ ]+\.[^@ ]+
|
||||
T5:extra-wörds: title-must-not-contain-word:extra-wörds
|
||||
words=hür,tëst
|
||||
T5:even-more-wörds: title-must-not-contain-word:even-more-wörds
|
||||
words=hür,tïtle
|
||||
|
||||
DEBUG: gitlint.cli Stdin data: 'WIP: tëst tïtle'
|
||||
DEBUG: gitlint.cli Stdin detected and not ignored. Using as input.
|
||||
DEBUG: gitlint.git ('config', '--get', 'core.commentchar')
|
||||
DEBUG: gitlint.cli Linting 1 commit(s)
|
||||
DEBUG: gitlint.lint Linting commit [SHA UNKNOWN]
|
||||
DEBUG: gitlint.lint Commit Object
|
||||
--- Commit Message ----
|
||||
WIP: tëst tïtle
|
||||
--- Meta info ---------
|
||||
Author: None <None>
|
||||
Date: None
|
||||
is-merge-commit: False
|
||||
is-fixup-commit: False
|
||||
is-squash-commit: False
|
||||
is-revert-commit: False
|
||||
Branches: []
|
||||
Changed Files: []
|
||||
-----------------------
|
||||
DEBUG: gitlint.cli Exit Code = 4
|
|
@ -0,0 +1,2 @@
|
|||
1: T1 Title exceeds max length (27>5): "WIP: Test hook config tïtle"
|
||||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Test hook config tïtle"
|
|
@ -0,0 +1,5 @@
|
|||
gitlint: checking commit message...
|
||||
-----------------------------------------------
|
||||
gitlint: Your commit message contains violations.
|
||||
Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)]
|
||||
Aborted!
|
|
@ -0,0 +1,6 @@
|
|||
1: T5 Title contains the word 'WIP' (case-insensitive): "{commit_msg}"
|
||||
3: B6 Body message is missing
|
||||
1: T5 Title contains the word 'WIP' (case-insensitive): "{commit_msg}"
|
||||
3: B6 Body message is missing
|
||||
1: T5 Title contains the word 'WIP' (case-insensitive): "{commit_msg}"
|
||||
3: B6 Body message is missing
|
|
@ -0,0 +1,14 @@
|
|||
gitlint: checking commit message...
|
||||
-----------------------------------------------
|
||||
gitlint: Your commit message contains violations.
|
||||
Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] gitlint: checking commit message...
|
||||
-----------------------------------------------
|
||||
gitlint: Your commit message contains violations.
|
||||
Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] gitlint: checking commit message...
|
||||
-----------------------------------------------
|
||||
gitlint: Your commit message contains violations.
|
||||
Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] Commit aborted.
|
||||
Your commit message:
|
||||
-----------------------------------------------
|
||||
{commit_msg}
|
||||
-----------------------------------------------
|
|
@ -0,0 +1,2 @@
|
|||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: commït-title"
|
||||
3: B5 Body message is too short (11<20): "commït-body"
|
|
@ -0,0 +1,4 @@
|
|||
gitlint: checking commit message...
|
||||
-----------------------------------------------
|
||||
gitlint: Your commit message contains violations.
|
||||
Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] Editing only possible when --msg-filename is specified.
|
|
@ -0,0 +1,2 @@
|
|||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: höok no"
|
||||
3: B6 Body message is missing
|
|
@ -0,0 +1,8 @@
|
|||
gitlint: checking commit message...
|
||||
-----------------------------------------------
|
||||
gitlint: Your commit message contains violations.
|
||||
Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)] Commit aborted.
|
||||
Your commit message:
|
||||
-----------------------------------------------
|
||||
WIP: höok no
|
||||
-----------------------------------------------
|
|
@ -0,0 +1,2 @@
|
|||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: tïtle"
|
||||
3: B6 Body message is missing
|
|
@ -0,0 +1,5 @@
|
|||
gitlint: checking commit message...
|
||||
-----------------------------------------------
|
||||
gitlint: Your commit message contains violations.
|
||||
Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)]
|
||||
Aborted!
|
|
@ -0,0 +1,2 @@
|
|||
gitlint: checking commit message...
|
||||
gitlint: OK (no violations in commit message)
|
|
@ -0,0 +1,2 @@
|
|||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: Test hook stdin tïtle"
|
||||
3: B6 Body message is missing
|
|
@ -0,0 +1,5 @@
|
|||
gitlint: checking commit message...
|
||||
-----------------------------------------------
|
||||
gitlint: Your commit message contains violations.
|
||||
Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)]
|
||||
Aborted!
|
|
@ -0,0 +1,2 @@
|
|||
1: T5 Title contains the word 'WIP' (case-insensitive): "WIP: höok yes"
|
||||
3: B6 Body message is missing
|
|
@ -0,0 +1,4 @@
|
|||
gitlint: checking commit message...
|
||||
-----------------------------------------------
|
||||
gitlint: Your commit message contains violations.
|
||||
Continue with commit anyways (this keeps the current commit message)? [y(es)/n(no)/e(dit)]
|
|
@ -0,0 +1,2 @@
|
|||
gitlint: checking commit message...
|
||||
{git_repo} is not a git repository.
|
|
@ -0,0 +1,2 @@
|
|||
gitlint: checking commit message...
|
||||
Error: The 'staged' option (--staged) can only be used when using '--msg-filename' or when piping data to gitlint via stdin.
|
108
gitlint-core/gitlint/tests/git/test_git.py
Normal file
108
gitlint-core/gitlint/tests/git/test_git.py
Normal file
|
@ -0,0 +1,108 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from gitlint.shell import ErrorReturnCode, CommandNotFound
|
||||
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint.git import GitContext, GitContextError, GitNotInstalledError, git_commentchar, git_hooks_dir
|
||||
|
||||
|
||||
class GitTests(BaseTestCase):
|
||||
|
||||
# Expected special_args passed to 'sh'
|
||||
expected_sh_special_args = {
|
||||
'_tty_out': False,
|
||||
'_cwd': "fåke/path"
|
||||
}
|
||||
|
||||
@patch('gitlint.git.sh')
|
||||
def test_get_latest_commit_command_not_found(self, sh):
|
||||
sh.git.side_effect = CommandNotFound("git")
|
||||
expected_msg = "'git' command not found. You need to install git to use gitlint on a local repository. " + \
|
||||
"See https://git-scm.com/book/en/v2/Getting-Started-Installing-Git on how to install git."
|
||||
with self.assertRaisesMessage(GitNotInstalledError, expected_msg):
|
||||
GitContext.from_local_repository("fåke/path")
|
||||
|
||||
# assert that commit message was read using git command
|
||||
sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
|
||||
|
||||
@patch('gitlint.git.sh')
|
||||
def test_get_latest_commit_git_error(self, sh):
|
||||
# Current directory not a git repo
|
||||
err = b"fatal: Not a git repository (or any of the parent directories): .git"
|
||||
sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err)
|
||||
|
||||
with self.assertRaisesMessage(GitContextError, "fåke/path is not a git repository."):
|
||||
GitContext.from_local_repository("fåke/path")
|
||||
|
||||
# assert that commit message was read using git command
|
||||
sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
|
||||
sh.git.reset_mock()
|
||||
|
||||
err = b"fatal: Random git error"
|
||||
sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err)
|
||||
|
||||
expected_msg = f"An error occurred while executing 'git log -1 --pretty=%H': {err}"
|
||||
with self.assertRaisesMessage(GitContextError, expected_msg):
|
||||
GitContext.from_local_repository("fåke/path")
|
||||
|
||||
# assert that commit message was read using git command
|
||||
sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
|
||||
|
||||
@patch('gitlint.git.sh')
|
||||
def test_git_no_commits_error(self, sh):
|
||||
# No commits: returned by 'git log'
|
||||
err = b"fatal: your current branch 'master' does not have any commits yet"
|
||||
|
||||
sh.git.side_effect = ErrorReturnCode("git log -1 --pretty=%H", b"", err)
|
||||
|
||||
expected_msg = "Current branch has no commits. Gitlint requires at least one commit to function."
|
||||
with self.assertRaisesMessage(GitContextError, expected_msg):
|
||||
GitContext.from_local_repository("fåke/path")
|
||||
|
||||
# assert that commit message was read using git command
|
||||
sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
|
||||
sh.git.reset_mock()
|
||||
|
||||
# Unknown reference 'HEAD' commits: returned by 'git rev-parse'
|
||||
err = (b"HEAD"
|
||||
b"fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree."
|
||||
b"Use '--' to separate paths from revisions, like this:"
|
||||
b"'git <command> [<revision>...] -- [<file>...]'")
|
||||
|
||||
sh.git.side_effect = [
|
||||
"#\n", # git config --get core.commentchar
|
||||
ErrorReturnCode("rev-parse --abbrev-ref HEAD", b"", err)
|
||||
]
|
||||
|
||||
with self.assertRaisesMessage(GitContextError, expected_msg):
|
||||
context = GitContext.from_commit_msg("test")
|
||||
context.current_branch
|
||||
|
||||
# assert that commit message was read using git command
|
||||
sh.git.assert_called_with("rev-parse", "--abbrev-ref", "HEAD", _tty_out=False, _cwd=None)
|
||||
|
||||
@patch("gitlint.git._git")
|
||||
def test_git_commentchar(self, git):
|
||||
git.return_value.exit_code = 1
|
||||
self.assertEqual(git_commentchar(), "#")
|
||||
|
||||
git.return_value.exit_code = 0
|
||||
git.return_value = "ä"
|
||||
self.assertEqual(git_commentchar(), "ä")
|
||||
|
||||
git.return_value = ';\n'
|
||||
self.assertEqual(git_commentchar(os.path.join("/föo", "bar")), ';')
|
||||
|
||||
git.assert_called_with("config", "--get", "core.commentchar", _ok_code=[0, 1],
|
||||
_cwd=os.path.join("/föo", "bar"))
|
||||
|
||||
@patch("gitlint.git._git")
|
||||
def test_git_hooks_dir(self, git):
|
||||
hooks_dir = os.path.join("föo", ".git", "hooks")
|
||||
git.return_value = hooks_dir + "\n"
|
||||
self.assertEqual(git_hooks_dir("/blä"), os.path.abspath(os.path.join("/blä", hooks_dir)))
|
||||
|
||||
git.assert_called_once_with("rev-parse", "--git-path", "hooks", _cwd="/blä")
|
619
gitlint-core/gitlint/tests/git/test_git_commit.py
Normal file
619
gitlint-core/gitlint/tests/git/test_git_commit.py
Normal file
|
@ -0,0 +1,619 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import copy
|
||||
import datetime
|
||||
|
||||
import dateutil
|
||||
|
||||
import arrow
|
||||
|
||||
from unittest.mock import patch, call
|
||||
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint.git import GitContext, GitCommit, GitContextError, LocalGitCommit, StagedLocalGitCommit, GitCommitMessage
|
||||
from gitlint.shell import ErrorReturnCode
|
||||
|
||||
|
||||
class GitCommitTests(BaseTestCase):
|
||||
|
||||
# Expected special_args passed to 'sh'
|
||||
expected_sh_special_args = {
|
||||
'_tty_out': False,
|
||||
'_cwd': "fåke/path"
|
||||
}
|
||||
|
||||
@patch('gitlint.git.sh')
|
||||
def test_get_latest_commit(self, sh):
|
||||
sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"
|
||||
|
||||
sh.git.side_effect = [
|
||||
sample_sha,
|
||||
"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")
|
||||
# assert that commit info was read using git command
|
||||
expected_calls = [
|
||||
call("log", "-1", "--pretty=%H", **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,
|
||||
**self.expected_sh_special_args),
|
||||
call('branch', '--contains', sample_sha, **self.expected_sh_special_args)
|
||||
]
|
||||
|
||||
# Only first 'git log' call should've happened at this point
|
||||
self.assertListEqual(sh.git.mock_calls, expected_calls[:1])
|
||||
|
||||
last_commit = context.commits[-1]
|
||||
self.assertIsInstance(last_commit, LocalGitCommit)
|
||||
self.assertEqual(last_commit.sha, sample_sha)
|
||||
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_from_local_repository_specific_refspec(self, sh):
|
||||
sample_refspec = "åbc123..def456"
|
||||
sample_sha = "åbc123"
|
||||
|
||||
sh.git.side_effect = [
|
||||
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
|
||||
"file1.txt\npåth/to/file2.txt\n",
|
||||
"foöbar\n* hürdur\n"
|
||||
]
|
||||
|
||||
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_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,
|
||||
**self.expected_sh_special_args),
|
||||
call('branch', '--contains', sample_sha, **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_sha)
|
||||
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_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"
|
||||
|
||||
sh.git.side_effect = [
|
||||
sample_sha,
|
||||
"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc def\n"
|
||||
"Merge \"foo bår commit\"",
|
||||
"#", # 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")
|
||||
# assert that commit info was read using git command
|
||||
expected_calls = [
|
||||
call("log", "-1", "--pretty=%H", **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,
|
||||
**self.expected_sh_special_args),
|
||||
call('branch', '--contains', sample_sha, **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_sha)
|
||||
self.assertEqual(last_commit.message.title, "Merge \"foo bår commit\"")
|
||||
self.assertEqual(last_commit.message.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", "def"])
|
||||
self.assertTrue(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_fixup_squash_commit(self, sh):
|
||||
commit_types = ["fixup", "squash"]
|
||||
for commit_type in commit_types:
|
||||
sample_sha = "d8ac47e9f2923c7f22d8668e3a1ed04eb4cdbca9"
|
||||
|
||||
sh.git.side_effect = [
|
||||
sample_sha,
|
||||
"test åuthor\x00test-emåil@foo.com\x002016-12-03 15:28:15 +0100\x00åbc\n"
|
||||
f"{commit_type}! \"foo bår commit\"",
|
||||
"#", # 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")
|
||||
# assert that commit info was read using git command
|
||||
expected_calls = [
|
||||
call("log", "-1", "--pretty=%H", **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,
|
||||
**self.expected_sh_special_args),
|
||||
call('branch', '--contains', sample_sha, **self.expected_sh_special_args)
|
||||
]
|
||||
|
||||
# Only first 'git log' call should've happened at this point
|
||||
self.assertEqual(sh.git.mock_calls, expected_calls[:-4])
|
||||
|
||||
last_commit = context.commits[-1]
|
||||
self.assertIsInstance(last_commit, LocalGitCommit)
|
||||
self.assertEqual(last_commit.sha, sample_sha)
|
||||
self.assertEqual(last_commit.message.title, f"{commit_type}! \"foo bår commit\"")
|
||||
self.assertEqual(last_commit.message.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"])
|
||||
|
||||
# First 2 'git log' calls should've happened at this point
|
||||
self.assertEqual(sh.git.mock_calls, expected_calls[:3])
|
||||
|
||||
# Asserting that squash and fixup are correct
|
||||
for type in commit_types:
|
||||
attr = "is_" + type + "_commit"
|
||||
self.assertEqual(getattr(last_commit, attr), commit_type == type)
|
||||
|
||||
self.assertFalse(last_commit.is_merge_commit)
|
||||
self.assertFalse(last_commit.is_revert_commit)
|
||||
self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"])
|
||||
|
||||
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)
|
||||
|
||||
sh.git.reset_mock()
|
||||
|
||||
@patch("gitlint.git.git_commentchar")
|
||||
def test_from_commit_msg_full(self, commentchar):
|
||||
commentchar.return_value = "#"
|
||||
gitcontext = GitContext.from_commit_msg(self.get_sample("commit_message/sample1"))
|
||||
|
||||
expected_title = "Commit title contåining 'WIP', as well as trailing punctuation."
|
||||
expected_body = ["This line should be empty",
|
||||
"This is the first line of the commit message body and it is meant to test a " +
|
||||
"line that exceeds the maximum line length of 80 characters.",
|
||||
"This line has a tråiling space. ",
|
||||
"This line has a trailing tab.\t"]
|
||||
expected_full = expected_title + "\n" + "\n".join(expected_body)
|
||||
expected_original = expected_full + (
|
||||
"\n# This is a cömmented line\n"
|
||||
"# ------------------------ >8 ------------------------\n"
|
||||
"# Anything after this line should be cleaned up\n"
|
||||
"# this line appears on `git commit -v` command\n"
|
||||
"diff --git a/gitlint/tests/samples/commit_message/sample1 "
|
||||
"b/gitlint/tests/samples/commit_message/sample1\n"
|
||||
"index 82dbe7f..ae71a14 100644\n"
|
||||
"--- a/gitlint/tests/samples/commit_message/sample1\n"
|
||||
"+++ b/gitlint/tests/samples/commit_message/sample1\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
)
|
||||
|
||||
commit = gitcontext.commits[-1]
|
||||
self.assertIsInstance(commit, GitCommit)
|
||||
self.assertFalse(isinstance(commit, LocalGitCommit))
|
||||
self.assertEqual(commit.message.title, expected_title)
|
||||
self.assertEqual(commit.message.body, expected_body)
|
||||
self.assertEqual(commit.message.full, expected_full)
|
||||
self.assertEqual(commit.message.original, expected_original)
|
||||
self.assertEqual(commit.author_name, None)
|
||||
self.assertEqual(commit.author_email, None)
|
||||
self.assertEqual(commit.date, None)
|
||||
self.assertListEqual(commit.parents, [])
|
||||
self.assertListEqual(commit.branches, [])
|
||||
self.assertFalse(commit.is_merge_commit)
|
||||
self.assertFalse(commit.is_fixup_commit)
|
||||
self.assertFalse(commit.is_squash_commit)
|
||||
self.assertFalse(commit.is_revert_commit)
|
||||
self.assertEqual(len(gitcontext.commits), 1)
|
||||
|
||||
def test_from_commit_msg_just_title(self):
|
||||
gitcontext = GitContext.from_commit_msg(self.get_sample("commit_message/sample2"))
|
||||
commit = gitcontext.commits[-1]
|
||||
|
||||
self.assertIsInstance(commit, GitCommit)
|
||||
self.assertFalse(isinstance(commit, LocalGitCommit))
|
||||
self.assertEqual(commit.message.title, "Just a title contåining WIP")
|
||||
self.assertEqual(commit.message.body, [])
|
||||
self.assertEqual(commit.message.full, "Just a title contåining WIP")
|
||||
self.assertEqual(commit.message.original, "Just a title contåining WIP")
|
||||
self.assertEqual(commit.author_name, None)
|
||||
self.assertEqual(commit.author_email, None)
|
||||
self.assertListEqual(commit.parents, [])
|
||||
self.assertListEqual(commit.branches, [])
|
||||
self.assertFalse(commit.is_merge_commit)
|
||||
self.assertFalse(commit.is_fixup_commit)
|
||||
self.assertFalse(commit.is_squash_commit)
|
||||
self.assertFalse(commit.is_revert_commit)
|
||||
self.assertEqual(len(gitcontext.commits), 1)
|
||||
|
||||
def test_from_commit_msg_empty(self):
|
||||
gitcontext = GitContext.from_commit_msg("")
|
||||
commit = gitcontext.commits[-1]
|
||||
|
||||
self.assertIsInstance(commit, GitCommit)
|
||||
self.assertFalse(isinstance(commit, LocalGitCommit))
|
||||
self.assertEqual(commit.message.title, "")
|
||||
self.assertEqual(commit.message.body, [])
|
||||
self.assertEqual(commit.message.full, "")
|
||||
self.assertEqual(commit.message.original, "")
|
||||
self.assertEqual(commit.author_name, None)
|
||||
self.assertEqual(commit.author_email, None)
|
||||
self.assertEqual(commit.date, None)
|
||||
self.assertListEqual(commit.parents, [])
|
||||
self.assertListEqual(commit.branches, [])
|
||||
self.assertFalse(commit.is_merge_commit)
|
||||
self.assertFalse(commit.is_fixup_commit)
|
||||
self.assertFalse(commit.is_squash_commit)
|
||||
self.assertFalse(commit.is_revert_commit)
|
||||
self.assertEqual(len(gitcontext.commits), 1)
|
||||
|
||||
@patch("gitlint.git.git_commentchar")
|
||||
def test_from_commit_msg_comment(self, commentchar):
|
||||
commentchar.return_value = "#"
|
||||
gitcontext = GitContext.from_commit_msg("Tïtle\n\nBödy 1\n#Cömment\nBody 2")
|
||||
commit = gitcontext.commits[-1]
|
||||
|
||||
self.assertIsInstance(commit, GitCommit)
|
||||
self.assertFalse(isinstance(commit, LocalGitCommit))
|
||||
self.assertEqual(commit.message.title, "Tïtle")
|
||||
self.assertEqual(commit.message.body, ["", "Bödy 1", "Body 2"])
|
||||
self.assertEqual(commit.message.full, "Tïtle\n\nBödy 1\nBody 2")
|
||||
self.assertEqual(commit.message.original, "Tïtle\n\nBödy 1\n#Cömment\nBody 2")
|
||||
self.assertEqual(commit.author_name, None)
|
||||
self.assertEqual(commit.author_email, None)
|
||||
self.assertEqual(commit.date, None)
|
||||
self.assertListEqual(commit.parents, [])
|
||||
self.assertListEqual(commit.branches, [])
|
||||
self.assertFalse(commit.is_merge_commit)
|
||||
self.assertFalse(commit.is_fixup_commit)
|
||||
self.assertFalse(commit.is_squash_commit)
|
||||
self.assertFalse(commit.is_revert_commit)
|
||||
self.assertEqual(len(gitcontext.commits), 1)
|
||||
|
||||
def test_from_commit_msg_merge_commit(self):
|
||||
commit_msg = "Merge f919b8f34898d9b48048bcd703bc47139f4ff621 into 8b0409a26da6ba8a47c1fd2e746872a8dab15401"
|
||||
gitcontext = GitContext.from_commit_msg(commit_msg)
|
||||
commit = gitcontext.commits[-1]
|
||||
|
||||
self.assertIsInstance(commit, GitCommit)
|
||||
self.assertFalse(isinstance(commit, LocalGitCommit))
|
||||
self.assertEqual(commit.message.title, commit_msg)
|
||||
self.assertEqual(commit.message.body, [])
|
||||
self.assertEqual(commit.message.full, commit_msg)
|
||||
self.assertEqual(commit.message.original, commit_msg)
|
||||
self.assertEqual(commit.author_name, None)
|
||||
self.assertEqual(commit.author_email, None)
|
||||
self.assertEqual(commit.date, None)
|
||||
self.assertListEqual(commit.parents, [])
|
||||
self.assertListEqual(commit.branches, [])
|
||||
self.assertTrue(commit.is_merge_commit)
|
||||
self.assertFalse(commit.is_fixup_commit)
|
||||
self.assertFalse(commit.is_squash_commit)
|
||||
self.assertFalse(commit.is_revert_commit)
|
||||
self.assertEqual(len(gitcontext.commits), 1)
|
||||
|
||||
def test_from_commit_msg_revert_commit(self):
|
||||
commit_msg = "Revert \"Prev commit message\"\n\nThis reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c."
|
||||
gitcontext = GitContext.from_commit_msg(commit_msg)
|
||||
commit = gitcontext.commits[-1]
|
||||
|
||||
self.assertIsInstance(commit, GitCommit)
|
||||
self.assertFalse(isinstance(commit, LocalGitCommit))
|
||||
self.assertEqual(commit.message.title, "Revert \"Prev commit message\"")
|
||||
self.assertEqual(commit.message.body, ["", "This reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c."])
|
||||
self.assertEqual(commit.message.full, commit_msg)
|
||||
self.assertEqual(commit.message.original, commit_msg)
|
||||
self.assertEqual(commit.author_name, None)
|
||||
self.assertEqual(commit.author_email, None)
|
||||
self.assertEqual(commit.date, None)
|
||||
self.assertListEqual(commit.parents, [])
|
||||
self.assertListEqual(commit.branches, [])
|
||||
self.assertFalse(commit.is_merge_commit)
|
||||
self.assertFalse(commit.is_fixup_commit)
|
||||
self.assertFalse(commit.is_squash_commit)
|
||||
self.assertTrue(commit.is_revert_commit)
|
||||
self.assertEqual(len(gitcontext.commits), 1)
|
||||
|
||||
def test_from_commit_msg_fixup_squash_commit(self):
|
||||
commit_types = ["fixup", "squash"]
|
||||
for commit_type in commit_types:
|
||||
commit_msg = f"{commit_type}! Test message"
|
||||
gitcontext = GitContext.from_commit_msg(commit_msg)
|
||||
commit = gitcontext.commits[-1]
|
||||
|
||||
self.assertIsInstance(commit, GitCommit)
|
||||
self.assertFalse(isinstance(commit, LocalGitCommit))
|
||||
self.assertEqual(commit.message.title, commit_msg)
|
||||
self.assertEqual(commit.message.body, [])
|
||||
self.assertEqual(commit.message.full, commit_msg)
|
||||
self.assertEqual(commit.message.original, commit_msg)
|
||||
self.assertEqual(commit.author_name, None)
|
||||
self.assertEqual(commit.author_email, None)
|
||||
self.assertEqual(commit.date, None)
|
||||
self.assertListEqual(commit.parents, [])
|
||||
self.assertListEqual(commit.branches, [])
|
||||
self.assertEqual(len(gitcontext.commits), 1)
|
||||
self.assertFalse(commit.is_merge_commit)
|
||||
self.assertFalse(commit.is_revert_commit)
|
||||
# Asserting that squash and fixup are correct
|
||||
for type in commit_types:
|
||||
attr = "is_" + type + "_commit"
|
||||
self.assertEqual(getattr(commit, attr), commit_type == type)
|
||||
|
||||
@patch('gitlint.git.sh')
|
||||
@patch('arrow.now')
|
||||
def test_staged_commit(self, now, sh):
|
||||
# StagedLocalGitCommit()
|
||||
|
||||
sh.git.side_effect = [
|
||||
"#", # git config --get core.commentchar
|
||||
"test åuthor\n", # git config --get user.name
|
||||
"test-emåil@foo.com\n", # git config --get user.email
|
||||
"my-brånch\n", # git rev-parse --abbrev-ref HEAD
|
||||
"file1.txt\npåth/to/file2.txt\n",
|
||||
]
|
||||
now.side_effect = [arrow.get("2020-02-19T12:18:46.675182+01:00")]
|
||||
|
||||
# We use a fixup commit, just to test a non-default path
|
||||
context = GitContext.from_staged_commit("fixup! Foōbar 123\n\ncömmit-body\n", "fåke/path")
|
||||
|
||||
# git calls we're expexting
|
||||
expected_calls = [
|
||||
call('config', '--get', 'core.commentchar', _ok_code=[0, 1], **self.expected_sh_special_args),
|
||||
call('config', '--get', 'user.name', **self.expected_sh_special_args),
|
||||
call('config', '--get', 'user.email', **self.expected_sh_special_args),
|
||||
call("rev-parse", "--abbrev-ref", "HEAD", **self.expected_sh_special_args),
|
||||
call("diff", "--staged", "--name-only", "-r", **self.expected_sh_special_args)
|
||||
]
|
||||
|
||||
last_commit = context.commits[-1]
|
||||
self.assertIsInstance(last_commit, StagedLocalGitCommit)
|
||||
self.assertIsNone(last_commit.sha, None)
|
||||
self.assertEqual(last_commit.message.title, "fixup! Foōbar 123")
|
||||
self.assertEqual(last_commit.message.body, ["", "cömmit-body"])
|
||||
# Only `git config --get core.commentchar` should've happened up until this point
|
||||
self.assertListEqual(sh.git.mock_calls, expected_calls[0:1])
|
||||
|
||||
self.assertEqual(last_commit.author_name, "test åuthor")
|
||||
self.assertListEqual(sh.git.mock_calls, expected_calls[0:2])
|
||||
|
||||
self.assertEqual(last_commit.author_email, "test-emåil@foo.com")
|
||||
self.assertListEqual(sh.git.mock_calls, expected_calls[0:3])
|
||||
|
||||
self.assertEqual(last_commit.date, datetime.datetime(2020, 2, 19, 12, 18, 46,
|
||||
tzinfo=dateutil.tz.tzoffset("+0100", 3600)))
|
||||
now.assert_called_once()
|
||||
|
||||
self.assertListEqual(last_commit.parents, [])
|
||||
self.assertFalse(last_commit.is_merge_commit)
|
||||
self.assertTrue(last_commit.is_fixup_commit)
|
||||
self.assertFalse(last_commit.is_squash_commit)
|
||||
self.assertFalse(last_commit.is_revert_commit)
|
||||
|
||||
self.assertListEqual(last_commit.branches, ["my-brånch"])
|
||||
self.assertListEqual(sh.git.mock_calls, expected_calls[0:4])
|
||||
|
||||
self.assertListEqual(last_commit.changed_files, ["file1.txt", "påth/to/file2.txt"])
|
||||
self.assertListEqual(sh.git.mock_calls, expected_calls[0:5])
|
||||
|
||||
@patch('gitlint.git.sh')
|
||||
def test_staged_commit_with_missing_username(self, sh):
|
||||
# StagedLocalGitCommit()
|
||||
|
||||
sh.git.side_effect = [
|
||||
"#", # git config --get core.commentchar
|
||||
ErrorReturnCode('git config --get user.name', b"", b""),
|
||||
]
|
||||
|
||||
expected_msg = "Missing git configuration: please set user.name"
|
||||
with self.assertRaisesMessage(GitContextError, expected_msg):
|
||||
ctx = GitContext.from_staged_commit("Foōbar 123\n\ncömmit-body\n", "fåke/path")
|
||||
[str(commit) for commit in ctx.commits]
|
||||
|
||||
@patch('gitlint.git.sh')
|
||||
def test_staged_commit_with_missing_email(self, sh):
|
||||
# StagedLocalGitCommit()
|
||||
|
||||
sh.git.side_effect = [
|
||||
"#", # git config --get core.commentchar
|
||||
"test åuthor\n", # git config --get user.name
|
||||
ErrorReturnCode('git config --get user.name', b"", b""),
|
||||
]
|
||||
|
||||
expected_msg = "Missing git configuration: please set user.email"
|
||||
with self.assertRaisesMessage(GitContextError, expected_msg):
|
||||
ctx = GitContext.from_staged_commit("Foōbar 123\n\ncömmit-body\n", "fåke/path")
|
||||
[str(commit) for commit in ctx.commits]
|
||||
|
||||
def test_gitcommitmessage_equality(self):
|
||||
commit_message1 = GitCommitMessage(GitContext(), "tëst\n\nfoo", "tëst\n\nfoo", "tēst", ["", "föo"])
|
||||
attrs = ['original', 'full', 'title', 'body']
|
||||
self.object_equality_test(commit_message1, attrs, {"context": commit_message1.context})
|
||||
|
||||
@patch("gitlint.git._git")
|
||||
def test_gitcommit_equality(self, git):
|
||||
# git will be called to setup the context (commentchar and current_branch), just return the same value
|
||||
# This only matters to test gitcontext equality, not gitcommit equality
|
||||
git.return_value = "foöbar"
|
||||
|
||||
# Test simple equality case
|
||||
now = datetime.datetime.utcnow()
|
||||
context1 = GitContext()
|
||||
commit_message1 = GitCommitMessage(context1, "tëst\n\nfoo", "tëst\n\nfoo", "tēst", ["", "föo"])
|
||||
commit1 = GitCommit(context1, commit_message1, "shä", now, "Jöhn Smith", "jöhn.smith@test.com", None,
|
||||
["föo/bar"], ["brånch1", "brånch2"])
|
||||
context1.commits = [commit1]
|
||||
|
||||
context2 = GitContext()
|
||||
commit_message2 = GitCommitMessage(context2, "tëst\n\nfoo", "tëst\n\nfoo", "tēst", ["", "föo"])
|
||||
commit2 = GitCommit(context2, commit_message1, "shä", now, "Jöhn Smith", "jöhn.smith@test.com", None,
|
||||
["föo/bar"], ["brånch1", "brånch2"])
|
||||
context2.commits = [commit2]
|
||||
|
||||
self.assertEqual(context1, context2)
|
||||
self.assertEqual(commit_message1, commit_message2)
|
||||
self.assertEqual(commit1, commit2)
|
||||
|
||||
# Check that objects are unequal when changing a single attribute
|
||||
kwargs = {'message': commit1.message, 'sha': commit1.sha, 'date': commit1.date,
|
||||
'author_name': commit1.author_name, 'author_email': commit1.author_email, 'parents': commit1.parents,
|
||||
'changed_files': commit1.changed_files, 'branches': commit1.branches}
|
||||
|
||||
self.object_equality_test(commit1, kwargs.keys(), {"context": commit1.context})
|
||||
|
||||
# Check that the is_* attributes that are affected by the commit message affect equality
|
||||
special_messages = {'is_merge_commit': "Merge: foöbar", 'is_fixup_commit': "fixup! foöbar",
|
||||
'is_squash_commit': "squash! foöbar", 'is_revert_commit': "Revert: foöbar"}
|
||||
for key in special_messages:
|
||||
kwargs_copy = copy.deepcopy(kwargs)
|
||||
clone1 = GitCommit(context=commit1.context, **kwargs_copy)
|
||||
clone1.message = GitCommitMessage.from_full_message(context1, special_messages[key])
|
||||
self.assertTrue(getattr(clone1, key))
|
||||
|
||||
clone2 = GitCommit(context=commit1.context, **kwargs_copy)
|
||||
clone2.message = GitCommitMessage.from_full_message(context1, "foöbar")
|
||||
self.assertNotEqual(clone1, clone2)
|
||||
|
||||
@patch("gitlint.git.git_commentchar")
|
||||
def test_commit_msg_custom_commentchar(self, patched):
|
||||
patched.return_value = "ä"
|
||||
context = GitContext()
|
||||
message = GitCommitMessage.from_full_message(context, "Tïtle\n\nBödy 1\näCömment\nBody 2")
|
||||
|
||||
self.assertEqual(message.title, "Tïtle")
|
||||
self.assertEqual(message.body, ["", "Bödy 1", "Body 2"])
|
||||
self.assertEqual(message.full, "Tïtle\n\nBödy 1\nBody 2")
|
||||
self.assertEqual(message.original, "Tïtle\n\nBödy 1\näCömment\nBody 2")
|
84
gitlint-core/gitlint/tests/git/test_git_context.py
Normal file
84
gitlint-core/gitlint/tests/git/test_git_context.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from unittest.mock import patch, call
|
||||
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint.git import GitContext
|
||||
|
||||
|
||||
class GitContextTests(BaseTestCase):
|
||||
|
||||
# Expected special_args passed to 'sh'
|
||||
expected_sh_special_args = {
|
||||
'_tty_out': False,
|
||||
'_cwd': "fåke/path"
|
||||
}
|
||||
|
||||
@patch('gitlint.git.sh')
|
||||
def test_gitcontext(self, sh):
|
||||
|
||||
sh.git.side_effect = [
|
||||
"#", # git config --get core.commentchar
|
||||
"\nfoöbar\n"
|
||||
]
|
||||
|
||||
expected_calls = [
|
||||
call("config", "--get", "core.commentchar", _ok_code=[0, 1], **self.expected_sh_special_args),
|
||||
call("rev-parse", "--abbrev-ref", "HEAD", **self.expected_sh_special_args)
|
||||
]
|
||||
|
||||
context = GitContext("fåke/path")
|
||||
self.assertEqual(sh.git.mock_calls, [])
|
||||
|
||||
# gitcontext.comment_branch
|
||||
self.assertEqual(context.commentchar, "#")
|
||||
self.assertEqual(sh.git.mock_calls, expected_calls[0:1])
|
||||
|
||||
# gitcontext.current_branch
|
||||
self.assertEqual(context.current_branch, "foöbar")
|
||||
self.assertEqual(sh.git.mock_calls, expected_calls)
|
||||
|
||||
@patch('gitlint.git.sh')
|
||||
def test_gitcontext_equality(self, sh):
|
||||
|
||||
sh.git.side_effect = [
|
||||
"û\n", # context1: git config --get core.commentchar
|
||||
"û\n", # context2: git config --get core.commentchar
|
||||
"my-brånch\n", # context1: git rev-parse --abbrev-ref HEAD
|
||||
"my-brånch\n", # context2: git rev-parse --abbrev-ref HEAD
|
||||
]
|
||||
|
||||
context1 = GitContext("fåke/path")
|
||||
context1.commits = ["fōo", "bår"] # we don't need real commits to check for equality
|
||||
|
||||
context2 = GitContext("fåke/path")
|
||||
context2.commits = ["fōo", "bår"]
|
||||
self.assertEqual(context1, context2)
|
||||
|
||||
# INEQUALITY
|
||||
# Different commits
|
||||
context2.commits = ["hür", "dür"]
|
||||
self.assertNotEqual(context1, context2)
|
||||
|
||||
# Different repository_path
|
||||
context2.commits = context1.commits
|
||||
context2.repository_path = "ōther/path"
|
||||
self.assertNotEqual(context1, context2)
|
||||
|
||||
# Different comment_char
|
||||
context3 = GitContext("fåke/path")
|
||||
context3.commits = ["fōo", "bår"]
|
||||
sh.git.side_effect = ([
|
||||
"ç\n", # context3: git config --get core.commentchar
|
||||
"my-brånch\n" # context3: git rev-parse --abbrev-ref HEAD
|
||||
])
|
||||
self.assertNotEqual(context1, context3)
|
||||
|
||||
# Different current_branch
|
||||
context4 = GitContext("fåke/path")
|
||||
context4.commits = ["fōo", "bår"]
|
||||
sh.git.side_effect = ([
|
||||
"û\n", # context4: git config --get core.commentchar
|
||||
"different-brånch\n" # context4: git rev-parse --abbrev-ref HEAD
|
||||
])
|
||||
self.assertNotEqual(context1, context4)
|
0
gitlint-core/gitlint/tests/rules/__init__.py
Normal file
0
gitlint-core/gitlint/tests/rules/__init__.py
Normal file
236
gitlint-core/gitlint/tests/rules/test_body_rules.py
Normal file
236
gitlint-core/gitlint/tests/rules/test_body_rules.py
Normal file
|
@ -0,0 +1,236 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint import rules
|
||||
|
||||
|
||||
class BodyRuleTests(BaseTestCase):
|
||||
def test_max_line_length(self):
|
||||
rule = rules.BodyMaxLineLength()
|
||||
|
||||
# assert no error
|
||||
violation = rule.validate("å" * 80, None)
|
||||
self.assertIsNone(violation)
|
||||
|
||||
# assert error on line length > 80
|
||||
expected_violation = rules.RuleViolation("B1", "Line exceeds max length (81>80)", "å" * 81)
|
||||
violations = rule.validate("å" * 81, None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# set line length to 120, and check no violation on length 73
|
||||
rule = rules.BodyMaxLineLength({'line-length': 120})
|
||||
violations = rule.validate("å" * 73, None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert raise on 121
|
||||
expected_violation = rules.RuleViolation("B1", "Line exceeds max length (121>120)", "å" * 121)
|
||||
violations = rule.validate("å" * 121, None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_trailing_whitespace(self):
|
||||
rule = rules.BodyTrailingWhitespace()
|
||||
|
||||
# assert no error
|
||||
violations = rule.validate("å", None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# trailing space
|
||||
expected_violation = rules.RuleViolation("B2", "Line has trailing whitespace", "å ")
|
||||
violations = rule.validate("å ", None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# trailing tab
|
||||
expected_violation = rules.RuleViolation("B2", "Line has trailing whitespace", "å\t")
|
||||
violations = rule.validate("å\t", None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_hard_tabs(self):
|
||||
rule = rules.BodyHardTab()
|
||||
|
||||
# assert no error
|
||||
violations = rule.validate("This is ã test", None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# contains hard tab
|
||||
expected_violation = rules.RuleViolation("B3", "Line contains hard tab characters (\\t)", "This is å\ttest")
|
||||
violations = rule.validate("This is å\ttest", None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_body_first_line_empty(self):
|
||||
rule = rules.BodyFirstLineEmpty()
|
||||
|
||||
# assert no error
|
||||
commit = self.gitcommit("Tïtle\n\nThis is the secōnd body line")
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# second line not empty
|
||||
expected_violation = rules.RuleViolation("B4", "Second line is not empty", "nöt empty", 2)
|
||||
|
||||
commit = self.gitcommit("Tïtle\nnöt empty\nThis is the secönd body line")
|
||||
violations = rule.validate(commit)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_body_min_length(self):
|
||||
rule = rules.BodyMinLength()
|
||||
|
||||
# assert no error - body is long enough
|
||||
commit = self.gitcommit("Title\n\nThis is the second body line\n")
|
||||
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert no error - no body
|
||||
commit = self.gitcommit("Tïtle\n")
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# body is too short
|
||||
expected_violation = rules.RuleViolation("B5", "Body message is too short (8<20)", "töoshort", 3)
|
||||
|
||||
commit = self.gitcommit("Tïtle\n\ntöoshort\n")
|
||||
violations = rule.validate(commit)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# assert error - short across multiple lines
|
||||
expected_violation = rules.RuleViolation("B5", "Body message is too short (11<20)", "secöndthïrd", 3)
|
||||
commit = self.gitcommit("Tïtle\n\nsecönd\nthïrd\n")
|
||||
violations = rule.validate(commit)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# set line length to 120, and check violation on length 21
|
||||
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{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{0}\n".format("å" * 8)) # pylint: disable=consider-using-f-string
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
def test_body_missing(self):
|
||||
rule = rules.BodyMissing()
|
||||
|
||||
# assert no error - body is present
|
||||
commit = self.gitcommit("Tïtle\n\nThis ïs the first body line\n")
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# body is too short
|
||||
expected_violation = rules.RuleViolation("B6", "Body message is missing", None, 3)
|
||||
|
||||
commit = self.gitcommit("Tïtle\n")
|
||||
violations = rule.validate(commit)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_body_missing_multiple_empty_new_lines(self):
|
||||
rule = rules.BodyMissing()
|
||||
|
||||
# body is too short
|
||||
expected_violation = rules.RuleViolation("B6", "Body message is missing", None, 3)
|
||||
|
||||
commit = self.gitcommit("Tïtle\n\n\n\n")
|
||||
violations = rule.validate(commit)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_body_missing_merge_commit(self):
|
||||
rule = rules.BodyMissing()
|
||||
|
||||
# assert no error - merge commit
|
||||
commit = self.gitcommit("Merge: Tïtle\n")
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert error for merge commits if ignore-merge-commits is disabled
|
||||
rule = rules.BodyMissing({'ignore-merge-commits': False})
|
||||
violations = rule.validate(commit)
|
||||
expected_violation = rules.RuleViolation("B6", "Body message is missing", None, 3)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_body_changed_file_mention(self):
|
||||
rule = rules.BodyChangedFileMention()
|
||||
|
||||
# assert no error when no files have changed and no files need to be mentioned
|
||||
commit = self.gitcommit("This is a test\n\nHere is a mention of föo/test.py")
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert no error when no files have changed but certain files need to be mentioned on change
|
||||
rule = rules.BodyChangedFileMention({'files': "bar.txt,föo/test.py"})
|
||||
commit = self.gitcommit("This is a test\n\nHere is a mention of föo/test.py")
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert no error if a file has changed and is mentioned
|
||||
commit = self.gitcommit("This is a test\n\nHere is a mention of föo/test.py", ["föo/test.py"])
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert no error if multiple files have changed and are mentioned
|
||||
commit_msg = "This is a test\n\nHere is a mention of föo/test.py\nAnd here is a mention of bar.txt"
|
||||
commit = self.gitcommit(commit_msg, ["föo/test.py", "bar.txt"])
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert error if file has changed and is not mentioned
|
||||
commit_msg = "This is a test\n\nHere is å mention of\nAnd here is a mention of bar.txt"
|
||||
commit = self.gitcommit(commit_msg, ["föo/test.py", "bar.txt"])
|
||||
violations = rule.validate(commit)
|
||||
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 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)
|
||||
expected_violation_2 = rules.RuleViolation("B7", "Body does not mention changed file 'bar.txt'", None, 4)
|
||||
self.assertEqual([expected_violation_2, expected_violation], violations)
|
||||
|
||||
def test_body_match_regex(self):
|
||||
# We intentionally add 2 newlines at the end of our commit message as that's how git will pass the
|
||||
# message. This way we also test that the rule strips off the last line.
|
||||
commit = self.gitcommit("US1234: åbc\nIgnored\nBödy\nFöo\nMy-Commit-Tag: föo\n\n")
|
||||
|
||||
# assert no violation on default regex (=everything allowed)
|
||||
rule = rules.BodyRegexMatches()
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert no violation on matching regex
|
||||
# (also note that first body line - in between title and rest of body - is ignored)
|
||||
rule = rules.BodyRegexMatches({'regex': "^Bödy(.*)"})
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert we can do end matching (and last empty line is ignored)
|
||||
# (also note that first body line - in between title and rest of body - is ignored)
|
||||
rule = rules.BodyRegexMatches({'regex': "My-Commit-Tag: föo$"})
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# common use-case: matching that a given line is present
|
||||
rule = rules.BodyRegexMatches({'regex': "(.*)Föo(.*)"})
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert violation on non-matching body
|
||||
rule = rules.BodyRegexMatches({'regex': "^Tëst(.*)Foo"})
|
||||
violations = rule.validate(commit)
|
||||
expected_violation = rules.RuleViolation("B8", "Body does not match regex (^Tëst(.*)Foo)", None, 6)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# assert no violation on None regex
|
||||
rule = rules.BodyRegexMatches({'regex': None})
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# Assert no issues when there's no body or a weird body variation
|
||||
bodies = ["åbc", "åbc\n", "åbc\nföo\n", "åbc\n\n", "åbc\nföo\nblå", "åbc\nföo\nblå\n"]
|
||||
for body in bodies:
|
||||
commit = self.gitcommit(body)
|
||||
rule = rules.BodyRegexMatches({'regex': ".*"})
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
140
gitlint-core/gitlint/tests/rules/test_configuration_rules.py
Normal file
140
gitlint-core/gitlint/tests/rules/test_configuration_rules.py
Normal file
|
@ -0,0 +1,140 @@
|
|||
# -*- 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("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([]) # nothing logged -> nothing ignored
|
||||
|
||||
# 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 = "DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \
|
||||
"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 = "DEBUG: gitlint.rules Ignoring commit because of rule 'I1': " + \
|
||||
"Commit title 'Releäse' matches the regex '^Releäse(.*)', ignoring rules: T1,B2"
|
||||
|
||||
def test_ignore_by_body(self):
|
||||
commit = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line")
|
||||
|
||||
# No regex specified -> Config shouldn't be changed
|
||||
rule = rules.IgnoreByBody()
|
||||
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.IgnoreByBody({"regex": "(.*)relëase(.*)"})
|
||||
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 'I2': " + \
|
||||
"Commit message line ' a relëase body' matches the regex '(.*)relëase(.*)'," + \
|
||||
" ignoring rules: all"
|
||||
self.assert_log_contains(expected_log_message)
|
||||
|
||||
# Matching regex with specific ignore
|
||||
rule = rules.IgnoreByBody({"regex": "(.*)relëase(.*)",
|
||||
"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 'I2': " + \
|
||||
"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")
|
||||
|
||||
# no regex specified, nothing should have happened:
|
||||
# commit and config should remain identical, log should be empty
|
||||
rule = rules.IgnoreBodyLines()
|
||||
config = LintConfig()
|
||||
rule.apply(config, commit1)
|
||||
self.assertEqual(commit1, commit2)
|
||||
self.assertEqual(config, LintConfig())
|
||||
self.assert_logged([])
|
||||
|
||||
# Matching regex
|
||||
rule = rules.IgnoreBodyLines({"regex": "(.*)relëase(.*)"})
|
||||
config = LintConfig()
|
||||
rule.apply(config, commit1)
|
||||
# Our modified commit should be identical to a commit that doesn't contain the specific line
|
||||
expected_commit = self.gitcommit("Tïtle\n\nThis is\n line")
|
||||
# The original message isn't touched by this rule, this way we always have a way to reference back to it,
|
||||
# so assert it's not modified by setting it to the same as commit1
|
||||
expected_commit.message.original = commit1.message.original
|
||||
self.assertEqual(commit1, expected_commit)
|
||||
self.assertEqual(config, LintConfig()) # config shouldn't have been modified
|
||||
self.assert_log_contains("DEBUG: gitlint.rules Ignoring line ' a relëase body' because it " +
|
||||
"matches '(.*)relëase(.*)'")
|
||||
|
||||
# Non-Matching regex: no changes expected
|
||||
commit1 = self.gitcommit("Tïtle\n\nThis is\n a relëase body\n line")
|
||||
rule = rules.IgnoreBodyLines({"regex": "(.*)föobar(.*)"})
|
||||
config = LintConfig()
|
||||
rule.apply(config, commit1)
|
||||
self.assertEqual(commit1, commit2)
|
||||
self.assertEqual(config, LintConfig()) # config shouldn't have been modified
|
59
gitlint-core/gitlint/tests/rules/test_meta_rules.py
Normal file
59
gitlint-core/gitlint/tests/rules/test_meta_rules.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint.rules import AuthorValidEmail, RuleViolation
|
||||
|
||||
|
||||
class MetaRuleTests(BaseTestCase):
|
||||
def test_author_valid_email_rule(self):
|
||||
rule = AuthorValidEmail()
|
||||
|
||||
# valid email addresses
|
||||
valid_email_addresses = ["föo@bar.com", "Jöhn.Doe@bar.com", "jöhn+doe@bar.com", "jöhn/doe@bar.com",
|
||||
"jöhn.doe@subdomain.bar.com"]
|
||||
for email in valid_email_addresses:
|
||||
commit = self.gitcommit("", author_email=email)
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# No email address (=allowed for now, as gitlint also lints messages passed via stdin that don't have an
|
||||
# email address)
|
||||
commit = self.gitcommit("")
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# Invalid email addresses: no TLD, no domain, no @, space anywhere (=valid but not allowed by gitlint)
|
||||
invalid_email_addresses = ["föo@bar", "JöhnDoe", "Jöhn Doe", "Jöhn Doe@foo.com", " JöhnDoe@foo.com",
|
||||
"JöhnDoe@ foo.com", "JöhnDoe@foo. com", "JöhnDoe@foo. com", "@bår.com",
|
||||
"föo@.com"]
|
||||
for email in invalid_email_addresses:
|
||||
commit = self.gitcommit("", author_email=email)
|
||||
violations = rule.validate(commit)
|
||||
self.assertListEqual(violations,
|
||||
[RuleViolation("M1", "Author email for commit is invalid", email)])
|
||||
|
||||
def test_author_valid_email_rule_custom_regex(self):
|
||||
# regex=None -> the rule isn't applied
|
||||
rule = AuthorValidEmail()
|
||||
rule.options['regex'].set(None)
|
||||
emailadresses = ["föo", None, "hür dür"]
|
||||
for email in emailadresses:
|
||||
commit = self.gitcommit("", author_email=email)
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# Custom domain
|
||||
rule = AuthorValidEmail({'regex': "[^@]+@bår.com"})
|
||||
valid_email_addresses = [
|
||||
"föo@bår.com", "Jöhn.Doe@bår.com", "jöhn+doe@bår.com", "jöhn/doe@bår.com"]
|
||||
for email in valid_email_addresses:
|
||||
commit = self.gitcommit("", author_email=email)
|
||||
violations = rule.validate(commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# Invalid email addresses
|
||||
invalid_email_addresses = ["föo@hur.com"]
|
||||
for email in invalid_email_addresses:
|
||||
commit = self.gitcommit("", author_email=email)
|
||||
violations = rule.validate(commit)
|
||||
self.assertListEqual(violations,
|
||||
[RuleViolation("M1", "Author email for commit is invalid", email)])
|
23
gitlint-core/gitlint/tests/rules/test_rules.py
Normal file
23
gitlint-core/gitlint/tests/rules/test_rules.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint.rules import Rule, RuleViolation
|
||||
|
||||
|
||||
class RuleTests(BaseTestCase):
|
||||
|
||||
def test_rule_equality(self):
|
||||
self.assertEqual(Rule(), Rule())
|
||||
# Ensure rules are not equal if they differ on their attributes
|
||||
for attr in ["id", "name", "target", "options"]:
|
||||
rule = Rule()
|
||||
setattr(rule, attr, "åbc")
|
||||
self.assertNotEqual(Rule(), rule)
|
||||
|
||||
def test_rule_log(self):
|
||||
rule = Rule()
|
||||
rule.log.debug("Tēst message")
|
||||
self.assert_log_contains("DEBUG: gitlint.rules Tēst message")
|
||||
|
||||
def test_rule_violation_equality(self):
|
||||
violation1 = RuleViolation("ïd1", "My messåge", "My cöntent", 1)
|
||||
self.object_equality_test(violation1, ["rule_id", "message", "content", "line_nr"])
|
186
gitlint-core/gitlint/tests/rules/test_title_rules.py
Normal file
186
gitlint-core/gitlint/tests/rules/test_title_rules.py
Normal file
|
@ -0,0 +1,186 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint.rules import TitleMaxLength, TitleTrailingWhitespace, TitleHardTab, TitleMustNotContainWord, \
|
||||
TitleTrailingPunctuation, TitleLeadingWhitespace, TitleRegexMatches, RuleViolation, TitleMinLength
|
||||
|
||||
|
||||
class TitleRuleTests(BaseTestCase):
|
||||
def test_max_line_length(self):
|
||||
rule = TitleMaxLength()
|
||||
|
||||
# assert no error
|
||||
violation = rule.validate("å" * 72, None)
|
||||
self.assertIsNone(violation)
|
||||
|
||||
# assert error on line length > 72
|
||||
expected_violation = RuleViolation("T1", "Title exceeds max length (73>72)", "å" * 73)
|
||||
violations = rule.validate("å" * 73, None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# set line length to 120, and check no violation on length 73
|
||||
rule = TitleMaxLength({'line-length': 120})
|
||||
violations = rule.validate("å" * 73, None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert raise on 121
|
||||
expected_violation = RuleViolation("T1", "Title exceeds max length (121>120)", "å" * 121)
|
||||
violations = rule.validate("å" * 121, None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_trailing_whitespace(self):
|
||||
rule = TitleTrailingWhitespace()
|
||||
|
||||
# assert no error
|
||||
violations = rule.validate("å", None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# trailing space
|
||||
expected_violation = RuleViolation("T2", "Title has trailing whitespace", "å ")
|
||||
violations = rule.validate("å ", None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# trailing tab
|
||||
expected_violation = RuleViolation("T2", "Title has trailing whitespace", "å\t")
|
||||
violations = rule.validate("å\t", None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_hard_tabs(self):
|
||||
rule = TitleHardTab()
|
||||
|
||||
# assert no error
|
||||
violations = rule.validate("This is å test", None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# contains hard tab
|
||||
expected_violation = RuleViolation("T4", "Title contains hard tab characters (\\t)", "This is å\ttest")
|
||||
violations = rule.validate("This is å\ttest", None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_trailing_punctuation(self):
|
||||
rule = TitleTrailingPunctuation()
|
||||
|
||||
# assert no error
|
||||
violations = rule.validate("This is å test", None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert errors for different punctuations
|
||||
punctuation = "?:!.,;"
|
||||
for char in punctuation:
|
||||
line = "This is å test" + char # note that make sure to include some unicode!
|
||||
gitcontext = self.gitcontext(line)
|
||||
expected_violation = RuleViolation("T3", f"Title has trailing punctuation ({char})", line)
|
||||
violations = rule.validate(line, gitcontext)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_title_must_not_contain_word(self):
|
||||
rule = TitleMustNotContainWord()
|
||||
|
||||
# no violations
|
||||
violations = rule.validate("This is å test", None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# no violation if WIP occurs inside a word
|
||||
violations = rule.validate("This is å wiping test", None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# match literally
|
||||
violations = rule.validate("WIP This is å test", None)
|
||||
expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
|
||||
"WIP This is å test")
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# match case insensitive
|
||||
violations = rule.validate("wip This is å test", None)
|
||||
expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
|
||||
"wip This is å test")
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# match if there is a colon after the word
|
||||
violations = rule.validate("WIP:This is å test", None)
|
||||
expected_violation = RuleViolation("T5", "Title contains the word 'WIP' (case-insensitive)",
|
||||
"WIP:This is å test")
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# match multiple words
|
||||
rule = TitleMustNotContainWord({'words': "wip,test,å"})
|
||||
violations = rule.validate("WIP:This is å test", None)
|
||||
expected_violation = RuleViolation("T5", "Title contains the word 'wip' (case-insensitive)",
|
||||
"WIP:This is å test")
|
||||
expected_violation2 = RuleViolation("T5", "Title contains the word 'test' (case-insensitive)",
|
||||
"WIP:This is å test")
|
||||
expected_violation3 = RuleViolation("T5", "Title contains the word 'å' (case-insensitive)",
|
||||
"WIP:This is å test")
|
||||
self.assertListEqual(violations, [expected_violation, expected_violation2, expected_violation3])
|
||||
|
||||
def test_leading_whitespace(self):
|
||||
rule = TitleLeadingWhitespace()
|
||||
|
||||
# assert no error
|
||||
violations = rule.validate("a", None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# leading space
|
||||
expected_violation = RuleViolation("T6", "Title has leading whitespace", " a")
|
||||
violations = rule.validate(" a", None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# leading tab
|
||||
expected_violation = RuleViolation("T6", "Title has leading whitespace", "\ta")
|
||||
violations = rule.validate("\ta", None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# unicode test
|
||||
expected_violation = RuleViolation("T6", "Title has leading whitespace", " ☺")
|
||||
violations = rule.validate(" ☺", None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_regex_matches(self):
|
||||
commit = self.gitcommit("US1234: åbc\n")
|
||||
|
||||
# assert no violation on default regex (=everything allowed)
|
||||
rule = TitleRegexMatches()
|
||||
violations = rule.validate(commit.message.title, commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert no violation on matching regex
|
||||
rule = TitleRegexMatches({'regex': "^US[0-9]*: å"})
|
||||
violations = rule.validate(commit.message.title, commit)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert violation when no matching regex
|
||||
rule = TitleRegexMatches({'regex': "^UÅ[0-9]*"})
|
||||
violations = rule.validate(commit.message.title, commit)
|
||||
expected_violation = RuleViolation("T7", "Title does not match regex (^UÅ[0-9]*)", "US1234: åbc")
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
def test_min_line_length(self):
|
||||
rule = TitleMinLength()
|
||||
|
||||
# assert no error
|
||||
violation = rule.validate("å" * 72, None)
|
||||
self.assertIsNone(violation)
|
||||
|
||||
# assert error on line length < 5
|
||||
expected_violation = RuleViolation("T8", "Title is too short (4<5)", "å" * 4, 1)
|
||||
violations = rule.validate("å" * 4, None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# set line length to 3, and check no violation on length 4
|
||||
rule = TitleMinLength({'min-length': 3})
|
||||
violations = rule.validate("å" * 4, None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert no violations on length 3 (this asserts we've implemented a *strict* less than)
|
||||
rule = TitleMinLength({'min-length': 3})
|
||||
violations = rule.validate("å" * 3, None)
|
||||
self.assertIsNone(violations)
|
||||
|
||||
# assert raise on 2
|
||||
expected_violation = RuleViolation("T8", "Title is too short (2<3)", "å" * 2, 1)
|
||||
violations = rule.validate("å" * 2, None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
||||
|
||||
# assert raise on empty title
|
||||
expected_violation = RuleViolation("T8", "Title is too short (0<3)", "", 1)
|
||||
violations = rule.validate("", None)
|
||||
self.assertListEqual(violations, [expected_violation])
|
256
gitlint-core/gitlint/tests/rules/test_user_rules.py
Normal file
256
gitlint-core/gitlint/tests/rules/test_user_rules.py
Normal file
|
@ -0,0 +1,256 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from gitlint.tests.base import BaseTestCase
|
||||
from gitlint.rule_finder import find_rule_classes, assert_valid_rule_class
|
||||
from gitlint.rules import UserRuleError
|
||||
|
||||
from gitlint import options, rules
|
||||
|
||||
|
||||
class UserRuleTests(BaseTestCase):
|
||||
def test_find_rule_classes(self):
|
||||
# Let's find some user classes!
|
||||
user_rule_path = self.get_sample_path("user_rules")
|
||||
classes = find_rule_classes(user_rule_path)
|
||||
|
||||
# Compare string representations because we can't import MyUserCommitRule here since samples/user_rules is not
|
||||
# a proper python package
|
||||
# Note that the following check effectively asserts that:
|
||||
# - There is only 1 rule recognized and it is MyUserCommitRule
|
||||
# - Other non-python files in the directory are ignored
|
||||
# - Other members of the my_commit_rules module are ignored
|
||||
# (such as func_should_be_ignored, global_variable_should_be_ignored)
|
||||
# - Rules are loaded non-recursively (user_rules/import_exception directory is ignored)
|
||||
self.assertEqual("[<class 'my_commit_rules.MyUserCommitRule'>]", str(classes))
|
||||
|
||||
# Assert that we added the new user_rules directory to the system path and modules
|
||||
self.assertIn(user_rule_path, sys.path)
|
||||
self.assertIn("my_commit_rules", sys.modules)
|
||||
|
||||
# Do some basic asserts on our user rule
|
||||
self.assertEqual(classes[0].id, "UC1")
|
||||
self.assertEqual(classes[0].name, "my-üser-commit-rule")
|
||||
expected_option = options.IntOption('violation-count', 1, "Number of violåtions to return")
|
||||
self.assertListEqual(classes[0].options_spec, [expected_option])
|
||||
self.assertTrue(hasattr(classes[0], "validate"))
|
||||
|
||||
# Test that we can instantiate the class and can execute run the validate method and that it returns the
|
||||
# expected result
|
||||
rule_class = classes[0]()
|
||||
violations = rule_class.validate("false-commit-object (ignored)")
|
||||
self.assertListEqual(violations, [rules.RuleViolation("UC1", "Commit violåtion 1", "Contënt 1", 1)])
|
||||
|
||||
# Have it return more violations
|
||||
rule_class.options['violation-count'].value = 2
|
||||
violations = rule_class.validate("false-commit-object (ignored)")
|
||||
self.assertListEqual(violations, [rules.RuleViolation("UC1", "Commit violåtion 1", "Contënt 1", 1),
|
||||
rules.RuleViolation("UC1", "Commit violåtion 2", "Contënt 2", 2)])
|
||||
|
||||
def test_extra_path_specified_by_file(self):
|
||||
# Test that find_rule_classes can handle an extra path given as a file name instead of a directory
|
||||
user_rule_path = self.get_sample_path("user_rules")
|
||||
user_rule_module = os.path.join(user_rule_path, "my_commit_rules.py")
|
||||
classes = find_rule_classes(user_rule_module)
|
||||
|
||||
rule_class = classes[0]()
|
||||
violations = rule_class.validate("false-commit-object (ignored)")
|
||||
self.assertListEqual(violations, [rules.RuleViolation("UC1", "Commit violåtion 1", "Contënt 1", 1)])
|
||||
|
||||
def test_rules_from_init_file(self):
|
||||
# Test that we can import rules that are defined in __init__.py files
|
||||
# This also tests that we can import rules from python packages. This use to cause issues with pypy
|
||||
# So this is also a regression test for that.
|
||||
user_rule_path = self.get_sample_path(os.path.join("user_rules", "parent_package"))
|
||||
classes = find_rule_classes(user_rule_path)
|
||||
|
||||
# convert classes to strings and sort them so we can compare them
|
||||
class_strings = sorted([str(clazz) for clazz in classes])
|
||||
expected = ["<class 'my_commit_rules.MyUserCommitRule'>", "<class 'parent_package.InitFileRule'>"]
|
||||
self.assertListEqual(class_strings, expected)
|
||||
|
||||
def test_empty_user_classes(self):
|
||||
# Test that we don't find rules if we scan a different directory
|
||||
user_rule_path = self.get_sample_path("config")
|
||||
classes = find_rule_classes(user_rule_path)
|
||||
self.assertListEqual(classes, [])
|
||||
|
||||
# Importantly, ensure that the directory is not added to the syspath as this happens only when we actually
|
||||
# find modules
|
||||
self.assertNotIn(user_rule_path, sys.path)
|
||||
|
||||
def test_failed_module_import(self):
|
||||
# test importing a bogus module
|
||||
user_rule_path = self.get_sample_path("user_rules/import_exception")
|
||||
# We don't check the entire error message because that is different based on the python version and underlying
|
||||
# operating system
|
||||
expected_msg = "Error while importing extra-path module 'invalid_python'"
|
||||
with self.assertRaisesRegex(UserRuleError, expected_msg):
|
||||
find_rule_classes(user_rule_path)
|
||||
|
||||
def test_find_rule_classes_nonexisting_path(self):
|
||||
with self.assertRaisesMessage(UserRuleError, "Invalid extra-path: föo/bar"):
|
||||
find_rule_classes("föo/bar")
|
||||
|
||||
def test_assert_valid_rule_class(self):
|
||||
class MyLineRuleClass(rules.LineRule):
|
||||
id = 'UC1'
|
||||
name = 'my-lïne-rule'
|
||||
target = rules.CommitMessageTitle
|
||||
|
||||
def validate(self):
|
||||
pass
|
||||
|
||||
class MyCommitRuleClass(rules.CommitRule):
|
||||
id = 'UC2'
|
||||
name = 'my-cömmit-rule'
|
||||
|
||||
def validate(self):
|
||||
pass
|
||||
|
||||
class MyConfigurationRuleClass(rules.ConfigurationRule):
|
||||
id = 'UC3'
|
||||
name = 'my-cönfiguration-rule'
|
||||
|
||||
def apply(self):
|
||||
pass
|
||||
|
||||
# Just assert that no error is raised
|
||||
self.assertIsNone(assert_valid_rule_class(MyLineRuleClass))
|
||||
self.assertIsNone(assert_valid_rule_class(MyCommitRuleClass))
|
||||
self.assertIsNone(assert_valid_rule_class(MyConfigurationRuleClass))
|
||||
|
||||
def test_assert_valid_rule_class_negative(self):
|
||||
# general test to make sure that incorrect rules will raise an exception
|
||||
user_rule_path = self.get_sample_path("user_rules/incorrect_linerule")
|
||||
with self.assertRaisesMessage(UserRuleError,
|
||||
"User-defined rule class 'MyUserLineRule' must have a 'validate' method"):
|
||||
find_rule_classes(user_rule_path)
|
||||
|
||||
def test_assert_valid_rule_class_negative_parent(self):
|
||||
# rule class must extend from LineRule or CommitRule
|
||||
class MyRuleClass:
|
||||
pass
|
||||
|
||||
expected_msg = "User-defined rule class 'MyRuleClass' must extend from gitlint.rules.LineRule, " + \
|
||||
"gitlint.rules.CommitRule or gitlint.rules.ConfigurationRule"
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
def test_assert_valid_rule_class_negative_id(self):
|
||||
|
||||
for parent_class in [rules.LineRule, rules.CommitRule]:
|
||||
|
||||
class MyRuleClass(parent_class):
|
||||
pass
|
||||
|
||||
# Rule class must have an id
|
||||
expected_msg = "User-defined rule class 'MyRuleClass' must have an 'id' attribute"
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
# Rule ids must be non-empty
|
||||
MyRuleClass.id = ""
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
# Rule ids must not start with one of the reserved id letters
|
||||
for letter in ["T", "R", "B", "M", "I"]:
|
||||
MyRuleClass.id = letter + "1"
|
||||
expected_msg = f"The id '{letter}' of 'MyRuleClass' is invalid. " + \
|
||||
"Gitlint reserves ids starting with R,T,B,M,I"
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
def test_assert_valid_rule_class_negative_name(self):
|
||||
for parent_class in [rules.LineRule, rules.CommitRule]:
|
||||
|
||||
class MyRuleClass(parent_class):
|
||||
id = "UC1"
|
||||
|
||||
# Rule class must have a name
|
||||
expected_msg = "User-defined rule class 'MyRuleClass' must have a 'name' attribute"
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
# Rule names must be non-empty
|
||||
MyRuleClass.name = ""
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
def test_assert_valid_rule_class_negative_option_spec(self):
|
||||
|
||||
for parent_class in [rules.LineRule, rules.CommitRule]:
|
||||
|
||||
class MyRuleClass(parent_class):
|
||||
id = "UC1"
|
||||
name = "my-rüle-class"
|
||||
|
||||
# if set, option_spec must be a list of gitlint options
|
||||
MyRuleClass.options_spec = "föo"
|
||||
expected_msg = "The options_spec attribute of user-defined rule class 'MyRuleClass' must be a list " + \
|
||||
"of gitlint.options.RuleOption"
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
# option_spec is a list, but not of gitlint options
|
||||
MyRuleClass.options_spec = ["föo", 123] # pylint: disable=bad-option-value,redefined-variable-type
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
def test_assert_valid_rule_class_negative_validate(self):
|
||||
|
||||
baseclasses = [rules.LineRule, rules.CommitRule]
|
||||
for clazz in baseclasses:
|
||||
class MyRuleClass(clazz):
|
||||
id = "UC1"
|
||||
name = "my-rüle-class"
|
||||
|
||||
with self.assertRaisesMessage(UserRuleError,
|
||||
"User-defined rule class 'MyRuleClass' must have a 'validate' method"):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
# validate attribute - not a method
|
||||
MyRuleClass.validate = "föo"
|
||||
with self.assertRaisesMessage(UserRuleError,
|
||||
"User-defined rule class 'MyRuleClass' must have a 'validate' method"):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
def test_assert_valid_rule_class_negative_apply(self):
|
||||
class MyRuleClass(rules.ConfigurationRule):
|
||||
id = "UCR1"
|
||||
name = "my-rüle-class"
|
||||
|
||||
expected_msg = "User-defined Configuration rule class 'MyRuleClass' must have an 'apply' method"
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
# validate attribute - not a method
|
||||
MyRuleClass.validate = "föo"
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
def test_assert_valid_rule_class_negative_target(self):
|
||||
class MyRuleClass(rules.LineRule):
|
||||
id = "UC1"
|
||||
name = "my-rüle-class"
|
||||
|
||||
def validate(self):
|
||||
pass
|
||||
|
||||
# no target
|
||||
expected_msg = "The target attribute of the user-defined LineRule class 'MyRuleClass' must be either " + \
|
||||
"gitlint.rules.CommitMessageTitle or gitlint.rules.CommitMessageBody"
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
# invalid target
|
||||
MyRuleClass.target = "föo"
|
||||
with self.assertRaisesMessage(UserRuleError, expected_msg):
|
||||
assert_valid_rule_class(MyRuleClass)
|
||||
|
||||
# valid target, no exception should be raised
|
||||
MyRuleClass.target = rules.CommitMessageTitle # pylint: disable=bad-option-value,redefined-variable-type
|
||||
self.assertIsNone(assert_valid_rule_class(MyRuleClass))
|
1
gitlint-core/gitlint/tests/samples/commit_message/fixup
Normal file
1
gitlint-core/gitlint/tests/samples/commit_message/fixup
Normal file
|
@ -0,0 +1 @@
|
|||
fixup! WIP: This is a fixup cömmit with violations.
|
3
gitlint-core/gitlint/tests/samples/commit_message/merge
Normal file
3
gitlint-core/gitlint/tests/samples/commit_message/merge
Normal file
|
@ -0,0 +1,3 @@
|
|||
Merge: "This is a merge commit with a long title that most definitely exceeds the normål limit of 72 chars"
|
||||
This line should be ëmpty
|
||||
This is the first line is meant to test å line that exceeds the maximum line length of 80 characters.
|
|
@ -0,0 +1,6 @@
|
|||
Normal Commit Tïtle
|
||||
|
||||
Nörmal body that contains a few lines of text describing the changes in the
|
||||
commit without violating any of gitlint's rules.
|
||||
|
||||
Sïgned-Off-By: foo@bar.com
|
3
gitlint-core/gitlint/tests/samples/commit_message/revert
Normal file
3
gitlint-core/gitlint/tests/samples/commit_message/revert
Normal file
|
@ -0,0 +1,3 @@
|
|||
Revert "WIP: this is a tïtle"
|
||||
|
||||
This reverts commit a8ad67e04164a537198dea94a4fde81c5592ae9c.
|
14
gitlint-core/gitlint/tests/samples/commit_message/sample1
Normal file
14
gitlint-core/gitlint/tests/samples/commit_message/sample1
Normal file
|
@ -0,0 +1,14 @@
|
|||
Commit title contåining 'WIP', as well as trailing punctuation.
|
||||
This line should be empty
|
||||
This is the first line of the commit message body and it is meant to test a line that exceeds the maximum line length of 80 characters.
|
||||
This line has a tråiling space.
|
||||
This line has a trailing tab.
|
||||
# This is a cömmented line
|
||||
# ------------------------ >8 ------------------------
|
||||
# Anything after this line should be cleaned up
|
||||
# this line appears on `git commit -v` command
|
||||
diff --git a/gitlint/tests/samples/commit_message/sample1 b/gitlint/tests/samples/commit_message/sample1
|
||||
index 82dbe7f..ae71a14 100644
|
||||
--- a/gitlint/tests/samples/commit_message/sample1
|
||||
+++ b/gitlint/tests/samples/commit_message/sample1
|
||||
@@ -1 +1 @@
|
|
@ -0,0 +1 @@
|
|||
Just a title contåining WIP
|
|
@ -0,0 +1,6 @@
|
|||
Commit title containing 'WIP', leading and tråiling whitespace and longer than 72 characters.
|
||||
This line should be empty
|
||||
This is the first line is meånt to test a line that exceeds the maximum line length of 80 characters.
|
||||
This line has a trailing space.
|
||||
This line has a tråiling tab.
|
||||
# This is a commented line
|
|
@ -0,0 +1,7 @@
|
|||
Commit title containing 'WIP', leading and tråiling whitespace and longer than 72 characters.
|
||||
This line should be empty
|
||||
This is the first line is meånt to test a line that exceeds the maximum line length of 80 characters.
|
||||
This line has a tråiling space.
|
||||
This line has a trailing tab.
|
||||
# This is a commented line
|
||||
gitlint-ignore: all
|
|
@ -0,0 +1,7 @@
|
|||
Commit title containing 'WIP', leading and tråiling whitespace and longer than 72 characters.
|
||||
This line should be ëmpty
|
||||
This is the first line is meånt to test a line that exceeds the maximum line length of 80 characters.
|
||||
This line has a tråiling space.
|
||||
This line has a trailing tab.
|
||||
# This is a commented line
|
||||
gitlint-ignore: T3, T6, body-max-line-length
|
3
gitlint-core/gitlint/tests/samples/commit_message/squash
Normal file
3
gitlint-core/gitlint/tests/samples/commit_message/squash
Normal file
|
@ -0,0 +1,3 @@
|
|||
squash! WIP: This is a squash cömmit with violations.
|
||||
|
||||
Body töo short
|
15
gitlint-core/gitlint/tests/samples/config/gitlintconfig
Normal file
15
gitlint-core/gitlint/tests/samples/config/gitlintconfig
Normal file
|
@ -0,0 +1,15 @@
|
|||
[general]
|
||||
ignore=title-trailing-whitespace,B2
|
||||
verbosity = 1
|
||||
ignore-merge-commits = false
|
||||
debug = false
|
||||
|
||||
[title-max-length]
|
||||
line-length=20
|
||||
|
||||
[B1]
|
||||
# B1 = body-max-line-length
|
||||
line-length=30
|
||||
|
||||
[title-must-not-contain-word]
|
||||
words=WIP,bögus
|
|
@ -0,0 +1,11 @@
|
|||
[general]
|
||||
ignore=title-trailing-whitespace,B2
|
||||
verbosity = 1
|
||||
|
||||
[title-max-length]
|
||||
line-length=föo
|
||||
|
||||
|
||||
[B1]
|
||||
# B1 = body-max-line-length
|
||||
line-length=30
|
8
gitlint-core/gitlint/tests/samples/config/named-rules
Normal file
8
gitlint-core/gitlint/tests/samples/config/named-rules
Normal file
|
@ -0,0 +1,8 @@
|
|||
[title-must-not-contain-word]
|
||||
words=WIP,bögus
|
||||
|
||||
[title-must-not-contain-word:extra-wörds]
|
||||
words=hür,tëst
|
||||
|
||||
[T5:even-more-wörds]
|
||||
words=hür,tïtle
|
1
gitlint-core/gitlint/tests/samples/config/no-sections
Normal file
1
gitlint-core/gitlint/tests/samples/config/no-sections
Normal file
|
@ -0,0 +1 @@
|
|||
ignore=title-max-length, T3
|
|
@ -0,0 +1,13 @@
|
|||
[general]
|
||||
ignore=title-trailing-whitespace,B2
|
||||
verbosity = 1
|
||||
ignore-merge-commits = false
|
||||
foo = bar
|
||||
|
||||
[title-max-length]
|
||||
line-length=20
|
||||
|
||||
|
||||
[B1]
|
||||
# B1 = body-max-line-length
|
||||
line-length=30
|
11
gitlint-core/gitlint/tests/samples/config/nonexisting-option
Normal file
11
gitlint-core/gitlint/tests/samples/config/nonexisting-option
Normal file
|
@ -0,0 +1,11 @@
|
|||
[general]
|
||||
ignore=title-trailing-whitespace,B2
|
||||
verbosity = 1
|
||||
|
||||
[title-max-length]
|
||||
föobar=foo
|
||||
|
||||
|
||||
[B1]
|
||||
# B1 = body-max-line-length
|
||||
line-length=30
|
11
gitlint-core/gitlint/tests/samples/config/nonexisting-rule
Normal file
11
gitlint-core/gitlint/tests/samples/config/nonexisting-rule
Normal file
|
@ -0,0 +1,11 @@
|
|||
[general]
|
||||
ignore=title-trailing-whitespace,B2
|
||||
verbosity = 1
|
||||
|
||||
[föobar]
|
||||
line-length=20
|
||||
|
||||
|
||||
[B1]
|
||||
# B1 = body-max-line-length
|
||||
line-length=30
|
|
@ -0,0 +1,2 @@
|
|||
This is just a bogus file.
|
||||
This file being here is part of the test: gitlint should ignore it.
|
|
@ -0,0 +1,3 @@
|
|||
# flake8: noqa
|
||||
# This is invalid python code which will cause an import exception
|
||||
class MyObject:
|
|
@ -0,0 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from gitlint.rules import LineRule
|
||||
|
||||
|
||||
class MyUserLineRule(LineRule):
|
||||
id = "UC2"
|
||||
name = "my-lïne-rule"
|
||||
|
||||
# missing validate method, missing target attribute
|
|
@ -0,0 +1,16 @@
|
|||
# This rule is ignored because it doesn't have a .py extension
|
||||
from gitlint.rules import CommitRule, RuleViolation
|
||||
from gitlint.options import IntOption
|
||||
|
||||
|
||||
class MyUserCommitRule2(CommitRule):
|
||||
name = "my-user-commit-rule2"
|
||||
id = "TUC2"
|
||||
options_spec = [IntOption('violation-count', 0, "Number of violations to return")]
|
||||
|
||||
def validate(self, _commit):
|
||||
violations = []
|
||||
for i in range(1, self.options['violation-count'].value + 1):
|
||||
violations.append(RuleViolation(self.id, "Commit violation %d" % i, "Content %d" % i, i))
|
||||
|
||||
return violations
|
|
@ -0,0 +1,26 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from gitlint.rules import CommitRule, RuleViolation
|
||||
from gitlint.options import IntOption
|
||||
|
||||
|
||||
class MyUserCommitRule(CommitRule):
|
||||
name = "my-üser-commit-rule"
|
||||
id = "UC1"
|
||||
options_spec = [IntOption('violation-count', 1, "Number of violåtions to return")]
|
||||
|
||||
def validate(self, _commit):
|
||||
violations = []
|
||||
for i in range(1, self.options['violation-count'].value + 1):
|
||||
violations.append(RuleViolation(self.id, "Commit violåtion %d" % i, "Contënt %d" % i, i))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
# The below code is present so that we can test that we actually ignore it
|
||||
|
||||
def func_should_be_ignored():
|
||||
pass
|
||||
|
||||
|
||||
global_variable_should_be_ignored = True
|
|
@ -0,0 +1,13 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is meant to test that we can also load rules from __init__.py files, this was an issue with pypy before.
|
||||
|
||||
from gitlint.rules import CommitRule
|
||||
|
||||
|
||||
class InitFileRule(CommitRule):
|
||||
name = "my-init-cömmit-rule"
|
||||
id = "UC1"
|
||||
options_spec = []
|
||||
|
||||
def validate(self, _commit):
|
||||
return []
|
|
@ -0,0 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from gitlint.rules import CommitRule
|
||||
|
||||
|
||||
class MyUserCommitRule(CommitRule):
|
||||
name = "my-user-cömmit-rule"
|
||||
id = "UC2"
|
||||
options_spec = []
|
||||
|
||||
def validate(self, _commit):
|
||||
return []
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue