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