1# Copyright (c) 2009, Google Inc. All rights reserved. 2# Copyright (c) 2009 Apple Inc. All rights reserved. 3# 4# Redistribution and use in source and binary forms, with or without 5# modification, are permitted provided that the following conditions are 6# met: 7# 8# * Redistributions of source code must retain the above copyright 9# notice, this list of conditions and the following disclaimer. 10# * Redistributions in binary form must reproduce the above 11# copyright notice, this list of conditions and the following disclaimer 12# in the documentation and/or other materials provided with the 13# distribution. 14# * Neither the name of Google Inc. nor the names of its 15# contributors may be used to endorse or promote products derived from 16# this software without specific prior written permission. 17# 18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29# 30# Python module for interacting with an SCM system (like SVN or Git) 31 32import os 33import re 34import subprocess 35 36# Import WebKit-specific modules. 37from webkitpy.changelogs import ChangeLog 38from webkitpy.executive import Executive, run_command, ScriptError 39from webkitpy.webkit_logging import error, log 40 41def detect_scm_system(path): 42 if SVN.in_working_directory(path): 43 return SVN(cwd=path) 44 45 if Git.in_working_directory(path): 46 return Git(cwd=path) 47 48 return None 49 50def first_non_empty_line_after_index(lines, index=0): 51 first_non_empty_line = index 52 for line in lines[index:]: 53 if re.match("^\s*$", line): 54 first_non_empty_line += 1 55 else: 56 break 57 return first_non_empty_line 58 59 60class CommitMessage: 61 def __init__(self, message): 62 self.message_lines = message[first_non_empty_line_after_index(message, 0):] 63 64 def body(self, lstrip=False): 65 lines = self.message_lines[first_non_empty_line_after_index(self.message_lines, 1):] 66 if lstrip: 67 lines = [line.lstrip() for line in lines] 68 return "\n".join(lines) + "\n" 69 70 def description(self, lstrip=False, strip_url=False): 71 line = self.message_lines[0] 72 if lstrip: 73 line = line.lstrip() 74 if strip_url: 75 line = re.sub("^(\s*)<.+> ", "\1", line) 76 return line 77 78 def message(self): 79 return "\n".join(self.message_lines) + "\n" 80 81 82class CheckoutNeedsUpdate(ScriptError): 83 def __init__(self, script_args, exit_code, output, cwd): 84 ScriptError.__init__(self, script_args=script_args, exit_code=exit_code, output=output, cwd=cwd) 85 86 87def commit_error_handler(error): 88 if re.search("resource out of date", error.output): 89 raise CheckoutNeedsUpdate(script_args=error.script_args, exit_code=error.exit_code, output=error.output, cwd=error.cwd) 90 Executive.default_error_handler(error) 91 92 93class SCM: 94 def __init__(self, cwd, dryrun=False): 95 self.cwd = cwd 96 self.checkout_root = self.find_checkout_root(self.cwd) 97 self.dryrun = dryrun 98 99 def scripts_directory(self): 100 return os.path.join(self.checkout_root, "WebKitTools", "Scripts") 101 102 def script_path(self, script_name): 103 return os.path.join(self.scripts_directory(), script_name) 104 105 def ensure_clean_working_directory(self, force_clean): 106 if not force_clean and not self.working_directory_is_clean(): 107 print run_command(self.status_command(), error_handler=Executive.ignore_error) 108 raise ScriptError(message="Working directory has modifications, pass --force-clean or --no-clean to continue.") 109 110 log("Cleaning working directory") 111 self.clean_working_directory() 112 113 def ensure_no_local_commits(self, force): 114 if not self.supports_local_commits(): 115 return 116 commits = self.local_commits() 117 if not len(commits): 118 return 119 if not force: 120 error("Working directory has local commits, pass --force-clean to continue.") 121 self.discard_local_commits() 122 123 def apply_patch(self, patch, force=False): 124 # It's possible that the patch was not made from the root directory. 125 # We should detect and handle that case. 126 # FIXME: scm.py should not deal with fetching Attachment data. Attachment should just have a .data() accessor. 127 curl_process = subprocess.Popen(['curl', '--location', '--silent', '--show-error', patch.url()], stdout=subprocess.PIPE) 128 args = [self.script_path('svn-apply')] 129 if patch.reviewer(): 130 args += ['--reviewer', patch.reviewer().full_name] 131 if force: 132 args.append('--force') 133 134 run_command(args, input=curl_process.stdout) 135 136 def run_status_and_extract_filenames(self, status_command, status_regexp): 137 filenames = [] 138 for line in run_command(status_command).splitlines(): 139 match = re.search(status_regexp, line) 140 if not match: 141 continue 142 # status = match.group('status') 143 filename = match.group('filename') 144 filenames.append(filename) 145 return filenames 146 147 def strip_r_from_svn_revision(self, svn_revision): 148 match = re.match("^r(?P<svn_revision>\d+)", svn_revision) 149 if (match): 150 return match.group('svn_revision') 151 return svn_revision 152 153 def svn_revision_from_commit_text(self, commit_text): 154 match = re.search(self.commit_success_regexp(), commit_text, re.MULTILINE) 155 return match.group('svn_revision') 156 157 # ChangeLog-specific code doesn't really belong in scm.py, but this function is very useful. 158 def modified_changelogs(self): 159 changelog_paths = [] 160 paths = self.changed_files() 161 for path in paths: 162 if os.path.basename(path) == "ChangeLog": 163 changelog_paths.append(path) 164 return changelog_paths 165 166 # FIXME: Requires unit test 167 # FIXME: commit_message_for_this_commit and modified_changelogs don't 168 # really belong here. We should have a separate module for 169 # handling ChangeLogs. 170 def commit_message_for_this_commit(self): 171 changelog_paths = self.modified_changelogs() 172 if not len(changelog_paths): 173 raise ScriptError(message="Found no modified ChangeLogs, cannot create a commit message.\n" 174 "All changes require a ChangeLog. See:\n" 175 "http://webkit.org/coding/contributing.html") 176 177 changelog_messages = [] 178 for changelog_path in changelog_paths: 179 log("Parsing ChangeLog: %s" % changelog_path) 180 changelog_entry = ChangeLog(changelog_path).latest_entry() 181 if not changelog_entry: 182 raise ScriptError(message="Failed to parse ChangeLog: " + os.path.abspath(changelog_path)) 183 changelog_messages.append(changelog_entry) 184 185 # FIXME: We should sort and label the ChangeLog messages like commit-log-editor does. 186 return CommitMessage("".join(changelog_messages).splitlines()) 187 188 @staticmethod 189 def in_working_directory(path): 190 raise NotImplementedError, "subclasses must implement" 191 192 @staticmethod 193 def find_checkout_root(path): 194 raise NotImplementedError, "subclasses must implement" 195 196 @staticmethod 197 def commit_success_regexp(): 198 raise NotImplementedError, "subclasses must implement" 199 200 def working_directory_is_clean(self): 201 raise NotImplementedError, "subclasses must implement" 202 203 def clean_working_directory(self): 204 raise NotImplementedError, "subclasses must implement" 205 206 def status_command(self): 207 raise NotImplementedError, "subclasses must implement" 208 209 def changed_files(self): 210 raise NotImplementedError, "subclasses must implement" 211 212 def display_name(self): 213 raise NotImplementedError, "subclasses must implement" 214 215 def create_patch(self): 216 raise NotImplementedError, "subclasses must implement" 217 218 def diff_for_revision(self, revision): 219 raise NotImplementedError, "subclasses must implement" 220 221 def apply_reverse_diff(self, revision): 222 raise NotImplementedError, "subclasses must implement" 223 224 def revert_files(self, file_paths): 225 raise NotImplementedError, "subclasses must implement" 226 227 def commit_with_message(self, message): 228 raise NotImplementedError, "subclasses must implement" 229 230 def svn_commit_log(self, svn_revision): 231 raise NotImplementedError, "subclasses must implement" 232 233 def last_svn_commit_log(self): 234 raise NotImplementedError, "subclasses must implement" 235 236 # Subclasses must indicate if they support local commits, 237 # but the SCM baseclass will only call local_commits methods when this is true. 238 @staticmethod 239 def supports_local_commits(): 240 raise NotImplementedError, "subclasses must implement" 241 242 def create_patch_from_local_commit(self, commit_id): 243 error("Your source control manager does not support creating a patch from a local commit.") 244 245 def create_patch_since_local_commit(self, commit_id): 246 error("Your source control manager does not support creating a patch from a local commit.") 247 248 def commit_locally_with_message(self, message): 249 error("Your source control manager does not support local commits.") 250 251 def discard_local_commits(self): 252 pass 253 254 def local_commits(self): 255 return [] 256 257 258class SVN(SCM): 259 def __init__(self, cwd, dryrun=False): 260 SCM.__init__(self, cwd, dryrun) 261 self.cached_version = None 262 263 @staticmethod 264 def in_working_directory(path): 265 return os.path.isdir(os.path.join(path, '.svn')) 266 267 @classmethod 268 def find_uuid(cls, path): 269 if not cls.in_working_directory(path): 270 return None 271 return cls.value_from_svn_info(path, 'Repository UUID') 272 273 @classmethod 274 def value_from_svn_info(cls, path, field_name): 275 svn_info_args = ['svn', 'info', path] 276 info_output = run_command(svn_info_args).rstrip() 277 match = re.search("^%s: (?P<value>.+)$" % field_name, info_output, re.MULTILINE) 278 if not match: 279 raise ScriptError(script_args=svn_info_args, message='svn info did not contain a %s.' % field_name) 280 return match.group('value') 281 282 @staticmethod 283 def find_checkout_root(path): 284 uuid = SVN.find_uuid(path) 285 # If |path| is not in a working directory, we're supposed to return |path|. 286 if not uuid: 287 return path 288 # Search up the directory hierarchy until we find a different UUID. 289 last_path = None 290 while True: 291 if uuid != SVN.find_uuid(path): 292 return last_path 293 last_path = path 294 (path, last_component) = os.path.split(path) 295 if last_path == path: 296 return None 297 298 @staticmethod 299 def commit_success_regexp(): 300 return "^Committed revision (?P<svn_revision>\d+)\.$" 301 302 def svn_version(self): 303 if not self.cached_version: 304 self.cached_version = run_command(['svn', '--version', '--quiet']) 305 306 return self.cached_version 307 308 def working_directory_is_clean(self): 309 return run_command(['svn', 'diff']) == "" 310 311 def clean_working_directory(self): 312 run_command(['svn', 'revert', '-R', '.']) 313 314 def status_command(self): 315 return ['svn', 'status'] 316 317 def changed_files(self): 318 if self.svn_version() > "1.6": 319 status_regexp = "^(?P<status>[ACDMR]).{6} (?P<filename>.+)$" 320 else: 321 status_regexp = "^(?P<status>[ACDMR]).{5} (?P<filename>.+)$" 322 return self.run_status_and_extract_filenames(self.status_command(), status_regexp) 323 324 @staticmethod 325 def supports_local_commits(): 326 return False 327 328 def display_name(self): 329 return "svn" 330 331 def create_patch(self): 332 return run_command(self.script_path("svn-create-patch"), cwd=self.checkout_root, return_stderr=False) 333 334 def diff_for_revision(self, revision): 335 return run_command(['svn', 'diff', '-c', str(revision)]) 336 337 def _repository_url(self): 338 return self.value_from_svn_info(self.checkout_root, 'URL') 339 340 def apply_reverse_diff(self, revision): 341 # '-c -revision' applies the inverse diff of 'revision' 342 svn_merge_args = ['svn', 'merge', '--non-interactive', '-c', '-%s' % revision, self._repository_url()] 343 log("WARNING: svn merge has been known to take more than 10 minutes to complete. It is recommended you use git for rollouts.") 344 log("Running '%s'" % " ".join(svn_merge_args)) 345 run_command(svn_merge_args) 346 347 def revert_files(self, file_paths): 348 run_command(['svn', 'revert'] + file_paths) 349 350 def commit_with_message(self, message): 351 if self.dryrun: 352 # Return a string which looks like a commit so that things which parse this output will succeed. 353 return "Dry run, no commit.\nCommitted revision 0." 354 return run_command(['svn', 'commit', '-m', message], error_handler=commit_error_handler) 355 356 def svn_commit_log(self, svn_revision): 357 svn_revision = self.strip_r_from_svn_revision(str(svn_revision)) 358 return run_command(['svn', 'log', '--non-interactive', '--revision', svn_revision]); 359 360 def last_svn_commit_log(self): 361 # BASE is the checkout revision, HEAD is the remote repository revision 362 # http://svnbook.red-bean.com/en/1.0/ch03s03.html 363 return self.svn_commit_log('BASE') 364 365# All git-specific logic should go here. 366class Git(SCM): 367 def __init__(self, cwd, dryrun=False): 368 SCM.__init__(self, cwd, dryrun) 369 370 @classmethod 371 def in_working_directory(cls, path): 372 return run_command(['git', 'rev-parse', '--is-inside-work-tree'], cwd=path, error_handler=Executive.ignore_error).rstrip() == "true" 373 374 @classmethod 375 def find_checkout_root(cls, path): 376 # "git rev-parse --show-cdup" would be another way to get to the root 377 (checkout_root, dot_git) = os.path.split(run_command(['git', 'rev-parse', '--git-dir'], cwd=path)) 378 # If we were using 2.6 # checkout_root = os.path.relpath(checkout_root, path) 379 if not os.path.isabs(checkout_root): # Sometimes git returns relative paths 380 checkout_root = os.path.join(path, checkout_root) 381 return checkout_root 382 383 @staticmethod 384 def commit_success_regexp(): 385 return "^Committed r(?P<svn_revision>\d+)$" 386 387 388 def discard_local_commits(self): 389 run_command(['git', 'reset', '--hard', 'trunk']) 390 391 def local_commits(self): 392 return run_command(['git', 'log', '--pretty=oneline', 'HEAD...trunk']).splitlines() 393 394 def rebase_in_progress(self): 395 return os.path.exists(os.path.join(self.checkout_root, '.git/rebase-apply')) 396 397 def working_directory_is_clean(self): 398 return run_command(['git', 'diff-index', 'HEAD']) == "" 399 400 def clean_working_directory(self): 401 # Could run git clean here too, but that wouldn't match working_directory_is_clean 402 run_command(['git', 'reset', '--hard', 'HEAD']) 403 # Aborting rebase even though this does not match working_directory_is_clean 404 if self.rebase_in_progress(): 405 run_command(['git', 'rebase', '--abort']) 406 407 def status_command(self): 408 return ['git', 'status'] 409 410 def changed_files(self): 411 status_command = ['git', 'diff', '-r', '--name-status', '-C', '-M', 'HEAD'] 412 status_regexp = '^(?P<status>[ADM])\t(?P<filename>.+)$' 413 return self.run_status_and_extract_filenames(status_command, status_regexp) 414 415 @staticmethod 416 def supports_local_commits(): 417 return True 418 419 def display_name(self): 420 return "git" 421 422 def create_patch(self): 423 return run_command(['git', 'diff', '--binary', 'HEAD']) 424 425 @classmethod 426 def git_commit_from_svn_revision(cls, revision): 427 # git svn find-rev always exits 0, even when the revision is not found. 428 return run_command(['git', 'svn', 'find-rev', 'r%s' % revision]).rstrip() 429 430 def diff_for_revision(self, revision): 431 git_commit = self.git_commit_from_svn_revision(revision) 432 return self.create_patch_from_local_commit(git_commit) 433 434 def apply_reverse_diff(self, revision): 435 # Assume the revision is an svn revision. 436 git_commit = self.git_commit_from_svn_revision(revision) 437 if not git_commit: 438 raise ScriptError(message='Failed to find git commit for revision %s, git svn log output: "%s"' % (revision, git_commit)) 439 440 # I think this will always fail due to ChangeLogs. 441 # FIXME: We need to detec specific failure conditions and handle them. 442 run_command(['git', 'revert', '--no-commit', git_commit], error_handler=Executive.ignore_error) 443 444 # Fix any ChangeLogs if necessary. 445 changelog_paths = self.modified_changelogs() 446 if len(changelog_paths): 447 run_command([self.script_path('resolve-ChangeLogs')] + changelog_paths) 448 449 def revert_files(self, file_paths): 450 run_command(['git', 'checkout', 'HEAD'] + file_paths) 451 452 def commit_with_message(self, message): 453 self.commit_locally_with_message(message) 454 return self.push_local_commits_to_server() 455 456 def svn_commit_log(self, svn_revision): 457 svn_revision = self.strip_r_from_svn_revision(svn_revision) 458 return run_command(['git', 'svn', 'log', '-r', svn_revision]) 459 460 def last_svn_commit_log(self): 461 return run_command(['git', 'svn', 'log', '--limit=1']) 462 463 # Git-specific methods: 464 465 def create_patch_from_local_commit(self, commit_id): 466 return run_command(['git', 'diff', '--binary', commit_id + "^.." + commit_id]) 467 468 def create_patch_since_local_commit(self, commit_id): 469 return run_command(['git', 'diff', '--binary', commit_id]) 470 471 def commit_locally_with_message(self, message): 472 run_command(['git', 'commit', '--all', '-F', '-'], input=message) 473 474 def push_local_commits_to_server(self): 475 if self.dryrun: 476 # Return a string which looks like a commit so that things which parse this output will succeed. 477 return "Dry run, no remote commit.\nCommitted r0" 478 return run_command(['git', 'svn', 'dcommit'], error_handler=commit_error_handler) 479 480 # This function supports the following argument formats: 481 # no args : rev-list trunk..HEAD 482 # A..B : rev-list A..B 483 # A...B : error! 484 # A B : [A, B] (different from git diff, which would use "rev-list A..B") 485 def commit_ids_from_commitish_arguments(self, args): 486 if not len(args): 487 # FIXME: trunk is not always the remote branch name, need a way to detect the name. 488 args.append('trunk..HEAD') 489 490 commit_ids = [] 491 for commitish in args: 492 if '...' in commitish: 493 raise ScriptError(message="'...' is not supported (found in '%s'). Did you mean '..'?" % commitish) 494 elif '..' in commitish: 495 commit_ids += reversed(run_command(['git', 'rev-list', commitish]).splitlines()) 496 else: 497 # Turn single commits or branch or tag names into commit ids. 498 commit_ids += run_command(['git', 'rev-parse', '--revs-only', commitish]).splitlines() 499 return commit_ids 500 501 def commit_message_for_local_commit(self, commit_id): 502 commit_lines = run_command(['git', 'cat-file', 'commit', commit_id]).splitlines() 503 504 # Skip the git headers. 505 first_line_after_headers = 0 506 for line in commit_lines: 507 first_line_after_headers += 1 508 if line == "": 509 break 510 return CommitMessage(commit_lines[first_line_after_headers:]) 511 512 def files_changed_summary_for_commit(self, commit_id): 513 return run_command(['git', 'diff-tree', '--shortstat', '--no-commit-id', commit_id]) 514