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