blob: 902d8e84669441caa13da52c2c31637f337d53f3 [file] [log] [blame]
maruel@chromium.org5f3eee32009-09-17 00:34:30 +00001# Copyright 2009 Google Inc. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15
16import os
17import re
18import subprocess
19import sys
20import xml.dom.minidom
21
22import gclient_utils
23
24SVN_COMMAND = "svn"
25
26
27### SCM abstraction layer
28
29
30class SCMWrapper(object):
31 """Add necessary glue between all the supported SCM.
32
33 This is the abstraction layer to bind to different SCM. Since currently only
34 subversion is supported, a lot of subersionism remains. This can be sorted out
35 once another SCM is supported."""
36 def __init__(self, url=None, root_dir=None, relpath=None,
37 scm_name='svn'):
38 # TODO(maruel): Deduce the SCM from the url.
39 self.scm_name = scm_name
40 self.url = url
41 self._root_dir = root_dir
42 if self._root_dir:
43 self._root_dir = self._root_dir.replace('/', os.sep)
44 self.relpath = relpath
45 if self.relpath:
46 self.relpath = self.relpath.replace('/', os.sep)
47
48 def FullUrlForRelativeUrl(self, url):
49 # Find the forth '/' and strip from there. A bit hackish.
50 return '/'.join(self.url.split('/')[:4]) + url
51
52 def RunCommand(self, command, options, args, file_list=None):
53 # file_list will have all files that are modified appended to it.
54
55 file_list = file_list or []
56
57 commands = {
58 'cleanup': self.cleanup,
59 'export': self.export,
60 'update': self.update,
61 'revert': self.revert,
62 'status': self.status,
63 'diff': self.diff,
64 'pack': self.pack,
65 'runhooks': self.status,
66 }
67
68 if not command in commands:
69 raise gclient_utils.Error('Unknown command %s' % command)
70
71 return commands[command](options, args, file_list)
72
73 def cleanup(self, options, args, file_list):
74 """Cleanup working copy."""
75 command = ['cleanup']
76 command.extend(args)
77 RunSVN(command, os.path.join(self._root_dir, self.relpath))
78
79 def diff(self, options, args, file_list):
80 # NOTE: This function does not currently modify file_list.
81 command = ['diff']
82 command.extend(args)
83 RunSVN(command, os.path.join(self._root_dir, self.relpath))
84
85 def export(self, options, args, file_list):
86 assert len(args) == 1
87 export_path = os.path.abspath(os.path.join(args[0], self.relpath))
88 try:
89 os.makedirs(export_path)
90 except OSError:
91 pass
92 assert os.path.exists(export_path)
93 command = ['export', '--force', '.']
94 command.append(export_path)
95 RunSVN(command, os.path.join(self._root_dir, self.relpath))
96
97 def update(self, options, args, file_list):
98 """Runs SCM to update or transparently checkout the working copy.
99
100 All updated files will be appended to file_list.
101
102 Raises:
103 Error: if can't get URL for relative path.
104 """
105 # Only update if git is not controlling the directory.
106 checkout_path = os.path.join(self._root_dir, self.relpath)
107 git_path = os.path.join(self._root_dir, self.relpath, '.git')
108 if os.path.exists(git_path):
109 print("________ found .git directory; skipping %s" % self.relpath)
110 return
111
112 if args:
113 raise gclient_utils.Error("Unsupported argument(s): %s" % ",".join(args))
114
115 url = self.url
116 components = url.split("@")
117 revision = None
118 forced_revision = False
119 if options.revision:
120 # Override the revision number.
121 url = '%s@%s' % (components[0], str(options.revision))
122 revision = int(options.revision)
123 forced_revision = True
124 elif len(components) == 2:
125 revision = int(components[1])
126 forced_revision = True
127
128 rev_str = ""
129 if revision:
130 rev_str = ' at %d' % revision
131
132 if not os.path.exists(checkout_path):
133 # We need to checkout.
134 command = ['checkout', url, checkout_path]
135 if revision:
136 command.extend(['--revision', str(revision)])
137 RunSVNAndGetFileList(command, self._root_dir, file_list)
138 return
139
140 # Get the existing scm url and the revision number of the current checkout.
141 from_info = CaptureSVNInfo(os.path.join(checkout_path, '.'), '.')
142 if not from_info:
143 raise gclient_utils.Error("Can't update/checkout %r if an unversioned "
144 "directory is present. Delete the directory "
145 "and try again." %
146 checkout_path)
147
148 if options.manually_grab_svn_rev:
149 # Retrieve the current HEAD version because svn is slow at null updates.
150 if not revision:
151 from_info_live = CaptureSVNInfo(from_info['URL'], '.')
152 revision = int(from_info_live['Revision'])
153 rev_str = ' at %d' % revision
154
155 if from_info['URL'] != components[0]:
156 to_info = CaptureSVNInfo(url, '.')
157 can_switch = ((from_info['Repository Root'] != to_info['Repository Root'])
158 and (from_info['UUID'] == to_info['UUID']))
159 if can_switch:
160 print("\n_____ relocating %s to a new checkout" % self.relpath)
161 # We have different roots, so check if we can switch --relocate.
162 # Subversion only permits this if the repository UUIDs match.
163 # Perform the switch --relocate, then rewrite the from_url
164 # to reflect where we "are now." (This is the same way that
165 # Subversion itself handles the metadata when switch --relocate
166 # is used.) This makes the checks below for whether we
167 # can update to a revision or have to switch to a different
168 # branch work as expected.
169 # TODO(maruel): TEST ME !
170 command = ["switch", "--relocate",
171 from_info['Repository Root'],
172 to_info['Repository Root'],
173 self.relpath]
174 RunSVN(command, self._root_dir)
175 from_info['URL'] = from_info['URL'].replace(
176 from_info['Repository Root'],
177 to_info['Repository Root'])
178 else:
179 if CaptureSVNStatus(checkout_path):
180 raise gclient_utils.Error("Can't switch the checkout to %s; UUID "
181 "don't match and there is local changes "
182 "in %s. Delete the directory and "
183 "try again." % (url, checkout_path))
184 # Ok delete it.
185 print("\n_____ switching %s to a new checkout" % self.relpath)
bradnelson@google.com8f9c69f2009-09-17 00:48:28 +0000186 gclient_utils.RemoveDirectory(checkout_path)
maruel@chromium.org5f3eee32009-09-17 00:34:30 +0000187 # We need to checkout.
188 command = ['checkout', url, checkout_path]
189 if revision:
190 command.extend(['--revision', str(revision)])
191 RunSVNAndGetFileList(command, self._root_dir, file_list)
192 return
193
194
195 # If the provided url has a revision number that matches the revision
196 # number of the existing directory, then we don't need to bother updating.
197 if not options.force and from_info['Revision'] == revision:
198 if options.verbose or not forced_revision:
199 print("\n_____ %s%s" % (self.relpath, rev_str))
200 return
201
202 command = ["update", checkout_path]
203 if revision:
204 command.extend(['--revision', str(revision)])
205 RunSVNAndGetFileList(command, self._root_dir, file_list)
206
207 def revert(self, options, args, file_list):
208 """Reverts local modifications. Subversion specific.
209
210 All reverted files will be appended to file_list, even if Subversion
211 doesn't know about them.
212 """
213 path = os.path.join(self._root_dir, self.relpath)
214 if not os.path.isdir(path):
215 # svn revert won't work if the directory doesn't exist. It needs to
216 # checkout instead.
217 print("\n_____ %s is missing, synching instead" % self.relpath)
218 # Don't reuse the args.
219 return self.update(options, [], file_list)
220
221 files = CaptureSVNStatus(path)
222 # Batch the command.
223 files_to_revert = []
224 for file in files:
225 file_path = os.path.join(path, file[1])
226 print(file_path)
227 # Unversioned file or unexpected unversioned file.
228 if file[0][0] in ('?', '~'):
229 # Remove extraneous file. Also remove unexpected unversioned
230 # directories. svn won't touch them but we want to delete these.
231 file_list.append(file_path)
232 try:
233 os.remove(file_path)
234 except EnvironmentError:
bradnelson@google.com8f9c69f2009-09-17 00:48:28 +0000235 gclient_utils.RemoveDirectory(file_path)
maruel@chromium.org5f3eee32009-09-17 00:34:30 +0000236
237 if file[0][0] != '?':
238 # For any other status, svn revert will work.
239 file_list.append(file_path)
240 files_to_revert.append(file[1])
241
242 # Revert them all at once.
243 if files_to_revert:
244 accumulated_paths = []
245 accumulated_length = 0
246 command = ['revert']
247 for p in files_to_revert:
248 # Some shell have issues with command lines too long.
249 if accumulated_length and accumulated_length + len(p) > 3072:
250 RunSVN(command + accumulated_paths,
251 os.path.join(self._root_dir, self.relpath))
bradnelson@google.comdf7aea42009-09-17 01:22:35 +0000252 accumulated_paths = [p]
253 accumulated_length = len(p)
maruel@chromium.org5f3eee32009-09-17 00:34:30 +0000254 else:
255 accumulated_paths.append(p)
256 accumulated_length += len(p)
257 if accumulated_paths:
258 RunSVN(command + accumulated_paths,
259 os.path.join(self._root_dir, self.relpath))
260
261 def status(self, options, args, file_list):
262 """Display status information."""
263 path = os.path.join(self._root_dir, self.relpath)
264 command = ['status']
265 command.extend(args)
266 if not os.path.isdir(path):
267 # svn status won't work if the directory doesn't exist.
268 print("\n________ couldn't run \'%s\' in \'%s\':\nThe directory "
269 "does not exist."
270 % (' '.join(command), path))
271 # There's no file list to retrieve.
272 else:
273 RunSVNAndGetFileList(command, path, file_list)
274
275 def pack(self, options, args, file_list):
276 """Generates a patch file which can be applied to the root of the
277 repository."""
278 path = os.path.join(self._root_dir, self.relpath)
279 command = ['diff']
280 command.extend(args)
281 # Simple class which tracks which file is being diffed and
282 # replaces instances of its file name in the original and
283 # working copy lines of the svn diff output.
284 class DiffFilterer(object):
285 index_string = "Index: "
286 original_prefix = "--- "
287 working_prefix = "+++ "
288
289 def __init__(self, relpath):
290 # Note that we always use '/' as the path separator to be
291 # consistent with svn's cygwin-style output on Windows
292 self._relpath = relpath.replace("\\", "/")
293 self._current_file = ""
294 self._replacement_file = ""
295
296 def SetCurrentFile(self, file):
297 self._current_file = file
298 # Note that we always use '/' as the path separator to be
299 # consistent with svn's cygwin-style output on Windows
300 self._replacement_file = self._relpath + '/' + file
301
302 def ReplaceAndPrint(self, line):
303 print(line.replace(self._current_file, self._replacement_file))
304
305 def Filter(self, line):
306 if (line.startswith(self.index_string)):
307 self.SetCurrentFile(line[len(self.index_string):])
308 self.ReplaceAndPrint(line)
309 else:
310 if (line.startswith(self.original_prefix) or
311 line.startswith(self.working_prefix)):
312 self.ReplaceAndPrint(line)
313 else:
314 print line
315
316 filterer = DiffFilterer(self.relpath)
317 RunSVNAndFilterOutput(command, path, False, False, filterer.Filter)
318
319
320# -----------------------------------------------------------------------------
321# SVN utils:
322
323
324def RunSVN(args, in_directory):
325 """Runs svn, sending output to stdout.
326
327 Args:
328 args: A sequence of command line parameters to be passed to svn.
329 in_directory: The directory where svn is to be run.
330
331 Raises:
332 Error: An error occurred while running the svn command.
333 """
334 c = [SVN_COMMAND]
335 c.extend(args)
336
337 gclient_utils.SubprocessCall(c, in_directory)
338
339
340def CaptureSVN(args, in_directory=None, print_error=True):
341 """Runs svn, capturing output sent to stdout as a string.
342
343 Args:
344 args: A sequence of command line parameters to be passed to svn.
345 in_directory: The directory where svn is to be run.
346
347 Returns:
348 The output sent to stdout as a string.
349 """
350 c = [SVN_COMMAND]
351 c.extend(args)
352
353 # *Sigh*: Windows needs shell=True, or else it won't search %PATH% for
354 # the svn.exe executable, but shell=True makes subprocess on Linux fail
355 # when it's called with a list because it only tries to execute the
356 # first string ("svn").
357 stderr = None
358 if not print_error:
359 stderr = subprocess.PIPE
360 return subprocess.Popen(c,
361 cwd=in_directory,
362 shell=(sys.platform == 'win32'),
363 stdout=subprocess.PIPE,
364 stderr=stderr).communicate()[0]
365
366
367def RunSVNAndGetFileList(args, in_directory, file_list):
368 """Runs svn checkout, update, or status, output to stdout.
369
370 The first item in args must be either "checkout", "update", or "status".
371
372 svn's stdout is parsed to collect a list of files checked out or updated.
373 These files are appended to file_list. svn's stdout is also printed to
374 sys.stdout as in RunSVN.
375
376 Args:
377 args: A sequence of command line parameters to be passed to svn.
378 in_directory: The directory where svn is to be run.
379
380 Raises:
381 Error: An error occurred while running the svn command.
382 """
383 command = [SVN_COMMAND]
384 command.extend(args)
385
386 # svn update and svn checkout use the same pattern: the first three columns
387 # are for file status, property status, and lock status. This is followed
388 # by two spaces, and then the path to the file.
389 update_pattern = '^... (.*)$'
390
391 # The first three columns of svn status are the same as for svn update and
392 # svn checkout. The next three columns indicate addition-with-history,
393 # switch, and remote lock status. This is followed by one space, and then
394 # the path to the file.
395 status_pattern = '^...... (.*)$'
396
397 # args[0] must be a supported command. This will blow up if it's something
398 # else, which is good. Note that the patterns are only effective when
399 # these commands are used in their ordinary forms, the patterns are invalid
400 # for "svn status --show-updates", for example.
401 pattern = {
402 'checkout': update_pattern,
403 'status': status_pattern,
404 'update': update_pattern,
405 }[args[0]]
406
407 compiled_pattern = re.compile(pattern)
408
409 def CaptureMatchingLines(line):
410 match = compiled_pattern.search(line)
411 if match:
412 file_list.append(match.group(1))
413
414 RunSVNAndFilterOutput(args,
415 in_directory,
416 True,
417 True,
418 CaptureMatchingLines)
419
420def RunSVNAndFilterOutput(args,
421 in_directory,
422 print_messages,
423 print_stdout,
424 filter):
425 """Runs svn checkout, update, status, or diff, optionally outputting
426 to stdout.
427
428 The first item in args must be either "checkout", "update",
429 "status", or "diff".
430
431 svn's stdout is passed line-by-line to the given filter function. If
432 print_stdout is true, it is also printed to sys.stdout as in RunSVN.
433
434 Args:
435 args: A sequence of command line parameters to be passed to svn.
436 in_directory: The directory where svn is to be run.
437 print_messages: Whether to print status messages to stdout about
438 which Subversion commands are being run.
439 print_stdout: Whether to forward Subversion's output to stdout.
440 filter: A function taking one argument (a string) which will be
441 passed each line (with the ending newline character removed) of
442 Subversion's output for filtering.
443
444 Raises:
445 Error: An error occurred while running the svn command.
446 """
447 command = [SVN_COMMAND]
448 command.extend(args)
449
450 gclient_utils.SubprocessCallAndFilter(command,
451 in_directory,
452 print_messages,
453 print_stdout,
454 filter=filter)
455
456def CaptureSVNInfo(relpath, in_directory=None, print_error=True):
457 """Returns a dictionary from the svn info output for the given file.
458
459 Args:
460 relpath: The directory where the working copy resides relative to
461 the directory given by in_directory.
462 in_directory: The directory where svn is to be run.
463 """
464 output = CaptureSVN(["info", "--xml", relpath], in_directory, print_error)
465 dom = gclient_utils.ParseXML(output)
466 result = {}
467 if dom:
468 GetNamedNodeText = gclient_utils.GetNamedNodeText
469 GetNodeNamedAttributeText = gclient_utils.GetNodeNamedAttributeText
470 def C(item, f):
471 if item is not None: return f(item)
472 # /info/entry/
473 # url
474 # reposityory/(root|uuid)
475 # wc-info/(schedule|depth)
476 # commit/(author|date)
477 # str() the results because they may be returned as Unicode, which
478 # interferes with the higher layers matching up things in the deps
479 # dictionary.
480 # TODO(maruel): Fix at higher level instead (!)
481 result['Repository Root'] = C(GetNamedNodeText(dom, 'root'), str)
482 result['URL'] = C(GetNamedNodeText(dom, 'url'), str)
483 result['UUID'] = C(GetNamedNodeText(dom, 'uuid'), str)
484 result['Revision'] = C(GetNodeNamedAttributeText(dom, 'entry', 'revision'),
485 int)
486 result['Node Kind'] = C(GetNodeNamedAttributeText(dom, 'entry', 'kind'),
487 str)
488 result['Schedule'] = C(GetNamedNodeText(dom, 'schedule'), str)
489 result['Path'] = C(GetNodeNamedAttributeText(dom, 'entry', 'path'), str)
490 result['Copied From URL'] = C(GetNamedNodeText(dom, 'copy-from-url'), str)
491 result['Copied From Rev'] = C(GetNamedNodeText(dom, 'copy-from-rev'), str)
492 return result
493
494
495def CaptureSVNHeadRevision(url):
496 """Get the head revision of a SVN repository.
497
498 Returns:
499 Int head revision
500 """
501 info = CaptureSVN(["info", "--xml", url], os.getcwd())
502 dom = xml.dom.minidom.parseString(info)
503 return int(dom.getElementsByTagName('entry')[0].getAttribute('revision'))
504
505
506def CaptureSVNStatus(files):
507 """Returns the svn 1.5 svn status emulated output.
508
509 @files can be a string (one file) or a list of files.
510
511 Returns an array of (status, file) tuples."""
512 command = ["status", "--xml"]
513 if not files:
514 pass
515 elif isinstance(files, basestring):
516 command.append(files)
517 else:
518 command.extend(files)
519
520 status_letter = {
521 None: ' ',
522 '': ' ',
523 'added': 'A',
524 'conflicted': 'C',
525 'deleted': 'D',
526 'external': 'X',
527 'ignored': 'I',
528 'incomplete': '!',
529 'merged': 'G',
530 'missing': '!',
531 'modified': 'M',
532 'none': ' ',
533 'normal': ' ',
534 'obstructed': '~',
535 'replaced': 'R',
536 'unversioned': '?',
537 }
538 dom = gclient_utils.ParseXML(CaptureSVN(command))
539 results = []
540 if dom:
541 # /status/target/entry/(wc-status|commit|author|date)
542 for target in dom.getElementsByTagName('target'):
543 base_path = target.getAttribute('path')
544 for entry in target.getElementsByTagName('entry'):
545 file = entry.getAttribute('path')
546 wc_status = entry.getElementsByTagName('wc-status')
547 assert len(wc_status) == 1
548 # Emulate svn 1.5 status ouput...
549 statuses = [' ' for i in range(7)]
550 # Col 0
551 xml_item_status = wc_status[0].getAttribute('item')
552 if xml_item_status in status_letter:
553 statuses[0] = status_letter[xml_item_status]
554 else:
555 raise Exception('Unknown item status "%s"; please implement me!' %
556 xml_item_status)
557 # Col 1
558 xml_props_status = wc_status[0].getAttribute('props')
559 if xml_props_status == 'modified':
560 statuses[1] = 'M'
561 elif xml_props_status == 'conflicted':
562 statuses[1] = 'C'
563 elif (not xml_props_status or xml_props_status == 'none' or
564 xml_props_status == 'normal'):
565 pass
566 else:
567 raise Exception('Unknown props status "%s"; please implement me!' %
568 xml_props_status)
569 # Col 2
570 if wc_status[0].getAttribute('wc-locked') == 'true':
571 statuses[2] = 'L'
572 # Col 3
573 if wc_status[0].getAttribute('copied') == 'true':
574 statuses[3] = '+'
575 item = (''.join(statuses), file)
576 results.append(item)
577 return results