Call fixChangeLogPatch when generating patches from webkit-patch
[WebKit-https.git] / Tools / Scripts / webkitpy / common / checkout / scm / git.py
1 # Copyright (c) 2009, 2010, 2011 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 import datetime
31 import logging
32 import os
33 import re
34
35 from webkitpy.common.memoized import memoized
36 from webkitpy.common.system.executive import Executive, ScriptError
37
38 from .commitmessage import CommitMessage
39 from .scm import AuthenticationError, SCM, commit_error_handler
40 from .svn import SVN, SVNRepository
41
42 _log = logging.getLogger(__name__)
43
44
45 class AmbiguousCommitError(Exception):
46     def __init__(self, num_local_commits, has_working_directory_changes):
47         Exception.__init__(self, "Found %s local commits and the working directory is %s" % (
48             num_local_commits, ["clean", "not clean"][has_working_directory_changes]))
49         self.num_local_commits = num_local_commits
50         self.has_working_directory_changes = has_working_directory_changes
51
52
53 class Git(SCM, SVNRepository):
54
55     # Git doesn't appear to document error codes, but seems to return
56     # 1 or 128, mostly.
57     ERROR_FILE_IS_MISSING = 128
58
59     executable_name = 'git'
60
61     def __init__(self, cwd, patch_directories, **kwargs):
62         SCM.__init__(self, cwd, **kwargs)
63         self._check_git_architecture()
64         if patch_directories == []:
65             raise Exception(message='Empty list of patch directories passed to SCM.__init__')
66         elif patch_directories == None:
67             self._patch_directories = [self._filesystem.relpath(cwd, self.checkout_root)]
68         else:
69             self._patch_directories = patch_directories
70
71     def _machine_is_64bit(self):
72         import platform
73         # This only is tested on Mac.
74         if not platform.mac_ver()[0]:
75             return False
76
77         # platform.architecture()[0] can be '64bit' even if the machine is 32bit:
78         # http://mail.python.org/pipermail/pythonmac-sig/2009-September/021648.html
79         # Use the sysctl command to find out what the processor actually supports.
80         return self.run(['sysctl', '-n', 'hw.cpu64bit_capable']).rstrip() == '1'
81
82     def _executable_is_64bit(self, path):
83         # Again, platform.architecture() fails us.  On my machine
84         # git_bits = platform.architecture(executable=git_path, bits='default')[0]
85         # git_bits is just 'default', meaning the call failed.
86         file_output = self.run(['file', path])
87         return re.search('x86_64', file_output)
88
89     def _check_git_architecture(self):
90         if not self._machine_is_64bit():
91             return
92
93         # We could path-search entirely in python or with
94         # which.py (http://code.google.com/p/which), but this is easier:
95         git_path = self.run(['which', self.executable_name]).rstrip()
96         if self._executable_is_64bit(git_path):
97             return
98
99         webkit_dev_thread_url = "https://lists.webkit.org/pipermail/webkit-dev/2010-December/015287.html"
100         _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_thread_url))
101
102     def _run_git(self, command_args, **kwargs):
103         full_command_args = [self.executable_name] + command_args
104         full_kwargs = kwargs
105         if not 'cwd' in full_kwargs:
106             full_kwargs['cwd'] = self.checkout_root
107         return self.run(full_command_args, **full_kwargs)
108
109     @classmethod
110     def in_working_directory(cls, path, executive=None):
111         try:
112             executive = executive or Executive()
113             return executive.run_command([cls.executable_name, 'rev-parse', '--is-inside-work-tree'], cwd=path, error_handler=Executive.ignore_error).rstrip() == "true"
114         except OSError, e:
115             # The Windows bots seem to through a WindowsError when git isn't installed.
116             return False
117
118     def find_checkout_root(self, path):
119         # "git rev-parse --show-cdup" would be another way to get to the root
120         checkout_root = self._run_git(['rev-parse', '--show-toplevel'], cwd=(path or "./")).strip()
121         if not self._filesystem.isabs(checkout_root):  # Sometimes git returns relative paths
122             checkout_root = self._filesystem.join(path, checkout_root)
123         return checkout_root
124
125     def to_object_name(self, filepath):
126         # FIXME: This can't be the right way to append a slash.
127         root_end_with_slash = self._filesystem.join(self.find_checkout_root(self._filesystem.dirname(filepath)), '')
128         # FIXME: This seems to want some sort of rel_path instead?
129         return filepath.replace(root_end_with_slash, '')
130
131     @classmethod
132     def read_git_config(cls, key, cwd=None, executive=None):
133         # FIXME: This should probably use cwd=self.checkout_root.
134         # Pass --get-all for cases where the config has multiple values
135         # Pass the cwd if provided so that we can handle the case of running webkit-patch outside of the working directory.
136         # FIXME: This should use an Executive.
137         executive = executive or Executive()
138         return executive.run_command([cls.executable_name, "config", "--get-all", key], error_handler=Executive.ignore_error, cwd=cwd).rstrip('\n')
139
140     @staticmethod
141     def commit_success_regexp():
142         return "^Committed r(?P<svn_revision>\d+)$"
143
144     def discard_local_commits(self):
145         self._run_git(['reset', '--hard', self.remote_branch_ref()])
146
147     def local_commits(self):
148         return self._run_git(['log', '--pretty=oneline', 'HEAD...' + self.remote_branch_ref()]).splitlines()
149
150     def rebase_in_progress(self):
151         return self._filesystem.exists(self.absolute_path(self._filesystem.join('.git', 'rebase-apply')))
152
153     def has_working_directory_changes(self):
154         return self._run_git(['diff', 'HEAD', '--no-renames', '--name-only']) != ""
155
156     def discard_working_directory_changes(self):
157         # Could run git clean here too, but that wouldn't match subversion
158         self._run_git(['reset', 'HEAD', '--hard'])
159         # Aborting rebase even though this does not match subversion
160         if self.rebase_in_progress():
161             self._run_git(['rebase', '--abort'])
162
163     def status_command(self):
164         # git status returns non-zero when there are changes, so we use git diff name --name-status HEAD instead.
165         # No file contents printed, thus utf-8 autodecoding in self.run is fine.
166         return [self.executable_name, "diff", "--name-status", "--no-renames", "HEAD"]
167
168     def _status_regexp(self, expected_types):
169         return '^(?P<status>[%s])\t(?P<filename>.+)$' % expected_types
170
171     def add_list(self, paths):
172         self._run_git(["add"] + paths)
173
174     def delete_list(self, paths):
175         return self._run_git(["rm", "-f"] + paths)
176
177     def exists(self, path):
178         return_code = self._run_git(["show", "HEAD:%s" % path], return_exit_code=True, decode_output=False)
179         return return_code != self.ERROR_FILE_IS_MISSING
180
181     def _branch_from_ref(self, ref):
182         return ref.replace('refs/heads/', '')
183
184     def _current_branch(self):
185         return self._branch_from_ref(self._run_git(['symbolic-ref', '-q', 'HEAD']).strip())
186
187     def _upstream_branch(self):
188         current_branch = self._current_branch()
189         return self._branch_from_ref(self.read_git_config('branch.%s.merge' % current_branch, cwd=self.checkout_root, executive=self._executive).strip())
190
191     def merge_base(self, git_commit):
192         if git_commit:
193             # Rewrite UPSTREAM to the upstream branch
194             if 'UPSTREAM' in git_commit:
195                 upstream = self._upstream_branch()
196                 if not upstream:
197                     raise ScriptError(message='No upstream/tracking branch set.')
198                 git_commit = git_commit.replace('UPSTREAM', upstream)
199
200             # Special-case <refname>.. to include working copy changes, e.g., 'HEAD....' shows only the diffs from HEAD.
201             if git_commit.endswith('....'):
202                 return git_commit[:-4]
203
204             if '..' not in git_commit:
205                 git_commit = git_commit + "^.." + git_commit
206             return git_commit
207
208         return self.remote_merge_base()
209
210     def modifications_staged_for_commit(self):
211         # This will only return non-deleted files with the "updated in index" status
212         # as defined by http://git-scm.com/docs/git-status.
213         status_command = [self.executable_name, 'status', '--short']
214         updated_in_index_regexp = '^M[ M] (?P<filename>.+)$'
215         return self.run_status_and_extract_filenames(status_command, updated_in_index_regexp)
216
217     def changed_files(self, git_commit=None):
218         # FIXME: --diff-filter could be used to avoid the "extract_filenames" step.
219         status_command = [self.executable_name, 'diff', '-r', '--name-status', "--no-renames", "--no-ext-diff", "--full-index", self.merge_base(git_commit), '--']
220         status_command.extend(self._patch_directories)
221         # FIXME: I'm not sure we're returning the same set of files that SVN.changed_files is.
222         # Added (A), Copied (C), Deleted (D), Modified (M), Renamed (R)
223         return self.run_status_and_extract_filenames(status_command, self._status_regexp("ADM"))
224
225     def _changes_files_for_commit(self, git_commit):
226         # --pretty="format:" makes git show not print the commit log header.
227         changed_files = self._run_git(["show", "--pretty=format:", "--name-only", git_commit])
228         # Strip blank lines which could appear at the top on older versions of git.
229         return changed_files.lstrip().splitlines()
230
231     def changed_files_for_revision(self, revision):
232         commit_id = self.git_commit_from_svn_revision(revision)
233         return self._changes_files_for_commit(commit_id)
234
235     def revisions_changing_file(self, path, limit=5):
236         # raise a script error if path does not exists to match the behavior of  the svn implementation.
237         if not self._filesystem.exists(path):
238             raise ScriptError(message="Path %s does not exist." % path)
239
240         # git rev-list head --remove-empty --limit=5 -- path would be equivalent.
241         commit_ids = self._run_git(["log", "--remove-empty", "--pretty=format:%H", "-%s" % limit, "--", path]).splitlines()
242         return filter(lambda revision: revision, map(self.svn_revision_from_git_commit, commit_ids))
243
244     def conflicted_files(self):
245         # We do not need to pass decode_output for this diff command
246         # as we're passing --name-status which does not output any data.
247         status_command = [self.executable_name, 'diff', '--name-status', '--no-renames', '--diff-filter=U']
248         return self.run_status_and_extract_filenames(status_command, self._status_regexp("U"))
249
250     def added_files(self):
251         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A"))
252
253     def deleted_files(self):
254         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D"))
255
256     @staticmethod
257     def supports_local_commits():
258         return True
259
260     def display_name(self):
261         return "git"
262
263     def _most_recent_log_matching(self, grep_str, path):
264         # We use '--grep=' + foo rather than '--grep', foo because
265         # git 1.7.0.4 (and earlier) didn't support the separate arg.
266         return self._run_git(['log', '-1', '--grep=' + grep_str, '--date=iso', self.find_checkout_root(path)])
267
268     def svn_revision(self, path):
269         git_log = self._most_recent_log_matching('git-svn-id:', path)
270         match = re.search("^\s*git-svn-id:.*@(?P<svn_revision>\d+)\ ", git_log, re.MULTILINE)
271         if not match:
272             return ""
273         return str(match.group('svn_revision'))
274
275     def svn_url(self):
276         git_command = ['svn', 'info']
277         status = self._run_git(git_command)
278         match = re.search(r'^URL: (?P<url>.*)$', status, re.MULTILINE)
279         if not match:
280             return ""
281         return match.group('url')
282
283     def timestamp_of_revision(self, path, revision):
284         git_log = self._most_recent_log_matching('git-svn-id:.*@%s' % revision, path)
285         match = re.search("^Date:\s*(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$", git_log, re.MULTILINE)
286         if not match:
287             return ""
288
289         # Manually modify the timezone since Git doesn't have an option to show it in UTC.
290         # Git also truncates milliseconds but we're going to ignore that for now.
291         time_with_timezone = datetime.datetime(int(match.group(1)), int(match.group(2)), int(match.group(3)),
292             int(match.group(4)), int(match.group(5)), int(match.group(6)), 0)
293
294         sign = 1 if match.group(7) == '+' else -1
295         time_without_timezone = time_with_timezone - datetime.timedelta(hours=sign * int(match.group(8)), minutes=int(match.group(9)))
296         return time_without_timezone.strftime('%Y-%m-%dT%H:%M:%SZ')
297
298     def prepend_svn_revision(self, diff):
299         revision = self.head_svn_revision()
300         if not revision:
301             return diff
302
303         return "Subversion Revision: " + revision + '\n' + diff
304
305     def create_patch(self, git_commit=None, changed_files=None, git_index=False):
306         """Returns a byte array (str()) representing the patch file.
307         Patch files are effectively binary since they may contain
308         files of multiple different encodings."""
309
310         # Put code changes at the top of the patch and layout tests
311         # at the bottom, this makes for easier reviewing.
312         config_path = self._filesystem.dirname(self._filesystem.path_to_module('webkitpy.common.config'))
313         order_file = self._filesystem.join(config_path, 'orderfile')
314         order = ""
315         if self._filesystem.exists(order_file):
316             order = "-O%s" % order_file
317
318         command = [self.executable_name, 'diff', '--binary', '--no-color', "--no-ext-diff", "--full-index", "--no-renames", order, self.merge_base(git_commit)]
319         if git_index:
320             command += ['--cached']
321         command += ["--"]
322         if changed_files:
323             command += changed_files
324         return self.fix_changelog_patch(
325                 self.prepend_svn_revision(
326                     self.run(command, decode_output=False, cwd=self.checkout_root)))
327
328     def _run_git_svn_find_rev(self, revision_or_treeish, branch=None):
329         # git svn find-rev requires SVN revisions to begin with the character 'r'.
330         command = ['svn', 'find-rev', revision_or_treeish]
331         if branch:
332             command.append(branch)
333
334         # git svn find-rev always exits 0, even when the revision or commit is not found.
335         return self._run_git(command).rstrip()
336
337     def _string_to_int_or_none(self, string):
338         try:
339             return int(string)
340         except ValueError, e:
341             return None
342
343     @memoized
344     def git_commit_from_svn_revision(self, svn_revision):
345         git_log = self._run_git(['log', '-1', '--grep=^\s*git-svn-id:.*@%s ' % svn_revision])
346         git_commit = re.search("^commit (?P<commit>[a-f0-9]{40})", git_log)
347         if not git_commit:
348             # FIXME: Alternatively we could offer to update the checkout? Or return None?
349             raise ScriptError(message='Failed to find git commit for revision %s, your checkout likely needs an update.' % svn_revision)
350         return str(git_commit.group('commit'))
351
352     @memoized
353     def svn_revision_from_git_commit(self, git_commit):
354         svn_revision = self._run_git_svn_find_rev(git_commit)
355         return self._string_to_int_or_none(svn_revision)
356
357     def contents_at_revision(self, path, revision):
358         """Returns a byte array (str()) containing the contents
359         of path @ revision in the repository."""
360         return self._run_git(["show", "%s:%s" % (self.git_commit_from_svn_revision(revision), path)], decode_output=False)
361
362     def diff_for_revision(self, revision):
363         git_commit = self.git_commit_from_svn_revision(revision)
364         return self.create_patch(git_commit)
365
366     def diff_for_file(self, path, log=None):
367         return self._run_git(['diff', 'HEAD', '--no-renames', '--', path])
368
369     def show_head(self, path):
370         return self._run_git(['show', 'HEAD:' + self.to_object_name(path)], decode_output=False)
371
372     def committer_email_for_revision(self, revision):
373         git_commit = self.git_commit_from_svn_revision(revision)
374         committer_email = self._run_git(["log", "-1", "--pretty=format:%ce", git_commit])
375         # Git adds an extra @repository_hash to the end of every committer email, remove it:
376         return committer_email.rsplit("@", 1)[0]
377
378     def apply_reverse_diff(self, revision):
379         # Assume the revision is an svn revision.
380         git_commit = self.git_commit_from_svn_revision(revision)
381         # I think this will always fail due to ChangeLogs.
382         self._run_git(['revert', '--no-commit', git_commit], error_handler=Executive.ignore_error)
383
384     def revert_files(self, file_paths):
385         self._run_git(['checkout', 'HEAD'] + file_paths)
386
387     def _assert_can_squash(self, has_working_directory_changes):
388         squash = self.read_git_config('webkit-patch.commit-should-always-squash', cwd=self.checkout_root, executive=self._executive)
389         should_squash = squash and squash.lower() == "true"
390
391         if not should_squash:
392             # Only warn if there are actually multiple commits to squash.
393             num_local_commits = len(self.local_commits())
394             if num_local_commits > 1 or (num_local_commits > 0 and has_working_directory_changes):
395                 raise AmbiguousCommitError(num_local_commits, has_working_directory_changes)
396
397     def commit_with_message(self, message, username=None, password=None, git_commit=None, force_squash=False, changed_files=None):
398         # Username is ignored during Git commits.
399         has_working_directory_changes = self.has_working_directory_changes()
400
401         if git_commit:
402             # Special-case HEAD.. to mean working-copy changes only.
403             if git_commit.upper() == 'HEAD..':
404                 if not has_working_directory_changes:
405                     raise ScriptError(message="The working copy is not modified. --git-commit=HEAD.. only commits working copy changes.")
406                 self.commit_locally_with_message(message)
407                 return self._commit_on_branch(message, 'HEAD', username=username, password=password)
408
409             # Need working directory changes to be committed so we can checkout the merge branch.
410             if has_working_directory_changes:
411                 # FIXME: webkit-patch land will modify the ChangeLogs to correct the reviewer.
412                 # That will modify the working-copy and cause us to hit this error.
413                 # The ChangeLog modification could be made to modify the existing local commit.
414                 raise ScriptError(message="Working copy is modified. Cannot commit individual git_commits.")
415             return self._commit_on_branch(message, git_commit, username=username, password=password)
416
417         if not force_squash:
418             self._assert_can_squash(has_working_directory_changes)
419         self._run_git(['reset', '--soft', self.remote_merge_base()])
420         self.commit_locally_with_message(message)
421         return self.push_local_commits_to_server(username=username, password=password)
422
423     def _commit_on_branch(self, message, git_commit, username=None, password=None):
424         branch_name = self._current_branch()
425         commit_ids = self.commit_ids_from_commitish_arguments([git_commit])
426
427         # We want to squash all this branch's commits into one commit with the proper description.
428         # We do this by doing a "merge --squash" into a new commit branch, then dcommitting that.
429         MERGE_BRANCH_NAME = 'webkit-patch-land'
430         self.delete_branch(MERGE_BRANCH_NAME)
431
432         # We might be in a directory that's present in this branch but not in the
433         # trunk.  Move up to the top of the tree so that git commands that expect a
434         # valid CWD won't fail after we check out the merge branch.
435         # FIXME: We should never be using chdir! We can instead pass cwd= to run_command/self.run!
436         self._filesystem.chdir(self.checkout_root)
437
438         # Stuff our change into the merge branch.
439         # We wrap in a try...finally block so if anything goes wrong, we clean up the branches.
440         commit_succeeded = True
441         try:
442             self._run_git(['checkout', '-q', '-b', MERGE_BRANCH_NAME, self.remote_branch_ref()])
443
444             for commit in commit_ids:
445                 # We're on a different branch now, so convert "head" to the branch name.
446                 commit = re.sub(r'(?i)head', branch_name, commit)
447                 # FIXME: Once changed_files and create_patch are modified to separately handle each
448                 # commit in a commit range, commit each cherry pick so they'll get dcommitted separately.
449                 self._run_git(['cherry-pick', '--no-commit', commit])
450
451             self._run_git(['commit', '-m', message])
452             output = self.push_local_commits_to_server(username=username, password=password)
453         except Exception, e:
454             _log.warning("COMMIT FAILED: " + str(e))
455             output = "Commit failed."
456             commit_succeeded = False
457         finally:
458             # And then swap back to the original branch and clean up.
459             self.discard_working_directory_changes()
460             self._run_git(['checkout', '-q', branch_name])
461             self.delete_branch(MERGE_BRANCH_NAME)
462
463         return output
464
465     def svn_commit_log(self, svn_revision):
466         svn_revision = self.strip_r_from_svn_revision(svn_revision)
467         return self._run_git(['svn', 'log', '-r', svn_revision])
468
469     def last_svn_commit_log(self):
470         return self._run_git(['svn', 'log', '--limit=1'])
471
472     def svn_blame(self, path):
473         return self._run_git(['svn', 'blame', path])
474
475     # Git-specific methods:
476     def _branch_ref_exists(self, branch_ref):
477         return self._run_git(['show-ref', '--quiet', '--verify', branch_ref], return_exit_code=True) == 0
478
479     def delete_branch(self, branch_name):
480         if self._branch_ref_exists('refs/heads/' + branch_name):
481             self._run_git(['branch', '-D', branch_name])
482
483     def remote_merge_base(self):
484         return self._run_git(['merge-base', self.remote_branch_ref(), 'HEAD']).strip()
485
486     def remote_branch_ref(self):
487         # Use references so that we can avoid collisions, e.g. we don't want to operate on refs/heads/trunk if it exists.
488         remote_branch_refs = self.read_git_config('svn-remote.svn.fetch', cwd=self.checkout_root, executive=self._executive)
489         if not remote_branch_refs:
490             remote_master_ref = 'refs/remotes/origin/master'
491             if not self._branch_ref_exists(remote_master_ref):
492                 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)
493             return remote_master_ref
494
495         # FIXME: What's the right behavior when there are multiple svn-remotes listed?
496         # For now, just use the first one.
497         first_remote_branch_ref = remote_branch_refs.split('\n')[0]
498         return first_remote_branch_ref.split(':')[1]
499
500     def cherrypick_merge(self, commit):
501         git_args = ['cherry-pick', '-n', commit]
502         return self._run_git(git_args)
503
504     def commit_locally_with_message(self, message):
505         self._run_git(['commit', '--all', '-F', '-'], input=message)
506
507     def push_local_commits_to_server(self, username=None, password=None):
508         dcommit_command = ['svn', 'dcommit', '--rmdir']
509         if (not username or not password) and not self.has_authorization_for_realm(self.svn_server_realm):
510             raise AuthenticationError(self.svn_server_host, prompt_for_password=True)
511         if username:
512             dcommit_command.extend(["--username", username])
513         output = self._run_git(dcommit_command, error_handler=commit_error_handler, input=password)
514         return output
515
516     # This function supports the following argument formats:
517     # no args : rev-list trunk..HEAD
518     # A..B    : rev-list A..B
519     # A...B   : error!
520     # A B     : [A, B]  (different from git diff, which would use "rev-list A..B")
521     def commit_ids_from_commitish_arguments(self, args):
522         if not len(args):
523             args.append('%s..HEAD' % self.remote_branch_ref())
524
525         commit_ids = []
526         for commitish in args:
527             if '...' in commitish:
528                 raise ScriptError(message="'...' is not supported (found in '%s'). Did you mean '..'?" % commitish)
529             elif '..' in commitish:
530                 commit_ids += reversed(self._run_git(['rev-list', commitish]).splitlines())
531             else:
532                 # Turn single commits or branch or tag names into commit ids.
533                 commit_ids += self._run_git(['rev-parse', '--revs-only', commitish]).splitlines()
534         return commit_ids
535
536     def commit_message_for_local_commit(self, commit_id):
537         commit_lines = self._run_git(['cat-file', 'commit', commit_id]).splitlines()
538
539         # Skip the git headers.
540         first_line_after_headers = 0
541         for line in commit_lines:
542             first_line_after_headers += 1
543             if line == "":
544                 break
545         return CommitMessage(commit_lines[first_line_after_headers:])
546
547     def files_changed_summary_for_commit(self, commit_id):
548         return self._run_git(['diff-tree', '--shortstat', '--no-renames', '--no-commit-id', commit_id])