import copy import logging import os import platform import stat import sys import click import gitlint from gitlint import hooks from gitlint.config import LintConfigBuilder, LintConfigError, LintConfigGenerator from gitlint.deprecation import DEPRECATED_LOG_FORMAT from gitlint.deprecation import LOG as DEPRECATED_LOG from gitlint.exception import GitlintError from gitlint.git import GitContext, GitContextError, git_version from gitlint.lint import GitLinter from gitlint.shell import shell from gitlint.utils import LOG_FORMAT # 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.""" 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.WARN) 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("TERMINAL_ENCODING: %s", gitlint.utils.TERMINAL_ENCODING) LOG.debug("FILE_ENCODING: %s", gitlint.utils.FILE_ENCODING) def build_config( 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") def from_commit_msg(message): return GitContext.from_staged_commit(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(",") if hash] 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 .