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