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