def set(self, value): if isinstance(value, list): the_list = value else: the_list = ustr(value).split(",") self.value = [ustr(item.strip()) for item in the_list if item.strip() != ""]
def cli(ctx, target, config, c, commits, extra_path, ignore, verbose, silent, debug): """ Git lint tool, checks your git commit messages for styling issues """ try: if debug: logging.getLogger("gitlint").setLevel(logging.DEBUG) 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(ctx, target, config, c, extra_path, ignore, verbose, silent, debug) LOG.debug(u"Configuration\n%s", ustr(config)) ctx.obj = (config, config_builder, commits) # If no subcommand is specified, then just lint if ctx.invoked_subcommand is None: ctx.invoke(lint) except GitContextError as e: click.echo(ustr(e)) ctx.exit(GIT_CONTEXT_ERROR_CODE)
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 rc.id == ustr(rule_id_or_name) or rc.name == ustr(rule_id_or_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( u"No contrib rule with id or name '{0}' found.".format( ustr(rule_id_or_name))) except (options.RuleOptionError, rules.UserRuleError) as e: raise LintConfigError(ustr(e))
def lint(ctx): """ Lints a git repository [default command] """ lint_config = ctx.obj[0] try: if sys.stdin.isatty(): # If target has not been set explicitly before, fallback to the current directory gitcontext = GitContext.from_local_repository(lint_config.target, ctx.obj[2]) else: stdin_str = ustr(sys.stdin.read()) gitcontext = GitContext.from_commit_msg(stdin_str) except GitContextError as e: click.echo(ustr(e)) ctx.exit(GIT_CONTEXT_ERROR_CODE) 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. if number_of_commits == 0: click.echo(u'No commits in range "{0}".'.format(ctx.obj[2])) ctx.exit(0) general_config_builder = ctx.obj[1] last_commit = gitcontext.commits[-1] # Let's get linting! first_violation = True exit_code = 0 for commit in gitcontext.commits: # Build a config_builder and linter taking into account the commit specific config (if any) config_builder = general_config_builder.clone() config_builder.set_config_from_commit(commit) lint_config = config_builder.build(lint_config) linter = GitLinter(lint_config) # Actually do the linting 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: linter.display.e(u"{0}Commit {1}:".format( "\n" if not first_violation or commit is last_commit else "", 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)
def _get_option(self, rule_name_or_id, option_name): rule_name_or_id = ustr(rule_name_or_id) # convert to unicode first option_name = ustr(option_name) rule = self.get_rule(rule_name_or_id) if not rule: raise LintConfigError(u"No such rule '{0}'".format(rule_name_or_id)) option = rule.options.get(option_name) if not option: raise LintConfigError(u"Rule '{0}' has no option '{1}'".format(rule_name_or_id, option_name)) return option
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.get_rule("contrib-title-conventional-commits") self.assertTrue(actual_rule.is_contrib) self.assertEqual(ustr(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" ], "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.get_rule("contrib-body-requires-signed-off-by") self.assertTrue(actual_rule.is_contrib) self.assertEqual(ustr(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.get_rule("contrib-body-requires-signed-off-by")) self.assertIsNone( config.get_rule("contrib-title-conventional-commits")) # empty value config.set_general_option("contrib", "") self.assertListEqual(config.contrib, [])
def cli( # pylint: disable=too-many-arguments ctx, target, config, c, commits, extra_path, ignore, contrib, msg_filename, ignore_stdin, staged, 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, verbose, silent, debug) LOG.debug(u"Configuration\n%s", ustr(config)) ctx.obj = (config, config_builder, commits, msg_filename) # If no subcommand is specified, then just lint if ctx.invoked_subcommand is None: ctx.invoke(lint) except GitContextError as e: click.echo(ustr(e)) ctx.exit(GIT_CONTEXT_ERROR_CODE) except GitLintUsageError as e: click.echo(u"Error: {0}".format(ustr(e))) ctx.exit(USAGE_ERROR_CODE) except LintConfigError as e: click.echo(u"Config Error: {0}".format(ustr(e))) ctx.exit(CONFIG_ERROR_CODE)
def set_from_config_file(self, filename): """ Loads lint config from a ini-style config file """ if not os.path.exists(filename): raise LintConfigError(u"Invalid file path: {0}".format(filename)) self._config_path = os.path.abspath(filename) try: parser = ConfigParser() parser.read(filename) for section_name in parser.sections(): for option_name, option_value in parser.items(section_name): self.set_option(section_name, option_name, ustr(option_value)) except ConfigParserError as e: raise LintConfigError(ustr(e))
def lint(ctx): """ Lints a git repository [default command] """ lint_config = ctx.obj[0] try: if sys.stdin.isatty(): # If target has not been set explicitly before, fallback to the current directory gitcontext = GitContext.from_local_repository( lint_config.target, ctx.obj[2]) else: stdin_str = ustr(sys.stdin.read()) gitcontext = GitContext.from_commit_msg(stdin_str) except GitContextError as e: click.echo(ustr(e)) ctx.exit(GIT_CONTEXT_ERROR_CODE) 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. if number_of_commits == 0: click.echo(u'No commits in range "{0}".'.format(ctx.obj[2])) ctx.exit(0) config_builder = ctx.obj[1] last_commit = gitcontext.commits[-1] # Apply an additional config that is specified in the last commit message config_builder.set_config_from_commit(last_commit) lint_config = config_builder.build(lint_config) # Let's get linting! linter = GitLinter(lint_config) first_violation = True for commit in gitcontext.commits: violations = linter.lint(commit) if violations: # Display the commit hash & new lines intelligently if number_of_commits > 1 and commit.sha: click.echo(u"{0}Commit {1}:".format( "\n" if not first_violation or commit is last_commit else "", commit.sha[:10])) linter.print_violations(violations) first_violation = False exit_code = min(MAX_VIOLATION_ERROR_CODE, len(violations)) ctx.exit(exit_code)
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(sstr(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 ustr(result) except CommandNotFound: raise GitNotInstalledError() 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: error_msg = u"{0} is not a git repository.".format( git_kwargs['_cwd']) raise GitContextError(error_msg) if (b"does not have any commits yet" in error_msg_lower or b"ambiguous argument 'head': unknown revision" in error_msg_lower): raise GitContextError( u"Current branch has no commits. Gitlint requires at least one commit to function." ) raise GitExitCodeError(e.full_cmd, error_msg)
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 for rule in self.rules: if hasattr(rule, 'user_defined') and rule.user_defined: del self._rules[rule.id] # Find rules in the new extra-path rule_classes = user_rules.find_rule_classes(self.extra_path) # Add the newly found rules to the existing rules for rule_class in rule_classes: rule_obj = rule_class() rule_obj.user_defined = True self._rules[rule_class.id] = rule_obj except (options.RuleOptionError, user_rules.UserRuleError) as e: raise LintConfigError(ustr(e))
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(ustr(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 })
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" + ustr(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"] for commit_type in ignore_commit_types: if getattr(commit, "is_{0}_commit".format(commit_type)) and \ getattr(self.config, "ignore_{0}_commits".format(commit_type)): 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 set(self, value): value = ustr(value) if not os.path.isdir(value): msg = u"Option {0} must be an existing directory (current value: '{1}')".format( self.name, value) raise RuleOptionError(msg) self.value = os.path.abspath(value)
def _exec(*args, **kwargs): if sys.version_info[0] == 2: no_command_error = OSError # noqa pylint: disable=undefined-variable,invalid-name else: no_command_error = FileNotFoundError # noqa pylint: disable=undefined-variable pipe = subprocess.PIPE popen_kwargs = { 'stdout': pipe, 'stderr': pipe, 'shell': kwargs['_tty_out'] } if '_cwd' in kwargs: popen_kwargs['cwd'] = kwargs['_cwd'] try: p = subprocess.Popen(args, **popen_kwargs) result = p.communicate() except no_command_error: raise CommandNotFound exit_code = p.returncode stdout = ustr(result[0]) 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)
def git_commentchar(): """ Shortcut for retrieving comment char from git config """ commentchar = _git("config", "--get", "core.commentchar", _ok_code=[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 commentchar.exit_code == 1: # pylint: disable=no-member commentchar = "#" return ustr(commentchar).replace(u"\n", u"")
def set(self, value): value = ustr(value).strip().lower() if value not in ['true', 'false']: raise RuleOptionError( u"Option '{0}' must be either 'true' or 'false'".format( self.name)) self.value = value == 'true'
def set(self, value): value = ustr(value) error_msg = u"" if self.type == 'dir': if not os.path.isdir(value): error_msg = u"Option {0} must be an existing directory (current value: '{1}')".format( self.name, value) elif self.type == 'file': if not os.path.isfile(value): error_msg = u"Option {0} must be an existing file (current value: '{1}')".format( self.name, value) elif self.type == 'both': if not os.path.isdir(value) and not os.path.isfile(value): error_msg = ( u"Option {0} must be either an existing directory or file " u"(current value: '{1}')").format(self.name, value) else: error_msg = u"Option {0} type must be one of: 'file', 'dir', 'both' (current: '{1}')".format( self.name, self.type) if error_msg: raise RuleOptionError(error_msg) self.value = os.path.realpath(value)
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 ustr(input_data) return False
def current_branch(self): current_branch = ustr( _git("rev-parse", "--abbrev-ref", "HEAD", _cwd=self.repository_path)).strip() return current_branch
def build_git_context(lint_config, msg_filename, 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(ustr(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( u"The 'staged' option (--staged) can only be used when using '--msg-filename' or " u"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." ) return GitContext.from_local_repository(lint_config.target, refspec)
def _git(*command_parts, **kwargs): """ Convenience function for running git commands. Automatically deals with exceptions and unicode. """ # Special arguments passed to sh: http://amoffat.github.io/sh/special_arguments.html git_kwargs = {'_tty_out': False} git_kwargs.update(kwargs) try: 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 ustr(result) except CommandNotFound: raise GitNotInstalledError() except ErrorReturnCode as e: # Something went wrong while executing the git command error_msg = e.stderr.strip() if '_cwd' in git_kwargs and b"not a git repository" in error_msg.lower( ): error_msg = u"{0} is not a git repository.".format( git_kwargs['_cwd']) else: error_msg = u"An error occurred while executing '{0}': {1}".format( e.full_cmd, error_msg) raise GitContextError(error_msg)
def set_from_config_file(self, filename): """ Loads lint config from a ini-style config file """ if not os.path.exists(filename): raise LintConfigError(u"Invalid file path: {0}".format(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) for section_name in parser.sections(): for option_name, option_value in parser.items(section_name): self.set_option(section_name, option_name, ustr(option_value)) except ConfigParserError as e: raise LintConfigError(ustr(e))
def get_rule(self, rule_id_or_name): # try finding rule by id rule_id_or_name = ustr(rule_id_or_name) # convert to unicode first 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 set_from_config_file(self, filename): """ Loads lint config from a ini-style config file """ if not os.path.exists(filename): raise LintConfigError(u"Invalid file path: {0}".format(filename)) self._config_path = os.path.realpath(filename) try: parser = ConfigParser() with io.open(filename, encoding=DEFAULT_ENCODING) as config_file: # readfp() is deprecated in python 3.2+, but compatible with 2.7 parser.readfp(config_file, filename) # pylint: disable=deprecated-method for section_name in parser.sections(): for option_name, option_value in parser.items(section_name): self.set_option(section_name, option_name, ustr(option_value)) except ConfigParserError as e: raise LintConfigError(ustr(e))
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 = ustr(content.read()) if variable_dict: expected = expected.format(**variable_dict) return expected
def author_email(self): try: return ustr( _git("config", "--get", "user.email", _cwd=self.context.repository_path)).strip() except GitExitCodeError: raise GitContextError( "Missing git configuration: please set user.email")
def __unicode__(self): format_str = u"--- Commit Message ----\n%s\n" + \ u"--- Meta info ---------\n" + \ u"Author: %s <%s>\nDate: %s\n" + \ u"is-merge-commit: %s\nis-fixup-commit: %s\n" + \ u"is-squash-commit: %s\nis-revert-commit: %s\n" + \ u"-----------------------" # pragma: no cover return format_str % ( ustr(self.message), self.author_name, self.author_email, self.date, self.is_merge_commit, self.is_fixup_commit, self.is_squash_commit, self.is_revert_commit) # pragma: no cover
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_dir = os.path.join( os.path.dirname(os.path.realpath(__file__)), "expected") expected_path = os.path.join(expected_dir, filename) expected = ustr(open(expected_path).read()) if variable_dict: expected = expected.format(**variable_dict) return expected
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 = u"'{0}' is not a valid value for option '{1}.{2}'. {3}." raise LintConfigError( msg.format(option_value, rule_name_or_id, option_name, ustr(e)))