Home | History | Annotate | Download | only in scm
      1 # Copyright (c) 2009, 2010, 2011 Google Inc. All rights reserved.
      2 # Copyright (c) 2009 Apple Inc. All rights reserved.
      3 #
      4 # Redistribution and use in source and binary forms, with or without
      5 # modification, are permitted provided that the following conditions are
      6 # met:
      7 #
      8 #     * Redistributions of source code must retain the above copyright
      9 # notice, this list of conditions and the following disclaimer.
     10 #     * Redistributions in binary form must reproduce the above
     11 # copyright notice, this list of conditions and the following disclaimer
     12 # in the documentation and/or other materials provided with the
     13 # distribution.
     14 #     * Neither the name of Google Inc. nor the names of its
     15 # contributors may be used to endorse or promote products derived from
     16 # this software without specific prior written permission.
     17 #
     18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
     19 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
     20 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
     21 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
     22 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
     23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
     24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
     25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
     26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
     27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
     28 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
     29 
     30 import datetime
     31 import logging
     32 import os
     33 import re
     34 
     35 from webkitpy.common.memoized import memoized
     36 from webkitpy.common.system.executive import Executive, ScriptError
     37 
     38 from .commitmessage import CommitMessage
     39 from .scm import AuthenticationError, SCM, commit_error_handler
     40 from .svn import SVN, SVNRepository
     41 
     42 _log = logging.getLogger(__name__)
     43 
     44 
     45 class AmbiguousCommitError(Exception):
     46     def __init__(self, num_local_commits, has_working_directory_changes):
     47         Exception.__init__(self, "Found %s local commits and the working directory is %s" % (
     48             num_local_commits, ["clean", "not clean"][has_working_directory_changes]))
     49         self.num_local_commits = num_local_commits
     50         self.has_working_directory_changes = has_working_directory_changes
     51 
     52 
     53 class Git(SCM, SVNRepository):
     54 
     55     # Git doesn't appear to document error codes, but seems to return
     56     # 1 or 128, mostly.
     57     ERROR_FILE_IS_MISSING = 128
     58 
     59     executable_name = 'git'
     60 
     61     def __init__(self, cwd, **kwargs):
     62         SCM.__init__(self, cwd, **kwargs)
     63         self._check_git_architecture()
     64 
     65     def _machine_is_64bit(self):
     66         import platform
     67         # This only is tested on Mac.
     68         if not platform.mac_ver()[0]:
     69             return False
     70 
     71         # platform.architecture()[0] can be '64bit' even if the machine is 32bit:
     72         # http://mail.python.org/pipermail/pythonmac-sig/2009-September/021648.html
     73         # Use the sysctl command to find out what the processor actually supports.
     74         return self.run(['sysctl', '-n', 'hw.cpu64bit_capable']).rstrip() == '1'
     75 
     76     def _executable_is_64bit(self, path):
     77         # Again, platform.architecture() fails us.  On my machine
     78         # git_bits = platform.architecture(executable=git_path, bits='default')[0]
     79         # git_bits is just 'default', meaning the call failed.
     80         file_output = self.run(['file', path])
     81         return re.search('x86_64', file_output)
     82 
     83     def _check_git_architecture(self):
     84         if not self._machine_is_64bit():
     85             return
     86 
     87         # We could path-search entirely in python or with
     88         # which.py (http://code.google.com/p/which), but this is easier:
     89         git_path = self.run(['which', self.executable_name]).rstrip()
     90         if self._executable_is_64bit(git_path):
     91             return
     92 
     93         webkit_dev_thread_url = "https://lists.webkit.org/pipermail/webkit-dev/2010-December/015287.html"
     94         _log.warning("This machine is 64-bit, but the git binary (%s) does not support 64-bit.\nInstall a 64-bit git for better performance, see:\n%s\n" % (git_path, webkit_dev_thread_url))
     95 
     96     def _run_git(self, command_args, **kwargs):
     97         full_command_args = [self.executable_name] + command_args
     98         full_kwargs = kwargs
     99         if not 'cwd' in full_kwargs:
    100             full_kwargs['cwd'] = self.checkout_root
    101         return self.run(full_command_args, **full_kwargs)
    102 
    103     @classmethod
    104     def in_working_directory(cls, path, executive=None):
    105         try:
    106             executive = executive or Executive()
    107             return executive.run_command([cls.executable_name, 'rev-parse', '--is-inside-work-tree'], cwd=path, error_handler=Executive.ignore_error).rstrip() == "true"
    108         except OSError, e:
    109             # The Windows bots seem to through a WindowsError when git isn't installed.
    110             return False
    111 
    112     def find_checkout_root(self, path):
    113         # "git rev-parse --show-cdup" would be another way to get to the root
    114         checkout_root = self._run_git(['rev-parse', '--show-toplevel'], cwd=(path or "./")).strip()
    115         if not self._filesystem.isabs(checkout_root):  # Sometimes git returns relative paths
    116             checkout_root = self._filesystem.join(path, checkout_root)
    117         return checkout_root
    118 
    119     def to_object_name(self, filepath):
    120         # FIXME: This can't be the right way to append a slash.
    121         root_end_with_slash = self._filesystem.join(self.find_checkout_root(self._filesystem.dirname(filepath)), '')
    122         # FIXME: This seems to want some sort of rel_path instead?
    123         return filepath.replace(root_end_with_slash, '')
    124 
    125     @classmethod
    126     def read_git_config(cls, key, cwd=None, executive=None):
    127         # FIXME: This should probably use cwd=self.checkout_root.
    128         # Pass --get-all for cases where the config has multiple values
    129         # Pass the cwd if provided so that we can handle the case of running webkit-patch outside of the working directory.
    130         # FIXME: This should use an Executive.
    131         executive = executive or Executive()
    132         return executive.run_command([cls.executable_name, "config", "--get-all", key], error_handler=Executive.ignore_error, cwd=cwd).rstrip('\n')
    133 
    134     @staticmethod
    135     def commit_success_regexp():
    136         return "^Committed r(?P<svn_revision>\d+)$"
    137 
    138     def discard_local_commits(self):
    139         self._run_git(['reset', '--hard', self.remote_branch_ref()])
    140 
    141     def local_commits(self, ref='HEAD'):
    142         return self._run_git(['log', '--pretty=oneline', ref + '...' + self.remote_branch_ref()]).splitlines()
    143 
    144     def rebase_in_progress(self):
    145         return self._filesystem.exists(self.absolute_path(self._filesystem.join('.git', 'rebase-apply')))
    146 
    147     def has_working_directory_changes(self):
    148         return self._run_git(['diff', 'HEAD', '--no-renames', '--name-only']) != ""
    149 
    150     def discard_working_directory_changes(self):
    151         # Could run git clean here too, but that wouldn't match subversion
    152         self._run_git(['reset', 'HEAD', '--hard'])
    153         # Aborting rebase even though this does not match subversion
    154         if self.rebase_in_progress():
    155             self._run_git(['rebase', '--abort'])
    156 
    157     def status_command(self):
    158         # git status returns non-zero when there are changes, so we use git diff name --name-status HEAD instead.
    159         # No file contents printed, thus utf-8 autodecoding in self.run is fine.
    160         return [self.executable_name, "diff", "--name-status", "--no-renames", "HEAD"]
    161 
    162     def _status_regexp(self, expected_types):
    163         return '^(?P<status>[%s])\t(?P<filename>.+)$' % expected_types
    164 
    165     def add_list(self, paths, return_exit_code=False):
    166         return self._run_git(["add"] + paths, return_exit_code=return_exit_code)
    167 
    168     def delete_list(self, paths):
    169         return self._run_git(["rm", "-f"] + paths)
    170 
    171     def move(self, origin, destination):
    172         return self._run_git(["mv", "-f", origin, destination])
    173 
    174     def exists(self, path):
    175         return_code = self._run_git(["show", "HEAD:%s" % path], return_exit_code=True, decode_output=False)
    176         return return_code != self.ERROR_FILE_IS_MISSING
    177 
    178     def _branch_from_ref(self, ref):
    179         return ref.replace('refs/heads/', '')
    180 
    181     def current_branch(self):
    182         return self._branch_from_ref(self._run_git(['symbolic-ref', '-q', 'HEAD']).strip())
    183 
    184     def _upstream_branch(self):
    185         current_branch = self.current_branch()
    186         return self._branch_from_ref(self.read_git_config('branch.%s.merge' % current_branch, cwd=self.checkout_root, executive=self._executive).strip())
    187 
    188     def merge_base(self, git_commit):
    189         if git_commit:
    190             # Rewrite UPSTREAM to the upstream branch
    191             if 'UPSTREAM' in git_commit:
    192                 upstream = self._upstream_branch()
    193                 if not upstream:
    194                     raise ScriptError(message='No upstream/tracking branch set.')
    195                 git_commit = git_commit.replace('UPSTREAM', upstream)
    196 
    197             # Special-case <refname>.. to include working copy changes, e.g., 'HEAD....' shows only the diffs from HEAD.
    198             if git_commit.endswith('....'):
    199                 return git_commit[:-4]
    200 
    201             if '..' not in git_commit:
    202                 git_commit = git_commit + "^.." + git_commit
    203             return git_commit
    204 
    205         return self.remote_merge_base()
    206 
    207     def changed_files(self, git_commit=None):
    208         # FIXME: --diff-filter could be used to avoid the "extract_filenames" step.
    209         status_command = [self.executable_name, 'diff', '-r', '--name-status', "--no-renames", "--no-ext-diff", "--full-index", self.merge_base(git_commit)]
    210         # FIXME: I'm not sure we're returning the same set of files that SVN.changed_files is.
    211         # Added (A), Copied (C), Deleted (D), Modified (M), Renamed (R)
    212         return self.run_status_and_extract_filenames(status_command, self._status_regexp("ADM"))
    213 
    214     def _changes_files_for_commit(self, git_commit):
    215         # --pretty="format:" makes git show not print the commit log header,
    216         changed_files = self._run_git(["show", "--pretty=format:", "--name-only", git_commit]).splitlines()
    217         # instead it just prints a blank line at the top, so we skip the blank line:
    218         return changed_files[1:]
    219 
    220     def changed_files_for_revision(self, revision):
    221         commit_id = self.git_commit_from_svn_revision(revision)
    222         return self._changes_files_for_commit(commit_id)
    223 
    224     def revisions_changing_file(self, path, limit=5):
    225         # raise a script error if path does not exists to match the behavior of  the svn implementation.
    226         if not self._filesystem.exists(path):
    227             raise ScriptError(message="Path %s does not exist." % path)
    228 
    229         # git rev-list head --remove-empty --limit=5 -- path would be equivalent.
    230         commit_ids = self._run_git(["log", "--remove-empty", "--pretty=format:%H", "-%s" % limit, "--", path]).splitlines()
    231         return filter(lambda revision: revision, map(self.svn_revision_from_git_commit, commit_ids))
    232 
    233     def conflicted_files(self):
    234         # We do not need to pass decode_output for this diff command
    235         # as we're passing --name-status which does not output any data.
    236         status_command = [self.executable_name, 'diff', '--name-status', '--no-renames', '--diff-filter=U']
    237         return self.run_status_and_extract_filenames(status_command, self._status_regexp("U"))
    238 
    239     def added_files(self):
    240         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A"))
    241 
    242     def deleted_files(self):
    243         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D"))
    244 
    245     @staticmethod
    246     def supports_local_commits():
    247         return True
    248 
    249     def display_name(self):
    250         return "git"
    251 
    252     def _most_recent_log_matching(self, grep_str, path):
    253         # We use '--grep=' + foo rather than '--grep', foo because
    254         # git 1.7.0.4 (and earlier) didn't support the separate arg.
    255         return self._run_git(['log', '-1', '--grep=' + grep_str, '--date=iso', self.find_checkout_root(path)])
    256 
    257     def svn_revision(self, path):
    258         git_log = self._most_recent_log_matching('git-svn-id:', path)
    259         match = re.search("^\s*git-svn-id:.*@(?P<svn_revision>\d+)\ ", git_log, re.MULTILINE)
    260         if not match:
    261             return ""
    262         return str(match.group('svn_revision'))
    263 
    264     def timestamp_of_revision(self, path, revision):
    265         git_log = self._most_recent_log_matching('git-svn-id:.*@%s' % revision, path)
    266         match = re.search("^Date:\s*(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$", git_log, re.MULTILINE)
    267         if not match:
    268             return ""
    269 
    270         # Manually modify the timezone since Git doesn't have an option to show it in UTC.
    271         # Git also truncates milliseconds but we're going to ignore that for now.
    272         time_with_timezone = datetime.datetime(int(match.group(1)), int(match.group(2)), int(match.group(3)),
    273             int(match.group(4)), int(match.group(5)), int(match.group(6)), 0)
    274 
    275         sign = 1 if match.group(7) == '+' else -1
    276         time_without_timezone = time_with_timezone - datetime.timedelta(hours=sign * int(match.group(8)), minutes=int(match.group(9)))
    277         return time_without_timezone.strftime('%Y-%m-%dT%H:%M:%SZ')
    278 
    279     def prepend_svn_revision(self, diff):
    280         revision = self.head_svn_revision()
    281         if not revision:
    282             return diff
    283 
    284         return "Subversion Revision: " + revision + '\n' + diff
    285 
    286     def create_patch(self, git_commit=None, changed_files=None):
    287         """Returns a byte array (str()) representing the patch file.
    288         Patch files are effectively binary since they may contain
    289         files of multiple different encodings."""
    290 
    291         # Put code changes at the top of the patch and layout tests
    292         # at the bottom, this makes for easier reviewing.
    293         config_path = self._filesystem.dirname(self._filesystem.path_to_module('webkitpy.common.config'))
    294         order_file = self._filesystem.join(config_path, 'orderfile')
    295         order = ""
    296         if self._filesystem.exists(order_file):
    297             order = "-O%s" % order_file
    298 
    299         command = [self.executable_name, 'diff', '--binary', '--no-color', "--no-ext-diff", "--full-index", "--no-renames", order, self.merge_base(git_commit), "--"]
    300         if changed_files:
    301             command += changed_files
    302         return self.prepend_svn_revision(self.run(command, decode_output=False, cwd=self.checkout_root))
    303 
    304     def _run_git_svn_find_rev(self, arg):
    305         # git svn find-rev always exits 0, even when the revision or commit is not found.
    306         return self._run_git(['svn', 'find-rev', arg]).rstrip()
    307 
    308     def _string_to_int_or_none(self, string):
    309         try:
    310             return int(string)
    311         except ValueError, e:
    312             return None
    313 
    314     @memoized
    315     def git_commit_from_svn_revision(self, svn_revision):
    316         # FIXME: https://bugs.webkit.org/show_bug.cgi?id=111668
    317         # We should change this to run git log --grep 'git-svn-id' instead
    318         # so that we don't require git+svn to be set up.
    319         git_commit = self._run_git_svn_find_rev('r%s' % svn_revision)
    320         if not git_commit:
    321             # FIXME: Alternatively we could offer to update the checkout? Or return None?
    322             raise ScriptError(message='Failed to find git commit for revision %s, your checkout likely needs an update.' % svn_revision)
    323         return git_commit
    324 
    325     @memoized
    326     def svn_revision_from_git_commit(self, git_commit):
    327         svn_revision = self._run_git_svn_find_rev(git_commit)
    328         return self._string_to_int_or_none(svn_revision)
    329 
    330     def contents_at_revision(self, path, revision):
    331         """Returns a byte array (str()) containing the contents
    332         of path @ revision in the repository."""
    333         return self._run_git(["show", "%s:%s" % (self.git_commit_from_svn_revision(revision), path)], decode_output=False)
    334 
    335     def diff_for_revision(self, revision):
    336         git_commit = self.git_commit_from_svn_revision(revision)
    337         return self.create_patch(git_commit)
    338 
    339     def diff_for_file(self, path, log=None):
    340         return self._run_git(['diff', 'HEAD', '--no-renames', '--', path])
    341 
    342     def show_head(self, path):
    343         return self._run_git(['show', 'HEAD:' + self.to_object_name(path)], decode_output=False)
    344 
    345     def committer_email_for_revision(self, revision):
    346         git_commit = self.git_commit_from_svn_revision(revision)
    347         committer_email = self._run_git(["log", "-1", "--pretty=format:%ce", git_commit])
    348         # Git adds an extra @repository_hash to the end of every committer email, remove it:
    349         return committer_email.rsplit("@", 1)[0]
    350 
    351     def apply_reverse_diff(self, revision):
    352         # Assume the revision is an svn revision.
    353         git_commit = self.git_commit_from_svn_revision(revision)
    354         # I think this will always fail due to ChangeLogs.
    355         self._run_git(['revert', '--no-commit', git_commit], error_handler=Executive.ignore_error)
    356 
    357     def revert_files(self, file_paths):
    358         self._run_git(['checkout', 'HEAD'] + file_paths)
    359 
    360     def _assert_can_squash(self, has_working_directory_changes):
    361         squash = self.read_git_config('webkit-patch.commit-should-always-squash', cwd=self.checkout_root, executive=self._executive)
    362         should_squash = squash and squash.lower() == "true"
    363 
    364         if not should_squash:
    365             # Only warn if there are actually multiple commits to squash.
    366             num_local_commits = len(self.local_commits())
    367             if num_local_commits > 1 or (num_local_commits > 0 and has_working_directory_changes):
    368                 raise AmbiguousCommitError(num_local_commits, has_working_directory_changes)
    369 
    370     def commit_with_message(self, message, username=None, password=None, git_commit=None, force_squash=False, changed_files=None):
    371         # Username is ignored during Git commits.
    372         has_working_directory_changes = self.has_working_directory_changes()
    373 
    374         if git_commit:
    375             # Special-case HEAD.. to mean working-copy changes only.
    376             if git_commit.upper() == 'HEAD..':
    377                 if not has_working_directory_changes:
    378                     raise ScriptError(message="The working copy is not modified. --git-commit=HEAD.. only commits working copy changes.")
    379                 self.commit_locally_with_message(message)
    380                 return self._commit_on_branch(message, 'HEAD', username=username, password=password)
    381 
    382             # Need working directory changes to be committed so we can checkout the merge branch.
    383             if has_working_directory_changes:
    384                 # FIXME: webkit-patch land will modify the ChangeLogs to correct the reviewer.
    385                 # That will modify the working-copy and cause us to hit this error.
    386                 # The ChangeLog modification could be made to modify the existing local commit.
    387                 raise ScriptError(message="Working copy is modified. Cannot commit individual git_commits.")
    388             return self._commit_on_branch(message, git_commit, username=username, password=password)
    389 
    390         if not force_squash:
    391             self._assert_can_squash(has_working_directory_changes)
    392         self._run_git(['reset', '--soft', self.remote_merge_base()])
    393         self.commit_locally_with_message(message)
    394         return self.push_local_commits_to_server(username=username, password=password)
    395 
    396     def checkout_branch(self, name):
    397         self._run_git(['checkout', '-q', name])
    398 
    399     def create_clean_branch(self, name):
    400         self._run_git(['checkout', '-q', '-b', name, self.remote_branch_ref()])
    401 
    402     def _commit_on_branch(self, message, git_commit, username=None, password=None):
    403         branch_name = self.current_branch()
    404         commit_ids = self.commit_ids_from_commitish_arguments([git_commit])
    405 
    406         # We want to squash all this branch's commits into one commit with the proper description.
    407         # We do this by doing a "merge --squash" into a new commit branch, then dcommitting that.
    408         MERGE_BRANCH_NAME = 'webkit-patch-land'
    409         self.delete_branch(MERGE_BRANCH_NAME)
    410 
    411         # We might be in a directory that's present in this branch but not in the
    412         # trunk.  Move up to the top of the tree so that git commands that expect a
    413         # valid CWD won't fail after we check out the merge branch.
    414         # FIXME: We should never be using chdir! We can instead pass cwd= to run_command/self.run!
    415         self._filesystem.chdir(self.checkout_root)
    416 
    417         # Stuff our change into the merge branch.
    418         # We wrap in a try...finally block so if anything goes wrong, we clean up the branches.
    419         commit_succeeded = True
    420         try:
    421             self.create_clean_branch(MERGE_BRANCH_NAME)
    422 
    423             for commit in commit_ids:
    424                 # We're on a different branch now, so convert "head" to the branch name.
    425                 commit = re.sub(r'(?i)head', branch_name, commit)
    426                 # FIXME: Once changed_files and create_patch are modified to separately handle each
    427                 # commit in a commit range, commit each cherry pick so they'll get dcommitted separately.
    428                 self._run_git(['cherry-pick', '--no-commit', commit])
    429 
    430             self._run_git(['commit', '-m', message])
    431             output = self.push_local_commits_to_server(username=username, password=password)
    432         except Exception, e:
    433             _log.warning("COMMIT FAILED: " + str(e))
    434             output = "Commit failed."
    435             commit_succeeded = False
    436         finally:
    437             # And then swap back to the original branch and clean up.
    438             self.discard_working_directory_changes()
    439             self.checkout_branch(branch_name)
    440             self.delete_branch(MERGE_BRANCH_NAME)
    441 
    442         return output
    443 
    444     def svn_commit_log(self, svn_revision):
    445         svn_revision = self.strip_r_from_svn_revision(svn_revision)
    446         return self._run_git(['svn', 'log', '-r', svn_revision])
    447 
    448     def last_svn_commit_log(self):
    449         return self._run_git(['svn', 'log', '--limit=1'])
    450 
    451     def blame(self, path):
    452         return self._run_git(['blame', path])
    453 
    454     def svn_blame(self, path):
    455         return self._run_git(['svn', 'blame', path])
    456 
    457     # Git-specific methods:
    458     def _branch_ref_exists(self, branch_ref):
    459         return self._run_git(['show-ref', '--quiet', '--verify', branch_ref], return_exit_code=True) == 0
    460 
    461     def delete_branch(self, branch_name):
    462         if self._branch_ref_exists('refs/heads/' + branch_name):
    463             self._run_git(['branch', '-D', branch_name])
    464 
    465     def remote_merge_base(self):
    466         return self._run_git(['merge-base', self.remote_branch_ref(), 'HEAD']).strip()
    467 
    468     def remote_branch_ref(self):
    469         # Use references so that we can avoid collisions, e.g. we don't want to operate on refs/heads/trunk if it exists.
    470         remote_branch_refs = self.read_git_config('svn-remote.svn.fetch', cwd=self.checkout_root, executive=self._executive)
    471         if not remote_branch_refs:
    472             remote_master_ref = 'refs/remotes/origin/master'
    473             if not self._branch_ref_exists(remote_master_ref):
    474                 raise ScriptError(message="Can't find a branch to diff against. svn-remote.svn.fetch is not in the git config and %s does not exist" % remote_master_ref)
    475             return remote_master_ref
    476 
    477         # FIXME: What's the right behavior when there are multiple svn-remotes listed?
    478         # For now, just use the first one.
    479         first_remote_branch_ref = remote_branch_refs.split('\n')[0]
    480         return first_remote_branch_ref.split(':')[1]
    481 
    482     def commit_locally_with_message(self, message, commit_all_working_directory_changes=True):
    483         command = ['commit', '-F', '-']
    484         if commit_all_working_directory_changes:
    485             command.insert(1, '--all')
    486         self._run_git(command, input=message)
    487 
    488     def push_local_commits_to_server(self, username=None, password=None):
    489         dcommit_command = ['svn', 'dcommit']
    490         if (not username or not password) and not self.has_authorization_for_realm(self.svn_server_realm):
    491             raise AuthenticationError(self.svn_server_host, prompt_for_password=True)
    492         if username:
    493             dcommit_command.extend(["--username", username])
    494         output = self._run_git(dcommit_command, error_handler=commit_error_handler, input=password)
    495         return output
    496 
    497     # This function supports the following argument formats:
    498     # no args : rev-list trunk..HEAD
    499     # A..B    : rev-list A..B
    500     # A...B   : error!
    501     # A B     : [A, B]  (different from git diff, which would use "rev-list A..B")
    502     def commit_ids_from_commitish_arguments(self, args):
    503         if not len(args):
    504             args.append('%s..HEAD' % self.remote_branch_ref())
    505 
    506         commit_ids = []
    507         for commitish in args:
    508             if '...' in commitish:
    509                 raise ScriptError(message="'...' is not supported (found in '%s'). Did you mean '..'?" % commitish)
    510             elif '..' in commitish:
    511                 commit_ids += reversed(self._run_git(['rev-list', commitish]).splitlines())
    512             else:
    513                 # Turn single commits or branch or tag names into commit ids.
    514                 commit_ids += self._run_git(['rev-parse', '--revs-only', commitish]).splitlines()
    515         return commit_ids
    516 
    517     def commit_message_for_local_commit(self, commit_id):
    518         commit_lines = self._run_git(['cat-file', 'commit', commit_id]).splitlines()
    519 
    520         # Skip the git headers.
    521         first_line_after_headers = 0
    522         for line in commit_lines:
    523             first_line_after_headers += 1
    524             if line == "":
    525                 break
    526         return CommitMessage(commit_lines[first_line_after_headers:])
    527 
    528     def files_changed_summary_for_commit(self, commit_id):
    529         return self._run_git(['diff-tree', '--shortstat', '--no-renames', '--no-commit-id', commit_id])
    530 
    531     # These methods are git specific and are meant to provide support for the Git oriented workflow
    532     # that Blink is moving towards, hence there are no equivalent methods in the SVN class.
    533 
    534     def pull(self):
    535         self._run_git(['pull'])
    536 
    537     def latest_git_commit(self):
    538         return self._run_git(['log', '-1', '--format=%H']).strip()
    539 
    540     def git_commits_since(self, commit):
    541         return self._run_git(['log', commit + '..master', '--format=%H', '--reverse']).split()
    542 
    543     def git_commit_detail(self, commit, format=None):
    544         args = ['log', '-1', commit]
    545         if format:
    546             args.append('--format=' + format)
    547         return self._run_git(args)
    548 
    549     def _branch_tracking_remote_master(self):
    550         origin_info = self._run_git(['remote', 'show', 'origin', '-n'])
    551         match = re.search("^\s*(?P<branch_name>\S+)\s+merges with remote master$", origin_info, re.MULTILINE)
    552         if not match:
    553             raise ScriptError(message="Unable to find local branch tracking origin/master.")
    554         branch = str(match.group("branch_name"))
    555         return self._run_git(['rev-parse', '--symbolic-full-name', branch]).strip()
    556 
    557     def is_cleanly_tracking_remote_master(self):
    558         if self.has_working_directory_changes():
    559             return False
    560         if self.current_branch() != self._branch_tracking_remote_master():
    561             return False
    562         if len(self.local_commits(self._branch_tracking_remote_master())) > 0:
    563             return False
    564         return True
    565 
    566     def ensure_cleanly_tracking_remote_master(self):
    567         self.discard_working_directory_changes()
    568         self._run_git(['checkout', '-q', self._branch_tracking_remote_master().replace('refs/heads/', '', 1)])
    569         self.discard_local_commits()
    570