blob: ee94091f0a0fb7cf2ec6769db857f6194a83c74b [file] [log] [blame]
[email protected]fb2b8eb2009-04-23 21:03:421#!/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# Wrapper script around Rietveld's upload.py that groups files into
7# changelists.
8
9import getpass
10import os
11import random
12import re
[email protected]374d65e2009-05-21 14:00:5213import shutil
[email protected]fb2b8eb2009-04-23 21:03:4214import string
15import subprocess
16import sys
17import tempfile
18import upload
19import urllib2
[email protected]207fdf32009-04-28 19:57:0120import xml.dom.minidom
[email protected]fb2b8eb2009-04-23 21:03:4221
[email protected]46a94102009-05-12 20:32:4322# gcl now depends on gclient.
23import gclient
[email protected]c1675e22009-04-27 20:30:4824
[email protected]98fc2b92009-05-21 14:11:5125__version__ = '1.1.1'
[email protected]c1675e22009-04-27 20:30:4826
27
[email protected]fb2b8eb2009-04-23 21:03:4228CODEREVIEW_SETTINGS = {
29 # Default values.
30 "CODE_REVIEW_SERVER": "codereview.chromium.org",
31 "CC_LIST": "[email protected]",
32 "VIEW_VC": "http://src.chromium.org/viewvc/chrome?view=rev&revision=",
33}
34
[email protected]fb2b8eb2009-04-23 21:03:4235# globals that store the root of the current repository and the directory where
36# we store information about changelists.
[email protected]98fc2b92009-05-21 14:11:5137REPOSITORY_ROOT = ""
[email protected]fb2b8eb2009-04-23 21:03:4238
39# Filename where we store repository specific information for gcl.
40CODEREVIEW_SETTINGS_FILE = "codereview.settings"
41
42# Warning message when the change appears to be missing tests.
43MISSING_TEST_MSG = "Change contains new or modified methods, but no new tests!"
44
[email protected]98fc2b92009-05-21 14:11:5145# Global cache of files cached in GetCacheDir().
46FILES_CACHE = {}
[email protected]fb2b8eb2009-04-23 21:03:4247
48
[email protected]207fdf32009-04-28 19:57:0149### SVN Functions
50
[email protected]fb2b8eb2009-04-23 21:03:4251def IsSVNMoved(filename):
52 """Determine if a file has been added through svn mv"""
[email protected]46a94102009-05-12 20:32:4353 info = gclient.CaptureSVNInfo(filename)
[email protected]fb2b8eb2009-04-23 21:03:4254 return (info.get('Copied From URL') and
55 info.get('Copied From Rev') and
56 info.get('Schedule') == 'add')
57
58
[email protected]fb2b8eb2009-04-23 21:03:4259def GetSVNFileProperty(file, property_name):
60 """Returns the value of an SVN property for the given file.
61
62 Args:
63 file: The file to check
64 property_name: The name of the SVN property, e.g. "svn:mime-type"
65
66 Returns:
67 The value of the property, which will be the empty string if the property
68 is not set on the file. If the file is not under version control, the
69 empty string is also returned.
70 """
71 output = RunShell(["svn", "propget", property_name, file])
72 if (output.startswith("svn: ") and
73 output.endswith("is not under version control")):
74 return ""
75 else:
76 return output
77
78
[email protected]207fdf32009-04-28 19:57:0179def UnknownFiles(extra_args):
80 """Runs svn status and prints unknown files.
81
82 Any args in |extra_args| are passed to the tool to support giving alternate
83 code locations.
84 """
[email protected]4810a962009-05-12 21:03:3485 return [item[1] for item in gclient.CaptureSVNStatus(extra_args)
86 if item[0][0] == '?']
[email protected]207fdf32009-04-28 19:57:0187
88
[email protected]fb2b8eb2009-04-23 21:03:4289def GetRepositoryRoot():
90 """Returns the top level directory of the current repository.
91
92 The directory is returned as an absolute path.
93 """
[email protected]98fc2b92009-05-21 14:11:5194 global REPOSITORY_ROOT
95 if not REPOSITORY_ROOT:
[email protected]46a94102009-05-12 20:32:4396 infos = gclient.CaptureSVNInfo(os.getcwd(), print_error=False)
97 cur_dir_repo_root = infos.get("Repository Root")
[email protected]fb2b8eb2009-04-23 21:03:4298 if not cur_dir_repo_root:
99 raise Exception("gcl run outside of repository")
100
[email protected]98fc2b92009-05-21 14:11:51101 REPOSITORY_ROOT = os.getcwd()
[email protected]fb2b8eb2009-04-23 21:03:42102 while True:
[email protected]98fc2b92009-05-21 14:11:51103 parent = os.path.dirname(REPOSITORY_ROOT)
[email protected]8c3ccf32009-05-20 18:28:37104 if (gclient.CaptureSVNInfo(parent, print_error=False).get(
105 "Repository Root") != cur_dir_repo_root):
[email protected]fb2b8eb2009-04-23 21:03:42106 break
[email protected]98fc2b92009-05-21 14:11:51107 REPOSITORY_ROOT = parent
108 return REPOSITORY_ROOT
[email protected]fb2b8eb2009-04-23 21:03:42109
110
111def GetInfoDir():
112 """Returns the directory where gcl info files are stored."""
[email protected]374d65e2009-05-21 14:00:52113 return os.path.join(GetRepositoryRoot(), '.svn', 'gcl_info')
114
115
116def GetChangesDir():
117 """Returns the directory where gcl change files are stored."""
118 return os.path.join(GetInfoDir(), 'changes')
[email protected]fb2b8eb2009-04-23 21:03:42119
120
[email protected]98fc2b92009-05-21 14:11:51121def GetCacheDir():
122 """Returns the directory where gcl change files are stored."""
123 return os.path.join(GetInfoDir(), 'cache')
124
125
126def GetCachedFile(filename, max_age=60*60*24*3, use_root=False):
127 """Retrieves a file from the repository and caches it in GetCacheDir() for
128 max_age seconds.
129
130 use_root: If False, look up the arborescence for the first match, otherwise go
131 directory to the root repository.
132
133 Note: The cache will be inconsistent if the same file is retrieved with both
134 use_root=True and use_root=False on the same file. Don't be stupid.
135 """
136 global FILES_CACHE
137 if filename not in FILES_CACHE:
138 # Don't try to look up twice.
139 FILES_CACHE[filename] = None
[email protected]9b613272009-04-24 01:28:28140 # First we check if we have a cached version.
[email protected]98fc2b92009-05-21 14:11:51141 cached_file = os.path.join(GetCacheDir(), filename)
142 if (not os.path.exists(cached_file) or
143 os.stat(cached_file).st_mtime > max_age):
[email protected]46a94102009-05-12 20:32:43144 dir_info = gclient.CaptureSVNInfo(".")
[email protected]9b613272009-04-24 01:28:28145 repo_root = dir_info["Repository Root"]
[email protected]98fc2b92009-05-21 14:11:51146 if use_root:
147 url_path = repo_root
148 else:
149 url_path = dir_info["URL"]
150 content = ""
[email protected]9b613272009-04-24 01:28:28151 while True:
152 # Look for the codereview.settings file at the current level.
[email protected]98fc2b92009-05-21 14:11:51153 svn_path = url_path + "/" + filename
154 content, rc = RunShellWithReturnCode(["svn", "cat", svn_path])
[email protected]9b613272009-04-24 01:28:28155 if not rc:
[email protected]98fc2b92009-05-21 14:11:51156 # Exit the loop if the file was found. Override content.
[email protected]9b613272009-04-24 01:28:28157 break
158 # Make sure to mark settings as empty if not found.
[email protected]98fc2b92009-05-21 14:11:51159 content = ""
[email protected]9b613272009-04-24 01:28:28160 if url_path == repo_root:
161 # Reached the root. Abandoning search.
[email protected]98fc2b92009-05-21 14:11:51162 break
[email protected]9b613272009-04-24 01:28:28163 # Go up one level to try again.
164 url_path = os.path.dirname(url_path)
[email protected]9b613272009-04-24 01:28:28165 # Write a cached version even if there isn't a file, so we don't try to
166 # fetch it each time.
[email protected]98fc2b92009-05-21 14:11:51167 WriteFile(cached_file, content)
168 else:
169 content = ReadFile(cached_settings_file)
170 # Keep the content cached in memory.
171 FILES_CACHE[filename] = content
172 return FILES_CACHE[filename]
[email protected]9b613272009-04-24 01:28:28173
[email protected]98fc2b92009-05-21 14:11:51174
175def GetCodeReviewSetting(key):
176 """Returns a value for the given key for this repository."""
177 # Use '__just_initialized' as a flag to determine if the settings were
178 # already initialized.
179 global CODEREVIEW_SETTINGS
180 if '__just_initialized' not in CODEREVIEW_SETTINGS:
[email protected]b0442182009-06-05 14:20:47181 settings_file = GetCachedFile(CODEREVIEW_SETTINGS_FILE)
182 if settings_file:
183 for line in settings_file.splitlines():
184 if not line or line.startswith("#"):
185 continue
186 k, v = line.split(": ", 1)
187 CODEREVIEW_SETTINGS[k] = v
[email protected]98fc2b92009-05-21 14:11:51188 CODEREVIEW_SETTINGS.setdefault('__just_initialized', None)
[email protected]fb2b8eb2009-04-23 21:03:42189 return CODEREVIEW_SETTINGS.get(key, "")
190
191
[email protected]fb2b8eb2009-04-23 21:03:42192def Warn(msg):
193 ErrorExit(msg, exit=False)
194
195
196def ErrorExit(msg, exit=True):
197 """Print an error message to stderr and optionally exit."""
198 print >>sys.stderr, msg
199 if exit:
200 sys.exit(1)
201
202
203def RunShellWithReturnCode(command, print_output=False):
204 """Executes a command and returns the output and the return code."""
[email protected]be0d1ca2009-05-12 19:23:02205 # Use a shell for subcommands on Windows to get a PATH search, and because svn
206 # may be a batch file.
207 use_shell = sys.platform.startswith("win")
[email protected]fb2b8eb2009-04-23 21:03:42208 p = subprocess.Popen(command, stdout=subprocess.PIPE,
209 stderr=subprocess.STDOUT, shell=use_shell,
210 universal_newlines=True)
211 if print_output:
212 output_array = []
213 while True:
214 line = p.stdout.readline()
215 if not line:
216 break
217 if print_output:
218 print line.strip('\n')
219 output_array.append(line)
220 output = "".join(output_array)
221 else:
222 output = p.stdout.read()
223 p.wait()
224 p.stdout.close()
225 return output, p.returncode
226
227
228def RunShell(command, print_output=False):
229 """Executes a command and returns the output."""
230 return RunShellWithReturnCode(command, print_output)[0]
231
232
[email protected]c1675e22009-04-27 20:30:48233def ReadFile(filename, flags='r'):
[email protected]fb2b8eb2009-04-23 21:03:42234 """Returns the contents of a file."""
[email protected]c1675e22009-04-27 20:30:48235 file = open(filename, flags)
[email protected]fb2b8eb2009-04-23 21:03:42236 result = file.read()
237 file.close()
238 return result
239
240
241def WriteFile(filename, contents):
242 """Overwrites the file with the given contents."""
243 file = open(filename, 'w')
244 file.write(contents)
245 file.close()
246
247
[email protected]be0d1ca2009-05-12 19:23:02248class ChangeInfo(object):
[email protected]fb2b8eb2009-04-23 21:03:42249 """Holds information about a changelist.
250
[email protected]32ba2602009-06-06 18:44:48251 name: change name.
252 issue: the Rietveld issue number or 0 if it hasn't been uploaded yet.
253 patchset: the Rietveld latest patchset number or 0.
[email protected]fb2b8eb2009-04-23 21:03:42254 description: the description.
255 files: a list of 2 tuple containing (status, filename) of changed files,
256 with paths being relative to the top repository directory.
257 """
[email protected]32ba2602009-06-06 18:44:48258
259 _SEPARATOR = "\n-----\n"
260 # The info files have the following format:
261 # issue_id, patchset\n (, patchset is optional)
262 # _SEPARATOR\n
263 # filepath1\n
264 # filepath2\n
265 # .
266 # .
267 # filepathn\n
268 # _SEPARATOR\n
269 # description
270
271 def __init__(self, name, issue, patchset, description, files):
[email protected]fb2b8eb2009-04-23 21:03:42272 self.name = name
[email protected]32ba2602009-06-06 18:44:48273 self.issue = int(issue)
274 self.patchset = int(patchset)
[email protected]fb2b8eb2009-04-23 21:03:42275 self.description = description
[email protected]be0d1ca2009-05-12 19:23:02276 if files is None:
277 files = []
[email protected]fb2b8eb2009-04-23 21:03:42278 self.files = files
279 self.patch = None
280
281 def FileList(self):
282 """Returns a list of files."""
283 return [file[1] for file in self.files]
284
285 def _NonDeletedFileList(self):
286 """Returns a list of files in this change, not including deleted files."""
287 return [file[1] for file in self.files if not file[0].startswith("D")]
288
289 def _AddedFileList(self):
290 """Returns a list of files added in this change."""
291 return [file[1] for file in self.files if file[0].startswith("A")]
292
293 def Save(self):
294 """Writes the changelist information to disk."""
[email protected]32ba2602009-06-06 18:44:48295 data = ChangeInfo._SEPARATOR.join([
296 "%d, %d" % (self.issue, self.patchset),
297 "\n".join([f[0] + f[1] for f in self.files]),
298 self.description])
[email protected]fb2b8eb2009-04-23 21:03:42299 WriteFile(GetChangelistInfoFile(self.name), data)
300
301 def Delete(self):
302 """Removes the changelist information from disk."""
303 os.remove(GetChangelistInfoFile(self.name))
304
305 def CloseIssue(self):
306 """Closes the Rietveld issue for this changelist."""
307 data = [("description", self.description),]
308 ctype, body = upload.EncodeMultipartFormData(data, [])
[email protected]2c8d4b22009-06-06 21:03:10309 SendToRietveld("/%d/close" % self.issue, body, ctype)
[email protected]fb2b8eb2009-04-23 21:03:42310
311 def UpdateRietveldDescription(self):
312 """Sets the description for an issue on Rietveld."""
313 data = [("description", self.description),]
314 ctype, body = upload.EncodeMultipartFormData(data, [])
[email protected]2c8d4b22009-06-06 21:03:10315 SendToRietveld("/%d/description" % self.issue, body, ctype)
[email protected]fb2b8eb2009-04-23 21:03:42316
317 def MissingTests(self):
318 """Returns True if the change looks like it needs unit tests but has none.
319
320 A change needs unit tests if it contains any new source files or methods.
321 """
322 SOURCE_SUFFIXES = [".cc", ".cpp", ".c", ".m", ".mm"]
323 # Ignore third_party entirely.
324 files = [file for file in self._NonDeletedFileList()
325 if file.find("third_party") == -1]
326 added_files = [file for file in self._AddedFileList()
327 if file.find("third_party") == -1]
328
329 # If the change is entirely in third_party, we're done.
330 if len(files) == 0:
331 return False
332
333 # Any new or modified test files?
334 # A test file's name ends with "test.*" or "tests.*".
335 test_files = [test for test in files
336 if os.path.splitext(test)[0].rstrip("s").endswith("test")]
337 if len(test_files) > 0:
338 return False
339
340 # Any new source files?
341 source_files = [file for file in added_files
342 if os.path.splitext(file)[1] in SOURCE_SUFFIXES]
343 if len(source_files) > 0:
344 return True
345
346 # Do the long test, checking the files for new methods.
347 return self._HasNewMethod()
348
349 def _HasNewMethod(self):
350 """Returns True if the changeset contains any new functions, or if a
351 function signature has been changed.
352
353 A function is identified by starting flush left, containing a "(" before
354 the next flush-left line, and either ending with "{" before the next
355 flush-left line or being followed by an unindented "{".
356
357 Currently this returns True for new methods, new static functions, and
358 methods or functions whose signatures have been changed.
359
360 Inline methods added to header files won't be detected by this. That's
361 acceptable for purposes of determining if a unit test is needed, since
362 inline methods should be trivial.
363 """
364 # To check for methods added to source or header files, we need the diffs.
365 # We'll generate them all, since there aren't likely to be many files
366 # apart from source and headers; besides, we'll want them all if we're
367 # uploading anyway.
368 if self.patch is None:
369 self.patch = GenerateDiff(self.FileList())
370
371 definition = ""
372 for line in self.patch.splitlines():
373 if not line.startswith("+"):
374 continue
375 line = line.strip("+").rstrip(" \t")
376 # Skip empty lines, comments, and preprocessor directives.
377 # TODO(pamg): Handle multiline comments if it turns out to be a problem.
378 if line == "" or line.startswith("/") or line.startswith("#"):
379 continue
380
381 # A possible definition ending with "{" is complete, so check it.
382 if definition.endswith("{"):
383 if definition.find("(") != -1:
384 return True
385 definition = ""
386
387 # A { or an indented line, when we're in a definition, continues it.
388 if (definition != "" and
389 (line == "{" or line.startswith(" ") or line.startswith("\t"))):
390 definition += line
391
392 # A flush-left line starts a new possible function definition.
393 elif not line.startswith(" ") and not line.startswith("\t"):
394 definition = line
395
396 return False
397
[email protected]32ba2602009-06-06 18:44:48398 @staticmethod
399 def Load(changename, fail_on_not_found=True, update_status=False):
400 """Gets information about a changelist.
[email protected]fb2b8eb2009-04-23 21:03:42401
[email protected]32ba2602009-06-06 18:44:48402 Args:
403 fail_on_not_found: if True, this function will quit the program if the
404 changelist doesn't exist.
405 update_status: if True, the svn status will be updated for all the files
406 and unchanged files will be removed.
407
408 Returns: a ChangeInfo object.
409 """
410 info_file = GetChangelistInfoFile(changename)
411 if not os.path.exists(info_file):
412 if fail_on_not_found:
413 ErrorExit("Changelist " + changename + " not found.")
[email protected]6cfadff2009-06-06 20:32:32414 return ChangeInfo(changename, 0, 0, '', None)
[email protected]32ba2602009-06-06 18:44:48415 split_data = ReadFile(info_file).split(ChangeInfo._SEPARATOR, 2)
416 if len(split_data) != 3:
417 ErrorExit("Changelist file %s is corrupt" % info_file)
418 items = split_data[0].split(',')
419 issue = 0
420 patchset = 0
421 if items[0]:
422 issue = int(items[0])
423 if len(items) > 1:
424 patchset = int(items[1])
425 files = []
426 for line in split_data[1].splitlines():
427 status = line[:7]
428 file = line[7:]
429 files.append((status, file))
430 description = split_data[2]
431 save = False
432 if update_status:
433 for file in files:
434 filename = os.path.join(GetRepositoryRoot(), file[1])
435 status_result = gclient.CaptureSVNStatus(filename)
436 if not status_result or not status_result[0][0]:
437 # File has been reverted.
438 save = True
439 files.remove(file)
440 continue
441 status = status_result[0][0]
442 if status != file[0]:
443 save = True
444 files[files.index(file)] = (status, file[1])
445 change_info = ChangeInfo(changename, issue, patchset, description, files)
446 if save:
447 change_info.Save()
448 return change_info
[email protected]fb2b8eb2009-04-23 21:03:42449
450
451def GetChangelistInfoFile(changename):
452 """Returns the file that stores information about a changelist."""
453 if not changename or re.search(r'[^\w-]', changename):
454 ErrorExit("Invalid changelist name: " + changename)
[email protected]374d65e2009-05-21 14:00:52455 return os.path.join(GetChangesDir(), changename)
[email protected]fb2b8eb2009-04-23 21:03:42456
457
458def LoadChangelistInfoForMultiple(changenames, fail_on_not_found=True,
459 update_status=False):
460 """Loads many changes and merge their files list into one pseudo change.
461
462 This is mainly usefull to concatenate many changes into one for a 'gcl try'.
463 """
464 changes = changenames.split(',')
[email protected]32ba2602009-06-06 18:44:48465 aggregate_change_info = ChangeInfo(changenames, 0, 0, '', None)
[email protected]fb2b8eb2009-04-23 21:03:42466 for change in changes:
[email protected]32ba2602009-06-06 18:44:48467 aggregate_change_info.files += ChangeInfo.Load(change, fail_on_not_found,
468 update_status).files
[email protected]fb2b8eb2009-04-23 21:03:42469 return aggregate_change_info
470
471
[email protected]fb2b8eb2009-04-23 21:03:42472def GetCLs():
473 """Returns a list of all the changelists in this repository."""
[email protected]374d65e2009-05-21 14:00:52474 cls = os.listdir(GetChangesDir())
[email protected]fb2b8eb2009-04-23 21:03:42475 if CODEREVIEW_SETTINGS_FILE in cls:
476 cls.remove(CODEREVIEW_SETTINGS_FILE)
477 return cls
478
479
480def GenerateChangeName():
481 """Generate a random changelist name."""
482 random.seed()
483 current_cl_names = GetCLs()
484 while True:
485 cl_name = (random.choice(string.ascii_lowercase) +
486 random.choice(string.digits) +
487 random.choice(string.ascii_lowercase) +
488 random.choice(string.digits))
489 if cl_name not in current_cl_names:
490 return cl_name
491
492
493def GetModifiedFiles():
494 """Returns a set that maps from changelist name to (status,filename) tuples.
495
496 Files not in a changelist have an empty changelist name. Filenames are in
497 relation to the top level directory of the current repository. Note that
498 only the current directory and subdirectories are scanned, in order to
499 improve performance while still being flexible.
500 """
501 files = {}
502
503 # Since the files are normalized to the root folder of the repositary, figure
504 # out what we need to add to the paths.
505 dir_prefix = os.getcwd()[len(GetRepositoryRoot()):].strip(os.sep)
506
507 # Get a list of all files in changelists.
508 files_in_cl = {}
509 for cl in GetCLs():
[email protected]32ba2602009-06-06 18:44:48510 change_info = ChangeInfo.Load(cl)
[email protected]fb2b8eb2009-04-23 21:03:42511 for status, filename in change_info.files:
512 files_in_cl[filename] = change_info.name
513
514 # Get all the modified files.
[email protected]4810a962009-05-12 21:03:34515 status_result = gclient.CaptureSVNStatus(None)
[email protected]207fdf32009-04-28 19:57:01516 for line in status_result:
517 status = line[0]
518 filename = line[1]
519 if status[0] == "?":
[email protected]fb2b8eb2009-04-23 21:03:42520 continue
[email protected]fb2b8eb2009-04-23 21:03:42521 if dir_prefix:
522 filename = os.path.join(dir_prefix, filename)
523 change_list_name = ""
524 if filename in files_in_cl:
525 change_list_name = files_in_cl[filename]
526 files.setdefault(change_list_name, []).append((status, filename))
527
528 return files
529
530
531def GetFilesNotInCL():
532 """Returns a list of tuples (status,filename) that aren't in any changelists.
533
534 See docstring of GetModifiedFiles for information about path of files and
535 which directories are scanned.
536 """
537 modified_files = GetModifiedFiles()
538 if "" not in modified_files:
539 return []
540 return modified_files[""]
541
542
543def SendToRietveld(request_path, payload=None,
544 content_type="application/octet-stream", timeout=None):
545 """Send a POST/GET to Rietveld. Returns the response body."""
546 def GetUserCredentials():
547 """Prompts the user for a username and password."""
548 email = upload.GetEmail()
549 password = getpass.getpass("Password for %s: " % email)
550 return email, password
551
552 server = GetCodeReviewSetting("CODE_REVIEW_SERVER")
553 rpc_server = upload.HttpRpcServer(server,
554 GetUserCredentials,
555 host_override=server,
556 save_cookies=True)
557 try:
558 return rpc_server.Send(request_path, payload, content_type, timeout)
559 except urllib2.URLError, e:
560 if timeout is None:
561 ErrorExit("Error accessing url %s" % request_path)
562 else:
563 return None
564
565
566def GetIssueDescription(issue):
567 """Returns the issue description from Rietveld."""
[email protected]32ba2602009-06-06 18:44:48568 return SendToRietveld("/%d/description" % issue)
[email protected]fb2b8eb2009-04-23 21:03:42569
570
[email protected]fb2b8eb2009-04-23 21:03:42571def Opened():
572 """Prints a list of modified files in the current directory down."""
573 files = GetModifiedFiles()
574 cl_keys = files.keys()
575 cl_keys.sort()
576 for cl_name in cl_keys:
577 if cl_name:
578 note = ""
[email protected]32ba2602009-06-06 18:44:48579 if len(ChangeInfo.Load(cl_name).files) != len(files[cl_name]):
[email protected]fb2b8eb2009-04-23 21:03:42580 note = " (Note: this changelist contains files outside this directory)"
581 print "\n--- Changelist " + cl_name + note + ":"
582 for file in files[cl_name]:
583 print "".join(file)
584
585
586def Help(argv=None):
[email protected]3bcc6ce2009-05-12 22:53:53587 if argv:
588 if argv[0] == 'try':
589 TryChange(None, ['--help'], swallow_exception=False)
590 return
591 if argv[0] == 'upload':
592 upload.RealMain(['upload.py', '--help'])
593 return
[email protected]fb2b8eb2009-04-23 21:03:42594
595 print (
596"""GCL is a wrapper for Subversion that simplifies working with groups of files.
[email protected]c1675e22009-04-27 20:30:48597version """ + __version__ + """
[email protected]fb2b8eb2009-04-23 21:03:42598
599Basic commands:
600-----------------------------------------
601 gcl change change_name
602 Add/remove files to a changelist. Only scans the current directory and
603 subdirectories.
604
605 gcl upload change_name [-r [email protected],[email protected],...]
606 [--send_mail] [--no_try] [--no_presubmit]
607 Uploads the changelist to the server for review.
608
[email protected]3b217f52009-06-01 17:54:20609 gcl commit change_name [--no_presubmit]
[email protected]fb2b8eb2009-04-23 21:03:42610 Commits the changelist to the repository.
611
612 gcl lint change_name
613 Check all the files in the changelist for possible style violations.
614
615Advanced commands:
616-----------------------------------------
617 gcl delete change_name
618 Deletes a changelist.
619
620 gcl diff change_name
621 Diffs all files in the changelist.
622
623 gcl presubmit change_name
624 Runs presubmit checks without uploading the changelist.
625
626 gcl diff
627 Diffs all files in the current directory and subdirectories that aren't in
628 a changelist.
629
630 gcl changes
631 Lists all the the changelists and the files in them.
632
633 gcl nothave [optional directory]
634 Lists files unknown to Subversion.
635
636 gcl opened
637 Lists modified files in the current directory and subdirectories.
638
639 gcl settings
640 Print the code review settings for this directory.
641
642 gcl status
643 Lists modified and unknown files in the current directory and
644 subdirectories.
645
646 gcl try change_name
647 Sends the change to the tryserver so a trybot can do a test run on your
648 code. To send multiple changes as one path, use a comma-separated list
649 of changenames.
650 --> Use 'gcl help try' for more information!
[email protected]3bcc6ce2009-05-12 22:53:53651
652 gcl help [command]
653 Print this help menu, or help for the given command if it exists.
[email protected]fb2b8eb2009-04-23 21:03:42654""")
655
656def GetEditor():
657 editor = os.environ.get("SVN_EDITOR")
658 if not editor:
659 editor = os.environ.get("EDITOR")
660
661 if not editor:
662 if sys.platform.startswith("win"):
663 editor = "notepad"
664 else:
665 editor = "vi"
666
667 return editor
668
669
670def GenerateDiff(files, root=None):
671 """Returns a string containing the diff for the given file list.
672
673 The files in the list should either be absolute paths or relative to the
674 given root. If no root directory is provided, the repository root will be
675 used.
676 """
677 previous_cwd = os.getcwd()
678 if root is None:
679 os.chdir(GetRepositoryRoot())
680 else:
681 os.chdir(root)
682
683 diff = []
684 for file in files:
685 # Use svn info output instead of os.path.isdir because the latter fails
686 # when the file is deleted.
[email protected]46a94102009-05-12 20:32:43687 if gclient.CaptureSVNInfo(file).get("Node Kind") in ("dir", "directory"):
[email protected]fb2b8eb2009-04-23 21:03:42688 continue
689 # If the user specified a custom diff command in their svn config file,
690 # then it'll be used when we do svn diff, which we don't want to happen
691 # since we want the unified diff. Using --diff-cmd=diff doesn't always
692 # work, since they can have another diff executable in their path that
693 # gives different line endings. So we use a bogus temp directory as the
694 # config directory, which gets around these problems.
695 if sys.platform.startswith("win"):
696 parent_dir = tempfile.gettempdir()
697 else:
698 parent_dir = sys.path[0] # tempdir is not secure.
699 bogus_dir = os.path.join(parent_dir, "temp_svn_config")
700 if not os.path.exists(bogus_dir):
701 os.mkdir(bogus_dir)
702 output = RunShell(["svn", "diff", "--config-dir", bogus_dir, file])
703 if output:
704 diff.append(output)
[email protected]c3150202009-05-13 14:31:01705 elif IsSVNMoved(file):
706 # svn diff on a mv/cp'd file outputs nothing.
707 # We put in an empty Index entry so upload.py knows about them.
[email protected]fb2b8eb2009-04-23 21:03:42708 diff.append("\nIndex: %s\n" % file)
[email protected]c3150202009-05-13 14:31:01709 else:
710 # The file is not modified anymore. It should be removed from the set.
711 pass
[email protected]fb2b8eb2009-04-23 21:03:42712 os.chdir(previous_cwd)
713 return "".join(diff)
714
715
716def UploadCL(change_info, args):
717 if not change_info.FileList():
718 print "Nothing to upload, changelist is empty."
719 return
720
721 if not "--no_presubmit" in args:
722 if not DoPresubmitChecks(change_info, committing=False):
723 return
724 else:
725 args.remove("--no_presubmit")
726
727 no_try = "--no_try" in args
728 if no_try:
729 args.remove("--no_try")
730 else:
731 # Support --no-try as --no_try
732 no_try = "--no-try" in args
733 if no_try:
734 args.remove("--no-try")
735
736 # Map --send-mail to --send_mail
737 if "--send-mail" in args:
738 args.remove("--send-mail")
739 args.append("--send_mail")
740
741 # Supports --clobber for the try server.
742 clobber = False
743 if "--clobber" in args:
744 args.remove("--clobber")
745 clobber = True
746
[email protected]003c2692009-05-20 13:08:08747 # Disable try when the server is overridden.
748 server_1 = re.compile(r"^-s\b.*")
749 server_2 = re.compile(r"^--server\b.*")
750 for arg in args:
751 if server_1.match(arg) or server_2.match(arg):
752 no_try = True
753 break
[email protected]fb2b8eb2009-04-23 21:03:42754
755 upload_arg = ["upload.py", "-y"]
756 upload_arg.append("--server=" + GetCodeReviewSetting("CODE_REVIEW_SERVER"))
757 upload_arg.extend(args)
758
759 desc_file = ""
760 if change_info.issue: # Uploading a new patchset.
761 found_message = False
762 for arg in args:
763 if arg.startswith("--message") or arg.startswith("-m"):
764 found_message = True
765 break
766
767 if not found_message:
768 upload_arg.append("--message=''")
769
[email protected]32ba2602009-06-06 18:44:48770 upload_arg.append("--issue=%d" % change_info.issue)
[email protected]fb2b8eb2009-04-23 21:03:42771 else: # First time we upload.
772 handle, desc_file = tempfile.mkstemp(text=True)
773 os.write(handle, change_info.description)
774 os.close(handle)
775
776 cc_list = GetCodeReviewSetting("CC_LIST")
777 if cc_list:
778 upload_arg.append("--cc=" + cc_list)
779 upload_arg.append("--description_file=" + desc_file + "")
780 if change_info.description:
781 subject = change_info.description[:77]
782 if subject.find("\r\n") != -1:
783 subject = subject[:subject.find("\r\n")]
784 if subject.find("\n") != -1:
785 subject = subject[:subject.find("\n")]
786 if len(change_info.description) > 77:
787 subject = subject + "..."
788 upload_arg.append("--message=" + subject)
789
790 # Change the current working directory before calling upload.py so that it
791 # shows the correct base.
792 previous_cwd = os.getcwd()
793 os.chdir(GetRepositoryRoot())
794
795 # If we have a lot of files with long paths, then we won't be able to fit
796 # the command to "svn diff". Instead, we generate the diff manually for
797 # each file and concatenate them before passing it to upload.py.
798 if change_info.patch is None:
799 change_info.patch = GenerateDiff(change_info.FileList())
800 issue, patchset = upload.RealMain(upload_arg, change_info.patch)
[email protected]32ba2602009-06-06 18:44:48801 if issue and patchset:
802 change_info.issue = int(issue)
803 change_info.patchset = int(patchset)
[email protected]fb2b8eb2009-04-23 21:03:42804 change_info.Save()
805
806 if desc_file:
807 os.remove(desc_file)
808
809 # Do background work on Rietveld to lint the file so that the results are
810 # ready when the issue is viewed.
811 SendToRietveld("/lint/issue%s_%s" % (issue, patchset), timeout=0.5)
812
813 # Once uploaded to Rietveld, send it to the try server.
814 if not no_try:
815 try_on_upload = GetCodeReviewSetting('TRY_ON_UPLOAD')
816 if try_on_upload and try_on_upload.lower() == 'true':
[email protected]32ba2602009-06-06 18:44:48817 trychange_args = []
[email protected]fb2b8eb2009-04-23 21:03:42818 if clobber:
[email protected]32ba2602009-06-06 18:44:48819 trychange_args.append('--clobber')
820 TryChange(change_info, trychange_args, swallow_exception=True)
[email protected]fb2b8eb2009-04-23 21:03:42821
822 os.chdir(previous_cwd)
823
824
825def PresubmitCL(change_info):
826 """Reports what presubmit checks on the change would report."""
827 if not change_info.FileList():
828 print "Nothing to presubmit check, changelist is empty."
829 return
830
831 print "*** Presubmit checks for UPLOAD would report: ***"
832 DoPresubmitChecks(change_info, committing=False)
833
834 print "\n\n*** Presubmit checks for COMMIT would report: ***"
835 DoPresubmitChecks(change_info, committing=True)
836
837
838def TryChange(change_info, args, swallow_exception):
839 """Create a diff file of change_info and send it to the try server."""
840 try:
841 import trychange
842 except ImportError:
843 if swallow_exception:
844 return
845 ErrorExit("You need to install trychange.py to use the try server.")
846
847 if change_info:
848 trychange_args = ['--name', change_info.name]
[email protected]32ba2602009-06-06 18:44:48849 if change_info.issue:
850 trychange_args.extend(["--issue", str(change_info.issue)])
851 if change_info.patchset:
852 trychange_args.extend(["--patchset", str(change_info.patchset)])
[email protected]fb2b8eb2009-04-23 21:03:42853 trychange_args.extend(args)
854 trychange.TryChange(trychange_args,
855 file_list=change_info.FileList(),
856 swallow_exception=swallow_exception,
857 prog='gcl try')
858 else:
859 trychange.TryChange(args,
860 file_list=None,
861 swallow_exception=swallow_exception,
862 prog='gcl try')
863
864
865def Commit(change_info, args):
866 if not change_info.FileList():
867 print "Nothing to commit, changelist is empty."
868 return
869
870 if not "--no_presubmit" in args:
871 if not DoPresubmitChecks(change_info, committing=True):
872 return
873 else:
874 args.remove("--no_presubmit")
875
[email protected]1bb04aa2009-06-01 17:52:11876 # We face a problem with svn here: Let's say change 'bleh' modifies
877 # svn:ignore on dir1\. but another unrelated change 'pouet' modifies
878 # dir1\foo.cc. When the user `gcl commit bleh`, foo.cc is *also committed*.
879 # The only fix is to use --non-recursive but that has its issues too:
880 # Let's say if dir1 is deleted, --non-recursive must *not* be used otherwise
881 # you'll get "svn: Cannot non-recursively commit a directory deletion of a
882 # directory with child nodes". Yay...
883 commit_cmd = ["svn", "commit"]
[email protected]fb2b8eb2009-04-23 21:03:42884 filename = ''
885 if change_info.issue:
886 # Get the latest description from Rietveld.
887 change_info.description = GetIssueDescription(change_info.issue)
888
889 commit_message = change_info.description.replace('\r\n', '\n')
890 if change_info.issue:
[email protected]32ba2602009-06-06 18:44:48891 commit_message += ('\nReview URL: http://%s/%d' %
[email protected]fb2b8eb2009-04-23 21:03:42892 (GetCodeReviewSetting("CODE_REVIEW_SERVER"),
893 change_info.issue))
894
895 handle, commit_filename = tempfile.mkstemp(text=True)
896 os.write(handle, commit_message)
897 os.close(handle)
898
899 handle, targets_filename = tempfile.mkstemp(text=True)
900 os.write(handle, "\n".join(change_info.FileList()))
901 os.close(handle)
902
903 commit_cmd += ['--file=' + commit_filename]
904 commit_cmd += ['--targets=' + targets_filename]
905 # Change the current working directory before calling commit.
906 previous_cwd = os.getcwd()
907 os.chdir(GetRepositoryRoot())
908 output = RunShell(commit_cmd, True)
909 os.remove(commit_filename)
910 os.remove(targets_filename)
911 if output.find("Committed revision") != -1:
912 change_info.Delete()
913
914 if change_info.issue:
915 revision = re.compile(".*?\nCommitted revision (\d+)",
916 re.DOTALL).match(output).group(1)
917 viewvc_url = GetCodeReviewSetting("VIEW_VC")
918 change_info.description = change_info.description + '\n'
919 if viewvc_url:
920 change_info.description += "\nCommitted: " + viewvc_url + revision
921 change_info.CloseIssue()
922 os.chdir(previous_cwd)
923
[email protected]2c8d4b22009-06-06 21:03:10924
[email protected]85532fc2009-06-04 22:36:53925def Change(change_info, override_description):
[email protected]fb2b8eb2009-04-23 21:03:42926 """Creates/edits a changelist."""
927 if change_info.issue:
928 try:
929 description = GetIssueDescription(change_info.issue)
930 except urllib2.HTTPError, err:
931 if err.code == 404:
932 # The user deleted the issue in Rietveld, so forget the old issue id.
933 description = change_info.description
[email protected]2c8d4b22009-06-06 21:03:10934 change_info.issue = 0
[email protected]fb2b8eb2009-04-23 21:03:42935 change_info.Save()
936 else:
937 ErrorExit("Error getting the description from Rietveld: " + err)
938 else:
[email protected]85532fc2009-06-04 22:36:53939 if override_description:
940 description = override_description
941 else:
942 description = change_info.description
[email protected]fb2b8eb2009-04-23 21:03:42943
944 other_files = GetFilesNotInCL()
[email protected]85532fc2009-06-04 22:36:53945
946 #Edited files will have a letter for the first character in a string.
947 #This regex looks for the presence of that character.
948 file_re = re.compile(r"^[a-z].+\Z", re.IGNORECASE)
949 affected_files = filter(lambda x: file_re.match(x[0]), other_files)
950 unaffected_files = filter(lambda x: not file_re.match(x[0]), other_files)
[email protected]fb2b8eb2009-04-23 21:03:42951
952 separator1 = ("\n---All lines above this line become the description.\n"
953 "---Repository Root: " + GetRepositoryRoot() + "\n"
954 "---Paths in this changelist (" + change_info.name + "):\n")
955 separator2 = "\n\n---Paths modified but not in any changelist:\n\n"
956 text = (description + separator1 + '\n' +
957 '\n'.join([f[0] + f[1] for f in change_info.files]) + separator2 +
[email protected]85532fc2009-06-04 22:36:53958 '\n'.join([f[0] + f[1] for f in affected_files]) + '\n' +
959 '\n'.join([f[0] + f[1] for f in unaffected_files]) + '\n')
[email protected]fb2b8eb2009-04-23 21:03:42960
961 handle, filename = tempfile.mkstemp(text=True)
962 os.write(handle, text)
963 os.close(handle)
964
965 os.system(GetEditor() + " " + filename)
966
967 result = ReadFile(filename)
968 os.remove(filename)
969
970 if not result:
971 return
972
973 split_result = result.split(separator1, 1)
974 if len(split_result) != 2:
975 ErrorExit("Don't modify the text starting with ---!\n\n" + result)
976
977 new_description = split_result[0]
978 cl_files_text = split_result[1]
[email protected]85532fc2009-06-04 22:36:53979 if new_description != description or override_description:
[email protected]fb2b8eb2009-04-23 21:03:42980 change_info.description = new_description
981 if change_info.issue:
982 # Update the Rietveld issue with the new description.
983 change_info.UpdateRietveldDescription()
984
985 new_cl_files = []
986 for line in cl_files_text.splitlines():
987 if not len(line):
988 continue
989 if line.startswith("---"):
990 break
991 status = line[:7]
992 file = line[7:]
993 new_cl_files.append((status, file))
994 change_info.files = new_cl_files
995
996 change_info.Save()
997 print change_info.name + " changelist saved."
998 if change_info.MissingTests():
999 Warn("WARNING: " + MISSING_TEST_MSG)
1000
1001# We don't lint files in these path prefixes.
1002IGNORE_PATHS = ("webkit",)
1003
1004# Valid extensions for files we want to lint.
1005CPP_EXTENSIONS = ("cpp", "cc", "h")
1006
1007def Lint(change_info, args):
1008 """Runs cpplint.py on all the files in |change_info|"""
1009 try:
1010 import cpplint
1011 except ImportError:
1012 ErrorExit("You need to install cpplint.py to lint C++ files.")
1013
1014 # Change the current working directory before calling lint so that it
1015 # shows the correct base.
1016 previous_cwd = os.getcwd()
1017 os.chdir(GetRepositoryRoot())
1018
1019 # Process cpplints arguments if any.
1020 filenames = cpplint.ParseArguments(args + change_info.FileList())
1021
1022 for file in filenames:
1023 if len([file for suffix in CPP_EXTENSIONS if file.endswith(suffix)]):
1024 if len([file for prefix in IGNORE_PATHS if file.startswith(prefix)]):
1025 print "Ignoring non-Google styled file %s" % file
1026 else:
1027 cpplint.ProcessFile(file, cpplint._cpplint_state.verbose_level)
1028
1029 print "Total errors found: %d\n" % cpplint._cpplint_state.error_count
1030 os.chdir(previous_cwd)
1031
1032
1033def DoPresubmitChecks(change_info, committing):
1034 """Imports presubmit, then calls presubmit.DoPresubmitChecks."""
1035 # Need to import here to avoid circular dependency.
[email protected]1033acc2009-05-13 14:36:481036 import presubmit_support
[email protected]0ff1fab2009-05-22 13:08:151037 root_presubmit = GetCachedFile('PRESUBMIT.py', use_root=True)
[email protected]1033acc2009-05-13 14:36:481038 result = presubmit_support.DoPresubmitChecks(change_info,
1039 committing,
1040 verbose=False,
1041 output_stream=sys.stdout,
[email protected]0ff1fab2009-05-22 13:08:151042 input_stream=sys.stdin,
1043 default_presubmit=root_presubmit)
[email protected]fb2b8eb2009-04-23 21:03:421044 if not result:
1045 print "\nPresubmit errors, can't continue (use --no_presubmit to bypass)"
1046 return result
1047
1048
1049def Changes():
1050 """Print all the changelists and their files."""
1051 for cl in GetCLs():
[email protected]32ba2602009-06-06 18:44:481052 change_info = ChangeInfo.Load(cl, True, True)
[email protected]fb2b8eb2009-04-23 21:03:421053 print "\n--- Changelist " + change_info.name + ":"
1054 for file in change_info.files:
1055 print "".join(file)
1056
1057
1058def main(argv=None):
1059 if argv is None:
1060 argv = sys.argv
1061
1062 if len(argv) == 1:
1063 Help()
1064 return 0;
1065
[email protected]374d65e2009-05-21 14:00:521066 # Create the directories where we store information about changelists if it
[email protected]fb2b8eb2009-04-23 21:03:421067 # doesn't exist.
1068 if not os.path.exists(GetInfoDir()):
1069 os.mkdir(GetInfoDir())
[email protected]374d65e2009-05-21 14:00:521070 if not os.path.exists(GetChangesDir()):
1071 os.mkdir(GetChangesDir())
1072 # For smooth upgrade support, move the files in GetInfoDir() to
1073 # GetChangesDir().
1074 # TODO(maruel): Remove this code in August 2009.
1075 for file in os.listdir(unicode(GetInfoDir())):
1076 file_path = os.path.join(unicode(GetInfoDir()), file)
1077 if os.path.isfile(file_path) and file != CODEREVIEW_SETTINGS_FILE:
1078 shutil.move(file_path, GetChangesDir())
[email protected]98fc2b92009-05-21 14:11:511079 if not os.path.exists(GetCacheDir()):
1080 os.mkdir(GetCacheDir())
[email protected]fb2b8eb2009-04-23 21:03:421081
1082 # Commands that don't require an argument.
1083 command = argv[1]
1084 if command == "opened":
1085 Opened()
1086 return 0
1087 if command == "status":
1088 Opened()
1089 print "\n--- Not in any changelist:"
1090 UnknownFiles([])
1091 return 0
1092 if command == "nothave":
1093 UnknownFiles(argv[2:])
1094 return 0
1095 if command == "changes":
1096 Changes()
1097 return 0
1098 if command == "help":
1099 Help(argv[2:])
1100 return 0
1101 if command == "diff" and len(argv) == 2:
1102 files = GetFilesNotInCL()
1103 print GenerateDiff([x[1] for x in files])
1104 return 0
1105 if command == "settings":
1106 ignore = GetCodeReviewSetting("UNKNOWN");
1107 print CODEREVIEW_SETTINGS
1108 return 0
1109
1110 if len(argv) == 2:
1111 if command == "change":
1112 # Generate a random changelist name.
1113 changename = GenerateChangeName()
1114 else:
1115 ErrorExit("Need a changelist name.")
1116 else:
1117 changename = argv[2]
1118
1119 # When the command is 'try' and --patchset is used, the patch to try
1120 # is on the Rietveld server. 'change' creates a change so it's fine if the
1121 # change didn't exist. All other commands require an existing change.
1122 fail_on_not_found = command != "try" and command != "change"
1123 if command == "try" and changename.find(',') != -1:
1124 change_info = LoadChangelistInfoForMultiple(changename, True, True)
1125 else:
[email protected]32ba2602009-06-06 18:44:481126 change_info = ChangeInfo.Load(changename, fail_on_not_found, True)
[email protected]fb2b8eb2009-04-23 21:03:421127
1128 if command == "change":
[email protected]85532fc2009-06-04 22:36:531129 if (len(argv) == 4):
1130 filename = argv[3]
1131 f = open(filename, 'rU')
1132 override_description = f.read()
1133 f.close()
1134 else:
1135 override_description = None
1136 Change(change_info, override_description)
[email protected]fb2b8eb2009-04-23 21:03:421137 elif command == "lint":
1138 Lint(change_info, argv[3:])
1139 elif command == "upload":
1140 UploadCL(change_info, argv[3:])
1141 elif command == "presubmit":
1142 PresubmitCL(change_info)
1143 elif command in ("commit", "submit"):
1144 Commit(change_info, argv[3:])
1145 elif command == "delete":
1146 change_info.Delete()
1147 elif command == "try":
1148 # When the change contains no file, send the "changename" positional
1149 # argument to trychange.py.
1150 if change_info.files:
1151 args = argv[3:]
1152 else:
1153 change_info = None
1154 args = argv[2:]
1155 TryChange(change_info, args, swallow_exception=False)
1156 else:
1157 # Everything else that is passed into gcl we redirect to svn, after adding
1158 # the files. This allows commands such as 'gcl diff xxx' to work.
1159 args =["svn", command]
1160 root = GetRepositoryRoot()
1161 args.extend([os.path.join(root, x) for x in change_info.FileList()])
1162 RunShell(args, True)
1163 return 0
1164
1165
1166if __name__ == "__main__":
1167 sys.exit(main())