2 # Copyright (c) 2012 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.
7 Wrapper script around Rietveld's upload.py that simplifies working with groups
23 import breakpad # pylint: disable=W0611
29 import presubmit_support
33 from third_party import upload
38 CODEREVIEW_SETTINGS = {
39 # To make gcl send reviews to a server, check in a file named
40 # "codereview.settings" (see |CODEREVIEW_SETTINGS_FILE| below) to your
41 # project's base directory and add the following line to codereview.settings:
42 # CODE_REVIEW_SERVER: codereview.yourserver.org
45 # globals that store the root of the current repository and the directory where
46 # we store information about changelists.
49 # Filename where we store repository specific information for gcl.
50 CODEREVIEW_SETTINGS_FILE = "codereview.settings"
51 CODEREVIEW_SETTINGS_FILE_NOT_FOUND = (
52 'No %s file found. Please add one.' % CODEREVIEW_SETTINGS_FILE)
54 # Warning message when the change appears to be missing tests.
55 MISSING_TEST_MSG = "Change contains new or modified methods, but no new tests!"
57 # Global cache of files cached in GetCacheDir().
60 # Valid extensions for files we want to lint.
61 DEFAULT_LINT_REGEX = r"(.*\.cpp|.*\.cc|.*\.h)"
62 DEFAULT_LINT_IGNORE_REGEX = r"$^"
64 def CheckHomeForFile(filename):
65 """Checks the users home dir for the existence of the given file. Returns
66 the path to the file if it's there, or None if it is not.
69 if sys.platform in ('cygwin', 'win32'):
70 home_vars.append('USERPROFILE')
71 for home_var in home_vars:
72 home = os.getenv(home_var)
74 full_path = os.path.join(home, filename)
75 if os.path.exists(full_path):
81 """Runs svn status and returns unknown files."""
83 item[1] for item in SVN.CaptureStatus([], GetRepositoryRoot())
88 def GetRepositoryRoot():
89 """Returns the top level directory of the current repository.
91 The directory is returned as an absolute path.
93 global REPOSITORY_ROOT
94 if not REPOSITORY_ROOT:
95 REPOSITORY_ROOT = SVN.GetCheckoutRoot(os.getcwd())
96 if not REPOSITORY_ROOT:
97 raise gclient_utils.Error("gcl run outside of repository")
98 return REPOSITORY_ROOT
102 """Returns the directory where gcl info files are stored."""
103 return os.path.join(GetRepositoryRoot(), '.svn', 'gcl_info')
107 """Returns the directory where gcl change files are stored."""
108 return os.path.join(GetInfoDir(), 'changes')
112 """Returns the directory where gcl change files are stored."""
113 return os.path.join(GetInfoDir(), 'cache')
116 def GetCachedFile(filename, max_age=60*60*24*3, use_root=False):
117 """Retrieves a file from the repository and caches it in GetCacheDir() for
120 use_root: If False, look up the arborescence for the first match, otherwise go
121 directory to the root repository.
123 Note: The cache will be inconsistent if the same file is retrieved with both
124 use_root=True and use_root=False. Don't be stupid.
126 if filename not in FILES_CACHE:
127 # Don't try to look up twice.
128 FILES_CACHE[filename] = None
129 # First we check if we have a cached version.
131 cached_file = os.path.join(GetCacheDir(), filename)
132 except (gclient_utils.Error, subprocess2.CalledProcessError):
134 if (not os.path.exists(cached_file) or
135 (time.time() - os.stat(cached_file).st_mtime) > max_age):
136 dir_info = SVN.CaptureLocalInfo([], '.')
137 repo_root = dir_info['Repository Root']
141 url_path = dir_info['URL']
143 # Look in the repository at the current level for the file.
147 # Take advantage of the fact that svn won't output to stderr in case
148 # of success but will do in case of failure so don't mind putting
149 # stderr into content_array.
151 svn_path = url_path + '/' + filename
152 args = ['svn', 'cat', svn_path]
153 if sys.platform != 'darwin':
154 # MacOSX 10.5.2 has a bug with svn 1.4.4 that will trigger the
155 # 'Can\'t get username or password' and can be fixed easily.
156 # The fix doesn't work if the user upgraded to svn 1.6.x. Bleh.
157 # I don't have time to fix their broken stuff.
158 args.append('--non-interactive')
159 gclient_utils.CheckCallAndFilter(
160 args, cwd='.', filter_fn=content_array.append)
161 # Exit the loop if the file was found. Override content.
162 content = '\n'.join(content_array)
164 except (gclient_utils.Error, subprocess2.CalledProcessError):
165 if content_array[0].startswith(
166 'svn: Can\'t get username or password'):
167 ErrorExit('Your svn credentials expired. Please run svn update '
168 'to fix the cached credentials')
169 if content_array[0].startswith('svn: Can\'t get password'):
170 ErrorExit('If are using a Mac and svn --version shows 1.4.x, '
171 'please hack gcl.py to remove --non-interactive usage, it\'s'
172 'a bug on your installed copy')
173 if (content_array[0].startswith('svn: File not found:') or
174 content_array[0].endswith('path not found')):
176 # Otherwise, fall through to trying again.
179 if url_path == repo_root:
180 # Reached the root. Abandoning search.
182 # Go up one level to try again.
183 url_path = os.path.dirname(url_path)
184 if content is not None or filename != CODEREVIEW_SETTINGS_FILE:
185 # Write a cached version even if there isn't a file, so we don't try to
186 # fetch it each time. codereview.settings must always be present so do
187 # not cache negative.
188 gclient_utils.FileWrite(cached_file, content or '')
190 content = gclient_utils.FileRead(cached_file, 'r')
191 # Keep the content cached in memory.
192 FILES_CACHE[filename] = content
193 return FILES_CACHE[filename]
196 def GetCodeReviewSetting(key):
197 """Returns a value for the given key for this repository."""
198 # Use '__just_initialized' as a flag to determine if the settings were
199 # already initialized.
200 if '__just_initialized' not in CODEREVIEW_SETTINGS:
201 settings_file = GetCachedFile(CODEREVIEW_SETTINGS_FILE)
203 CODEREVIEW_SETTINGS.update(
204 gclient_utils.ParseCodereviewSettingsContent(settings_file))
205 CODEREVIEW_SETTINGS.setdefault('__just_initialized', None)
206 return CODEREVIEW_SETTINGS.get(key, "")
210 print >> sys.stderr, msg
214 print >> sys.stderr, msg
218 def RunShellWithReturnCode(command, print_output=False):
219 """Executes a command and returns the output and the return code."""
220 p = subprocess2.Popen(
222 cwd=GetRepositoryRoot(),
223 stdout=subprocess2.PIPE,
224 stderr=subprocess2.STDOUT,
225 universal_newlines=True)
229 line = p.stdout.readline()
233 print line.strip('\n')
234 output_array.append(line)
235 output = "".join(output_array)
237 output = p.stdout.read()
240 return output, p.returncode
243 def RunShell(command, print_output=False):
244 """Executes a command and returns the output."""
245 return RunShellWithReturnCode(command, print_output)[0]
248 def FilterFlag(args, flag):
249 """Returns True if the flag is present in args list.
251 The flag is removed from args if present.
259 class ChangeInfo(object):
260 """Holds information about a changelist.
263 issue: the Rietveld issue number or 0 if it hasn't been uploaded yet.
264 patchset: the Rietveld latest patchset number or 0.
265 description: the description.
266 files: a list of 2 tuple containing (status, filename) of changed files,
267 with paths being relative to the top repository directory.
268 local_root: Local root directory
269 rietveld: rietveld server for this change
271 # Kept for unit test support. This is for the old format, it's deprecated.
272 SEPARATOR = "\n-----\n"
274 def __init__(self, name, issue, patchset, description, files, local_root,
275 rietveld_url, needs_upload):
276 # Defer the description processing to git_cl.ChangeDescription.
277 self._desc = git_cl.ChangeDescription(description)
279 self.issue = int(issue)
280 self.patchset = int(patchset)
281 self._files = files or []
283 self._local_root = local_root
284 self.needs_upload = needs_upload
285 self.rietveld = gclient_utils.UpgradeToHttps(
286 rietveld_url or GetCodeReviewSetting('CODE_REVIEW_SERVER'))
287 self._rpc_server = None
290 def description(self):
291 return self._desc.description
293 def force_description(self, new_description):
294 self._desc = git_cl.ChangeDescription(new_description)
295 self.needs_upload = True
297 def append_footer(self, line):
298 self._desc.append_footer(line)
300 def get_reviewers(self):
301 return self._desc.get_reviewers()
303 def update_reviewers(self, reviewers):
304 self._desc.update_reviewers(reviewers)
306 def NeedsUpload(self):
307 return self.needs_upload
309 def GetFileNames(self):
310 """Returns the list of file names included in this change."""
311 return [f[1] for f in self._files]
314 """Returns the list of files included in this change with their status."""
317 def GetLocalRoot(self):
318 """Returns the local repository checkout root directory."""
319 return self._local_root
322 """Returns True if this change already exists (i.e., is not new)."""
323 return (self.issue or self.description or self._files)
325 def _NonDeletedFileList(self):
326 """Returns a list of files in this change, not including deleted files."""
327 return [f[1] for f in self.GetFiles()
328 if not f[0].startswith("D")]
330 def _AddedFileList(self):
331 """Returns a list of files added in this change."""
332 return [f[1] for f in self.GetFiles() if f[0].startswith("A")]
335 """Writes the changelist information to disk."""
338 'patchset': self.patchset,
339 'needs_upload': self.NeedsUpload(),
340 'files': self.GetFiles(),
341 'description': self.description,
342 'rietveld': self.rietveld,
343 }, sort_keys=True, indent=2)
344 gclient_utils.FileWrite(GetChangelistInfoFile(self.name), data)
347 """Removes the changelist information from disk."""
348 os.remove(GetChangelistInfoFile(self.name))
351 if not self._rpc_server:
352 if not self.rietveld:
353 ErrorExit(CODEREVIEW_SETTINGS_FILE_NOT_FOUND)
354 self._rpc_server = rietveld.CachingRietveld(self.rietveld, None, None)
355 return self._rpc_server
357 def CloseIssue(self):
358 """Closes the Rietveld issue for this changelist."""
359 # Newer versions of Rietveld require us to pass an XSRF token to POST, so
360 # we fetch it from the server.
361 xsrf_token = self.SendToRietveld(
363 extra_headers={'X-Requesting-XSRF-Token': '1'})
365 # You cannot close an issue with a GET.
366 # We pass an empty string for the data so it is a POST rather than a GET.
367 data = [("description", self.description),
368 ("xsrf_token", xsrf_token)]
369 ctype, body = upload.EncodeMultipartFormData(data, [])
370 self.SendToRietveld('/%d/close' % self.issue, payload=body,
373 def UpdateRietveldDescription(self):
374 """Sets the description for an issue on Rietveld."""
375 data = [("description", self.description),]
376 ctype, body = upload.EncodeMultipartFormData(data, [])
377 self.SendToRietveld('/%d/description' % self.issue, payload=body,
379 self.needs_upload = False
381 def GetIssueDescription(self):
382 """Returns the issue description from Rietveld."""
383 return self.SendToRietveld('/%d/description' % self.issue).replace('\r\n',
386 def UpdateDescriptionFromIssue(self):
387 """Updates self.description with the issue description from Rietveld."""
388 self._desc = git_cl.ChangeDescription(self.GetIssueDescription())
390 def GetApprovingReviewers(self):
391 """Returns the issue reviewers list from Rietveld."""
392 return git_cl.get_approving_reviewers(
393 self.RpcServer().get_issue_properties(self.issue, True))
395 def AddComment(self, comment):
396 """Adds a comment for an issue on Rietveld.
397 As a side effect, this will email everyone associated with the issue."""
398 return self.RpcServer().add_comment(self.issue, comment)
401 """Do background work on Rietveld to lint the file so that the results are
402 ready when the issue is viewed."""
403 if self.issue and self.patchset:
405 self.SendToRietveld('/lint/issue%s_%s' % (self.issue, self.patchset),
407 except ssl.SSLError as e:
408 # It takes more than 60 seconds to lint some CLs. Silently ignore
409 # the expected timeout.
410 if e.message != 'The read operation timed out':
413 def SendToRietveld(self, request_path, timeout=None, **kwargs):
414 """Send a POST/GET to Rietveld. Returns the response body."""
416 return self.RpcServer().Send(request_path, timeout=timeout, **kwargs)
417 except urllib2.URLError:
419 ErrorExit('Error accessing url %s' % request_path)
423 def MissingTests(self):
424 """Returns True if the change looks like it needs unit tests but has none.
426 A change needs unit tests if it contains any new source files or methods.
428 SOURCE_SUFFIXES = [".cc", ".cpp", ".c", ".m", ".mm"]
429 # Ignore third_party entirely.
430 files = [f for f in self._NonDeletedFileList()
431 if f.find("third_party") == -1]
432 added_files = [f for f in self._AddedFileList()
433 if f.find("third_party") == -1]
435 # If the change is entirely in third_party, we're done.
439 # Any new or modified test files?
440 # A test file's name ends with "test.*" or "tests.*".
441 test_files = [test for test in files
442 if os.path.splitext(test)[0].rstrip("s").endswith("test")]
443 if len(test_files) > 0:
446 # Any new source files?
447 source_files = [item for item in added_files
448 if os.path.splitext(item)[1] in SOURCE_SUFFIXES]
449 if len(source_files) > 0:
452 # Do the long test, checking the files for new methods.
453 return self._HasNewMethod()
455 def _HasNewMethod(self):
456 """Returns True if the changeset contains any new functions, or if a
457 function signature has been changed.
459 A function is identified by starting flush left, containing a "(" before
460 the next flush-left line, and either ending with "{" before the next
461 flush-left line or being followed by an unindented "{".
463 Currently this returns True for new methods, new static functions, and
464 methods or functions whose signatures have been changed.
466 Inline methods added to header files won't be detected by this. That's
467 acceptable for purposes of determining if a unit test is needed, since
468 inline methods should be trivial.
470 # To check for methods added to source or header files, we need the diffs.
471 # We'll generate them all, since there aren't likely to be many files
472 # apart from source and headers; besides, we'll want them all if we're
474 if self.patch is None:
475 self.patch = GenerateDiff(self.GetFileNames())
478 for line in self.patch.splitlines():
479 if not line.startswith("+"):
481 line = line.strip("+").rstrip(" \t")
482 # Skip empty lines, comments, and preprocessor directives.
483 # TODO(pamg): Handle multiline comments if it turns out to be a problem.
484 if line == "" or line.startswith("/") or line.startswith("#"):
487 # A possible definition ending with "{" is complete, so check it.
488 if definition.endswith("{"):
489 if definition.find("(") != -1:
493 # A { or an indented line, when we're in a definition, continues it.
494 if (definition != "" and
495 (line == "{" or line.startswith(" ") or line.startswith("\t"))):
498 # A flush-left line starts a new possible function definition.
499 elif not line.startswith(" ") and not line.startswith("\t"):
505 def Load(changename, local_root, fail_on_not_found, update_status):
506 """Gets information about a changelist.
509 fail_on_not_found: if True, this function will quit the program if the
510 changelist doesn't exist.
511 update_status: if True, the svn status will be updated for all the files
512 and unchanged files will be removed.
514 Returns: a ChangeInfo object.
516 info_file = GetChangelistInfoFile(changename)
517 if not os.path.exists(info_file):
518 if fail_on_not_found:
519 ErrorExit("Changelist " + changename + " not found.")
520 return ChangeInfo(changename, 0, 0, '', None, local_root, None, False)
521 content = gclient_utils.FileRead(info_file)
524 values = ChangeInfo._LoadNewFormat(content)
527 values = ChangeInfo._LoadOldFormat(content)
531 ('Changelist file %s is corrupt.\n'
532 'Either run "gcl delete %s" or manually edit the file') % (
533 info_file, changename))
534 files = values['files']
536 for item in files[:]:
537 status_result = SVN.CaptureStatus(item[1], local_root)
538 if not status_result or not status_result[0][0]:
539 # File has been reverted.
543 status = status_result[0][0]
544 if status != item[0]:
546 files[files.index(item)] = (status, item[1])
547 change_info = ChangeInfo(
551 values['description'],
554 values.get('rietveld'),
555 values['needs_upload'])
561 def _LoadOldFormat(content):
562 # The info files have the following format:
563 # issue_id, patchset\n (, patchset is optional)
572 split_data = content.split(ChangeInfo.SEPARATOR, 2)
573 if len(split_data) != 3:
574 raise ValueError('Bad change format')
578 'needs_upload': False,
581 items = split_data[0].split(', ')
583 values['issue'] = int(items[0])
585 values['patchset'] = int(items[1])
587 values['needs_upload'] = (items[2] == "dirty")
588 for line in split_data[1].splitlines():
591 values['files'].append((status, filename))
592 values['description'] = split_data[2]
596 def _LoadNewFormat(content):
597 return json.loads(content)
600 out = ['%s:' % self.__class__.__name__]
602 if k.startswith('__'):
605 if v is self or callable(getattr(self, k)):
607 out.append(' %s: %r' % (k, v))
608 return '\n'.join(out)
611 def GetChangelistInfoFile(changename):
612 """Returns the file that stores information about a changelist."""
613 if not changename or re.search(r'[^\w-]', changename):
614 ErrorExit("Invalid changelist name: " + changename)
615 return os.path.join(GetChangesDir(), changename)
618 def LoadChangelistInfoForMultiple(changenames, local_root, fail_on_not_found,
620 """Loads many changes and merge their files list into one pseudo change.
622 This is mainly usefull to concatenate many changes into one for a 'gcl try'.
624 changes = changenames.split(',')
625 aggregate_change_info = ChangeInfo(
626 changenames, 0, 0, '', None, local_root, None, False)
627 for change in changes:
628 aggregate_change_info._files += ChangeInfo.Load(
629 change, local_root, fail_on_not_found, update_status).GetFiles()
630 return aggregate_change_info
634 """Returns a list of all the changelists in this repository."""
635 cls = os.listdir(GetChangesDir())
636 if CODEREVIEW_SETTINGS_FILE in cls:
637 cls.remove(CODEREVIEW_SETTINGS_FILE)
641 def GenerateChangeName():
642 """Generate a random changelist name."""
644 current_cl_names = GetCLs()
646 cl_name = (random.choice(string.ascii_lowercase) +
647 random.choice(string.digits) +
648 random.choice(string.ascii_lowercase) +
649 random.choice(string.digits))
650 if cl_name not in current_cl_names:
654 def GetModifiedFiles():
655 """Returns a set that maps from changelist name to (status,filename) tuples.
657 Files not in a changelist have an empty changelist name. Filenames are in
658 relation to the top level directory of the current repository. Note that
659 only the current directory and subdirectories are scanned, in order to
660 improve performance while still being flexible.
664 # Since the files are normalized to the root folder of the repositary, figure
665 # out what we need to add to the paths.
666 dir_prefix = os.getcwd()[len(GetRepositoryRoot()):].strip(os.sep)
668 # Get a list of all files in changelists.
671 change_info = ChangeInfo.Load(cl, GetRepositoryRoot(),
672 fail_on_not_found=True, update_status=False)
673 for status, filename in change_info.GetFiles():
674 files_in_cl[filename] = change_info.name
676 # Get all the modified files down the current directory.
677 for line in SVN.CaptureStatus(None, os.getcwd()):
683 filename = os.path.join(dir_prefix, filename)
684 change_list_name = ""
685 if filename in files_in_cl:
686 change_list_name = files_in_cl[filename]
687 files.setdefault(change_list_name, []).append((status, filename))
692 def GetFilesNotInCL():
693 """Returns a list of tuples (status,filename) that aren't in any changelists.
695 See docstring of GetModifiedFiles for information about path of files and
696 which directories are scanned.
698 modified_files = GetModifiedFiles()
699 if "" not in modified_files:
701 return modified_files[""]
704 def ListFiles(show_unknown_files):
705 files = GetModifiedFiles()
706 cl_keys = files.keys()
708 for cl_name in cl_keys:
712 change_info = ChangeInfo.Load(cl_name, GetRepositoryRoot(),
713 fail_on_not_found=True, update_status=False)
714 if len(change_info.GetFiles()) != len(files[cl_name]):
715 note = " (Note: this changelist contains files outside this directory)"
716 print "\n--- Changelist " + cl_name + note + ":"
717 for filename in files[cl_name]:
718 print "".join(filename)
719 if show_unknown_files:
720 unknown_files = UnknownFiles()
721 if (files.get('') or (show_unknown_files and len(unknown_files))):
722 print "\n--- Not in any changelist:"
723 for item in files.get('', []):
725 if show_unknown_files:
726 for filename in unknown_files:
727 print "? %s" % filename
731 def GenerateDiff(files):
732 return SVN.GenerateDiff(
733 files, GetRepositoryRoot(), full_move=False, revision=None)
737 tree_status_url = GetCodeReviewSetting('STATUS')
738 return git_cl.GetTreeStatus(tree_status_url) if tree_status_url else "unset"
741 def OptionallyDoPresubmitChecks(change_info, committing, args):
742 if FilterFlag(args, "--no_presubmit") or FilterFlag(args, "--force"):
744 breakpad.DEFAULT_URL + '/breakpad',
745 'GclHooksBypassedCommit',
746 'Issue %s/%s bypassed hook when committing (tree status was "%s")' %
747 (change_info.rietveld, change_info.issue, GetTreeStatus()),
749 return presubmit_support.PresubmitOutput()
750 return DoPresubmitChecks(change_info, committing, True)
753 def defer_attributes(a, b):
754 """Copy attributes from an object (like a function) to another."""
756 if not getattr(b, x, None):
757 setattr(b, x, getattr(a, x))
760 def need_change(function):
761 """Converts args -> change_info."""
762 # pylint: disable=W0612,W0621
764 if not len(args) == 1:
765 ErrorExit("You need to pass a change list name")
766 change_info = ChangeInfo.Load(args[0], GetRepositoryRoot(), True, True)
767 return function(change_info)
768 defer_attributes(function, hook)
769 hook.need_change = True
774 def need_change_and_args(function):
775 """Converts args -> change_info."""
776 # pylint: disable=W0612,W0621
779 ErrorExit("You need to pass a change list name")
780 change_info = ChangeInfo.Load(args.pop(0), GetRepositoryRoot(), True, True)
781 return function(change_info, args)
782 defer_attributes(function, hook)
783 hook.need_change = True
787 def no_args(function):
788 """Make sure no args are passed."""
789 # pylint: disable=W0612,W0621
792 ErrorExit("Doesn't support arguments")
794 defer_attributes(function, hook)
800 """Decorate a function with new attributes."""
801 def decorate(function):
803 setattr(function, k, kwargs[k])
810 """Lists modified files in the current directory down."""
811 return ListFiles(False)
816 """Lists modified and unknown files in the current directory down."""
817 return ListFiles(True)
820 @need_change_and_args
821 @attrs(usage='[--no_presubmit] [--no_watchlists]')
822 def CMDupload(change_info, args):
823 """Uploads the changelist to the server for review.
825 This does not submit a try job; use gcl try to submit a try job.
827 if '-s' in args or '--server' in args:
828 ErrorExit('Don\'t use the -s flag, fix codereview.settings instead')
829 if not change_info.GetFiles():
830 print "Nothing to upload, changelist is empty."
833 output = OptionallyDoPresubmitChecks(change_info, False, args)
834 if not output.should_continue():
836 no_watchlists = (FilterFlag(args, "--no_watchlists") or
837 FilterFlag(args, "--no-watchlists"))
839 # Map --send-mail to --send_mail
840 if FilterFlag(args, "--send-mail"):
841 args.append("--send_mail")
843 # Replace -m with -t and --message with --title, but make sure to
844 # preserve anything after the -m/--message.
845 found_deprecated_arg = [False]
846 def replace_message(a):
847 if a.startswith('-m'):
848 found_deprecated_arg[0] = True
850 elif a.startswith('--message'):
851 found_deprecated_arg[0] = True
852 return '--title' + a[9:]
854 args = map(replace_message, args)
855 if found_deprecated_arg[0]:
856 print >> sys.stderr, (
857 '\nWARNING: Use -t or --title to set the title of the patchset.\n'
858 'In the near future, -m or --message will send a message instead.\n'
859 'See http://goo.gl/JGg0Z for details.\n')
861 upload_arg = ["upload.py", "-y"]
862 upload_arg.append("--server=%s" % change_info.rietveld.encode('utf-8'))
864 reviewers = change_info.get_reviewers() or output.reviewers
866 not any(arg.startswith('-r') or arg.startswith('--reviewer') for
868 upload_arg.append('--reviewers=%s' % ','.join(reviewers))
870 upload_arg.extend(args)
874 if change_info.issue:
875 # Uploading a new patchset.
876 upload_arg.append("--issue=%d" % change_info.issue)
878 if not any(i.startswith('--title') or i.startswith('-t') for i in args):
879 upload_arg.append('--title= ')
881 # First time we upload.
882 handle, desc_file = tempfile.mkstemp(text=True)
883 os.write(handle, change_info.description)
886 # Watchlist processing -- CC people interested in this changeset
887 # http://dev.chromium.org/developers/contributing-code/watchlists
888 if not no_watchlists:
890 watchlist = watchlists.Watchlists(change_info.GetLocalRoot())
891 watchers = watchlist.GetWatchersForPaths(change_info.GetFileNames())
893 # We check this before applying the "PRIVATE" parameter of codereview
894 # settings assuming that the author of the settings file has put
895 # addresses which we can send private CLs to, and so we should ignore
896 # CC_LIST only when --private is specified explicitly on the command
898 if "--private" in upload_arg:
899 Warn("WARNING: CC_LIST and WATCHLISTS are ignored when --private is "
900 "specified. You need to review and add them manually if "
905 cc_list = GetCodeReviewSetting("CC_LIST")
906 if not no_watchlists and watchers:
907 # Filter out all empty elements and join by ','
908 cc_list = ','.join(filter(None, [cc_list] + watchers))
910 upload_arg.append("--cc=" + cc_list)
911 upload_arg.append("--file=%s" % desc_file)
913 if GetCodeReviewSetting("PRIVATE") == "True":
914 upload_arg.append("--private")
916 project = GetCodeReviewSetting("PROJECT")
918 upload_arg.append("--project=%s" % project)
920 # If we have a lot of files with long paths, then we won't be able to fit
921 # the command to "svn diff". Instead, we generate the diff manually for
922 # each file and concatenate them before passing it to upload.py.
923 if change_info.patch is None:
924 change_info.patch = GenerateDiff(change_info.GetFileNames())
926 # Change the current working directory before calling upload.py so that it
927 # shows the correct base.
928 previous_cwd = os.getcwd()
929 os.chdir(change_info.GetLocalRoot())
932 issue, patchset = upload.RealMain(upload_arg, change_info.patch)
933 except KeyboardInterrupt:
935 if issue and patchset:
936 change_info.issue = int(issue)
937 change_info.patchset = int(patchset)
939 change_info.PrimeLint()
941 os.chdir(previous_cwd)
945 print "*** Upload does not submit a try; use gcl try to submit a try. ***"
949 @need_change_and_args
950 @attrs(usage='[--upload]')
951 def CMDpresubmit(change_info, args):
952 """Runs presubmit checks on the change.
954 The actual presubmit code is implemented in presubmit_support.py and looks
955 for PRESUBMIT.py files."""
956 if not change_info.GetFiles():
957 print('Nothing to presubmit check, changelist is empty.')
959 parser = optparse.OptionParser()
960 parser.add_option('--upload', action='store_true')
961 options, args = parser.parse_args(args)
963 parser.error('Unrecognized args: %s' % args)
965 print('*** Presubmit checks for UPLOAD would report: ***')
966 return not DoPresubmitChecks(change_info, False, False)
968 print('*** Presubmit checks for COMMIT would report: ***')
969 return not DoPresubmitChecks(change_info, True, False)
972 def TryChange(change_info, args, swallow_exception):
973 """Create a diff file of change_info and send it to the try server."""
977 if swallow_exception:
979 ErrorExit("You need to install trychange.py to use the try server.")
983 trychange_args.extend(['--name', change_info.name])
984 if change_info.issue:
985 trychange_args.extend(["--issue", str(change_info.issue)])
986 if change_info.patchset:
987 trychange_args.extend(["--patchset", str(change_info.patchset)])
988 change = presubmit_support.SvnChange(change_info.name,
989 change_info.description,
990 change_info.GetLocalRoot(),
991 change_info.GetFiles(),
993 change_info.patchset,
998 trychange_args.extend(args)
999 return trychange.TryChange(
1002 swallow_exception=swallow_exception,
1005 'When called from gcl, use the format gcl try <change_name>.\n')
1008 @need_change_and_args
1009 @attrs(usage='[--no_presubmit]')
1010 def CMDcommit(change_info, args):
1011 """Commits the changelist to the repository."""
1012 if not change_info.GetFiles():
1013 print "Nothing to commit, changelist is empty."
1016 # OptionallyDoPresubmitChecks has a side-effect which eats these flags.
1017 bypassed = '--no_presubmit' in args or '--force' in args
1018 output = OptionallyDoPresubmitChecks(change_info, True, args)
1019 if not output.should_continue():
1022 # We face a problem with svn here: Let's say change 'bleh' modifies
1023 # svn:ignore on dir1\. but another unrelated change 'pouet' modifies
1024 # dir1\foo.cc. When the user `gcl commit bleh`, foo.cc is *also committed*.
1025 # The only fix is to use --non-recursive but that has its issues too:
1026 # Let's say if dir1 is deleted, --non-recursive must *not* be used otherwise
1027 # you'll get "svn: Cannot non-recursively commit a directory deletion of a
1028 # directory with child nodes". Yay...
1029 commit_cmd = ["svn", "commit"]
1030 if change_info.issue:
1031 # Get the latest description from Rietveld.
1032 change_info.UpdateDescriptionFromIssue()
1034 change_info.update_reviewers(change_info.GetApprovingReviewers())
1036 commit_desc = git_cl.ChangeDescription(change_info.description)
1037 if change_info.issue:
1038 server = change_info.rietveld
1039 if not server.startswith("http://") and not server.startswith("https://"):
1040 server = "http://" + server
1041 commit_desc.append_footer('Review URL: %s/%d' % (server, change_info.issue))
1043 handle, commit_filename = tempfile.mkstemp(text=True)
1044 os.write(handle, commit_desc.description)
1047 handle, targets_filename = tempfile.mkstemp(text=True)
1048 os.write(handle, "\n".join(change_info.GetFileNames()))
1051 commit_cmd += ['--file=' + commit_filename]
1052 commit_cmd += ['--targets=' + targets_filename]
1053 # Change the current working directory before calling commit.
1056 output = RunShell(commit_cmd, True)
1057 except subprocess2.CalledProcessError, e:
1058 ErrorExit('Commit failed.\n%s' % e)
1060 os.remove(commit_filename)
1062 os.remove(targets_filename)
1063 if output.find("Committed revision") != -1:
1064 change_info.Delete()
1066 if change_info.issue:
1067 revision = re.compile(".*?\nCommitted revision (\d+)",
1068 re.DOTALL).match(output).group(1)
1069 viewvc_url = GetCodeReviewSetting('VIEW_VC')
1070 if viewvc_url and revision:
1071 change_info.append_footer('Committed: ' + viewvc_url + revision)
1073 change_info.append_footer('Committed: ' + revision)
1074 change_info.CloseIssue()
1075 props = change_info.RpcServer().get_issue_properties(
1076 change_info.issue, False)
1077 patch_num = len(props['patchsets'])
1078 comment = "Committed patchset #%d (id:%d) manually as r%s" % (
1079 patch_num, props['patchsets'][-1], revision)
1081 comment += ' (tree was closed).' if GetTreeStatus() == 'closed' else '.'
1083 comment += ' (presubmit successful).'
1084 change_info.AddComment(comment)
1088 def CMDchange(args):
1089 """Creates or edits a changelist.
1091 Only scans the current directory and subdirectories.
1093 # Verify the user is running the change command from a read-write checkout.
1094 svn_info = SVN.CaptureLocalInfo([], '.')
1096 ErrorExit("Current checkout is unversioned. Please retry with a versioned "
1100 # Generate a random changelist name.
1101 changename = GenerateChangeName()
1102 elif args[0] == '--force':
1103 changename = GenerateChangeName()
1105 changename = args[0]
1106 change_info = ChangeInfo.Load(changename, GetRepositoryRoot(), False, True)
1109 if not os.path.isfile(args[1]):
1110 ErrorExit('The change "%s" doesn\'t exist.' % args[1])
1111 f = open(args[1], 'rU')
1112 override_description = f.read()
1115 override_description = None
1117 if change_info.issue and not change_info.NeedsUpload():
1119 description = change_info.GetIssueDescription()
1120 except urllib2.HTTPError, err:
1122 # The user deleted the issue in Rietveld, so forget the old issue id.
1123 description = change_info.description
1124 change_info.issue = 0
1127 ErrorExit("Error getting the description from Rietveld: " + err)
1129 if override_description:
1130 description = override_description
1132 description = change_info.description
1134 other_files = GetFilesNotInCL()
1136 # Edited files (as opposed to files with only changed properties) will have
1137 # a letter for the first character in the status string.
1138 file_re = re.compile(r"^[a-z].+\Z", re.IGNORECASE)
1139 affected_files = [x for x in other_files if file_re.match(x[0])]
1140 unaffected_files = [x for x in other_files if not file_re.match(x[0])]
1142 description = description.rstrip() + '\n'
1144 separator1 = ("\n---All lines above this line become the description.\n"
1145 "---Repository Root: " + change_info.GetLocalRoot() + "\n"
1146 "---Paths in this changelist (" + change_info.name + "):\n")
1147 separator2 = "\n\n---Paths modified but not in any changelist:\n\n"
1149 text = (description + separator1 + '\n' +
1150 '\n'.join([f[0] + f[1] for f in change_info.GetFiles()]))
1152 if change_info.Exists():
1153 text += (separator2 +
1154 '\n'.join([f[0] + f[1] for f in affected_files]) + '\n')
1156 text += ('\n'.join([f[0] + f[1] for f in affected_files]) + '\n' +
1158 text += '\n'.join([f[0] + f[1] for f in unaffected_files]) + '\n'
1160 result = gclient_utils.RunEditor(text, False)
1162 ErrorExit('Running editor failed')
1164 split_result = result.split(separator1, 1)
1165 if len(split_result) != 2:
1166 ErrorExit("Don't modify the text starting with ---!\n\n%r" % result)
1168 # Update the CL description if it has changed.
1169 new_description = split_result[0]
1170 cl_files_text = split_result[1]
1171 if new_description != description or override_description:
1172 change_info.force_description(new_description)
1175 for line in cl_files_text.splitlines():
1178 if line.startswith("---"):
1182 new_cl_files.append((status, filename))
1184 if (not len(change_info.GetFiles()) and not change_info.issue and
1185 not len(new_description) and not new_cl_files):
1186 ErrorExit("Empty changelist not saved")
1188 change_info._files = new_cl_files
1190 if svn_info.get('URL', '').startswith('http:'):
1191 Warn("WARNING: Creating CL in a read-only checkout. You will need to "
1192 "commit using a commit queue!")
1194 print change_info.name + " changelist saved."
1195 if change_info.MissingTests():
1196 Warn("WARNING: " + MISSING_TEST_MSG)
1198 # Update the Rietveld issue.
1199 if change_info.issue and change_info.NeedsUpload():
1200 change_info.UpdateRietveldDescription()
1205 @need_change_and_args
1206 def CMDlint(change_info, args):
1207 """Runs cpplint.py on all the files in the change list.
1209 Checks all the files in the changelist for possible style violations.
1211 # Access to a protected member _XX of a client class
1212 # pylint: disable=W0212
1215 import cpplint_chromium
1217 ErrorExit("You need to install cpplint.py to lint C++ files.")
1218 # Change the current working directory before calling lint so that it
1219 # shows the correct base.
1220 previous_cwd = os.getcwd()
1221 os.chdir(change_info.GetLocalRoot())
1223 # Process cpplints arguments if any.
1224 filenames = cpplint.ParseArguments(args + change_info.GetFileNames())
1226 white_list = GetCodeReviewSetting("LINT_REGEX")
1228 white_list = DEFAULT_LINT_REGEX
1229 white_regex = re.compile(white_list)
1230 black_list = GetCodeReviewSetting("LINT_IGNORE_REGEX")
1232 black_list = DEFAULT_LINT_IGNORE_REGEX
1233 black_regex = re.compile(black_list)
1234 extra_check_functions = [cpplint_chromium.CheckPointerDeclarationWhitespace]
1235 for filename in filenames:
1236 if white_regex.match(filename):
1237 if black_regex.match(filename):
1238 print "Ignoring file %s" % filename
1240 cpplint.ProcessFile(filename, cpplint._cpplint_state.verbose_level,
1241 extra_check_functions)
1243 print "Skipping file %s" % filename
1245 os.chdir(previous_cwd)
1247 print "Total errors found: %d\n" % cpplint._cpplint_state.error_count
1251 def DoPresubmitChecks(change_info, committing, may_prompt):
1252 """Imports presubmit, then calls presubmit.DoPresubmitChecks."""
1253 root_presubmit = GetCachedFile('PRESUBMIT.py', use_root=True)
1254 change = presubmit_support.SvnChange(change_info.name,
1255 change_info.description,
1256 change_info.GetLocalRoot(),
1257 change_info.GetFiles(),
1259 change_info.patchset,
1261 output = presubmit_support.DoPresubmitChecks(
1263 committing=committing,
1265 output_stream=sys.stdout,
1266 input_stream=sys.stdin,
1267 default_presubmit=root_presubmit,
1268 may_prompt=may_prompt,
1269 rietveld_obj=change_info.RpcServer())
1270 if not output.should_continue() and may_prompt:
1271 # TODO(dpranke): move into DoPresubmitChecks(), unify cmd line args.
1272 print "\nPresubmit errors, can't continue (use --no_presubmit to bypass)"
1279 """Lists all the changelists and their files."""
1281 change_info = ChangeInfo.Load(cl, GetRepositoryRoot(), True, True)
1282 print "\n--- Changelist " + change_info.name + ":"
1283 for filename in change_info.GetFiles():
1284 print "".join(filename)
1289 def CMDdeleteempties():
1290 """Delete all changelists that have no files."""
1291 print "\n--- Deleting:"
1293 change_info = ChangeInfo.Load(cl, GetRepositoryRoot(), True, True)
1294 if not len(change_info.GetFiles()):
1295 print change_info.name
1296 change_info.Delete()
1302 """Lists files unknown to Subversion."""
1303 for filename in UnknownFiles():
1304 print "? " + "".join(filename)
1308 @attrs(usage='<svn options>')
1310 """Diffs all files in the changelist or all files that aren't in a CL."""
1313 change_info = ChangeInfo.Load(args.pop(0), GetRepositoryRoot(), True, True)
1314 files = change_info.GetFileNames()
1316 files = [f[1] for f in GetFilesNotInCL()]
1318 root = GetRepositoryRoot()
1319 cmd = ['svn', 'diff']
1320 cmd.extend([os.path.join(root, x) for x in files])
1322 return RunShellWithReturnCode(cmd, print_output=True)[1]
1327 """Prints code review settings for this checkout."""
1328 # Force load settings
1329 GetCodeReviewSetting("UNKNOWN")
1330 del CODEREVIEW_SETTINGS['__just_initialized']
1331 print '\n'.join(("%s: %s" % (str(k), str(v))
1332 for (k,v) in CODEREVIEW_SETTINGS.iteritems()))
1337 def CMDdescription(change_info):
1338 """Prints the description of the specified change to stdout."""
1339 print change_info.description
1343 def CMDdelete(args):
1344 """Deletes a changelist."""
1345 if not len(args) == 1:
1346 ErrorExit('You need to pass a change list name')
1347 filepath = GetChangelistInfoFile(args[0])
1348 if not os.path.isfile(filepath):
1349 ErrorExit('You need to pass a valid change list name')
1355 """Sends the change to the tryserver to do a test run on your code.
1357 To send multiple changes as one path, use a comma-separated list of
1358 changenames. Use 'gcl help try' for more information!"""
1359 # When the change contains no file, send the "changename" positional
1360 # argument to trychange.py.
1361 # When the command is 'try' and --patchset is used, the patch to try
1362 # is on the Rietveld server.
1364 ErrorExit("You need to pass a change list name")
1365 if args[0].find(',') != -1:
1366 change_info = LoadChangelistInfoForMultiple(args[0], GetRepositoryRoot(),
1369 change_info = ChangeInfo.Load(args[0], GetRepositoryRoot(),
1372 props = change_info.RpcServer().get_issue_properties(
1373 change_info.issue, False)
1374 if props.get('private'):
1375 ErrorExit('Cannot use trybots on a private issue')
1377 if change_info.GetFiles():
1381 return TryChange(change_info, args, swallow_exception=False)
1384 @attrs(usage='<old-name> <new-name>')
1385 def CMDrename(args):
1386 """Renames an existing change."""
1388 ErrorExit("Usage: gcl rename <old-name> <new-name>.")
1390 src_file = GetChangelistInfoFile(src)
1391 if not os.path.isfile(src_file):
1392 ErrorExit("Change '%s' does not exist." % src)
1393 dst_file = GetChangelistInfoFile(dst)
1394 if os.path.isfile(dst_file):
1395 ErrorExit("Change '%s' already exists; pick a new name." % dst)
1396 os.rename(src_file, dst_file)
1397 print "Change '%s' renamed '%s'." % (src, dst)
1401 def CMDpassthru(args):
1402 """Everything else that is passed into gcl we redirect to svn.
1404 It assumes a change list name is passed and is converted with the files names.
1406 if not args or len(args) < 2:
1407 ErrorExit("You need to pass a change list name for this svn fall-through "
1410 args = ["svn", args[0]]
1412 root = GetRepositoryRoot()
1413 change_info = ChangeInfo.Load(cl_name, root, True, True)
1414 args.extend([os.path.join(root, x) for x in change_info.GetFileNames()])
1415 return RunShellWithReturnCode(args, print_output=True)[1]
1419 return getattr(sys.modules[__name__], 'CMD' + name, None)
1422 def GenUsage(command):
1423 """Modify an OptParse object with the function's documentation."""
1424 obj = Command(command)
1426 more = getattr(obj, 'usage', '')
1427 if command == 'help':
1428 display = '<command>'
1429 need_change_val = ''
1430 if getattr(obj, 'need_change', None):
1431 need_change_val = ' <change_list>'
1432 options = ' [options]'
1433 if getattr(obj, 'no_args', None):
1435 res = 'Usage: gcl %s%s%s %s\n\n' % (display, need_change_val, options, more)
1436 res += re.sub('\n ', '\n', obj.__doc__)
1441 """Prints this help or help for the given command."""
1442 if args and 'CMD' + args[0] in dir(sys.modules[__name__]):
1443 print GenUsage(args[0])
1445 # These commands defer to external tools so give this info too.
1446 if args[0] == 'try':
1447 TryChange(None, ['--help'], swallow_exception=False)
1448 if args[0] == 'upload':
1449 upload.RealMain(['upload.py', '--help'])
1452 print GenUsage('help')
1453 print sys.modules[__name__].__doc__
1454 print 'version ' + __version__ + '\n'
1456 print('Commands are:\n' + '\n'.join([
1457 ' %-12s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1458 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1463 if sys.hexversion < 0x02060000:
1464 print >> sys.stderr, (
1465 '\nYour python version %s is unsupported, please upgrade.\n' %
1466 sys.version.split(' ', 1)[0])
1470 command = Command(argv[0])
1471 # Help can be run from anywhere.
1472 if command == CMDhelp:
1473 return command(argv[1:])
1477 except (gclient_utils.Error, subprocess2.CalledProcessError):
1478 print >> sys.stderr, 'To use gcl, you need to be in a subversion checkout.'
1481 # Create the directories where we store information about changelists if it
1484 if not os.path.exists(GetInfoDir()):
1485 os.mkdir(GetInfoDir())
1486 if not os.path.exists(GetChangesDir()):
1487 os.mkdir(GetChangesDir())
1488 if not os.path.exists(GetCacheDir()):
1489 os.mkdir(GetCacheDir())
1492 return command(argv[1:])
1493 # Unknown command, try to pass that to svn
1494 return CMDpassthru(argv)
1495 except (gclient_utils.Error, subprocess2.CalledProcessError), e:
1496 print >> sys.stderr, 'Got an exception'
1497 print >> sys.stderr, str(e)
1499 except upload.ClientLoginError, e:
1500 print >> sys.stderr, 'Got an exception logging in to Rietveld'
1501 print >> sys.stderr, str(e)
1503 except urllib2.HTTPError, e:
1506 print >> sys.stderr, (
1507 'AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1508 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e))
1512 if __name__ == "__main__":
1513 fix_encoding.fix_encoding()
1514 sys.exit(main(sys.argv[1:]))