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