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