blob: 43a1a4c2aaf903c662cdf873e03516c0061f7b91 [file] [log] [blame]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001#!/usr/bin/python
2#
3# Copyright 2008 Google Inc. All Rights Reserved.
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""A wrapper script to manage a set of client modules in different SCM.
18
19This script is intended to be used to help basic management of client
20program sources residing in one or more Subversion modules, along with
21other modules it depends on, also in Subversion, but possibly on
22multiple respositories, making a wrapper system apparently necessary.
23
24Files
25 .gclient : Current client configuration, written by 'config' command.
26 Format is a Python script defining 'solutions', a list whose
27 entries each are maps binding the strings "name" and "url"
28 to strings specifying the name and location of the client
29 module, as well as "custom_deps" to a map similar to the DEPS
30 file below.
31 .gclient_entries : A cache constructed by 'update' command. Format is a
32 Python script defining 'entries', a list of the names
33 of all modules in the client
34 <module>/DEPS : Python script defining var 'deps' as a map from each requisite
35 submodule name to a URL where it can be found (via one SCM)
36
37Hooks
38 .gclient and DEPS files may optionally contain a list named "hooks" to
39 allow custom actions to be performed based on files that have changed in the
40 working copy as a result of a "sync"/"update" or "revert" operation. Hooks
41 can also be run based on what files have been modified in the working copy
42 with the "runhooks" operation. If any of these operation are run with
43 --force, all known hooks will run regardless of the state of the working
44 copy.
45
46 Each item in a "hooks" list is a dict, containing these two keys:
47 "pattern" The associated value is a string containing a regular
48 expression. When a file whose pathname matches the expression
49 is checked out, updated, or reverted, the hook's "action" will
50 run.
51 "action" A list describing a command to run along with its arguments, if
52 any. An action command will run at most one time per gclient
53 invocation, regardless of how many files matched the pattern.
54 The action is executed in the same directory as the .gclient
55 file. If the first item in the list is the string "python",
56 the current Python interpreter (sys.executable) will be used
57 to run the command.
58
59 Example:
60 hooks = [
61 { "pattern": "\\.(gif|jpe?g|pr0n|png)$",
62 "action": ["python", "image_indexer.py", "--all"]},
63 ]
64"""
65
66__author__ = "darinf@gmail.com (Darin Fisher)"
67__version__ = "0.3.1"
68
69import errno
70import optparse
71import os
72import re
73import stat
74import subprocess
75import sys
76import time
77import urlparse
78import xml.dom.minidom
79import urllib
80
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000081
82SVN_COMMAND = "svn"
83
84
85# default help text
86DEFAULT_USAGE_TEXT = (
87"""usage: %prog <subcommand> [options] [--] [svn options/args...]
88a wrapper for managing a set of client modules in svn.
89Version """ + __version__ + """
90
91subcommands:
92 cleanup
93 config
94 diff
95 revert
96 status
97 sync
98 update
99 runhooks
100 revinfo
101
102Options and extra arguments can be passed to invoked svn commands by
103appending them to the command line. Note that if the first such
104appended option starts with a dash (-) then the options must be
105preceded by -- to distinguish them from gclient options.
106
107For additional help on a subcommand or examples of usage, try
108 %prog help <subcommand>
109 %prog help files
110""")
111
112GENERIC_UPDATE_USAGE_TEXT = (
113 """Perform a checkout/update of the modules specified by the gclient
114configuration; see 'help config'. Unless --revision is specified,
115then the latest revision of the root solutions is checked out, with
116dependent submodule versions updated according to DEPS files.
117If --revision is specified, then the given revision is used in place
118of the latest, either for a single solution or for all solutions.
119Unless the --force option is provided, solutions and modules whose
120local revision matches the one to update (i.e., they have not changed
121in the repository) are *not* modified.
122This a synonym for 'gclient %(alias)s'
123
124usage: gclient %(cmd)s [options] [--] [svn update options/args]
125
126Valid options:
127 --force : force update even for unchanged modules
128 --revision REV : update/checkout all solutions with specified revision
129 --revision SOLUTION@REV : update given solution to specified revision
130 --deps PLATFORM(S) : sync deps for the given platform(s), or 'all'
131 --verbose : output additional diagnostics
132
133Examples:
134 gclient %(cmd)s
135 update files from SVN according to current configuration,
136 *for modules which have changed since last update or sync*
137 gclient %(cmd)s --force
138 update files from SVN according to current configuration, for
139 all modules (useful for recovering files deleted from local copy)
140""")
141
142COMMAND_USAGE_TEXT = {
143 "cleanup":
144 """Clean up all working copies, using 'svn cleanup' for each module.
145Additional options and args may be passed to 'svn cleanup'.
146
147usage: cleanup [options] [--] [svn cleanup args/options]
148
149Valid options:
150 --verbose : output additional diagnostics
151""",
152 "config": """Create a .gclient file in the current directory; this
153specifies the configuration for further commands. After update/sync,
154top-level DEPS files in each module are read to determine dependent
155modules to operate on as well. If optional [url] parameter is
156provided, then configuration is read from a specified Subversion server
157URL. Otherwise, a --spec option must be provided.
158
159usage: config [option | url] [safesync url]
160
161Valid options:
162 --spec=GCLIENT_SPEC : contents of .gclient are read from string parameter.
163 *Note that due to Cygwin/Python brokenness, it
164 probably can't contain any newlines.*
165
166Examples:
167 gclient config https://gclient.googlecode.com/svn/trunk/gclient
168 configure a new client to check out gclient.py tool sources
169 gclient config --spec='solutions=[{"name":"gclient","""
170 '"url":"https://gclient.googlecode.com/svn/trunk/gclient",'
171 '"custom_deps":{}}]',
172 "diff": """Display the differences between two revisions of modules.
173(Does 'svn diff' for each checked out module and dependences.)
174Additional args and options to 'svn diff' can be passed after
175gclient options.
176
177usage: diff [options] [--] [svn args/options]
178
179Valid options:
180 --verbose : output additional diagnostics
181
182Examples:
183 gclient diff
184 simple 'svn diff' for configured client and dependences
185 gclient diff -- -x -b
186 use 'svn diff -x -b' to suppress whitespace-only differences
187 gclient diff -- -r HEAD -x -b
188 diff versus the latest version of each module
189""",
190 "revert":
191 """Revert every file in every managed directory in the client view.
192
193usage: revert
194""",
195 "status":
196 """Show the status of client and dependent modules, using 'svn diff'
197for each module. Additional options and args may be passed to 'svn diff'.
198
199usage: status [options] [--] [svn diff args/options]
200
201Valid options:
202 --verbose : output additional diagnostics
203""",
204 "sync": GENERIC_UPDATE_USAGE_TEXT % {"cmd": "sync", "alias": "update"},
205 "update": GENERIC_UPDATE_USAGE_TEXT % {"cmd": "update", "alias": "sync"},
206 "help": """Describe the usage of this program or its subcommands.
207
208usage: help [options] [subcommand]
209
210Valid options:
211 --verbose : output additional diagnostics
212""",
213 "runhooks":
214 """Runs hooks for files that have been modified in the local working copy,
215according to 'svn status'.
216
217usage: runhooks [options]
218
219Valid options:
220 --force : runs all known hooks, regardless of the working
221 copy status
222 --verbose : output additional diagnostics
223""",
224 "revinfo":
225 """Outputs source path, server URL and revision information for every
226dependency in all solutions (no local checkout required).
227
228usage: revinfo [options]
229""",
230}
231
232# parameterized by (solution_name, solution_url, safesync_url)
233DEFAULT_CLIENT_FILE_TEXT = (
234 """
235# An element of this array (a \"solution\") describes a repository directory
236# that will be checked out into your working copy. Each solution may
237# optionally define additional dependencies (via its DEPS file) to be
238# checked out alongside the solution's directory. A solution may also
239# specify custom dependencies (via the \"custom_deps\" property) that
240# override or augment the dependencies specified by the DEPS file.
241# If a \"safesync_url\" is specified, it is assumed to reference the location of
242# a text file which contains nothing but the last known good SCM revision to
243# sync against. It is fetched if specified and used unless --head is passed
244solutions = [
245 { \"name\" : \"%s\",
246 \"url\" : \"%s\",
247 \"custom_deps\" : {
248 # To use the trunk of a component instead of what's in DEPS:
249 #\"component\": \"https://svnserver/component/trunk/\",
250 # To exclude a component from your working copy:
251 #\"data/really_large_component\": None,
252 },
253 \"safesync_url\": \"%s\"
254 }
255]
256""")
257
258
259## Generic utils
260
maruel@chromium.orge105d8d2009-04-30 17:58:25 +0000261def ParseXML(output):
262 try:
263 return xml.dom.minidom.parseString(output)
264 except xml.parsers.expat.ExpatError:
265 return None
266
267
maruel@chromium.org483b0082009-05-07 02:57:14 +0000268def GetNamedNodeText(node, node_name):
269 child_nodes = node.getElementsByTagName(node_name)
270 if not child_nodes:
271 return None
272 assert len(child_nodes) == 1 and child_nodes[0].childNodes.length == 1
273 return child_nodes[0].firstChild.nodeValue
274
275
276def GetNodeNamedAttributeText(node, node_name, attribute_name):
277 child_nodes = node.getElementsByTagName(node_name)
278 if not child_nodes:
279 return None
280 assert len(child_nodes) == 1
281 return child_nodes[0].getAttribute(attribute_name)
282
283
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000284class Error(Exception):
285 """gclient exception class."""
286 pass
287
288class PrintableObject(object):
289 def __str__(self):
290 output = ''
291 for i in dir(self):
292 if i.startswith('__'):
293 continue
294 output += '%s = %s\n' % (i, str(getattr(self, i, '')))
295 return output
296
297
298def FileRead(filename):
299 content = None
300 f = open(filename, "rU")
301 try:
302 content = f.read()
303 finally:
304 f.close()
305 return content
306
307
308def FileWrite(filename, content):
309 f = open(filename, "w")
310 try:
311 f.write(content)
312 finally:
313 f.close()
314
315
316def RemoveDirectory(*path):
317 """Recursively removes a directory, even if it's marked read-only.
318
319 Remove the directory located at *path, if it exists.
320
321 shutil.rmtree() doesn't work on Windows if any of the files or directories
322 are read-only, which svn repositories and some .svn files are. We need to
323 be able to force the files to be writable (i.e., deletable) as we traverse
324 the tree.
325
326 Even with all this, Windows still sometimes fails to delete a file, citing
327 a permission error (maybe something to do with antivirus scans or disk
328 indexing). The best suggestion any of the user forums had was to wait a
329 bit and try again, so we do that too. It's hand-waving, but sometimes it
330 works. :/
331
332 On POSIX systems, things are a little bit simpler. The modes of the files
333 to be deleted doesn't matter, only the modes of the directories containing
334 them are significant. As the directory tree is traversed, each directory
335 has its mode set appropriately before descending into it. This should
336 result in the entire tree being removed, with the possible exception of
337 *path itself, because nothing attempts to change the mode of its parent.
338 Doing so would be hazardous, as it's not a directory slated for removal.
339 In the ordinary case, this is not a problem: for our purposes, the user
340 will never lack write permission on *path's parent.
341 """
342 file_path = os.path.join(*path)
343 if not os.path.exists(file_path):
344 return
345
346 if os.path.islink(file_path) or not os.path.isdir(file_path):
347 raise Error("RemoveDirectory asked to remove non-directory %s" % file_path)
348
349 has_win32api = False
350 if sys.platform == 'win32':
351 has_win32api = True
352 # Some people don't have the APIs installed. In that case we'll do without.
353 try:
354 win32api = __import__('win32api')
355 win32con = __import__('win32con')
356 except ImportError:
357 has_win32api = False
358 else:
359 # On POSIX systems, we need the x-bit set on the directory to access it,
360 # the r-bit to see its contents, and the w-bit to remove files from it.
361 # The actual modes of the files within the directory is irrelevant.
362 os.chmod(file_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
363 for fn in os.listdir(file_path):
364 fullpath = os.path.join(file_path, fn)
365
366 # If fullpath is a symbolic link that points to a directory, isdir will
367 # be True, but we don't want to descend into that as a directory, we just
368 # want to remove the link. Check islink and treat links as ordinary files
369 # would be treated regardless of what they reference.
370 if os.path.islink(fullpath) or not os.path.isdir(fullpath):
371 if sys.platform == 'win32':
372 os.chmod(fullpath, stat.S_IWRITE)
373 if has_win32api:
374 win32api.SetFileAttributes(fullpath, win32con.FILE_ATTRIBUTE_NORMAL)
375 try:
376 os.remove(fullpath)
377 except OSError, e:
378 if e.errno != errno.EACCES or sys.platform != 'win32':
379 raise
380 print 'Failed to delete %s: trying again' % fullpath
381 time.sleep(0.1)
382 os.remove(fullpath)
383 else:
384 RemoveDirectory(fullpath)
385
386 if sys.platform == 'win32':
387 os.chmod(file_path, stat.S_IWRITE)
388 if has_win32api:
389 win32api.SetFileAttributes(file_path, win32con.FILE_ATTRIBUTE_NORMAL)
390 try:
391 os.rmdir(file_path)
392 except OSError, e:
393 if e.errno != errno.EACCES or sys.platform != 'win32':
394 raise
395 print 'Failed to remove %s: trying again' % file_path
396 time.sleep(0.1)
397 os.rmdir(file_path)
398
399
400def SubprocessCall(command, in_directory, out, fail_status=None):
401 """Runs command, a list, in directory in_directory.
402
403 This function wraps SubprocessCallAndCapture, but does not perform the
404 capturing functions. See that function for a more complete usage
405 description.
406 """
407 # Call subprocess and capture nothing:
408 SubprocessCallAndCapture(command, in_directory, out, fail_status)
409
410
411def SubprocessCallAndCapture(command, in_directory, out, fail_status=None,
412 pattern=None, capture_list=None):
413 """Runs command, a list, in directory in_directory.
414
415 A message indicating what is being done, as well as the command's stdout,
416 is printed to out.
417
418 If a pattern is specified, any line in the output matching pattern will have
419 its first match group appended to capture_list.
420
421 If the command fails, as indicated by a nonzero exit status, gclient will
422 exit with an exit status of fail_status. If fail_status is None (the
423 default), gclient will raise an Error exception.
424 """
425
426 print >> out, ("\n________ running \'%s\' in \'%s\'"
427 % (' '.join(command), in_directory))
428
429 # *Sigh*: Windows needs shell=True, or else it won't search %PATH% for the
430 # executable, but shell=True makes subprocess on Linux fail when it's called
431 # with a list because it only tries to execute the first item in the list.
432 kid = subprocess.Popen(command, bufsize=0, cwd=in_directory,
433 shell=(sys.platform == 'win32'), stdout=subprocess.PIPE)
434
435 if pattern:
436 compiled_pattern = re.compile(pattern)
437
438 # Also, we need to forward stdout to prevent weird re-ordering of output.
439 # This has to be done on a per byte basis to make sure it is not buffered:
440 # normally buffering is done for each line, but if svn requests input, no
441 # end-of-line character is output after the prompt and it would not show up.
442 in_byte = kid.stdout.read(1)
443 in_line = ""
444 while in_byte:
445 if in_byte != "\r":
446 out.write(in_byte)
447 in_line += in_byte
448 if in_byte == "\n" and pattern:
449 match = compiled_pattern.search(in_line[:-1])
450 if match:
451 capture_list.append(match.group(1))
452 in_line = ""
453 in_byte = kid.stdout.read(1)
454 rv = kid.wait()
455
456 if rv:
457 msg = "failed to run command: %s" % " ".join(command)
458
459 if fail_status != None:
460 print >>sys.stderr, msg
461 sys.exit(fail_status)
462
463 raise Error(msg)
464
465
466def IsUsingGit(root, paths):
467 """Returns True if we're using git to manage any of our checkouts.
468 |entries| is a list of paths to check."""
469 for path in paths:
470 if os.path.exists(os.path.join(root, path, '.git')):
471 return True
472 return False
473
474# -----------------------------------------------------------------------------
475# SVN utils:
476
477
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000478def RunSVN(args, in_directory):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000479 """Runs svn, sending output to stdout.
480
481 Args:
482 args: A sequence of command line parameters to be passed to svn.
483 in_directory: The directory where svn is to be run.
484
485 Raises:
486 Error: An error occurred while running the svn command.
487 """
488 c = [SVN_COMMAND]
489 c.extend(args)
490
491 SubprocessCall(c, in_directory, options.stdout)
492
493
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000494def CaptureSVN(args, in_directory=None, print_error=True):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000495 """Runs svn, capturing output sent to stdout as a string.
496
497 Args:
498 args: A sequence of command line parameters to be passed to svn.
499 in_directory: The directory where svn is to be run.
500
501 Returns:
502 The output sent to stdout as a string.
503 """
504 c = [SVN_COMMAND]
505 c.extend(args)
506
507 # *Sigh*: Windows needs shell=True, or else it won't search %PATH% for
508 # the svn.exe executable, but shell=True makes subprocess on Linux fail
509 # when it's called with a list because it only tries to execute the
510 # first string ("svn").
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000511 stderr = None
512 if print_error:
513 stderr = subprocess.PIPE
514 return subprocess.Popen(c,
515 cwd=in_directory,
516 shell=(sys.platform == 'win32'),
517 stdout=subprocess.PIPE,
518 stderr=stderr).communicate()[0]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000519
520
521def RunSVNAndGetFileList(options, args, in_directory, file_list):
522 """Runs svn checkout, update, or status, output to stdout.
523
524 The first item in args must be either "checkout", "update", or "status".
525
526 svn's stdout is parsed to collect a list of files checked out or updated.
527 These files are appended to file_list. svn's stdout is also printed to
528 sys.stdout as in RunSVN.
529
530 Args:
531 args: A sequence of command line parameters to be passed to svn.
532 in_directory: The directory where svn is to be run.
533
534 Raises:
535 Error: An error occurred while running the svn command.
536 """
537 command = [SVN_COMMAND]
538 command.extend(args)
539
540 # svn update and svn checkout use the same pattern: the first three columns
541 # are for file status, property status, and lock status. This is followed
542 # by two spaces, and then the path to the file.
543 update_pattern = '^... (.*)$'
544
545 # The first three columns of svn status are the same as for svn update and
546 # svn checkout. The next three columns indicate addition-with-history,
547 # switch, and remote lock status. This is followed by one space, and then
548 # the path to the file.
549 status_pattern = '^...... (.*)$'
550
551 # args[0] must be a supported command. This will blow up if it's something
552 # else, which is good. Note that the patterns are only effective when
553 # these commands are used in their ordinary forms, the patterns are invalid
554 # for "svn status --show-updates", for example.
555 pattern = {
556 'checkout': update_pattern,
557 'status': status_pattern,
558 'update': update_pattern,
559 }[args[0]]
560
561 SubprocessCallAndCapture(command, in_directory, options.stdout,
562 pattern=pattern, capture_list=file_list)
563
564
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000565def CaptureSVNInfo(relpath, in_directory=None, print_error=True):
maruel@chromium.org2dc8a4d2009-05-11 12:41:20 +0000566 """Returns a dictionary from the svn info output for the given file.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000567
568 Args:
569 relpath: The directory where the working copy resides relative to
570 the directory given by in_directory.
571 in_directory: The directory where svn is to be run.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000572 """
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000573 output = CaptureSVN(["info", "--xml", relpath], in_directory, print_error)
574 dom = ParseXML(output)
maruel@chromium.org2dc8a4d2009-05-11 12:41:20 +0000575 result = {}
maruel@chromium.org483b0082009-05-07 02:57:14 +0000576 if dom:
maruel@chromium.org2dc8a4d2009-05-11 12:41:20 +0000577 def C(item, f):
578 if item is not None: return f(item)
maruel@chromium.org483b0082009-05-07 02:57:14 +0000579 # /info/entry/
580 # url
581 # reposityory/(root|uuid)
582 # wc-info/(schedule|depth)
583 # commit/(author|date)
584 # str() the results because they may be returned as Unicode, which
585 # interferes with the higher layers matching up things in the deps
586 # dictionary.
maruel@chromium.org2dc8a4d2009-05-11 12:41:20 +0000587 # TODO(maruel): Fix at higher level instead (!)
588 result['Repository Root'] = C(GetNamedNodeText(dom, 'root'), str)
589 result['URL'] = C(GetNamedNodeText(dom, 'url'), str)
590 result['UUID'] = C(GetNamedNodeText(dom, 'uuid'), str)
591 result['Revision'] = C(GetNodeNamedAttributeText(dom, 'entry', 'revision'),
592 int)
593 result['Node Kind'] = C(GetNodeNamedAttributeText(dom, 'entry', 'kind'),
594 str)
595 result['Schedule'] = C(GetNamedNodeText(dom, 'schedule'), str)
596 result['Path'] = C(GetNodeNamedAttributeText(dom, 'entry', 'path'), str)
597 result['Copied From URL'] = C(GetNamedNodeText(dom, 'copy-from-url'), str)
598 result['Copied From Rev'] = C(GetNamedNodeText(dom, 'copy-from-rev'), str)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000599 return result
600
601
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000602def CaptureSVNHeadRevision(url):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000603 """Get the head revision of a SVN repository.
604
605 Returns:
606 Int head revision
607 """
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000608 info = CaptureSVN(["info", "--xml", url], os.getcwd())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000609 dom = xml.dom.minidom.parseString(info)
610 return int(dom.getElementsByTagName('entry')[0].getAttribute('revision'))
611
612
613class FileStatus:
maruel@chromium.orgedd27d12009-05-01 17:46:56 +0000614 def __init__(self, path, text_status, props, lock, history):
maruel@chromium.orge105d8d2009-04-30 17:58:25 +0000615 self.path = path
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000616 self.text_status = text_status
617 self.props = props
maruel@chromium.orgedd27d12009-05-01 17:46:56 +0000618 self.lock = lock
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000619 self.history = history
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000620
621 def __str__(self):
maruel@chromium.orge105d8d2009-04-30 17:58:25 +0000622 # Emulate svn status 1.5 output.
maruel@chromium.orgedd27d12009-05-01 17:46:56 +0000623 return (self.text_status + self.props + self.lock + self.history + ' ' +
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000624 self.path)
625
626
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000627def CaptureSVNStatus(path):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000628 """Runs 'svn status' on an existing path.
629
630 Args:
631 path: The directory to run svn status.
632
633 Returns:
maruel@chromium.orge105d8d2009-04-30 17:58:25 +0000634 An array of FileStatus corresponding to the emulated output of 'svn status'
635 version 1.5."""
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000636 dom = ParseXML(CaptureSVN(["status", "--xml"], path))
maruel@chromium.orge105d8d2009-04-30 17:58:25 +0000637 results = []
638 if dom:
639 # /status/target/entry/(wc-status|commit|author|date)
640 for target in dom.getElementsByTagName('target'):
641 base_path = target.getAttribute('path')
642 for entry in target.getElementsByTagName('entry'):
643 file = entry.getAttribute('path')
644 wc_status = entry.getElementsByTagName('wc-status')
645 assert len(wc_status) == 1
646 # Emulate svn 1.5 status ouput...
647 statuses = [' ' for i in range(7)]
648 # Col 0
649 xml_item_status = wc_status[0].getAttribute('item')
650 if xml_item_status == 'unversioned':
651 statuses[0] = '?'
652 elif xml_item_status == 'modified':
653 statuses[0] = 'M'
654 elif xml_item_status == 'added':
655 statuses[0] = 'A'
656 elif xml_item_status == 'conflicted':
657 statuses[0] = 'C'
maruel@chromium.orgedd27d12009-05-01 17:46:56 +0000658 elif xml_item_status in ('incomplete', 'missing'):
659 statuses[0] = '!'
maruel@chromium.orge105d8d2009-04-30 17:58:25 +0000660 elif not xml_item_status:
661 pass
662 else:
663 raise Exception('Unknown item status "%s"; please implement me!' %
664 xml_item_status)
665 # Col 1
666 xml_props_status = wc_status[0].getAttribute('props')
667 if xml_props_status == 'modified':
668 statuses[1] = 'M'
669 elif xml_props_status == 'conflicted':
670 statuses[1] = 'C'
671 elif (not xml_props_status or xml_props_status == 'none' or
672 xml_props_status == 'normal'):
673 pass
674 else:
675 raise Exception('Unknown props status "%s"; please implement me!' %
676 xml_props_status)
maruel@chromium.orgedd27d12009-05-01 17:46:56 +0000677 # Col 2
678 if wc_status[0].getAttribute('wc-locked') == 'true':
679 statuses[2] = 'L'
maruel@chromium.orge105d8d2009-04-30 17:58:25 +0000680 # Col 3
681 if wc_status[0].getAttribute('copied') == 'true':
682 statuses[3] = '+'
maruel@chromium.orgedd27d12009-05-01 17:46:56 +0000683 item = FileStatus(file, statuses[0], statuses[1], statuses[2],
684 statuses[3])
maruel@chromium.orge105d8d2009-04-30 17:58:25 +0000685 results.append(item)
686 return results
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000687
688
689### SCM abstraction layer
690
691
692class SCMWrapper(object):
693 """Add necessary glue between all the supported SCM.
694
695 This is the abstraction layer to bind to different SCM. Since currently only
696 subversion is supported, a lot of subersionism remains. This can be sorted out
697 once another SCM is supported."""
698 def __init__(self, url=None, root_dir=None, relpath=None,
699 scm_name='svn'):
700 # TODO(maruel): Deduce the SCM from the url.
701 self.scm_name = scm_name
702 self.url = url
703 self._root_dir = root_dir
704 if self._root_dir:
maruel@chromium.orge105d8d2009-04-30 17:58:25 +0000705 self._root_dir = self._root_dir.replace('/', os.sep)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000706 self.relpath = relpath
707 if self.relpath:
maruel@chromium.orge105d8d2009-04-30 17:58:25 +0000708 self.relpath = self.relpath.replace('/', os.sep)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000709
710 def FullUrlForRelativeUrl(self, url):
711 # Find the forth '/' and strip from there. A bit hackish.
712 return '/'.join(self.url.split('/')[:4]) + url
713
714 def RunCommand(self, command, options, args, file_list=None):
715 # file_list will have all files that are modified appended to it.
716
717 if file_list == None:
718 file_list = []
719
720 commands = {
721 'cleanup': self.cleanup,
722 'update': self.update,
723 'revert': self.revert,
724 'status': self.status,
725 'diff': self.diff,
726 'runhooks': self.status,
727 }
728
729 if not command in commands:
730 raise Error('Unknown command %s' % command)
731
732 return commands[command](options, args, file_list)
733
734 def cleanup(self, options, args, file_list):
735 """Cleanup working copy."""
736 command = ['cleanup']
737 command.extend(args)
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000738 RunSVN(command, os.path.join(self._root_dir, self.relpath))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000739
740 def diff(self, options, args, file_list):
741 # NOTE: This function does not currently modify file_list.
742 command = ['diff']
743 command.extend(args)
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000744 RunSVN(command, os.path.join(self._root_dir, self.relpath))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000745
746 def update(self, options, args, file_list):
747 """Runs SCM to update or transparently checkout the working copy.
748
749 All updated files will be appended to file_list.
750
751 Raises:
752 Error: if can't get URL for relative path.
753 """
754 # Only update if git is not controlling the directory.
755 git_path = os.path.join(self._root_dir, self.relpath, '.git')
756 if options.path_exists(git_path):
757 print >> options.stdout, (
758 "________ found .git directory; skipping %s" % self.relpath)
759 return
760
761 if args:
762 raise Error("Unsupported argument(s): %s" % ",".join(args))
763
764 url = self.url
765 components = url.split("@")
766 revision = None
767 forced_revision = False
768 if options.revision:
769 # Override the revision number.
770 url = '%s@%s' % (components[0], str(options.revision))
771 revision = int(options.revision)
772 forced_revision = True
773 elif len(components) == 2:
774 revision = int(components[1])
775 forced_revision = True
776
777 rev_str = ""
778 if revision:
779 rev_str = ' at %d' % revision
780
781 if not options.path_exists(os.path.join(self._root_dir, self.relpath)):
782 # We need to checkout.
783 command = ['checkout', url, os.path.join(self._root_dir, self.relpath)]
maruel@chromium.orgedd27d12009-05-01 17:46:56 +0000784 if revision:
785 command.extend(['--revision', str(revision)])
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000786 RunSVNAndGetFileList(options, command, self._root_dir, file_list)
maruel@chromium.orgedd27d12009-05-01 17:46:56 +0000787 return
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000788
789 # Get the existing scm url and the revision number of the current checkout.
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000790 from_info = CaptureSVNInfo(os.path.join(self._root_dir, self.relpath, '.'),
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000791 '.')
792
793 if options.manually_grab_svn_rev:
794 # Retrieve the current HEAD version because svn is slow at null updates.
795 if not revision:
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000796 from_info_live = CaptureSVNInfo(from_info['URL'], '.')
maruel@chromium.org2dc8a4d2009-05-11 12:41:20 +0000797 revision = int(from_info_live['Revision'])
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000798 rev_str = ' at %d' % revision
799
maruel@chromium.org2dc8a4d2009-05-11 12:41:20 +0000800 if from_info['URL'] != components[0]:
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000801 to_info = CaptureSVNInfo(url, '.')
maruel@chromium.org2dc8a4d2009-05-11 12:41:20 +0000802 if from_info['Repository Root'] != to_info['Repository Root']:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000803 # We have different roots, so check if we can switch --relocate.
804 # Subversion only permits this if the repository UUIDs match.
maruel@chromium.org2dc8a4d2009-05-11 12:41:20 +0000805 if from_info['UUID'] != to_info['UUID']:
806 raise Error("Can't switch the checkout to %s; UUID don't match. That "
807 "simply means in theory, gclient should verify you don't "
808 "have a local change, remove the old checkout and do a "
809 "fresh new checkout of the new repo. Contributions are "
810 "welcome." % url)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000811
812 # Perform the switch --relocate, then rewrite the from_url
813 # to reflect where we "are now." (This is the same way that
814 # Subversion itself handles the metadata when switch --relocate
815 # is used.) This makes the checks below for whether we
816 # can update to a revision or have to switch to a different
817 # branch work as expected.
818 # TODO(maruel): TEST ME !
maruel@chromium.org2dc8a4d2009-05-11 12:41:20 +0000819 command = ["switch", "--relocate",
820 from_info['Repository Root'],
821 to_info['Repository Root'],
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000822 self.relpath]
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000823 RunSVN(command, self._root_dir)
maruel@chromium.org2dc8a4d2009-05-11 12:41:20 +0000824 from_info['URL'] = from_info['URL'].replace(
825 from_info['Repository Root'],
826 to_info['Repository Root'])
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000827
828 # If the provided url has a revision number that matches the revision
829 # number of the existing directory, then we don't need to bother updating.
maruel@chromium.org2dc8a4d2009-05-11 12:41:20 +0000830 if not options.force and from_info['Revision'] == revision:
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000831 if options.verbose or not forced_revision:
832 print >>options.stdout, ("\n_____ %s%s" % (
833 self.relpath, rev_str))
834 return
835
836 command = ["update", os.path.join(self._root_dir, self.relpath)]
837 if revision:
838 command.extend(['--revision', str(revision)])
839 RunSVNAndGetFileList(options, command, self._root_dir, file_list)
840
841 def revert(self, options, args, file_list):
842 """Reverts local modifications. Subversion specific.
843
844 All reverted files will be appended to file_list, even if Subversion
845 doesn't know about them.
846 """
847 path = os.path.join(self._root_dir, self.relpath)
848 if not os.path.isdir(path):
maruel@chromium.orgedd27d12009-05-01 17:46:56 +0000849 # svn revert won't work if the directory doesn't exist. It needs to
850 # checkout instead.
851 print >>options.stdout, ("\n_____ %s is missing, synching instead" %
852 self.relpath)
853 # Don't reuse the args.
854 return self.update(options, [], file_list)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000855
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000856 files = CaptureSVNStatus(path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000857 # Batch the command.
858 files_to_revert = []
859 for file in files:
860 file_path = os.path.join(path, file.path)
861 print >>options.stdout, file_path
862 # Unversioned file or unexpected unversioned file.
863 if file.text_status in ('?', '~'):
864 # Remove extraneous file. Also remove unexpected unversioned
865 # directories. svn won't touch them but we want to delete these.
866 file_list.append(file_path)
867 try:
868 os.remove(file_path)
869 except EnvironmentError:
870 RemoveDirectory(file_path)
871
872 if file.text_status != '?':
873 # For any other status, svn revert will work.
874 file_list.append(file_path)
875 files_to_revert.append(file.path)
876
877 # Revert them all at once.
878 if files_to_revert:
879 accumulated_paths = []
880 accumulated_length = 0
881 command = ['revert']
882 for p in files_to_revert:
883 # Some shell have issues with command lines too long.
884 if accumulated_length and accumulated_length + len(p) > 3072:
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000885 RunSVN(command + accumulated_paths,
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000886 os.path.join(self._root_dir, self.relpath))
887 accumulated_paths = []
888 accumulated_length = 0
889 else:
890 accumulated_paths.append(p)
891 accumulated_length += len(p)
892 if accumulated_paths:
maruel@chromium.org09b3ba22009-05-12 00:37:02 +0000893 RunSVN(command + accumulated_paths,
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000894 os.path.join(self._root_dir, self.relpath))
895
896 def status(self, options, args, file_list):
897 """Display status information."""
maruel@chromium.orgedd27d12009-05-01 17:46:56 +0000898 path = os.path.join(self._root_dir, self.relpath)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000899 command = ['status']
900 command.extend(args)
maruel@chromium.orgedd27d12009-05-01 17:46:56 +0000901 if not os.path.isdir(path):
902 # svn status won't work if the directory doesn't exist.
903 print >> options.stdout, (
904 "\n________ couldn't run \'%s\' in \'%s\':\nThe directory "
905 "does not exist."
906 % (' '.join(command), path))
907 # There's no file list to retrieve.
908 else:
909 RunSVNAndGetFileList(options, command, path, file_list)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000910
911
912## GClient implementation.
913
914
915class GClient(object):
916 """Object that represent a gclient checkout."""
917
918 supported_commands = [
919 'cleanup', 'diff', 'revert', 'status', 'update', 'runhooks'
920 ]
921
922 def __init__(self, root_dir, options):
923 self._root_dir = root_dir
924 self._options = options
925 self._config_content = None
926 self._config_dict = {}
927 self._deps_hooks = []
928
929 def SetConfig(self, content):
930 self._config_dict = {}
931 self._config_content = content
932 exec(content, self._config_dict)
933
934 def SaveConfig(self):
935 FileWrite(os.path.join(self._root_dir, self._options.config_filename),
936 self._config_content)
937
938 def _LoadConfig(self):
939 client_source = FileRead(os.path.join(self._root_dir,
940 self._options.config_filename))
941 self.SetConfig(client_source)
942
943 def ConfigContent(self):
944 return self._config_content
945
946 def GetVar(self, key, default=None):
947 return self._config_dict.get(key, default)
948
949 @staticmethod
950 def LoadCurrentConfig(options, from_dir=None):
951 """Searches for and loads a .gclient file relative to the current working
952 dir.
953
954 Returns:
955 A dict representing the contents of the .gclient file or an empty dict if
956 the .gclient file doesn't exist.
957 """
958 if not from_dir:
959 from_dir = os.curdir
960 path = os.path.realpath(from_dir)
961 while not options.path_exists(os.path.join(path, options.config_filename)):
962 next = os.path.split(path)
963 if not next[1]:
964 return None
965 path = next[0]
966 client = options.gclient(path, options)
967 client._LoadConfig()
968 return client
969
970 def SetDefaultConfig(self, solution_name, solution_url, safesync_url):
971 self.SetConfig(DEFAULT_CLIENT_FILE_TEXT % (
972 solution_name, solution_url, safesync_url
973 ))
974
975 def _SaveEntries(self, entries):
976 """Creates a .gclient_entries file to record the list of unique checkouts.
977
978 The .gclient_entries file lives in the same directory as .gclient.
979
980 Args:
981 entries: A sequence of solution names.
982 """
983 text = "entries = [\n"
984 for entry in entries:
985 text += " \"%s\",\n" % entry
986 text += "]\n"
987 FileWrite(os.path.join(self._root_dir, self._options.entries_filename),
988 text)
989
990 def _ReadEntries(self):
991 """Read the .gclient_entries file for the given client.
992
993 Args:
994 client: The client for which the entries file should be read.
995
996 Returns:
997 A sequence of solution names, which will be empty if there is the
998 entries file hasn't been created yet.
999 """
1000 scope = {}
1001 filename = os.path.join(self._root_dir, self._options.entries_filename)
1002 if not self._options.path_exists(filename):
1003 return []
1004 exec(FileRead(filename), scope)
1005 return scope["entries"]
1006
1007 class FromImpl:
1008 """Used to implement the From syntax."""
1009
1010 def __init__(self, module_name):
1011 self.module_name = module_name
1012
1013 def __str__(self):
1014 return 'From("%s")' % self.module_name
1015
1016 class _VarImpl:
1017 def __init__(self, custom_vars, local_scope):
1018 self._custom_vars = custom_vars
1019 self._local_scope = local_scope
1020
1021 def Lookup(self, var_name):
1022 """Implements the Var syntax."""
1023 if var_name in self._custom_vars:
1024 return self._custom_vars[var_name]
1025 elif var_name in self._local_scope.get("vars", {}):
1026 return self._local_scope["vars"][var_name]
1027 raise Error("Var is not defined: %s" % var_name)
1028
1029 def _ParseSolutionDeps(self, solution_name, solution_deps_content,
1030 custom_vars):
1031 """Parses the DEPS file for the specified solution.
1032
1033 Args:
1034 solution_name: The name of the solution to query.
1035 solution_deps_content: Content of the DEPS file for the solution
1036 custom_vars: A dict of vars to override any vars defined in the DEPS file.
1037
1038 Returns:
1039 A dict mapping module names (as relative paths) to URLs or an empty
1040 dict if the solution does not have a DEPS file.
1041 """
1042 # Skip empty
1043 if not solution_deps_content:
1044 return {}
1045 # Eval the content
1046 local_scope = {}
1047 var = self._VarImpl(custom_vars, local_scope)
1048 global_scope = {"From": self.FromImpl, "Var": var.Lookup, "deps_os": {}}
1049 exec(solution_deps_content, global_scope, local_scope)
1050 deps = local_scope.get("deps", {})
1051
1052 # load os specific dependencies if defined. these dependencies may
1053 # override or extend the values defined by the 'deps' member.
1054 if "deps_os" in local_scope:
1055 deps_os_choices = {
1056 "win32": "win",
1057 "win": "win",
1058 "cygwin": "win",
1059 "darwin": "mac",
1060 "mac": "mac",
1061 "unix": "unix",
1062 "linux": "unix",
1063 "linux2": "unix",
1064 }
1065
1066 if self._options.deps_os is not None:
1067 deps_to_include = self._options.deps_os.split(",")
1068 if "all" in deps_to_include:
1069 deps_to_include = deps_os_choices.values()
1070 else:
1071 deps_to_include = [deps_os_choices.get(self._options.platform, "unix")]
1072
1073 deps_to_include = set(deps_to_include)
1074 for deps_os_key in deps_to_include:
1075 os_deps = local_scope["deps_os"].get(deps_os_key, {})
1076 if len(deps_to_include) > 1:
1077 # Ignore any overrides when including deps for more than one
1078 # platform, so we collect the broadest set of dependencies available.
1079 # We may end up with the wrong revision of something for our
1080 # platform, but this is the best we can do.
1081 deps.update([x for x in os_deps.items() if not x[0] in deps])
1082 else:
1083 deps.update(os_deps)
1084
1085 if 'hooks' in local_scope:
1086 self._deps_hooks.extend(local_scope['hooks'])
1087
1088 # If use_relative_paths is set in the DEPS file, regenerate
1089 # the dictionary using paths relative to the directory containing
1090 # the DEPS file.
1091 if local_scope.get('use_relative_paths'):
1092 rel_deps = {}
1093 for d, url in deps.items():
1094 # normpath is required to allow DEPS to use .. in their
1095 # dependency local path.
1096 rel_deps[os.path.normpath(os.path.join(solution_name, d))] = url
1097 return rel_deps
1098 else:
1099 return deps
1100
1101 def _ParseAllDeps(self, solution_urls, solution_deps_content):
1102 """Parse the complete list of dependencies for the client.
1103
1104 Args:
1105 solution_urls: A dict mapping module names (as relative paths) to URLs
1106 corresponding to the solutions specified by the client. This parameter
1107 is passed as an optimization.
1108 solution_deps_content: A dict mapping module names to the content
1109 of their DEPS files
1110
1111 Returns:
1112 A dict mapping module names (as relative paths) to URLs corresponding
1113 to the entire set of dependencies to checkout for the given client.
1114
1115 Raises:
1116 Error: If a dependency conflicts with another dependency or of a solution.
1117 """
1118 deps = {}
1119 for solution in self.GetVar("solutions"):
1120 custom_vars = solution.get("custom_vars", {})
1121 solution_deps = self._ParseSolutionDeps(
1122 solution["name"],
1123 solution_deps_content[solution["name"]],
1124 custom_vars)
1125
1126 # If a line is in custom_deps, but not in the solution, we want to append
1127 # this line to the solution.
1128 if "custom_deps" in solution:
1129 for d in solution["custom_deps"]:
1130 if d not in solution_deps:
1131 solution_deps[d] = solution["custom_deps"][d]
1132
1133 for d in solution_deps:
1134 if "custom_deps" in solution and d in solution["custom_deps"]:
1135 # Dependency is overriden.
1136 url = solution["custom_deps"][d]
1137 if url is None:
1138 continue
1139 else:
1140 url = solution_deps[d]
1141 # if we have a From reference dependent on another solution, then
1142 # just skip the From reference. When we pull deps for the solution,
1143 # we will take care of this dependency.
1144 #
1145 # If multiple solutions all have the same From reference, then we
1146 # should only add one to our list of dependencies.
1147 if type(url) != str:
1148 if url.module_name in solution_urls:
1149 # Already parsed.
1150 continue
1151 if d in deps and type(deps[d]) != str:
1152 if url.module_name == deps[d].module_name:
1153 continue
1154 else:
1155 parsed_url = urlparse.urlparse(url)
1156 scheme = parsed_url[0]
1157 if not scheme:
1158 # A relative url. Fetch the real base.
1159 path = parsed_url[2]
1160 if path[0] != "/":
1161 raise Error(
1162 "relative DEPS entry \"%s\" must begin with a slash" % d)
1163 # Create a scm just to query the full url.
1164 scm = self._options.scm_wrapper(solution["url"], self._root_dir,
1165 None)
1166 url = scm.FullUrlForRelativeUrl(url)
1167 if d in deps and deps[d] != url:
1168 raise Error(
1169 "Solutions have conflicting versions of dependency \"%s\"" % d)
1170 if d in solution_urls and solution_urls[d] != url:
1171 raise Error(
1172 "Dependency \"%s\" conflicts with specified solution" % d)
1173 # Grab the dependency.
1174 deps[d] = url
1175 return deps
1176
1177 def _RunHookAction(self, hook_dict):
1178 """Runs the action from a single hook.
1179 """
1180 command = hook_dict['action'][:]
1181 if command[0] == 'python':
1182 # If the hook specified "python" as the first item, the action is a
1183 # Python script. Run it by starting a new copy of the same
1184 # interpreter.
1185 command[0] = sys.executable
1186
1187 # Use a discrete exit status code of 2 to indicate that a hook action
1188 # failed. Users of this script may wish to treat hook action failures
1189 # differently from VC failures.
1190 SubprocessCall(command, self._root_dir, self._options.stdout,
1191 fail_status=2)
1192
1193 def _RunHooks(self, command, file_list, is_using_git):
1194 """Evaluates all hooks, running actions as needed.
1195 """
1196 # Hooks only run for these command types.
1197 if not command in ('update', 'revert', 'runhooks'):
1198 return
1199
1200 # Get any hooks from the .gclient file.
1201 hooks = self.GetVar("hooks", [])
1202 # Add any hooks found in DEPS files.
1203 hooks.extend(self._deps_hooks)
1204
1205 # If "--force" was specified, run all hooks regardless of what files have
1206 # changed. If the user is using git, then we don't know what files have
1207 # changed so we always run all hooks.
1208 if self._options.force or is_using_git:
1209 for hook_dict in hooks:
1210 self._RunHookAction(hook_dict)
1211 return
1212
1213 # Run hooks on the basis of whether the files from the gclient operation
1214 # match each hook's pattern.
1215 for hook_dict in hooks:
1216 pattern = re.compile(hook_dict['pattern'])
1217 for file in file_list:
1218 if not pattern.search(file):
1219 continue
1220
1221 self._RunHookAction(hook_dict)
1222
1223 # The hook's action only runs once. Don't bother looking for any
1224 # more matches.
1225 break
1226
1227 def RunOnDeps(self, command, args):
1228 """Runs a command on each dependency in a client and its dependencies.
1229
1230 The module's dependencies are specified in its top-level DEPS files.
1231
1232 Args:
1233 command: The command to use (e.g., 'status' or 'diff')
1234 args: list of str - extra arguments to add to the command line.
1235
1236 Raises:
1237 Error: If the client has conflicting entries.
1238 """
1239 if not command in self.supported_commands:
1240 raise Error("'%s' is an unsupported command" % command)
1241
1242 # Check for revision overrides.
1243 revision_overrides = {}
1244 for revision in self._options.revisions:
1245 if revision.find("@") == -1:
1246 raise Error(
1247 "Specify the full dependency when specifying a revision number.")
1248 revision_elem = revision.split("@")
1249 # Disallow conflicting revs
1250 if revision_overrides.has_key(revision_elem[0]) and \
1251 revision_overrides[revision_elem[0]] != revision_elem[1]:
1252 raise Error(
1253 "Conflicting revision numbers specified.")
1254 revision_overrides[revision_elem[0]] = revision_elem[1]
1255
1256 solutions = self.GetVar("solutions")
1257 if not solutions:
1258 raise Error("No solution specified")
1259
1260 # When running runhooks --force, there's no need to consult the SCM.
1261 # All known hooks are expected to run unconditionally regardless of working
1262 # copy state, so skip the SCM status check.
1263 run_scm = not (command == 'runhooks' and self._options.force)
1264
1265 entries = {}
1266 entries_deps_content = {}
1267 file_list = []
1268 # Run on the base solutions first.
1269 for solution in solutions:
1270 name = solution["name"]
1271 if name in entries:
1272 raise Error("solution %s specified more than once" % name)
1273 url = solution["url"]
1274 entries[name] = url
1275 if run_scm:
1276 self._options.revision = revision_overrides.get(name)
1277 scm = self._options.scm_wrapper(url, self._root_dir, name)
1278 scm.RunCommand(command, self._options, args, file_list)
1279 self._options.revision = None
1280 try:
1281 deps_content = FileRead(os.path.join(self._root_dir, name,
1282 self._options.deps_file))
1283 except IOError, e:
1284 if e.errno != errno.ENOENT:
1285 raise
1286 deps_content = ""
1287 entries_deps_content[name] = deps_content
1288
1289 # Process the dependencies next (sort alphanumerically to ensure that
1290 # containing directories get populated first and for readability)
1291 deps = self._ParseAllDeps(entries, entries_deps_content)
1292 deps_to_process = deps.keys()
1293 deps_to_process.sort()
1294
1295 # First pass for direct dependencies.
1296 for d in deps_to_process:
1297 if type(deps[d]) == str:
1298 url = deps[d]
1299 entries[d] = url
1300 if run_scm:
1301 self._options.revision = revision_overrides.get(d)
1302 scm = self._options.scm_wrapper(url, self._root_dir, d)
1303 scm.RunCommand(command, self._options, args, file_list)
1304 self._options.revision = None
1305
1306 # Second pass for inherited deps (via the From keyword)
1307 for d in deps_to_process:
1308 if type(deps[d]) != str:
1309 sub_deps = self._ParseSolutionDeps(
1310 deps[d].module_name,
1311 FileRead(os.path.join(self._root_dir,
1312 deps[d].module_name,
1313 self._options.deps_file)),
1314 {})
1315 url = sub_deps[d]
1316 entries[d] = url
1317 if run_scm:
1318 self._options.revision = revision_overrides.get(d)
1319 scm = self._options.scm_wrapper(url, self._root_dir, d)
1320 scm.RunCommand(command, self._options, args, file_list)
1321 self._options.revision = None
1322
1323 is_using_git = IsUsingGit(self._root_dir, entries.keys())
1324 self._RunHooks(command, file_list, is_using_git)
1325
1326 if command == 'update':
1327 # notify the user if there is an orphaned entry in their working copy.
1328 # TODO(darin): we should delete this directory manually if it doesn't
1329 # have any changes in it.
1330 prev_entries = self._ReadEntries()
1331 for entry in prev_entries:
1332 e_dir = os.path.join(self._root_dir, entry)
1333 if entry not in entries and self._options.path_exists(e_dir):
maruel@chromium.org09b3ba22009-05-12 00:37:02 +00001334 if CaptureSVNStatus(e_dir):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001335 # There are modified files in this entry
1336 entries[entry] = None # Keep warning until removed.
1337 print >> self._options.stdout, (
1338 "\nWARNING: \"%s\" is no longer part of this client. "
1339 "It is recommended that you manually remove it.\n") % entry
1340 else:
1341 # Delete the entry
1342 print >> self._options.stdout, ("\n________ deleting \'%s\' " +
1343 "in \'%s\'") % (entry, self._root_dir)
1344 RemoveDirectory(e_dir)
1345 # record the current list of entries for next time
1346 self._SaveEntries(entries)
1347
1348 def PrintRevInfo(self):
1349 """Output revision info mapping for the client and its dependencies. This
1350 allows the capture of a overall "revision" for the source tree that can
1351 be used to reproduce the same tree in the future. The actual output
1352 contains enough information (source paths, svn server urls and revisions)
1353 that it can be used either to generate external svn commands (without
1354 gclient) or as input to gclient's --rev option (with some massaging of
1355 the data).
1356
1357 NOTE: Unlike RunOnDeps this does not require a local checkout and is run
1358 on the Pulse master. It MUST NOT execute hooks.
1359
1360 Raises:
1361 Error: If the client has conflicting entries.
1362 """
1363 # Check for revision overrides.
1364 revision_overrides = {}
1365 for revision in self._options.revisions:
1366 if revision.find("@") < 0:
1367 raise Error(
1368 "Specify the full dependency when specifying a revision number.")
1369 revision_elem = revision.split("@")
1370 # Disallow conflicting revs
1371 if revision_overrides.has_key(revision_elem[0]) and \
1372 revision_overrides[revision_elem[0]] != revision_elem[1]:
1373 raise Error(
1374 "Conflicting revision numbers specified.")
1375 revision_overrides[revision_elem[0]] = revision_elem[1]
1376
1377 solutions = self.GetVar("solutions")
1378 if not solutions:
1379 raise Error("No solution specified")
1380
1381 entries = {}
1382 entries_deps_content = {}
1383
1384 # Inner helper to generate base url and rev tuple (including honoring
1385 # |revision_overrides|)
1386 def GetURLAndRev(name, original_url):
1387 if original_url.find("@") < 0:
1388 if revision_overrides.has_key(name):
1389 return (original_url, int(revision_overrides[name]))
1390 else:
1391 # TODO(aharper): SVN/SCMWrapper cleanup (non-local commandset)
maruel@chromium.org09b3ba22009-05-12 00:37:02 +00001392 return (original_url, CaptureSVNHeadRevision(original_url))
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001393 else:
1394 url_components = original_url.split("@")
1395 if revision_overrides.has_key(name):
1396 return (url_components[0], int(revision_overrides[name]))
1397 else:
1398 return (url_components[0], int(url_components[1]))
1399
1400 # Run on the base solutions first.
1401 for solution in solutions:
1402 name = solution["name"]
1403 if name in entries:
1404 raise Error("solution %s specified more than once" % name)
1405 (url, rev) = GetURLAndRev(name, solution["url"])
1406 entries[name] = "%s@%d" % (url, rev)
1407 # TODO(aharper): SVN/SCMWrapper cleanup (non-local commandset)
1408 entries_deps_content[name] = CaptureSVN(
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001409 ["cat",
1410 "%s/%s@%d" % (url,
1411 self._options.deps_file,
1412 rev)],
1413 os.getcwd())
1414
1415 # Process the dependencies next (sort alphanumerically to ensure that
1416 # containing directories get populated first and for readability)
1417 deps = self._ParseAllDeps(entries, entries_deps_content)
1418 deps_to_process = deps.keys()
1419 deps_to_process.sort()
1420
1421 # First pass for direct dependencies.
1422 for d in deps_to_process:
1423 if type(deps[d]) == str:
1424 (url, rev) = GetURLAndRev(d, deps[d])
1425 entries[d] = "%s@%d" % (url, rev)
1426
1427 # Second pass for inherited deps (via the From keyword)
1428 for d in deps_to_process:
1429 if type(deps[d]) != str:
1430 deps_parent_url = entries[deps[d].module_name]
1431 if deps_parent_url.find("@") < 0:
1432 raise Error("From %s missing revisioned url" % deps[d].module_name)
1433 deps_parent_url_components = deps_parent_url.split("@")
1434 # TODO(aharper): SVN/SCMWrapper cleanup (non-local commandset)
1435 deps_parent_content = CaptureSVN(
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001436 ["cat",
1437 "%s/%s@%s" % (deps_parent_url_components[0],
1438 self._options.deps_file,
1439 deps_parent_url_components[1])],
1440 os.getcwd())
1441 sub_deps = self._ParseSolutionDeps(
1442 deps[d].module_name,
1443 FileRead(os.path.join(self._root_dir,
1444 deps[d].module_name,
1445 self._options.deps_file)),
1446 {})
1447 (url, rev) = GetURLAndRev(d, sub_deps[d])
1448 entries[d] = "%s@%d" % (url, rev)
1449
1450 print ";".join(["%s,%s" % (x, entries[x]) for x in sorted(entries.keys())])
1451
1452
1453## gclient commands.
1454
1455
1456def DoCleanup(options, args):
1457 """Handle the cleanup subcommand.
1458
1459 Raises:
1460 Error: if client isn't configured properly.
1461 """
1462 client = options.gclient.LoadCurrentConfig(options)
1463 if not client:
1464 raise Error("client not configured; see 'gclient config'")
1465 if options.verbose:
1466 # Print out the .gclient file. This is longer than if we just printed the
1467 # client dict, but more legible, and it might contain helpful comments.
1468 print >>options.stdout, client.ConfigContent()
1469 options.verbose = True
1470 return client.RunOnDeps('cleanup', args)
1471
1472
1473def DoConfig(options, args):
1474 """Handle the config subcommand.
1475
1476 Args:
1477 options: If options.spec set, a string providing contents of config file.
1478 args: The command line args. If spec is not set,
1479 then args[0] is a string URL to get for config file.
1480
1481 Raises:
1482 Error: on usage error
1483 """
1484 if len(args) < 1 and not options.spec:
1485 raise Error("required argument missing; see 'gclient help config'")
1486 if options.path_exists(options.config_filename):
1487 raise Error("%s file already exists in the current directory" %
1488 options.config_filename)
1489 client = options.gclient('.', options)
1490 if options.spec:
1491 client.SetConfig(options.spec)
1492 else:
1493 # TODO(darin): it would be nice to be able to specify an alternate relpath
1494 # for the given URL.
1495 base_url = args[0]
1496 name = args[0].split("/")[-1]
1497 safesync_url = ""
1498 if len(args) > 1:
1499 safesync_url = args[1]
1500 client.SetDefaultConfig(name, base_url, safesync_url)
1501 client.SaveConfig()
1502
1503
1504def DoHelp(options, args):
1505 """Handle the help subcommand giving help for another subcommand.
1506
1507 Raises:
1508 Error: if the command is unknown.
1509 """
1510 if len(args) == 1 and args[0] in COMMAND_USAGE_TEXT:
1511 print >>options.stdout, COMMAND_USAGE_TEXT[args[0]]
1512 else:
1513 raise Error("unknown subcommand '%s'; see 'gclient help'" % args[0])
1514
1515
1516def DoStatus(options, args):
1517 """Handle the status subcommand.
1518
1519 Raises:
1520 Error: if client isn't configured properly.
1521 """
1522 client = options.gclient.LoadCurrentConfig(options)
1523 if not client:
1524 raise Error("client not configured; see 'gclient config'")
1525 if options.verbose:
1526 # Print out the .gclient file. This is longer than if we just printed the
1527 # client dict, but more legible, and it might contain helpful comments.
1528 print >>options.stdout, client.ConfigContent()
1529 options.verbose = True
1530 return client.RunOnDeps('status', args)
1531
1532
1533def DoUpdate(options, args):
1534 """Handle the update and sync subcommands.
1535
1536 Raises:
1537 Error: if client isn't configured properly.
1538 """
1539 client = options.gclient.LoadCurrentConfig(options)
1540
1541 if not client:
1542 raise Error("client not configured; see 'gclient config'")
1543
1544 if not options.head:
1545 solutions = client.GetVar('solutions')
1546 if solutions:
1547 for s in solutions:
1548 if s.get('safesync_url', ''):
1549 # rip through revisions and make sure we're not over-riding
1550 # something that was explicitly passed
1551 has_key = False
1552 for r in options.revisions:
1553 if r.split('@')[0] == s['name']:
1554 has_key = True
1555 break
1556
1557 if not has_key:
1558 handle = urllib.urlopen(s['safesync_url'])
1559 rev = handle.read().strip()
1560 handle.close()
1561 if len(rev):
1562 options.revisions.append(s['name']+'@'+rev)
1563
1564 if options.verbose:
1565 # Print out the .gclient file. This is longer than if we just printed the
1566 # client dict, but more legible, and it might contain helpful comments.
1567 print >>options.stdout, client.ConfigContent()
1568 return client.RunOnDeps('update', args)
1569
1570
1571def DoDiff(options, args):
1572 """Handle the diff subcommand.
1573
1574 Raises:
1575 Error: if client isn't configured properly.
1576 """
1577 client = options.gclient.LoadCurrentConfig(options)
1578 if not client:
1579 raise Error("client not configured; see 'gclient config'")
1580 if options.verbose:
1581 # Print out the .gclient file. This is longer than if we just printed the
1582 # client dict, but more legible, and it might contain helpful comments.
1583 print >>options.stdout, client.ConfigContent()
1584 options.verbose = True
1585 return client.RunOnDeps('diff', args)
1586
1587
1588def DoRevert(options, args):
1589 """Handle the revert subcommand.
1590
1591 Raises:
1592 Error: if client isn't configured properly.
1593 """
1594 client = options.gclient.LoadCurrentConfig(options)
1595 if not client:
1596 raise Error("client not configured; see 'gclient config'")
1597 return client.RunOnDeps('revert', args)
1598
1599
1600def DoRunHooks(options, args):
1601 """Handle the runhooks subcommand.
1602
1603 Raises:
1604 Error: if client isn't configured properly.
1605 """
1606 client = options.gclient.LoadCurrentConfig(options)
1607 if not client:
1608 raise Error("client not configured; see 'gclient config'")
1609 if options.verbose:
1610 # Print out the .gclient file. This is longer than if we just printed the
1611 # client dict, but more legible, and it might contain helpful comments.
1612 print >>options.stdout, client.ConfigContent()
1613 return client.RunOnDeps('runhooks', args)
1614
1615
1616def DoRevInfo(options, args):
1617 """Handle the revinfo subcommand.
1618
1619 Raises:
1620 Error: if client isn't configured properly.
1621 """
1622 client = options.gclient.LoadCurrentConfig(options)
1623 if not client:
1624 raise Error("client not configured; see 'gclient config'")
1625 client.PrintRevInfo()
1626
1627
1628gclient_command_map = {
1629 "cleanup": DoCleanup,
1630 "config": DoConfig,
1631 "diff": DoDiff,
1632 "help": DoHelp,
1633 "status": DoStatus,
1634 "sync": DoUpdate,
1635 "update": DoUpdate,
1636 "revert": DoRevert,
1637 "runhooks": DoRunHooks,
1638 "revinfo" : DoRevInfo,
1639}
1640
1641
1642def DispatchCommand(command, options, args, command_map=None):
1643 """Dispatches the appropriate subcommand based on command line arguments."""
1644 if command_map is None:
1645 command_map = gclient_command_map
1646
1647 if command in command_map:
1648 return command_map[command](options, args)
1649 else:
1650 raise Error("unknown subcommand '%s'; see 'gclient help'" % command)
1651
1652
1653def Main(argv):
1654 """Parse command line arguments and dispatch command."""
1655
1656 option_parser = optparse.OptionParser(usage=DEFAULT_USAGE_TEXT,
1657 version=__version__)
1658 option_parser.disable_interspersed_args()
1659 option_parser.add_option("", "--force", action="store_true", default=False,
1660 help=("(update/sync only) force update even "
1661 "for modules which haven't changed"))
1662 option_parser.add_option("", "--revision", action="append", dest="revisions",
1663 metavar="REV", default=[],
1664 help=("(update/sync only) sync to a specific "
1665 "revision, can be used multiple times for "
1666 "each solution, e.g. --revision=src@123, "
1667 "--revision=internal@32"))
1668 option_parser.add_option("", "--deps", default=None, dest="deps_os",
1669 metavar="OS_LIST",
1670 help=("(update/sync only) sync deps for the "
1671 "specified (comma-separated) platform(s); "
1672 "'all' will sync all platforms"))
1673 option_parser.add_option("", "--spec", default=None,
1674 help=("(config only) create a gclient file "
1675 "containing the provided string"))
1676 option_parser.add_option("", "--verbose", action="store_true", default=False,
1677 help="produce additional output for diagnostics")
1678 option_parser.add_option("", "--manually_grab_svn_rev", action="store_true",
1679 default=False,
1680 help="Skip svn up whenever possible by requesting "
1681 "actual HEAD revision from the repository")
1682 option_parser.add_option("", "--head", action="store_true", default=False,
1683 help=("skips any safesync_urls specified in "
1684 "configured solutions"))
1685
1686 if len(argv) < 2:
1687 # Users don't need to be told to use the 'help' command.
1688 option_parser.print_help()
1689 return 1
1690 # Add manual support for --version as first argument.
1691 if argv[1] == '--version':
1692 option_parser.print_version()
1693 return 0
1694
1695 # Add manual support for --help as first argument.
1696 if argv[1] == '--help':
1697 argv[1] = 'help'
1698
1699 command = argv[1]
1700 options, args = option_parser.parse_args(argv[2:])
1701
1702 if len(argv) < 3 and command == "help":
1703 option_parser.print_help()
1704 return 0
1705
1706 # Files used for configuration and state saving.
1707 options.config_filename = os.environ.get("GCLIENT_FILE", ".gclient")
1708 options.entries_filename = ".gclient_entries"
1709 options.deps_file = "DEPS"
1710
1711 # These are overridded when testing. They are not externally visible.
1712 options.stdout = sys.stdout
1713 options.path_exists = os.path.exists
1714 options.gclient = GClient
1715 options.scm_wrapper = SCMWrapper
1716 options.platform = sys.platform
1717 return DispatchCommand(command, options, args)
1718
1719
1720if "__main__" == __name__:
1721 try:
1722 result = Main(sys.argv)
1723 except Error, e:
1724 print "Error: %s" % str(e)
1725 result = 1
1726 sys.exit(result)
1727
1728# vim: ts=2:sw=2:tw=80:et: