1b65fedf807e89f6a5da8cd32238f05c944b5b5f
[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):
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):
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 self.dryrun:
571             _log = logging.getLogger("webkitpy.common.system")
572             _log.debug('Would run SVN command: "' + " ".join(svn_commit_args) + '"')
573
574             # Return a string which looks like a commit so that things which parse this output will succeed.
575             return "Dry run, no commit.\nCommitted revision 0."
576
577         # FIXME: Should this use cwd=self.checkout_root?
578         return self.run(svn_commit_args, error_handler=commit_error_handler)
579
580     def svn_commit_log(self, svn_revision):
581         svn_revision = self.strip_r_from_svn_revision(svn_revision)
582         return self.run(['svn', 'log', '--non-interactive', '--revision', svn_revision])
583
584     def last_svn_commit_log(self):
585         # BASE is the checkout revision, HEAD is the remote repository revision
586         # http://svnbook.red-bean.com/en/1.0/ch03s03.html
587         return self.svn_commit_log('BASE')
588
589     def propset(self, pname, pvalue, path):
590         dir, base = os.path.split(path)
591         return self.run(['svn', 'pset', pname, pvalue, base], cwd=dir)
592
593     def propget(self, pname, path):
594         dir, base = os.path.split(path)
595         return self.run(['svn', 'pget', pname, base], cwd=dir).encode('utf-8').rstrip("\n")
596
597
598 # All git-specific logic should go here.
599 class Git(SCM):
600     def __init__(self, cwd, executive=None):
601         SCM.__init__(self, cwd, executive)
602         self._check_git_architecture()
603
604     def _machine_is_64bit(self):
605         import platform
606         # This only is tested on Mac.
607         if not platform.mac_ver()[0]:
608             return False
609
610         # platform.architecture()[0] can be '64bit' even if the machine is 32bit:
611         # http://mail.python.org/pipermail/pythonmac-sig/2009-September/021648.html
612         # Use the sysctl command to find out what the processor actually supports.
613         return self.run(['sysctl', '-n', 'hw.cpu64bit_capable']).rstrip() == '1'
614
615     def _executable_is_64bit(self, path):
616         # Again, platform.architecture() fails us.  On my machine
617         # git_bits = platform.architecture(executable=git_path, bits='default')[0]
618         # git_bits is just 'default', meaning the call failed.
619         file_output = self.run(['file', path])
620         return re.search('x86_64', file_output)
621
622     def _check_git_architecture(self):
623         if not self._machine_is_64bit():
624             return
625
626         # We could path-search entirely in python or with
627         # which.py (http://code.google.com/p/which), but this is easier:
628         git_path = self.run(['which', 'git']).rstrip()
629         if self._executable_is_64bit(git_path):
630             return
631
632         webkit_dev_thead_url = "https://lists.webkit.org/pipermail/webkit-dev/2010-December/015249.html"
633         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))
634
635     @classmethod
636     def in_working_directory(cls, path):
637         return run_command(['git', 'rev-parse', '--is-inside-work-tree'], cwd=path, error_handler=Executive.ignore_error).rstrip() == "true"
638
639     @classmethod
640     def find_checkout_root(cls, path):
641         # "git rev-parse --show-cdup" would be another way to get to the root
642         (checkout_root, dot_git) = os.path.split(run_command(['git', 'rev-parse', '--git-dir'], cwd=(path or "./")))
643         # If we were using 2.6 # checkout_root = os.path.relpath(checkout_root, path)
644         if not os.path.isabs(checkout_root): # Sometimes git returns relative paths
645             checkout_root = os.path.join(path, checkout_root)
646         return checkout_root
647
648     @classmethod
649     def to_object_name(cls, filepath):
650         root_end_with_slash = os.path.join(cls.find_checkout_root(os.path.dirname(filepath)), '')
651         return filepath.replace(root_end_with_slash, '')
652
653     @classmethod
654     def read_git_config(cls, key):
655         # FIXME: This should probably use cwd=self.checkout_root.
656         # Pass --get-all for cases where the config has multiple values
657         return run_command(["git", "config", "--get-all", key],
658             error_handler=Executive.ignore_error).rstrip('\n')
659
660     @staticmethod
661     def commit_success_regexp():
662         return "^Committed r(?P<svn_revision>\d+)$"
663
664     def discard_local_commits(self):
665         # FIXME: This should probably use cwd=self.checkout_root
666         self.run(['git', 'reset', '--hard', self.remote_branch_ref()])
667     
668     def local_commits(self):
669         # FIXME: This should probably use cwd=self.checkout_root
670         return self.run(['git', 'log', '--pretty=oneline', 'HEAD...' + self.remote_branch_ref()]).splitlines()
671
672     def rebase_in_progress(self):
673         return os.path.exists(os.path.join(self.checkout_root, '.git/rebase-apply'))
674
675     def working_directory_is_clean(self):
676         # FIXME: This should probably use cwd=self.checkout_root
677         return self.run(['git', 'diff', 'HEAD', '--name-only']) == ""
678
679     def clean_working_directory(self):
680         # FIXME: These should probably use cwd=self.checkout_root.
681         # Could run git clean here too, but that wouldn't match working_directory_is_clean
682         self.run(['git', 'reset', '--hard', 'HEAD'])
683         # Aborting rebase even though this does not match working_directory_is_clean
684         if self.rebase_in_progress():
685             self.run(['git', 'rebase', '--abort'])
686
687     def status_command(self):
688         # git status returns non-zero when there are changes, so we use git diff name --name-status HEAD instead.
689         # No file contents printed, thus utf-8 autodecoding in self.run is fine.
690         return ["git", "diff", "--name-status", "HEAD"]
691
692     def _status_regexp(self, expected_types):
693         return '^(?P<status>[%s])\t(?P<filename>.+)$' % expected_types
694
695     def add(self, path, return_exit_code=False):
696         return self.run(["git", "add", path], return_exit_code=return_exit_code)
697
698     def delete(self, path):
699         return self.run(["git", "rm", "-f", path])
700
701     def merge_base(self, git_commit):
702         if git_commit:
703             # Special-case HEAD.. to mean working-copy changes only.
704             if git_commit.upper() == 'HEAD..':
705                 return 'HEAD'
706
707             if '..' not in git_commit:
708                 git_commit = git_commit + "^.." + git_commit
709             return git_commit
710
711         return self.remote_merge_base()
712
713     def changed_files(self, git_commit=None):
714         # FIXME: --diff-filter could be used to avoid the "extract_filenames" step.
715         status_command = ['git', 'diff', '-r', '--name-status', '-C', '-M', "--no-ext-diff", "--full-index", self.merge_base(git_commit)]
716         # FIXME: I'm not sure we're returning the same set of files that SVN.changed_files is.
717         # Added (A), Copied (C), Deleted (D), Modified (M), Renamed (R)
718         return self.run_status_and_extract_filenames(status_command, self._status_regexp("ADM"))
719
720     def _changes_files_for_commit(self, git_commit):
721         # --pretty="format:" makes git show not print the commit log header,
722         changed_files = self.run(["git", "show", "--pretty=format:", "--name-only", git_commit]).splitlines()
723         # instead it just prints a blank line at the top, so we skip the blank line:
724         return changed_files[1:]
725
726     def changed_files_for_revision(self, revision):
727         commit_id = self.git_commit_from_svn_revision(revision)
728         return self._changes_files_for_commit(commit_id)
729
730     def revisions_changing_file(self, path, limit=5):
731         # git rev-list head --remove-empty --limit=5 -- path would be equivalent.
732         commit_ids = self.run(["git", "log", "--remove-empty", "--pretty=format:%H", "-%s" % limit, "--", path]).splitlines()
733         return filter(lambda revision: revision, map(self.svn_revision_from_git_commit, commit_ids))
734
735     def conflicted_files(self):
736         # We do not need to pass decode_output for this diff command
737         # as we're passing --name-status which does not output any data.
738         status_command = ['git', 'diff', '--name-status', '-C', '-M', '--diff-filter=U']
739         return self.run_status_and_extract_filenames(status_command, self._status_regexp("U"))
740
741     def added_files(self):
742         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A"))
743
744     def deleted_files(self):
745         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D"))
746
747     @staticmethod
748     def supports_local_commits():
749         return True
750
751     def display_name(self):
752         return "git"
753
754     def prepend_svn_revision(self, diff):
755         git_log = self.run(['git', 'log', '-25'])
756         match = re.search("^\s*git-svn-id:.*@(?P<svn_revision>\d+)\ ", git_log, re.MULTILINE)
757         if not match:
758             return diff
759
760         return "Subversion Revision: " + str(match.group('svn_revision')) + '\n' + diff
761
762     def create_patch(self, git_commit=None, changed_files=None):
763         """Returns a byte array (str()) representing the patch file.
764         Patch files are effectively binary since they may contain
765         files of multiple different encodings."""
766         command = ['git', 'diff', '--binary', "--no-ext-diff", "--full-index", "-M", self.merge_base(git_commit), "--"]
767         if changed_files:
768             command += changed_files
769         return self.prepend_svn_revision(self.run(command, decode_output=False, cwd=self.checkout_root))
770
771     def _run_git_svn_find_rev(self, arg):
772         # git svn find-rev always exits 0, even when the revision or commit is not found.
773         return self.run(['git', 'svn', 'find-rev', arg], cwd=self.checkout_root).rstrip()
774
775     def _string_to_int_or_none(self, string):
776         try:
777             return int(string)
778         except ValueError, e:
779             return None
780
781     @memoized
782     def git_commit_from_svn_revision(self, svn_revision):
783         git_commit = self._run_git_svn_find_rev('r%s' % svn_revision)
784         if not git_commit:
785             # FIXME: Alternatively we could offer to update the checkout? Or return None?
786             raise ScriptError(message='Failed to find git commit for revision %s, your checkout likely needs an update.' % svn_revision)
787         return git_commit
788
789     @memoized
790     def svn_revision_from_git_commit(self, git_commit):
791         svn_revision = self._run_git_svn_find_rev(git_commit)
792         return self._string_to_int_or_none(svn_revision)
793
794     def contents_at_revision(self, path, revision):
795         """Returns a byte array (str()) containing the contents
796         of path @ revision in the repository."""
797         return self.run(["git", "show", "%s:%s" % (self.git_commit_from_svn_revision(revision), path)], decode_output=False)
798
799     def diff_for_revision(self, revision):
800         git_commit = self.git_commit_from_svn_revision(revision)
801         return self.create_patch(git_commit)
802
803     def diff_for_file(self, path, log=None):
804         return self.run(['git', 'diff', 'HEAD', '--', path])
805
806     def show_head(self, path):
807         return self.run(['git', 'show', 'HEAD:' + self.to_object_name(path)], decode_output=False)
808
809     def committer_email_for_revision(self, revision):
810         git_commit = self.git_commit_from_svn_revision(revision)
811         committer_email = self.run(["git", "log", "-1", "--pretty=format:%ce", git_commit])
812         # Git adds an extra @repository_hash to the end of every committer email, remove it:
813         return committer_email.rsplit("@", 1)[0]
814
815     def apply_reverse_diff(self, revision):
816         # Assume the revision is an svn revision.
817         git_commit = self.git_commit_from_svn_revision(revision)
818         # I think this will always fail due to ChangeLogs.
819         self.run(['git', 'revert', '--no-commit', git_commit], error_handler=Executive.ignore_error)
820
821     def revert_files(self, file_paths):
822         self.run(['git', 'checkout', 'HEAD'] + file_paths)
823
824     def _assert_can_squash(self, working_directory_is_clean):
825         squash = Git.read_git_config('webkit-patch.commit-should-always-squash')
826         should_squash = squash and squash.lower() == "true"
827
828         if not should_squash:
829             # Only warn if there are actually multiple commits to squash.
830             num_local_commits = len(self.local_commits())
831             if num_local_commits > 1 or (num_local_commits > 0 and not working_directory_is_clean):
832                 raise AmbiguousCommitError(num_local_commits, working_directory_is_clean)
833
834     def commit_with_message(self, message, username=None, git_commit=None, force_squash=False):
835         # Username is ignored during Git commits.
836         working_directory_is_clean = self.working_directory_is_clean()
837
838         if git_commit:
839             # Special-case HEAD.. to mean working-copy changes only.
840             if git_commit.upper() == 'HEAD..':
841                 if working_directory_is_clean:
842                     raise ScriptError(message="The working copy is not modified. --git-commit=HEAD.. only commits working copy changes.")
843                 self.commit_locally_with_message(message)
844                 return self._commit_on_branch(message, 'HEAD')
845
846             # Need working directory changes to be committed so we can checkout the merge branch.
847             if not working_directory_is_clean:
848                 # FIXME: webkit-patch land will modify the ChangeLogs to correct the reviewer.
849                 # That will modify the working-copy and cause us to hit this error.
850                 # The ChangeLog modification could be made to modify the existing local commit.
851                 raise ScriptError(message="Working copy is modified. Cannot commit individual git_commits.")
852             return self._commit_on_branch(message, git_commit)
853
854         if not force_squash:
855             self._assert_can_squash(working_directory_is_clean)
856         self.run(['git', 'reset', '--soft', self.remote_merge_base()])
857         self.commit_locally_with_message(message)
858         return self.push_local_commits_to_server()
859
860     def _commit_on_branch(self, message, git_commit):
861         branch_ref = self.run(['git', 'symbolic-ref', 'HEAD']).strip()
862         branch_name = branch_ref.replace('refs/heads/', '')
863         commit_ids = self.commit_ids_from_commitish_arguments([git_commit])
864
865         # We want to squash all this branch's commits into one commit with the proper description.
866         # We do this by doing a "merge --squash" into a new commit branch, then dcommitting that.
867         MERGE_BRANCH_NAME = 'webkit-patch-land'
868         self.delete_branch(MERGE_BRANCH_NAME)
869
870         # We might be in a directory that's present in this branch but not in the
871         # trunk.  Move up to the top of the tree so that git commands that expect a
872         # valid CWD won't fail after we check out the merge branch.
873         os.chdir(self.checkout_root)
874
875         # Stuff our change into the merge branch.
876         # We wrap in a try...finally block so if anything goes wrong, we clean up the branches.
877         commit_succeeded = True
878         try:
879             self.run(['git', 'checkout', '-q', '-b', MERGE_BRANCH_NAME, self.remote_branch_ref()])
880
881             for commit in commit_ids:
882                 # We're on a different branch now, so convert "head" to the branch name.
883                 commit = re.sub(r'(?i)head', branch_name, commit)
884                 # FIXME: Once changed_files and create_patch are modified to separately handle each
885                 # commit in a commit range, commit each cherry pick so they'll get dcommitted separately.
886                 self.run(['git', 'cherry-pick', '--no-commit', commit])
887
888             self.run(['git', 'commit', '-m', message])
889             output = self.push_local_commits_to_server()
890         except Exception, e:
891             log("COMMIT FAILED: " + str(e))
892             output = "Commit failed."
893             commit_succeeded = False
894         finally:
895             # And then swap back to the original branch and clean up.
896             self.clean_working_directory()
897             self.run(['git', 'checkout', '-q', branch_name])
898             self.delete_branch(MERGE_BRANCH_NAME)
899
900         return output
901
902     def svn_commit_log(self, svn_revision):
903         svn_revision = self.strip_r_from_svn_revision(svn_revision)
904         return self.run(['git', 'svn', 'log', '-r', svn_revision])
905
906     def last_svn_commit_log(self):
907         return self.run(['git', 'svn', 'log', '--limit=1'])
908
909     # Git-specific methods:
910     def _branch_ref_exists(self, branch_ref):
911         return self.run(['git', 'show-ref', '--quiet', '--verify', branch_ref], return_exit_code=True) == 0
912
913     def delete_branch(self, branch_name):
914         if self._branch_ref_exists('refs/heads/' + branch_name):
915             self.run(['git', 'branch', '-D', branch_name])
916
917     def remote_merge_base(self):
918         return self.run(['git', 'merge-base', self.remote_branch_ref(), 'HEAD']).strip()
919
920     def remote_branch_ref(self):
921         # Use references so that we can avoid collisions, e.g. we don't want to operate on refs/heads/trunk if it exists.
922         remote_branch_refs = Git.read_git_config('svn-remote.svn.fetch')
923         if not remote_branch_refs:
924             remote_master_ref = 'refs/remotes/origin/master'
925             if not self._branch_ref_exists(remote_master_ref):
926                 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)
927             return remote_master_ref
928
929         # FIXME: What's the right behavior when there are multiple svn-remotes listed?
930         # For now, just use the first one.
931         first_remote_branch_ref = remote_branch_refs.split('\n')[0]
932         return first_remote_branch_ref.split(':')[1]
933
934     def commit_locally_with_message(self, message):
935         self.run(['git', 'commit', '--all', '-F', '-'], input=message)
936
937     def push_local_commits_to_server(self):
938         dcommit_command = ['git', 'svn', 'dcommit']
939         if self.dryrun:
940             dcommit_command.append('--dry-run')
941         output = self.run(dcommit_command, error_handler=commit_error_handler)
942         # Return a string which looks like a commit so that things which parse this output will succeed.
943         if self.dryrun:
944             output += "\nCommitted r0"
945         return output
946
947     # This function supports the following argument formats:
948     # no args : rev-list trunk..HEAD
949     # A..B    : rev-list A..B
950     # A...B   : error!
951     # A B     : [A, B]  (different from git diff, which would use "rev-list A..B")
952     def commit_ids_from_commitish_arguments(self, args):
953         if not len(args):
954             args.append('%s..HEAD' % self.remote_branch_ref())
955
956         commit_ids = []
957         for commitish in args:
958             if '...' in commitish:
959                 raise ScriptError(message="'...' is not supported (found in '%s'). Did you mean '..'?" % commitish)
960             elif '..' in commitish:
961                 commit_ids += reversed(self.run(['git', 'rev-list', commitish]).splitlines())
962             else:
963                 # Turn single commits or branch or tag names into commit ids.
964                 commit_ids += self.run(['git', 'rev-parse', '--revs-only', commitish]).splitlines()
965         return commit_ids
966
967     def commit_message_for_local_commit(self, commit_id):
968         commit_lines = self.run(['git', 'cat-file', 'commit', commit_id]).splitlines()
969
970         # Skip the git headers.
971         first_line_after_headers = 0
972         for line in commit_lines:
973             first_line_after_headers += 1
974             if line == "":
975                 break
976         return CommitMessage(commit_lines[first_line_after_headers:])
977
978     def files_changed_summary_for_commit(self, commit_id):
979         return self.run(['git', 'diff-tree', '--shortstat', '--no-commit-id', commit_id])