|
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 |
|
32 import os |
|
33 import re |
|
34 import sys |
|
35 import shutil |
|
36 |
|
37 from webkitpy.common.system.executive import Executive, run_command, ScriptError |
|
38 from webkitpy.common.system.deprecated_logging import error, log |
|
39 |
|
40 |
|
41 def detect_scm_system(path): |
|
42 absolute_path = os.path.abspath(path) |
|
43 |
|
44 if SVN.in_working_directory(absolute_path): |
|
45 return SVN(cwd=absolute_path) |
|
46 |
|
47 if Git.in_working_directory(absolute_path): |
|
48 return Git(cwd=absolute_path) |
|
49 |
|
50 return None |
|
51 |
|
52 |
|
53 def first_non_empty_line_after_index(lines, index=0): |
|
54 first_non_empty_line = index |
|
55 for line in lines[index:]: |
|
56 if re.match("^\s*$", line): |
|
57 first_non_empty_line += 1 |
|
58 else: |
|
59 break |
|
60 return first_non_empty_line |
|
61 |
|
62 |
|
63 class CommitMessage: |
|
64 def __init__(self, message): |
|
65 self.message_lines = message[first_non_empty_line_after_index(message, 0):] |
|
66 |
|
67 def body(self, lstrip=False): |
|
68 lines = self.message_lines[first_non_empty_line_after_index(self.message_lines, 1):] |
|
69 if lstrip: |
|
70 lines = [line.lstrip() for line in lines] |
|
71 return "\n".join(lines) + "\n" |
|
72 |
|
73 def description(self, lstrip=False, strip_url=False): |
|
74 line = self.message_lines[0] |
|
75 if lstrip: |
|
76 line = line.lstrip() |
|
77 if strip_url: |
|
78 line = re.sub("^(\s*)<.+> ", "\1", line) |
|
79 return line |
|
80 |
|
81 def message(self): |
|
82 return "\n".join(self.message_lines) + "\n" |
|
83 |
|
84 |
|
85 class CheckoutNeedsUpdate(ScriptError): |
|
86 def __init__(self, script_args, exit_code, output, cwd): |
|
87 ScriptError.__init__(self, script_args=script_args, exit_code=exit_code, output=output, cwd=cwd) |
|
88 |
|
89 |
|
90 def commit_error_handler(error): |
|
91 if re.search("resource out of date", error.output): |
|
92 raise CheckoutNeedsUpdate(script_args=error.script_args, exit_code=error.exit_code, output=error.output, cwd=error.cwd) |
|
93 Executive.default_error_handler(error) |
|
94 |
|
95 |
|
96 class AuthenticationError(Exception): |
|
97 def __init__(self, server_host): |
|
98 self.server_host = server_host |
|
99 |
|
100 |
|
101 class AmbiguousCommitError(Exception): |
|
102 def __init__(self, num_local_commits, working_directory_is_clean): |
|
103 self.num_local_commits = num_local_commits |
|
104 self.working_directory_is_clean = working_directory_is_clean |
|
105 |
|
106 |
|
107 # SCM methods are expected to return paths relative to self.checkout_root. |
|
108 class SCM: |
|
109 def __init__(self, cwd): |
|
110 self.cwd = cwd |
|
111 self.checkout_root = self.find_checkout_root(self.cwd) |
|
112 self.dryrun = False |
|
113 |
|
114 # A wrapper used by subclasses to create processes. |
|
115 def run(self, args, cwd=None, input=None, error_handler=None, return_exit_code=False, return_stderr=True, decode_output=True): |
|
116 # FIXME: We should set cwd appropriately. |
|
117 # FIXME: We should use Executive. |
|
118 return run_command(args, |
|
119 cwd=cwd, |
|
120 input=input, |
|
121 error_handler=error_handler, |
|
122 return_exit_code=return_exit_code, |
|
123 return_stderr=return_stderr, |
|
124 decode_output=decode_output) |
|
125 |
|
126 # SCM always returns repository relative path, but sometimes we need |
|
127 # absolute paths to pass to rm, etc. |
|
128 def absolute_path(self, repository_relative_path): |
|
129 return os.path.join(self.checkout_root, repository_relative_path) |
|
130 |
|
131 # FIXME: This belongs in Checkout, not SCM. |
|
132 def scripts_directory(self): |
|
133 return os.path.join(self.checkout_root, "WebKitTools", "Scripts") |
|
134 |
|
135 # FIXME: This belongs in Checkout, not SCM. |
|
136 def script_path(self, script_name): |
|
137 return os.path.join(self.scripts_directory(), script_name) |
|
138 |
|
139 def ensure_clean_working_directory(self, force_clean): |
|
140 if not force_clean and not self.working_directory_is_clean(): |
|
141 # FIXME: Shouldn't this use cwd=self.checkout_root? |
|
142 print self.run(self.status_command(), error_handler=Executive.ignore_error) |
|
143 raise ScriptError(message="Working directory has modifications, pass --force-clean or --no-clean to continue.") |
|
144 |
|
145 log("Cleaning working directory") |
|
146 self.clean_working_directory() |
|
147 |
|
148 def ensure_no_local_commits(self, force): |
|
149 if not self.supports_local_commits(): |
|
150 return |
|
151 commits = self.local_commits() |
|
152 if not len(commits): |
|
153 return |
|
154 if not force: |
|
155 error("Working directory has local commits, pass --force-clean to continue.") |
|
156 self.discard_local_commits() |
|
157 |
|
158 def run_status_and_extract_filenames(self, status_command, status_regexp): |
|
159 filenames = [] |
|
160 # We run with cwd=self.checkout_root so that returned-paths are root-relative. |
|
161 for line in self.run(status_command, cwd=self.checkout_root).splitlines(): |
|
162 match = re.search(status_regexp, line) |
|
163 if not match: |
|
164 continue |
|
165 # status = match.group('status') |
|
166 filename = match.group('filename') |
|
167 filenames.append(filename) |
|
168 return filenames |
|
169 |
|
170 def strip_r_from_svn_revision(self, svn_revision): |
|
171 match = re.match("^r(?P<svn_revision>\d+)", unicode(svn_revision)) |
|
172 if (match): |
|
173 return match.group('svn_revision') |
|
174 return svn_revision |
|
175 |
|
176 def svn_revision_from_commit_text(self, commit_text): |
|
177 match = re.search(self.commit_success_regexp(), commit_text, re.MULTILINE) |
|
178 return match.group('svn_revision') |
|
179 |
|
180 @staticmethod |
|
181 def _subclass_must_implement(): |
|
182 raise NotImplementedError("subclasses must implement") |
|
183 |
|
184 @staticmethod |
|
185 def in_working_directory(path): |
|
186 SCM._subclass_must_implement() |
|
187 |
|
188 @staticmethod |
|
189 def find_checkout_root(path): |
|
190 SCM._subclass_must_implement() |
|
191 |
|
192 @staticmethod |
|
193 def commit_success_regexp(): |
|
194 SCM._subclass_must_implement() |
|
195 |
|
196 def working_directory_is_clean(self): |
|
197 self._subclass_must_implement() |
|
198 |
|
199 def clean_working_directory(self): |
|
200 self._subclass_must_implement() |
|
201 |
|
202 def status_command(self): |
|
203 self._subclass_must_implement() |
|
204 |
|
205 def add(self, path, return_exit_code=False): |
|
206 self._subclass_must_implement() |
|
207 |
|
208 def delete(self, path): |
|
209 self._subclass_must_implement() |
|
210 |
|
211 def changed_files(self, git_commit=None): |
|
212 self._subclass_must_implement() |
|
213 |
|
214 def changed_files_for_revision(self): |
|
215 self._subclass_must_implement() |
|
216 |
|
217 def added_files(self): |
|
218 self._subclass_must_implement() |
|
219 |
|
220 def conflicted_files(self): |
|
221 self._subclass_must_implement() |
|
222 |
|
223 def display_name(self): |
|
224 self._subclass_must_implement() |
|
225 |
|
226 def create_patch(self, git_commit=None): |
|
227 self._subclass_must_implement() |
|
228 |
|
229 def committer_email_for_revision(self, revision): |
|
230 self._subclass_must_implement() |
|
231 |
|
232 def contents_at_revision(self, path, revision): |
|
233 self._subclass_must_implement() |
|
234 |
|
235 def diff_for_revision(self, revision): |
|
236 self._subclass_must_implement() |
|
237 |
|
238 def diff_for_file(self, path, log=None): |
|
239 self._subclass_must_implement() |
|
240 |
|
241 def show_head(self, path): |
|
242 self._subclass_must_implement() |
|
243 |
|
244 def apply_reverse_diff(self, revision): |
|
245 self._subclass_must_implement() |
|
246 |
|
247 def revert_files(self, file_paths): |
|
248 self._subclass_must_implement() |
|
249 |
|
250 def commit_with_message(self, message, username=None, git_commit=None, force_squash=False): |
|
251 self._subclass_must_implement() |
|
252 |
|
253 def svn_commit_log(self, svn_revision): |
|
254 self._subclass_must_implement() |
|
255 |
|
256 def last_svn_commit_log(self): |
|
257 self._subclass_must_implement() |
|
258 |
|
259 # Subclasses must indicate if they support local commits, |
|
260 # but the SCM baseclass will only call local_commits methods when this is true. |
|
261 @staticmethod |
|
262 def supports_local_commits(): |
|
263 SCM._subclass_must_implement() |
|
264 |
|
265 def remote_merge_base(): |
|
266 SCM._subclass_must_implement() |
|
267 |
|
268 def commit_locally_with_message(self, message): |
|
269 error("Your source control manager does not support local commits.") |
|
270 |
|
271 def discard_local_commits(self): |
|
272 pass |
|
273 |
|
274 def local_commits(self): |
|
275 return [] |
|
276 |
|
277 |
|
278 class SVN(SCM): |
|
279 # FIXME: We should move these values to a WebKit-specific config. file. |
|
280 svn_server_host = "svn.webkit.org" |
|
281 svn_server_realm = "<http://svn.webkit.org:80> Mac OS Forge" |
|
282 |
|
283 def __init__(self, cwd): |
|
284 SCM.__init__(self, cwd) |
|
285 self.cached_version = None |
|
286 self._bogus_dir = None |
|
287 |
|
288 @staticmethod |
|
289 def in_working_directory(path): |
|
290 return os.path.isdir(os.path.join(path, '.svn')) |
|
291 |
|
292 @classmethod |
|
293 def find_uuid(cls, path): |
|
294 if not cls.in_working_directory(path): |
|
295 return None |
|
296 return cls.value_from_svn_info(path, 'Repository UUID') |
|
297 |
|
298 @classmethod |
|
299 def value_from_svn_info(cls, path, field_name): |
|
300 svn_info_args = ['svn', 'info', path] |
|
301 info_output = run_command(svn_info_args).rstrip() |
|
302 match = re.search("^%s: (?P<value>.+)$" % field_name, info_output, re.MULTILINE) |
|
303 if not match: |
|
304 raise ScriptError(script_args=svn_info_args, message='svn info did not contain a %s.' % field_name) |
|
305 return match.group('value') |
|
306 |
|
307 @staticmethod |
|
308 def find_checkout_root(path): |
|
309 uuid = SVN.find_uuid(path) |
|
310 # If |path| is not in a working directory, we're supposed to return |path|. |
|
311 if not uuid: |
|
312 return path |
|
313 # Search up the directory hierarchy until we find a different UUID. |
|
314 last_path = None |
|
315 while True: |
|
316 if uuid != SVN.find_uuid(path): |
|
317 return last_path |
|
318 last_path = path |
|
319 (path, last_component) = os.path.split(path) |
|
320 if last_path == path: |
|
321 return None |
|
322 |
|
323 @staticmethod |
|
324 def commit_success_regexp(): |
|
325 return "^Committed revision (?P<svn_revision>\d+)\.$" |
|
326 |
|
327 def has_authorization_for_realm(self, realm=svn_server_realm, home_directory=os.getenv("HOME")): |
|
328 # Assumes find and grep are installed. |
|
329 if not os.path.isdir(os.path.join(home_directory, ".subversion")): |
|
330 return False |
|
331 find_args = ["find", ".subversion", "-type", "f", "-exec", "grep", "-q", realm, "{}", ";", "-print"]; |
|
332 find_output = self.run(find_args, cwd=home_directory, error_handler=Executive.ignore_error).rstrip() |
|
333 return find_output and os.path.isfile(os.path.join(home_directory, find_output)) |
|
334 |
|
335 def svn_version(self): |
|
336 if not self.cached_version: |
|
337 self.cached_version = self.run(['svn', '--version', '--quiet']) |
|
338 |
|
339 return self.cached_version |
|
340 |
|
341 def working_directory_is_clean(self): |
|
342 return self.run(["svn", "diff"], cwd=self.checkout_root, decode_output=False) == "" |
|
343 |
|
344 def clean_working_directory(self): |
|
345 # svn revert -R is not as awesome as git reset --hard. |
|
346 # It will leave added files around, causing later svn update |
|
347 # calls to fail on the bots. We make this mirror git reset --hard |
|
348 # by deleting any added files as well. |
|
349 added_files = reversed(sorted(self.added_files())) |
|
350 # added_files() returns directories for SVN, we walk the files in reverse path |
|
351 # length order so that we remove files before we try to remove the directories. |
|
352 self.run(["svn", "revert", "-R", "."], cwd=self.checkout_root) |
|
353 for path in added_files: |
|
354 # This is robust against cwd != self.checkout_root |
|
355 absolute_path = self.absolute_path(path) |
|
356 # Completely lame that there is no easy way to remove both types with one call. |
|
357 if os.path.isdir(path): |
|
358 os.rmdir(absolute_path) |
|
359 else: |
|
360 os.remove(absolute_path) |
|
361 |
|
362 def status_command(self): |
|
363 return ['svn', 'status'] |
|
364 |
|
365 def _status_regexp(self, expected_types): |
|
366 field_count = 6 if self.svn_version() > "1.6" else 5 |
|
367 return "^(?P<status>[%s]).{%s} (?P<filename>.+)$" % (expected_types, field_count) |
|
368 |
|
369 def _add_parent_directories(self, path): |
|
370 """Does 'svn add' to the path and its parents.""" |
|
371 if self.in_working_directory(path): |
|
372 return |
|
373 dirname = os.path.dirname(path) |
|
374 # We have dirname directry - ensure it added. |
|
375 if dirname != path: |
|
376 self._add_parent_directories(dirname) |
|
377 self.add(path) |
|
378 |
|
379 def add(self, path, return_exit_code=False): |
|
380 self._add_parent_directories(os.path.dirname(os.path.abspath(path))) |
|
381 return self.run(["svn", "add", path], return_exit_code=return_exit_code) |
|
382 |
|
383 def delete(self, path): |
|
384 parent, base = os.path.split(os.path.abspath(path)) |
|
385 return self.run(["svn", "delete", "--force", base], cwd=parent) |
|
386 |
|
387 def changed_files(self, git_commit=None): |
|
388 return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("ACDMR")) |
|
389 |
|
390 def changed_files_for_revision(self, revision): |
|
391 # As far as I can tell svn diff --summarize output looks just like svn status output. |
|
392 # No file contents printed, thus utf-8 auto-decoding in self.run is fine. |
|
393 status_command = ["svn", "diff", "--summarize", "-c", revision] |
|
394 return self.run_status_and_extract_filenames(status_command, self._status_regexp("ACDMR")) |
|
395 |
|
396 def conflicted_files(self): |
|
397 return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("C")) |
|
398 |
|
399 def added_files(self): |
|
400 return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A")) |
|
401 |
|
402 def deleted_files(self): |
|
403 return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D")) |
|
404 |
|
405 @staticmethod |
|
406 def supports_local_commits(): |
|
407 return False |
|
408 |
|
409 def display_name(self): |
|
410 return "svn" |
|
411 |
|
412 # FIXME: This method should be on Checkout. |
|
413 def create_patch(self, git_commit=None): |
|
414 """Returns a byte array (str()) representing the patch file. |
|
415 Patch files are effectively binary since they may contain |
|
416 files of multiple different encodings.""" |
|
417 return self.run([self.script_path("svn-create-patch")], |
|
418 cwd=self.checkout_root, return_stderr=False, |
|
419 decode_output=False) |
|
420 |
|
421 def committer_email_for_revision(self, revision): |
|
422 return self.run(["svn", "propget", "svn:author", "--revprop", "-r", revision]).rstrip() |
|
423 |
|
424 def contents_at_revision(self, path, revision): |
|
425 """Returns a byte array (str()) containing the contents |
|
426 of path @ revision in the repository.""" |
|
427 remote_path = "%s/%s" % (self._repository_url(), path) |
|
428 return self.run(["svn", "cat", "-r", revision, remote_path], decode_output=False) |
|
429 |
|
430 def diff_for_revision(self, revision): |
|
431 # FIXME: This should probably use cwd=self.checkout_root |
|
432 return self.run(['svn', 'diff', '-c', revision]) |
|
433 |
|
434 def _bogus_dir_name(self): |
|
435 if sys.platform.startswith("win"): |
|
436 parent_dir = tempfile.gettempdir() |
|
437 else: |
|
438 parent_dir = sys.path[0] # tempdir is not secure. |
|
439 return os.path.join(parent_dir, "temp_svn_config") |
|
440 |
|
441 def _setup_bogus_dir(self, log): |
|
442 self._bogus_dir = self._bogus_dir_name() |
|
443 if not os.path.exists(self._bogus_dir): |
|
444 os.mkdir(self._bogus_dir) |
|
445 self._delete_bogus_dir = True |
|
446 else: |
|
447 self._delete_bogus_dir = False |
|
448 if log: |
|
449 log.debug(' Html: temp config dir: "%s".', self._bogus_dir) |
|
450 |
|
451 def _teardown_bogus_dir(self, log): |
|
452 if self._delete_bogus_dir: |
|
453 shutil.rmtree(self._bogus_dir, True) |
|
454 if log: |
|
455 log.debug(' Html: removed temp config dir: "%s".', self._bogus_dir) |
|
456 self._bogus_dir = None |
|
457 |
|
458 def diff_for_file(self, path, log=None): |
|
459 self._setup_bogus_dir(log) |
|
460 try: |
|
461 args = ['svn', 'diff'] |
|
462 if self._bogus_dir: |
|
463 args += ['--config-dir', self._bogus_dir] |
|
464 args.append(path) |
|
465 return self.run(args) |
|
466 finally: |
|
467 self._teardown_bogus_dir(log) |
|
468 |
|
469 def show_head(self, path): |
|
470 return self.run(['svn', 'cat', '-r', 'BASE', path], decode_output=False) |
|
471 |
|
472 def _repository_url(self): |
|
473 return self.value_from_svn_info(self.checkout_root, 'URL') |
|
474 |
|
475 def apply_reverse_diff(self, revision): |
|
476 # '-c -revision' applies the inverse diff of 'revision' |
|
477 svn_merge_args = ['svn', 'merge', '--non-interactive', '-c', '-%s' % revision, self._repository_url()] |
|
478 log("WARNING: svn merge has been known to take more than 10 minutes to complete. It is recommended you use git for rollouts.") |
|
479 log("Running '%s'" % " ".join(svn_merge_args)) |
|
480 # FIXME: Should this use cwd=self.checkout_root? |
|
481 self.run(svn_merge_args) |
|
482 |
|
483 def revert_files(self, file_paths): |
|
484 # FIXME: This should probably use cwd=self.checkout_root. |
|
485 self.run(['svn', 'revert'] + file_paths) |
|
486 |
|
487 def commit_with_message(self, message, username=None, git_commit=None, force_squash=False): |
|
488 # git-commit and force are not used by SVN. |
|
489 if self.dryrun: |
|
490 # Return a string which looks like a commit so that things which parse this output will succeed. |
|
491 return "Dry run, no commit.\nCommitted revision 0." |
|
492 |
|
493 svn_commit_args = ["svn", "commit"] |
|
494 |
|
495 if not username and not self.has_authorization_for_realm(): |
|
496 raise AuthenticationError(self.svn_server_host) |
|
497 if username: |
|
498 svn_commit_args.extend(["--username", username]) |
|
499 |
|
500 svn_commit_args.extend(["-m", message]) |
|
501 # FIXME: Should this use cwd=self.checkout_root? |
|
502 return self.run(svn_commit_args, error_handler=commit_error_handler) |
|
503 |
|
504 def svn_commit_log(self, svn_revision): |
|
505 svn_revision = self.strip_r_from_svn_revision(svn_revision) |
|
506 return self.run(['svn', 'log', '--non-interactive', '--revision', svn_revision]) |
|
507 |
|
508 def last_svn_commit_log(self): |
|
509 # BASE is the checkout revision, HEAD is the remote repository revision |
|
510 # http://svnbook.red-bean.com/en/1.0/ch03s03.html |
|
511 return self.svn_commit_log('BASE') |
|
512 |
|
513 def propset(self, pname, pvalue, path): |
|
514 dir, base = os.path.split(path) |
|
515 return self.run(['svn', 'pset', pname, pvalue, base], cwd=dir) |
|
516 |
|
517 def propget(self, pname, path): |
|
518 dir, base = os.path.split(path) |
|
519 return self.run(['svn', 'pget', pname, base], cwd=dir).encode('utf-8').rstrip("\n") |
|
520 |
|
521 # All git-specific logic should go here. |
|
522 class Git(SCM): |
|
523 def __init__(self, cwd): |
|
524 SCM.__init__(self, cwd) |
|
525 |
|
526 @classmethod |
|
527 def in_working_directory(cls, path): |
|
528 return run_command(['git', 'rev-parse', '--is-inside-work-tree'], cwd=path, error_handler=Executive.ignore_error).rstrip() == "true" |
|
529 |
|
530 @classmethod |
|
531 def find_checkout_root(cls, path): |
|
532 # "git rev-parse --show-cdup" would be another way to get to the root |
|
533 (checkout_root, dot_git) = os.path.split(run_command(['git', 'rev-parse', '--git-dir'], cwd=(path or "./"))) |
|
534 # If we were using 2.6 # checkout_root = os.path.relpath(checkout_root, path) |
|
535 if not os.path.isabs(checkout_root): # Sometimes git returns relative paths |
|
536 checkout_root = os.path.join(path, checkout_root) |
|
537 return checkout_root |
|
538 |
|
539 @classmethod |
|
540 def to_object_name(cls, filepath): |
|
541 root_end_with_slash = os.path.join(cls.find_checkout_root(os.path.dirname(filepath)), '') |
|
542 return filepath.replace(root_end_with_slash, '') |
|
543 |
|
544 @classmethod |
|
545 def read_git_config(cls, key): |
|
546 # FIXME: This should probably use cwd=self.checkout_root. |
|
547 return run_command(["git", "config", key], |
|
548 error_handler=Executive.ignore_error).rstrip('\n') |
|
549 |
|
550 @staticmethod |
|
551 def commit_success_regexp(): |
|
552 return "^Committed r(?P<svn_revision>\d+)$" |
|
553 |
|
554 def discard_local_commits(self): |
|
555 # FIXME: This should probably use cwd=self.checkout_root |
|
556 self.run(['git', 'reset', '--hard', self.remote_branch_ref()]) |
|
557 |
|
558 def local_commits(self): |
|
559 # FIXME: This should probably use cwd=self.checkout_root |
|
560 return self.run(['git', 'log', '--pretty=oneline', 'HEAD...' + self.remote_branch_ref()]).splitlines() |
|
561 |
|
562 def rebase_in_progress(self): |
|
563 return os.path.exists(os.path.join(self.checkout_root, '.git/rebase-apply')) |
|
564 |
|
565 def working_directory_is_clean(self): |
|
566 # FIXME: This should probably use cwd=self.checkout_root |
|
567 return self.run(['git', 'diff', 'HEAD', '--name-only']) == "" |
|
568 |
|
569 def clean_working_directory(self): |
|
570 # FIXME: These should probably use cwd=self.checkout_root. |
|
571 # Could run git clean here too, but that wouldn't match working_directory_is_clean |
|
572 self.run(['git', 'reset', '--hard', 'HEAD']) |
|
573 # Aborting rebase even though this does not match working_directory_is_clean |
|
574 if self.rebase_in_progress(): |
|
575 self.run(['git', 'rebase', '--abort']) |
|
576 |
|
577 def status_command(self): |
|
578 # git status returns non-zero when there are changes, so we use git diff name --name-status HEAD instead. |
|
579 # No file contents printed, thus utf-8 autodecoding in self.run is fine. |
|
580 return ["git", "diff", "--name-status", "HEAD"] |
|
581 |
|
582 def _status_regexp(self, expected_types): |
|
583 return '^(?P<status>[%s])\t(?P<filename>.+)$' % expected_types |
|
584 |
|
585 def add(self, path, return_exit_code=False): |
|
586 return self.run(["git", "add", path], return_exit_code=return_exit_code) |
|
587 |
|
588 def delete(self, path): |
|
589 return self.run(["git", "rm", "-f", path]) |
|
590 |
|
591 def _assert_synced(self): |
|
592 if len(run_command(['git', 'rev-list', '--max-count=1', self.remote_branch_ref(), '^HEAD'])): |
|
593 raise ScriptError(message="Not fully merged/rebased to %s. This branch needs to be synced first." % self.remote_branch_ref()) |
|
594 |
|
595 def merge_base(self, git_commit): |
|
596 if git_commit: |
|
597 # Special-case HEAD.. to mean working-copy changes only. |
|
598 if git_commit.upper() == 'HEAD..': |
|
599 return 'HEAD' |
|
600 |
|
601 if '..' not in git_commit: |
|
602 git_commit = git_commit + "^.." + git_commit |
|
603 return git_commit |
|
604 |
|
605 self._assert_synced() |
|
606 return self.remote_merge_base() |
|
607 |
|
608 def changed_files(self, git_commit=None): |
|
609 status_command = ['git', 'diff', '-r', '--name-status', '-C', '-M', "--no-ext-diff", "--full-index", self.merge_base(git_commit)] |
|
610 return self.run_status_and_extract_filenames(status_command, self._status_regexp("ADM")) |
|
611 |
|
612 def _changes_files_for_commit(self, git_commit): |
|
613 # --pretty="format:" makes git show not print the commit log header, |
|
614 changed_files = self.run(["git", "show", "--pretty=format:", "--name-only", git_commit]).splitlines() |
|
615 # instead it just prints a blank line at the top, so we skip the blank line: |
|
616 return changed_files[1:] |
|
617 |
|
618 def changed_files_for_revision(self, revision): |
|
619 commit_id = self.git_commit_from_svn_revision(revision) |
|
620 return self._changes_files_for_commit(commit_id) |
|
621 |
|
622 def conflicted_files(self): |
|
623 # We do not need to pass decode_output for this diff command |
|
624 # as we're passing --name-status which does not output any data. |
|
625 status_command = ['git', 'diff', '--name-status', '-C', '-M', '--diff-filter=U'] |
|
626 return self.run_status_and_extract_filenames(status_command, self._status_regexp("U")) |
|
627 |
|
628 def added_files(self): |
|
629 return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A")) |
|
630 |
|
631 def deleted_files(self): |
|
632 return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D")) |
|
633 |
|
634 @staticmethod |
|
635 def supports_local_commits(): |
|
636 return True |
|
637 |
|
638 def display_name(self): |
|
639 return "git" |
|
640 |
|
641 def create_patch(self, git_commit=None): |
|
642 """Returns a byte array (str()) representing the patch file. |
|
643 Patch files are effectively binary since they may contain |
|
644 files of multiple different encodings.""" |
|
645 # FIXME: This should probably use cwd=self.checkout_root |
|
646 return self.run(['git', 'diff', '--binary', "--no-ext-diff", "--full-index", "-M", self.merge_base(git_commit)], decode_output=False) |
|
647 |
|
648 @classmethod |
|
649 def git_commit_from_svn_revision(cls, revision): |
|
650 # FIXME: This should probably use cwd=self.checkout_root |
|
651 git_commit = run_command(['git', 'svn', 'find-rev', 'r%s' % revision]).rstrip() |
|
652 # git svn find-rev always exits 0, even when the revision is not found. |
|
653 if not git_commit: |
|
654 raise ScriptError(message='Failed to find git commit for revision %s, your checkout likely needs an update.' % revision) |
|
655 return git_commit |
|
656 |
|
657 def contents_at_revision(self, path, revision): |
|
658 """Returns a byte array (str()) containing the contents |
|
659 of path @ revision in the repository.""" |
|
660 return self.run(["git", "show", "%s:%s" % (self.git_commit_from_svn_revision(revision), path)], decode_output=False) |
|
661 |
|
662 def diff_for_revision(self, revision): |
|
663 git_commit = self.git_commit_from_svn_revision(revision) |
|
664 return self.create_patch(git_commit) |
|
665 |
|
666 def diff_for_file(self, path, log=None): |
|
667 return self.run(['git', 'diff', 'HEAD', '--', path]) |
|
668 |
|
669 def show_head(self, path): |
|
670 return self.run(['git', 'show', 'HEAD:' + self.to_object_name(path)], decode_output=False) |
|
671 |
|
672 def committer_email_for_revision(self, revision): |
|
673 git_commit = self.git_commit_from_svn_revision(revision) |
|
674 committer_email = self.run(["git", "log", "-1", "--pretty=format:%ce", git_commit]) |
|
675 # Git adds an extra @repository_hash to the end of every committer email, remove it: |
|
676 return committer_email.rsplit("@", 1)[0] |
|
677 |
|
678 def apply_reverse_diff(self, revision): |
|
679 # Assume the revision is an svn revision. |
|
680 git_commit = self.git_commit_from_svn_revision(revision) |
|
681 # I think this will always fail due to ChangeLogs. |
|
682 self.run(['git', 'revert', '--no-commit', git_commit], error_handler=Executive.ignore_error) |
|
683 |
|
684 def revert_files(self, file_paths): |
|
685 self.run(['git', 'checkout', 'HEAD'] + file_paths) |
|
686 |
|
687 def _assert_can_squash(self, working_directory_is_clean): |
|
688 squash = Git.read_git_config('webkit-patch.commit_should_always_squash') |
|
689 should_squash = squash and squash.lower() == "true" |
|
690 |
|
691 if not should_squash: |
|
692 # Only warn if there are actually multiple commits to squash. |
|
693 num_local_commits = len(self.local_commits()) |
|
694 if num_local_commits > 1 or (num_local_commits > 0 and not working_directory_is_clean): |
|
695 raise AmbiguousCommitError(num_local_commits, working_directory_is_clean) |
|
696 |
|
697 def commit_with_message(self, message, username=None, git_commit=None, force_squash=False): |
|
698 # Username is ignored during Git commits. |
|
699 working_directory_is_clean = self.working_directory_is_clean() |
|
700 |
|
701 if git_commit: |
|
702 # Special-case HEAD.. to mean working-copy changes only. |
|
703 if git_commit.upper() == 'HEAD..': |
|
704 if working_directory_is_clean: |
|
705 raise ScriptError(message="The working copy is not modified. --git-commit=HEAD.. only commits working copy changes.") |
|
706 self.commit_locally_with_message(message) |
|
707 return self._commit_on_branch(message, 'HEAD') |
|
708 |
|
709 # Need working directory changes to be committed so we can checkout the merge branch. |
|
710 if not working_directory_is_clean: |
|
711 # FIXME: webkit-patch land will modify the ChangeLogs to correct the reviewer. |
|
712 # That will modify the working-copy and cause us to hit this error. |
|
713 # The ChangeLog modification could be made to modify the existing local commit. |
|
714 raise ScriptError(message="Working copy is modified. Cannot commit individual git_commits.") |
|
715 return self._commit_on_branch(message, git_commit) |
|
716 |
|
717 if not force_squash: |
|
718 self._assert_can_squash(working_directory_is_clean) |
|
719 self._assert_synced() |
|
720 self.run(['git', 'reset', '--soft', self.remote_branch_ref()]) |
|
721 self.commit_locally_with_message(message) |
|
722 return self.push_local_commits_to_server() |
|
723 |
|
724 def _commit_on_branch(self, message, git_commit): |
|
725 branch_ref = self.run(['git', 'symbolic-ref', 'HEAD']).strip() |
|
726 branch_name = branch_ref.replace('refs/heads/', '') |
|
727 commit_ids = self.commit_ids_from_commitish_arguments([git_commit]) |
|
728 |
|
729 # We want to squash all this branch's commits into one commit with the proper description. |
|
730 # We do this by doing a "merge --squash" into a new commit branch, then dcommitting that. |
|
731 MERGE_BRANCH_NAME = 'webkit-patch-land' |
|
732 self.delete_branch(MERGE_BRANCH_NAME) |
|
733 |
|
734 # We might be in a directory that's present in this branch but not in the |
|
735 # trunk. Move up to the top of the tree so that git commands that expect a |
|
736 # valid CWD won't fail after we check out the merge branch. |
|
737 os.chdir(self.checkout_root) |
|
738 |
|
739 # Stuff our change into the merge branch. |
|
740 # We wrap in a try...finally block so if anything goes wrong, we clean up the branches. |
|
741 commit_succeeded = True |
|
742 try: |
|
743 self.run(['git', 'checkout', '-q', '-b', MERGE_BRANCH_NAME, self.remote_branch_ref()]) |
|
744 |
|
745 for commit in commit_ids: |
|
746 # We're on a different branch now, so convert "head" to the branch name. |
|
747 commit = re.sub(r'(?i)head', branch_name, commit) |
|
748 # FIXME: Once changed_files and create_patch are modified to separately handle each |
|
749 # commit in a commit range, commit each cherry pick so they'll get dcommitted separately. |
|
750 self.run(['git', 'cherry-pick', '--no-commit', commit]) |
|
751 |
|
752 self.run(['git', 'commit', '-m', message]) |
|
753 output = self.push_local_commits_to_server() |
|
754 except Exception, e: |
|
755 log("COMMIT FAILED: " + str(e)) |
|
756 output = "Commit failed." |
|
757 commit_succeeded = False |
|
758 finally: |
|
759 # And then swap back to the original branch and clean up. |
|
760 self.clean_working_directory() |
|
761 self.run(['git', 'checkout', '-q', branch_name]) |
|
762 self.delete_branch(MERGE_BRANCH_NAME) |
|
763 |
|
764 return output |
|
765 |
|
766 def svn_commit_log(self, svn_revision): |
|
767 svn_revision = self.strip_r_from_svn_revision(svn_revision) |
|
768 return self.run(['git', 'svn', 'log', '-r', svn_revision]) |
|
769 |
|
770 def last_svn_commit_log(self): |
|
771 return self.run(['git', 'svn', 'log', '--limit=1']) |
|
772 |
|
773 # Git-specific methods: |
|
774 def _branch_ref_exists(self, branch_ref): |
|
775 return self.run(['git', 'show-ref', '--quiet', '--verify', branch_ref], return_exit_code=True) == 0 |
|
776 |
|
777 def delete_branch(self, branch_name): |
|
778 if self._branch_ref_exists('refs/heads/' + branch_name): |
|
779 self.run(['git', 'branch', '-D', branch_name]) |
|
780 |
|
781 def remote_merge_base(self): |
|
782 return self.run(['git', 'merge-base', self.remote_branch_ref(), 'HEAD']).strip() |
|
783 |
|
784 def remote_branch_ref(self): |
|
785 # Use references so that we can avoid collisions, e.g. we don't want to operate on refs/heads/trunk if it exists. |
|
786 |
|
787 # FIXME: This should so something like: Git.read_git_config('svn-remote.svn.fetch').split(':')[1] |
|
788 # but that doesn't work if the git repo is tracking multiple svn branches. |
|
789 remote_branch_refs = [ |
|
790 'refs/remotes/trunk', # A git-svn checkout as per http://trac.webkit.org/wiki/UsingGitWithWebKit. |
|
791 'refs/remotes/origin/master', # A git clone of git://git.webkit.org/WebKit.git that is not tracking svn. |
|
792 ] |
|
793 |
|
794 for ref in remote_branch_refs: |
|
795 if self._branch_ref_exists(ref): |
|
796 return ref |
|
797 |
|
798 raise ScriptError(message="Can't find a branch to diff against. %s branches do not exist." % " and ".join(remote_branch_refs)) |
|
799 |
|
800 def commit_locally_with_message(self, message): |
|
801 self.run(['git', 'commit', '--all', '-F', '-'], input=message) |
|
802 |
|
803 def push_local_commits_to_server(self): |
|
804 dcommit_command = ['git', 'svn', 'dcommit'] |
|
805 if self.dryrun: |
|
806 dcommit_command.append('--dry-run') |
|
807 output = self.run(dcommit_command, error_handler=commit_error_handler) |
|
808 # Return a string which looks like a commit so that things which parse this output will succeed. |
|
809 if self.dryrun: |
|
810 output += "\nCommitted r0" |
|
811 return output |
|
812 |
|
813 # This function supports the following argument formats: |
|
814 # no args : rev-list trunk..HEAD |
|
815 # A..B : rev-list A..B |
|
816 # A...B : error! |
|
817 # A B : [A, B] (different from git diff, which would use "rev-list A..B") |
|
818 def commit_ids_from_commitish_arguments(self, args): |
|
819 if not len(args): |
|
820 args.append('%s..HEAD' % self.remote_branch_ref()) |
|
821 |
|
822 commit_ids = [] |
|
823 for commitish in args: |
|
824 if '...' in commitish: |
|
825 raise ScriptError(message="'...' is not supported (found in '%s'). Did you mean '..'?" % commitish) |
|
826 elif '..' in commitish: |
|
827 commit_ids += reversed(self.run(['git', 'rev-list', commitish]).splitlines()) |
|
828 else: |
|
829 # Turn single commits or branch or tag names into commit ids. |
|
830 commit_ids += self.run(['git', 'rev-parse', '--revs-only', commitish]).splitlines() |
|
831 return commit_ids |
|
832 |
|
833 def commit_message_for_local_commit(self, commit_id): |
|
834 commit_lines = self.run(['git', 'cat-file', 'commit', commit_id]).splitlines() |
|
835 |
|
836 # Skip the git headers. |
|
837 first_line_after_headers = 0 |
|
838 for line in commit_lines: |
|
839 first_line_after_headers += 1 |
|
840 if line == "": |
|
841 break |
|
842 return CommitMessage(commit_lines[first_line_after_headers:]) |
|
843 |
|
844 def files_changed_summary_for_commit(self, commit_id): |
|
845 return self.run(['git', 'diff-tree', '--shortstat', '--no-commit-id', commit_id]) |