WebKitTools/Scripts/webkitpy/common/checkout/scm.py
changeset 0 4f2f89ce4247
equal deleted inserted replaced
-1:000000000000 0:4f2f89ce4247
       
     1 # Copyright (c) 2009, 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 # Python module for interacting with an SCM system (like SVN or Git)
       
    31 
       
    32 import os
       
    33 import re
       
    34 import sys
       
    35 import shutil
       
    36 
       
    37 from webkitpy.common.system.executive import Executive, run_command, ScriptError
       
    38 from webkitpy.common.system.deprecated_logging import error, log
       
    39 
       
    40 
       
    41 def detect_scm_system(path):
       
    42     absolute_path = os.path.abspath(path)
       
    43 
       
    44     if SVN.in_working_directory(absolute_path):
       
    45         return SVN(cwd=absolute_path)
       
    46     
       
    47     if Git.in_working_directory(absolute_path):
       
    48         return Git(cwd=absolute_path)
       
    49     
       
    50     return None
       
    51 
       
    52 
       
    53 def first_non_empty_line_after_index(lines, index=0):
       
    54     first_non_empty_line = index
       
    55     for line in lines[index:]:
       
    56         if re.match("^\s*$", line):
       
    57             first_non_empty_line += 1
       
    58         else:
       
    59             break
       
    60     return first_non_empty_line
       
    61 
       
    62 
       
    63 class CommitMessage:
       
    64     def __init__(self, message):
       
    65         self.message_lines = message[first_non_empty_line_after_index(message, 0):]
       
    66 
       
    67     def body(self, lstrip=False):
       
    68         lines = self.message_lines[first_non_empty_line_after_index(self.message_lines, 1):]
       
    69         if lstrip:
       
    70             lines = [line.lstrip() for line in lines]
       
    71         return "\n".join(lines) + "\n"
       
    72 
       
    73     def description(self, lstrip=False, strip_url=False):
       
    74         line = self.message_lines[0]
       
    75         if lstrip:
       
    76             line = line.lstrip()
       
    77         if strip_url:
       
    78             line = re.sub("^(\s*)<.+> ", "\1", line)
       
    79         return line
       
    80 
       
    81     def message(self):
       
    82         return "\n".join(self.message_lines) + "\n"
       
    83 
       
    84 
       
    85 class CheckoutNeedsUpdate(ScriptError):
       
    86     def __init__(self, script_args, exit_code, output, cwd):
       
    87         ScriptError.__init__(self, script_args=script_args, exit_code=exit_code, output=output, cwd=cwd)
       
    88 
       
    89 
       
    90 def commit_error_handler(error):
       
    91     if re.search("resource out of date", error.output):
       
    92         raise CheckoutNeedsUpdate(script_args=error.script_args, exit_code=error.exit_code, output=error.output, cwd=error.cwd)
       
    93     Executive.default_error_handler(error)
       
    94 
       
    95 
       
    96 class AuthenticationError(Exception):
       
    97     def __init__(self, server_host):
       
    98         self.server_host = server_host
       
    99 
       
   100 
       
   101 class AmbiguousCommitError(Exception):
       
   102     def __init__(self, num_local_commits, working_directory_is_clean):
       
   103         self.num_local_commits = num_local_commits
       
   104         self.working_directory_is_clean = working_directory_is_clean
       
   105 
       
   106 
       
   107 # SCM methods are expected to return paths relative to self.checkout_root.
       
   108 class SCM:
       
   109     def __init__(self, cwd):
       
   110         self.cwd = cwd
       
   111         self.checkout_root = self.find_checkout_root(self.cwd)
       
   112         self.dryrun = False
       
   113 
       
   114     # A wrapper used by subclasses to create processes.
       
   115     def run(self, args, cwd=None, input=None, error_handler=None, return_exit_code=False, return_stderr=True, decode_output=True):
       
   116         # FIXME: We should set cwd appropriately.
       
   117         # FIXME: We should use Executive.
       
   118         return run_command(args,
       
   119                            cwd=cwd,
       
   120                            input=input,
       
   121                            error_handler=error_handler,
       
   122                            return_exit_code=return_exit_code,
       
   123                            return_stderr=return_stderr,
       
   124                            decode_output=decode_output)
       
   125 
       
   126     # SCM always returns repository relative path, but sometimes we need
       
   127     # absolute paths to pass to rm, etc.
       
   128     def absolute_path(self, repository_relative_path):
       
   129         return os.path.join(self.checkout_root, repository_relative_path)
       
   130 
       
   131     # FIXME: This belongs in Checkout, not SCM.
       
   132     def scripts_directory(self):
       
   133         return os.path.join(self.checkout_root, "WebKitTools", "Scripts")
       
   134 
       
   135     # FIXME: This belongs in Checkout, not SCM.
       
   136     def script_path(self, script_name):
       
   137         return os.path.join(self.scripts_directory(), script_name)
       
   138 
       
   139     def ensure_clean_working_directory(self, force_clean):
       
   140         if not force_clean and not self.working_directory_is_clean():
       
   141             # FIXME: Shouldn't this use cwd=self.checkout_root?
       
   142             print self.run(self.status_command(), error_handler=Executive.ignore_error)
       
   143             raise ScriptError(message="Working directory has modifications, pass --force-clean or --no-clean to continue.")
       
   144         
       
   145         log("Cleaning working directory")
       
   146         self.clean_working_directory()
       
   147     
       
   148     def ensure_no_local_commits(self, force):
       
   149         if not self.supports_local_commits():
       
   150             return
       
   151         commits = self.local_commits()
       
   152         if not len(commits):
       
   153             return
       
   154         if not force:
       
   155             error("Working directory has local commits, pass --force-clean to continue.")
       
   156         self.discard_local_commits()
       
   157 
       
   158     def run_status_and_extract_filenames(self, status_command, status_regexp):
       
   159         filenames = []
       
   160         # We run with cwd=self.checkout_root so that returned-paths are root-relative.
       
   161         for line in self.run(status_command, cwd=self.checkout_root).splitlines():
       
   162             match = re.search(status_regexp, line)
       
   163             if not match:
       
   164                 continue
       
   165             # status = match.group('status')
       
   166             filename = match.group('filename')
       
   167             filenames.append(filename)
       
   168         return filenames
       
   169 
       
   170     def strip_r_from_svn_revision(self, svn_revision):
       
   171         match = re.match("^r(?P<svn_revision>\d+)", unicode(svn_revision))
       
   172         if (match):
       
   173             return match.group('svn_revision')
       
   174         return svn_revision
       
   175 
       
   176     def svn_revision_from_commit_text(self, commit_text):
       
   177         match = re.search(self.commit_success_regexp(), commit_text, re.MULTILINE)
       
   178         return match.group('svn_revision')
       
   179 
       
   180     @staticmethod
       
   181     def _subclass_must_implement():
       
   182         raise NotImplementedError("subclasses must implement")
       
   183 
       
   184     @staticmethod
       
   185     def in_working_directory(path):
       
   186         SCM._subclass_must_implement()
       
   187 
       
   188     @staticmethod
       
   189     def find_checkout_root(path):
       
   190         SCM._subclass_must_implement()
       
   191 
       
   192     @staticmethod
       
   193     def commit_success_regexp():
       
   194         SCM._subclass_must_implement()
       
   195 
       
   196     def working_directory_is_clean(self):
       
   197         self._subclass_must_implement()
       
   198 
       
   199     def clean_working_directory(self):
       
   200         self._subclass_must_implement()
       
   201 
       
   202     def status_command(self):
       
   203         self._subclass_must_implement()
       
   204 
       
   205     def add(self, path, return_exit_code=False):
       
   206         self._subclass_must_implement()
       
   207 
       
   208     def delete(self, path):
       
   209         self._subclass_must_implement()
       
   210 
       
   211     def changed_files(self, git_commit=None):
       
   212         self._subclass_must_implement()
       
   213 
       
   214     def changed_files_for_revision(self):
       
   215         self._subclass_must_implement()
       
   216 
       
   217     def added_files(self):
       
   218         self._subclass_must_implement()
       
   219 
       
   220     def conflicted_files(self):
       
   221         self._subclass_must_implement()
       
   222 
       
   223     def display_name(self):
       
   224         self._subclass_must_implement()
       
   225 
       
   226     def create_patch(self, git_commit=None):
       
   227         self._subclass_must_implement()
       
   228 
       
   229     def committer_email_for_revision(self, revision):
       
   230         self._subclass_must_implement()
       
   231 
       
   232     def contents_at_revision(self, path, revision):
       
   233         self._subclass_must_implement()
       
   234 
       
   235     def diff_for_revision(self, revision):
       
   236         self._subclass_must_implement()
       
   237 
       
   238     def diff_for_file(self, path, log=None):
       
   239         self._subclass_must_implement()
       
   240 
       
   241     def show_head(self, path):
       
   242         self._subclass_must_implement()
       
   243 
       
   244     def apply_reverse_diff(self, revision):
       
   245         self._subclass_must_implement()
       
   246 
       
   247     def revert_files(self, file_paths):
       
   248         self._subclass_must_implement()
       
   249 
       
   250     def commit_with_message(self, message, username=None, git_commit=None, force_squash=False):
       
   251         self._subclass_must_implement()
       
   252 
       
   253     def svn_commit_log(self, svn_revision):
       
   254         self._subclass_must_implement()
       
   255 
       
   256     def last_svn_commit_log(self):
       
   257         self._subclass_must_implement()
       
   258 
       
   259     # Subclasses must indicate if they support local commits,
       
   260     # but the SCM baseclass will only call local_commits methods when this is true.
       
   261     @staticmethod
       
   262     def supports_local_commits():
       
   263         SCM._subclass_must_implement()
       
   264 
       
   265     def remote_merge_base():
       
   266         SCM._subclass_must_implement()
       
   267 
       
   268     def commit_locally_with_message(self, message):
       
   269         error("Your source control manager does not support local commits.")
       
   270 
       
   271     def discard_local_commits(self):
       
   272         pass
       
   273 
       
   274     def local_commits(self):
       
   275         return []
       
   276 
       
   277 
       
   278 class SVN(SCM):
       
   279     # FIXME: We should move these values to a WebKit-specific config. file.
       
   280     svn_server_host = "svn.webkit.org"
       
   281     svn_server_realm = "<http://svn.webkit.org:80> Mac OS Forge"
       
   282 
       
   283     def __init__(self, cwd):
       
   284         SCM.__init__(self, cwd)
       
   285         self.cached_version = None
       
   286         self._bogus_dir = None
       
   287 
       
   288     @staticmethod
       
   289     def in_working_directory(path):
       
   290         return os.path.isdir(os.path.join(path, '.svn'))
       
   291     
       
   292     @classmethod
       
   293     def find_uuid(cls, path):
       
   294         if not cls.in_working_directory(path):
       
   295             return None
       
   296         return cls.value_from_svn_info(path, 'Repository UUID')
       
   297 
       
   298     @classmethod
       
   299     def value_from_svn_info(cls, path, field_name):
       
   300         svn_info_args = ['svn', 'info', path]
       
   301         info_output = run_command(svn_info_args).rstrip()
       
   302         match = re.search("^%s: (?P<value>.+)$" % field_name, info_output, re.MULTILINE)
       
   303         if not match:
       
   304             raise ScriptError(script_args=svn_info_args, message='svn info did not contain a %s.' % field_name)
       
   305         return match.group('value')
       
   306 
       
   307     @staticmethod
       
   308     def find_checkout_root(path):
       
   309         uuid = SVN.find_uuid(path)
       
   310         # If |path| is not in a working directory, we're supposed to return |path|.
       
   311         if not uuid:
       
   312             return path
       
   313         # Search up the directory hierarchy until we find a different UUID.
       
   314         last_path = None
       
   315         while True:
       
   316             if uuid != SVN.find_uuid(path):
       
   317                 return last_path
       
   318             last_path = path
       
   319             (path, last_component) = os.path.split(path)
       
   320             if last_path == path:
       
   321                 return None
       
   322 
       
   323     @staticmethod
       
   324     def commit_success_regexp():
       
   325         return "^Committed revision (?P<svn_revision>\d+)\.$"
       
   326 
       
   327     def has_authorization_for_realm(self, realm=svn_server_realm, home_directory=os.getenv("HOME")):
       
   328         # Assumes find and grep are installed.
       
   329         if not os.path.isdir(os.path.join(home_directory, ".subversion")):
       
   330             return False
       
   331         find_args = ["find", ".subversion", "-type", "f", "-exec", "grep", "-q", realm, "{}", ";", "-print"];
       
   332         find_output = self.run(find_args, cwd=home_directory, error_handler=Executive.ignore_error).rstrip()
       
   333         return find_output and os.path.isfile(os.path.join(home_directory, find_output))
       
   334 
       
   335     def svn_version(self):
       
   336         if not self.cached_version:
       
   337             self.cached_version = self.run(['svn', '--version', '--quiet'])
       
   338         
       
   339         return self.cached_version
       
   340 
       
   341     def working_directory_is_clean(self):
       
   342         return self.run(["svn", "diff"], cwd=self.checkout_root, decode_output=False) == ""
       
   343 
       
   344     def clean_working_directory(self):
       
   345         # svn revert -R is not as awesome as git reset --hard.
       
   346         # It will leave added files around, causing later svn update
       
   347         # calls to fail on the bots.  We make this mirror git reset --hard
       
   348         # by deleting any added files as well.
       
   349         added_files = reversed(sorted(self.added_files()))
       
   350         # added_files() returns directories for SVN, we walk the files in reverse path
       
   351         # length order so that we remove files before we try to remove the directories.
       
   352         self.run(["svn", "revert", "-R", "."], cwd=self.checkout_root)
       
   353         for path in added_files:
       
   354             # This is robust against cwd != self.checkout_root
       
   355             absolute_path = self.absolute_path(path)
       
   356             # Completely lame that there is no easy way to remove both types with one call.
       
   357             if os.path.isdir(path):
       
   358                 os.rmdir(absolute_path)
       
   359             else:
       
   360                 os.remove(absolute_path)
       
   361 
       
   362     def status_command(self):
       
   363         return ['svn', 'status']
       
   364 
       
   365     def _status_regexp(self, expected_types):
       
   366         field_count = 6 if self.svn_version() > "1.6" else 5
       
   367         return "^(?P<status>[%s]).{%s} (?P<filename>.+)$" % (expected_types, field_count)
       
   368 
       
   369     def _add_parent_directories(self, path):
       
   370         """Does 'svn add' to the path and its parents."""
       
   371         if self.in_working_directory(path):
       
   372             return
       
   373         dirname = os.path.dirname(path)
       
   374         # We have dirname directry - ensure it added.
       
   375         if dirname != path:
       
   376             self._add_parent_directories(dirname)
       
   377         self.add(path)
       
   378 
       
   379     def add(self, path, return_exit_code=False):
       
   380         self._add_parent_directories(os.path.dirname(os.path.abspath(path)))
       
   381         return self.run(["svn", "add", path], return_exit_code=return_exit_code)
       
   382 
       
   383     def delete(self, path):
       
   384         parent, base = os.path.split(os.path.abspath(path))
       
   385         return self.run(["svn", "delete", "--force", base], cwd=parent)
       
   386 
       
   387     def changed_files(self, git_commit=None):
       
   388         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("ACDMR"))
       
   389 
       
   390     def changed_files_for_revision(self, revision):
       
   391         # As far as I can tell svn diff --summarize output looks just like svn status output.
       
   392         # No file contents printed, thus utf-8 auto-decoding in self.run is fine.
       
   393         status_command = ["svn", "diff", "--summarize", "-c", revision]
       
   394         return self.run_status_and_extract_filenames(status_command, self._status_regexp("ACDMR"))
       
   395 
       
   396     def conflicted_files(self):
       
   397         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("C"))
       
   398 
       
   399     def added_files(self):
       
   400         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A"))
       
   401 
       
   402     def deleted_files(self):
       
   403         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D"))
       
   404 
       
   405     @staticmethod
       
   406     def supports_local_commits():
       
   407         return False
       
   408 
       
   409     def display_name(self):
       
   410         return "svn"
       
   411 
       
   412     # FIXME: This method should be on Checkout.
       
   413     def create_patch(self, git_commit=None):
       
   414         """Returns a byte array (str()) representing the patch file.
       
   415         Patch files are effectively binary since they may contain
       
   416         files of multiple different encodings."""
       
   417         return self.run([self.script_path("svn-create-patch")],
       
   418             cwd=self.checkout_root, return_stderr=False,
       
   419             decode_output=False)
       
   420 
       
   421     def committer_email_for_revision(self, revision):
       
   422         return self.run(["svn", "propget", "svn:author", "--revprop", "-r", revision]).rstrip()
       
   423 
       
   424     def contents_at_revision(self, path, revision):
       
   425         """Returns a byte array (str()) containing the contents
       
   426         of path @ revision in the repository."""
       
   427         remote_path = "%s/%s" % (self._repository_url(), path)
       
   428         return self.run(["svn", "cat", "-r", revision, remote_path], decode_output=False)
       
   429 
       
   430     def diff_for_revision(self, revision):
       
   431         # FIXME: This should probably use cwd=self.checkout_root
       
   432         return self.run(['svn', 'diff', '-c', revision])
       
   433 
       
   434     def _bogus_dir_name(self):
       
   435         if sys.platform.startswith("win"):
       
   436             parent_dir = tempfile.gettempdir()
       
   437         else:
       
   438             parent_dir = sys.path[0]  # tempdir is not secure.
       
   439         return os.path.join(parent_dir, "temp_svn_config")
       
   440 
       
   441     def _setup_bogus_dir(self, log):
       
   442         self._bogus_dir = self._bogus_dir_name()
       
   443         if not os.path.exists(self._bogus_dir):
       
   444             os.mkdir(self._bogus_dir)
       
   445             self._delete_bogus_dir = True
       
   446         else:
       
   447             self._delete_bogus_dir = False
       
   448         if log:
       
   449             log.debug('  Html: temp config dir: "%s".', self._bogus_dir)
       
   450 
       
   451     def _teardown_bogus_dir(self, log):
       
   452         if self._delete_bogus_dir:
       
   453             shutil.rmtree(self._bogus_dir, True)
       
   454             if log:
       
   455                 log.debug('  Html: removed temp config dir: "%s".', self._bogus_dir)
       
   456         self._bogus_dir = None
       
   457 
       
   458     def diff_for_file(self, path, log=None):
       
   459         self._setup_bogus_dir(log)
       
   460         try:
       
   461             args = ['svn', 'diff']
       
   462             if self._bogus_dir:
       
   463                 args += ['--config-dir', self._bogus_dir]
       
   464             args.append(path)
       
   465             return self.run(args)
       
   466         finally:
       
   467             self._teardown_bogus_dir(log)
       
   468 
       
   469     def show_head(self, path):
       
   470         return self.run(['svn', 'cat', '-r', 'BASE', path], decode_output=False)
       
   471 
       
   472     def _repository_url(self):
       
   473         return self.value_from_svn_info(self.checkout_root, 'URL')
       
   474 
       
   475     def apply_reverse_diff(self, revision):
       
   476         # '-c -revision' applies the inverse diff of 'revision'
       
   477         svn_merge_args = ['svn', 'merge', '--non-interactive', '-c', '-%s' % revision, self._repository_url()]
       
   478         log("WARNING: svn merge has been known to take more than 10 minutes to complete.  It is recommended you use git for rollouts.")
       
   479         log("Running '%s'" % " ".join(svn_merge_args))
       
   480         # FIXME: Should this use cwd=self.checkout_root?
       
   481         self.run(svn_merge_args)
       
   482 
       
   483     def revert_files(self, file_paths):
       
   484         # FIXME: This should probably use cwd=self.checkout_root.
       
   485         self.run(['svn', 'revert'] + file_paths)
       
   486 
       
   487     def commit_with_message(self, message, username=None, git_commit=None, force_squash=False):
       
   488         # git-commit and force are not used by SVN.
       
   489         if self.dryrun:
       
   490             # Return a string which looks like a commit so that things which parse this output will succeed.
       
   491             return "Dry run, no commit.\nCommitted revision 0."
       
   492 
       
   493         svn_commit_args = ["svn", "commit"]
       
   494 
       
   495         if not username and not self.has_authorization_for_realm():
       
   496             raise AuthenticationError(self.svn_server_host)
       
   497         if username:
       
   498             svn_commit_args.extend(["--username", username])
       
   499 
       
   500         svn_commit_args.extend(["-m", message])
       
   501         # FIXME: Should this use cwd=self.checkout_root?
       
   502         return self.run(svn_commit_args, error_handler=commit_error_handler)
       
   503 
       
   504     def svn_commit_log(self, svn_revision):
       
   505         svn_revision = self.strip_r_from_svn_revision(svn_revision)
       
   506         return self.run(['svn', 'log', '--non-interactive', '--revision', svn_revision])
       
   507 
       
   508     def last_svn_commit_log(self):
       
   509         # BASE is the checkout revision, HEAD is the remote repository revision
       
   510         # http://svnbook.red-bean.com/en/1.0/ch03s03.html
       
   511         return self.svn_commit_log('BASE')
       
   512 
       
   513     def propset(self, pname, pvalue, path):
       
   514         dir, base = os.path.split(path)
       
   515         return self.run(['svn', 'pset', pname, pvalue, base], cwd=dir)
       
   516 
       
   517     def propget(self, pname, path):
       
   518         dir, base = os.path.split(path)
       
   519         return self.run(['svn', 'pget', pname, base], cwd=dir).encode('utf-8').rstrip("\n")
       
   520 
       
   521 # All git-specific logic should go here.
       
   522 class Git(SCM):
       
   523     def __init__(self, cwd):
       
   524         SCM.__init__(self, cwd)
       
   525 
       
   526     @classmethod
       
   527     def in_working_directory(cls, path):
       
   528         return run_command(['git', 'rev-parse', '--is-inside-work-tree'], cwd=path, error_handler=Executive.ignore_error).rstrip() == "true"
       
   529 
       
   530     @classmethod
       
   531     def find_checkout_root(cls, path):
       
   532         # "git rev-parse --show-cdup" would be another way to get to the root
       
   533         (checkout_root, dot_git) = os.path.split(run_command(['git', 'rev-parse', '--git-dir'], cwd=(path or "./")))
       
   534         # If we were using 2.6 # checkout_root = os.path.relpath(checkout_root, path)
       
   535         if not os.path.isabs(checkout_root): # Sometimes git returns relative paths
       
   536             checkout_root = os.path.join(path, checkout_root)
       
   537         return checkout_root
       
   538 
       
   539     @classmethod
       
   540     def to_object_name(cls, filepath):
       
   541         root_end_with_slash = os.path.join(cls.find_checkout_root(os.path.dirname(filepath)), '')
       
   542         return filepath.replace(root_end_with_slash, '')
       
   543 
       
   544     @classmethod
       
   545     def read_git_config(cls, key):
       
   546         # FIXME: This should probably use cwd=self.checkout_root.
       
   547         return run_command(["git", "config", key],
       
   548             error_handler=Executive.ignore_error).rstrip('\n')
       
   549 
       
   550     @staticmethod
       
   551     def commit_success_regexp():
       
   552         return "^Committed r(?P<svn_revision>\d+)$"
       
   553 
       
   554     def discard_local_commits(self):
       
   555         # FIXME: This should probably use cwd=self.checkout_root
       
   556         self.run(['git', 'reset', '--hard', self.remote_branch_ref()])
       
   557     
       
   558     def local_commits(self):
       
   559         # FIXME: This should probably use cwd=self.checkout_root
       
   560         return self.run(['git', 'log', '--pretty=oneline', 'HEAD...' + self.remote_branch_ref()]).splitlines()
       
   561 
       
   562     def rebase_in_progress(self):
       
   563         return os.path.exists(os.path.join(self.checkout_root, '.git/rebase-apply'))
       
   564 
       
   565     def working_directory_is_clean(self):
       
   566         # FIXME: This should probably use cwd=self.checkout_root
       
   567         return self.run(['git', 'diff', 'HEAD', '--name-only']) == ""
       
   568 
       
   569     def clean_working_directory(self):
       
   570         # FIXME: These should probably use cwd=self.checkout_root.
       
   571         # Could run git clean here too, but that wouldn't match working_directory_is_clean
       
   572         self.run(['git', 'reset', '--hard', 'HEAD'])
       
   573         # Aborting rebase even though this does not match working_directory_is_clean
       
   574         if self.rebase_in_progress():
       
   575             self.run(['git', 'rebase', '--abort'])
       
   576 
       
   577     def status_command(self):
       
   578         # git status returns non-zero when there are changes, so we use git diff name --name-status HEAD instead.
       
   579         # No file contents printed, thus utf-8 autodecoding in self.run is fine.
       
   580         return ["git", "diff", "--name-status", "HEAD"]
       
   581 
       
   582     def _status_regexp(self, expected_types):
       
   583         return '^(?P<status>[%s])\t(?P<filename>.+)$' % expected_types
       
   584 
       
   585     def add(self, path, return_exit_code=False):
       
   586         return self.run(["git", "add", path], return_exit_code=return_exit_code)
       
   587 
       
   588     def delete(self, path):
       
   589         return self.run(["git", "rm", "-f", path])
       
   590 
       
   591     def _assert_synced(self):
       
   592         if len(run_command(['git', 'rev-list', '--max-count=1', self.remote_branch_ref(), '^HEAD'])):
       
   593             raise ScriptError(message="Not fully merged/rebased to %s. This branch needs to be synced first." % self.remote_branch_ref())
       
   594 
       
   595     def merge_base(self, git_commit):
       
   596         if git_commit:
       
   597             # Special-case HEAD.. to mean working-copy changes only.
       
   598             if git_commit.upper() == 'HEAD..':
       
   599                 return 'HEAD'
       
   600 
       
   601             if '..' not in git_commit:
       
   602                 git_commit = git_commit + "^.." + git_commit
       
   603             return git_commit
       
   604 
       
   605         self._assert_synced()
       
   606         return self.remote_merge_base()
       
   607 
       
   608     def changed_files(self, git_commit=None):
       
   609         status_command = ['git', 'diff', '-r', '--name-status', '-C', '-M', "--no-ext-diff", "--full-index", self.merge_base(git_commit)]
       
   610         return self.run_status_and_extract_filenames(status_command, self._status_regexp("ADM"))
       
   611 
       
   612     def _changes_files_for_commit(self, git_commit):
       
   613         # --pretty="format:" makes git show not print the commit log header,
       
   614         changed_files = self.run(["git", "show", "--pretty=format:", "--name-only", git_commit]).splitlines()
       
   615         # instead it just prints a blank line at the top, so we skip the blank line:
       
   616         return changed_files[1:]
       
   617 
       
   618     def changed_files_for_revision(self, revision):
       
   619         commit_id = self.git_commit_from_svn_revision(revision)
       
   620         return self._changes_files_for_commit(commit_id)
       
   621 
       
   622     def conflicted_files(self):
       
   623         # We do not need to pass decode_output for this diff command
       
   624         # as we're passing --name-status which does not output any data.
       
   625         status_command = ['git', 'diff', '--name-status', '-C', '-M', '--diff-filter=U']
       
   626         return self.run_status_and_extract_filenames(status_command, self._status_regexp("U"))
       
   627 
       
   628     def added_files(self):
       
   629         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A"))
       
   630 
       
   631     def deleted_files(self):
       
   632         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D"))
       
   633 
       
   634     @staticmethod
       
   635     def supports_local_commits():
       
   636         return True
       
   637 
       
   638     def display_name(self):
       
   639         return "git"
       
   640 
       
   641     def create_patch(self, git_commit=None):
       
   642         """Returns a byte array (str()) representing the patch file.
       
   643         Patch files are effectively binary since they may contain
       
   644         files of multiple different encodings."""
       
   645         # FIXME: This should probably use cwd=self.checkout_root
       
   646         return self.run(['git', 'diff', '--binary', "--no-ext-diff", "--full-index", "-M", self.merge_base(git_commit)], decode_output=False)
       
   647 
       
   648     @classmethod
       
   649     def git_commit_from_svn_revision(cls, revision):
       
   650         # FIXME: This should probably use cwd=self.checkout_root
       
   651         git_commit = run_command(['git', 'svn', 'find-rev', 'r%s' % revision]).rstrip()
       
   652         # git svn find-rev always exits 0, even when the revision is not found.
       
   653         if not git_commit:
       
   654             raise ScriptError(message='Failed to find git commit for revision %s, your checkout likely needs an update.' % revision)
       
   655         return git_commit
       
   656 
       
   657     def contents_at_revision(self, path, revision):
       
   658         """Returns a byte array (str()) containing the contents
       
   659         of path @ revision in the repository."""
       
   660         return self.run(["git", "show", "%s:%s" % (self.git_commit_from_svn_revision(revision), path)], decode_output=False)
       
   661 
       
   662     def diff_for_revision(self, revision):
       
   663         git_commit = self.git_commit_from_svn_revision(revision)
       
   664         return self.create_patch(git_commit)
       
   665 
       
   666     def diff_for_file(self, path, log=None):
       
   667         return self.run(['git', 'diff', 'HEAD', '--', path])
       
   668 
       
   669     def show_head(self, path):
       
   670         return self.run(['git', 'show', 'HEAD:' + self.to_object_name(path)], decode_output=False)
       
   671 
       
   672     def committer_email_for_revision(self, revision):
       
   673         git_commit = self.git_commit_from_svn_revision(revision)
       
   674         committer_email = self.run(["git", "log", "-1", "--pretty=format:%ce", git_commit])
       
   675         # Git adds an extra @repository_hash to the end of every committer email, remove it:
       
   676         return committer_email.rsplit("@", 1)[0]
       
   677 
       
   678     def apply_reverse_diff(self, revision):
       
   679         # Assume the revision is an svn revision.
       
   680         git_commit = self.git_commit_from_svn_revision(revision)
       
   681         # I think this will always fail due to ChangeLogs.
       
   682         self.run(['git', 'revert', '--no-commit', git_commit], error_handler=Executive.ignore_error)
       
   683 
       
   684     def revert_files(self, file_paths):
       
   685         self.run(['git', 'checkout', 'HEAD'] + file_paths)
       
   686 
       
   687     def _assert_can_squash(self, working_directory_is_clean):
       
   688         squash = Git.read_git_config('webkit-patch.commit_should_always_squash')
       
   689         should_squash = squash and squash.lower() == "true"
       
   690 
       
   691         if not should_squash:
       
   692             # Only warn if there are actually multiple commits to squash.
       
   693             num_local_commits = len(self.local_commits())
       
   694             if num_local_commits > 1 or (num_local_commits > 0 and not working_directory_is_clean):
       
   695                 raise AmbiguousCommitError(num_local_commits, working_directory_is_clean)
       
   696 
       
   697     def commit_with_message(self, message, username=None, git_commit=None, force_squash=False):
       
   698         # Username is ignored during Git commits.
       
   699         working_directory_is_clean = self.working_directory_is_clean()
       
   700 
       
   701         if git_commit:
       
   702             # Special-case HEAD.. to mean working-copy changes only.
       
   703             if git_commit.upper() == 'HEAD..':
       
   704                 if working_directory_is_clean:
       
   705                     raise ScriptError(message="The working copy is not modified. --git-commit=HEAD.. only commits working copy changes.")
       
   706                 self.commit_locally_with_message(message)
       
   707                 return self._commit_on_branch(message, 'HEAD')
       
   708 
       
   709             # Need working directory changes to be committed so we can checkout the merge branch.
       
   710             if not working_directory_is_clean:
       
   711                 # FIXME: webkit-patch land will modify the ChangeLogs to correct the reviewer.
       
   712                 # That will modify the working-copy and cause us to hit this error.
       
   713                 # The ChangeLog modification could be made to modify the existing local commit.
       
   714                 raise ScriptError(message="Working copy is modified. Cannot commit individual git_commits.")
       
   715             return self._commit_on_branch(message, git_commit)
       
   716 
       
   717         if not force_squash:
       
   718             self._assert_can_squash(working_directory_is_clean)
       
   719         self._assert_synced()
       
   720         self.run(['git', 'reset', '--soft', self.remote_branch_ref()])
       
   721         self.commit_locally_with_message(message)
       
   722         return self.push_local_commits_to_server()
       
   723 
       
   724     def _commit_on_branch(self, message, git_commit):
       
   725         branch_ref = self.run(['git', 'symbolic-ref', 'HEAD']).strip()
       
   726         branch_name = branch_ref.replace('refs/heads/', '')
       
   727         commit_ids = self.commit_ids_from_commitish_arguments([git_commit])
       
   728 
       
   729         # We want to squash all this branch's commits into one commit with the proper description.
       
   730         # We do this by doing a "merge --squash" into a new commit branch, then dcommitting that.
       
   731         MERGE_BRANCH_NAME = 'webkit-patch-land'
       
   732         self.delete_branch(MERGE_BRANCH_NAME)
       
   733 
       
   734         # We might be in a directory that's present in this branch but not in the
       
   735         # trunk.  Move up to the top of the tree so that git commands that expect a
       
   736         # valid CWD won't fail after we check out the merge branch.
       
   737         os.chdir(self.checkout_root)
       
   738 
       
   739         # Stuff our change into the merge branch.
       
   740         # We wrap in a try...finally block so if anything goes wrong, we clean up the branches.
       
   741         commit_succeeded = True
       
   742         try:
       
   743             self.run(['git', 'checkout', '-q', '-b', MERGE_BRANCH_NAME, self.remote_branch_ref()])
       
   744 
       
   745             for commit in commit_ids:
       
   746                 # We're on a different branch now, so convert "head" to the branch name.
       
   747                 commit = re.sub(r'(?i)head', branch_name, commit)
       
   748                 # FIXME: Once changed_files and create_patch are modified to separately handle each
       
   749                 # commit in a commit range, commit each cherry pick so they'll get dcommitted separately.
       
   750                 self.run(['git', 'cherry-pick', '--no-commit', commit])
       
   751 
       
   752             self.run(['git', 'commit', '-m', message])
       
   753             output = self.push_local_commits_to_server()
       
   754         except Exception, e:
       
   755             log("COMMIT FAILED: " + str(e))
       
   756             output = "Commit failed."
       
   757             commit_succeeded = False
       
   758         finally:
       
   759             # And then swap back to the original branch and clean up.
       
   760             self.clean_working_directory()
       
   761             self.run(['git', 'checkout', '-q', branch_name])
       
   762             self.delete_branch(MERGE_BRANCH_NAME)
       
   763 
       
   764         return output
       
   765 
       
   766     def svn_commit_log(self, svn_revision):
       
   767         svn_revision = self.strip_r_from_svn_revision(svn_revision)
       
   768         return self.run(['git', 'svn', 'log', '-r', svn_revision])
       
   769 
       
   770     def last_svn_commit_log(self):
       
   771         return self.run(['git', 'svn', 'log', '--limit=1'])
       
   772 
       
   773     # Git-specific methods:
       
   774     def _branch_ref_exists(self, branch_ref):
       
   775         return self.run(['git', 'show-ref', '--quiet', '--verify', branch_ref], return_exit_code=True) == 0
       
   776 
       
   777     def delete_branch(self, branch_name):
       
   778         if self._branch_ref_exists('refs/heads/' + branch_name):
       
   779             self.run(['git', 'branch', '-D', branch_name])
       
   780 
       
   781     def remote_merge_base(self):
       
   782         return self.run(['git', 'merge-base', self.remote_branch_ref(), 'HEAD']).strip()
       
   783 
       
   784     def remote_branch_ref(self):
       
   785         # Use references so that we can avoid collisions, e.g. we don't want to operate on refs/heads/trunk if it exists.
       
   786 
       
   787         # FIXME: This should so something like: Git.read_git_config('svn-remote.svn.fetch').split(':')[1]
       
   788         # but that doesn't work if the git repo is tracking multiple svn branches.
       
   789         remote_branch_refs = [
       
   790             'refs/remotes/trunk',  # A git-svn checkout as per http://trac.webkit.org/wiki/UsingGitWithWebKit.
       
   791             'refs/remotes/origin/master',  # A git clone of git://git.webkit.org/WebKit.git that is not tracking svn.
       
   792         ]
       
   793 
       
   794         for ref in remote_branch_refs:
       
   795             if self._branch_ref_exists(ref):
       
   796                 return ref
       
   797 
       
   798         raise ScriptError(message="Can't find a branch to diff against. %s branches do not exist." % " and ".join(remote_branch_refs))
       
   799 
       
   800     def commit_locally_with_message(self, message):
       
   801         self.run(['git', 'commit', '--all', '-F', '-'], input=message)
       
   802 
       
   803     def push_local_commits_to_server(self):
       
   804         dcommit_command = ['git', 'svn', 'dcommit']
       
   805         if self.dryrun:
       
   806             dcommit_command.append('--dry-run')
       
   807         output = self.run(dcommit_command, error_handler=commit_error_handler)
       
   808         # Return a string which looks like a commit so that things which parse this output will succeed.
       
   809         if self.dryrun:
       
   810             output += "\nCommitted r0"
       
   811         return output
       
   812 
       
   813     # This function supports the following argument formats:
       
   814     # no args : rev-list trunk..HEAD
       
   815     # A..B    : rev-list A..B
       
   816     # A...B   : error!
       
   817     # A B     : [A, B]  (different from git diff, which would use "rev-list A..B")
       
   818     def commit_ids_from_commitish_arguments(self, args):
       
   819         if not len(args):
       
   820             args.append('%s..HEAD' % self.remote_branch_ref())
       
   821 
       
   822         commit_ids = []
       
   823         for commitish in args:
       
   824             if '...' in commitish:
       
   825                 raise ScriptError(message="'...' is not supported (found in '%s'). Did you mean '..'?" % commitish)
       
   826             elif '..' in commitish:
       
   827                 commit_ids += reversed(self.run(['git', 'rev-list', commitish]).splitlines())
       
   828             else:
       
   829                 # Turn single commits or branch or tag names into commit ids.
       
   830                 commit_ids += self.run(['git', 'rev-parse', '--revs-only', commitish]).splitlines()
       
   831         return commit_ids
       
   832 
       
   833     def commit_message_for_local_commit(self, commit_id):
       
   834         commit_lines = self.run(['git', 'cat-file', 'commit', commit_id]).splitlines()
       
   835 
       
   836         # Skip the git headers.
       
   837         first_line_after_headers = 0
       
   838         for line in commit_lines:
       
   839             first_line_after_headers += 1
       
   840             if line == "":
       
   841                 break
       
   842         return CommitMessage(commit_lines[first_line_after_headers:])
       
   843 
       
   844     def files_changed_summary_for_commit(self, commit_id):
       
   845         return self.run(['git', 'diff-tree', '--shortstat', '--no-commit-id', commit_id])