Add the ability to search for modifications that are staged for commit.
[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]).splitlines()
228         # instead it just prints a blank line at the top, so we skip the blank line:
229         return changed_files[1:]
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 timestamp_of_revision(self, path, revision):
276         git_log = self._most_recent_log_matching('git-svn-id:.*@%s' % revision, path)
277         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)
278         if not match:
279             return ""
280
281         # Manually modify the timezone since Git doesn't have an option to show it in UTC.
282         # Git also truncates milliseconds but we're going to ignore that for now.
283         time_with_timezone = datetime.datetime(int(match.group(1)), int(match.group(2)), int(match.group(3)),
284             int(match.group(4)), int(match.group(5)), int(match.group(6)), 0)
285
286         sign = 1 if match.group(7) == '+' else -1
287         time_without_timezone = time_with_timezone - datetime.timedelta(hours=sign * int(match.group(8)), minutes=int(match.group(9)))
288         return time_without_timezone.strftime('%Y-%m-%dT%H:%M:%SZ')
289
290     def prepend_svn_revision(self, diff):
291         revision = self.head_svn_revision()
292         if not revision:
293             return diff
294
295         return "Subversion Revision: " + revision + '\n' + diff
296
297     def create_patch(self, git_commit=None, changed_files=None, git_index=False):
298         """Returns a byte array (str()) representing the patch file.
299         Patch files are effectively binary since they may contain
300         files of multiple different encodings."""
301
302         # Put code changes at the top of the patch and layout tests
303         # at the bottom, this makes for easier reviewing.
304         config_path = self._filesystem.dirname(self._filesystem.path_to_module('webkitpy.common.config'))
305         order_file = self._filesystem.join(config_path, 'orderfile')
306         order = ""
307         if self._filesystem.exists(order_file):
308             order = "-O%s" % order_file
309
310         command = [self.executable_name, 'diff', '--binary', '--no-color', "--no-ext-diff", "--full-index", "--no-renames", order, self.merge_base(git_commit)]
311         if git_index:
312             command += ['--cached']
313         command += ["--"]
314         if changed_files:
315             command += changed_files
316         return self.prepend_svn_revision(self.run(command, decode_output=False, cwd=self.checkout_root))
317
318     def _run_git_svn_find_rev(self, revision, branch=None):
319         revision = str(revision)
320         if revision and revision[0] != 'r':
321             revision = 'r' + revision
322
323         # git svn find-rev always exits 0, even when the revision or commit is not found.
324         command = ['svn', 'find-rev', revision]
325         if branch:
326             command.append(branch)
327
328         return self._run_git(command).rstrip()
329
330     def _string_to_int_or_none(self, string):
331         try:
332             return int(string)
333         except ValueError, e:
334             return None
335
336     @memoized
337     def git_commit_from_svn_revision(self, svn_revision):
338         git_log = self._run_git(['log', '-1', '--grep=^\s*git-svn-id:.*@%s ' % svn_revision])
339         git_commit = re.search("^commit (?P<commit>[a-f0-9]{40})", git_log)
340         if not git_commit:
341             # FIXME: Alternatively we could offer to update the checkout? Or return None?
342             raise ScriptError(message='Failed to find git commit for revision %s, your checkout likely needs an update.' % svn_revision)
343         return str(git_commit.group('commit'))
344
345     @memoized
346     def svn_revision_from_git_commit(self, git_commit):
347         svn_revision = self._run_git_svn_find_rev(git_commit)
348         return self._string_to_int_or_none(svn_revision)
349
350     def contents_at_revision(self, path, revision):
351         """Returns a byte array (str()) containing the contents
352         of path @ revision in the repository."""
353         return self._run_git(["show", "%s:%s" % (self.git_commit_from_svn_revision(revision), path)], decode_output=False)
354
355     def diff_for_revision(self, revision):
356         git_commit = self.git_commit_from_svn_revision(revision)
357         return self.create_patch(git_commit)
358
359     def diff_for_file(self, path, log=None):
360         return self._run_git(['diff', 'HEAD', '--no-renames', '--', path])
361
362     def show_head(self, path):
363         return self._run_git(['show', 'HEAD:' + self.to_object_name(path)], decode_output=False)
364
365     def committer_email_for_revision(self, revision):
366         git_commit = self.git_commit_from_svn_revision(revision)
367         committer_email = self._run_git(["log", "-1", "--pretty=format:%ce", git_commit])
368         # Git adds an extra @repository_hash to the end of every committer email, remove it:
369         return committer_email.rsplit("@", 1)[0]
370
371     def apply_reverse_diff(self, revision):
372         # Assume the revision is an svn revision.
373         git_commit = self.git_commit_from_svn_revision(revision)
374         # I think this will always fail due to ChangeLogs.
375         self._run_git(['revert', '--no-commit', git_commit], error_handler=Executive.ignore_error)
376
377     def revert_files(self, file_paths):
378         self._run_git(['checkout', 'HEAD'] + file_paths)
379
380     def _assert_can_squash(self, has_working_directory_changes):
381         squash = self.read_git_config('webkit-patch.commit-should-always-squash', cwd=self.checkout_root, executive=self._executive)
382         should_squash = squash and squash.lower() == "true"
383
384         if not should_squash:
385             # Only warn if there are actually multiple commits to squash.
386             num_local_commits = len(self.local_commits())
387             if num_local_commits > 1 or (num_local_commits > 0 and has_working_directory_changes):
388                 raise AmbiguousCommitError(num_local_commits, has_working_directory_changes)
389
390     def commit_with_message(self, message, username=None, password=None, git_commit=None, force_squash=False, changed_files=None):
391         # Username is ignored during Git commits.
392         has_working_directory_changes = self.has_working_directory_changes()
393
394         if git_commit:
395             # Special-case HEAD.. to mean working-copy changes only.
396             if git_commit.upper() == 'HEAD..':
397                 if not has_working_directory_changes:
398                     raise ScriptError(message="The working copy is not modified. --git-commit=HEAD.. only commits working copy changes.")
399                 self.commit_locally_with_message(message)
400                 return self._commit_on_branch(message, 'HEAD', username=username, password=password)
401
402             # Need working directory changes to be committed so we can checkout the merge branch.
403             if has_working_directory_changes:
404                 # FIXME: webkit-patch land will modify the ChangeLogs to correct the reviewer.
405                 # That will modify the working-copy and cause us to hit this error.
406                 # The ChangeLog modification could be made to modify the existing local commit.
407                 raise ScriptError(message="Working copy is modified. Cannot commit individual git_commits.")
408             return self._commit_on_branch(message, git_commit, username=username, password=password)
409
410         if not force_squash:
411             self._assert_can_squash(has_working_directory_changes)
412         self._run_git(['reset', '--soft', self.remote_merge_base()])
413         self.commit_locally_with_message(message)
414         return self.push_local_commits_to_server(username=username, password=password)
415
416     def _commit_on_branch(self, message, git_commit, username=None, password=None):
417         branch_name = self._current_branch()
418         commit_ids = self.commit_ids_from_commitish_arguments([git_commit])
419
420         # We want to squash all this branch's commits into one commit with the proper description.
421         # We do this by doing a "merge --squash" into a new commit branch, then dcommitting that.
422         MERGE_BRANCH_NAME = 'webkit-patch-land'
423         self.delete_branch(MERGE_BRANCH_NAME)
424
425         # We might be in a directory that's present in this branch but not in the
426         # trunk.  Move up to the top of the tree so that git commands that expect a
427         # valid CWD won't fail after we check out the merge branch.
428         # FIXME: We should never be using chdir! We can instead pass cwd= to run_command/self.run!
429         self._filesystem.chdir(self.checkout_root)
430
431         # Stuff our change into the merge branch.
432         # We wrap in a try...finally block so if anything goes wrong, we clean up the branches.
433         commit_succeeded = True
434         try:
435             self._run_git(['checkout', '-q', '-b', MERGE_BRANCH_NAME, self.remote_branch_ref()])
436
437             for commit in commit_ids:
438                 # We're on a different branch now, so convert "head" to the branch name.
439                 commit = re.sub(r'(?i)head', branch_name, commit)
440                 # FIXME: Once changed_files and create_patch are modified to separately handle each
441                 # commit in a commit range, commit each cherry pick so they'll get dcommitted separately.
442                 self._run_git(['cherry-pick', '--no-commit', commit])
443
444             self._run_git(['commit', '-m', message])
445             output = self.push_local_commits_to_server(username=username, password=password)
446         except Exception, e:
447             _log.warning("COMMIT FAILED: " + str(e))
448             output = "Commit failed."
449             commit_succeeded = False
450         finally:
451             # And then swap back to the original branch and clean up.
452             self.discard_working_directory_changes()
453             self._run_git(['checkout', '-q', branch_name])
454             self.delete_branch(MERGE_BRANCH_NAME)
455
456         return output
457
458     def svn_commit_log(self, svn_revision):
459         svn_revision = self.strip_r_from_svn_revision(svn_revision)
460         return self._run_git(['svn', 'log', '-r', svn_revision])
461
462     def last_svn_commit_log(self):
463         return self._run_git(['svn', 'log', '--limit=1'])
464
465     def svn_blame(self, path):
466         return self._run_git(['svn', 'blame', path])
467
468     # Git-specific methods:
469     def _branch_ref_exists(self, branch_ref):
470         return self._run_git(['show-ref', '--quiet', '--verify', branch_ref], return_exit_code=True) == 0
471
472     def delete_branch(self, branch_name):
473         if self._branch_ref_exists('refs/heads/' + branch_name):
474             self._run_git(['branch', '-D', branch_name])
475
476     def remote_merge_base(self):
477         return self._run_git(['merge-base', self.remote_branch_ref(), 'HEAD']).strip()
478
479     def remote_branch_ref(self):
480         # Use references so that we can avoid collisions, e.g. we don't want to operate on refs/heads/trunk if it exists.
481         remote_branch_refs = self.read_git_config('svn-remote.svn.fetch', cwd=self.checkout_root, executive=self._executive)
482         if not remote_branch_refs:
483             remote_master_ref = 'refs/remotes/origin/master'
484             if not self._branch_ref_exists(remote_master_ref):
485                 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)
486             return remote_master_ref
487
488         # FIXME: What's the right behavior when there are multiple svn-remotes listed?
489         # For now, just use the first one.
490         first_remote_branch_ref = remote_branch_refs.split('\n')[0]
491         return first_remote_branch_ref.split(':')[1]
492
493     def commit_locally_with_message(self, message):
494         self._run_git(['commit', '--all', '-F', '-'], input=message)
495
496     def push_local_commits_to_server(self, username=None, password=None):
497         dcommit_command = ['svn', 'dcommit', '--rmdir']
498         if (not username or not password) and not self.has_authorization_for_realm(self.svn_server_realm):
499             raise AuthenticationError(self.svn_server_host, prompt_for_password=True)
500         if username:
501             dcommit_command.extend(["--username", username])
502         output = self._run_git(dcommit_command, error_handler=commit_error_handler, input=password)
503         return output
504
505     # This function supports the following argument formats:
506     # no args : rev-list trunk..HEAD
507     # A..B    : rev-list A..B
508     # A...B   : error!
509     # A B     : [A, B]  (different from git diff, which would use "rev-list A..B")
510     def commit_ids_from_commitish_arguments(self, args):
511         if not len(args):
512             args.append('%s..HEAD' % self.remote_branch_ref())
513
514         commit_ids = []
515         for commitish in args:
516             if '...' in commitish:
517                 raise ScriptError(message="'...' is not supported (found in '%s'). Did you mean '..'?" % commitish)
518             elif '..' in commitish:
519                 commit_ids += reversed(self._run_git(['rev-list', commitish]).splitlines())
520             else:
521                 # Turn single commits or branch or tag names into commit ids.
522                 commit_ids += self._run_git(['rev-parse', '--revs-only', commitish]).splitlines()
523         return commit_ids
524
525     def commit_message_for_local_commit(self, commit_id):
526         commit_lines = self._run_git(['cat-file', 'commit', commit_id]).splitlines()
527
528         # Skip the git headers.
529         first_line_after_headers = 0
530         for line in commit_lines:
531             first_line_after_headers += 1
532             if line == "":
533                 break
534         return CommitMessage(commit_lines[first_line_after_headers:])
535
536     def files_changed_summary_for_commit(self, commit_id):
537         return self._run_git(['diff-tree', '--shortstat', '--no-renames', '--no-commit-id', commit_id])