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