blob: 7c73ab05ad4f235674e2e728c8426f7d3200286e [file] [log] [blame]
maruel@google.comfb2b8eb2009-04-23 21:03:42 +00001#!/usr/bin/python
2# Copyright (c) 2006-2009 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Enables directory-specific presubmit checks to run at upload and/or commit.
7"""
8
maruel@chromium.orgc1675e22009-04-27 20:30:48 +00009__version__ = '1.0.1'
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000010
11# TODO(joi) Add caching where appropriate/needed. The API is designed to allow
12# caching (between all different invocations of presubmit scripts for a given
13# change). We should add it as our presubmit scripts start feeling slow.
14
15import cPickle # Exposed through the API.
16import cStringIO # Exposed through the API.
17import exceptions
18import fnmatch
19import glob
20import marshal # Exposed through the API.
21import optparse
22import os # Somewhat exposed through the API.
23import pickle # Exposed through the API.
24import re # Exposed through the API.
25import subprocess # Exposed through the API.
26import sys # Parts exposed through API.
27import tempfile # Exposed through the API.
28import types
29import urllib2 # Exposed through the API.
30
31# Local imports.
32# TODO(joi) Would be cleaner to factor out utils in gcl to separate module, but
33# for now it would only be a couple of functions so hardly worth it.
34import gcl
maruel@chromium.org46a94102009-05-12 20:32:43 +000035import gclient
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000036import presubmit_canned_checks
37
38
39# Matches key/value (or "tag") lines in changelist descriptions.
40_tag_line_re = re.compile(
41 '^\s*(?P<key>[A-Z][A-Z_0-9]*)\s*=\s*(?P<value>.*?)\s*$')
42
43
44# Friendly names may be used for certain keys. All values for key-value pairs
45# in change descriptions (like BUG=123) can be retrieved from a change object
46# directly as if they were attributes, e.g. change.R (or equivalently because
47# we have a friendly name for it, change.Reviewers), change.BUG (or
48# change.BugIDs) and so forth.
49#
50# Add to this mapping as needed/desired.
51SPECIAL_KEYS = {
52 'Reviewers' : 'R',
53 'BugIDs' : 'BUG',
kuchhal@chromium.org00c41e42009-05-12 21:43:13 +000054 'Tested': 'TESTED',
55 'Test': 'TEST'
maruel@google.comfb2b8eb2009-04-23 21:03:42 +000056}
57
58
59class NotImplementedException(Exception):
60 """We're leaving placeholders in a bunch of places to remind us of the
61 design of the API, but we have not implemented all of it yet. Implement as
62 the need arises.
63 """
64 pass
65
66
67def normpath(path):
68 '''Version of os.path.normpath that also changes backward slashes to
69 forward slashes when not running on Windows.
70 '''
71 # This is safe to always do because the Windows version of os.path.normpath
72 # will replace forward slashes with backward slashes.
73 path = path.replace(os.sep, '/')
74 return os.path.normpath(path)
75
76
77
78class OutputApi(object):
79 """This class (more like a module) gets passed to presubmit scripts so that
80 they can specify various types of results.
81 """
82
83 class PresubmitResult(object):
84 """Base class for result objects."""
85
86 def __init__(self, message, items=None, long_text=''):
87 """
88 message: A short one-line message to indicate errors.
89 items: A list of short strings to indicate where errors occurred.
90 long_text: multi-line text output, e.g. from another tool
91 """
92 self._message = message
93 self._items = []
94 if items:
95 self._items = items
96 self._long_text = long_text.rstrip()
97
98 def _Handle(self, output_stream, input_stream, may_prompt=True):
99 """Writes this result to the output stream.
100
101 Args:
102 output_stream: Where to write
103
104 Returns:
105 True if execution may continue, False otherwise.
106 """
107 output_stream.write(self._message)
108 output_stream.write('\n')
109 for item in self._items:
110 output_stream.write(' %s\n' % item)
111 if self._long_text:
112 output_stream.write('\n***************\n%s\n***************\n\n' %
113 self._long_text)
114
115 if self.ShouldPrompt() and may_prompt:
116 output_stream.write('Are you sure you want to continue? (y/N): ')
117 response = input_stream.readline()
118 if response.strip().lower() != 'y':
119 return False
120
121 return not self.IsFatal()
122
123 def IsFatal(self):
124 """An error that is fatal stops g4 mail/submit immediately, i.e. before
125 other presubmit scripts are run.
126 """
127 return False
128
129 def ShouldPrompt(self):
130 """Whether this presubmit result should result in a prompt warning."""
131 return False
132
133 class PresubmitError(PresubmitResult):
134 """A hard presubmit error."""
135 def IsFatal(self):
136 return True
137
138 class PresubmitPromptWarning(PresubmitResult):
139 """An warning that prompts the user if they want to continue."""
140 def ShouldPrompt(self):
141 return True
142
143 class PresubmitNotifyResult(PresubmitResult):
144 """Just print something to the screen -- but it's not even a warning."""
145 pass
146
147 class MailTextResult(PresubmitResult):
148 """A warning that should be included in the review request email."""
149 def __init__(self, *args, **kwargs):
150 raise NotImplementedException() # TODO(joi) Implement.
151
152
153class InputApi(object):
154 """An instance of this object is passed to presubmit scripts so they can
155 know stuff about the change they're looking at.
156 """
157
158 def __init__(self, change, presubmit_path):
159 """Builds an InputApi object.
160
161 Args:
162 change: A presubmit.GclChange object.
163 presubmit_path: The path to the presubmit script being processed.
164 """
165 self.change = change
166
167 # We expose various modules and functions as attributes of the input_api
168 # so that presubmit scripts don't have to import them.
169 self.basename = os.path.basename
170 self.cPickle = cPickle
171 self.cStringIO = cStringIO
172 self.os_path = os.path
173 self.pickle = pickle
174 self.marshal = marshal
175 self.re = re
176 self.subprocess = subprocess
177 self.tempfile = tempfile
178 self.urllib2 = urllib2
179
180 # InputApi.platform is the platform you're currently running on.
181 self.platform = sys.platform
182
183 # The local path of the currently-being-processed presubmit script.
maruel@chromium.org3d235242009-05-15 12:40:48 +0000184 self._current_presubmit_path = os.path.dirname(presubmit_path)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000185
186 # We carry the canned checks so presubmit scripts can easily use them.
187 self.canned_checks = presubmit_canned_checks
188
189 def PresubmitLocalPath(self):
190 """Returns the local path of the presubmit script currently being run.
191
192 This is useful if you don't want to hard-code absolute paths in the
193 presubmit script. For example, It can be used to find another file
194 relative to the PRESUBMIT.py script, so the whole tree can be branched and
195 the presubmit script still works, without editing its content.
196 """
maruel@chromium.org3d235242009-05-15 12:40:48 +0000197 return self._current_presubmit_path
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000198
199 @staticmethod
200 def DepotToLocalPath(depot_path):
201 """Translate a depot path to a local path (relative to client root).
202
203 Args:
204 Depot path as a string.
205
206 Returns:
207 The local path of the depot path under the user's current client, or None
208 if the file is not mapped.
209
210 Remember to check for the None case and show an appropriate error!
211 """
maruel@chromium.org46a94102009-05-12 20:32:43 +0000212 local_path = gclient.CaptureSVNInfo(depot_path).get('Path')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000213 if not local_path:
214 return None
215 else:
216 return local_path
217
218 @staticmethod
219 def LocalToDepotPath(local_path):
220 """Translate a local path to a depot path.
221
222 Args:
223 Local path (relative to current directory, or absolute) as a string.
224
225 Returns:
226 The depot path (SVN URL) of the file if mapped, otherwise None.
227 """
maruel@chromium.org46a94102009-05-12 20:32:43 +0000228 depot_path = gclient.CaptureSVNInfo(local_path).get('URL')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000229 if not depot_path:
230 return None
231 else:
232 return depot_path
233
234 @staticmethod
235 def FilterTextFiles(affected_files, include_deletes=True):
236 """Filters out all except text files and optionally also filters out
237 deleted files.
238
239 Args:
240 affected_files: List of AffectedFiles objects.
241 include_deletes: If false, deleted files will be filtered out.
242
243 Returns:
244 Filtered list of AffectedFiles objects.
245 """
246 output_files = []
247 for af in affected_files:
248 if include_deletes or af.Action() != 'D':
249 path = af.AbsoluteLocalPath()
250 mime_type = gcl.GetSVNFileProperty(path, 'svn:mime-type')
251 if not mime_type or mime_type.startswith('text/'):
252 output_files.append(af)
253 return output_files
254
255 def AffectedFiles(self, include_dirs=False, include_deletes=True):
256 """Same as input_api.change.AffectedFiles() except only lists files
257 (and optionally directories) in the same directory as the current presubmit
258 script, or subdirectories thereof.
259 """
260 output_files = []
maruel@chromium.org3d235242009-05-15 12:40:48 +0000261 dir_with_slash = normpath("%s/" % self.PresubmitLocalPath())
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000262 if len(dir_with_slash) == 1:
263 dir_with_slash = ''
264 for af in self.change.AffectedFiles(include_dirs, include_deletes):
265 af_path = normpath(af.LocalPath())
266 if af_path.startswith(dir_with_slash):
267 output_files.append(af)
268 return output_files
269
270 def LocalPaths(self, include_dirs=False):
271 """Returns local paths of input_api.AffectedFiles()."""
272 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
273
274 def AbsoluteLocalPaths(self, include_dirs=False):
275 """Returns absolute local paths of input_api.AffectedFiles()."""
276 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
277
278 def ServerPaths(self, include_dirs=False):
279 """Returns server paths of input_api.AffectedFiles()."""
280 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
281
282 def AffectedTextFiles(self, include_deletes=True):
283 """Same as input_api.change.AffectedTextFiles() except only lists files
284 in the same directory as the current presubmit script, or subdirectories
285 thereof.
286
287 Warning: This function retrieves the svn property on each file so it can be
288 slow for large change lists.
289 """
290 return InputApi.FilterTextFiles(self.AffectedFiles(include_dirs=False),
291 include_deletes)
292
293 def RightHandSideLines(self):
294 """An iterator over all text lines in "new" version of changed files.
295
296 Only lists lines from new or modified text files in the change that are
297 contained by the directory of the currently executing presubmit script.
298
299 This is useful for doing line-by-line regex checks, like checking for
300 trailing whitespace.
301
302 Yields:
303 a 3 tuple:
304 the AffectedFile instance of the current file;
305 integer line number (1-based); and
306 the contents of the line as a string.
307 """
308 return InputApi._RightHandSideLinesImpl(
309 self.AffectedTextFiles(include_deletes=False))
310
311 @staticmethod
312 def _RightHandSideLinesImpl(affected_files):
313 """Implements RightHandSideLines for InputApi and GclChange."""
314 for af in affected_files:
315 lines = af.NewContents()
316 line_number = 0
317 for line in lines:
318 line_number += 1
319 yield (af, line_number, line)
320
321
322class AffectedFile(object):
323 """Representation of a file in a change."""
324
325 def __init__(self, path, action, repository_root=''):
326 self.path = path
327 self.action = action.strip()
328 self.repository_root = repository_root
329
330 def ServerPath(self):
331 """Returns a path string that identifies the file in the SCM system.
332
333 Returns the empty string if the file does not exist in SCM.
334 """
maruel@chromium.org46a94102009-05-12 20:32:43 +0000335 return gclient.CaptureSVNInfo(self.AbsoluteLocalPath()).get('URL', '')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000336
337 def LocalPath(self):
338 """Returns the path of this file on the local disk relative to client root.
339 """
340 return normpath(self.path)
341
342 def AbsoluteLocalPath(self):
343 """Returns the absolute path of this file on the local disk.
344 """
345 return normpath(os.path.join(self.repository_root, self.LocalPath()))
346
347 def IsDirectory(self):
348 """Returns true if this object is a directory."""
349 if os.path.exists(self.path):
350 # Retrieve directly from the file system; it is much faster than querying
351 # subversion, especially on Windows.
352 return os.path.isdir(self.path)
353 else:
maruel@chromium.org46a94102009-05-12 20:32:43 +0000354 return gclient.CaptureSVNInfo(self.path).get('Node Kind') in ('dir',
355 'directory')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000356
357 def SvnProperty(self, property_name):
358 """Returns the specified SVN property of this file, or the empty string
359 if no such property.
360 """
361 return gcl.GetSVNFileProperty(self.AbsoluteLocalPath(), property_name)
362
363 def Action(self):
364 """Returns the action on this opened file, e.g. A, M, D, etc."""
365 return self.action
366
367 def NewContents(self):
368 """Returns an iterator over the lines in the new version of file.
369
370 The new version is the file in the user's workspace, i.e. the "right hand
371 side".
372
373 Contents will be empty if the file is a directory or does not exist.
374 """
375 if self.IsDirectory():
376 return []
377 else:
378 return gcl.ReadFile(self.AbsoluteLocalPath()).splitlines()
379
380 def OldContents(self):
381 """Returns an iterator over the lines in the old version of file.
382
383 The old version is the file in depot, i.e. the "left hand side".
384 """
385 raise NotImplementedError() # Implement when needed
386
387 def OldFileTempPath(self):
388 """Returns the path on local disk where the old contents resides.
389
390 The old version is the file in depot, i.e. the "left hand side".
391 This is a read-only cached copy of the old contents. *DO NOT* try to
392 modify this file.
393 """
394 raise NotImplementedError() # Implement if/when needed.
395
396
397class GclChange(object):
398 """A gcl change. See gcl.ChangeInfo for more info."""
399
400 def __init__(self, change_info, repository_root=''):
401 self.name = change_info.name
402 self.full_description = change_info.description
403 self.repository_root = repository_root
404
405 # From the description text, build up a dictionary of key/value pairs
406 # plus the description minus all key/value or "tag" lines.
407 self.description_without_tags = []
408 self.tags = {}
409 for line in change_info.description.splitlines():
410 m = _tag_line_re.match(line)
411 if m:
412 self.tags[m.group('key')] = m.group('value')
413 else:
414 self.description_without_tags.append(line)
415
416 # Change back to text and remove whitespace at end.
417 self.description_without_tags = '\n'.join(self.description_without_tags)
418 self.description_without_tags = self.description_without_tags.rstrip()
419
420 self.affected_files = [AffectedFile(info[1], info[0], repository_root) for
421 info in change_info.files]
422
423 def Change(self):
424 """Returns the change name."""
425 return self.name
426
427 def Changelist(self):
428 """Synonym for Change()."""
429 return self.Change()
430
431 def DescriptionText(self):
432 """Returns the user-entered changelist description, minus tags.
433
434 Any line in the user-provided description starting with e.g. "FOO="
435 (whitespace permitted before and around) is considered a tag line. Such
436 lines are stripped out of the description this function returns.
437 """
438 return self.description_without_tags
439
440 def FullDescriptionText(self):
441 """Returns the complete changelist description including tags."""
442 return self.full_description
443
444 def RepositoryRoot(self):
445 """Returns the repository root for this change, as an absolute path."""
446 return self.repository_root
447
448 def __getattr__(self, attr):
449 """Return keys directly as attributes on the object.
450
451 You may use a friendly name (from SPECIAL_KEYS) or the actual name of
452 the key.
453 """
454 if attr in SPECIAL_KEYS:
455 key = SPECIAL_KEYS[attr]
456 if key in self.tags:
457 return self.tags[key]
458 if attr in self.tags:
459 return self.tags[attr]
460
461 def AffectedFiles(self, include_dirs=False, include_deletes=True):
462 """Returns a list of AffectedFile instances for all files in the change.
463
464 Args:
465 include_deletes: If false, deleted files will be filtered out.
466 include_dirs: True to include directories in the list
467
468 Returns:
469 [AffectedFile(path, action), AffectedFile(path, action)]
470 """
471 if include_dirs:
472 affected = self.affected_files
473 else:
474 affected = filter(lambda x: not x.IsDirectory(), self.affected_files)
475
476 if include_deletes:
477 return affected
478 else:
479 return filter(lambda x: x.Action() != 'D', affected)
480
481 def AffectedTextFiles(self, include_deletes=True):
482 """Return a list of the text files in a change.
483
484 It's common to want to iterate over only the text files.
485
486 Args:
487 include_deletes: Controls whether to return files with "delete" actions,
488 which commonly aren't relevant to presubmit scripts.
489 """
490 return InputApi.FilterTextFiles(self.AffectedFiles(include_dirs=False),
491 include_deletes)
492
493 def LocalPaths(self, include_dirs=False):
494 """Convenience function."""
495 return [af.LocalPath() for af in self.AffectedFiles(include_dirs)]
496
497 def AbsoluteLocalPaths(self, include_dirs=False):
498 """Convenience function."""
499 return [af.AbsoluteLocalPath() for af in self.AffectedFiles(include_dirs)]
500
501 def ServerPaths(self, include_dirs=False):
502 """Convenience function."""
503 return [af.ServerPath() for af in self.AffectedFiles(include_dirs)]
504
505 def RightHandSideLines(self):
506 """An iterator over all text lines in "new" version of changed files.
507
508 Lists lines from new or modified text files in the change.
509
510 This is useful for doing line-by-line regex checks, like checking for
511 trailing whitespace.
512
513 Yields:
514 a 3 tuple:
515 the AffectedFile instance of the current file;
516 integer line number (1-based); and
517 the contents of the line as a string.
518 """
519 return InputApi._RightHandSideLinesImpl(
520 self.AffectedTextFiles(include_deletes=False))
521
522
523def ListRelevantPresubmitFiles(files):
524 """Finds all presubmit files that apply to a given set of source files.
525
526 Args:
527 files: An iterable container containing file paths.
528
529 Return:
530 ['foo/blat/PRESUBMIT.py', 'mat/gat/PRESUBMIT.py']
531 """
532 checked_dirs = {} # Keys are directory paths, values are ignored.
533 source_dirs = [os.path.dirname(f) for f in files]
534 presubmit_files = []
535 for dir in source_dirs:
536 while (True):
537 if dir in checked_dirs:
538 break # We've already walked up from this directory.
539
540 test_path = os.path.join(dir, 'PRESUBMIT.py')
541 if os.path.isfile(test_path):
542 presubmit_files.append(normpath(test_path))
543
544 checked_dirs[dir] = ''
545 if dir in ['', '.']:
546 break
547 else:
548 dir = os.path.dirname(dir)
549 return presubmit_files
550
551
552class PresubmitExecuter(object):
553
554 def __init__(self, change_info, committing):
555 """
556 Args:
557 change_info: The ChangeInfo object for the change.
558 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
559 """
560 self.change = GclChange(change_info, gcl.GetRepositoryRoot())
561 self.committing = committing
562
563 def ExecPresubmitScript(self, script_text, presubmit_path):
564 """Executes a single presubmit script.
565
566 Args:
567 script_text: The text of the presubmit script.
568 presubmit_path: The path to the presubmit file (this will be reported via
569 input_api.PresubmitLocalPath()).
570
571 Return:
572 A list of result objects, empty if no problems.
573 """
574 input_api = InputApi(self.change, presubmit_path)
575 context = {}
576 exec script_text in context
577
578 # These function names must change if we make substantial changes to
579 # the presubmit API that are not backwards compatible.
580 if self.committing:
581 function_name = 'CheckChangeOnCommit'
582 else:
583 function_name = 'CheckChangeOnUpload'
584 if function_name in context:
585 context['__args'] = (input_api, OutputApi())
586 result = eval(function_name + '(*__args)', context)
587 if not (isinstance(result, types.TupleType) or
588 isinstance(result, types.ListType)):
589 raise exceptions.RuntimeError(
590 'Presubmit functions must return a tuple or list')
591 for item in result:
592 if not isinstance(item, OutputApi.PresubmitResult):
593 raise exceptions.RuntimeError(
594 'All presubmit results must be of types derived from '
595 'output_api.PresubmitResult')
596 else:
597 result = () # no error since the script doesn't care about current event.
598
599 return result
600
601
602def DoPresubmitChecks(change_info,
603 committing,
604 verbose,
605 output_stream,
maruel@google.com9b613272009-04-24 01:28:28 +0000606 input_stream):
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000607 """Runs all presubmit checks that apply to the files in the change.
608
609 This finds all PRESUBMIT.py files in directories enclosing the files in the
610 change (up to the repository root) and calls the relevant entrypoint function
611 depending on whether the change is being committed or uploaded.
612
613 Prints errors, warnings and notifications. Prompts the user for warnings
614 when needed.
615
616 Args:
617 change_info: The ChangeInfo object for the change.
618 committing: True if 'gcl commit' is running, False if 'gcl upload' is.
619 verbose: Prints debug info.
620 output_stream: A stream to write output from presubmit tests to.
621 input_stream: A stream to read input from the user.
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000622
623 Return:
624 True if execution can continue, False if not.
625 """
626 presubmit_files = ListRelevantPresubmitFiles(change_info.FileList())
627 if not presubmit_files and verbose:
628 print "Warning, no presubmit.py found."
629 results = []
630 executer = PresubmitExecuter(change_info, committing)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000631 for filename in presubmit_files:
maruel@chromium.org3d235242009-05-15 12:40:48 +0000632 filename = os.path.abspath(filename)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000633 if verbose:
634 print "Running %s" % filename
maruel@chromium.orgc1675e22009-04-27 20:30:48 +0000635 # Accept CRLF presubmit script.
maruel@chromium.org277003e2009-05-01 12:51:43 +0000636 presubmit_script = gcl.ReadFile(filename, 'rU')
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000637 results += executer.ExecPresubmitScript(presubmit_script, filename)
638
639 errors = []
640 notifications = []
641 warnings = []
642 for result in results:
643 if not result.IsFatal() and not result.ShouldPrompt():
644 notifications.append(result)
645 elif result.ShouldPrompt():
646 warnings.append(result)
647 else:
648 errors.append(result)
649
650 error_count = 0
651 for name, items in (('Messages', notifications),
652 ('Warnings', warnings),
653 ('ERRORS', errors)):
654 if items:
655 output_stream.write('\n** Presubmit %s **\n\n' % name)
656 for item in items:
657 if not item._Handle(output_stream, input_stream,
658 may_prompt=False):
659 error_count += 1
660 output_stream.write('\n')
661 if not errors and warnings:
662 output_stream.write(
663 'There were presubmit warnings. Sure you want to continue? (y/N): ')
664 response = input_stream.readline()
665 if response.strip().lower() != 'y':
666 error_count += 1
667 return (error_count == 0)
668
669
670def ScanSubDirs(mask, recursive):
671 if not recursive:
672 return [x for x in glob.glob(mask) if '.svn' not in x]
673 else:
674 results = []
675 for root, dirs, files in os.walk('.'):
676 if '.svn' in dirs:
677 dirs.remove('.svn')
678 for name in files:
679 if fnmatch.fnmatch(name, mask):
680 results.append(os.path.join(root, name))
681 return results
682
683
684def ParseFiles(args, recursive):
685 files = []
686 for arg in args:
687 files.extend([('M', file) for file in ScanSubDirs(arg, recursive)])
688 return files
689
690
691def Main(argv):
692 parser = optparse.OptionParser(usage="%prog [options]",
693 version="%prog " + str(__version__))
694 parser.add_option("-c", "--commit", action="store_true",
695 help="Use commit instead of upload checks")
696 parser.add_option("-r", "--recursive", action="store_true",
697 help="Act recursively")
698 parser.add_option("-v", "--verbose", action="store_true",
699 help="Verbose output")
700 options, args = parser.parse_args(argv[1:])
701 files = ParseFiles(args, options.recursive)
702 if options.verbose:
703 print "Found %d files." % len(files)
maruel@google.com9b613272009-04-24 01:28:28 +0000704 return DoPresubmitChecks(gcl.ChangeInfo(name='temp', files=files),
705 options.commit,
706 options.verbose,
707 sys.stdout,
708 sys.stdin)
maruel@google.comfb2b8eb2009-04-23 21:03:42 +0000709
710
711if __name__ == '__main__':
712 sys.exit(Main(sys.argv))