From 27ecaeb1b6b18127c9a982b5ae5d803c47223457 Mon Sep 17 00:00:00 2001 From: Clement Date: Thu, 15 Nov 2018 16:06:47 -0800 Subject: [PATCH 1/6] Add RC branch creation script --- tools/scripts/rc-branches.py | 241 +++++++++++++++++++++++++ tools/scripts/utils/__init__.py | 1 + tools/scripts/utils/git.py | 303 ++++++++++++++++++++++++++++++++ 3 files changed, 545 insertions(+) create mode 100755 tools/scripts/rc-branches.py create mode 100644 tools/scripts/utils/__init__.py create mode 100644 tools/scripts/utils/git.py diff --git a/tools/scripts/rc-branches.py b/tools/scripts/rc-branches.py new file mode 100755 index 0000000000..a9e1c8c304 --- /dev/null +++ b/tools/scripts/rc-branches.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python + +import logging +import os +import re +import sys + +from optparse import OptionParser + +from utils import git + +FORMAT = '[%(levelname)s] %(message)s' +logging.basicConfig(format=FORMAT, level=logging.DEBUG) + +remote_name = "upstream" +remote_master_branch = "{}/master".format(remote_name) + +def checkVersionBranches(version): + """Check the branches for a given version were created properly.""" + + repo = git.Repository(git.Repository.get_base_directory()) + + # Validate that user passed a valid version + VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") + match = VERSION_RE.match(version) + if not match: + raise ValueError("Invalid version (should be X.Y.Z)") + + # Parse the version component and build proper version strings + major = int(match.group(1)) + minor = int(match.group(2)) + patch = int(match.group(3)) + version = "v{}.{}.{}".format(major, minor, patch) # clean up version + + is_major_release = False + is_minor_release = False + is_patch_release = False + if patch != 0: + is_patch_release = True + previous_version = "v{}.{}.{}".format(major, minor, patch - 1) + elif minor != 0: + is_minor_release = True + previous_version = "v{}.{}.{}".format(major, minor - 1, 0) + else: + is_major_release = True + raise ValueError("Major releases not yet supported") + + # Build the branch names + previous_rc_branch = "{}/{}-rc".format(remote_name, previous_version) + base_branch = "{}/{}-rc-base".format(remote_name, version) + rc_branch = "{}/{}-rc".format(remote_name, version) + + # Verify the branches' existance + if not repo.does_branch_exist(previous_rc_branch): + raise ValueError("Previous RC branch not found: {}".format(previous_rc_branch)) + + if not repo.does_branch_exist(base_branch): + raise ValueError("Base branch not found: {}".format(base_branch)) + + if not repo.does_branch_exist(rc_branch): + raise ValueError("RC branch not found: {}".format(rc_branch)) + + # Figure out SHA for each of the branches + previous_rc_commit = repo.git_rev_parse([previous_rc_branch]) + base_commit = repo.git_rev_parse([base_branch]) + rc_commit = repo.git_rev_parse([rc_branch]) + + # Check the base branch is an ancestor of the rc branch + if not repo.is_ancestor(base_commit, rc_commit): + raise ValueError("{} is not an ancesctor of {}".format(base_branch, rc_branch)) + + # Check that the base branch is the merge base of the previous and current RCs + merge_base = repo.get_merge_base(previous_rc_commit, rc_commit) + if base_commit != merge_base: + raise ValueError("Base branch is not the merge base between {} and {}".format(previous_rc_branch, rc_branch)) + + # For patch releases, warn if the base commit is not the previous RC commit + if is_patch_release: + if not repo.does_tag_exist(version): + logging.warning("The tag {} does not exist, which suggests {} hasn't yet been cut.".format(version, version)) + + if base_commit != previous_rc_commit: + logging.warning("Previous version has commits not in this patch"); + logging.warning("Type \"git diff {}..{}\" to see the commit list".format(base_commit, previous_rc_commit)); + + # Check base branch is part of the previous RC + previous_rc_base_commit = repo.get_merge_base(previous_rc_commit, remote_master_branch) + if repo.is_ancestor(base_commit, previous_rc_base_commit): + raise ValueError("{} is older than {}".format(base_branch, rc_branch)) + + print("[SUCCESS] Checked {}".format(version)) + +def createVersionBranches(version): + """Create the branches for a given version.""" + + repo = git.Repository(git.Repository.get_base_directory()) + + # Validate the user is on a local branch that has the right merge base + if repo.is_detached(): + raise ValueError("You must not run this script in a detached HEAD state") + + # Validate the user has no pending changes + if repo.is_working_tree_dirty(): + raise ValueError("Your working tree has pending changes. You must have a clean working tree before proceeding.") + + # Validate that user passed a valid version + VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") + match = VERSION_RE.match(version) + if not match: + raise ValueError("Invalid version (should be X.Y.Z)") + + # Parse the version component and build proper version strings + major = int(match.group(1)) + minor = int(match.group(2)) + patch = int(match.group(3)) + version = "v{}.{}.{}".format(major, minor, patch) # clean up version + + is_major_release = False + is_minor_release = False + is_patch_release = False + if patch != 0: + is_patch_release = True + previous_version = "v{}.{}.{}".format(major, minor, patch - 1) + elif minor != 0: + is_minor_release = True + previous_version = "v{}.{}.{}".format(major, minor - 1, 0) + else: + is_major_release = True + raise ValueError("Major releases not yet supported") + + # Build the branch names + previous_rc_branch = "{}-rc".format(previous_version) + base_branch = "{}-rc-base".format(version) + rc_branch = "{}-rc".format(version) + remote_previous_rc_branch = "{}/{}".format(remote_name, previous_rc_branch) + remote_base_branch = "{}/{}".format(remote_name, base_branch) + remote_rc_branch = "{}/{}".format(remote_name, rc_branch) + + # Make sure the remote is up to date + repo.git_fetch([remote_name]) + + # Verify the previous RC branch exists + if not repo.does_branch_exist(remote_previous_rc_branch): + raise ValueError("Previous RC branch not found: {}".format(remote_previous_rc_branch)) + + # Verify the branches don't already exist + if repo.does_branch_exist(remote_base_branch): + raise ValueError("Base branch already exists: {}".format(remote_base_branch)) + + if repo.does_branch_exist(remote_rc_branch): + raise ValueError("RC branch already exists: {}".format(remote_rc_branch)) + + if repo.does_branch_exist(base_branch): + raise ValueError("Base branch already exists locally: {}".format(base_branch)) + + if repo.does_branch_exist(rc_branch): + raise ValueError("RC branch already exists locally: {}".format(rc_branch)) + + # Save current branch name + current_branch_name = repo.get_branch_name() + + # Create the RC branches + if is_patch_release: + + # Check tag exists, if it doesn't, print warning and ask for comfirmation + if not repo.does_tag_exist(version): + logging.warning("The tag {} does not exist, which suggests {} hasn't yet been cut.".format(version, version)) + logging.warning("Creating the branches now means that they will diverge from {} if anything is merge into it.".format(previous_version)) + logging.warning("This is not recommanded unless necessary.") + + validAnswer = False + askCount = 0 + while not validAnswer and askCount < 3: + answer = input("Are you sure you want to do this? [y/n]").strip().lower() + askCount += 1 + validAnswer = answer == "y" or answer == "n" + + if not validAnswer: + raise ValueError("Did not understand response") + + if answer == "n": + print("Aborting") + return + else: + print("Creating branches") + + repo.git_checkout(["-b", base_branch, remote_previous_rc_branch]) + repo.push_to_remote_branch(remote_name, base_branch) + repo.git_checkout(["-b", rc_branch, remote_previous_rc_branch]) + repo.push_to_remote_branch(remote_name, rc_branch) + else: + merge_base = repo.get_merge_base(remote_previous_rc_branch, remote_master_branch) + repo.git_checkout(["-b", base_branch, merge_base]) + repo.push_to_remote_branch(remote_name, base_branch) + repo.git_checkout(["-b", rc_branch, remote_master_branch]) + repo.push_to_remote_branch(remote_name, rc_branch) + + repo.git_checkout([current_branch_name]) + + print("[SUCCESS] Created {} and {}".format(base_branch, rc_branch)) + print("[SUCCESS] You can make the PR from the following webpage:") + print("[SUCCESS] https://github.com/highfidelity/hifi/compare/{}...{}".format(base_branch, rc_branch)) + +def usage(): + """Print usage.""" + print("rc-branches.py supports the following commands:") + print("\ncheck ") + print(" - version to check of the form \"X.Y.Z\"") + print("\ncreate ") + print(" - version to create branches for of the form \"X.Y.Z\"") + +def main(): + """Execute Main entry point.""" + global remote_name + + parser = OptionParser() + parser.add_option("-r", "--remote", type="string", dest="remote_name", default=remote_name) + (options, args) = parser.parse_args(args=sys.argv) + + remote_name = options.remote_name + + if len(args) < 3: + usage() + return + + command = args[1] + version = args[2] + + try: + if command == "check": + checkVersionBranches(version) + elif command == "create": + createVersionBranches(version) + else: + usage() + except Exception as ex: + logging.error(ex) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/tools/scripts/utils/__init__.py b/tools/scripts/utils/__init__.py new file mode 100644 index 0000000000..4b7a2bb941 --- /dev/null +++ b/tools/scripts/utils/__init__.py @@ -0,0 +1 @@ +"""Empty.""" diff --git a/tools/scripts/utils/git.py b/tools/scripts/utils/git.py new file mode 100644 index 0000000000..156d2240c3 --- /dev/null +++ b/tools/scripts/utils/git.py @@ -0,0 +1,303 @@ +"""Module to run git commands on a repository.""" + +# Copied from https://github.com/mongodb/mongo under Apache 2.0 +# Modified by Clement Brisset on 11/14/18. + +import logging +import os +import sys + +# The subprocess32 module resolves the thread-safety issues of the subprocess module in Python 2.x +# when the _posixsubprocess C extension module is also available. Additionally, the _posixsubprocess +# C extension module avoids triggering invalid free() calls on Python's internal data structure for +# thread-local storage by skipping the PyOS_AfterFork() call when the 'preexec_fn' parameter isn't +# specified to subprocess.Popen(). See SERVER-22219 for more details. +# +# The subprocess32 module is untested on Windows and thus isn't recommended for use, even when it's +# installed. See https://github.com/google/python-subprocess32/blob/3.2.7/README.md#usage. +if os.name == "posix" and sys.version_info[0] == 2: + try: + import subprocess32 as subprocess + except ImportError: + import warnings + warnings.warn(("Falling back to using the subprocess module because subprocess32 isn't" + " available. When using the subprocess module, a child process may trigger" + " an invalid free(). See SERVER-22219 for more details."), RuntimeWarning) + import subprocess # type: ignore +else: + import subprocess + +LOGGER = logging.getLogger(__name__) + + +class Repository(object): # pylint: disable=too-many-public-methods + """Represent a local git repository.""" + + def __init__(self, directory): + """Initialize Repository.""" + self.directory = directory + + def git_add(self, args): + """Run a git add command.""" + return self._callgito("add", args) + + def git_cat_file(self, args): + """Run a git cat-file command.""" + return self._callgito("cat-file", args) + + def git_checkout(self, args): + """Run a git checkout command.""" + return self._callgito("checkout", args) + + def git_commit(self, args): + """Run a git commit command.""" + return self._callgito("commit", args) + + def git_diff(self, args): + """Run a git diff command.""" + return self._callgito("diff", args) + + def git_log(self, args): + """Run a git log command.""" + return self._callgito("log", args) + + def git_push(self, args): + """Run a git push command.""" + return self._callgito("push", args) + + def git_fetch(self, args): + """Run a git fetch command.""" + return self._callgito("fetch", args) + + def git_ls_files(self, args): + """Run a git ls-files command and return the result as a str.""" + return self._callgito("ls-files", args) + + def git_rebase(self, args): + """Run a git rebase command.""" + return self._callgito("rebase", args) + + def git_reset(self, args): + """Run a git reset command.""" + return self._callgito("reset", args) + + def git_rev_list(self, args): + """Run a git rev-list command.""" + return self._callgito("rev-list", args) + + def git_rev_parse(self, args): + """Run a git rev-parse command.""" + return self._callgito("rev-parse", args).rstrip() + + def git_rm(self, args): + """Run a git rm command.""" + return self._callgito("rm", args) + + def git_show(self, args): + """Run a git show command.""" + return self._callgito("show", args) + + def get_origin_url(self): + """Return the URL of the origin repository.""" + return self._callgito("config", ["--local", "--get", "remote.origin.url"]).rstrip() + + def get_branch_name(self): + """ + Get the current branch name, short form. + + This returns "master", not "refs/head/master". + Raises a GitException if the current branch is detached. + """ + branch = self.git_rev_parse(["--abbrev-ref", "HEAD"]) + if branch == "HEAD": + raise GitException("Branch is currently detached") + return branch + + def get_current_revision(self): + """Retrieve the current revision of the repository.""" + return self.git_rev_parse(["HEAD"]).rstrip() + + def configure(self, parameter, value): + """Set a local configuration parameter.""" + return self._callgito("config", ["--local", parameter, value]) + + def is_detached(self): + """Return True if the current working tree in a detached HEAD state.""" + # symbolic-ref returns 1 if the repo is in a detached HEAD state + return self._callgit("symbolic-ref", ["--quiet", "HEAD"]) == 1 + + def is_ancestor(self, parent_revision, child_revision): + """Return True if the specified parent hash an ancestor of child hash.""" + # If the common point between parent_revision and child_revision is + # parent_revision, then parent_revision is an ancestor of child_revision. + merge_base = self._callgito("merge-base", [parent_revision, child_revision]).rstrip() + return parent_revision == merge_base + + def is_commit(self, revision): + """Return True if the specified hash is a valid git commit.""" + # cat-file -e returns 0 if it is a valid hash + return not self._callgit("cat-file", ["-e", "{0}^{{commit}}".format(revision)]) + + def is_working_tree_dirty(self): + """Return True if the current working tree has changes.""" + # diff returns 1 if the working tree has local changes + return self._callgit("diff", ["--quiet"]) == 1 + + def does_branch_exist(self, branch): + """Return True if the branch exists.""" + # rev-parse returns 0 if the branch exists + return not self._callgit("rev-parse", ["--verify", "--quiet", branch]) + + def does_tag_exist(self, tag): + """Return True if the tag exists.""" + # rev-parse returns 0 if the tag exists + return not self._callgit("rev-parse", ["--verify", "--quiet", tag]) + + def get_merge_base(self, commit1, commit2 = "HEAD"): + """Get the merge base between 'commit' and HEAD.""" + return self._callgito("merge-base", [commit1, commit2]).rstrip() + + def commit_with_message(self, message): + """Commit the staged changes with the given message.""" + return self.git_commit(["--message", message]) + + def push_to_remote_branch(self, remote, remote_branch): + """Push the current branch to the specified remote repository and branch.""" + refspec = "{}:{}".format(self.get_branch_name(), remote_branch) + return self.git_push([remote, refspec]) + + def fetch_remote_branch(self, repository, branch): + """Fetch the changes from a remote branch.""" + return self.git_fetch([repository, branch]) + + def rebase_from_upstream(self, upstream, ignore_date=False): + """Rebase the repository on an upstream reference. + + If 'ignore_date' is True, the '--ignore-date' option is passed to git. + """ + args = [upstream] + if ignore_date: + args.append("--ignore-date") + return self.git_rebase(args) + + @staticmethod + def clone(url, directory, branch=None, depth=None): + """Clone the repository designed by 'url' into 'directory'. + + Return a Repository instance. + """ + params = ["git", "clone"] + if branch: + params += ["--branch", branch] + if depth: + params += ["--depth", depth] + params += [url, directory] + result = Repository._run_process("clone", params) + result.check_returncode() + return Repository(directory) + + @staticmethod + def get_base_directory(directory=None): + """Return the base directory of the repository the given directory belongs to. + + If no directory is specified, then the current working directory is used. + """ + if directory is not None: + params = ["git", "-C", directory] + else: + params = ["git"] + params.extend(["rev-parse", "--show-toplevel"]) + result = Repository._run_process("rev-parse", params) + result.check_returncode() + return os.path.normpath(result.stdout.rstrip()) + + @staticmethod + def current_repository(): + """Return the Repository the current working directory belongs to.""" + return Repository(Repository.get_base_directory()) + + def _callgito(self, cmd, args): + """Call git for this repository, and return the captured output.""" + result = self._run_cmd(cmd, args) + result.check_returncode() + return result.stdout + + def _callgit(self, cmd, args, raise_exception=False): + """ + Call git for this repository without capturing output. + + This is designed to be used when git returns non-zero exit codes. + """ + result = self._run_cmd(cmd, args) + if raise_exception: + result.check_returncode() + return result.returncode + + def _run_cmd(self, cmd, args): + """Run the git command and return a GitCommandResult instance.""" + + LOGGER.debug("Running: git {} {}".format(cmd, " ".join(args))) + params = ["git", cmd] + args + return self._run_process(cmd, params, cwd=self.directory) + + @staticmethod + def _run_process(cmd, params, cwd=None): + process = subprocess.Popen(params, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd) + (stdout, stderr) = process.communicate() + if process.returncode: + if stdout: + LOGGER.error("Output of '%s': %s", " ".join(params), stdout.rstrip()) + if stderr: + LOGGER.error("Error output of '%s': %s", " ".join(params), stderr.rstrip()) + return GitCommandResult(cmd, params, process.returncode, stdout=stdout, stderr=stderr) + + +class GitException(Exception): + """Custom Exception for the git module. + + Args: + message: the exception message. + returncode: the return code of the failed git command, if any. + cmd: the git subcommand that was run, if any. + process_args: a list containing the git command and arguments (includes 'git' as its first + element) that were run, if any. + stderr: the error output of the git command. + """ + + def __init__( # pylint: disable=too-many-arguments + self, message, returncode=None, cmd=None, process_args=None, stdout=None, stderr=None): + """Initialize GitException.""" + Exception.__init__(self, message) + self.returncode = returncode + self.cmd = cmd + self.process_args = process_args + self.stdout = stdout + self.stderr = stderr + + +class GitCommandResult(object): + """The result of running git subcommand. + + Args: + cmd: the git subcommand that was executed (e.g. 'clone', 'diff'). + process_args: the full list of process arguments, starting with the 'git' command. + returncode: the return code. + stdout: the output of the command. + stderr: the error output of the command. + """ + + def __init__( # pylint: disable=too-many-arguments + self, cmd, process_args, returncode, stdout=None, stderr=None): + """Initialize GitCommandResult.""" + self.cmd = cmd + self.process_args = process_args + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + def check_returncode(self): + """Raise GitException if the exit code is non-zero.""" + if self.returncode: + raise GitException("Command '{0}' failed with code '{1}'".format( + " ".join(self.process_args), self.returncode), self.returncode, self.cmd, + self.process_args, self.stdout, self.stderr) From 8b2c9e1c48f0e0f375f17c77a413ebf7fcec9ab5 Mon Sep 17 00:00:00 2001 From: Stephen Birarda Date: Thu, 15 Nov 2018 18:17:08 -0800 Subject: [PATCH 2/6] Apply suggestions from code review Changed wording in a few comments Co-Authored-By: Atlante45 --- tools/scripts/rc-branches.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/scripts/rc-branches.py b/tools/scripts/rc-branches.py index a9e1c8c304..1d78fc0722 100755 --- a/tools/scripts/rc-branches.py +++ b/tools/scripts/rc-branches.py @@ -16,7 +16,7 @@ remote_name = "upstream" remote_master_branch = "{}/master".format(remote_name) def checkVersionBranches(version): - """Check the branches for a given version were created properly.""" + """Check that the branches for a given version were created properly.""" repo = git.Repository(git.Repository.get_base_directory()) @@ -77,7 +77,7 @@ def checkVersionBranches(version): # For patch releases, warn if the base commit is not the previous RC commit if is_patch_release: if not repo.does_tag_exist(version): - logging.warning("The tag {} does not exist, which suggests {} hasn't yet been cut.".format(version, version)) + logging.warning("The tag {} does not exist, which suggests {} has not been released.".format(version, version)) if base_commit != previous_rc_commit: logging.warning("Previous version has commits not in this patch"); @@ -164,9 +164,9 @@ def createVersionBranches(version): # Check tag exists, if it doesn't, print warning and ask for comfirmation if not repo.does_tag_exist(version): - logging.warning("The tag {} does not exist, which suggests {} hasn't yet been cut.".format(version, version)) - logging.warning("Creating the branches now means that they will diverge from {} if anything is merge into it.".format(previous_version)) - logging.warning("This is not recommanded unless necessary.") + logging.warning("The tag {} does not exist, which suggests {} has not yet been released.".format(version, version)) + logging.warning("Creating the branches now means that {} will diverge from {} if anything is merged into {}.".format(version, previous_version, previous_version)) + logging.warning("This is not recommended unless necessary.") validAnswer = False askCount = 0 From dc91b1c915847f2637e7ff53fe7fba3a16c963de Mon Sep 17 00:00:00 2001 From: Clement Date: Thu, 15 Nov 2018 18:42:15 -0800 Subject: [PATCH 3/6] CR --- tools/scripts/rc-branches.py | 161 +++++++++++++++++++---------------- 1 file changed, 86 insertions(+), 75 deletions(-) diff --git a/tools/scripts/rc-branches.py b/tools/scripts/rc-branches.py index 1d78fc0722..8db78a72bc 100755 --- a/tools/scripts/rc-branches.py +++ b/tools/scripts/rc-branches.py @@ -15,64 +15,88 @@ logging.basicConfig(format=FORMAT, level=logging.DEBUG) remote_name = "upstream" remote_master_branch = "{}/master".format(remote_name) + +class VersionParser: + """A parser for version numbers""" + def __init__(self, versionString): + # Validate that user passed a valid version + VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") + match = VERSION_RE.match(versionString) + if not match: + raise ValueError("Invalid version (should be X.Y.Z)") + + # Parse the version component and build proper version strings + self.major = int(match.group(1)) + self.minor = int(match.group(2)) + self.patch = int(match.group(3)) + self.version = "v{}.{}.{}".format(self.major, self.minor, self.patch) # clean up version + + self.is_major_release = False + self.is_minor_release = False + self.is_patch_release = False + if self.patch != 0: + self.is_patch_release = True + self.previous_version = "v{}.{}.{}".format(self.major, self.minor, self.patch - 1) + elif self.minor != 0: + self.is_minor_release = True + self.previous_version = "v{}.{}.{}".format(self.major, self.minor - 1, 0) + else: + self.is_major_release = True + self.previous_version = "v{}.{}.{}".format(self.major - 1, 0, 0) + raise ValueError("Major releases not yet supported") + + # Build the branch names + self.previous_rc_branch = "{}-rc".format(self.previous_version) + self.base_branch = "{}-rc-base".format(self.version) + self.rc_branch = "{}-rc".format(self.version) + self.remote_previous_rc_branch = "{}/{}".format(remote_name, self.previous_rc_branch) + self.remote_base_branch = "{}/{}".format(remote_name, self.base_branch) + self.remote_rc_branch = "{}/{}".format(remote_name, self.rc_branch) + + def checkVersionBranches(version): """Check that the branches for a given version were created properly.""" + parser = VersionParser(version) + major = parser.major + minor = parser.minor + patch = parser.patch + previous_version = parser.previous_version + version = parser.version + + is_major_release = parser.is_major_release + is_minor_release = parser.is_minor_release + is_patch_release = parser.is_patch_release + + remote_previous_rc_branch = parser.remote_previous_rc_branch + remote_base_branch = parser.remote_base_branch + remote_rc_branch = parser.remote_rc_branch + repo = git.Repository(git.Repository.get_base_directory()) - # Validate that user passed a valid version - VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") - match = VERSION_RE.match(version) - if not match: - raise ValueError("Invalid version (should be X.Y.Z)") - - # Parse the version component and build proper version strings - major = int(match.group(1)) - minor = int(match.group(2)) - patch = int(match.group(3)) - version = "v{}.{}.{}".format(major, minor, patch) # clean up version - - is_major_release = False - is_minor_release = False - is_patch_release = False - if patch != 0: - is_patch_release = True - previous_version = "v{}.{}.{}".format(major, minor, patch - 1) - elif minor != 0: - is_minor_release = True - previous_version = "v{}.{}.{}".format(major, minor - 1, 0) - else: - is_major_release = True - raise ValueError("Major releases not yet supported") - - # Build the branch names - previous_rc_branch = "{}/{}-rc".format(remote_name, previous_version) - base_branch = "{}/{}-rc-base".format(remote_name, version) - rc_branch = "{}/{}-rc".format(remote_name, version) - # Verify the branches' existance - if not repo.does_branch_exist(previous_rc_branch): - raise ValueError("Previous RC branch not found: {}".format(previous_rc_branch)) + if not repo.does_branch_exist(remote_previous_rc_branch): + raise ValueError("Previous RC branch not found: {}".format(remote_previous_rc_branch)) - if not repo.does_branch_exist(base_branch): - raise ValueError("Base branch not found: {}".format(base_branch)) + if not repo.does_branch_exist(remote_base_branch): + raise ValueError("Base branch not found: {}".format(remote_base_branch)) - if not repo.does_branch_exist(rc_branch): - raise ValueError("RC branch not found: {}".format(rc_branch)) + if not repo.does_branch_exist(remote_rc_branch): + raise ValueError("RC branch not found: {}".format(remote_rc_branch)) # Figure out SHA for each of the branches - previous_rc_commit = repo.git_rev_parse([previous_rc_branch]) - base_commit = repo.git_rev_parse([base_branch]) - rc_commit = repo.git_rev_parse([rc_branch]) + previous_rc_commit = repo.git_rev_parse([remote_previous_rc_branch]) + base_commit = repo.git_rev_parse([remote_base_branch]) + rc_commit = repo.git_rev_parse([remote_rc_branch]) # Check the base branch is an ancestor of the rc branch if not repo.is_ancestor(base_commit, rc_commit): - raise ValueError("{} is not an ancesctor of {}".format(base_branch, rc_branch)) + raise ValueError("{} is not an ancesctor of {}".format(remote_base_branch, remote_rc_branch)) # Check that the base branch is the merge base of the previous and current RCs merge_base = repo.get_merge_base(previous_rc_commit, rc_commit) if base_commit != merge_base: - raise ValueError("Base branch is not the merge base between {} and {}".format(previous_rc_branch, rc_branch)) + raise ValueError("Base branch is not the merge base between {} and {}".format(remote_previous_rc_branch, remote_rc_branch)) # For patch releases, warn if the base commit is not the previous RC commit if is_patch_release: @@ -86,13 +110,31 @@ def checkVersionBranches(version): # Check base branch is part of the previous RC previous_rc_base_commit = repo.get_merge_base(previous_rc_commit, remote_master_branch) if repo.is_ancestor(base_commit, previous_rc_base_commit): - raise ValueError("{} is older than {}".format(base_branch, rc_branch)) + raise ValueError("{} is older than {}".format(remote_base_branch, remote_rc_branch)) print("[SUCCESS] Checked {}".format(version)) def createVersionBranches(version): """Create the branches for a given version.""" + parser = VersionParser(version) + major = parser.major + minor = parser.minor + patch = parser.patch + previous_version = parser.previous_version + version = parser.version + + is_major_release = parser.is_major_release + is_minor_release = parser.is_minor_release + is_patch_release = parser.is_patch_release + + previous_rc_branch = parser.previous_rc_branch + base_branch = parser.base_branch + rc_branch = parser.rc_branch + remote_previous_rc_branch = parser.remote_previous_rc_branch + remote_base_branch = parser.remote_base_branch + remote_rc_branch = parser.remote_rc_branch + repo = git.Repository(git.Repository.get_base_directory()) # Validate the user is on a local branch that has the right merge base @@ -103,39 +145,6 @@ def createVersionBranches(version): if repo.is_working_tree_dirty(): raise ValueError("Your working tree has pending changes. You must have a clean working tree before proceeding.") - # Validate that user passed a valid version - VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") - match = VERSION_RE.match(version) - if not match: - raise ValueError("Invalid version (should be X.Y.Z)") - - # Parse the version component and build proper version strings - major = int(match.group(1)) - minor = int(match.group(2)) - patch = int(match.group(3)) - version = "v{}.{}.{}".format(major, minor, patch) # clean up version - - is_major_release = False - is_minor_release = False - is_patch_release = False - if patch != 0: - is_patch_release = True - previous_version = "v{}.{}.{}".format(major, minor, patch - 1) - elif minor != 0: - is_minor_release = True - previous_version = "v{}.{}.{}".format(major, minor - 1, 0) - else: - is_major_release = True - raise ValueError("Major releases not yet supported") - - # Build the branch names - previous_rc_branch = "{}-rc".format(previous_version) - base_branch = "{}-rc-base".format(version) - rc_branch = "{}-rc".format(version) - remote_previous_rc_branch = "{}/{}".format(remote_name, previous_rc_branch) - remote_base_branch = "{}/{}".format(remote_name, base_branch) - remote_rc_branch = "{}/{}".format(remote_name, rc_branch) - # Make sure the remote is up to date repo.git_fetch([remote_name]) @@ -200,6 +209,8 @@ def createVersionBranches(version): print("[SUCCESS] Created {} and {}".format(base_branch, rc_branch)) print("[SUCCESS] You can make the PR from the following webpage:") print("[SUCCESS] https://github.com/highfidelity/hifi/compare/{}...{}".format(base_branch, rc_branch)) + if is_patch_release: + print("[SUCCESS] NOTE: You will have to wait for the first fix to be merged into the RC branch to be able to create the PR") def usage(): """Print usage.""" From 75ac98bd456fe2e422846891222b06b036739858 Mon Sep 17 00:00:00 2001 From: Clement Date: Wed, 21 Nov 2018 16:55:14 -0800 Subject: [PATCH 4/6] Fix tag warning to check the previous version --- tools/scripts/rc-branches.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/scripts/rc-branches.py b/tools/scripts/rc-branches.py index 8db78a72bc..2825b81271 100755 --- a/tools/scripts/rc-branches.py +++ b/tools/scripts/rc-branches.py @@ -172,8 +172,8 @@ def createVersionBranches(version): if is_patch_release: # Check tag exists, if it doesn't, print warning and ask for comfirmation - if not repo.does_tag_exist(version): - logging.warning("The tag {} does not exist, which suggests {} has not yet been released.".format(version, version)) + if not repo.does_tag_exist(previous_version): + logging.warning("The tag {} does not exist, which suggests {} has not yet been released.".format(previous_version, previous_version)) logging.warning("Creating the branches now means that {} will diverge from {} if anything is merged into {}.".format(version, previous_version, previous_version)) logging.warning("This is not recommended unless necessary.") From d86fcbe5c6f8fb212881a24afc365670a2f44d44 Mon Sep 17 00:00:00 2001 From: Clement Date: Wed, 21 Nov 2018 16:57:24 -0800 Subject: [PATCH 5/6] Use argparse instead of the deprecated optparse --- tools/scripts/rc-branches.py | 40 ++++++++++++++---------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/tools/scripts/rc-branches.py b/tools/scripts/rc-branches.py index 2825b81271..0ae397374e 100755 --- a/tools/scripts/rc-branches.py +++ b/tools/scripts/rc-branches.py @@ -5,7 +5,7 @@ import os import re import sys -from optparse import OptionParser +import argparse from utils import git @@ -212,38 +212,28 @@ def createVersionBranches(version): if is_patch_release: print("[SUCCESS] NOTE: You will have to wait for the first fix to be merged into the RC branch to be able to create the PR") -def usage(): - """Print usage.""" - print("rc-branches.py supports the following commands:") - print("\ncheck ") - print(" - version to check of the form \"X.Y.Z\"") - print("\ncreate ") - print(" - version to create branches for of the form \"X.Y.Z\"") - def main(): """Execute Main entry point.""" global remote_name - parser = OptionParser() - parser.add_option("-r", "--remote", type="string", dest="remote_name", default=remote_name) - (options, args) = parser.parse_args(args=sys.argv) + parser = argparse.ArgumentParser(description='RC branches tool', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog='''Example commands you can run:\n%(prog)s check 0.75.0\n%(prog)s create 0.75.1''') + parser.add_argument("command", help="command to execute", choices=["check", "create"]) + parser.add_argument("version", help="version of the form \"X.Y.Z\"") + parser.add_argument("--remote", dest="remote_name", default=remote_name, + help="git remote to use as reference") + args = parser.parse_args() - remote_name = options.remote_name - - if len(args) < 3: - usage() - return - - command = args[1] - version = args[2] + remote_name = args.remote_name try: - if command == "check": - checkVersionBranches(version) - elif command == "create": - createVersionBranches(version) + if args.command == "check": + checkVersionBranches(args.version) + elif args.command == "create": + createVersionBranches(args.version) else: - usage() + parser.print_help() except Exception as ex: logging.error(ex) sys.exit(1) From cc2546d7f2b128cbf078012b54c09e190793e1a2 Mon Sep 17 00:00:00 2001 From: Clement Date: Wed, 21 Nov 2018 18:26:31 -0800 Subject: [PATCH 6/6] Switch to GitPython --- tools/scripts/Readme.md | 13 ++ tools/scripts/rc-branches.py | 156 +++++++--------- tools/scripts/requirements.txt | 1 + tools/scripts/utils/__init__.py | 1 - tools/scripts/utils/git.py | 303 -------------------------------- 5 files changed, 80 insertions(+), 394 deletions(-) create mode 100644 tools/scripts/Readme.md create mode 100644 tools/scripts/requirements.txt delete mode 100644 tools/scripts/utils/__init__.py delete mode 100644 tools/scripts/utils/git.py diff --git a/tools/scripts/Readme.md b/tools/scripts/Readme.md new file mode 100644 index 0000000000..75bdd928eb --- /dev/null +++ b/tools/scripts/Readme.md @@ -0,0 +1,13 @@ +## Setup + +Run the following command to install all the dependencies: + +```pip install -r requirements.txt``` + + +## Usage + +``` +./rc-branches.py check v0.76.1 +./rc-branches.py create v0.77.0 +``` diff --git a/tools/scripts/rc-branches.py b/tools/scripts/rc-branches.py index 0ae397374e..d72278b918 100755 --- a/tools/scripts/rc-branches.py +++ b/tools/scripts/rc-branches.py @@ -7,7 +7,7 @@ import sys import argparse -from utils import git +from git import Repo FORMAT = '[%(levelname)s] %(message)s' logging.basicConfig(format=FORMAT, level=logging.DEBUG) @@ -58,129 +58,102 @@ def checkVersionBranches(version): """Check that the branches for a given version were created properly.""" parser = VersionParser(version) - major = parser.major - minor = parser.minor - patch = parser.patch - previous_version = parser.previous_version - version = parser.version - is_major_release = parser.is_major_release - is_minor_release = parser.is_minor_release - is_patch_release = parser.is_patch_release - - remote_previous_rc_branch = parser.remote_previous_rc_branch - remote_base_branch = parser.remote_base_branch - remote_rc_branch = parser.remote_rc_branch - - repo = git.Repository(git.Repository.get_base_directory()) + repo = Repo(os.getcwd(), search_parent_directories=True) + assert not repo.bare # Verify the branches' existance - if not repo.does_branch_exist(remote_previous_rc_branch): - raise ValueError("Previous RC branch not found: {}".format(remote_previous_rc_branch)) + if parser.remote_previous_rc_branch not in repo.refs: + raise ValueError("Previous RC branch not found: {}".format(parser.remote_previous_rc_branch)) - if not repo.does_branch_exist(remote_base_branch): - raise ValueError("Base branch not found: {}".format(remote_base_branch)) + if parser.remote_base_branch not in repo.refs: + raise ValueError("Base branch not found: {}".format(parser.remote_base_branch)) - if not repo.does_branch_exist(remote_rc_branch): - raise ValueError("RC branch not found: {}".format(remote_rc_branch)) + if parser.remote_rc_branch not in repo.refs: + raise ValueError("RC branch not found: {}".format(parser.remote_rc_branch)) - # Figure out SHA for each of the branches - previous_rc_commit = repo.git_rev_parse([remote_previous_rc_branch]) - base_commit = repo.git_rev_parse([remote_base_branch]) - rc_commit = repo.git_rev_parse([remote_rc_branch]) + previous_rc = repo.refs[parser.remote_previous_rc_branch] + current_rc_base = repo.refs[parser.remote_base_branch] + current_rc = repo.refs[parser.remote_rc_branch] + master = repo.refs[remote_master_branch] # Check the base branch is an ancestor of the rc branch - if not repo.is_ancestor(base_commit, rc_commit): - raise ValueError("{} is not an ancesctor of {}".format(remote_base_branch, remote_rc_branch)) + if not repo.is_ancestor(current_rc_base, current_rc): + raise ValueError("{} is not an ancesctor of {}".format(current_rc_base, current_rc)) # Check that the base branch is the merge base of the previous and current RCs - merge_base = repo.get_merge_base(previous_rc_commit, rc_commit) - if base_commit != merge_base: - raise ValueError("Base branch is not the merge base between {} and {}".format(remote_previous_rc_branch, remote_rc_branch)) + merge_base = repo.merge_base(previous_rc, current_rc) + if current_rc_base.commit not in merge_base: + raise ValueError("Base branch is not the merge base between {} and {}".format(previous_rc, current_rc)) # For patch releases, warn if the base commit is not the previous RC commit - if is_patch_release: - if not repo.does_tag_exist(version): - logging.warning("The tag {} does not exist, which suggests {} has not been released.".format(version, version)) + if parser.is_patch_release: + if parser.previous_version not in repo.tags: + logging.warning("The tag {0} does not exist, which suggests {0} has not been released.".format(parser.previous_version)) - if base_commit != previous_rc_commit: + if current_rc_base.commit != previous_rc.commit: logging.warning("Previous version has commits not in this patch"); - logging.warning("Type \"git diff {}..{}\" to see the commit list".format(base_commit, previous_rc_commit)); + logging.warning("Type \"git diff {}..{}\" to see the commit list".format(current_rc_base, previous_rc)); # Check base branch is part of the previous RC - previous_rc_base_commit = repo.get_merge_base(previous_rc_commit, remote_master_branch) - if repo.is_ancestor(base_commit, previous_rc_base_commit): - raise ValueError("{} is older than {}".format(remote_base_branch, remote_rc_branch)) + previous_rc_base_commit = repo.merge_base(previous_rc, master) + if repo.is_ancestor(current_rc_base, previous_rc_base_commit): + raise ValueError("{} is older than {}".format(current_rc_base, previous_rc)) - print("[SUCCESS] Checked {}".format(version)) + print("[SUCCESS] Checked {}".format(parser.version)) def createVersionBranches(version): """Create the branches for a given version.""" parser = VersionParser(version) - major = parser.major - minor = parser.minor - patch = parser.patch - previous_version = parser.previous_version - version = parser.version - is_major_release = parser.is_major_release - is_minor_release = parser.is_minor_release - is_patch_release = parser.is_patch_release - - previous_rc_branch = parser.previous_rc_branch - base_branch = parser.base_branch - rc_branch = parser.rc_branch - remote_previous_rc_branch = parser.remote_previous_rc_branch - remote_base_branch = parser.remote_base_branch - remote_rc_branch = parser.remote_rc_branch - - repo = git.Repository(git.Repository.get_base_directory()) + repo = Repo(os.getcwd(), search_parent_directories=True) + assert not repo.bare # Validate the user is on a local branch that has the right merge base - if repo.is_detached(): + if repo.head.is_detached: raise ValueError("You must not run this script in a detached HEAD state") # Validate the user has no pending changes - if repo.is_working_tree_dirty(): + if repo.is_dirty(): raise ValueError("Your working tree has pending changes. You must have a clean working tree before proceeding.") # Make sure the remote is up to date - repo.git_fetch([remote_name]) + remote = repo.remotes[remote_name] + remote.fetch(prune=True) # Verify the previous RC branch exists - if not repo.does_branch_exist(remote_previous_rc_branch): - raise ValueError("Previous RC branch not found: {}".format(remote_previous_rc_branch)) + if parser.remote_previous_rc_branch not in repo.refs: + raise ValueError("Previous RC branch not found: {}".format(parser.remote_previous_rc_branch)) # Verify the branches don't already exist - if repo.does_branch_exist(remote_base_branch): - raise ValueError("Base branch already exists: {}".format(remote_base_branch)) + if parser.remote_base_branch in repo.refs: + raise ValueError("Base branch already exists: {}".format(parser.remote_base_branch)) - if repo.does_branch_exist(remote_rc_branch): - raise ValueError("RC branch already exists: {}".format(remote_rc_branch)) + if parser.remote_rc_branch in repo.refs: + raise ValueError("RC branch already exists: {}".format(parser.remote_rc_branch)) - if repo.does_branch_exist(base_branch): - raise ValueError("Base branch already exists locally: {}".format(base_branch)) + if parser.base_branch in repo.refs: + raise ValueError("Base branch already exists locally: {}".format(parser.base_branch)) - if repo.does_branch_exist(rc_branch): - raise ValueError("RC branch already exists locally: {}".format(rc_branch)) + if parser.rc_branch in repo.refs: + raise ValueError("RC branch already exists locally: {}".format(parser.rc_branch)) # Save current branch name - current_branch_name = repo.get_branch_name() + current_branch_name = repo.active_branch # Create the RC branches - if is_patch_release: - + if parser.is_patch_release: # Check tag exists, if it doesn't, print warning and ask for comfirmation - if not repo.does_tag_exist(previous_version): - logging.warning("The tag {} does not exist, which suggests {} has not yet been released.".format(previous_version, previous_version)) - logging.warning("Creating the branches now means that {} will diverge from {} if anything is merged into {}.".format(version, previous_version, previous_version)) + if parser.previous_version not in repo.tags: + logging.warning("The tag {0} does not exist, which suggests {0} has not yet been released.".format(parser.previous_version)) + logging.warning("Creating the branches now means that {0} will diverge from {1} if anything is merged into {1}.".format(parser.version, parser.previous_version)) logging.warning("This is not recommended unless necessary.") validAnswer = False askCount = 0 while not validAnswer and askCount < 3: - answer = input("Are you sure you want to do this? [y/n]").strip().lower() + answer = input("Are you sure you want to do this? [y/n] ").strip().lower() askCount += 1 validAnswer = answer == "y" or answer == "n" @@ -193,23 +166,26 @@ def createVersionBranches(version): else: print("Creating branches") - repo.git_checkout(["-b", base_branch, remote_previous_rc_branch]) - repo.push_to_remote_branch(remote_name, base_branch) - repo.git_checkout(["-b", rc_branch, remote_previous_rc_branch]) - repo.push_to_remote_branch(remote_name, rc_branch) + previous_rc = repo.refs[parser.remote_previous_rc_branch] + + repo.create_head(parser.base_branch, previous_rc) + remote.push("{0}:{0}".format(parser.base_branch)) + repo.create_head(parser.rc_branch, previous_rc) + remote.push("{0}:{0}".format(parser.rc_branch)) else: - merge_base = repo.get_merge_base(remote_previous_rc_branch, remote_master_branch) - repo.git_checkout(["-b", base_branch, merge_base]) - repo.push_to_remote_branch(remote_name, base_branch) - repo.git_checkout(["-b", rc_branch, remote_master_branch]) - repo.push_to_remote_branch(remote_name, rc_branch) + previous_rc = repo.refs[parser.remote_previous_rc_branch] + master = repo.refs[remote_master_branch] + merge_base = repo.merge_base(previous_rc, master) - repo.git_checkout([current_branch_name]) + repo.create_head(parser.base_branch, merge_base[0]) + remote.push("{0}:{0}".format(parser.base_branch)) + repo.create_head(parser.rc_branch, master) + remote.push("{0}:{0}".format(parser.rc_branch)) - print("[SUCCESS] Created {} and {}".format(base_branch, rc_branch)) + print("[SUCCESS] Created {} and {}".format(parser.base_branch, parser.rc_branch)) print("[SUCCESS] You can make the PR from the following webpage:") - print("[SUCCESS] https://github.com/highfidelity/hifi/compare/{}...{}".format(base_branch, rc_branch)) - if is_patch_release: + print("[SUCCESS] https://github.com/highfidelity/hifi/compare/{}...{}".format(parser.base_branch, parser.rc_branch)) + if parser.is_patch_release: print("[SUCCESS] NOTE: You will have to wait for the first fix to be merged into the RC branch to be able to create the PR") def main(): @@ -234,7 +210,7 @@ def main(): createVersionBranches(args.version) else: parser.print_help() - except Exception as ex: + except ValueError as ex: logging.error(ex) sys.exit(1) diff --git a/tools/scripts/requirements.txt b/tools/scripts/requirements.txt new file mode 100644 index 0000000000..64b1adaeeb --- /dev/null +++ b/tools/scripts/requirements.txt @@ -0,0 +1 @@ +GitPython diff --git a/tools/scripts/utils/__init__.py b/tools/scripts/utils/__init__.py deleted file mode 100644 index 4b7a2bb941..0000000000 --- a/tools/scripts/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Empty.""" diff --git a/tools/scripts/utils/git.py b/tools/scripts/utils/git.py deleted file mode 100644 index 156d2240c3..0000000000 --- a/tools/scripts/utils/git.py +++ /dev/null @@ -1,303 +0,0 @@ -"""Module to run git commands on a repository.""" - -# Copied from https://github.com/mongodb/mongo under Apache 2.0 -# Modified by Clement Brisset on 11/14/18. - -import logging -import os -import sys - -# The subprocess32 module resolves the thread-safety issues of the subprocess module in Python 2.x -# when the _posixsubprocess C extension module is also available. Additionally, the _posixsubprocess -# C extension module avoids triggering invalid free() calls on Python's internal data structure for -# thread-local storage by skipping the PyOS_AfterFork() call when the 'preexec_fn' parameter isn't -# specified to subprocess.Popen(). See SERVER-22219 for more details. -# -# The subprocess32 module is untested on Windows and thus isn't recommended for use, even when it's -# installed. See https://github.com/google/python-subprocess32/blob/3.2.7/README.md#usage. -if os.name == "posix" and sys.version_info[0] == 2: - try: - import subprocess32 as subprocess - except ImportError: - import warnings - warnings.warn(("Falling back to using the subprocess module because subprocess32 isn't" - " available. When using the subprocess module, a child process may trigger" - " an invalid free(). See SERVER-22219 for more details."), RuntimeWarning) - import subprocess # type: ignore -else: - import subprocess - -LOGGER = logging.getLogger(__name__) - - -class Repository(object): # pylint: disable=too-many-public-methods - """Represent a local git repository.""" - - def __init__(self, directory): - """Initialize Repository.""" - self.directory = directory - - def git_add(self, args): - """Run a git add command.""" - return self._callgito("add", args) - - def git_cat_file(self, args): - """Run a git cat-file command.""" - return self._callgito("cat-file", args) - - def git_checkout(self, args): - """Run a git checkout command.""" - return self._callgito("checkout", args) - - def git_commit(self, args): - """Run a git commit command.""" - return self._callgito("commit", args) - - def git_diff(self, args): - """Run a git diff command.""" - return self._callgito("diff", args) - - def git_log(self, args): - """Run a git log command.""" - return self._callgito("log", args) - - def git_push(self, args): - """Run a git push command.""" - return self._callgito("push", args) - - def git_fetch(self, args): - """Run a git fetch command.""" - return self._callgito("fetch", args) - - def git_ls_files(self, args): - """Run a git ls-files command and return the result as a str.""" - return self._callgito("ls-files", args) - - def git_rebase(self, args): - """Run a git rebase command.""" - return self._callgito("rebase", args) - - def git_reset(self, args): - """Run a git reset command.""" - return self._callgito("reset", args) - - def git_rev_list(self, args): - """Run a git rev-list command.""" - return self._callgito("rev-list", args) - - def git_rev_parse(self, args): - """Run a git rev-parse command.""" - return self._callgito("rev-parse", args).rstrip() - - def git_rm(self, args): - """Run a git rm command.""" - return self._callgito("rm", args) - - def git_show(self, args): - """Run a git show command.""" - return self._callgito("show", args) - - def get_origin_url(self): - """Return the URL of the origin repository.""" - return self._callgito("config", ["--local", "--get", "remote.origin.url"]).rstrip() - - def get_branch_name(self): - """ - Get the current branch name, short form. - - This returns "master", not "refs/head/master". - Raises a GitException if the current branch is detached. - """ - branch = self.git_rev_parse(["--abbrev-ref", "HEAD"]) - if branch == "HEAD": - raise GitException("Branch is currently detached") - return branch - - def get_current_revision(self): - """Retrieve the current revision of the repository.""" - return self.git_rev_parse(["HEAD"]).rstrip() - - def configure(self, parameter, value): - """Set a local configuration parameter.""" - return self._callgito("config", ["--local", parameter, value]) - - def is_detached(self): - """Return True if the current working tree in a detached HEAD state.""" - # symbolic-ref returns 1 if the repo is in a detached HEAD state - return self._callgit("symbolic-ref", ["--quiet", "HEAD"]) == 1 - - def is_ancestor(self, parent_revision, child_revision): - """Return True if the specified parent hash an ancestor of child hash.""" - # If the common point between parent_revision and child_revision is - # parent_revision, then parent_revision is an ancestor of child_revision. - merge_base = self._callgito("merge-base", [parent_revision, child_revision]).rstrip() - return parent_revision == merge_base - - def is_commit(self, revision): - """Return True if the specified hash is a valid git commit.""" - # cat-file -e returns 0 if it is a valid hash - return not self._callgit("cat-file", ["-e", "{0}^{{commit}}".format(revision)]) - - def is_working_tree_dirty(self): - """Return True if the current working tree has changes.""" - # diff returns 1 if the working tree has local changes - return self._callgit("diff", ["--quiet"]) == 1 - - def does_branch_exist(self, branch): - """Return True if the branch exists.""" - # rev-parse returns 0 if the branch exists - return not self._callgit("rev-parse", ["--verify", "--quiet", branch]) - - def does_tag_exist(self, tag): - """Return True if the tag exists.""" - # rev-parse returns 0 if the tag exists - return not self._callgit("rev-parse", ["--verify", "--quiet", tag]) - - def get_merge_base(self, commit1, commit2 = "HEAD"): - """Get the merge base between 'commit' and HEAD.""" - return self._callgito("merge-base", [commit1, commit2]).rstrip() - - def commit_with_message(self, message): - """Commit the staged changes with the given message.""" - return self.git_commit(["--message", message]) - - def push_to_remote_branch(self, remote, remote_branch): - """Push the current branch to the specified remote repository and branch.""" - refspec = "{}:{}".format(self.get_branch_name(), remote_branch) - return self.git_push([remote, refspec]) - - def fetch_remote_branch(self, repository, branch): - """Fetch the changes from a remote branch.""" - return self.git_fetch([repository, branch]) - - def rebase_from_upstream(self, upstream, ignore_date=False): - """Rebase the repository on an upstream reference. - - If 'ignore_date' is True, the '--ignore-date' option is passed to git. - """ - args = [upstream] - if ignore_date: - args.append("--ignore-date") - return self.git_rebase(args) - - @staticmethod - def clone(url, directory, branch=None, depth=None): - """Clone the repository designed by 'url' into 'directory'. - - Return a Repository instance. - """ - params = ["git", "clone"] - if branch: - params += ["--branch", branch] - if depth: - params += ["--depth", depth] - params += [url, directory] - result = Repository._run_process("clone", params) - result.check_returncode() - return Repository(directory) - - @staticmethod - def get_base_directory(directory=None): - """Return the base directory of the repository the given directory belongs to. - - If no directory is specified, then the current working directory is used. - """ - if directory is not None: - params = ["git", "-C", directory] - else: - params = ["git"] - params.extend(["rev-parse", "--show-toplevel"]) - result = Repository._run_process("rev-parse", params) - result.check_returncode() - return os.path.normpath(result.stdout.rstrip()) - - @staticmethod - def current_repository(): - """Return the Repository the current working directory belongs to.""" - return Repository(Repository.get_base_directory()) - - def _callgito(self, cmd, args): - """Call git for this repository, and return the captured output.""" - result = self._run_cmd(cmd, args) - result.check_returncode() - return result.stdout - - def _callgit(self, cmd, args, raise_exception=False): - """ - Call git for this repository without capturing output. - - This is designed to be used when git returns non-zero exit codes. - """ - result = self._run_cmd(cmd, args) - if raise_exception: - result.check_returncode() - return result.returncode - - def _run_cmd(self, cmd, args): - """Run the git command and return a GitCommandResult instance.""" - - LOGGER.debug("Running: git {} {}".format(cmd, " ".join(args))) - params = ["git", cmd] + args - return self._run_process(cmd, params, cwd=self.directory) - - @staticmethod - def _run_process(cmd, params, cwd=None): - process = subprocess.Popen(params, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd) - (stdout, stderr) = process.communicate() - if process.returncode: - if stdout: - LOGGER.error("Output of '%s': %s", " ".join(params), stdout.rstrip()) - if stderr: - LOGGER.error("Error output of '%s': %s", " ".join(params), stderr.rstrip()) - return GitCommandResult(cmd, params, process.returncode, stdout=stdout, stderr=stderr) - - -class GitException(Exception): - """Custom Exception for the git module. - - Args: - message: the exception message. - returncode: the return code of the failed git command, if any. - cmd: the git subcommand that was run, if any. - process_args: a list containing the git command and arguments (includes 'git' as its first - element) that were run, if any. - stderr: the error output of the git command. - """ - - def __init__( # pylint: disable=too-many-arguments - self, message, returncode=None, cmd=None, process_args=None, stdout=None, stderr=None): - """Initialize GitException.""" - Exception.__init__(self, message) - self.returncode = returncode - self.cmd = cmd - self.process_args = process_args - self.stdout = stdout - self.stderr = stderr - - -class GitCommandResult(object): - """The result of running git subcommand. - - Args: - cmd: the git subcommand that was executed (e.g. 'clone', 'diff'). - process_args: the full list of process arguments, starting with the 'git' command. - returncode: the return code. - stdout: the output of the command. - stderr: the error output of the command. - """ - - def __init__( # pylint: disable=too-many-arguments - self, cmd, process_args, returncode, stdout=None, stderr=None): - """Initialize GitCommandResult.""" - self.cmd = cmd - self.process_args = process_args - self.returncode = returncode - self.stdout = stdout - self.stderr = stderr - - def check_returncode(self): - """Raise GitException if the exit code is non-zero.""" - if self.returncode: - raise GitException("Command '{0}' failed with code '{1}'".format( - " ".join(self.process_args), self.returncode), self.returncode, self.cmd, - self.process_args, self.stdout, self.stderr)