# 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.deprecation import LOG as DEPRECATED_LOG, DEPRECATED_LOG_FORMAT 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 ) 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, mostly used for debug root_log = logging.getLogger("gitlint") root_log.propagate = False # Don't propagate to child loggers, the gitlint root logger handles everything root_log.setLevel(logging.ERROR) handler = logging.StreamHandler() formatter = logging.Formatter(LOG_FORMAT) handler.setFormatter(formatter) root_log.addHandler(handler) # Deprecated log, to log deprecation warnings DEPRECATED_LOG.propagate = False # Don't propagate to child logger DEPRECATED_LOG.setLevel(logging.WARNING) deprecated_log_handler = logging.StreamHandler() deprecated_log_handler.setFormatter(logging.Formatter(DEPRECATED_LOG_FORMAT)) DEPRECATED_LOG.addHandler(deprecated_log_handler) 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 sent to stdin or False if nothing is sent""" # 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( # pylint: disable=unnecessary-lambda-assignment message, lint_config.target ) ) # 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.") # 3.1 Linting a range of commits if refspec: # 3.1.1 Not real refspec, but comma-separated list of commit hashes if "," in refspec: commit_hashes = [hash.strip() for hash in refspec.split(",")] return GitContext.from_local_repository(lint_config.target, commit_hashes=commit_hashes) # 3.1.2 Real refspec return GitContext.from_local_repository(lint_config.target, refspec=refspec) # 3.2 Linting a specific commit if commit_hash: return GitContext.from_local_repository(lint_config.target, commit_hashes=[commit_hash]) # 3.3 Fallback to linting the current HEAD return GitContext.from_local_repository(lint_config.target) 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 # fmt: off @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', envvar='GITLINT_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 .