Last active
May 14, 2022 17:03
-
-
Save rubeniskov/583eb8633bc65053b9577cc9213cb610 to your computer and use it in GitHub Desktop.
Guess the version using the git history message commits as a reference
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
from __future__ import print_function | |
import os, getopt, sys, re, sys, time | |
# Globals vars | |
__VERBOSE__ = False | |
__VERSION__ = "0.0.2" | |
# Default vars | |
DEFAULT_GIT_LOG_COMMAND = "git log --pretty=format:'%s' --reverse" | |
DEFAULT_VERSION_FORMAT = "{version_core}{prerelease}{build}" | |
DEFAULT_VERSION_FROM = "0.0.0" | |
# Semantic Versioning 2.0.0 | |
# https://semver.org/ | |
PREFIX_PRERELEASE = "-" | |
PREFIX_BUILD = "+" | |
VERSION_CORE_SEPARATOR = "." | |
VERSION_CORE_FRAGMENTS = [ "major", "minor", "patch" ] | |
REGEX_SEMVER = re.compile( | |
r""" | |
^v? | |
""" + | |
"\.".join( | |
map(lambda x: "(?P<{0}>0|[1-9]\d*)".format(x), | |
VERSION_CORE_FRAGMENTS | |
) | |
) | |
+ | |
""" | |
(?:-(?P<prerelease> | |
(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) | |
(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* | |
))? | |
(?:\+(?P<build> | |
[0-9a-zA-Z-]+ | |
(?:\.[0-9a-zA-Z-]+)* | |
))? | |
$ | |
""", | |
re.VERBOSE, | |
) | |
# Semantic Commit Messages | |
# https://www.conventionalcommits.org/en/v1.0.0-beta.2/ | |
# https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716 | |
# related order with VERSION_CORE_FRAGMENTS | |
MESSAGE_BUMP_RULES = [ | |
"BREAKING[_\s-]CHANGES", | |
"^feat(?:ure)?(?:\([0-9a-zA-Z-]+\))?:", | |
"^(?:bug|hot)?fix(?:\([0-9a-zA-Z-]+\))?:" | |
] | |
REGEX_MESSAGE_BUMP = re.compile( | |
r"|".join( | |
map(lambda x: "({0})".format(x), | |
MESSAGE_BUMP_RULES | |
) | |
), | |
re.IGNORECASE | |
) | |
def eprint(*args, **kwargs): | |
print(*args, file=sys.stderr, **kwargs) | |
def verbose(*args, **kwargs): | |
if __VERBOSE__: | |
eprint(time.time(), *args, **kwargs) | |
def merge_dicts(x, y): | |
z = x.copy() | |
z.update(y) | |
return z | |
def fallfront(value, front = None): | |
return (front or value) if value else "" | |
def parse_version(version): | |
""" | |
Parse version to major, minor, patch parts. | |
:param version: version string | |
:return: array with major, minor, patch numbers, | |
:rtype: dict | |
""" | |
match = REGEX_SEMVER.match(version) | |
if match is None: | |
raise ValueError("%s is not valid SemVer string \n-- SEMVER PATTERN -- \n%s" % (version, REGEX_SEMVER.pattern)) | |
groups = match.groupdict() | |
groups["version_core"] = map(lambda x: int(groups[x]), list(VERSION_CORE_FRAGMENTS)) | |
return groups | |
def format_version_core(version_core): | |
return VERSION_CORE_SEPARATOR.join(str(x) for x in version_core) | |
def format_version(semver, only_core = False): | |
return format_version_core(semver["version_core"]) if only_core else ( | |
DEFAULT_VERSION_FORMAT | |
).format(**merge_dicts(semver, { | |
"version_core": format_version_core(semver["version_core"]), | |
"prerelease": fallfront(semver["prerelease"], PREFIX_PRERELEASE + fallfront(semver["prerelease"])), | |
"build": fallfront(semver["build"], PREFIX_BUILD + fallfront(semver["build"])), | |
})) | |
def bump_version_by_index(version_core, index, step = 1): | |
""" | |
Increase the version core fragment by index using the step count | |
:param version_core: version list | |
:return: the ref of list version_core, | |
:rtype: list | |
""" | |
vclen = len(version_core) | |
for i in range(vclen - 1 - index): | |
# Reset decendents counters | |
version_core[vclen - i - 1] = 0 | |
# Increase the version core fragment | |
version_core[index] += step | |
return version_core | |
def guess_version_from_commit_message(commit_message, current_version = None): | |
# Use a pre defined current_version or initialize one using zeros | |
current_version = current_version or [0] * len(VERSION_CORE_FRAGMENTS) | |
matches = REGEX_MESSAGE_BUMP.search(commit_message) | |
if matches: | |
# Detected a commit message which match with any rule | |
groups = matches.groups() | |
# Identify what kind of rule is matched associated by | |
# version core fragment index | |
version_core_fragment_index = next(i for i,v in enumerate(groups) if v is not None) | |
# Bump version core fragment by index | |
bump_version_by_index(current_version["version_core"], version_core_fragment_index) | |
return current_version | |
def guess_all_versions(stream): | |
version = parse_version(DEFAULT_VERSION_FROM) | |
for commit_message in stream: | |
version = guess_version_from_commit_message(commit_message, version) | |
print('\033[93m' | |
+ format_version(version, True) | |
+ '\033[0m' + "\t" | |
+ commit_message.rstrip() | |
) | |
def guess_current_version(stream): | |
version = parse_version(DEFAULT_VERSION_FROM) | |
for commit_message in stream: | |
version = guess_version_from_commit_message(commit_message, version) | |
print(format_version(version)) | |
def show_version(): | |
eprint("v{0} License MIT 2020".format(__VERSION__)) | |
def usage(): | |
eprint("""Usage: {program} [options] <filename> | |
Guess the version using the git history message commits as a reference taking the following rules | |
* Commit message prefixed with "fix: " means a "patch" version change | |
* Commit message prefixed with "feat: " means a "minor" version change | |
* Commit containing the keyword "BREAKING_CHANGES" means a "major" version change | |
Available options: | |
-a, --all Show the list of commits with the corresponding version changes | |
-f, --format=<value> Formatter used to generate the version string ("{version_format}") | |
-s, --from=<value> The initial version to start count ({version_from}) | |
-v, --verbose Enable verbose traces | |
-h, --help Show usage | |
-v, --version Show version | |
Examples | |
{program} | |
{program} <file_name> | |
{program} <({git_log_command}) | |
{git_log_command} | {program} | |
""".format(**{ | |
"program": sys.argv[0], | |
"version_format": DEFAULT_VERSION_FORMAT, | |
"version_from": DEFAULT_VERSION_FROM, | |
"git_log_command": DEFAULT_GIT_LOG_COMMAND, | |
"version": show_version() | |
})) | |
def main(): | |
global __VERBOSE__ | |
global DEFAULT_VERSION_FORMAT | |
global DEFAULT_VERSION_FROM | |
global DEFAULT_GIT_LOG_COMMAND | |
stream = None | |
all = False | |
try: | |
opts, args = getopt.getopt(sys.argv[1:], "hvVaf:s:", ["help", "version", "verbose", "all", "from=", "format="]) | |
except getopt.GetoptError as err: | |
eprint(err) | |
usage(cmd) | |
sys.exit(2) | |
for opt, value in opts: | |
if opt in ("-V", "--verbose"): | |
__VERBOSE__ = True | |
verbose("Enabled verbose mode") | |
elif opt in ("--all"): | |
all = True | |
elif opt in ("-f", "--format"): | |
DEFAULT_VERSION_FORMAT = value | |
elif opt in ("-s", "--from"): | |
DEFAULT_VERSION_FROM = value | |
elif opt in ("-v", "--version"): | |
show_version() | |
sys.exit() | |
elif opt in ("-h", "--help"): | |
usage() | |
sys.exit() | |
else: | |
assert False, usage() | |
if len(args) > 0: | |
try: | |
filename = os.path.join(os.getcwd(), args[0]) | |
stream = open(filename, 'r') | |
verbose("Reading log from file \"{0}\"".format(filename)) | |
except OSError: | |
pass | |
if stream is None: | |
if sys.stdin.isatty(): | |
verbose("Executing git command \"{0}\"".format(DEFAULT_GIT_LOG_COMMAND)) | |
stream = os.popen(DEFAULT_GIT_LOG_COMMAND) | |
else: | |
verbose("Pointing reading from stdin data") | |
stream = sys.stdin | |
if all: | |
guess_all_versions(stream) | |
else: | |
guess_current_version(stream) | |
if __name__ == "__main__": | |
main() | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment