diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2021-12-04 03:31:49 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2021-12-04 03:31:49 +0000 |
commit | 23d0ac82f3d68663ddc74a0e1f9b963beb8d62b9 (patch) | |
tree | fc37aa2c97a3d28b3dee7f3892041158981ed335 /gitlint/cli.py | |
parent | Releasing progress-linux version 0.16.0-1. (diff) | |
download | gitlint-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.py | 454 |
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 |