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