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