1"""Module to run git commands on a repository.""" 2 3from __future__ import absolute_import 4 5import logging 6import os 7import sys 8 9# The subprocess32 module resolves the thread-safety issues of the subprocess module in Python 2.x 10# when the _posixsubprocess C extension module is also available. Additionally, the _posixsubprocess 11# C extension module avoids triggering invalid free() calls on Python's internal data structure for 12# thread-local storage by skipping the PyOS_AfterFork() call when the 'preexec_fn' parameter isn't 13# specified to subprocess.Popen(). See SERVER-22219 for more details. 14# 15# The subprocess32 module is untested on Windows and thus isn't recommended for use, even when it's 16# installed. See https://github.com/google/python-subprocess32/blob/3.2.7/README.md#usage. 17if os.name == "posix" and sys.version_info[0] == 2: 18 try: 19 import subprocess32 as subprocess 20 except ImportError: 21 import warnings 22 warnings.warn(("Falling back to using the subprocess module because subprocess32 isn't" 23 " available. When using the subprocess module, a child process may trigger" 24 " an invalid free(). See SERVER-22219 for more details."), 25 RuntimeWarning) 26 import subprocess 27else: 28 import subprocess 29 30 31LOGGER = logging.getLogger(__name__) 32 33 34class Repository(object): 35 """Represent a local git repository.""" 36 def __init__(self, directory): 37 self.directory = directory 38 39 def git_add(self, args): 40 """Run a git add command.""" 41 return self._callgito("add", args) 42 43 def git_cat_file(self, args): 44 """Run a git cat-file command.""" 45 return self._callgito("cat-file", args) 46 47 def git_commit(self, args): 48 """Run a git commit command.""" 49 return self._callgito("commit", args) 50 51 def git_diff(self, args): 52 """Run a git diff command.""" 53 return self._callgito("diff", args) 54 55 def git_log(self, args): 56 """Run a git log command.""" 57 return self._callgito("log", args) 58 59 def git_push(self, args): 60 """Run a git push command.""" 61 return self._callgito("push", args) 62 63 def git_fetch(self, args): 64 """Run a git fetch command.""" 65 return self._callgito("fetch", args) 66 67 def git_ls_files(self, args): 68 """Run a git ls-files command and return the result as a str.""" 69 return self._callgito("ls-files", args) 70 71 def git_rebase(self, args): 72 """Run a git rebase command.""" 73 return self._callgito("rebase", args) 74 75 def git_reset(self, args): 76 """Run a git reset command.""" 77 return self._callgito("reset", args) 78 79 def git_rev_list(self, args): 80 """Run a git rev-list command.""" 81 return self._callgito("rev-list", args) 82 83 def git_rev_parse(self, args): 84 """Run a git rev-parse command.""" 85 return self._callgito("rev-parse", args).rstrip() 86 87 def git_rm(self, args): 88 """Run a git rm command.""" 89 return self._callgito("rm", args) 90 91 def git_show(self, args): 92 """Run a git show command.""" 93 return self._callgito("show", args) 94 95 def get_origin_url(self): 96 """Return the URL of the origin repository.""" 97 return self._callgito( 98 "config", ["--local", "--get", "remote.origin.url"]).rstrip() 99 100 def get_branch_name(self): 101 """ 102 Get the current branch name, short form. 103 104 This returns "master", not "refs/head/master". 105 Raises a GitException if the current branch is detached. 106 """ 107 branch = self.git_rev_parse(["--abbrev-ref", "HEAD"]) 108 if branch == "HEAD": 109 raise GitException("Branch is currently detached") 110 return branch 111 112 def get_current_revision(self): 113 """Retrieve the current revision of the repository.""" 114 return self.git_rev_parse(["HEAD"]).rstrip() 115 116 def configure(self, parameter, value): 117 """Set a local configuration parameter.""" 118 return self._callgito("config", ["--local", parameter, value]) 119 120 def is_detached(self): 121 """Return True if the current working tree in a detached HEAD state.""" 122 # symbolic-ref returns 1 if the repo is in a detached HEAD state 123 return self._callgit("symbolic-ref", ["--quiet", "HEAD"]) == 1 124 125 def is_ancestor(self, parent_revision, child_revision): 126 """Return True if the specified parent hash an ancestor of child hash.""" 127 # If the common point between parent_revision and child_revision is 128 # parent_revision, then parent_revision is an ancestor of child_revision. 129 merge_base = self._callgito("merge-base", [parent_revision, 130 child_revision]).rstrip() 131 return parent_revision == merge_base 132 133 def is_commit(self, revision): 134 """Return True if the specified hash is a valid git commit.""" 135 # cat-file -e returns 0 if it is a valid hash 136 return not self._callgit("cat-file", ["-e", "{0}^{{commit}}".format(revision)]) 137 138 def is_working_tree_dirty(self): 139 """Return True if the current working tree has changes.""" 140 # diff returns 1 if the working tree has local changes 141 return self._callgit("diff", ["--quiet"]) == 1 142 143 def does_branch_exist(self, branch): 144 """Return True if the branch exists.""" 145 # rev-parse returns 0 if the branch exists 146 return not self._callgit("rev-parse", ["--verify", branch]) 147 148 def get_merge_base(self, commit): 149 """Get the merge base between 'commit' and HEAD.""" 150 return self._callgito("merge-base", ["HEAD", commit]).rstrip() 151 152 def commit_with_message(self, message): 153 """Commit the staged changes with the given message.""" 154 return self.git_commit(["--message", message]) 155 156 def push_to_remote_branch(self, remote, remote_branch): 157 """Push the current branch to the specified remote repository and branch.""" 158 refspec = "{}:{}".format(self.get_branch_name(), remote_branch) 159 return self.git_push([remote, refspec]) 160 161 def fetch_remote_branch(self, repository, branch): 162 """Fetch the changes from a remote branch.""" 163 return self.git_fetch([repository, branch]) 164 165 def rebase_from_upstream(self, upstream, ignore_date=False): 166 """Rebase the repository on an upstream reference. 167 168 If 'ignore_date' is True, the '--ignore-date' option is passed to git. 169 """ 170 args = [upstream] 171 if ignore_date: 172 args.append("--ignore-date") 173 return self.git_rebase(args) 174 175 @staticmethod 176 def clone(url, directory, branch=None, depth=None): 177 """Clone the repository designed by 'url' into 'directory'. 178 179 Return a Repository instance.""" 180 params = ["git", "clone"] 181 if branch: 182 params += ["--branch", branch] 183 if depth: 184 params += ["--depth", depth] 185 params += [url, directory] 186 result = Repository._run_process("clone", params) 187 result.check_returncode() 188 return Repository(directory) 189 190 @staticmethod 191 def get_base_directory(directory=None): 192 """Return the base directory of the repository the given directory belongs to. 193 194 If no directory is specified, then the current working directory is used.""" 195 if directory is not None: 196 params = ["git", "-C", directory] 197 else: 198 params = ["git"] 199 params.extend(["rev-parse", "--show-toplevel"]) 200 result = Repository._run_process("rev-parse", params) 201 result.check_returncode() 202 return result.stdout.rstrip() 203 204 @staticmethod 205 def current_repository(): 206 """Return the Repository the current working directory belongs to.""" 207 return Repository(Repository.get_base_directory()) 208 209 def _callgito(self, cmd, args): 210 """Call git for this repository, and return the captured output.""" 211 result = self._run_cmd(cmd, args) 212 result.check_returncode() 213 return result.stdout 214 215 def _callgit(self, cmd, args, raise_exception=False): 216 """ 217 Call git for this repository without capturing output. 218 219 This is designed to be used when git returns non-zero exit codes. 220 """ 221 result = self._run_cmd(cmd, args) 222 if raise_exception: 223 result.check_returncode() 224 return result.returncode 225 226 def _run_cmd(self, cmd, args): 227 """Run the git command and return a GitCommandResult instance. 228 """ 229 230 params = ["git", cmd] + args 231 return self._run_process(cmd, params, cwd=self.directory) 232 233 @staticmethod 234 def _run_process(cmd, params, cwd=None): 235 process = subprocess.Popen(params, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd) 236 (stdout, stderr) = process.communicate() 237 if process.returncode: 238 if stdout: 239 LOGGER.error("Output of '%s': %s", " ".join(params), stdout) 240 if stderr: 241 LOGGER.error("Error output of '%s': %s", " ".join(params), stderr) 242 return GitCommandResult(cmd, params, process.returncode, stdout=stdout, stderr=stderr) 243 244 245class GitException(Exception): 246 """Custom Exception for the git module. 247 248 Args: 249 message: the exception message. 250 returncode: the return code of the failed git command, if any. 251 cmd: the git subcommand that was run, if any. 252 process_args: a list containing the git command and arguments (includes 'git' as its first 253 element) that were run, if any. 254 stderr: the error output of the git command. 255 """ 256 def __init__(self, message, returncode=None, cmd=None, process_args=None, 257 stdout=None, stderr=None): 258 Exception.__init__(self, message) 259 self.returncode = returncode 260 self.cmd = cmd 261 self.process_args = process_args 262 self.stdout = stdout 263 self.stderr = stderr 264 265 266class GitCommandResult(object): 267 """The result of running git subcommand. 268 269 Args: 270 cmd: the git subcommand that was executed (e.g. 'clone', 'diff'). 271 process_args: the full list of process arguments, starting with the 'git' command. 272 returncode: the return code. 273 stdout: the output of the command. 274 stderr: the error output of the command. 275 """ 276 277 def __init__(self, cmd, process_args, returncode, stdout=None, stderr=None): 278 self.cmd = cmd 279 self.process_args = process_args 280 self.returncode = returncode 281 self.stdout = stdout 282 self.stderr = stderr 283 284 def check_returncode(self): 285 """Raise GitException if the exit code is non-zero.""" 286 if self.returncode: 287 raise GitException( 288 "Command '{0}' failed with code '{1}'".format(" ".join(self.process_args), 289 self.returncode), 290 self.returncode, self.cmd, self.process_args, self.stdout, self.stderr) 291