blob: 51a3f5f8db4d081638f4cd0423b841bffae6b53c [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, '.')
171 can_switch = ((from_info['Repository Root'] != to_info['Repository Root'])
172 and (from_info['UUID'] == to_info['UUID']))
173 if can_switch:
174 print("\n_____ relocating %s to a new checkout" % self.relpath)
175 # We have different roots, so check if we can switch --relocate.
176 # Subversion only permits this if the repository UUIDs match.
177 # Perform the switch --relocate, then rewrite the from_url
178 # to reflect where we "are now." (This is the same way that
179 # Subversion itself handles the metadata when switch --relocate
180 # is used.) This makes the checks below for whether we
181 # can update to a revision or have to switch to a different
182 # branch work as expected.
183 # TODO(maruel): TEST ME !
184 command = ["switch", "--relocate",
185 from_info['Repository Root'],
186 to_info['Repository Root'],
187 self.relpath]
188 RunSVN(command, self._root_dir)
189 from_info['URL'] = from_info['URL'].replace(
190 from_info['Repository Root'],
191 to_info['Repository Root'])
192 else:
193 if CaptureSVNStatus(checkout_path):
194 raise gclient_utils.Error("Can't switch the checkout to %s; UUID "
195 "don't match and there is local changes "
196 "in %s. Delete the directory and "
197 "try again." % (url, checkout_path))
198 # Ok delete it.
199 print("\n_____ switching %s to a new checkout" % self.relpath)
bradnelson@google.com8f9c69f2009-09-17 00:48:28 +0000200 gclient_utils.RemoveDirectory(checkout_path)
maruel@chromium.org5f3eee32009-09-17 00:34:30 +0000201 # We need to checkout.
202 command = ['checkout', url, checkout_path]
203 if revision:
204 command.extend(['--revision', str(revision)])
205 RunSVNAndGetFileList(command, self._root_dir, file_list)
206 return
207
208
209 # If the provided url has a revision number that matches the revision
210 # number of the existing directory, then we don't need to bother updating.
211 if not options.force and from_info['Revision'] == revision:
212 if options.verbose or not forced_revision:
213 print("\n_____ %s%s" % (self.relpath, rev_str))
214 return
215
216 command = ["update", checkout_path]
217 if revision:
218 command.extend(['--revision', str(revision)])
219 RunSVNAndGetFileList(command, self._root_dir, file_list)
220
221 def revert(self, options, args, file_list):
222 """Reverts local modifications. Subversion specific.
223
224 All reverted files will be appended to file_list, even if Subversion
225 doesn't know about them.
226 """
227 path = os.path.join(self._root_dir, self.relpath)
228 if not os.path.isdir(path):
229 # svn revert won't work if the directory doesn't exist. It needs to
230 # checkout instead.
231 print("\n_____ %s is missing, synching instead" % self.relpath)
232 # Don't reuse the args.
233 return self.update(options, [], file_list)
234
maruel@chromium.org754960e2009-09-21 12:31:05 +0000235 for file in CaptureSVNStatus(path):
maruel@chromium.org5f3eee32009-09-17 00:34:30 +0000236 file_path = os.path.join(path, file[1])
maruel@chromium.orgaa3dd472009-09-21 19:02:48 +0000237 if file[0][0] == 'X':
maruel@chromium.org754960e2009-09-21 12:31:05 +0000238 # Ignore externals.
maruel@chromium.orgaa3dd472009-09-21 19:02:48 +0000239 logging.info('Ignoring external %s' % file_path)
maruel@chromium.org754960e2009-09-21 12:31:05 +0000240 continue
241
maruel@chromium.orgaa3dd472009-09-21 19:02:48 +0000242 if logging.getLogger().isEnabledFor(logging.INFO):
243 logging.info('%s%s' % (file[0], file[1]))
244 else:
245 print(file_path)
246 if file[0].isspace():
247 logging.error('No idea what is the status of %s.\n'
248 'You just found a bug in gclient, please ping '
249 'maruel@chromium.org ASAP!' % file_path)
250 # svn revert is really stupid. It fails on inconsistent line-endings,
251 # on switched directories, etc. So take no chance and delete everything!
252 try:
253 if not os.path.exists(file_path):
254 pass
255 elif os.path.isfile(file_path):
maruel@chromium.org754960e2009-09-21 12:31:05 +0000256 logging.info('os.remove(%s)' % file_path)
maruel@chromium.org5f3eee32009-09-17 00:34:30 +0000257 os.remove(file_path)
maruel@chromium.orgaa3dd472009-09-21 19:02:48 +0000258 elif os.path.isdir(file_path):
maruel@chromium.org754960e2009-09-21 12:31:05 +0000259 logging.info('gclient_utils.RemoveDirectory(%s)' % file_path)
bradnelson@google.com8f9c69f2009-09-17 00:48:28 +0000260 gclient_utils.RemoveDirectory(file_path)
maruel@chromium.org5f3eee32009-09-17 00:34:30 +0000261 else:
maruel@chromium.orgaa3dd472009-09-21 19:02:48 +0000262 logging.error('no idea what is %s.\nYou just found a bug in gclient'
263 ', please ping maruel@chromium.org ASAP!' % file_path)
264 except EnvironmentError:
265 logging.error('Failed to remove %s.' % file_path)
266
267 # svn revert is so broken we don't even use it. Using
268 # "svn up --revision BASE" achieve the same effect.
269 RunSVNAndGetFileList(['update', '--revision', 'BASE'], path, file_list)
maruel@chromium.org5f3eee32009-09-17 00:34:30 +0000270
msb@chromium.orgcb5442b2009-09-22 16:51:24 +0000271 def runhooks(self, options, args, file_list):
272 self.status(options, args, file_list)
273
maruel@chromium.org5f3eee32009-09-17 00:34:30 +0000274 def status(self, options, args, file_list):
275 """Display status information."""
276 path = os.path.join(self._root_dir, self.relpath)
277 command = ['status']
278 command.extend(args)
279 if not os.path.isdir(path):
280 # svn status won't work if the directory doesn't exist.
281 print("\n________ couldn't run \'%s\' in \'%s\':\nThe directory "
282 "does not exist."
283 % (' '.join(command), path))
284 # There's no file list to retrieve.
285 else:
286 RunSVNAndGetFileList(command, path, file_list)
287
288 def pack(self, options, args, file_list):
289 """Generates a patch file which can be applied to the root of the
290 repository."""
291 path = os.path.join(self._root_dir, self.relpath)
292 command = ['diff']
293 command.extend(args)
294 # Simple class which tracks which file is being diffed and
295 # replaces instances of its file name in the original and
296 # working copy lines of the svn diff output.
297 class DiffFilterer(object):
298 index_string = "Index: "
299 original_prefix = "--- "
300 working_prefix = "+++ "
301
302 def __init__(self, relpath):
303 # Note that we always use '/' as the path separator to be
304 # consistent with svn's cygwin-style output on Windows
305 self._relpath = relpath.replace("\\", "/")
306 self._current_file = ""
307 self._replacement_file = ""
308
309 def SetCurrentFile(self, file):
310 self._current_file = file
311 # Note that we always use '/' as the path separator to be
312 # consistent with svn's cygwin-style output on Windows
313 self._replacement_file = self._relpath + '/' + file
314
315 def ReplaceAndPrint(self, line):
316 print(line.replace(self._current_file, self._replacement_file))
317
318 def Filter(self, line):
319 if (line.startswith(self.index_string)):
320 self.SetCurrentFile(line[len(self.index_string):])
321 self.ReplaceAndPrint(line)
322 else:
323 if (line.startswith(self.original_prefix) or
324 line.startswith(self.working_prefix)):
325 self.ReplaceAndPrint(line)
326 else:
327 print line
328
329 filterer = DiffFilterer(self.relpath)
330 RunSVNAndFilterOutput(command, path, False, False, filterer.Filter)
331
332
333# -----------------------------------------------------------------------------
334# SVN utils:
335
336
337def RunSVN(args, in_directory):
338 """Runs svn, sending output to stdout.
339
340 Args:
341 args: A sequence of command line parameters to be passed to svn.
342 in_directory: The directory where svn is to be run.
343
344 Raises:
345 Error: An error occurred while running the svn command.
346 """
347 c = [SVN_COMMAND]
348 c.extend(args)
349
350 gclient_utils.SubprocessCall(c, in_directory)
351
352
353def CaptureSVN(args, in_directory=None, print_error=True):
354 """Runs svn, capturing output sent to stdout as a string.
355
356 Args:
357 args: A sequence of command line parameters to be passed to svn.
358 in_directory: The directory where svn is to be run.
359
360 Returns:
361 The output sent to stdout as a string.
362 """
363 c = [SVN_COMMAND]
364 c.extend(args)
365
366 # *Sigh*: Windows needs shell=True, or else it won't search %PATH% for
367 # the svn.exe executable, but shell=True makes subprocess on Linux fail
368 # when it's called with a list because it only tries to execute the
369 # first string ("svn").
370 stderr = None
371 if not print_error:
372 stderr = subprocess.PIPE
373 return subprocess.Popen(c,
374 cwd=in_directory,
375 shell=(sys.platform == 'win32'),
376 stdout=subprocess.PIPE,
377 stderr=stderr).communicate()[0]
378
379
380def RunSVNAndGetFileList(args, in_directory, file_list):
381 """Runs svn checkout, update, or status, output to stdout.
382
383 The first item in args must be either "checkout", "update", or "status".
384
385 svn's stdout is parsed to collect a list of files checked out or updated.
386 These files are appended to file_list. svn's stdout is also printed to
387 sys.stdout as in RunSVN.
388
389 Args:
390 args: A sequence of command line parameters to be passed to svn.
391 in_directory: The directory where svn is to be run.
392
393 Raises:
394 Error: An error occurred while running the svn command.
395 """
396 command = [SVN_COMMAND]
397 command.extend(args)
398
399 # svn update and svn checkout use the same pattern: the first three columns
400 # are for file status, property status, and lock status. This is followed
401 # by two spaces, and then the path to the file.
402 update_pattern = '^... (.*)$'
403
404 # The first three columns of svn status are the same as for svn update and
405 # svn checkout. The next three columns indicate addition-with-history,
406 # switch, and remote lock status. This is followed by one space, and then
407 # the path to the file.
408 status_pattern = '^...... (.*)$'
409
410 # args[0] must be a supported command. This will blow up if it's something
411 # else, which is good. Note that the patterns are only effective when
412 # these commands are used in their ordinary forms, the patterns are invalid
413 # for "svn status --show-updates", for example.
414 pattern = {
415 'checkout': update_pattern,
416 'status': status_pattern,
417 'update': update_pattern,
418 }[args[0]]
419
420 compiled_pattern = re.compile(pattern)
421
422 def CaptureMatchingLines(line):
423 match = compiled_pattern.search(line)
424 if match:
425 file_list.append(match.group(1))
426
427 RunSVNAndFilterOutput(args,
428 in_directory,
429 True,
430 True,
431 CaptureMatchingLines)
432
433def RunSVNAndFilterOutput(args,
434 in_directory,
435 print_messages,
436 print_stdout,
437 filter):
438 """Runs svn checkout, update, status, or diff, optionally outputting
439 to stdout.
440
441 The first item in args must be either "checkout", "update",
442 "status", or "diff".
443
444 svn's stdout is passed line-by-line to the given filter function. If
445 print_stdout is true, it is also printed to sys.stdout as in RunSVN.
446
447 Args:
448 args: A sequence of command line parameters to be passed to svn.
449 in_directory: The directory where svn is to be run.
450 print_messages: Whether to print status messages to stdout about
451 which Subversion commands are being run.
452 print_stdout: Whether to forward Subversion's output to stdout.
453 filter: A function taking one argument (a string) which will be
454 passed each line (with the ending newline character removed) of
455 Subversion's output for filtering.
456
457 Raises:
458 Error: An error occurred while running the svn command.
459 """
460 command = [SVN_COMMAND]
461 command.extend(args)
462
463 gclient_utils.SubprocessCallAndFilter(command,
464 in_directory,
465 print_messages,
466 print_stdout,
467 filter=filter)
468
469def CaptureSVNInfo(relpath, in_directory=None, print_error=True):
470 """Returns a dictionary from the svn info output for the given file.
471
472 Args:
473 relpath: The directory where the working copy resides relative to
474 the directory given by in_directory.
475 in_directory: The directory where svn is to be run.
476 """
477 output = CaptureSVN(["info", "--xml", relpath], in_directory, print_error)
478 dom = gclient_utils.ParseXML(output)
479 result = {}
480 if dom:
481 GetNamedNodeText = gclient_utils.GetNamedNodeText
482 GetNodeNamedAttributeText = gclient_utils.GetNodeNamedAttributeText
483 def C(item, f):
484 if item is not None: return f(item)
485 # /info/entry/
486 # url
487 # reposityory/(root|uuid)
488 # wc-info/(schedule|depth)
489 # commit/(author|date)
490 # str() the results because they may be returned as Unicode, which
491 # interferes with the higher layers matching up things in the deps
492 # dictionary.
493 # TODO(maruel): Fix at higher level instead (!)
494 result['Repository Root'] = C(GetNamedNodeText(dom, 'root'), str)
495 result['URL'] = C(GetNamedNodeText(dom, 'url'), str)
496 result['UUID'] = C(GetNamedNodeText(dom, 'uuid'), str)
497 result['Revision'] = C(GetNodeNamedAttributeText(dom, 'entry', 'revision'),
498 int)
499 result['Node Kind'] = C(GetNodeNamedAttributeText(dom, 'entry', 'kind'),
500 str)
501 result['Schedule'] = C(GetNamedNodeText(dom, 'schedule'), str)
502 result['Path'] = C(GetNodeNamedAttributeText(dom, 'entry', 'path'), str)
503 result['Copied From URL'] = C(GetNamedNodeText(dom, 'copy-from-url'), str)
504 result['Copied From Rev'] = C(GetNamedNodeText(dom, 'copy-from-rev'), str)
505 return result
506
507
508def CaptureSVNHeadRevision(url):
509 """Get the head revision of a SVN repository.
510
511 Returns:
512 Int head revision
513 """
514 info = CaptureSVN(["info", "--xml", url], os.getcwd())
515 dom = xml.dom.minidom.parseString(info)
516 return int(dom.getElementsByTagName('entry')[0].getAttribute('revision'))
517
518
519def CaptureSVNStatus(files):
520 """Returns the svn 1.5 svn status emulated output.
521
522 @files can be a string (one file) or a list of files.
523
524 Returns an array of (status, file) tuples."""
525 command = ["status", "--xml"]
526 if not files:
527 pass
528 elif isinstance(files, basestring):
529 command.append(files)
530 else:
531 command.extend(files)
532
533 status_letter = {
534 None: ' ',
535 '': ' ',
536 'added': 'A',
537 'conflicted': 'C',
538 'deleted': 'D',
539 'external': 'X',
540 'ignored': 'I',
541 'incomplete': '!',
542 'merged': 'G',
543 'missing': '!',
544 'modified': 'M',
545 'none': ' ',
546 'normal': ' ',
547 'obstructed': '~',
548 'replaced': 'R',
549 'unversioned': '?',
550 }
551 dom = gclient_utils.ParseXML(CaptureSVN(command))
552 results = []
553 if dom:
554 # /status/target/entry/(wc-status|commit|author|date)
555 for target in dom.getElementsByTagName('target'):
556 base_path = target.getAttribute('path')
557 for entry in target.getElementsByTagName('entry'):
558 file = entry.getAttribute('path')
559 wc_status = entry.getElementsByTagName('wc-status')
560 assert len(wc_status) == 1
561 # Emulate svn 1.5 status ouput...
562 statuses = [' ' for i in range(7)]
563 # Col 0
564 xml_item_status = wc_status[0].getAttribute('item')
565 if xml_item_status in status_letter:
566 statuses[0] = status_letter[xml_item_status]
567 else:
568 raise Exception('Unknown item status "%s"; please implement me!' %
569 xml_item_status)
570 # Col 1
571 xml_props_status = wc_status[0].getAttribute('props')
572 if xml_props_status == 'modified':
573 statuses[1] = 'M'
574 elif xml_props_status == 'conflicted':
575 statuses[1] = 'C'
576 elif (not xml_props_status or xml_props_status == 'none' or
577 xml_props_status == 'normal'):
578 pass
579 else:
580 raise Exception('Unknown props status "%s"; please implement me!' %
581 xml_props_status)
582 # Col 2
583 if wc_status[0].getAttribute('wc-locked') == 'true':
584 statuses[2] = 'L'
585 # Col 3
586 if wc_status[0].getAttribute('copied') == 'true':
587 statuses[3] = '+'
maruel@chromium.orgaa3dd472009-09-21 19:02:48 +0000588 # Col 4
589 if wc_status[0].getAttribute('switched') == 'true':
590 statuses[4] = 'S'
591 # TODO(maruel): Col 5 and 6
maruel@chromium.org5f3eee32009-09-17 00:34:30 +0000592 item = (''.join(statuses), file)
593 results.append(item)
594 return results