summaryrefslogtreecommitdiffstats
path: root/gitlint/cli.py
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2021-12-04 03:31:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2021-12-04 03:31:49 +0000
commit23d0ac82f3d68663ddc74a0e1f9b963beb8d62b9 (patch)
treefc37aa2c97a3d28b3dee7f3892041158981ed335 /gitlint/cli.py
parentReleasing progress-linux version 0.16.0-1. (diff)
downloadgitlint-23d0ac82f3d68663ddc74a0e1f9b963beb8d62b9.tar.xz
gitlint-23d0ac82f3d68663ddc74a0e1f9b963beb8d62b9.zip
Merging upstream version 0.17.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'gitlint/cli.py')
-rw-r--r--gitlint/cli.py454
1 files changed, 0 insertions, 454 deletions
diff --git a/gitlint/cli.py b/gitlint/cli.py
deleted file mode 100644
index 19676b3..0000000
--- a/gitlint/cli.py
+++ /dev/null
@@ -1,454 +0,0 @@
-# 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