blob: 581ab4d8c45b04d59327b73c3d9f04d14732b74e [file] [log] [blame]
[email protected]fb2b8eb2009-04-23 21:03:421#!/usr/bin/env python
[email protected]71e12a92012-02-14 02:34:152# coding: utf-8
[email protected]fb2b8eb2009-04-23 21:03:423#
4# Copyright 2007 Google Inc.
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17
18"""Tool for uploading diffs from a version control system to the codereview app.
19
[email protected]9ad0cf62010-11-19 18:07:5320Usage summary: upload.py [options] [-- diff_options] [path...]
[email protected]fb2b8eb2009-04-23 21:03:4221
22Diff options are passed to the diff command of the underlying system.
23
24Supported version control systems:
25 Git
26 Mercurial
27 Subversion
[email protected]fbe3d302011-03-24 17:52:2328 Perforce
29 CVS
[email protected]fb2b8eb2009-04-23 21:03:4230
31It is important for Git/Mercurial users to specify a tree/node/branch to diff
32against by using the '--rev' option.
33"""
34# This code is derived from appcfg.py in the App Engine SDK (open source),
35# and from ASPN recipe #146306.
36
[email protected]c97bfc62010-05-11 19:15:1237import ConfigParser
[email protected]fb2b8eb2009-04-23 21:03:4238import cookielib
[email protected]1c5518e2011-04-21 16:38:1539import errno
[email protected]c97bfc62010-05-11 19:15:1240import fnmatch
[email protected]fb2b8eb2009-04-23 21:03:4241import getpass
42import logging
[email protected]fbe3d302011-03-24 17:52:2343import marshal
[email protected]fb2b8eb2009-04-23 21:03:4244import mimetypes
45import optparse
46import os
47import re
48import socket
49import subprocess
50import sys
51import urllib
52import urllib2
53import urlparse
54
[email protected]b563dd32009-11-20 15:01:4955# The md5 module was deprecated in Python 2.5.
56try:
[email protected]d971f7f2009-05-01 12:51:2357 from hashlib import md5
58except ImportError:
[email protected]b563dd32009-11-20 15:01:4959 from md5 import md5
[email protected]d971f7f2009-05-01 12:51:2360
[email protected]fb2b8eb2009-04-23 21:03:4261try:
62 import readline
63except ImportError:
64 pass
65
[email protected]8b7f1e72010-08-04 14:57:3866try:
67 import keyring
68except ImportError:
69 keyring = None
70
[email protected]fb2b8eb2009-04-23 21:03:4271# The logging verbosity:
72# 0: Errors only.
73# 1: Status messages.
74# 2: Info logs.
75# 3: Debug logs.
76verbosity = 1
77
[email protected]8b7f1e72010-08-04 14:57:3878# The account type used for authentication.
79# This line could be changed by the review server (see handler for
80# upload.py).
81AUTH_ACCOUNT_TYPE = "GOOGLE"
82
83# URL of the default review server. As for AUTH_ACCOUNT_TYPE, this line could be
84# changed by the review server (see handler for upload.py).
85DEFAULT_REVIEW_SERVER = "codereview.appspot.com"
86
[email protected]fb2b8eb2009-04-23 21:03:4287# Max size of patch or base file.
88MAX_UPLOAD_SIZE = 900 * 1024
89
[email protected]b563dd32009-11-20 15:01:4990# Constants for version control names. Used by GuessVCSName.
91VCS_GIT = "Git"
92VCS_MERCURIAL = "Mercurial"
93VCS_SUBVERSION = "Subversion"
[email protected]fbe3d302011-03-24 17:52:2394VCS_PERFORCE = "Perforce"
95VCS_CVS = "CVS"
[email protected]b563dd32009-11-20 15:01:4996VCS_UNKNOWN = "Unknown"
[email protected]fb2b8eb2009-04-23 21:03:4297
[email protected]b563dd32009-11-20 15:01:4998VCS_ABBREVIATIONS = {
99 VCS_MERCURIAL.lower(): VCS_MERCURIAL,
100 "hg": VCS_MERCURIAL,
101 VCS_SUBVERSION.lower(): VCS_SUBVERSION,
102 "svn": VCS_SUBVERSION,
[email protected]fbe3d302011-03-24 17:52:23103 VCS_PERFORCE.lower(): VCS_PERFORCE,
104 "p4": VCS_PERFORCE,
[email protected]b563dd32009-11-20 15:01:49105 VCS_GIT.lower(): VCS_GIT,
[email protected]fbe3d302011-03-24 17:52:23106 VCS_CVS.lower(): VCS_CVS,
[email protected]b563dd32009-11-20 15:01:49107}
108
[email protected]c97bfc62010-05-11 19:15:12109# The result of parsing Subversion's [auto-props] setting.
110svn_auto_props_map = None
[email protected]b563dd32009-11-20 15:01:49111
112def GetEmail(prompt):
[email protected]fb2b8eb2009-04-23 21:03:42113 """Prompts the user for their email address and returns it.
114
115 The last used email address is saved to a file and offered up as a suggestion
116 to the user. If the user presses enter without typing in anything the last
117 used email address is used. If the user enters a new address, it is saved
118 for next time we prompt.
119
120 """
[email protected]b563dd32009-11-20 15:01:49121 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
[email protected]fb2b8eb2009-04-23 21:03:42122 last_email = ""
[email protected]fb2b8eb2009-04-23 21:03:42123 if os.path.exists(last_email_file_name):
124 try:
125 last_email_file = open(last_email_file_name, "r")
126 last_email = last_email_file.readline().strip("\n")
127 last_email_file.close()
[email protected]b563dd32009-11-20 15:01:49128 prompt += " [%s]" % last_email
[email protected]fb2b8eb2009-04-23 21:03:42129 except IOError, e:
130 pass
[email protected]b563dd32009-11-20 15:01:49131 email = raw_input(prompt + ": ").strip()
[email protected]fb2b8eb2009-04-23 21:03:42132 if email:
133 try:
134 last_email_file = open(last_email_file_name, "w")
135 last_email_file.write(email)
136 last_email_file.close()
137 except IOError, e:
138 pass
139 else:
140 email = last_email
141 return email
142
143
144def StatusUpdate(msg):
145 """Print a status message to stdout.
146
147 If 'verbosity' is greater than 0, print the message.
148
149 Args:
150 msg: The string to print.
151 """
152 if verbosity > 0:
153 print msg
154
155
156def ErrorExit(msg):
157 """Print an error message to stderr and exit."""
158 print >>sys.stderr, msg
159 sys.exit(1)
160
161
162class ClientLoginError(urllib2.HTTPError):
163 """Raised to indicate there was an error authenticating with ClientLogin."""
164
165 def __init__(self, url, code, msg, headers, args):
166 urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
167 self.args = args
[email protected]e8829d32011-05-27 22:52:01168 self.info = args.get("Info", None)
[email protected]fb2b8eb2009-04-23 21:03:42169
170
171class AbstractRpcServer(object):
172 """Provides a common interface for a simple RPC server."""
173
174 def __init__(self, host, auth_function, host_override=None, extra_headers={},
[email protected]8b7f1e72010-08-04 14:57:38175 save_cookies=False, account_type=AUTH_ACCOUNT_TYPE):
[email protected]fb2b8eb2009-04-23 21:03:42176 """Creates a new HttpRpcServer.
177
178 Args:
179 host: The host to send requests to.
180 auth_function: A function that takes no arguments and returns an
181 (email, password) tuple when called. Will be called if authentication
182 is required.
183 host_override: The host header to send to the server (defaults to host).
184 extra_headers: A dict of extra headers to append to every request.
185 save_cookies: If True, save the authentication cookies to local disk.
186 If False, use an in-memory cookiejar instead. Subclasses must
187 implement this functionality. Defaults to False.
[email protected]8b7f1e72010-08-04 14:57:38188 account_type: Account type used for authentication. Defaults to
189 AUTH_ACCOUNT_TYPE.
[email protected]fb2b8eb2009-04-23 21:03:42190 """
191 self.host = host
[email protected]55ec1d82010-04-27 22:38:29192 if (not self.host.startswith("http://") and
193 not self.host.startswith("https://")):
[email protected]eca300b2010-04-28 00:29:31194 self.host = "http://" + self.host
[email protected]fb2b8eb2009-04-23 21:03:42195 self.host_override = host_override
196 self.auth_function = auth_function
197 self.authenticated = False
198 self.extra_headers = extra_headers
199 self.save_cookies = save_cookies
[email protected]8b7f1e72010-08-04 14:57:38200 self.account_type = account_type
[email protected]fb2b8eb2009-04-23 21:03:42201 self.opener = self._GetOpener()
202 if self.host_override:
203 logging.info("Server: %s; Host: %s", self.host, self.host_override)
204 else:
205 logging.info("Server: %s", self.host)
206
207 def _GetOpener(self):
208 """Returns an OpenerDirector for making HTTP requests.
209
210 Returns:
211 A urllib2.OpenerDirector object.
212 """
213 raise NotImplementedError()
214
215 def _CreateRequest(self, url, data=None):
216 """Creates a new urllib request."""
217 logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
[email protected]71e12a92012-02-14 02:34:15218 req = urllib2.Request(url, data=data, headers={"Accept": "text/plain"})
[email protected]fb2b8eb2009-04-23 21:03:42219 if self.host_override:
220 req.add_header("Host", self.host_override)
221 for key, value in self.extra_headers.iteritems():
222 req.add_header(key, value)
223 return req
224
[email protected]fbe3d302011-03-24 17:52:23225 def _GetAuthToken(self, email, password):
[email protected]fb2b8eb2009-04-23 21:03:42226 """Uses ClientLogin to authenticate the user, returning an auth token.
227
228 Args:
229 email: The user's email address
230 password: The user's password
231
232 Raises:
233 ClientLoginError: If there was an error authenticating with ClientLogin.
234 HTTPError: If there was some other form of HTTP error.
235
236 Returns:
237 The authentication token returned by ClientLogin.
238 """
[email protected]8b7f1e72010-08-04 14:57:38239 account_type = self.account_type
[email protected]fbe3d302011-03-24 17:52:23240 if self.host.endswith(".google.com"):
[email protected]c97bfc62010-05-11 19:15:12241 # Needed for use inside Google.
242 account_type = "HOSTED"
[email protected]fb2b8eb2009-04-23 21:03:42243 req = self._CreateRequest(
244 url="https://www.google.com/accounts/ClientLogin",
245 data=urllib.urlencode({
246 "Email": email,
247 "Passwd": password,
248 "service": "ah",
249 "source": "rietveld-codereview-upload",
250 "accountType": account_type,
251 }),
252 )
253 try:
254 response = self.opener.open(req)
255 response_body = response.read()
256 response_dict = dict(x.split("=")
257 for x in response_body.split("\n") if x)
258 return response_dict["Auth"]
259 except urllib2.HTTPError, e:
260 if e.code == 403:
261 body = e.read()
262 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
263 raise ClientLoginError(req.get_full_url(), e.code, e.msg,
264 e.headers, response_dict)
265 else:
266 raise
267
[email protected]fbe3d302011-03-24 17:52:23268 def _GetAuthCookie(self, auth_token):
[email protected]fb2b8eb2009-04-23 21:03:42269 """Fetches authentication cookies for an authentication token.
270
271 Args:
272 auth_token: The authentication token returned by ClientLogin.
273
274 Raises:
275 HTTPError: If there was an error fetching the authentication cookies.
276 """
277 # This is a dummy value to allow us to identify when we're successful.
278 continue_location = "http://localhost/"
279 args = {"continue": continue_location, "auth": auth_token}
[email protected]fbe3d302011-03-24 17:52:23280 req = self._CreateRequest("%s/_ah/login?%s" %
281 (self.host, urllib.urlencode(args)))
282 try:
283 response = self.opener.open(req)
284 except urllib2.HTTPError, e:
285 response = e
[email protected]fb2b8eb2009-04-23 21:03:42286 if (response.code != 302 or
287 response.info()["location"] != continue_location):
288 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg,
289 response.headers, response.fp)
290 self.authenticated = True
291
[email protected]fbe3d302011-03-24 17:52:23292 def _Authenticate(self):
[email protected]fb2b8eb2009-04-23 21:03:42293 """Authenticates the user.
294
295 The authentication process works as follows:
296 1) We get a username and password from the user
297 2) We use ClientLogin to obtain an AUTH token for the user
298 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
299 3) We pass the auth token to /_ah/login on the server to obtain an
300 authentication cookie. If login was successful, it tries to redirect
301 us to the URL we provided.
302
303 If we attempt to access the upload API without first obtaining an
[email protected]b563dd32009-11-20 15:01:49304 authentication cookie, it returns a 401 response (or a 302) and
305 directs us to authenticate ourselves with ClientLogin.
[email protected]fb2b8eb2009-04-23 21:03:42306 """
307 for i in range(3):
308 credentials = self.auth_function()
309 try:
[email protected]fbe3d302011-03-24 17:52:23310 auth_token = self._GetAuthToken(credentials[0], credentials[1])
[email protected]fb2b8eb2009-04-23 21:03:42311 except ClientLoginError, e:
[email protected]e8829d32011-05-27 22:52:01312 print >>sys.stderr, ''
[email protected]fb2b8eb2009-04-23 21:03:42313 if e.reason == "BadAuthentication":
[email protected]e8829d32011-05-27 22:52:01314 if e.info == "InvalidSecondFactor":
315 print >>sys.stderr, (
316 "Use an application-specific password instead "
317 "of your regular account password.\n"
318 "See http://www.google.com/"
319 "support/accounts/bin/answer.py?answer=185833")
320 else:
321 print >>sys.stderr, "Invalid username or password."
322 elif e.reason == "CaptchaRequired":
[email protected]fb2b8eb2009-04-23 21:03:42323 print >>sys.stderr, (
324 "Please go to\n"
325 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
[email protected]8b7f1e72010-08-04 14:57:38326 "and verify you are a human. Then try again.\n"
327 "If you are using a Google Apps account the URL is:\n"
328 "https://www.google.com/a/yourdomain.com/UnlockCaptcha")
[email protected]e8829d32011-05-27 22:52:01329 elif e.reason == "NotVerified":
[email protected]fb2b8eb2009-04-23 21:03:42330 print >>sys.stderr, "Account not verified."
[email protected]e8829d32011-05-27 22:52:01331 elif e.reason == "TermsNotAgreed":
[email protected]fb2b8eb2009-04-23 21:03:42332 print >>sys.stderr, "User has not agreed to TOS."
[email protected]e8829d32011-05-27 22:52:01333 elif e.reason == "AccountDeleted":
[email protected]fb2b8eb2009-04-23 21:03:42334 print >>sys.stderr, "The user account has been deleted."
[email protected]e8829d32011-05-27 22:52:01335 elif e.reason == "AccountDisabled":
[email protected]fb2b8eb2009-04-23 21:03:42336 print >>sys.stderr, "The user account has been disabled."
337 break
[email protected]e8829d32011-05-27 22:52:01338 elif e.reason == "ServiceDisabled":
[email protected]fb2b8eb2009-04-23 21:03:42339 print >>sys.stderr, ("The user's access to the service has been "
340 "disabled.")
[email protected]e8829d32011-05-27 22:52:01341 elif e.reason == "ServiceUnavailable":
[email protected]fb2b8eb2009-04-23 21:03:42342 print >>sys.stderr, "The service is not available; try again later."
[email protected]e8829d32011-05-27 22:52:01343 else:
344 # Unknown error.
345 raise
346 print >>sys.stderr, ''
347 continue
[email protected]fbe3d302011-03-24 17:52:23348 self._GetAuthCookie(auth_token)
[email protected]fb2b8eb2009-04-23 21:03:42349 return
350
351 def Send(self, request_path, payload=None,
352 content_type="application/octet-stream",
353 timeout=None,
[email protected]c97bfc62010-05-11 19:15:12354 extra_headers=None,
[email protected]fb2b8eb2009-04-23 21:03:42355 **kwargs):
356 """Sends an RPC and returns the response.
357
358 Args:
359 request_path: The path to send the request to, eg /api/appversion/create.
360 payload: The body of the request, or None to send an empty request.
361 content_type: The Content-Type header to use.
362 timeout: timeout in seconds; default None i.e. no timeout.
363 (Note: for large requests on OS X, the timeout doesn't work right.)
[email protected]c97bfc62010-05-11 19:15:12364 extra_headers: Dict containing additional HTTP headers that should be
365 included in the request (string header names mapped to their values),
366 or None to not include any additional headers.
[email protected]fb2b8eb2009-04-23 21:03:42367 kwargs: Any keyword arguments are converted into query string parameters.
368
369 Returns:
370 The response body, as a string.
371 """
372 # TODO: Don't require authentication. Let the server say
373 # whether it is necessary.
374 if not self.authenticated:
[email protected]fbe3d302011-03-24 17:52:23375 self._Authenticate()
[email protected]fb2b8eb2009-04-23 21:03:42376
377 old_timeout = socket.getdefaulttimeout()
378 socket.setdefaulttimeout(timeout)
379 try:
380 tries = 0
381 while True:
382 tries += 1
[email protected]fbe3d302011-03-24 17:52:23383 args = dict(kwargs)
384 url = "%s%s" % (self.host, request_path)
385 if args:
386 url += "?" + urllib.urlencode(args)
[email protected]fb2b8eb2009-04-23 21:03:42387 req = self._CreateRequest(url=url, data=payload)
388 req.add_header("Content-Type", content_type)
[email protected]c97bfc62010-05-11 19:15:12389 if extra_headers:
390 for header, value in extra_headers.items():
391 req.add_header(header, value)
[email protected]fb2b8eb2009-04-23 21:03:42392 try:
393 f = self.opener.open(req)
394 response = f.read()
395 f.close()
396 return response
397 except urllib2.HTTPError, e:
398 if tries > 3:
399 raise
[email protected]b563dd32009-11-20 15:01:49400 elif e.code == 401 or e.code == 302:
[email protected]fbe3d302011-03-24 17:52:23401 self._Authenticate()
[email protected]ea4d91e2010-11-20 03:11:33402 elif e.code == 301:
403 # Handle permanent redirect manually.
404 url = e.info()["location"]
[email protected]fbe3d302011-03-24 17:52:23405 url_loc = urlparse.urlparse(url)
406 self.host = '%s://%s' % (url_loc[0], url_loc[1])
[email protected]71e12a92012-02-14 02:34:15407 elif e.code >= 500:
408 ErrorExit(e.read())
[email protected]fb2b8eb2009-04-23 21:03:42409 else:
410 raise
411 finally:
412 socket.setdefaulttimeout(old_timeout)
413
414
415class HttpRpcServer(AbstractRpcServer):
416 """Provides a simplified RPC-style interface for HTTP requests."""
417
[email protected]fbe3d302011-03-24 17:52:23418 def _Authenticate(self):
[email protected]fb2b8eb2009-04-23 21:03:42419 """Save the cookie jar after authentication."""
[email protected]fbe3d302011-03-24 17:52:23420 super(HttpRpcServer, self)._Authenticate()
[email protected]fb2b8eb2009-04-23 21:03:42421 if self.save_cookies:
422 StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
423 self.cookie_jar.save()
424
425 def _GetOpener(self):
426 """Returns an OpenerDirector that supports cookies and ignores redirects.
427
428 Returns:
429 A urllib2.OpenerDirector object.
430 """
431 opener = urllib2.OpenerDirector()
432 opener.add_handler(urllib2.ProxyHandler())
433 opener.add_handler(urllib2.UnknownHandler())
434 opener.add_handler(urllib2.HTTPHandler())
435 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
436 opener.add_handler(urllib2.HTTPSHandler())
437 opener.add_handler(urllib2.HTTPErrorProcessor())
438 if self.save_cookies:
[email protected]b563dd32009-11-20 15:01:49439 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies")
[email protected]fb2b8eb2009-04-23 21:03:42440 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
441 if os.path.exists(self.cookie_file):
442 try:
443 self.cookie_jar.load()
444 self.authenticated = True
445 StatusUpdate("Loaded authentication cookies from %s" %
446 self.cookie_file)
447 except (cookielib.LoadError, IOError):
448 # Failed to load cookies - just ignore them.
449 pass
450 else:
451 # Create an empty cookie file with mode 600
452 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
453 os.close(fd)
454 # Always chmod the cookie file
455 os.chmod(self.cookie_file, 0600)
456 else:
457 # Don't save cookies across runs of update.py.
458 self.cookie_jar = cookielib.CookieJar()
459 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
460 return opener
461
462
[email protected]8e2bb162011-11-10 15:22:29463class CondensedHelpFormatter(optparse.IndentedHelpFormatter):
464 """Frees more horizontal space by removing indentation from group
465 options and collapsing arguments between short and long, e.g.
466 '-o ARG, --opt=ARG' to -o --opt ARG"""
467
468 def format_heading(self, heading):
469 return "%s:\n" % heading
470
471 def format_option(self, option):
472 self.dedent()
473 res = optparse.HelpFormatter.format_option(self, option)
474 self.indent()
475 return res
476
477 def format_option_strings(self, option):
478 self.set_long_opt_delimiter(" ")
479 optstr = optparse.HelpFormatter.format_option_strings(self, option)
480 optlist = optstr.split(", ")
481 if len(optlist) > 1:
482 if option.takes_value():
483 # strip METAVAR from all but the last option
484 optlist = [x.split()[0] for x in optlist[:-1]] + optlist[-1:]
485 optstr = " ".join(optlist)
486 return optstr
487
488
[email protected]9ad0cf62010-11-19 18:07:53489parser = optparse.OptionParser(
[email protected]8e2bb162011-11-10 15:22:29490 usage="%prog [options] [-- diff_options] [path...]",
491 add_help_option=False,
492 formatter=CondensedHelpFormatter()
493)
494parser.add_option("-h", "--help", action="store_true",
495 help="Show this help message and exit.")
[email protected]fb2b8eb2009-04-23 21:03:42496parser.add_option("-y", "--assume_yes", action="store_true",
497 dest="assume_yes", default=False,
498 help="Assume that the answer to yes/no questions is 'yes'.")
499# Logging
500group = parser.add_option_group("Logging options")
501group.add_option("-q", "--quiet", action="store_const", const=0,
502 dest="verbose", help="Print errors only.")
503group.add_option("-v", "--verbose", action="store_const", const=2,
504 dest="verbose", default=1,
[email protected]9ad0cf62010-11-19 18:07:53505 help="Print info level logs.")
[email protected]fb2b8eb2009-04-23 21:03:42506group.add_option("--noisy", action="store_const", const=3,
507 dest="verbose", help="Print all logs.")
[email protected]fbe3d302011-03-24 17:52:23508group.add_option("--print_diffs", dest="print_diffs", action="store_true",
509 help="Print full diffs.")
[email protected]fb2b8eb2009-04-23 21:03:42510# Review server
511group = parser.add_option_group("Review server options")
512group.add_option("-s", "--server", action="store", dest="server",
[email protected]8b7f1e72010-08-04 14:57:38513 default=DEFAULT_REVIEW_SERVER,
[email protected]fb2b8eb2009-04-23 21:03:42514 metavar="SERVER",
515 help=("The server to upload to. The format is host[:port]. "
[email protected]b563dd32009-11-20 15:01:49516 "Defaults to '%default'."))
[email protected]fb2b8eb2009-04-23 21:03:42517group.add_option("-e", "--email", action="store", dest="email",
518 metavar="EMAIL", default=None,
519 help="The username to use. Will prompt if omitted.")
520group.add_option("-H", "--host", action="store", dest="host",
521 metavar="HOST", default=None,
522 help="Overrides the Host header sent with all RPCs.")
523group.add_option("--no_cookies", action="store_false",
524 dest="save_cookies", default=True,
525 help="Do not save authentication cookies to local disk.")
[email protected]8b7f1e72010-08-04 14:57:38526group.add_option("--account_type", action="store", dest="account_type",
527 metavar="TYPE", default=AUTH_ACCOUNT_TYPE,
528 choices=["GOOGLE", "HOSTED"],
529 help=("Override the default account type "
530 "(defaults to '%default', "
531 "valid choices are 'GOOGLE' and 'HOSTED')."))
[email protected]fb2b8eb2009-04-23 21:03:42532# Issue
533group = parser.add_option_group("Issue options")
[email protected]71e12a92012-02-14 02:34:15534group.add_option("-t", "--title", action="store", dest="title",
535 help="New issue subject or new patch set title")
536group.add_option("-m", "--message", action="store", dest="message",
[email protected]fb2b8eb2009-04-23 21:03:42537 default=None,
[email protected]71e12a92012-02-14 02:34:15538 help="New issue description or new patch set message")
539group.add_option("-F", "--file", action="store", dest="file",
540 default=None, help="Read the message above from file.")
[email protected]fb2b8eb2009-04-23 21:03:42541group.add_option("-r", "--reviewers", action="store", dest="reviewers",
542 metavar="REVIEWERS", default=None,
543 help="Add reviewers (comma separated email addresses).")
544group.add_option("--cc", action="store", dest="cc",
545 metavar="CC", default=None,
546 help="Add CC (comma separated email addresses).")
[email protected]b563dd32009-11-20 15:01:49547group.add_option("--private", action="store_true", dest="private",
548 default=False,
549 help="Make the issue restricted to reviewers and those CCed")
[email protected]fb2b8eb2009-04-23 21:03:42550# Upload options
551group = parser.add_option_group("Patch options")
[email protected]fb2b8eb2009-04-23 21:03:42552group.add_option("-i", "--issue", type="int", action="store",
553 metavar="ISSUE", default=None,
554 help="Issue number to which to add. Defaults to new issue.")
[email protected]c97bfc62010-05-11 19:15:12555group.add_option("--base_url", action="store", dest="base_url", default=None,
[email protected]8e2bb162011-11-10 15:22:29556 help="Base URL path for files (listed as \"Base URL\" when "
[email protected]c97bfc62010-05-11 19:15:12557 "viewing issue). If omitted, will be guessed automatically "
558 "for SVN repos and left blank for others.")
[email protected]fb2b8eb2009-04-23 21:03:42559group.add_option("--download_base", action="store_true",
560 dest="download_base", default=False,
561 help="Base files will be downloaded by the server "
562 "(side-by-side diffs may not work on files with CRs).")
563group.add_option("--rev", action="store", dest="revision",
564 metavar="REV", default=None,
[email protected]c97bfc62010-05-11 19:15:12565 help="Base revision/branch/tree to diff against. Use "
566 "rev1:rev2 range to review already committed changeset.")
[email protected]fb2b8eb2009-04-23 21:03:42567group.add_option("--send_mail", action="store_true",
568 dest="send_mail", default=False,
569 help="Send notification email to reviewers.")
[email protected]b615eda2011-08-23 13:37:40570group.add_option("-p", "--send_patch", action="store_true",
571 dest="send_patch", default=False,
[email protected]8e2bb162011-11-10 15:22:29572 help="Same as --send_mail, but include diff as an "
573 "attachment, and prepend email subject with 'PATCH:'.")
[email protected]b563dd32009-11-20 15:01:49574group.add_option("--vcs", action="store", dest="vcs",
575 metavar="VCS", default=None,
576 help=("Version control system (optional, usually upload.py "
577 "already guesses the right VCS)."))
[email protected]c97bfc62010-05-11 19:15:12578group.add_option("--emulate_svn_auto_props", action="store_true",
579 dest="emulate_svn_auto_props", default=False,
580 help=("Emulate Subversion's auto properties feature."))
[email protected]fbe3d302011-03-24 17:52:23581# Perforce-specific
582group = parser.add_option_group("Perforce-specific options "
583 "(overrides P4 environment variables)")
584group.add_option("--p4_port", action="store", dest="p4_port",
585 metavar="P4_PORT", default=None,
586 help=("Perforce server and port (optional)"))
587group.add_option("--p4_changelist", action="store", dest="p4_changelist",
588 metavar="P4_CHANGELIST", default=None,
589 help=("Perforce changelist id"))
590group.add_option("--p4_client", action="store", dest="p4_client",
591 metavar="P4_CLIENT", default=None,
592 help=("Perforce client/workspace"))
593group.add_option("--p4_user", action="store", dest="p4_user",
594 metavar="P4_USER", default=None,
595 help=("Perforce user"))
[email protected]fb2b8eb2009-04-23 21:03:42596
[email protected]8b7f1e72010-08-04 14:57:38597def GetRpcServer(server, email=None, host_override=None, save_cookies=True,
598 account_type=AUTH_ACCOUNT_TYPE):
[email protected]fb2b8eb2009-04-23 21:03:42599 """Returns an instance of an AbstractRpcServer.
600
[email protected]c97bfc62010-05-11 19:15:12601 Args:
602 server: String containing the review server URL.
603 email: String containing user's email address.
604 host_override: If not None, string containing an alternate hostname to use
605 in the host header.
606 save_cookies: Whether authentication cookies should be saved to disk.
[email protected]8b7f1e72010-08-04 14:57:38607 account_type: Account type for authentication, either 'GOOGLE'
608 or 'HOSTED'. Defaults to AUTH_ACCOUNT_TYPE.
[email protected]c97bfc62010-05-11 19:15:12609
[email protected]fb2b8eb2009-04-23 21:03:42610 Returns:
611 A new AbstractRpcServer, on which RPC calls can be made.
612 """
613
614 rpc_server_class = HttpRpcServer
615
[email protected]fb2b8eb2009-04-23 21:03:42616 # If this is the dev_appserver, use fake authentication.
[email protected]c97bfc62010-05-11 19:15:12617 host = (host_override or server).lower()
[email protected]9ad0cf62010-11-19 18:07:53618 if re.match(r'(http://)?localhost([:/]|$)', host):
[email protected]fb2b8eb2009-04-23 21:03:42619 if email is None:
620 email = "[email protected]"
621 logging.info("Using debug user %s. Override with --email" % email)
622 server = rpc_server_class(
[email protected]c97bfc62010-05-11 19:15:12623 server,
[email protected]fb2b8eb2009-04-23 21:03:42624 lambda: (email, "password"),
[email protected]c97bfc62010-05-11 19:15:12625 host_override=host_override,
[email protected]fb2b8eb2009-04-23 21:03:42626 extra_headers={"Cookie":
627 'dev_appserver_login="%s:False"' % email},
[email protected]8b7f1e72010-08-04 14:57:38628 save_cookies=save_cookies,
629 account_type=account_type)
[email protected]fb2b8eb2009-04-23 21:03:42630 # Don't try to talk to ClientLogin.
631 server.authenticated = True
632 return server
633
[email protected]c97bfc62010-05-11 19:15:12634 def GetUserCredentials():
635 """Prompts the user for a username and password."""
636 # Create a local alias to the email variable to avoid Python's crazy
637 # scoping rules.
[email protected]4f6d25c2011-08-23 20:34:11638 global keyring
[email protected]c97bfc62010-05-11 19:15:12639 local_email = email
640 if local_email is None:
641 local_email = GetEmail("Email (login for uploading to %s)" % server)
[email protected]8b7f1e72010-08-04 14:57:38642 password = None
643 if keyring:
[email protected]4f6d25c2011-08-23 20:34:11644 try:
645 password = keyring.get_password(host, local_email)
646 except:
647 # Sadly, we have to trap all errors here as
648 # gnomekeyring.IOError inherits from object. :/
649 print "Failed to get password from keyring"
650 keyring = None
[email protected]8b7f1e72010-08-04 14:57:38651 if password is not None:
652 print "Using password from system keyring."
653 else:
654 password = getpass.getpass("Password for %s: " % local_email)
655 if keyring:
656 answer = raw_input("Store password in system keyring?(y/N) ").strip()
657 if answer == "y":
658 keyring.set_password(host, local_email, password)
[email protected]c97bfc62010-05-11 19:15:12659 return (local_email, password)
660
661 return rpc_server_class(server,
662 GetUserCredentials,
663 host_override=host_override,
664 save_cookies=save_cookies)
[email protected]fb2b8eb2009-04-23 21:03:42665
666
667def EncodeMultipartFormData(fields, files):
668 """Encode form fields for multipart/form-data.
669
670 Args:
671 fields: A sequence of (name, value) elements for regular form fields.
672 files: A sequence of (name, filename, value) elements for data to be
673 uploaded as files.
674 Returns:
675 (content_type, body) ready for httplib.HTTP instance.
676
677 Source:
678 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
679 """
680 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
681 CRLF = '\r\n'
682 lines = []
683 for (key, value) in fields:
684 lines.append('--' + BOUNDARY)
685 lines.append('Content-Disposition: form-data; name="%s"' % key)
686 lines.append('')
[email protected]8b7f1e72010-08-04 14:57:38687 if isinstance(value, unicode):
688 value = value.encode('utf-8')
[email protected]fb2b8eb2009-04-23 21:03:42689 lines.append(value)
690 for (key, filename, value) in files:
691 lines.append('--' + BOUNDARY)
692 lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' %
693 (key, filename))
694 lines.append('Content-Type: %s' % GetContentType(filename))
695 lines.append('')
[email protected]8b7f1e72010-08-04 14:57:38696 if isinstance(value, unicode):
697 value = value.encode('utf-8')
[email protected]fb2b8eb2009-04-23 21:03:42698 lines.append(value)
699 lines.append('--' + BOUNDARY + '--')
700 lines.append('')
701 body = CRLF.join(lines)
702 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
703 return content_type, body
704
705
706def GetContentType(filename):
707 """Helper to guess the content-type from the filename."""
708 return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
709
710
711# Use a shell for subcommands on Windows to get a PATH search.
712use_shell = sys.platform.startswith("win")
713
[email protected]1c5518e2011-04-21 16:38:15714def RunShellWithReturnCodeAndStderr(command, print_output=False,
[email protected]b563dd32009-11-20 15:01:49715 universal_newlines=True,
716 env=os.environ):
[email protected]1c5518e2011-04-21 16:38:15717 """Executes a command and returns the output from stdout, stderr and the return code.
[email protected]fb2b8eb2009-04-23 21:03:42718
719 Args:
720 command: Command to execute.
721 print_output: If True, the output is printed to stdout.
722 If False, both stdout and stderr are ignored.
723 universal_newlines: Use universal_newlines flag (default: True).
724
725 Returns:
[email protected]1c5518e2011-04-21 16:38:15726 Tuple (stdout, stderr, return code)
[email protected]fb2b8eb2009-04-23 21:03:42727 """
728 logging.info("Running %s", command)
[email protected]1c5518e2011-04-21 16:38:15729 env = env.copy()
730 env['LC_MESSAGES'] = 'C'
[email protected]fb2b8eb2009-04-23 21:03:42731 p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
[email protected]b563dd32009-11-20 15:01:49732 shell=use_shell, universal_newlines=universal_newlines,
733 env=env)
[email protected]fb2b8eb2009-04-23 21:03:42734 if print_output:
735 output_array = []
736 while True:
737 line = p.stdout.readline()
738 if not line:
739 break
740 print line.strip("\n")
741 output_array.append(line)
742 output = "".join(output_array)
743 else:
744 output = p.stdout.read()
745 p.wait()
746 errout = p.stderr.read()
747 if print_output and errout:
748 print >>sys.stderr, errout
749 p.stdout.close()
750 p.stderr.close()
[email protected]1c5518e2011-04-21 16:38:15751 return output, errout, p.returncode
[email protected]fb2b8eb2009-04-23 21:03:42752
[email protected]1c5518e2011-04-21 16:38:15753def RunShellWithReturnCode(command, print_output=False,
754 universal_newlines=True,
755 env=os.environ):
756 """Executes a command and returns the output from stdout and the return code."""
757 out, err, retcode = RunShellWithReturnCodeAndStderr(command, print_output,
758 universal_newlines, env)
759 return out, retcode
[email protected]fb2b8eb2009-04-23 21:03:42760
761def RunShell(command, silent_ok=False, universal_newlines=True,
[email protected]b563dd32009-11-20 15:01:49762 print_output=False, env=os.environ):
[email protected]fb2b8eb2009-04-23 21:03:42763 data, retcode = RunShellWithReturnCode(command, print_output,
[email protected]b563dd32009-11-20 15:01:49764 universal_newlines, env)
[email protected]fb2b8eb2009-04-23 21:03:42765 if retcode:
766 ErrorExit("Got error status from %s:\n%s" % (command, data))
767 if not silent_ok and not data:
768 ErrorExit("No output from %s" % command)
769 return data
770
771
772class VersionControlSystem(object):
773 """Abstract base class providing an interface to the VCS."""
774
775 def __init__(self, options):
776 """Constructor.
777
778 Args:
779 options: Command line options.
780 """
781 self.options = options
[email protected]71e12a92012-02-14 02:34:15782
[email protected]8e2bb162011-11-10 15:22:29783 def GetGUID(self):
784 """Return string to distinguish the repository from others, for example to
785 query all opened review issues for it"""
786 raise NotImplementedError(
787 "abstract method -- subclass %s must override" % self.__class__)
[email protected]fb2b8eb2009-04-23 21:03:42788
[email protected]c97bfc62010-05-11 19:15:12789 def PostProcessDiff(self, diff):
790 """Return the diff with any special post processing this VCS needs, e.g.
791 to include an svn-style "Index:"."""
792 return diff
793
[email protected]fb2b8eb2009-04-23 21:03:42794 def GenerateDiff(self, args):
795 """Return the current diff as a string.
796
797 Args:
798 args: Extra arguments to pass to the diff command.
799 """
800 raise NotImplementedError(
801 "abstract method -- subclass %s must override" % self.__class__)
802
803 def GetUnknownFiles(self):
804 """Return a list of files unknown to the VCS."""
805 raise NotImplementedError(
806 "abstract method -- subclass %s must override" % self.__class__)
807
808 def CheckForUnknownFiles(self):
809 """Show an "are you sure?" prompt if there are unknown files."""
810 unknown_files = self.GetUnknownFiles()
811 if unknown_files:
812 print "The following files are not added to version control:"
813 for line in unknown_files:
814 print line
815 prompt = "Are you sure to continue?(y/N) "
816 answer = raw_input(prompt).strip()
817 if answer != "y":
818 ErrorExit("User aborted")
819
820 def GetBaseFile(self, filename):
821 """Get the content of the upstream version of a file.
822
823 Returns:
824 A tuple (base_content, new_content, is_binary, status)
825 base_content: The contents of the base file.
826 new_content: For text files, this is empty. For binary files, this is
827 the contents of the new file, since the diff output won't contain
828 information to reconstruct the current file.
829 is_binary: True iff the file is binary.
830 status: The status of the file.
831 """
832
833 raise NotImplementedError(
834 "abstract method -- subclass %s must override" % self.__class__)
835
836
837 def GetBaseFiles(self, diff):
838 """Helper that calls GetBase file for each file in the patch.
839
840 Returns:
841 A dictionary that maps from filename to GetBaseFile's tuple. Filenames
842 are retrieved based on lines that start with "Index:" or
843 "Property changes on:".
844 """
845 files = {}
846 for line in diff.splitlines(True):
847 if line.startswith('Index:') or line.startswith('Property changes on:'):
848 unused, filename = line.split(':', 1)
849 # On Windows if a file has property changes its filename uses '\'
850 # instead of '/'.
851 filename = filename.strip().replace('\\', '/')
852 files[filename] = self.GetBaseFile(filename)
853 return files
854
855
856 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
857 files):
858 """Uploads the base files (and if necessary, the current ones as well)."""
859
860 def UploadFile(filename, file_id, content, is_binary, status, is_base):
861 """Uploads a file to the server."""
862 file_too_large = False
863 if is_base:
864 type = "base"
865 else:
866 type = "current"
867 if len(content) > MAX_UPLOAD_SIZE:
868 print ("Not uploading the %s file for %s because it's too large." %
869 (type, filename))
870 file_too_large = True
871 content = ""
[email protected]d971f7f2009-05-01 12:51:23872 checksum = md5(content).hexdigest()
[email protected]fb2b8eb2009-04-23 21:03:42873 if options.verbose > 0 and not file_too_large:
874 print "Uploading %s file for %s" % (type, filename)
875 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
876 form_fields = [("filename", filename),
877 ("status", status),
878 ("checksum", checksum),
879 ("is_binary", str(is_binary)),
880 ("is_current", str(not is_base)),
881 ]
882 if file_too_large:
883 form_fields.append(("file_too_large", "1"))
884 if options.email:
885 form_fields.append(("user", options.email))
886 ctype, body = EncodeMultipartFormData(form_fields,
887 [("data", filename, content)])
888 response_body = rpc_server.Send(url, body,
889 content_type=ctype)
890 if not response_body.startswith("OK"):
891 StatusUpdate(" --> %s" % response_body)
892 sys.exit(1)
893
894 patches = dict()
895 [patches.setdefault(v, k) for k, v in patch_list]
896 for filename in patches.keys():
897 base_content, new_content, is_binary, status = files[filename]
898 file_id_str = patches.get(filename)
899 if file_id_str.find("nobase") != -1:
900 base_content = None
901 file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
902 file_id = int(file_id_str)
903 if base_content != None:
904 UploadFile(filename, file_id, base_content, is_binary, status, True)
905 if new_content != None:
906 UploadFile(filename, file_id, new_content, is_binary, status, False)
907
908 def IsImage(self, filename):
909 """Returns true if the filename has an image extension."""
910 mimetype = mimetypes.guess_type(filename)[0]
911 if not mimetype:
912 return False
913 return mimetype.startswith("image/")
914
[email protected]b615eda2011-08-23 13:37:40915 def IsBinaryData(self, data):
916 """Returns true if data contains a null byte."""
917 # Derived from how Mercurial's heuristic, see
918 # http://selenic.com/hg/file/848a6658069e/mercurial/util.py#l229
919 return bool(data and "\0" in data)
[email protected]b563dd32009-11-20 15:01:49920
[email protected]fb2b8eb2009-04-23 21:03:42921
922class SubversionVCS(VersionControlSystem):
923 """Implementation of the VersionControlSystem interface for Subversion."""
924
925 def __init__(self, options):
926 super(SubversionVCS, self).__init__(options)
927 if self.options.revision:
928 match = re.match(r"(\d+)(:(\d+))?", self.options.revision)
929 if not match:
930 ErrorExit("Invalid Subversion revision %s." % self.options.revision)
931 self.rev_start = match.group(1)
932 self.rev_end = match.group(3)
933 else:
934 self.rev_start = self.rev_end = None
935 # Cache output from "svn list -r REVNO dirname".
936 # Keys: dirname, Values: 2-tuple (ouput for start rev and end rev).
937 self.svnls_cache = {}
[email protected]c97bfc62010-05-11 19:15:12938 # Base URL is required to fetch files deleted in an older revision.
[email protected]fb2b8eb2009-04-23 21:03:42939 # Result is cached to not guess it over and over again in GetBaseFile().
940 required = self.options.download_base or self.options.revision is not None
941 self.svn_base = self._GuessBase(required)
[email protected]71e12a92012-02-14 02:34:15942
[email protected]8e2bb162011-11-10 15:22:29943 def GetGUID(self):
944 return self._GetInfo("Repository UUID")
[email protected]fb2b8eb2009-04-23 21:03:42945
946 def GuessBase(self, required):
947 """Wrapper for _GuessBase."""
948 return self.svn_base
949
950 def _GuessBase(self, required):
[email protected]9ad0cf62010-11-19 18:07:53951 """Returns base URL for current diff.
[email protected]fb2b8eb2009-04-23 21:03:42952
953 Args:
954 required: If true, exits if the url can't be guessed, otherwise None is
955 returned.
956 """
[email protected]8e2bb162011-11-10 15:22:29957 url = self._GetInfo("URL")
958 if url:
[email protected]fb2b8eb2009-04-23 21:03:42959 scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
[email protected]9ad0cf62010-11-19 18:07:53960 guess = ""
[email protected]8e2bb162011-11-10 15:22:29961 # TODO(anatoli) - repository specific hacks should be handled by server
[email protected]9ad0cf62010-11-19 18:07:53962 if netloc == "svn.python.org" and scheme == "svn+ssh":
963 path = "projects" + path
964 scheme = "http"
965 guess = "Python "
[email protected]fb2b8eb2009-04-23 21:03:42966 elif netloc.endswith(".googlecode.com"):
[email protected]9ad0cf62010-11-19 18:07:53967 scheme = "http"
968 guess = "Google Code "
969 path = path + "/"
970 base = urlparse.urlunparse((scheme, netloc, path, params,
971 query, fragment))
972 logging.info("Guessed %sbase = %s", guess, base)
[email protected]fb2b8eb2009-04-23 21:03:42973 return base
974 if required:
975 ErrorExit("Can't find URL in output from svn info")
976 return None
[email protected]71e12a92012-02-14 02:34:15977
[email protected]8e2bb162011-11-10 15:22:29978 def _GetInfo(self, key):
979 """Parses 'svn info' for current dir. Returns value for key or None"""
980 for line in RunShell(["svn", "info"]).splitlines():
981 if line.startswith(key + ": "):
982 return line.split(":", 1)[1].strip()
[email protected]fb2b8eb2009-04-23 21:03:42983
[email protected]b615eda2011-08-23 13:37:40984 def _EscapeFilename(self, filename):
985 """Escapes filename for SVN commands."""
986 if "@" in filename and not filename.endswith("@"):
987 filename = "%s@" % filename
988 return filename
989
[email protected]fb2b8eb2009-04-23 21:03:42990 def GenerateDiff(self, args):
991 cmd = ["svn", "diff"]
992 if self.options.revision:
993 cmd += ["-r", self.options.revision]
994 cmd.extend(args)
995 data = RunShell(cmd)
996 count = 0
997 for line in data.splitlines():
998 if line.startswith("Index:") or line.startswith("Property changes on:"):
999 count += 1
1000 logging.info(line)
1001 if not count:
1002 ErrorExit("No valid patches found in output from svn diff")
1003 return data
1004
1005 def _CollapseKeywords(self, content, keyword_str):
1006 """Collapses SVN keywords."""
1007 # svn cat translates keywords but svn diff doesn't. As a result of this
1008 # behavior patching.PatchChunks() fails with a chunk mismatch error.
1009 # This part was originally written by the Review Board development team
1010 # who had the same problem (http://reviews.review-board.org/r/276/).
1011 # Mapping of keywords to known aliases
1012 svn_keywords = {
1013 # Standard keywords
1014 'Date': ['Date', 'LastChangedDate'],
1015 'Revision': ['Revision', 'LastChangedRevision', 'Rev'],
1016 'Author': ['Author', 'LastChangedBy'],
1017 'HeadURL': ['HeadURL', 'URL'],
1018 'Id': ['Id'],
1019
1020 # Aliases
1021 'LastChangedDate': ['LastChangedDate', 'Date'],
1022 'LastChangedRevision': ['LastChangedRevision', 'Rev', 'Revision'],
1023 'LastChangedBy': ['LastChangedBy', 'Author'],
1024 'URL': ['URL', 'HeadURL'],
1025 }
1026
1027 def repl(m):
1028 if m.group(2):
1029 return "$%s::%s$" % (m.group(1), " " * len(m.group(3)))
1030 return "$%s$" % m.group(1)
1031 keywords = [keyword
1032 for name in keyword_str.split(" ")
1033 for keyword in svn_keywords.get(name, [])]
1034 return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content)
1035
1036 def GetUnknownFiles(self):
1037 status = RunShell(["svn", "status", "--ignore-externals"], silent_ok=True)
1038 unknown_files = []
1039 for line in status.split("\n"):
1040 if line and line[0] == "?":
1041 unknown_files.append(line)
1042 return unknown_files
1043
1044 def ReadFile(self, filename):
1045 """Returns the contents of a file."""
1046 file = open(filename, 'rb')
1047 result = ""
1048 try:
1049 result = file.read()
1050 finally:
1051 file.close()
1052 return result
1053
1054 def GetStatus(self, filename):
1055 """Returns the status of a file."""
1056 if not self.options.revision:
[email protected]b615eda2011-08-23 13:37:401057 status = RunShell(["svn", "status", "--ignore-externals",
1058 self._EscapeFilename(filename)])
[email protected]fb2b8eb2009-04-23 21:03:421059 if not status:
1060 ErrorExit("svn status returned no output for %s" % filename)
1061 status_lines = status.splitlines()
1062 # If file is in a cl, the output will begin with
1063 # "\n--- Changelist 'cl_name':\n". See
1064 # http://svn.collab.net/repos/svn/trunk/notes/changelist-design.txt
1065 if (len(status_lines) == 3 and
1066 not status_lines[0] and
1067 status_lines[1].startswith("--- Changelist")):
1068 status = status_lines[2]
1069 else:
1070 status = status_lines[0]
1071 # If we have a revision to diff against we need to run "svn list"
1072 # for the old and the new revision and compare the results to get
1073 # the correct status for a file.
1074 else:
1075 dirname, relfilename = os.path.split(filename)
1076 if dirname not in self.svnls_cache:
[email protected]b615eda2011-08-23 13:37:401077 cmd = ["svn", "list", "-r", self.rev_start,
1078 self._EscapeFilename(dirname) or "."]
[email protected]1c5518e2011-04-21 16:38:151079 out, err, returncode = RunShellWithReturnCodeAndStderr(cmd)
[email protected]fb2b8eb2009-04-23 21:03:421080 if returncode:
[email protected]1c5518e2011-04-21 16:38:151081 # Directory might not yet exist at start revison
1082 # svn: Unable to find repository location for 'abc' in revision nnn
1083 if re.match('^svn: Unable to find repository location for .+ in revision \d+', err):
1084 old_files = ()
1085 else:
1086 ErrorExit("Failed to get status for %s:\n%s" % (filename, err))
1087 else:
1088 old_files = out.splitlines()
[email protected]fb2b8eb2009-04-23 21:03:421089 args = ["svn", "list"]
1090 if self.rev_end:
1091 args += ["-r", self.rev_end]
[email protected]b615eda2011-08-23 13:37:401092 cmd = args + [self._EscapeFilename(dirname) or "."]
[email protected]fb2b8eb2009-04-23 21:03:421093 out, returncode = RunShellWithReturnCode(cmd)
1094 if returncode:
1095 ErrorExit("Failed to run command %s" % cmd)
1096 self.svnls_cache[dirname] = (old_files, out.splitlines())
1097 old_files, new_files = self.svnls_cache[dirname]
1098 if relfilename in old_files and relfilename not in new_files:
1099 status = "D "
1100 elif relfilename in old_files and relfilename in new_files:
1101 status = "M "
1102 else:
1103 status = "A "
1104 return status
1105
1106 def GetBaseFile(self, filename):
1107 status = self.GetStatus(filename)
1108 base_content = None
1109 new_content = None
1110
1111 # If a file is copied its status will be "A +", which signifies
1112 # "addition-with-history". See "svn st" for more information. We need to
1113 # upload the original file or else diff parsing will fail if the file was
1114 # edited.
1115 if status[0] == "A" and status[3] != "+":
1116 # We'll need to upload the new content if we're adding a binary file
1117 # since diff's output won't contain it.
[email protected]b615eda2011-08-23 13:37:401118 mimetype = RunShell(["svn", "propget", "svn:mime-type",
1119 self._EscapeFilename(filename)], silent_ok=True)
[email protected]fb2b8eb2009-04-23 21:03:421120 base_content = ""
[email protected]b563dd32009-11-20 15:01:491121 is_binary = bool(mimetype) and not mimetype.startswith("text/")
[email protected]f0526412012-05-23 13:51:181122 if is_binary:
[email protected]fb2b8eb2009-04-23 21:03:421123 new_content = self.ReadFile(filename)
1124 elif (status[0] in ("M", "D", "R") or
1125 (status[0] == "A" and status[3] == "+") or # Copied file.
1126 (status[0] == " " and status[1] == "M")): # Property change.
1127 args = []
1128 if self.options.revision:
[email protected]b615eda2011-08-23 13:37:401129 # filename must not be escaped. We already add an ampersand here.
[email protected]fb2b8eb2009-04-23 21:03:421130 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
1131 else:
1132 # Don't change filename, it's needed later.
1133 url = filename
1134 args += ["-r", "BASE"]
1135 cmd = ["svn"] + args + ["propget", "svn:mime-type", url]
1136 mimetype, returncode = RunShellWithReturnCode(cmd)
1137 if returncode:
1138 # File does not exist in the requested revision.
1139 # Reset mimetype, it contains an error message.
1140 mimetype = ""
[email protected]fbe3d302011-03-24 17:52:231141 else:
1142 mimetype = mimetype.strip()
[email protected]fb2b8eb2009-04-23 21:03:421143 get_base = False
[email protected]b615eda2011-08-23 13:37:401144 # this test for binary is exactly the test prescribed by the
1145 # official SVN docs at
1146 # http://subversion.apache.org/faq.html#binary-files
[email protected]fbe3d302011-03-24 17:52:231147 is_binary = (bool(mimetype) and
1148 not mimetype.startswith("text/") and
[email protected]b615eda2011-08-23 13:37:401149 mimetype not in ("image/x-xbitmap", "image/x-xpixmap"))
[email protected]fb2b8eb2009-04-23 21:03:421150 if status[0] == " ":
1151 # Empty base content just to force an upload.
1152 base_content = ""
1153 elif is_binary:
[email protected]f0526412012-05-23 13:51:181154 get_base = True
1155 if status[0] == "M":
1156 if not self.rev_end:
1157 new_content = self.ReadFile(filename)
1158 else:
1159 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_end)
1160 new_content = RunShell(["svn", "cat", url],
1161 universal_newlines=True, silent_ok=True)
[email protected]fb2b8eb2009-04-23 21:03:421162 else:
1163 get_base = True
1164
1165 if get_base:
1166 if is_binary:
1167 universal_newlines = False
1168 else:
1169 universal_newlines = True
1170 if self.rev_start:
1171 # "svn cat -r REV delete_file.txt" doesn't work. cat requires
1172 # the full URL with "@REV" appended instead of using "-r" option.
1173 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
1174 base_content = RunShell(["svn", "cat", url],
1175 universal_newlines=universal_newlines,
1176 silent_ok=True)
1177 else:
[email protected]8b7f1e72010-08-04 14:57:381178 base_content, ret_code = RunShellWithReturnCode(
[email protected]b615eda2011-08-23 13:37:401179 ["svn", "cat", self._EscapeFilename(filename)],
1180 universal_newlines=universal_newlines)
[email protected]8b7f1e72010-08-04 14:57:381181 if ret_code and status[0] == "R":
1182 # It's a replaced file without local history (see issue208).
1183 # The base file needs to be fetched from the server.
1184 url = "%s/%s" % (self.svn_base, filename)
1185 base_content = RunShell(["svn", "cat", url],
1186 universal_newlines=universal_newlines,
1187 silent_ok=True)
1188 elif ret_code:
[email protected]31485692010-08-16 16:43:221189 ErrorExit("Got error status from 'svn cat %s'" % filename)
[email protected]fb2b8eb2009-04-23 21:03:421190 if not is_binary:
1191 args = []
1192 if self.rev_start:
1193 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
1194 else:
1195 url = filename
1196 args += ["-r", "BASE"]
1197 cmd = ["svn"] + args + ["propget", "svn:keywords", url]
1198 keywords, returncode = RunShellWithReturnCode(cmd)
1199 if keywords and not returncode:
1200 base_content = self._CollapseKeywords(base_content, keywords)
1201 else:
1202 StatusUpdate("svn status returned unexpected output: %s" % status)
1203 sys.exit(1)
1204 return base_content, new_content, is_binary, status[0:5]
1205
1206
1207class GitVCS(VersionControlSystem):
1208 """Implementation of the VersionControlSystem interface for Git."""
1209
1210 def __init__(self, options):
1211 super(GitVCS, self).__init__(options)
[email protected]b563dd32009-11-20 15:01:491212 # Map of filename -> (hash before, hash after) of base file.
1213 # Hashes for "no such file" are represented as None.
1214 self.hashes = {}
1215 # Map of new filename -> old filename for renames.
1216 self.renames = {}
[email protected]71e12a92012-02-14 02:34:151217
[email protected]8e2bb162011-11-10 15:22:291218 def GetGUID(self):
1219 revlist = RunShell("git rev-list --parents HEAD".split()).splitlines()
1220 # M-A: Return the 1st root hash, there could be multiple when a
1221 # subtree is merged. In that case, more analysis would need to
1222 # be done to figure out which HEAD is the 'most representative'.
1223 for r in revlist:
1224 if ' ' not in r:
1225 return r
[email protected]fb2b8eb2009-04-23 21:03:421226
[email protected]c97bfc62010-05-11 19:15:121227 def PostProcessDiff(self, gitdiff):
1228 """Converts the diff output to include an svn-style "Index:" line as well
1229 as record the hashes of the files, so we can upload them along with our
1230 diff."""
[email protected]b563dd32009-11-20 15:01:491231 # Special used by git to indicate "no such content".
1232 NULL_HASH = "0"*40
1233
[email protected]c97bfc62010-05-11 19:15:121234 def IsFileNew(filename):
1235 return filename in self.hashes and self.hashes[filename][0] is None
[email protected]b563dd32009-11-20 15:01:491236
[email protected]c97bfc62010-05-11 19:15:121237 def AddSubversionPropertyChange(filename):
1238 """Add svn's property change information into the patch if given file is
1239 new file.
1240
1241 We use Subversion's auto-props setting to retrieve its property.
1242 See http://svnbook.red-bean.com/en/1.1/ch07.html#svn-ch-7-sect-1.3.2 for
1243 Subversion's [auto-props] setting.
1244 """
1245 if self.options.emulate_svn_auto_props and IsFileNew(filename):
1246 svnprops = GetSubversionPropertyChanges(filename)
1247 if svnprops:
1248 svndiff.append("\n" + svnprops + "\n")
1249
[email protected]fb2b8eb2009-04-23 21:03:421250 svndiff = []
1251 filecount = 0
1252 filename = None
1253 for line in gitdiff.splitlines():
[email protected]b563dd32009-11-20 15:01:491254 match = re.match(r"diff --git a/(.*) b/(.*)$", line)
[email protected]fb2b8eb2009-04-23 21:03:421255 if match:
[email protected]c97bfc62010-05-11 19:15:121256 # Add auto property here for previously seen file.
1257 if filename is not None:
1258 AddSubversionPropertyChange(filename)
[email protected]fb2b8eb2009-04-23 21:03:421259 filecount += 1
[email protected]b563dd32009-11-20 15:01:491260 # Intentionally use the "after" filename so we can show renames.
1261 filename = match.group(2)
[email protected]fb2b8eb2009-04-23 21:03:421262 svndiff.append("Index: %s\n" % filename)
[email protected]b563dd32009-11-20 15:01:491263 if match.group(1) != match.group(2):
1264 self.renames[match.group(2)] = match.group(1)
[email protected]fb2b8eb2009-04-23 21:03:421265 else:
1266 # The "index" line in a git diff looks like this (long hashes elided):
1267 # index 82c0d44..b2cee3f 100755
1268 # We want to save the left hash, as that identifies the base file.
[email protected]b563dd32009-11-20 15:01:491269 match = re.match(r"index (\w+)\.\.(\w+)", line)
[email protected]fb2b8eb2009-04-23 21:03:421270 if match:
[email protected]b563dd32009-11-20 15:01:491271 before, after = (match.group(1), match.group(2))
1272 if before == NULL_HASH:
1273 before = None
1274 if after == NULL_HASH:
1275 after = None
1276 self.hashes[filename] = (before, after)
[email protected]fb2b8eb2009-04-23 21:03:421277 svndiff.append(line + "\n")
1278 if not filecount:
1279 ErrorExit("No valid patches found in output from git diff")
[email protected]c97bfc62010-05-11 19:15:121280 # Add auto property for the last seen file.
1281 assert filename is not None
1282 AddSubversionPropertyChange(filename)
[email protected]fb2b8eb2009-04-23 21:03:421283 return "".join(svndiff)
1284
[email protected]c97bfc62010-05-11 19:15:121285 def GenerateDiff(self, extra_args):
1286 extra_args = extra_args[:]
1287 if self.options.revision:
[email protected]9ad0cf62010-11-19 18:07:531288 if ":" in self.options.revision:
1289 extra_args = self.options.revision.split(":", 1) + extra_args
1290 else:
1291 extra_args = [self.options.revision] + extra_args
[email protected]c97bfc62010-05-11 19:15:121292
1293 # --no-ext-diff is broken in some versions of Git, so try to work around
1294 # this by overriding the environment (but there is still a problem if the
1295 # git config key "diff.external" is used).
1296 env = os.environ.copy()
1297 if 'GIT_EXTERNAL_DIFF' in env: del env['GIT_EXTERNAL_DIFF']
[email protected]04d22592011-05-05 17:24:201298 # -M/-C will not print the diff for the deleted file when a file is renamed.
1299 # This is confusing because the original file will not be shown on the
1300 # review when a file is renamed. So first get the diff of all deleted files,
1301 # then the diff of everything except deleted files with rename and copy
1302 # support enabled.
[email protected]b615eda2011-08-23 13:37:401303 cmd = [
[email protected]f029dcf2012-06-12 17:22:571304 "git", "diff", "--no-color", "--no-ext-diff", "--full-index",
1305 "--ignore-submodules",
[email protected]b615eda2011-08-23 13:37:401306 ]
[email protected]f029dcf2012-06-12 17:22:571307 diff = RunShell(
1308 cmd + ["--diff-filter=D"] + extra_args, env=env, silent_ok=True)
1309 diff += RunShell(
1310 cmd + ["--find-copies-harder", "--diff-filter=ACMRT"] + extra_args,
1311 env=env, silent_ok=True)
1312 # The CL could be only file deletion or not. So accept silent diff for both
1313 # commands then check for an empty diff manually.
[email protected]04d22592011-05-05 17:24:201314 if not diff:
1315 ErrorExit("No output from %s" % (cmd + extra_args))
1316 return diff
[email protected]c97bfc62010-05-11 19:15:121317
[email protected]fb2b8eb2009-04-23 21:03:421318 def GetUnknownFiles(self):
1319 status = RunShell(["git", "ls-files", "--exclude-standard", "--others"],
1320 silent_ok=True)
1321 return status.splitlines()
1322
[email protected]b563dd32009-11-20 15:01:491323 def GetFileContent(self, file_hash, is_binary):
1324 """Returns the content of a file identified by its git hash."""
1325 data, retcode = RunShellWithReturnCode(["git", "show", file_hash],
1326 universal_newlines=not is_binary)
1327 if retcode:
1328 ErrorExit("Got error status from 'git show %s'" % file_hash)
1329 return data
1330
[email protected]fb2b8eb2009-04-23 21:03:421331 def GetBaseFile(self, filename):
[email protected]b563dd32009-11-20 15:01:491332 hash_before, hash_after = self.hashes.get(filename, (None,None))
[email protected]fb2b8eb2009-04-23 21:03:421333 base_content = None
1334 new_content = None
[email protected]b563dd32009-11-20 15:01:491335 status = None
1336
1337 if filename in self.renames:
1338 status = "A +" # Match svn attribute name for renames.
1339 if filename not in self.hashes:
1340 # If a rename doesn't change the content, we never get a hash.
[email protected]8e2bb162011-11-10 15:22:291341 base_content = RunShell(
1342 ["git", "show", "HEAD:" + filename], silent_ok=True)
[email protected]b563dd32009-11-20 15:01:491343 elif not hash_before:
[email protected]fb2b8eb2009-04-23 21:03:421344 status = "A"
1345 base_content = ""
[email protected]b563dd32009-11-20 15:01:491346 elif not hash_after:
1347 status = "D"
[email protected]fb2b8eb2009-04-23 21:03:421348 else:
1349 status = "M"
[email protected]b563dd32009-11-20 15:01:491350
[email protected]b615eda2011-08-23 13:37:401351 is_binary = self.IsBinaryData(base_content)
[email protected]b563dd32009-11-20 15:01:491352 is_image = self.IsImage(filename)
1353
1354 # Grab the before/after content if we need it.
[email protected]f0526412012-05-23 13:51:181355 # Grab the base content if we don't have it already.
1356 if base_content is None and hash_before:
1357 base_content = self.GetFileContent(hash_before, is_binary)
1358 # Only include the "after" file if it's an image; otherwise it
1359 # it is reconstructed from the diff.
1360 if is_image and hash_after:
1361 new_content = self.GetFileContent(hash_after, is_binary)
[email protected]b563dd32009-11-20 15:01:491362
[email protected]fb2b8eb2009-04-23 21:03:421363 return (base_content, new_content, is_binary, status)
1364
1365
[email protected]fbe3d302011-03-24 17:52:231366class CVSVCS(VersionControlSystem):
1367 """Implementation of the VersionControlSystem interface for CVS."""
1368
1369 def __init__(self, options):
1370 super(CVSVCS, self).__init__(options)
1371
[email protected]8e2bb162011-11-10 15:22:291372 def GetGUID(self):
1373 """For now we don't know how to get repository ID for CVS"""
1374 return
1375
[email protected]fbe3d302011-03-24 17:52:231376 def GetOriginalContent_(self, filename):
1377 RunShell(["cvs", "up", filename], silent_ok=True)
1378 # TODO need detect file content encoding
1379 content = open(filename).read()
1380 return content.replace("\r\n", "\n")
1381
1382 def GetBaseFile(self, filename):
1383 base_content = None
1384 new_content = None
[email protected]fbe3d302011-03-24 17:52:231385 status = "A"
1386
1387 output, retcode = RunShellWithReturnCode(["cvs", "status", filename])
1388 if retcode:
1389 ErrorExit("Got error status from 'cvs status %s'" % filename)
1390
1391 if output.find("Status: Locally Modified") != -1:
1392 status = "M"
1393 temp_filename = "%s.tmp123" % filename
1394 os.rename(filename, temp_filename)
1395 base_content = self.GetOriginalContent_(filename)
1396 os.rename(temp_filename, filename)
1397 elif output.find("Status: Locally Added"):
1398 status = "A"
1399 base_content = ""
1400 elif output.find("Status: Needs Checkout"):
1401 status = "D"
1402 base_content = self.GetOriginalContent_(filename)
1403
[email protected]b615eda2011-08-23 13:37:401404 return (base_content, new_content, self.IsBinaryData(base_content), status)
[email protected]fbe3d302011-03-24 17:52:231405
1406 def GenerateDiff(self, extra_args):
1407 cmd = ["cvs", "diff", "-u", "-N"]
1408 if self.options.revision:
1409 cmd += ["-r", self.options.revision]
1410
1411 cmd.extend(extra_args)
1412 data, retcode = RunShellWithReturnCode(cmd)
1413 count = 0
[email protected]e8829d32011-05-27 22:52:011414 if retcode in [0, 1]:
[email protected]fbe3d302011-03-24 17:52:231415 for line in data.splitlines():
1416 if line.startswith("Index:"):
1417 count += 1
1418 logging.info(line)
1419
1420 if not count:
1421 ErrorExit("No valid patches found in output from cvs diff")
1422
1423 return data
1424
1425 def GetUnknownFiles(self):
[email protected]e8829d32011-05-27 22:52:011426 data, retcode = RunShellWithReturnCode(["cvs", "diff"])
1427 if retcode not in [0, 1]:
1428 ErrorExit("Got error status from 'cvs diff':\n%s" % (data,))
[email protected]fbe3d302011-03-24 17:52:231429 unknown_files = []
[email protected]e8829d32011-05-27 22:52:011430 for line in data.split("\n"):
[email protected]fbe3d302011-03-24 17:52:231431 if line and line[0] == "?":
1432 unknown_files.append(line)
1433 return unknown_files
1434
[email protected]fb2b8eb2009-04-23 21:03:421435class MercurialVCS(VersionControlSystem):
1436 """Implementation of the VersionControlSystem interface for Mercurial."""
1437
1438 def __init__(self, options, repo_dir):
1439 super(MercurialVCS, self).__init__(options)
1440 # Absolute path to repository (we can be in a subdir)
1441 self.repo_dir = os.path.normpath(repo_dir)
1442 # Compute the subdir
1443 cwd = os.path.normpath(os.getcwd())
1444 assert cwd.startswith(self.repo_dir)
1445 self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/")
1446 if self.options.revision:
1447 self.base_rev = self.options.revision
1448 else:
1449 self.base_rev = RunShell(["hg", "parent", "-q"]).split(':')[1].strip()
1450
[email protected]8e2bb162011-11-10 15:22:291451 def GetGUID(self):
1452 # See chapter "Uniquely identifying a repository"
1453 # http://hgbook.red-bean.com/read/customizing-the-output-of-mercurial.html
1454 info = RunShell("hg log -r0 --template {node}".split())
1455 return info.strip()
1456
[email protected]fb2b8eb2009-04-23 21:03:421457 def _GetRelPath(self, filename):
1458 """Get relative path of a file according to the current directory,
1459 given its logical path in the repo."""
[email protected]71e12a92012-02-14 02:34:151460 absname = os.path.join(self.repo_dir, filename)
1461 return os.path.relpath(absname)
[email protected]fb2b8eb2009-04-23 21:03:421462
1463 def GenerateDiff(self, extra_args):
[email protected]4b37d612012-04-30 20:00:051464 cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
[email protected]fb2b8eb2009-04-23 21:03:421465 data = RunShell(cmd, silent_ok=True)
1466 svndiff = []
1467 filecount = 0
1468 for line in data.splitlines():
1469 m = re.match("diff --git a/(\S+) b/(\S+)", line)
1470 if m:
1471 # Modify line to make it look like as it comes from svn diff.
1472 # With this modification no changes on the server side are required
1473 # to make upload.py work with Mercurial repos.
1474 # NOTE: for proper handling of moved/copied files, we have to use
1475 # the second filename.
1476 filename = m.group(2)
1477 svndiff.append("Index: %s" % filename)
1478 svndiff.append("=" * 67)
1479 filecount += 1
1480 logging.info(line)
1481 else:
1482 svndiff.append(line)
1483 if not filecount:
1484 ErrorExit("No valid patches found in output from hg diff")
1485 return "\n".join(svndiff) + "\n"
1486
1487 def GetUnknownFiles(self):
1488 """Return a list of files unknown to the VCS."""
1489 args = []
[email protected]4b37d612012-04-30 20:00:051490 status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
[email protected]fb2b8eb2009-04-23 21:03:421491 silent_ok=True)
1492 unknown_files = []
1493 for line in status.splitlines():
1494 st, fn = line.split(" ", 1)
1495 if st == "?":
1496 unknown_files.append(fn)
1497 return unknown_files
1498
1499 def GetBaseFile(self, filename):
[email protected]71e12a92012-02-14 02:34:151500 # "hg status" and "hg cat" both take a path relative to the current subdir,
1501 # but "hg diff" has given us the path relative to the repo root.
[email protected]fb2b8eb2009-04-23 21:03:421502 base_content = ""
1503 new_content = None
1504 is_binary = False
1505 oldrelpath = relpath = self._GetRelPath(filename)
1506 # "hg status -C" returns two lines for moved/copied files, one otherwise
[email protected]4b37d612012-04-30 20:00:051507 out = RunShell(["hg", "status", "-C", "--rev", self.base_rev, relpath])
[email protected]fb2b8eb2009-04-23 21:03:421508 out = out.splitlines()
1509 # HACK: strip error message about missing file/directory if it isn't in
1510 # the working copy
1511 if out[0].startswith('%s: ' % relpath):
1512 out = out[1:]
[email protected]9ad0cf62010-11-19 18:07:531513 status, _ = out[0].split(' ', 1)
1514 if len(out) > 1 and status == "A":
[email protected]fb2b8eb2009-04-23 21:03:421515 # Moved/copied => considered as modified, use old filename to
1516 # retrieve base contents
1517 oldrelpath = out[1].strip()
1518 status = "M"
[email protected]b563dd32009-11-20 15:01:491519 if ":" in self.base_rev:
1520 base_rev = self.base_rev.split(":", 1)[0]
1521 else:
1522 base_rev = self.base_rev
[email protected]fb2b8eb2009-04-23 21:03:421523 if status != "A":
[email protected]b563dd32009-11-20 15:01:491524 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
[email protected]fb2b8eb2009-04-23 21:03:421525 silent_ok=True)
[email protected]b615eda2011-08-23 13:37:401526 is_binary = self.IsBinaryData(base_content)
[email protected]fb2b8eb2009-04-23 21:03:421527 if status != "R":
1528 new_content = open(relpath, "rb").read()
[email protected]b615eda2011-08-23 13:37:401529 is_binary = is_binary or self.IsBinaryData(new_content)
[email protected]fb2b8eb2009-04-23 21:03:421530 if is_binary and base_content:
1531 # Fetch again without converting newlines
[email protected]b563dd32009-11-20 15:01:491532 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
[email protected]fb2b8eb2009-04-23 21:03:421533 silent_ok=True, universal_newlines=False)
[email protected]f0526412012-05-23 13:51:181534 if not is_binary:
[email protected]fb2b8eb2009-04-23 21:03:421535 new_content = None
1536 return base_content, new_content, is_binary, status
1537
1538
[email protected]fbe3d302011-03-24 17:52:231539class PerforceVCS(VersionControlSystem):
1540 """Implementation of the VersionControlSystem interface for Perforce."""
1541
1542 def __init__(self, options):
[email protected]b615eda2011-08-23 13:37:401543
[email protected]fbe3d302011-03-24 17:52:231544 def ConfirmLogin():
1545 # Make sure we have a valid perforce session
1546 while True:
1547 data, retcode = self.RunPerforceCommandWithReturnCode(
1548 ["login", "-s"], marshal_output=True)
1549 if not data:
1550 ErrorExit("Error checking perforce login")
1551 if not retcode and (not "code" in data or data["code"] != "error"):
1552 break
1553 print "Enter perforce password: "
1554 self.RunPerforceCommandWithReturnCode(["login"])
[email protected]b615eda2011-08-23 13:37:401555
[email protected]fbe3d302011-03-24 17:52:231556 super(PerforceVCS, self).__init__(options)
[email protected]b615eda2011-08-23 13:37:401557
[email protected]fbe3d302011-03-24 17:52:231558 self.p4_changelist = options.p4_changelist
1559 if not self.p4_changelist:
1560 ErrorExit("A changelist id is required")
1561 if (options.revision):
1562 ErrorExit("--rev is not supported for perforce")
[email protected]b615eda2011-08-23 13:37:401563
[email protected]fbe3d302011-03-24 17:52:231564 self.p4_port = options.p4_port
1565 self.p4_client = options.p4_client
1566 self.p4_user = options.p4_user
[email protected]b615eda2011-08-23 13:37:401567
[email protected]fbe3d302011-03-24 17:52:231568 ConfirmLogin()
[email protected]b615eda2011-08-23 13:37:401569
[email protected]71e12a92012-02-14 02:34:151570 if not options.title:
[email protected]fbe3d302011-03-24 17:52:231571 description = self.RunPerforceCommand(["describe", self.p4_changelist],
1572 marshal_output=True)
1573 if description and "desc" in description:
1574 # Rietveld doesn't support multi-line descriptions
[email protected]71e12a92012-02-14 02:34:151575 raw_title = description["desc"].strip()
1576 lines = raw_title.splitlines()
[email protected]fbe3d302011-03-24 17:52:231577 if len(lines):
[email protected]71e12a92012-02-14 02:34:151578 options.title = lines[0]
[email protected]b615eda2011-08-23 13:37:401579
[email protected]8e2bb162011-11-10 15:22:291580 def GetGUID(self):
1581 """For now we don't know how to get repository ID for Perforce"""
1582 return
1583
[email protected]fbe3d302011-03-24 17:52:231584 def RunPerforceCommandWithReturnCode(self, extra_args, marshal_output=False,
1585 universal_newlines=True):
1586 args = ["p4"]
1587 if marshal_output:
1588 # -G makes perforce format its output as marshalled python objects
1589 args.extend(["-G"])
1590 if self.p4_port:
1591 args.extend(["-p", self.p4_port])
1592 if self.p4_client:
1593 args.extend(["-c", self.p4_client])
1594 if self.p4_user:
1595 args.extend(["-u", self.p4_user])
1596 args.extend(extra_args)
[email protected]b615eda2011-08-23 13:37:401597
[email protected]fbe3d302011-03-24 17:52:231598 data, retcode = RunShellWithReturnCode(
1599 args, print_output=False, universal_newlines=universal_newlines)
1600 if marshal_output and data:
1601 data = marshal.loads(data)
1602 return data, retcode
[email protected]b615eda2011-08-23 13:37:401603
[email protected]fbe3d302011-03-24 17:52:231604 def RunPerforceCommand(self, extra_args, marshal_output=False,
1605 universal_newlines=True):
1606 # This might be a good place to cache call results, since things like
1607 # describe or fstat might get called repeatedly.
1608 data, retcode = self.RunPerforceCommandWithReturnCode(
1609 extra_args, marshal_output, universal_newlines)
1610 if retcode:
1611 ErrorExit("Got error status from %s:\n%s" % (extra_args, data))
1612 return data
1613
1614 def GetFileProperties(self, property_key_prefix = "", command = "describe"):
1615 description = self.RunPerforceCommand(["describe", self.p4_changelist],
1616 marshal_output=True)
[email protected]b615eda2011-08-23 13:37:401617
[email protected]fbe3d302011-03-24 17:52:231618 changed_files = {}
1619 file_index = 0
1620 # Try depotFile0, depotFile1, ... until we don't find a match
1621 while True:
1622 file_key = "depotFile%d" % file_index
1623 if file_key in description:
1624 filename = description[file_key]
1625 change_type = description[property_key_prefix + str(file_index)]
1626 changed_files[filename] = change_type
1627 file_index += 1
1628 else:
1629 break
1630 return changed_files
1631
1632 def GetChangedFiles(self):
1633 return self.GetFileProperties("action")
1634
1635 def GetUnknownFiles(self):
1636 # Perforce doesn't detect new files, they have to be explicitly added
1637 return []
1638
1639 def IsBaseBinary(self, filename):
1640 base_filename = self.GetBaseFilename(filename)
1641 return self.IsBinaryHelper(base_filename, "files")
1642
1643 def IsPendingBinary(self, filename):
1644 return self.IsBinaryHelper(filename, "describe")
1645
[email protected]fbe3d302011-03-24 17:52:231646 def IsBinaryHelper(self, filename, command):
1647 file_types = self.GetFileProperties("type", command)
1648 if not filename in file_types:
1649 ErrorExit("Trying to check binary status of unknown file %s." % filename)
1650 # This treats symlinks, macintosh resource files, temporary objects, and
1651 # unicode as binary. See the Perforce docs for more details:
1652 # http://www.perforce.com/perforce/doc.current/manuals/cmdref/o.ftypes.html
1653 return not file_types[filename].endswith("text")
1654
1655 def GetFileContent(self, filename, revision, is_binary):
1656 file_arg = filename
1657 if revision:
1658 file_arg += "#" + revision
1659 # -q suppresses the initial line that displays the filename and revision
1660 return self.RunPerforceCommand(["print", "-q", file_arg],
1661 universal_newlines=not is_binary)
1662
1663 def GetBaseFilename(self, filename):
1664 actionsWithDifferentBases = [
1665 "move/add", # p4 move
1666 "branch", # p4 integrate (to a new file), similar to hg "add"
1667 "add", # p4 integrate (to a new file), after modifying the new file
1668 ]
[email protected]b615eda2011-08-23 13:37:401669
[email protected]fbe3d302011-03-24 17:52:231670 # We only see a different base for "add" if this is a downgraded branch
[email protected]b615eda2011-08-23 13:37:401671 # after a file was branched (integrated), then edited.
[email protected]fbe3d302011-03-24 17:52:231672 if self.GetAction(filename) in actionsWithDifferentBases:
1673 # -Or shows information about pending integrations/moves
1674 fstat_result = self.RunPerforceCommand(["fstat", "-Or", filename],
1675 marshal_output=True)
[email protected]b615eda2011-08-23 13:37:401676
[email protected]fbe3d302011-03-24 17:52:231677 baseFileKey = "resolveFromFile0" # I think it's safe to use only file0
1678 if baseFileKey in fstat_result:
1679 return fstat_result[baseFileKey]
[email protected]b615eda2011-08-23 13:37:401680
[email protected]fbe3d302011-03-24 17:52:231681 return filename
1682
1683 def GetBaseRevision(self, filename):
1684 base_filename = self.GetBaseFilename(filename)
[email protected]b615eda2011-08-23 13:37:401685
[email protected]fbe3d302011-03-24 17:52:231686 have_result = self.RunPerforceCommand(["have", base_filename],
1687 marshal_output=True)
1688 if "haveRev" in have_result:
1689 return have_result["haveRev"]
[email protected]b615eda2011-08-23 13:37:401690
[email protected]fbe3d302011-03-24 17:52:231691 def GetLocalFilename(self, filename):
1692 where = self.RunPerforceCommand(["where", filename], marshal_output=True)
1693 if "path" in where:
1694 return where["path"]
1695
[email protected]b615eda2011-08-23 13:37:401696 def GenerateDiff(self, args):
[email protected]fbe3d302011-03-24 17:52:231697 class DiffData:
1698 def __init__(self, perforceVCS, filename, action):
1699 self.perforceVCS = perforceVCS
1700 self.filename = filename
1701 self.action = action
1702 self.base_filename = perforceVCS.GetBaseFilename(filename)
[email protected]b615eda2011-08-23 13:37:401703
[email protected]fbe3d302011-03-24 17:52:231704 self.file_body = None
1705 self.base_rev = None
1706 self.prefix = None
1707 self.working_copy = True
1708 self.change_summary = None
[email protected]b615eda2011-08-23 13:37:401709
[email protected]fbe3d302011-03-24 17:52:231710 def GenerateDiffHeader(diffData):
1711 header = []
1712 header.append("Index: %s" % diffData.filename)
1713 header.append("=" * 67)
[email protected]b615eda2011-08-23 13:37:401714
[email protected]fbe3d302011-03-24 17:52:231715 if diffData.base_filename != diffData.filename:
1716 if diffData.action.startswith("move"):
1717 verb = "rename"
1718 else:
1719 verb = "copy"
1720 header.append("%s from %s" % (verb, diffData.base_filename))
1721 header.append("%s to %s" % (verb, diffData.filename))
[email protected]b615eda2011-08-23 13:37:401722
[email protected]fbe3d302011-03-24 17:52:231723 suffix = "\t(revision %s)" % diffData.base_rev
1724 header.append("--- " + diffData.base_filename + suffix)
1725 if diffData.working_copy:
1726 suffix = "\t(working copy)"
1727 header.append("+++ " + diffData.filename + suffix)
1728 if diffData.change_summary:
1729 header.append(diffData.change_summary)
1730 return header
[email protected]b615eda2011-08-23 13:37:401731
[email protected]fbe3d302011-03-24 17:52:231732 def GenerateMergeDiff(diffData, args):
1733 # -du generates a unified diff, which is nearly svn format
1734 diffData.file_body = self.RunPerforceCommand(
1735 ["diff", "-du", diffData.filename] + args)
1736 diffData.base_rev = self.GetBaseRevision(diffData.filename)
1737 diffData.prefix = ""
[email protected]b615eda2011-08-23 13:37:401738
[email protected]fbe3d302011-03-24 17:52:231739 # We have to replace p4's file status output (the lines starting
1740 # with +++ or ---) to match svn's diff format
1741 lines = diffData.file_body.splitlines()
1742 first_good_line = 0
1743 while (first_good_line < len(lines) and
1744 not lines[first_good_line].startswith("@@")):
1745 first_good_line += 1
1746 diffData.file_body = "\n".join(lines[first_good_line:])
1747 return diffData
1748
1749 def GenerateAddDiff(diffData):
1750 fstat = self.RunPerforceCommand(["fstat", diffData.filename],
1751 marshal_output=True)
1752 if "headRev" in fstat:
1753 diffData.base_rev = fstat["headRev"] # Re-adding a deleted file
1754 else:
1755 diffData.base_rev = "0" # Brand new file
1756 diffData.working_copy = False
1757 rel_path = self.GetLocalFilename(diffData.filename)
1758 diffData.file_body = open(rel_path, 'r').read()
1759 # Replicate svn's list of changed lines
1760 line_count = len(diffData.file_body.splitlines())
1761 diffData.change_summary = "@@ -0,0 +1"
1762 if line_count > 1:
1763 diffData.change_summary += ",%d" % line_count
1764 diffData.change_summary += " @@"
1765 diffData.prefix = "+"
1766 return diffData
[email protected]b615eda2011-08-23 13:37:401767
[email protected]fbe3d302011-03-24 17:52:231768 def GenerateDeleteDiff(diffData):
1769 diffData.base_rev = self.GetBaseRevision(diffData.filename)
1770 is_base_binary = self.IsBaseBinary(diffData.filename)
1771 # For deletes, base_filename == filename
[email protected]b615eda2011-08-23 13:37:401772 diffData.file_body = self.GetFileContent(diffData.base_filename,
1773 None,
[email protected]fbe3d302011-03-24 17:52:231774 is_base_binary)
1775 # Replicate svn's list of changed lines
1776 line_count = len(diffData.file_body.splitlines())
1777 diffData.change_summary = "@@ -1"
1778 if line_count > 1:
1779 diffData.change_summary += ",%d" % line_count
1780 diffData.change_summary += " +0,0 @@"
1781 diffData.prefix = "-"
1782 return diffData
[email protected]b615eda2011-08-23 13:37:401783
[email protected]fbe3d302011-03-24 17:52:231784 changed_files = self.GetChangedFiles()
[email protected]b615eda2011-08-23 13:37:401785
[email protected]fbe3d302011-03-24 17:52:231786 svndiff = []
1787 filecount = 0
1788 for (filename, action) in changed_files.items():
1789 svn_status = self.PerforceActionToSvnStatus(action)
1790 if svn_status == "SKIP":
1791 continue
[email protected]b615eda2011-08-23 13:37:401792
[email protected]fbe3d302011-03-24 17:52:231793 diffData = DiffData(self, filename, action)
1794 # Is it possible to diff a branched file? Stackoverflow says no:
1795 # http://stackoverflow.com/questions/1771314/in-perforce-command-line-how-to-diff-a-file-reopened-for-add
1796 if svn_status == "M":
1797 diffData = GenerateMergeDiff(diffData, args)
1798 elif svn_status == "A":
1799 diffData = GenerateAddDiff(diffData)
1800 elif svn_status == "D":
1801 diffData = GenerateDeleteDiff(diffData)
1802 else:
1803 ErrorExit("Unknown file action %s (svn action %s)." % \
1804 (action, svn_status))
[email protected]b615eda2011-08-23 13:37:401805
[email protected]fbe3d302011-03-24 17:52:231806 svndiff += GenerateDiffHeader(diffData)
[email protected]b615eda2011-08-23 13:37:401807
[email protected]fbe3d302011-03-24 17:52:231808 for line in diffData.file_body.splitlines():
1809 svndiff.append(diffData.prefix + line)
1810 filecount += 1
1811 if not filecount:
1812 ErrorExit("No valid patches found in output from p4 diff")
1813 return "\n".join(svndiff) + "\n"
1814
1815 def PerforceActionToSvnStatus(self, status):
1816 # Mirroring the list at http://permalink.gmane.org/gmane.comp.version-control.mercurial.devel/28717
1817 # Is there something more official?
1818 return {
1819 "add" : "A",
1820 "branch" : "A",
1821 "delete" : "D",
1822 "edit" : "M", # Also includes changing file types.
1823 "integrate" : "M",
1824 "move/add" : "M",
1825 "move/delete": "SKIP",
1826 "purge" : "D", # How does a file's status become "purge"?
1827 }[status]
1828
1829 def GetAction(self, filename):
1830 changed_files = self.GetChangedFiles()
1831 if not filename in changed_files:
1832 ErrorExit("Trying to get base version of unknown file %s." % filename)
[email protected]b615eda2011-08-23 13:37:401833
[email protected]fbe3d302011-03-24 17:52:231834 return changed_files[filename]
1835
1836 def GetBaseFile(self, filename):
1837 base_filename = self.GetBaseFilename(filename)
1838 base_content = ""
1839 new_content = None
[email protected]b615eda2011-08-23 13:37:401840
[email protected]fbe3d302011-03-24 17:52:231841 status = self.PerforceActionToSvnStatus(self.GetAction(filename))
[email protected]b615eda2011-08-23 13:37:401842
[email protected]fbe3d302011-03-24 17:52:231843 if status != "A":
1844 revision = self.GetBaseRevision(base_filename)
1845 if not revision:
1846 ErrorExit("Couldn't find base revision for file %s" % filename)
1847 is_base_binary = self.IsBaseBinary(base_filename)
1848 base_content = self.GetFileContent(base_filename,
1849 revision,
1850 is_base_binary)
[email protected]b615eda2011-08-23 13:37:401851
[email protected]fbe3d302011-03-24 17:52:231852 is_binary = self.IsPendingBinary(filename)
1853 if status != "D" and status != "SKIP":
1854 relpath = self.GetLocalFilename(filename)
[email protected]f0526412012-05-23 13:51:181855 if is_binary:
[email protected]fbe3d302011-03-24 17:52:231856 new_content = open(relpath, "rb").read()
[email protected]b615eda2011-08-23 13:37:401857
[email protected]fbe3d302011-03-24 17:52:231858 return base_content, new_content, is_binary, status
1859
[email protected]fb2b8eb2009-04-23 21:03:421860# NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync.
1861def SplitPatch(data):
1862 """Splits a patch into separate pieces for each file.
1863
1864 Args:
1865 data: A string containing the output of svn diff.
1866
1867 Returns:
1868 A list of 2-tuple (filename, text) where text is the svn diff output
1869 pertaining to filename.
1870 """
1871 patches = []
1872 filename = None
1873 diff = []
1874 for line in data.splitlines(True):
1875 new_filename = None
1876 if line.startswith('Index:'):
1877 unused, new_filename = line.split(':', 1)
1878 new_filename = new_filename.strip()
1879 elif line.startswith('Property changes on:'):
1880 unused, temp_filename = line.split(':', 1)
1881 # When a file is modified, paths use '/' between directories, however
1882 # when a property is modified '\' is used on Windows. Make them the same
1883 # otherwise the file shows up twice.
1884 temp_filename = temp_filename.strip().replace('\\', '/')
1885 if temp_filename != filename:
1886 # File has property changes but no modifications, create a new diff.
1887 new_filename = temp_filename
1888 if new_filename:
1889 if filename and diff:
1890 patches.append((filename, ''.join(diff)))
1891 filename = new_filename
1892 diff = [line]
1893 continue
1894 if diff is not None:
1895 diff.append(line)
1896 if filename and diff:
1897 patches.append((filename, ''.join(diff)))
1898 return patches
1899
1900
1901def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
1902 """Uploads a separate patch for each file in the diff output.
1903
1904 Returns a list of [patch_key, filename] for each file.
1905 """
1906 patches = SplitPatch(data)
1907 rv = []
1908 for patch in patches:
1909 if len(patch[1]) > MAX_UPLOAD_SIZE:
1910 print ("Not uploading the patch for " + patch[0] +
1911 " because the file is too large.")
1912 continue
1913 form_fields = [("filename", patch[0])]
1914 if not options.download_base:
1915 form_fields.append(("content_upload", "1"))
1916 files = [("data", "data.diff", patch[1])]
1917 ctype, body = EncodeMultipartFormData(form_fields, files)
1918 url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
1919 print "Uploading patch for " + patch[0]
1920 response_body = rpc_server.Send(url, body, content_type=ctype)
1921 lines = response_body.splitlines()
1922 if not lines or lines[0] != "OK":
1923 StatusUpdate(" --> %s" % response_body)
1924 sys.exit(1)
1925 rv.append([lines[1], patch[0]])
1926 return rv
1927
1928
[email protected]fbe3d302011-03-24 17:52:231929def GuessVCSName(options):
[email protected]fb2b8eb2009-04-23 21:03:421930 """Helper to guess the version control system.
1931
1932 This examines the current directory, guesses which VersionControlSystem
[email protected]b563dd32009-11-20 15:01:491933 we're using, and returns an string indicating which VCS is detected.
[email protected]fb2b8eb2009-04-23 21:03:421934
1935 Returns:
[email protected]b563dd32009-11-20 15:01:491936 A pair (vcs, output). vcs is a string indicating which VCS was detected
[email protected]fbe3d302011-03-24 17:52:231937 and is one of VCS_GIT, VCS_MERCURIAL, VCS_SUBVERSION, VCS_PERFORCE,
1938 VCS_CVS, or VCS_UNKNOWN.
1939 Since local perforce repositories can't be easily detected, this method
1940 will only guess VCS_PERFORCE if any perforce options have been specified.
[email protected]b563dd32009-11-20 15:01:491941 output is a string containing any interesting output from the vcs
1942 detection routine, or None if there is nothing interesting.
[email protected]fb2b8eb2009-04-23 21:03:421943 """
[email protected]fbe3d302011-03-24 17:52:231944 for attribute, value in options.__dict__.iteritems():
1945 if attribute.startswith("p4") and value != None:
1946 return (VCS_PERFORCE, None)
[email protected]b615eda2011-08-23 13:37:401947
[email protected]1c5518e2011-04-21 16:38:151948 def RunDetectCommand(vcs_type, command):
1949 """Helper to detect VCS by executing command.
[email protected]b615eda2011-08-23 13:37:401950
[email protected]1c5518e2011-04-21 16:38:151951 Returns:
1952 A pair (vcs, output) or None. Throws exception on error.
1953 """
1954 try:
1955 out, returncode = RunShellWithReturnCode(command)
1956 if returncode == 0:
1957 return (vcs_type, out.strip())
1958 except OSError, (errcode, message):
1959 if errcode != errno.ENOENT: # command not found code
1960 raise
[email protected]b615eda2011-08-23 13:37:401961
[email protected]fb2b8eb2009-04-23 21:03:421962 # Mercurial has a command to get the base directory of a repository
1963 # Try running it, but don't die if we don't have hg installed.
1964 # NOTE: we try Mercurial first as it can sit on top of an SVN working copy.
[email protected]1c5518e2011-04-21 16:38:151965 res = RunDetectCommand(VCS_MERCURIAL, ["hg", "root"])
1966 if res != None:
1967 return res
[email protected]fb2b8eb2009-04-23 21:03:421968
[email protected]71e12a92012-02-14 02:34:151969 # Subversion from 1.7 has a single centralized .svn folder
1970 # ( see http://subversion.apache.org/docs/release-notes/1.7.html#wc-ng )
1971 # That's why we use 'svn info' instead of checking for .svn dir
1972 res = RunDetectCommand(VCS_SUBVERSION, ["svn", "info"])
1973 if res != None:
1974 return res
[email protected]fb2b8eb2009-04-23 21:03:421975
1976 # Git has a command to test if you're in a git tree.
1977 # Try running it, but don't die if we don't have git installed.
[email protected]1c5518e2011-04-21 16:38:151978 res = RunDetectCommand(VCS_GIT, ["git", "rev-parse",
1979 "--is-inside-work-tree"])
1980 if res != None:
1981 return res
[email protected]fb2b8eb2009-04-23 21:03:421982
[email protected]fbe3d302011-03-24 17:52:231983 # detect CVS repos use `cvs status && $? == 0` rules
[email protected]1c5518e2011-04-21 16:38:151984 res = RunDetectCommand(VCS_CVS, ["cvs", "status"])
1985 if res != None:
1986 return res
[email protected]fbe3d302011-03-24 17:52:231987
[email protected]b563dd32009-11-20 15:01:491988 return (VCS_UNKNOWN, None)
1989
1990
1991def GuessVCS(options):
1992 """Helper to guess the version control system.
1993
1994 This verifies any user-specified VersionControlSystem (by command line
1995 or environment variable). If the user didn't specify one, this examines
1996 the current directory, guesses which VersionControlSystem we're using,
1997 and returns an instance of the appropriate class. Exit with an error
1998 if we can't figure it out.
1999
2000 Returns:
2001 A VersionControlSystem instance. Exits if the VCS can't be guessed.
2002 """
2003 vcs = options.vcs
2004 if not vcs:
2005 vcs = os.environ.get("CODEREVIEW_VCS")
2006 if vcs:
2007 v = VCS_ABBREVIATIONS.get(vcs.lower())
2008 if v is None:
2009 ErrorExit("Unknown version control system %r specified." % vcs)
2010 (vcs, extra_output) = (v, None)
2011 else:
[email protected]fbe3d302011-03-24 17:52:232012 (vcs, extra_output) = GuessVCSName(options)
[email protected]b563dd32009-11-20 15:01:492013
2014 if vcs == VCS_MERCURIAL:
2015 if extra_output is None:
2016 extra_output = RunShell(["hg", "root"]).strip()
2017 return MercurialVCS(options, extra_output)
2018 elif vcs == VCS_SUBVERSION:
2019 return SubversionVCS(options)
[email protected]fbe3d302011-03-24 17:52:232020 elif vcs == VCS_PERFORCE:
2021 return PerforceVCS(options)
[email protected]b563dd32009-11-20 15:01:492022 elif vcs == VCS_GIT:
2023 return GitVCS(options)
[email protected]fbe3d302011-03-24 17:52:232024 elif vcs == VCS_CVS:
2025 return CVSVCS(options)
[email protected]b563dd32009-11-20 15:01:492026
[email protected]fb2b8eb2009-04-23 21:03:422027 ErrorExit(("Could not guess version control system. "
2028 "Are you in a working copy directory?"))
2029
2030
[email protected]b563dd32009-11-20 15:01:492031def CheckReviewer(reviewer):
2032 """Validate a reviewer -- either a nickname or an email addres.
2033
2034 Args:
2035 reviewer: A nickname or an email address.
2036
2037 Calls ErrorExit() if it is an invalid email address.
2038 """
2039 if "@" not in reviewer:
2040 return # Assume nickname
2041 parts = reviewer.split("@")
2042 if len(parts) > 2:
2043 ErrorExit("Invalid email address: %r" % reviewer)
2044 assert len(parts) == 2
2045 if "." not in parts[1]:
2046 ErrorExit("Invalid email address: %r" % reviewer)
2047
2048
[email protected]c97bfc62010-05-11 19:15:122049def LoadSubversionAutoProperties():
2050 """Returns the content of [auto-props] section of Subversion's config file as
2051 a dictionary.
2052
2053 Returns:
2054 A dictionary whose key-value pair corresponds the [auto-props] section's
2055 key-value pair.
2056 In following cases, returns empty dictionary:
2057 - config file doesn't exist, or
2058 - 'enable-auto-props' is not set to 'true-like-value' in [miscellany].
2059 """
[email protected]9ad0cf62010-11-19 18:07:532060 if os.name == 'nt':
2061 subversion_config = os.environ.get("APPDATA") + "\\Subversion\\config"
2062 else:
2063 subversion_config = os.path.expanduser("~/.subversion/config")
[email protected]c97bfc62010-05-11 19:15:122064 if not os.path.exists(subversion_config):
2065 return {}
2066 config = ConfigParser.ConfigParser()
2067 config.read(subversion_config)
2068 if (config.has_section("miscellany") and
2069 config.has_option("miscellany", "enable-auto-props") and
2070 config.getboolean("miscellany", "enable-auto-props") and
2071 config.has_section("auto-props")):
2072 props = {}
2073 for file_pattern in config.options("auto-props"):
2074 props[file_pattern] = ParseSubversionPropertyValues(
2075 config.get("auto-props", file_pattern))
2076 return props
2077 else:
2078 return {}
2079
2080def ParseSubversionPropertyValues(props):
2081 """Parse the given property value which comes from [auto-props] section and
2082 returns a list whose element is a (svn_prop_key, svn_prop_value) pair.
2083
2084 See the following doctest for example.
2085
2086 >>> ParseSubversionPropertyValues('svn:eol-style=LF')
2087 [('svn:eol-style', 'LF')]
2088 >>> ParseSubversionPropertyValues('svn:mime-type=image/jpeg')
2089 [('svn:mime-type', 'image/jpeg')]
2090 >>> ParseSubversionPropertyValues('svn:eol-style=LF;svn:executable')
2091 [('svn:eol-style', 'LF'), ('svn:executable', '*')]
2092 """
2093 key_value_pairs = []
2094 for prop in props.split(";"):
2095 key_value = prop.split("=")
2096 assert len(key_value) <= 2
2097 if len(key_value) == 1:
2098 # If value is not given, use '*' as a Subversion's convention.
2099 key_value_pairs.append((key_value[0], "*"))
2100 else:
2101 key_value_pairs.append((key_value[0], key_value[1]))
2102 return key_value_pairs
2103
2104
2105def GetSubversionPropertyChanges(filename):
2106 """Return a Subversion's 'Property changes on ...' string, which is used in
2107 the patch file.
2108
2109 Args:
2110 filename: filename whose property might be set by [auto-props] config.
2111
2112 Returns:
2113 A string like 'Property changes on |filename| ...' if given |filename|
2114 matches any entries in [auto-props] section. None, otherwise.
2115 """
2116 global svn_auto_props_map
2117 if svn_auto_props_map is None:
2118 svn_auto_props_map = LoadSubversionAutoProperties()
2119
2120 all_props = []
2121 for file_pattern, props in svn_auto_props_map.items():
2122 if fnmatch.fnmatch(filename, file_pattern):
2123 all_props.extend(props)
2124 if all_props:
2125 return FormatSubversionPropertyChanges(filename, all_props)
2126 return None
2127
2128
2129def FormatSubversionPropertyChanges(filename, props):
2130 """Returns Subversion's 'Property changes on ...' strings using given filename
2131 and properties.
2132
2133 Args:
2134 filename: filename
2135 props: A list whose element is a (svn_prop_key, svn_prop_value) pair.
2136
2137 Returns:
2138 A string which can be used in the patch file for Subversion.
2139
2140 See the following doctest for example.
2141
2142 >>> print FormatSubversionPropertyChanges('foo.cc', [('svn:eol-style', 'LF')])
2143 Property changes on: foo.cc
2144 ___________________________________________________________________
2145 Added: svn:eol-style
2146 + LF
2147 <BLANKLINE>
2148 """
2149 prop_changes_lines = [
2150 "Property changes on: %s" % filename,
2151 "___________________________________________________________________"]
2152 for key, value in props:
2153 prop_changes_lines.append("Added: " + key)
2154 prop_changes_lines.append(" + " + value)
2155 return "\n".join(prop_changes_lines) + "\n"
2156
2157
[email protected]fb2b8eb2009-04-23 21:03:422158def RealMain(argv, data=None):
[email protected]b563dd32009-11-20 15:01:492159 """The real main function.
2160
2161 Args:
2162 argv: Command line arguments.
2163 data: Diff contents. If None (default) the diff is generated by
2164 the VersionControlSystem implementation returned by GuessVCS().
2165
2166 Returns:
2167 A 2-tuple (issue id, patchset id).
2168 The patchset id is None if the base files are not uploaded by this
2169 script (applies only to SVN checkouts).
2170 """
[email protected]fb2b8eb2009-04-23 21:03:422171 options, args = parser.parse_args(argv[1:])
[email protected]8e2bb162011-11-10 15:22:292172 if options.help:
2173 if options.verbose < 2:
2174 # hide Perforce options
2175 parser.epilog = "Use '--help -v' to show additional Perforce options."
2176 parser.option_groups.remove(parser.get_option_group('--p4_port'))
2177 parser.print_help()
2178 sys.exit(0)
2179
[email protected]fb2b8eb2009-04-23 21:03:422180 global verbosity
2181 verbosity = options.verbose
2182 if verbosity >= 3:
2183 logging.getLogger().setLevel(logging.DEBUG)
2184 elif verbosity >= 2:
2185 logging.getLogger().setLevel(logging.INFO)
[email protected]c97bfc62010-05-11 19:15:122186
[email protected]fb2b8eb2009-04-23 21:03:422187 vcs = GuessVCS(options)
[email protected]c97bfc62010-05-11 19:15:122188
2189 base = options.base_url
[email protected]fb2b8eb2009-04-23 21:03:422190 if isinstance(vcs, SubversionVCS):
[email protected]c97bfc62010-05-11 19:15:122191 # Guessing the base field is only supported for Subversion.
[email protected]fb2b8eb2009-04-23 21:03:422192 # Note: Fetching base files may become deprecated in future releases.
[email protected]c97bfc62010-05-11 19:15:122193 guessed_base = vcs.GuessBase(options.download_base)
2194 if base:
2195 if guessed_base and base != guessed_base:
2196 print "Using base URL \"%s\" from --base_url instead of \"%s\"" % \
2197 (base, guessed_base)
2198 else:
2199 base = guessed_base
2200
[email protected]fb2b8eb2009-04-23 21:03:422201 if not base and options.download_base:
2202 options.download_base = True
2203 logging.info("Enabled upload of base file")
2204 if not options.assume_yes:
2205 vcs.CheckForUnknownFiles()
2206 if data is None:
2207 data = vcs.GenerateDiff(args)
[email protected]c97bfc62010-05-11 19:15:122208 data = vcs.PostProcessDiff(data)
[email protected]fbe3d302011-03-24 17:52:232209 if options.print_diffs:
2210 print "Rietveld diff start:*****"
2211 print data
2212 print "Rietveld diff end:*****"
[email protected]fb2b8eb2009-04-23 21:03:422213 files = vcs.GetBaseFiles(data)
2214 if verbosity >= 1:
2215 print "Upload server:", options.server, "(change with -s/--server)"
[email protected]c97bfc62010-05-11 19:15:122216 rpc_server = GetRpcServer(options.server,
2217 options.email,
2218 options.host,
[email protected]8b7f1e72010-08-04 14:57:382219 options.save_cookies,
2220 options.account_type)
[email protected]71e12a92012-02-14 02:34:152221 form_fields = []
2222
[email protected]8e2bb162011-11-10 15:22:292223 repo_guid = vcs.GetGUID()
2224 if repo_guid:
2225 form_fields.append(("repo_guid", repo_guid))
[email protected]fb2b8eb2009-04-23 21:03:422226 if base:
[email protected]fbe3d302011-03-24 17:52:232227 b = urlparse.urlparse(base)
2228 username, netloc = urllib.splituser(b.netloc)
2229 if username:
2230 logging.info("Removed username from base URL")
2231 base = urlparse.urlunparse((b.scheme, netloc, b.path, b.params,
2232 b.query, b.fragment))
[email protected]fb2b8eb2009-04-23 21:03:422233 form_fields.append(("base", base))
2234 if options.issue:
2235 form_fields.append(("issue", str(options.issue)))
2236 if options.email:
2237 form_fields.append(("user", options.email))
2238 if options.reviewers:
2239 for reviewer in options.reviewers.split(','):
[email protected]b563dd32009-11-20 15:01:492240 CheckReviewer(reviewer)
[email protected]fb2b8eb2009-04-23 21:03:422241 form_fields.append(("reviewers", options.reviewers))
2242 if options.cc:
2243 for cc in options.cc.split(','):
[email protected]b563dd32009-11-20 15:01:492244 CheckReviewer(cc)
[email protected]fb2b8eb2009-04-23 21:03:422245 form_fields.append(("cc", options.cc))
[email protected]71e12a92012-02-14 02:34:152246
2247 # Process --message, --title and --file.
2248 message = options.message or ""
2249 title = options.title or ""
2250 if options.file:
2251 if options.message:
2252 ErrorExit("Can't specify both message and message file options")
2253 file = open(options.file, 'r')
2254 message = file.read()
[email protected]fb2b8eb2009-04-23 21:03:422255 file.close()
[email protected]71e12a92012-02-14 02:34:152256 if options.issue:
2257 prompt = "Title describing this patch set: "
2258 else:
2259 prompt = "New issue subject: "
2260 title = (
2261 title or message.split('\n', 1)[0].strip() or raw_input(prompt).strip())
2262 if not title and not options.issue:
2263 ErrorExit("A non-empty title is required for a new issue")
2264 # For existing issues, it's fine to give a patchset an empty name. Rietveld
2265 # doesn't accept that so use a whitespace.
2266 title = title or " "
2267 if len(title) > 100:
2268 title = title[:99] + '…'
2269 if title and not options.issue:
2270 message = message or title
2271
2272 form_fields.append(("subject", title))
[email protected]4b37d612012-04-30 20:00:052273 # If it's a new issue send message as description. Otherwise a new
2274 # message is created below on upload_complete.
2275 if message and not options.issue:
2276 form_fields.append(("description", message))
[email protected]71e12a92012-02-14 02:34:152277
[email protected]fb2b8eb2009-04-23 21:03:422278 # Send a hash of all the base file so the server can determine if a copy
2279 # already exists in an earlier patchset.
2280 base_hashes = ""
2281 for file, info in files.iteritems():
2282 if not info[0] is None:
[email protected]d971f7f2009-05-01 12:51:232283 checksum = md5(info[0]).hexdigest()
[email protected]fb2b8eb2009-04-23 21:03:422284 if base_hashes:
2285 base_hashes += "|"
2286 base_hashes += checksum + ":" + file
2287 form_fields.append(("base_hashes", base_hashes))
[email protected]b563dd32009-11-20 15:01:492288 if options.private:
2289 if options.issue:
2290 print "Warning: Private flag ignored when updating an existing issue."
2291 else:
2292 form_fields.append(("private", "1"))
[email protected]b615eda2011-08-23 13:37:402293 if options.send_patch:
2294 options.send_mail = True
[email protected]fb2b8eb2009-04-23 21:03:422295 if not options.download_base:
2296 form_fields.append(("content_upload", "1"))
2297 if len(data) > MAX_UPLOAD_SIZE:
2298 print "Patch is large, so uploading file patches separately."
2299 uploaded_diff_file = []
2300 form_fields.append(("separate_patches", "1"))
2301 else:
2302 uploaded_diff_file = [("data", "data.diff", data)]
2303 ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file)
2304 response_body = rpc_server.Send("/upload", body, content_type=ctype)
[email protected]b563dd32009-11-20 15:01:492305 patchset = None
[email protected]fb2b8eb2009-04-23 21:03:422306 if not options.download_base or not uploaded_diff_file:
2307 lines = response_body.splitlines()
2308 if len(lines) >= 2:
2309 msg = lines[0]
2310 patchset = lines[1].strip()
2311 patches = [x.split(" ", 1) for x in lines[2:]]
[email protected]fb2b8eb2009-04-23 21:03:422312 else:
2313 msg = response_body
2314 else:
2315 msg = response_body
2316 StatusUpdate(msg)
2317 if not response_body.startswith("Issue created.") and \
2318 not response_body.startswith("Issue updated."):
2319 sys.exit(0)
2320 issue = msg[msg.rfind("/")+1:]
2321
2322 if not uploaded_diff_file:
2323 result = UploadSeparatePatches(issue, rpc_server, patchset, data, options)
2324 if not options.download_base:
2325 patches = result
2326
2327 if not options.download_base:
2328 vcs.UploadBaseFiles(issue, rpc_server, patches, patchset, options, files)
[email protected]71e12a92012-02-14 02:34:152329
2330 payload = {} # payload for final request
2331 if options.send_mail:
2332 payload["send_mail"] = "yes"
2333 if options.send_patch:
2334 payload["attach_patch"] = "yes"
[email protected]4b37d612012-04-30 20:00:052335 if options.issue and message:
2336 payload["message"] = message
[email protected]71e12a92012-02-14 02:34:152337 payload = urllib.urlencode(payload)
2338 rpc_server.Send("/" + issue + "/upload_complete/" + (patchset or ""),
2339 payload=payload)
[email protected]fb2b8eb2009-04-23 21:03:422340 return issue, patchset
2341
2342
2343def main():
2344 try:
[email protected]1c5518e2011-04-21 16:38:152345 logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:"
2346 "%(lineno)s %(message)s "))
2347 os.environ['LC_ALL'] = 'C'
[email protected]fb2b8eb2009-04-23 21:03:422348 RealMain(sys.argv)
2349 except KeyboardInterrupt:
2350 print
2351 StatusUpdate("Interrupted.")
2352 sys.exit(1)
2353
2354
2355if __name__ == "__main__":
2356 main()