blob: 7b5cc8045b214296120633b42ce949e64e669e6f [file] [log] [blame]
Joe Gregorioe754eb82010-08-20 10:56:32 -04001#!/usr/bin/env python
Joe Gregorio003b6e42013-02-13 15:42:19 -05002# coding: utf-8
Joe Gregorioe754eb82010-08-20 10:56:32 -04003#
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
Joe Gregorio30a08fd2011-04-11 11:33:42 -040020Usage summary: upload.py [options] [-- diff_options] [path...]
Joe Gregorioe754eb82010-08-20 10:56:32 -040021
22Diff options are passed to the diff command of the underlying system.
23
24Supported version control systems:
25 Git
26 Mercurial
27 Subversion
Joe Gregorio30a08fd2011-04-11 11:33:42 -040028 Perforce
29 CVS
Joe Gregorioe754eb82010-08-20 10:56:32 -040030
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
37import ConfigParser
38import cookielib
Joe Gregorio003b6e42013-02-13 15:42:19 -050039import errno
Joe Gregorioe754eb82010-08-20 10:56:32 -040040import fnmatch
41import getpass
42import logging
Joe Gregorio30a08fd2011-04-11 11:33:42 -040043import marshal
Joe Gregorioe754eb82010-08-20 10:56:32 -040044import mimetypes
45import optparse
46import os
47import re
48import socket
49import subprocess
50import sys
51import urllib
52import urllib2
53import urlparse
54
55# The md5 module was deprecated in Python 2.5.
56try:
57 from hashlib import md5
58except ImportError:
59 from md5 import md5
60
61try:
62 import readline
63except ImportError:
64 pass
65
Joe Gregorio30a08fd2011-04-11 11:33:42 -040066try:
67 import keyring
68except ImportError:
69 keyring = None
70
Joe Gregorioe754eb82010-08-20 10:56:32 -040071# The logging verbosity:
72# 0: Errors only.
73# 1: Status messages.
74# 2: Info logs.
75# 3: Debug logs.
76verbosity = 1
77
Joe Gregorio30a08fd2011-04-11 11:33:42 -040078# 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
Joe Gregorioe754eb82010-08-20 10:56:32 -040087# Max size of patch or base file.
88MAX_UPLOAD_SIZE = 900 * 1024
89
90# Constants for version control names. Used by GuessVCSName.
91VCS_GIT = "Git"
92VCS_MERCURIAL = "Mercurial"
93VCS_SUBVERSION = "Subversion"
Joe Gregorio30a08fd2011-04-11 11:33:42 -040094VCS_PERFORCE = "Perforce"
95VCS_CVS = "CVS"
Joe Gregorioe754eb82010-08-20 10:56:32 -040096VCS_UNKNOWN = "Unknown"
97
Joe Gregorioe754eb82010-08-20 10:56:32 -040098VCS_ABBREVIATIONS = {
99 VCS_MERCURIAL.lower(): VCS_MERCURIAL,
100 "hg": VCS_MERCURIAL,
101 VCS_SUBVERSION.lower(): VCS_SUBVERSION,
102 "svn": VCS_SUBVERSION,
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400103 VCS_PERFORCE.lower(): VCS_PERFORCE,
104 "p4": VCS_PERFORCE,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400105 VCS_GIT.lower(): VCS_GIT,
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400106 VCS_CVS.lower(): VCS_CVS,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400107}
108
109# The result of parsing Subversion's [auto-props] setting.
110svn_auto_props_map = None
111
112def GetEmail(prompt):
113 """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 """
121 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
122 last_email = ""
123 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()
128 prompt += " [%s]" % last_email
129 except IOError, e:
130 pass
131 email = raw_input(prompt + ": ").strip()
132 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
Joe Gregorio003b6e42013-02-13 15:42:19 -0500168 self._reason = args["Error"]
169 self.info = args.get("Info", None)
170
171 @property
172 def reason(self):
173 # reason is a property on python 2.7 but a member variable on <=2.6.
174 # self.args is modified so it cannot be used as-is so save the value in
175 # self._reason.
176 return self._reason
Joe Gregorioe754eb82010-08-20 10:56:32 -0400177
178
179class AbstractRpcServer(object):
180 """Provides a common interface for a simple RPC server."""
181
182 def __init__(self, host, auth_function, host_override=None, extra_headers={},
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400183 save_cookies=False, account_type=AUTH_ACCOUNT_TYPE):
Joe Gregorio003b6e42013-02-13 15:42:19 -0500184 """Creates a new AbstractRpcServer.
Joe Gregorioe754eb82010-08-20 10:56:32 -0400185
186 Args:
187 host: The host to send requests to.
188 auth_function: A function that takes no arguments and returns an
189 (email, password) tuple when called. Will be called if authentication
190 is required.
191 host_override: The host header to send to the server (defaults to host).
192 extra_headers: A dict of extra headers to append to every request.
193 save_cookies: If True, save the authentication cookies to local disk.
194 If False, use an in-memory cookiejar instead. Subclasses must
195 implement this functionality. Defaults to False.
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400196 account_type: Account type used for authentication. Defaults to
197 AUTH_ACCOUNT_TYPE.
Joe Gregorioe754eb82010-08-20 10:56:32 -0400198 """
199 self.host = host
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400200 if (not self.host.startswith("http://") and
201 not self.host.startswith("https://")):
202 self.host = "http://" + self.host
Joe Gregorioe754eb82010-08-20 10:56:32 -0400203 self.host_override = host_override
204 self.auth_function = auth_function
205 self.authenticated = False
206 self.extra_headers = extra_headers
207 self.save_cookies = save_cookies
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400208 self.account_type = account_type
Joe Gregorioe754eb82010-08-20 10:56:32 -0400209 self.opener = self._GetOpener()
210 if self.host_override:
211 logging.info("Server: %s; Host: %s", self.host, self.host_override)
212 else:
213 logging.info("Server: %s", self.host)
214
215 def _GetOpener(self):
216 """Returns an OpenerDirector for making HTTP requests.
217
218 Returns:
219 A urllib2.OpenerDirector object.
220 """
221 raise NotImplementedError()
222
223 def _CreateRequest(self, url, data=None):
224 """Creates a new urllib request."""
225 logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
Joe Gregorio003b6e42013-02-13 15:42:19 -0500226 req = urllib2.Request(url, data=data, headers={"Accept": "text/plain"})
Joe Gregorioe754eb82010-08-20 10:56:32 -0400227 if self.host_override:
228 req.add_header("Host", self.host_override)
229 for key, value in self.extra_headers.iteritems():
230 req.add_header(key, value)
231 return req
232
233 def _GetAuthToken(self, email, password):
234 """Uses ClientLogin to authenticate the user, returning an auth token.
235
236 Args:
237 email: The user's email address
238 password: The user's password
239
240 Raises:
241 ClientLoginError: If there was an error authenticating with ClientLogin.
242 HTTPError: If there was some other form of HTTP error.
243
244 Returns:
245 The authentication token returned by ClientLogin.
246 """
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400247 account_type = self.account_type
Joe Gregorioe754eb82010-08-20 10:56:32 -0400248 if self.host.endswith(".google.com"):
249 # Needed for use inside Google.
250 account_type = "HOSTED"
251 req = self._CreateRequest(
252 url="https://www.google.com/accounts/ClientLogin",
253 data=urllib.urlencode({
254 "Email": email,
255 "Passwd": password,
256 "service": "ah",
257 "source": "rietveld-codereview-upload",
258 "accountType": account_type,
259 }),
260 )
261 try:
262 response = self.opener.open(req)
263 response_body = response.read()
264 response_dict = dict(x.split("=")
265 for x in response_body.split("\n") if x)
266 return response_dict["Auth"]
267 except urllib2.HTTPError, e:
268 if e.code == 403:
269 body = e.read()
270 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
271 raise ClientLoginError(req.get_full_url(), e.code, e.msg,
272 e.headers, response_dict)
273 else:
274 raise
275
276 def _GetAuthCookie(self, auth_token):
277 """Fetches authentication cookies for an authentication token.
278
279 Args:
280 auth_token: The authentication token returned by ClientLogin.
281
282 Raises:
283 HTTPError: If there was an error fetching the authentication cookies.
284 """
285 # This is a dummy value to allow us to identify when we're successful.
286 continue_location = "http://localhost/"
287 args = {"continue": continue_location, "auth": auth_token}
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400288 req = self._CreateRequest("%s/_ah/login?%s" %
Joe Gregorioe754eb82010-08-20 10:56:32 -0400289 (self.host, urllib.urlencode(args)))
290 try:
291 response = self.opener.open(req)
292 except urllib2.HTTPError, e:
293 response = e
294 if (response.code != 302 or
295 response.info()["location"] != continue_location):
296 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg,
297 response.headers, response.fp)
298 self.authenticated = True
299
300 def _Authenticate(self):
301 """Authenticates the user.
302
303 The authentication process works as follows:
304 1) We get a username and password from the user
305 2) We use ClientLogin to obtain an AUTH token for the user
306 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
307 3) We pass the auth token to /_ah/login on the server to obtain an
308 authentication cookie. If login was successful, it tries to redirect
309 us to the URL we provided.
310
311 If we attempt to access the upload API without first obtaining an
312 authentication cookie, it returns a 401 response (or a 302) and
313 directs us to authenticate ourselves with ClientLogin.
314 """
315 for i in range(3):
316 credentials = self.auth_function()
317 try:
318 auth_token = self._GetAuthToken(credentials[0], credentials[1])
319 except ClientLoginError, e:
Joe Gregorio003b6e42013-02-13 15:42:19 -0500320 print >>sys.stderr, ''
Joe Gregorioe754eb82010-08-20 10:56:32 -0400321 if e.reason == "BadAuthentication":
Joe Gregorio003b6e42013-02-13 15:42:19 -0500322 if e.info == "InvalidSecondFactor":
323 print >>sys.stderr, (
324 "Use an application-specific password instead "
325 "of your regular account password.\n"
326 "See http://www.google.com/"
327 "support/accounts/bin/answer.py?answer=185833")
328 else:
329 print >>sys.stderr, "Invalid username or password."
330 elif e.reason == "CaptchaRequired":
Joe Gregorioe754eb82010-08-20 10:56:32 -0400331 print >>sys.stderr, (
332 "Please go to\n"
333 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400334 "and verify you are a human. Then try again.\n"
335 "If you are using a Google Apps account the URL is:\n"
336 "https://www.google.com/a/yourdomain.com/UnlockCaptcha")
Joe Gregorio003b6e42013-02-13 15:42:19 -0500337 elif e.reason == "NotVerified":
Joe Gregorioe754eb82010-08-20 10:56:32 -0400338 print >>sys.stderr, "Account not verified."
Joe Gregorio003b6e42013-02-13 15:42:19 -0500339 elif e.reason == "TermsNotAgreed":
Joe Gregorioe754eb82010-08-20 10:56:32 -0400340 print >>sys.stderr, "User has not agreed to TOS."
Joe Gregorio003b6e42013-02-13 15:42:19 -0500341 elif e.reason == "AccountDeleted":
Joe Gregorioe754eb82010-08-20 10:56:32 -0400342 print >>sys.stderr, "The user account has been deleted."
Joe Gregorio003b6e42013-02-13 15:42:19 -0500343 elif e.reason == "AccountDisabled":
Joe Gregorioe754eb82010-08-20 10:56:32 -0400344 print >>sys.stderr, "The user account has been disabled."
345 break
Joe Gregorio003b6e42013-02-13 15:42:19 -0500346 elif e.reason == "ServiceDisabled":
Joe Gregorioe754eb82010-08-20 10:56:32 -0400347 print >>sys.stderr, ("The user's access to the service has been "
348 "disabled.")
Joe Gregorio003b6e42013-02-13 15:42:19 -0500349 elif e.reason == "ServiceUnavailable":
Joe Gregorioe754eb82010-08-20 10:56:32 -0400350 print >>sys.stderr, "The service is not available; try again later."
Joe Gregorio003b6e42013-02-13 15:42:19 -0500351 else:
352 # Unknown error.
353 raise
354 print >>sys.stderr, ''
355 continue
Joe Gregorioe754eb82010-08-20 10:56:32 -0400356 self._GetAuthCookie(auth_token)
357 return
358
359 def Send(self, request_path, payload=None,
360 content_type="application/octet-stream",
361 timeout=None,
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400362 extra_headers=None,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400363 **kwargs):
364 """Sends an RPC and returns the response.
365
366 Args:
367 request_path: The path to send the request to, eg /api/appversion/create.
368 payload: The body of the request, or None to send an empty request.
369 content_type: The Content-Type header to use.
370 timeout: timeout in seconds; default None i.e. no timeout.
371 (Note: for large requests on OS X, the timeout doesn't work right.)
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400372 extra_headers: Dict containing additional HTTP headers that should be
373 included in the request (string header names mapped to their values),
374 or None to not include any additional headers.
Joe Gregorioe754eb82010-08-20 10:56:32 -0400375 kwargs: Any keyword arguments are converted into query string parameters.
376
377 Returns:
378 The response body, as a string.
379 """
380 # TODO: Don't require authentication. Let the server say
381 # whether it is necessary.
382 if not self.authenticated:
383 self._Authenticate()
384
385 old_timeout = socket.getdefaulttimeout()
386 socket.setdefaulttimeout(timeout)
387 try:
388 tries = 0
389 while True:
390 tries += 1
391 args = dict(kwargs)
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400392 url = "%s%s" % (self.host, request_path)
Joe Gregorioe754eb82010-08-20 10:56:32 -0400393 if args:
394 url += "?" + urllib.urlencode(args)
395 req = self._CreateRequest(url=url, data=payload)
396 req.add_header("Content-Type", content_type)
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400397 if extra_headers:
398 for header, value in extra_headers.items():
399 req.add_header(header, value)
Joe Gregorioe754eb82010-08-20 10:56:32 -0400400 try:
401 f = self.opener.open(req)
402 response = f.read()
403 f.close()
404 return response
405 except urllib2.HTTPError, e:
406 if tries > 3:
407 raise
408 elif e.code == 401 or e.code == 302:
409 self._Authenticate()
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400410 elif e.code == 301:
411 # Handle permanent redirect manually.
412 url = e.info()["location"]
413 url_loc = urlparse.urlparse(url)
414 self.host = '%s://%s' % (url_loc[0], url_loc[1])
Joe Gregorio003b6e42013-02-13 15:42:19 -0500415 elif e.code >= 500:
416 ErrorExit(e.read())
Joe Gregorioe754eb82010-08-20 10:56:32 -0400417 else:
418 raise
419 finally:
420 socket.setdefaulttimeout(old_timeout)
421
422
423class HttpRpcServer(AbstractRpcServer):
424 """Provides a simplified RPC-style interface for HTTP requests."""
425
426 def _Authenticate(self):
427 """Save the cookie jar after authentication."""
428 super(HttpRpcServer, self)._Authenticate()
429 if self.save_cookies:
430 StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
431 self.cookie_jar.save()
432
433 def _GetOpener(self):
434 """Returns an OpenerDirector that supports cookies and ignores redirects.
435
436 Returns:
437 A urllib2.OpenerDirector object.
438 """
439 opener = urllib2.OpenerDirector()
440 opener.add_handler(urllib2.ProxyHandler())
441 opener.add_handler(urllib2.UnknownHandler())
442 opener.add_handler(urllib2.HTTPHandler())
443 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
444 opener.add_handler(urllib2.HTTPSHandler())
445 opener.add_handler(urllib2.HTTPErrorProcessor())
446 if self.save_cookies:
447 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies")
448 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
449 if os.path.exists(self.cookie_file):
450 try:
451 self.cookie_jar.load()
452 self.authenticated = True
453 StatusUpdate("Loaded authentication cookies from %s" %
454 self.cookie_file)
455 except (cookielib.LoadError, IOError):
456 # Failed to load cookies - just ignore them.
457 pass
458 else:
459 # Create an empty cookie file with mode 600
460 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
461 os.close(fd)
462 # Always chmod the cookie file
463 os.chmod(self.cookie_file, 0600)
464 else:
465 # Don't save cookies across runs of update.py.
466 self.cookie_jar = cookielib.CookieJar()
467 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
468 return opener
469
470
Joe Gregorio003b6e42013-02-13 15:42:19 -0500471class CondensedHelpFormatter(optparse.IndentedHelpFormatter):
472 """Frees more horizontal space by removing indentation from group
473 options and collapsing arguments between short and long, e.g.
474 '-o ARG, --opt=ARG' to -o --opt ARG"""
475
476 def format_heading(self, heading):
477 return "%s:\n" % heading
478
479 def format_option(self, option):
480 self.dedent()
481 res = optparse.HelpFormatter.format_option(self, option)
482 self.indent()
483 return res
484
485 def format_option_strings(self, option):
486 self.set_long_opt_delimiter(" ")
487 optstr = optparse.HelpFormatter.format_option_strings(self, option)
488 optlist = optstr.split(", ")
489 if len(optlist) > 1:
490 if option.takes_value():
491 # strip METAVAR from all but the last option
492 optlist = [x.split()[0] for x in optlist[:-1]] + optlist[-1:]
493 optstr = " ".join(optlist)
494 return optstr
495
496
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400497parser = optparse.OptionParser(
Joe Gregorio003b6e42013-02-13 15:42:19 -0500498 usage="%prog [options] [-- diff_options] [path...]",
499 add_help_option=False,
500 formatter=CondensedHelpFormatter()
501)
502parser.add_option("-h", "--help", action="store_true",
503 help="Show this help message and exit.")
Joe Gregorioe754eb82010-08-20 10:56:32 -0400504parser.add_option("-y", "--assume_yes", action="store_true",
505 dest="assume_yes", default=False,
506 help="Assume that the answer to yes/no questions is 'yes'.")
507# Logging
508group = parser.add_option_group("Logging options")
509group.add_option("-q", "--quiet", action="store_const", const=0,
510 dest="verbose", help="Print errors only.")
511group.add_option("-v", "--verbose", action="store_const", const=2,
512 dest="verbose", default=1,
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400513 help="Print info level logs.")
Joe Gregorioe754eb82010-08-20 10:56:32 -0400514group.add_option("--noisy", action="store_const", const=3,
515 dest="verbose", help="Print all logs.")
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400516group.add_option("--print_diffs", dest="print_diffs", action="store_true",
517 help="Print full diffs.")
Joe Gregorioe754eb82010-08-20 10:56:32 -0400518# Review server
519group = parser.add_option_group("Review server options")
520group.add_option("-s", "--server", action="store", dest="server",
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400521 default=DEFAULT_REVIEW_SERVER,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400522 metavar="SERVER",
523 help=("The server to upload to. The format is host[:port]. "
524 "Defaults to '%default'."))
525group.add_option("-e", "--email", action="store", dest="email",
526 metavar="EMAIL", default=None,
527 help="The username to use. Will prompt if omitted.")
528group.add_option("-H", "--host", action="store", dest="host",
529 metavar="HOST", default=None,
530 help="Overrides the Host header sent with all RPCs.")
531group.add_option("--no_cookies", action="store_false",
532 dest="save_cookies", default=True,
533 help="Do not save authentication cookies to local disk.")
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400534group.add_option("--account_type", action="store", dest="account_type",
535 metavar="TYPE", default=AUTH_ACCOUNT_TYPE,
536 choices=["GOOGLE", "HOSTED"],
537 help=("Override the default account type "
538 "(defaults to '%default', "
539 "valid choices are 'GOOGLE' and 'HOSTED')."))
Joe Gregorioe754eb82010-08-20 10:56:32 -0400540# Issue
541group = parser.add_option_group("Issue options")
Joe Gregorio003b6e42013-02-13 15:42:19 -0500542group.add_option("-t", "--title", action="store", dest="title",
543 help="New issue subject or new patch set title")
544group.add_option("-m", "--message", action="store", dest="message",
Joe Gregorioe754eb82010-08-20 10:56:32 -0400545 default=None,
Joe Gregorio003b6e42013-02-13 15:42:19 -0500546 help="New issue description or new patch set message")
547group.add_option("-F", "--file", action="store", dest="file",
548 default=None, help="Read the message above from file.")
Joe Gregorioe754eb82010-08-20 10:56:32 -0400549group.add_option("-r", "--reviewers", action="store", dest="reviewers",
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400550 metavar="REVIEWERS", default="jcgregorio@google.com",
Joe Gregorioe754eb82010-08-20 10:56:32 -0400551 help="Add reviewers (comma separated email addresses).")
552group.add_option("--cc", action="store", dest="cc",
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400553 metavar="CC",
554 default="google-api-python-client@googlegroups.com",
Joe Gregorioe754eb82010-08-20 10:56:32 -0400555 help="Add CC (comma separated email addresses).")
556group.add_option("--private", action="store_true", dest="private",
557 default=False,
558 help="Make the issue restricted to reviewers and those CCed")
559# Upload options
560group = parser.add_option_group("Patch options")
Joe Gregorioe754eb82010-08-20 10:56:32 -0400561group.add_option("-i", "--issue", type="int", action="store",
562 metavar="ISSUE", default=None,
563 help="Issue number to which to add. Defaults to new issue.")
564group.add_option("--base_url", action="store", dest="base_url", default=None,
Joe Gregorio003b6e42013-02-13 15:42:19 -0500565 help="Base URL path for files (listed as \"Base URL\" when "
Joe Gregorioe754eb82010-08-20 10:56:32 -0400566 "viewing issue). If omitted, will be guessed automatically "
567 "for SVN repos and left blank for others.")
568group.add_option("--download_base", action="store_true",
569 dest="download_base", default=False,
570 help="Base files will be downloaded by the server "
571 "(side-by-side diffs may not work on files with CRs).")
572group.add_option("--rev", action="store", dest="revision",
573 metavar="REV", default=None,
574 help="Base revision/branch/tree to diff against. Use "
575 "rev1:rev2 range to review already committed changeset.")
576group.add_option("--send_mail", action="store_true",
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400577 dest="send_mail", default=False,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400578 help="Send notification email to reviewers.")
Joe Gregorio003b6e42013-02-13 15:42:19 -0500579group.add_option("-p", "--send_patch", action="store_true",
580 dest="send_patch", default=False,
581 help="Same as --send_mail, but include diff as an "
582 "attachment, and prepend email subject with 'PATCH:'.")
Joe Gregorioe754eb82010-08-20 10:56:32 -0400583group.add_option("--vcs", action="store", dest="vcs",
584 metavar="VCS", default=None,
585 help=("Version control system (optional, usually upload.py "
586 "already guesses the right VCS)."))
587group.add_option("--emulate_svn_auto_props", action="store_true",
588 dest="emulate_svn_auto_props", default=False,
589 help=("Emulate Subversion's auto properties feature."))
Joe Gregorio003b6e42013-02-13 15:42:19 -0500590# Git-specific
591group = parser.add_option_group("Git-specific options")
592group.add_option("--git_similarity", action="store", dest="git_similarity",
593 metavar="SIM", type="int", default=50,
594 help=("Set the minimum similarity index for detecting renames "
595 "and copies. See `git diff -C`. (default 50)."))
596group.add_option("--git_no_find_copies", action="store_false", default=True,
597 dest="git_find_copies",
598 help=("Prevents git from looking for copies (default off)."))
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400599# Perforce-specific
600group = parser.add_option_group("Perforce-specific options "
601 "(overrides P4 environment variables)")
602group.add_option("--p4_port", action="store", dest="p4_port",
603 metavar="P4_PORT", default=None,
604 help=("Perforce server and port (optional)"))
605group.add_option("--p4_changelist", action="store", dest="p4_changelist",
606 metavar="P4_CHANGELIST", default=None,
607 help=("Perforce changelist id"))
608group.add_option("--p4_client", action="store", dest="p4_client",
609 metavar="P4_CLIENT", default=None,
610 help=("Perforce client/workspace"))
611group.add_option("--p4_user", action="store", dest="p4_user",
612 metavar="P4_USER", default=None,
613 help=("Perforce user"))
Joe Gregorioe754eb82010-08-20 10:56:32 -0400614
Joe Gregorio003b6e42013-02-13 15:42:19 -0500615
616class KeyringCreds(object):
617 def __init__(self, server, host, email):
618 self.server = server
619 self.host = host
620 self.email = email
621 self.accounts_seen = set()
622
623 def GetUserCredentials(self):
624 """Prompts the user for a username and password.
625
626 Only use keyring on the initial call. If the keyring contains the wrong
627 password, we want to give the user a chance to enter another one.
628 """
629 # Create a local alias to the email variable to avoid Python's crazy
630 # scoping rules.
631 global keyring
632 email = self.email
633 if email is None:
634 email = GetEmail("Email (login for uploading to %s)" % self.server)
635 password = None
636 if keyring and not email in self.accounts_seen:
637 try:
638 password = keyring.get_password(self.host, email)
639 except:
640 # Sadly, we have to trap all errors here as
641 # gnomekeyring.IOError inherits from object. :/
642 print "Failed to get password from keyring"
643 keyring = None
644 if password is not None:
645 print "Using password from system keyring."
646 self.accounts_seen.add(email)
647 else:
648 password = getpass.getpass("Password for %s: " % email)
649 if keyring:
650 answer = raw_input("Store password in system keyring?(y/N) ").strip()
651 if answer == "y":
652 keyring.set_password(self.host, email, password)
653 self.accounts_seen.add(email)
654 return (email, password)
655
656
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400657def GetRpcServer(server, email=None, host_override=None, save_cookies=True,
658 account_type=AUTH_ACCOUNT_TYPE):
Joe Gregorioe754eb82010-08-20 10:56:32 -0400659 """Returns an instance of an AbstractRpcServer.
660
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400661 Args:
662 server: String containing the review server URL.
663 email: String containing user's email address.
664 host_override: If not None, string containing an alternate hostname to use
665 in the host header.
666 save_cookies: Whether authentication cookies should be saved to disk.
667 account_type: Account type for authentication, either 'GOOGLE'
668 or 'HOSTED'. Defaults to AUTH_ACCOUNT_TYPE.
669
Joe Gregorioe754eb82010-08-20 10:56:32 -0400670 Returns:
Joe Gregorio003b6e42013-02-13 15:42:19 -0500671 A new HttpRpcServer, on which RPC calls can be made.
Joe Gregorioe754eb82010-08-20 10:56:32 -0400672 """
673
Joe Gregorioe754eb82010-08-20 10:56:32 -0400674 # If this is the dev_appserver, use fake authentication.
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400675 host = (host_override or server).lower()
676 if re.match(r'(http://)?localhost([:/]|$)', host):
Joe Gregorioe754eb82010-08-20 10:56:32 -0400677 if email is None:
678 email = "test@example.com"
679 logging.info("Using debug user %s. Override with --email" % email)
Joe Gregorio003b6e42013-02-13 15:42:19 -0500680 server = HttpRpcServer(
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400681 server,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400682 lambda: (email, "password"),
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400683 host_override=host_override,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400684 extra_headers={"Cookie":
685 'dev_appserver_login="%s:False"' % email},
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400686 save_cookies=save_cookies,
687 account_type=account_type)
Joe Gregorioe754eb82010-08-20 10:56:32 -0400688 # Don't try to talk to ClientLogin.
689 server.authenticated = True
690 return server
691
Joe Gregorio003b6e42013-02-13 15:42:19 -0500692 return HttpRpcServer(server,
693 KeyringCreds(server, host, email).GetUserCredentials,
694 host_override=host_override,
695 save_cookies=save_cookies,
696 account_type=account_type)
Joe Gregorioe754eb82010-08-20 10:56:32 -0400697
698
699def EncodeMultipartFormData(fields, files):
700 """Encode form fields for multipart/form-data.
701
702 Args:
703 fields: A sequence of (name, value) elements for regular form fields.
704 files: A sequence of (name, filename, value) elements for data to be
705 uploaded as files.
706 Returns:
707 (content_type, body) ready for httplib.HTTP instance.
708
709 Source:
710 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
711 """
712 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
713 CRLF = '\r\n'
714 lines = []
715 for (key, value) in fields:
716 lines.append('--' + BOUNDARY)
717 lines.append('Content-Disposition: form-data; name="%s"' % key)
718 lines.append('')
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400719 if isinstance(value, unicode):
720 value = value.encode('utf-8')
Joe Gregorioe754eb82010-08-20 10:56:32 -0400721 lines.append(value)
722 for (key, filename, value) in files:
723 lines.append('--' + BOUNDARY)
724 lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' %
725 (key, filename))
726 lines.append('Content-Type: %s' % GetContentType(filename))
727 lines.append('')
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400728 if isinstance(value, unicode):
729 value = value.encode('utf-8')
Joe Gregorioe754eb82010-08-20 10:56:32 -0400730 lines.append(value)
731 lines.append('--' + BOUNDARY + '--')
732 lines.append('')
733 body = CRLF.join(lines)
734 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
735 return content_type, body
736
737
738def GetContentType(filename):
739 """Helper to guess the content-type from the filename."""
740 return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
741
742
743# Use a shell for subcommands on Windows to get a PATH search.
744use_shell = sys.platform.startswith("win")
745
Joe Gregorio003b6e42013-02-13 15:42:19 -0500746def RunShellWithReturnCodeAndStderr(command, print_output=False,
Joe Gregorioe754eb82010-08-20 10:56:32 -0400747 universal_newlines=True,
748 env=os.environ):
Joe Gregorio003b6e42013-02-13 15:42:19 -0500749 """Executes a command and returns the output from stdout, stderr and the return code.
Joe Gregorioe754eb82010-08-20 10:56:32 -0400750
751 Args:
752 command: Command to execute.
753 print_output: If True, the output is printed to stdout.
754 If False, both stdout and stderr are ignored.
755 universal_newlines: Use universal_newlines flag (default: True).
756
757 Returns:
Joe Gregorio003b6e42013-02-13 15:42:19 -0500758 Tuple (stdout, stderr, return code)
Joe Gregorioe754eb82010-08-20 10:56:32 -0400759 """
760 logging.info("Running %s", command)
Joe Gregorio003b6e42013-02-13 15:42:19 -0500761 env = env.copy()
762 env['LC_MESSAGES'] = 'C'
Joe Gregorioe754eb82010-08-20 10:56:32 -0400763 p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
764 shell=use_shell, universal_newlines=universal_newlines,
765 env=env)
766 if print_output:
767 output_array = []
768 while True:
769 line = p.stdout.readline()
770 if not line:
771 break
772 print line.strip("\n")
773 output_array.append(line)
774 output = "".join(output_array)
775 else:
776 output = p.stdout.read()
777 p.wait()
778 errout = p.stderr.read()
779 if print_output and errout:
780 print >>sys.stderr, errout
781 p.stdout.close()
782 p.stderr.close()
Joe Gregorio003b6e42013-02-13 15:42:19 -0500783 return output, errout, p.returncode
Joe Gregorioe754eb82010-08-20 10:56:32 -0400784
Joe Gregorio003b6e42013-02-13 15:42:19 -0500785def RunShellWithReturnCode(command, print_output=False,
786 universal_newlines=True,
787 env=os.environ):
788 """Executes a command and returns the output from stdout and the return code."""
789 out, err, retcode = RunShellWithReturnCodeAndStderr(command, print_output,
790 universal_newlines, env)
791 return out, retcode
Joe Gregorioe754eb82010-08-20 10:56:32 -0400792
793def RunShell(command, silent_ok=False, universal_newlines=True,
794 print_output=False, env=os.environ):
795 data, retcode = RunShellWithReturnCode(command, print_output,
796 universal_newlines, env)
797 if retcode:
798 ErrorExit("Got error status from %s:\n%s" % (command, data))
799 if not silent_ok and not data:
800 ErrorExit("No output from %s" % command)
801 return data
802
803
804class VersionControlSystem(object):
805 """Abstract base class providing an interface to the VCS."""
806
807 def __init__(self, options):
808 """Constructor.
809
810 Args:
811 options: Command line options.
812 """
813 self.options = options
814
Joe Gregorio003b6e42013-02-13 15:42:19 -0500815 def GetGUID(self):
816 """Return string to distinguish the repository from others, for example to
817 query all opened review issues for it"""
818 raise NotImplementedError(
819 "abstract method -- subclass %s must override" % self.__class__)
820
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400821 def PostProcessDiff(self, diff):
822 """Return the diff with any special post processing this VCS needs, e.g.
823 to include an svn-style "Index:"."""
824 return diff
825
Joe Gregorioe754eb82010-08-20 10:56:32 -0400826 def GenerateDiff(self, args):
827 """Return the current diff as a string.
828
829 Args:
830 args: Extra arguments to pass to the diff command.
831 """
832 raise NotImplementedError(
833 "abstract method -- subclass %s must override" % self.__class__)
834
835 def GetUnknownFiles(self):
836 """Return a list of files unknown to the VCS."""
837 raise NotImplementedError(
838 "abstract method -- subclass %s must override" % self.__class__)
839
840 def CheckForUnknownFiles(self):
841 """Show an "are you sure?" prompt if there are unknown files."""
842 unknown_files = self.GetUnknownFiles()
843 if unknown_files:
844 print "The following files are not added to version control:"
845 for line in unknown_files:
846 print line
847 prompt = "Are you sure to continue?(y/N) "
848 answer = raw_input(prompt).strip()
849 if answer != "y":
850 ErrorExit("User aborted")
851
852 def GetBaseFile(self, filename):
853 """Get the content of the upstream version of a file.
854
855 Returns:
856 A tuple (base_content, new_content, is_binary, status)
857 base_content: The contents of the base file.
858 new_content: For text files, this is empty. For binary files, this is
859 the contents of the new file, since the diff output won't contain
860 information to reconstruct the current file.
861 is_binary: True iff the file is binary.
862 status: The status of the file.
863 """
864
865 raise NotImplementedError(
866 "abstract method -- subclass %s must override" % self.__class__)
867
868
869 def GetBaseFiles(self, diff):
870 """Helper that calls GetBase file for each file in the patch.
871
872 Returns:
873 A dictionary that maps from filename to GetBaseFile's tuple. Filenames
874 are retrieved based on lines that start with "Index:" or
875 "Property changes on:".
876 """
877 files = {}
878 for line in diff.splitlines(True):
879 if line.startswith('Index:') or line.startswith('Property changes on:'):
880 unused, filename = line.split(':', 1)
881 # On Windows if a file has property changes its filename uses '\'
882 # instead of '/'.
883 filename = filename.strip().replace('\\', '/')
884 files[filename] = self.GetBaseFile(filename)
885 return files
886
887
888 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
889 files):
890 """Uploads the base files (and if necessary, the current ones as well)."""
891
892 def UploadFile(filename, file_id, content, is_binary, status, is_base):
893 """Uploads a file to the server."""
894 file_too_large = False
895 if is_base:
896 type = "base"
897 else:
898 type = "current"
899 if len(content) > MAX_UPLOAD_SIZE:
900 print ("Not uploading the %s file for %s because it's too large." %
901 (type, filename))
902 file_too_large = True
903 content = ""
904 checksum = md5(content).hexdigest()
905 if options.verbose > 0 and not file_too_large:
906 print "Uploading %s file for %s" % (type, filename)
907 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
908 form_fields = [("filename", filename),
909 ("status", status),
910 ("checksum", checksum),
911 ("is_binary", str(is_binary)),
912 ("is_current", str(not is_base)),
913 ]
914 if file_too_large:
915 form_fields.append(("file_too_large", "1"))
916 if options.email:
917 form_fields.append(("user", options.email))
918 ctype, body = EncodeMultipartFormData(form_fields,
919 [("data", filename, content)])
920 response_body = rpc_server.Send(url, body,
921 content_type=ctype)
922 if not response_body.startswith("OK"):
923 StatusUpdate(" --> %s" % response_body)
924 sys.exit(1)
925
926 patches = dict()
927 [patches.setdefault(v, k) for k, v in patch_list]
928 for filename in patches.keys():
929 base_content, new_content, is_binary, status = files[filename]
930 file_id_str = patches.get(filename)
931 if file_id_str.find("nobase") != -1:
932 base_content = None
933 file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
934 file_id = int(file_id_str)
935 if base_content != None:
936 UploadFile(filename, file_id, base_content, is_binary, status, True)
937 if new_content != None:
938 UploadFile(filename, file_id, new_content, is_binary, status, False)
939
940 def IsImage(self, filename):
941 """Returns true if the filename has an image extension."""
942 mimetype = mimetypes.guess_type(filename)[0]
943 if not mimetype:
944 return False
945 return mimetype.startswith("image/")
946
Joe Gregorio003b6e42013-02-13 15:42:19 -0500947 def IsBinaryData(self, data):
948 """Returns true if data contains a null byte."""
949 # Derived from how Mercurial's heuristic, see
950 # http://selenic.com/hg/file/848a6658069e/mercurial/util.py#l229
951 return bool(data and "\0" in data)
Joe Gregorioe754eb82010-08-20 10:56:32 -0400952
953
954class SubversionVCS(VersionControlSystem):
955 """Implementation of the VersionControlSystem interface for Subversion."""
956
957 def __init__(self, options):
958 super(SubversionVCS, self).__init__(options)
959 if self.options.revision:
960 match = re.match(r"(\d+)(:(\d+))?", self.options.revision)
961 if not match:
962 ErrorExit("Invalid Subversion revision %s." % self.options.revision)
963 self.rev_start = match.group(1)
964 self.rev_end = match.group(3)
965 else:
966 self.rev_start = self.rev_end = None
967 # Cache output from "svn list -r REVNO dirname".
968 # Keys: dirname, Values: 2-tuple (ouput for start rev and end rev).
969 self.svnls_cache = {}
970 # Base URL is required to fetch files deleted in an older revision.
971 # Result is cached to not guess it over and over again in GetBaseFile().
972 required = self.options.download_base or self.options.revision is not None
973 self.svn_base = self._GuessBase(required)
974
Joe Gregorio003b6e42013-02-13 15:42:19 -0500975 def GetGUID(self):
976 return self._GetInfo("Repository UUID")
977
Joe Gregorioe754eb82010-08-20 10:56:32 -0400978 def GuessBase(self, required):
979 """Wrapper for _GuessBase."""
980 return self.svn_base
981
982 def _GuessBase(self, required):
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400983 """Returns base URL for current diff.
Joe Gregorioe754eb82010-08-20 10:56:32 -0400984
985 Args:
986 required: If true, exits if the url can't be guessed, otherwise None is
987 returned.
988 """
Joe Gregorio003b6e42013-02-13 15:42:19 -0500989 url = self._GetInfo("URL")
990 if url:
Joe Gregorioe754eb82010-08-20 10:56:32 -0400991 scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400992 guess = ""
Joe Gregorio003b6e42013-02-13 15:42:19 -0500993 # TODO(anatoli) - repository specific hacks should be handled by server
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400994 if netloc == "svn.python.org" and scheme == "svn+ssh":
995 path = "projects" + path
996 scheme = "http"
997 guess = "Python "
Joe Gregorioe754eb82010-08-20 10:56:32 -0400998 elif netloc.endswith(".googlecode.com"):
Joe Gregorio30a08fd2011-04-11 11:33:42 -0400999 scheme = "http"
1000 guess = "Google Code "
1001 path = path + "/"
1002 base = urlparse.urlunparse((scheme, netloc, path, params,
1003 query, fragment))
1004 logging.info("Guessed %sbase = %s", guess, base)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001005 return base
1006 if required:
1007 ErrorExit("Can't find URL in output from svn info")
1008 return None
1009
Joe Gregorio003b6e42013-02-13 15:42:19 -05001010 def _GetInfo(self, key):
1011 """Parses 'svn info' for current dir. Returns value for key or None"""
1012 for line in RunShell(["svn", "info"]).splitlines():
1013 if line.startswith(key + ": "):
1014 return line.split(":", 1)[1].strip()
1015
1016 def _EscapeFilename(self, filename):
1017 """Escapes filename for SVN commands."""
1018 if "@" in filename and not filename.endswith("@"):
1019 filename = "%s@" % filename
1020 return filename
1021
Joe Gregorioe754eb82010-08-20 10:56:32 -04001022 def GenerateDiff(self, args):
1023 cmd = ["svn", "diff"]
1024 if self.options.revision:
1025 cmd += ["-r", self.options.revision]
1026 cmd.extend(args)
1027 data = RunShell(cmd)
1028 count = 0
1029 for line in data.splitlines():
1030 if line.startswith("Index:") or line.startswith("Property changes on:"):
1031 count += 1
1032 logging.info(line)
1033 if not count:
1034 ErrorExit("No valid patches found in output from svn diff")
1035 return data
1036
1037 def _CollapseKeywords(self, content, keyword_str):
1038 """Collapses SVN keywords."""
1039 # svn cat translates keywords but svn diff doesn't. As a result of this
1040 # behavior patching.PatchChunks() fails with a chunk mismatch error.
1041 # This part was originally written by the Review Board development team
1042 # who had the same problem (http://reviews.review-board.org/r/276/).
1043 # Mapping of keywords to known aliases
1044 svn_keywords = {
1045 # Standard keywords
1046 'Date': ['Date', 'LastChangedDate'],
1047 'Revision': ['Revision', 'LastChangedRevision', 'Rev'],
1048 'Author': ['Author', 'LastChangedBy'],
1049 'HeadURL': ['HeadURL', 'URL'],
1050 'Id': ['Id'],
1051
1052 # Aliases
1053 'LastChangedDate': ['LastChangedDate', 'Date'],
1054 'LastChangedRevision': ['LastChangedRevision', 'Rev', 'Revision'],
1055 'LastChangedBy': ['LastChangedBy', 'Author'],
1056 'URL': ['URL', 'HeadURL'],
1057 }
1058
1059 def repl(m):
1060 if m.group(2):
1061 return "$%s::%s$" % (m.group(1), " " * len(m.group(3)))
1062 return "$%s$" % m.group(1)
1063 keywords = [keyword
1064 for name in keyword_str.split(" ")
1065 for keyword in svn_keywords.get(name, [])]
1066 return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content)
1067
1068 def GetUnknownFiles(self):
1069 status = RunShell(["svn", "status", "--ignore-externals"], silent_ok=True)
1070 unknown_files = []
1071 for line in status.split("\n"):
1072 if line and line[0] == "?":
1073 unknown_files.append(line)
1074 return unknown_files
1075
1076 def ReadFile(self, filename):
1077 """Returns the contents of a file."""
1078 file = open(filename, 'rb')
1079 result = ""
1080 try:
1081 result = file.read()
1082 finally:
1083 file.close()
1084 return result
1085
1086 def GetStatus(self, filename):
1087 """Returns the status of a file."""
1088 if not self.options.revision:
Joe Gregorio003b6e42013-02-13 15:42:19 -05001089 status = RunShell(["svn", "status", "--ignore-externals",
1090 self._EscapeFilename(filename)])
Joe Gregorioe754eb82010-08-20 10:56:32 -04001091 if not status:
1092 ErrorExit("svn status returned no output for %s" % filename)
1093 status_lines = status.splitlines()
1094 # If file is in a cl, the output will begin with
1095 # "\n--- Changelist 'cl_name':\n". See
1096 # http://svn.collab.net/repos/svn/trunk/notes/changelist-design.txt
1097 if (len(status_lines) == 3 and
1098 not status_lines[0] and
1099 status_lines[1].startswith("--- Changelist")):
1100 status = status_lines[2]
1101 else:
1102 status = status_lines[0]
1103 # If we have a revision to diff against we need to run "svn list"
1104 # for the old and the new revision and compare the results to get
1105 # the correct status for a file.
1106 else:
1107 dirname, relfilename = os.path.split(filename)
1108 if dirname not in self.svnls_cache:
Joe Gregorio003b6e42013-02-13 15:42:19 -05001109 cmd = ["svn", "list", "-r", self.rev_start,
1110 self._EscapeFilename(dirname) or "."]
1111 out, err, returncode = RunShellWithReturnCodeAndStderr(cmd)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001112 if returncode:
Joe Gregorio003b6e42013-02-13 15:42:19 -05001113 # Directory might not yet exist at start revison
1114 # svn: Unable to find repository location for 'abc' in revision nnn
1115 if re.match('^svn: Unable to find repository location for .+ in revision \d+', err):
1116 old_files = ()
1117 else:
1118 ErrorExit("Failed to get status for %s:\n%s" % (filename, err))
1119 else:
1120 old_files = out.splitlines()
Joe Gregorioe754eb82010-08-20 10:56:32 -04001121 args = ["svn", "list"]
1122 if self.rev_end:
1123 args += ["-r", self.rev_end]
Joe Gregorio003b6e42013-02-13 15:42:19 -05001124 cmd = args + [self._EscapeFilename(dirname) or "."]
Joe Gregorioe754eb82010-08-20 10:56:32 -04001125 out, returncode = RunShellWithReturnCode(cmd)
1126 if returncode:
1127 ErrorExit("Failed to run command %s" % cmd)
1128 self.svnls_cache[dirname] = (old_files, out.splitlines())
1129 old_files, new_files = self.svnls_cache[dirname]
1130 if relfilename in old_files and relfilename not in new_files:
1131 status = "D "
1132 elif relfilename in old_files and relfilename in new_files:
1133 status = "M "
1134 else:
1135 status = "A "
1136 return status
1137
1138 def GetBaseFile(self, filename):
1139 status = self.GetStatus(filename)
1140 base_content = None
1141 new_content = None
1142
1143 # If a file is copied its status will be "A +", which signifies
1144 # "addition-with-history". See "svn st" for more information. We need to
1145 # upload the original file or else diff parsing will fail if the file was
1146 # edited.
1147 if status[0] == "A" and status[3] != "+":
1148 # We'll need to upload the new content if we're adding a binary file
1149 # since diff's output won't contain it.
Joe Gregorio003b6e42013-02-13 15:42:19 -05001150 mimetype = RunShell(["svn", "propget", "svn:mime-type",
1151 self._EscapeFilename(filename)], silent_ok=True)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001152 base_content = ""
1153 is_binary = bool(mimetype) and not mimetype.startswith("text/")
Joe Gregorio003b6e42013-02-13 15:42:19 -05001154 if is_binary:
Joe Gregorioe754eb82010-08-20 10:56:32 -04001155 new_content = self.ReadFile(filename)
1156 elif (status[0] in ("M", "D", "R") or
1157 (status[0] == "A" and status[3] == "+") or # Copied file.
1158 (status[0] == " " and status[1] == "M")): # Property change.
1159 args = []
1160 if self.options.revision:
Joe Gregorio003b6e42013-02-13 15:42:19 -05001161 # filename must not be escaped. We already add an ampersand here.
Joe Gregorioe754eb82010-08-20 10:56:32 -04001162 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
1163 else:
1164 # Don't change filename, it's needed later.
1165 url = filename
1166 args += ["-r", "BASE"]
1167 cmd = ["svn"] + args + ["propget", "svn:mime-type", url]
1168 mimetype, returncode = RunShellWithReturnCode(cmd)
1169 if returncode:
1170 # File does not exist in the requested revision.
1171 # Reset mimetype, it contains an error message.
1172 mimetype = ""
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001173 else:
1174 mimetype = mimetype.strip()
Joe Gregorioe754eb82010-08-20 10:56:32 -04001175 get_base = False
Joe Gregorio003b6e42013-02-13 15:42:19 -05001176 # this test for binary is exactly the test prescribed by the
1177 # official SVN docs at
1178 # http://subversion.apache.org/faq.html#binary-files
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001179 is_binary = (bool(mimetype) and
1180 not mimetype.startswith("text/") and
Joe Gregorio003b6e42013-02-13 15:42:19 -05001181 mimetype not in ("image/x-xbitmap", "image/x-xpixmap"))
Joe Gregorioe754eb82010-08-20 10:56:32 -04001182 if status[0] == " ":
1183 # Empty base content just to force an upload.
1184 base_content = ""
1185 elif is_binary:
Joe Gregorio003b6e42013-02-13 15:42:19 -05001186 get_base = True
1187 if status[0] == "M":
1188 if not self.rev_end:
1189 new_content = self.ReadFile(filename)
1190 else:
1191 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_end)
1192 new_content = RunShell(["svn", "cat", url],
1193 universal_newlines=True, silent_ok=True)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001194 else:
1195 get_base = True
1196
1197 if get_base:
1198 if is_binary:
1199 universal_newlines = False
1200 else:
1201 universal_newlines = True
1202 if self.rev_start:
1203 # "svn cat -r REV delete_file.txt" doesn't work. cat requires
1204 # the full URL with "@REV" appended instead of using "-r" option.
1205 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
1206 base_content = RunShell(["svn", "cat", url],
1207 universal_newlines=universal_newlines,
1208 silent_ok=True)
1209 else:
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001210 base_content, ret_code = RunShellWithReturnCode(
Joe Gregorio003b6e42013-02-13 15:42:19 -05001211 ["svn", "cat", self._EscapeFilename(filename)],
1212 universal_newlines=universal_newlines)
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001213 if ret_code and status[0] == "R":
1214 # It's a replaced file without local history (see issue208).
1215 # The base file needs to be fetched from the server.
1216 url = "%s/%s" % (self.svn_base, filename)
1217 base_content = RunShell(["svn", "cat", url],
1218 universal_newlines=universal_newlines,
1219 silent_ok=True)
1220 elif ret_code:
1221 ErrorExit("Got error status from 'svn cat %s'" % filename)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001222 if not is_binary:
1223 args = []
1224 if self.rev_start:
1225 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
1226 else:
1227 url = filename
1228 args += ["-r", "BASE"]
1229 cmd = ["svn"] + args + ["propget", "svn:keywords", url]
1230 keywords, returncode = RunShellWithReturnCode(cmd)
1231 if keywords and not returncode:
1232 base_content = self._CollapseKeywords(base_content, keywords)
1233 else:
1234 StatusUpdate("svn status returned unexpected output: %s" % status)
1235 sys.exit(1)
1236 return base_content, new_content, is_binary, status[0:5]
1237
1238
1239class GitVCS(VersionControlSystem):
1240 """Implementation of the VersionControlSystem interface for Git."""
1241
1242 def __init__(self, options):
1243 super(GitVCS, self).__init__(options)
1244 # Map of filename -> (hash before, hash after) of base file.
1245 # Hashes for "no such file" are represented as None.
1246 self.hashes = {}
1247 # Map of new filename -> old filename for renames.
1248 self.renames = {}
1249
Joe Gregorio003b6e42013-02-13 15:42:19 -05001250 def GetGUID(self):
1251 revlist = RunShell("git rev-list --parents HEAD".split()).splitlines()
1252 # M-A: Return the 1st root hash, there could be multiple when a
1253 # subtree is merged. In that case, more analysis would need to
1254 # be done to figure out which HEAD is the 'most representative'.
1255 for r in revlist:
1256 if ' ' not in r:
1257 return r
1258
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001259 def PostProcessDiff(self, gitdiff):
1260 """Converts the diff output to include an svn-style "Index:" line as well
1261 as record the hashes of the files, so we can upload them along with our
1262 diff."""
Joe Gregorioe754eb82010-08-20 10:56:32 -04001263 # Special used by git to indicate "no such content".
1264 NULL_HASH = "0"*40
1265
Joe Gregorioe754eb82010-08-20 10:56:32 -04001266 def IsFileNew(filename):
1267 return filename in self.hashes and self.hashes[filename][0] is None
1268
1269 def AddSubversionPropertyChange(filename):
1270 """Add svn's property change information into the patch if given file is
1271 new file.
1272
1273 We use Subversion's auto-props setting to retrieve its property.
1274 See http://svnbook.red-bean.com/en/1.1/ch07.html#svn-ch-7-sect-1.3.2 for
1275 Subversion's [auto-props] setting.
1276 """
1277 if self.options.emulate_svn_auto_props and IsFileNew(filename):
1278 svnprops = GetSubversionPropertyChanges(filename)
1279 if svnprops:
1280 svndiff.append("\n" + svnprops + "\n")
1281
1282 svndiff = []
1283 filecount = 0
1284 filename = None
1285 for line in gitdiff.splitlines():
1286 match = re.match(r"diff --git a/(.*) b/(.*)$", line)
1287 if match:
1288 # Add auto property here for previously seen file.
1289 if filename is not None:
1290 AddSubversionPropertyChange(filename)
1291 filecount += 1
1292 # Intentionally use the "after" filename so we can show renames.
1293 filename = match.group(2)
1294 svndiff.append("Index: %s\n" % filename)
1295 if match.group(1) != match.group(2):
1296 self.renames[match.group(2)] = match.group(1)
1297 else:
1298 # The "index" line in a git diff looks like this (long hashes elided):
1299 # index 82c0d44..b2cee3f 100755
1300 # We want to save the left hash, as that identifies the base file.
1301 match = re.match(r"index (\w+)\.\.(\w+)", line)
1302 if match:
1303 before, after = (match.group(1), match.group(2))
1304 if before == NULL_HASH:
1305 before = None
1306 if after == NULL_HASH:
1307 after = None
1308 self.hashes[filename] = (before, after)
1309 svndiff.append(line + "\n")
1310 if not filecount:
1311 ErrorExit("No valid patches found in output from git diff")
1312 # Add auto property for the last seen file.
1313 assert filename is not None
1314 AddSubversionPropertyChange(filename)
1315 return "".join(svndiff)
1316
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001317 def GenerateDiff(self, extra_args):
1318 extra_args = extra_args[:]
1319 if self.options.revision:
1320 if ":" in self.options.revision:
1321 extra_args = self.options.revision.split(":", 1) + extra_args
1322 else:
1323 extra_args = [self.options.revision] + extra_args
1324
1325 # --no-ext-diff is broken in some versions of Git, so try to work around
1326 # this by overriding the environment (but there is still a problem if the
1327 # git config key "diff.external" is used).
1328 env = os.environ.copy()
Joe Gregorio003b6e42013-02-13 15:42:19 -05001329 if "GIT_EXTERNAL_DIFF" in env:
1330 del env["GIT_EXTERNAL_DIFF"]
1331 # -M/-C will not print the diff for the deleted file when a file is renamed.
1332 # This is confusing because the original file will not be shown on the
1333 # review when a file is renamed. So, get a diff with ONLY deletes, then
1334 # append a diff (with rename detection), without deletes.
1335 cmd = [
1336 "git", "diff", "--no-color", "--no-ext-diff", "--full-index",
1337 "--ignore-submodules",
1338 ]
1339 diff = RunShell(
1340 cmd + ["--no-renames", "--diff-filter=D"] + extra_args,
1341 env=env, silent_ok=True)
1342 if self.options.git_find_copies:
1343 similarity_options = ["--find-copies-harder", "-l100000",
1344 "-C%s" % self.options.git_similarity ]
1345 else:
1346 similarity_options = ["-M%s" % self.options.git_similarity ]
1347 diff += RunShell(
1348 cmd + ["--diff-filter=AMCRT"] + similarity_options + extra_args,
1349 env=env, silent_ok=True)
1350
1351 # The CL could be only file deletion or not. So accept silent diff for both
1352 # commands then check for an empty diff manually.
1353 if not diff:
1354 ErrorExit("No output from %s" % (cmd + extra_args))
1355 return diff
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001356
Joe Gregorioe754eb82010-08-20 10:56:32 -04001357 def GetUnknownFiles(self):
1358 status = RunShell(["git", "ls-files", "--exclude-standard", "--others"],
1359 silent_ok=True)
1360 return status.splitlines()
1361
1362 def GetFileContent(self, file_hash, is_binary):
1363 """Returns the content of a file identified by its git hash."""
1364 data, retcode = RunShellWithReturnCode(["git", "show", file_hash],
1365 universal_newlines=not is_binary)
1366 if retcode:
1367 ErrorExit("Got error status from 'git show %s'" % file_hash)
1368 return data
1369
1370 def GetBaseFile(self, filename):
1371 hash_before, hash_after = self.hashes.get(filename, (None,None))
1372 base_content = None
1373 new_content = None
Joe Gregorioe754eb82010-08-20 10:56:32 -04001374 status = None
1375
1376 if filename in self.renames:
1377 status = "A +" # Match svn attribute name for renames.
1378 if filename not in self.hashes:
1379 # If a rename doesn't change the content, we never get a hash.
Joe Gregorio003b6e42013-02-13 15:42:19 -05001380 base_content = RunShell(
1381 ["git", "show", "HEAD:" + filename], silent_ok=True)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001382 elif not hash_before:
1383 status = "A"
1384 base_content = ""
1385 elif not hash_after:
1386 status = "D"
1387 else:
1388 status = "M"
1389
Joe Gregorio003b6e42013-02-13 15:42:19 -05001390 is_binary = self.IsBinaryData(base_content)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001391 is_image = self.IsImage(filename)
1392
1393 # Grab the before/after content if we need it.
Joe Gregorio003b6e42013-02-13 15:42:19 -05001394 # Grab the base content if we don't have it already.
1395 if base_content is None and hash_before:
1396 base_content = self.GetFileContent(hash_before, is_binary)
1397 # Only include the "after" file if it's an image; otherwise it
1398 # it is reconstructed from the diff.
1399 if is_image and hash_after:
1400 new_content = self.GetFileContent(hash_after, is_binary)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001401
1402 return (base_content, new_content, is_binary, status)
1403
1404
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001405class CVSVCS(VersionControlSystem):
1406 """Implementation of the VersionControlSystem interface for CVS."""
1407
1408 def __init__(self, options):
1409 super(CVSVCS, self).__init__(options)
1410
Joe Gregorio003b6e42013-02-13 15:42:19 -05001411 def GetGUID(self):
1412 """For now we don't know how to get repository ID for CVS"""
1413 return
1414
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001415 def GetOriginalContent_(self, filename):
1416 RunShell(["cvs", "up", filename], silent_ok=True)
1417 # TODO need detect file content encoding
1418 content = open(filename).read()
1419 return content.replace("\r\n", "\n")
1420
1421 def GetBaseFile(self, filename):
1422 base_content = None
1423 new_content = None
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001424 status = "A"
1425
1426 output, retcode = RunShellWithReturnCode(["cvs", "status", filename])
1427 if retcode:
1428 ErrorExit("Got error status from 'cvs status %s'" % filename)
1429
1430 if output.find("Status: Locally Modified") != -1:
1431 status = "M"
1432 temp_filename = "%s.tmp123" % filename
1433 os.rename(filename, temp_filename)
1434 base_content = self.GetOriginalContent_(filename)
1435 os.rename(temp_filename, filename)
1436 elif output.find("Status: Locally Added"):
1437 status = "A"
1438 base_content = ""
1439 elif output.find("Status: Needs Checkout"):
1440 status = "D"
1441 base_content = self.GetOriginalContent_(filename)
1442
Joe Gregorio003b6e42013-02-13 15:42:19 -05001443 return (base_content, new_content, self.IsBinaryData(base_content), status)
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001444
1445 def GenerateDiff(self, extra_args):
1446 cmd = ["cvs", "diff", "-u", "-N"]
1447 if self.options.revision:
1448 cmd += ["-r", self.options.revision]
1449
1450 cmd.extend(extra_args)
1451 data, retcode = RunShellWithReturnCode(cmd)
1452 count = 0
Joe Gregorio003b6e42013-02-13 15:42:19 -05001453 if retcode in [0, 1]:
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001454 for line in data.splitlines():
1455 if line.startswith("Index:"):
1456 count += 1
1457 logging.info(line)
1458
1459 if not count:
1460 ErrorExit("No valid patches found in output from cvs diff")
1461
1462 return data
1463
1464 def GetUnknownFiles(self):
Joe Gregorio003b6e42013-02-13 15:42:19 -05001465 data, retcode = RunShellWithReturnCode(["cvs", "diff"])
1466 if retcode not in [0, 1]:
1467 ErrorExit("Got error status from 'cvs diff':\n%s" % (data,))
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001468 unknown_files = []
Joe Gregorio003b6e42013-02-13 15:42:19 -05001469 for line in data.split("\n"):
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001470 if line and line[0] == "?":
1471 unknown_files.append(line)
1472 return unknown_files
1473
Joe Gregorioe754eb82010-08-20 10:56:32 -04001474class MercurialVCS(VersionControlSystem):
1475 """Implementation of the VersionControlSystem interface for Mercurial."""
1476
1477 def __init__(self, options, repo_dir):
1478 super(MercurialVCS, self).__init__(options)
1479 # Absolute path to repository (we can be in a subdir)
1480 self.repo_dir = os.path.normpath(repo_dir)
1481 # Compute the subdir
1482 cwd = os.path.normpath(os.getcwd())
1483 assert cwd.startswith(self.repo_dir)
1484 self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/")
1485 if self.options.revision:
1486 self.base_rev = self.options.revision
1487 else:
1488 self.base_rev = RunShell(["hg", "parent", "-q"]).split(':')[1].strip()
1489
Joe Gregorio003b6e42013-02-13 15:42:19 -05001490 def GetGUID(self):
1491 # See chapter "Uniquely identifying a repository"
1492 # http://hgbook.red-bean.com/read/customizing-the-output-of-mercurial.html
1493 info = RunShell("hg log -r0 --template {node}".split())
1494 return info.strip()
1495
Joe Gregorioe754eb82010-08-20 10:56:32 -04001496 def _GetRelPath(self, filename):
1497 """Get relative path of a file according to the current directory,
1498 given its logical path in the repo."""
Joe Gregorio003b6e42013-02-13 15:42:19 -05001499 absname = os.path.join(self.repo_dir, filename)
1500 return os.path.relpath(absname)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001501
1502 def GenerateDiff(self, extra_args):
Joe Gregorioe754eb82010-08-20 10:56:32 -04001503 cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
1504 data = RunShell(cmd, silent_ok=True)
1505 svndiff = []
1506 filecount = 0
1507 for line in data.splitlines():
1508 m = re.match("diff --git a/(\S+) b/(\S+)", line)
1509 if m:
1510 # Modify line to make it look like as it comes from svn diff.
1511 # With this modification no changes on the server side are required
1512 # to make upload.py work with Mercurial repos.
1513 # NOTE: for proper handling of moved/copied files, we have to use
1514 # the second filename.
1515 filename = m.group(2)
1516 svndiff.append("Index: %s" % filename)
1517 svndiff.append("=" * 67)
1518 filecount += 1
1519 logging.info(line)
1520 else:
1521 svndiff.append(line)
1522 if not filecount:
1523 ErrorExit("No valid patches found in output from hg diff")
1524 return "\n".join(svndiff) + "\n"
1525
1526 def GetUnknownFiles(self):
1527 """Return a list of files unknown to the VCS."""
1528 args = []
1529 status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
1530 silent_ok=True)
1531 unknown_files = []
1532 for line in status.splitlines():
1533 st, fn = line.split(" ", 1)
1534 if st == "?":
1535 unknown_files.append(fn)
1536 return unknown_files
1537
1538 def GetBaseFile(self, filename):
Joe Gregorio003b6e42013-02-13 15:42:19 -05001539 # "hg status" and "hg cat" both take a path relative to the current subdir,
1540 # but "hg diff" has given us the path relative to the repo root.
Joe Gregorioe754eb82010-08-20 10:56:32 -04001541 base_content = ""
1542 new_content = None
1543 is_binary = False
1544 oldrelpath = relpath = self._GetRelPath(filename)
1545 # "hg status -C" returns two lines for moved/copied files, one otherwise
1546 out = RunShell(["hg", "status", "-C", "--rev", self.base_rev, relpath])
1547 out = out.splitlines()
1548 # HACK: strip error message about missing file/directory if it isn't in
1549 # the working copy
1550 if out[0].startswith('%s: ' % relpath):
1551 out = out[1:]
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001552 status, _ = out[0].split(' ', 1)
1553 if len(out) > 1 and status == "A":
Joe Gregorioe754eb82010-08-20 10:56:32 -04001554 # Moved/copied => considered as modified, use old filename to
1555 # retrieve base contents
1556 oldrelpath = out[1].strip()
1557 status = "M"
Joe Gregorioe754eb82010-08-20 10:56:32 -04001558 if ":" in self.base_rev:
1559 base_rev = self.base_rev.split(":", 1)[0]
1560 else:
1561 base_rev = self.base_rev
1562 if status != "A":
1563 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
1564 silent_ok=True)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001565 is_binary = self.IsBinaryData(base_content)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001566 if status != "R":
1567 new_content = open(relpath, "rb").read()
Joe Gregorio003b6e42013-02-13 15:42:19 -05001568 is_binary = is_binary or self.IsBinaryData(new_content)
Joe Gregorioe754eb82010-08-20 10:56:32 -04001569 if is_binary and base_content:
1570 # Fetch again without converting newlines
1571 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
1572 silent_ok=True, universal_newlines=False)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001573 if not is_binary:
Joe Gregorioe754eb82010-08-20 10:56:32 -04001574 new_content = None
1575 return base_content, new_content, is_binary, status
1576
1577
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001578class PerforceVCS(VersionControlSystem):
1579 """Implementation of the VersionControlSystem interface for Perforce."""
1580
1581 def __init__(self, options):
Joe Gregorio003b6e42013-02-13 15:42:19 -05001582
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001583 def ConfirmLogin():
1584 # Make sure we have a valid perforce session
1585 while True:
1586 data, retcode = self.RunPerforceCommandWithReturnCode(
1587 ["login", "-s"], marshal_output=True)
1588 if not data:
1589 ErrorExit("Error checking perforce login")
1590 if not retcode and (not "code" in data or data["code"] != "error"):
1591 break
1592 print "Enter perforce password: "
1593 self.RunPerforceCommandWithReturnCode(["login"])
Joe Gregorio003b6e42013-02-13 15:42:19 -05001594
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001595 super(PerforceVCS, self).__init__(options)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001596
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001597 self.p4_changelist = options.p4_changelist
1598 if not self.p4_changelist:
1599 ErrorExit("A changelist id is required")
1600 if (options.revision):
1601 ErrorExit("--rev is not supported for perforce")
Joe Gregorio003b6e42013-02-13 15:42:19 -05001602
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001603 self.p4_port = options.p4_port
1604 self.p4_client = options.p4_client
1605 self.p4_user = options.p4_user
Joe Gregorio003b6e42013-02-13 15:42:19 -05001606
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001607 ConfirmLogin()
Joe Gregorio003b6e42013-02-13 15:42:19 -05001608
1609 if not options.title:
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001610 description = self.RunPerforceCommand(["describe", self.p4_changelist],
1611 marshal_output=True)
1612 if description and "desc" in description:
1613 # Rietveld doesn't support multi-line descriptions
Joe Gregorio003b6e42013-02-13 15:42:19 -05001614 raw_title = description["desc"].strip()
1615 lines = raw_title.splitlines()
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001616 if len(lines):
Joe Gregorio003b6e42013-02-13 15:42:19 -05001617 options.title = lines[0]
1618
1619 def GetGUID(self):
1620 """For now we don't know how to get repository ID for Perforce"""
1621 return
1622
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001623 def RunPerforceCommandWithReturnCode(self, extra_args, marshal_output=False,
1624 universal_newlines=True):
1625 args = ["p4"]
1626 if marshal_output:
1627 # -G makes perforce format its output as marshalled python objects
1628 args.extend(["-G"])
1629 if self.p4_port:
1630 args.extend(["-p", self.p4_port])
1631 if self.p4_client:
1632 args.extend(["-c", self.p4_client])
1633 if self.p4_user:
1634 args.extend(["-u", self.p4_user])
1635 args.extend(extra_args)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001636
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001637 data, retcode = RunShellWithReturnCode(
1638 args, print_output=False, universal_newlines=universal_newlines)
1639 if marshal_output and data:
1640 data = marshal.loads(data)
1641 return data, retcode
Joe Gregorio003b6e42013-02-13 15:42:19 -05001642
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001643 def RunPerforceCommand(self, extra_args, marshal_output=False,
1644 universal_newlines=True):
1645 # This might be a good place to cache call results, since things like
1646 # describe or fstat might get called repeatedly.
1647 data, retcode = self.RunPerforceCommandWithReturnCode(
1648 extra_args, marshal_output, universal_newlines)
1649 if retcode:
1650 ErrorExit("Got error status from %s:\n%s" % (extra_args, data))
1651 return data
1652
1653 def GetFileProperties(self, property_key_prefix = "", command = "describe"):
1654 description = self.RunPerforceCommand(["describe", self.p4_changelist],
1655 marshal_output=True)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001656
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001657 changed_files = {}
1658 file_index = 0
1659 # Try depotFile0, depotFile1, ... until we don't find a match
1660 while True:
1661 file_key = "depotFile%d" % file_index
1662 if file_key in description:
1663 filename = description[file_key]
1664 change_type = description[property_key_prefix + str(file_index)]
1665 changed_files[filename] = change_type
1666 file_index += 1
1667 else:
1668 break
1669 return changed_files
1670
1671 def GetChangedFiles(self):
1672 return self.GetFileProperties("action")
1673
1674 def GetUnknownFiles(self):
1675 # Perforce doesn't detect new files, they have to be explicitly added
1676 return []
1677
1678 def IsBaseBinary(self, filename):
1679 base_filename = self.GetBaseFilename(filename)
1680 return self.IsBinaryHelper(base_filename, "files")
1681
1682 def IsPendingBinary(self, filename):
1683 return self.IsBinaryHelper(filename, "describe")
1684
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001685 def IsBinaryHelper(self, filename, command):
1686 file_types = self.GetFileProperties("type", command)
1687 if not filename in file_types:
1688 ErrorExit("Trying to check binary status of unknown file %s." % filename)
1689 # This treats symlinks, macintosh resource files, temporary objects, and
1690 # unicode as binary. See the Perforce docs for more details:
1691 # http://www.perforce.com/perforce/doc.current/manuals/cmdref/o.ftypes.html
1692 return not file_types[filename].endswith("text")
1693
1694 def GetFileContent(self, filename, revision, is_binary):
1695 file_arg = filename
1696 if revision:
1697 file_arg += "#" + revision
1698 # -q suppresses the initial line that displays the filename and revision
1699 return self.RunPerforceCommand(["print", "-q", file_arg],
1700 universal_newlines=not is_binary)
1701
1702 def GetBaseFilename(self, filename):
1703 actionsWithDifferentBases = [
1704 "move/add", # p4 move
1705 "branch", # p4 integrate (to a new file), similar to hg "add"
1706 "add", # p4 integrate (to a new file), after modifying the new file
1707 ]
Joe Gregorio003b6e42013-02-13 15:42:19 -05001708
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001709 # We only see a different base for "add" if this is a downgraded branch
Joe Gregorio003b6e42013-02-13 15:42:19 -05001710 # after a file was branched (integrated), then edited.
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001711 if self.GetAction(filename) in actionsWithDifferentBases:
1712 # -Or shows information about pending integrations/moves
1713 fstat_result = self.RunPerforceCommand(["fstat", "-Or", filename],
1714 marshal_output=True)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001715
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001716 baseFileKey = "resolveFromFile0" # I think it's safe to use only file0
1717 if baseFileKey in fstat_result:
1718 return fstat_result[baseFileKey]
Joe Gregorio003b6e42013-02-13 15:42:19 -05001719
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001720 return filename
1721
1722 def GetBaseRevision(self, filename):
1723 base_filename = self.GetBaseFilename(filename)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001724
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001725 have_result = self.RunPerforceCommand(["have", base_filename],
1726 marshal_output=True)
1727 if "haveRev" in have_result:
1728 return have_result["haveRev"]
Joe Gregorio003b6e42013-02-13 15:42:19 -05001729
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001730 def GetLocalFilename(self, filename):
1731 where = self.RunPerforceCommand(["where", filename], marshal_output=True)
1732 if "path" in where:
1733 return where["path"]
1734
Joe Gregorio003b6e42013-02-13 15:42:19 -05001735 def GenerateDiff(self, args):
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001736 class DiffData:
1737 def __init__(self, perforceVCS, filename, action):
1738 self.perforceVCS = perforceVCS
1739 self.filename = filename
1740 self.action = action
1741 self.base_filename = perforceVCS.GetBaseFilename(filename)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001742
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001743 self.file_body = None
1744 self.base_rev = None
1745 self.prefix = None
1746 self.working_copy = True
1747 self.change_summary = None
Joe Gregorio003b6e42013-02-13 15:42:19 -05001748
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001749 def GenerateDiffHeader(diffData):
1750 header = []
1751 header.append("Index: %s" % diffData.filename)
1752 header.append("=" * 67)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001753
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001754 if diffData.base_filename != diffData.filename:
1755 if diffData.action.startswith("move"):
1756 verb = "rename"
1757 else:
1758 verb = "copy"
1759 header.append("%s from %s" % (verb, diffData.base_filename))
1760 header.append("%s to %s" % (verb, diffData.filename))
Joe Gregorio003b6e42013-02-13 15:42:19 -05001761
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001762 suffix = "\t(revision %s)" % diffData.base_rev
1763 header.append("--- " + diffData.base_filename + suffix)
1764 if diffData.working_copy:
1765 suffix = "\t(working copy)"
1766 header.append("+++ " + diffData.filename + suffix)
1767 if diffData.change_summary:
1768 header.append(diffData.change_summary)
1769 return header
Joe Gregorio003b6e42013-02-13 15:42:19 -05001770
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001771 def GenerateMergeDiff(diffData, args):
1772 # -du generates a unified diff, which is nearly svn format
1773 diffData.file_body = self.RunPerforceCommand(
1774 ["diff", "-du", diffData.filename] + args)
1775 diffData.base_rev = self.GetBaseRevision(diffData.filename)
1776 diffData.prefix = ""
Joe Gregorio003b6e42013-02-13 15:42:19 -05001777
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001778 # We have to replace p4's file status output (the lines starting
1779 # with +++ or ---) to match svn's diff format
1780 lines = diffData.file_body.splitlines()
1781 first_good_line = 0
1782 while (first_good_line < len(lines) and
1783 not lines[first_good_line].startswith("@@")):
1784 first_good_line += 1
1785 diffData.file_body = "\n".join(lines[first_good_line:])
1786 return diffData
1787
1788 def GenerateAddDiff(diffData):
1789 fstat = self.RunPerforceCommand(["fstat", diffData.filename],
1790 marshal_output=True)
1791 if "headRev" in fstat:
1792 diffData.base_rev = fstat["headRev"] # Re-adding a deleted file
1793 else:
1794 diffData.base_rev = "0" # Brand new file
1795 diffData.working_copy = False
1796 rel_path = self.GetLocalFilename(diffData.filename)
1797 diffData.file_body = open(rel_path, 'r').read()
1798 # Replicate svn's list of changed lines
1799 line_count = len(diffData.file_body.splitlines())
1800 diffData.change_summary = "@@ -0,0 +1"
1801 if line_count > 1:
1802 diffData.change_summary += ",%d" % line_count
1803 diffData.change_summary += " @@"
1804 diffData.prefix = "+"
1805 return diffData
Joe Gregorio003b6e42013-02-13 15:42:19 -05001806
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001807 def GenerateDeleteDiff(diffData):
1808 diffData.base_rev = self.GetBaseRevision(diffData.filename)
1809 is_base_binary = self.IsBaseBinary(diffData.filename)
1810 # For deletes, base_filename == filename
Joe Gregorio003b6e42013-02-13 15:42:19 -05001811 diffData.file_body = self.GetFileContent(diffData.base_filename,
1812 None,
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001813 is_base_binary)
1814 # Replicate svn's list of changed lines
1815 line_count = len(diffData.file_body.splitlines())
1816 diffData.change_summary = "@@ -1"
1817 if line_count > 1:
1818 diffData.change_summary += ",%d" % line_count
1819 diffData.change_summary += " +0,0 @@"
1820 diffData.prefix = "-"
1821 return diffData
Joe Gregorio003b6e42013-02-13 15:42:19 -05001822
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001823 changed_files = self.GetChangedFiles()
Joe Gregorio003b6e42013-02-13 15:42:19 -05001824
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001825 svndiff = []
1826 filecount = 0
1827 for (filename, action) in changed_files.items():
1828 svn_status = self.PerforceActionToSvnStatus(action)
1829 if svn_status == "SKIP":
1830 continue
Joe Gregorio003b6e42013-02-13 15:42:19 -05001831
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001832 diffData = DiffData(self, filename, action)
1833 # Is it possible to diff a branched file? Stackoverflow says no:
1834 # http://stackoverflow.com/questions/1771314/in-perforce-command-line-how-to-diff-a-file-reopened-for-add
1835 if svn_status == "M":
1836 diffData = GenerateMergeDiff(diffData, args)
1837 elif svn_status == "A":
1838 diffData = GenerateAddDiff(diffData)
1839 elif svn_status == "D":
1840 diffData = GenerateDeleteDiff(diffData)
1841 else:
1842 ErrorExit("Unknown file action %s (svn action %s)." % \
1843 (action, svn_status))
Joe Gregorio003b6e42013-02-13 15:42:19 -05001844
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001845 svndiff += GenerateDiffHeader(diffData)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001846
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001847 for line in diffData.file_body.splitlines():
1848 svndiff.append(diffData.prefix + line)
1849 filecount += 1
1850 if not filecount:
1851 ErrorExit("No valid patches found in output from p4 diff")
1852 return "\n".join(svndiff) + "\n"
1853
1854 def PerforceActionToSvnStatus(self, status):
1855 # Mirroring the list at http://permalink.gmane.org/gmane.comp.version-control.mercurial.devel/28717
1856 # Is there something more official?
1857 return {
1858 "add" : "A",
1859 "branch" : "A",
1860 "delete" : "D",
1861 "edit" : "M", # Also includes changing file types.
1862 "integrate" : "M",
1863 "move/add" : "M",
1864 "move/delete": "SKIP",
1865 "purge" : "D", # How does a file's status become "purge"?
1866 }[status]
1867
1868 def GetAction(self, filename):
1869 changed_files = self.GetChangedFiles()
1870 if not filename in changed_files:
1871 ErrorExit("Trying to get base version of unknown file %s." % filename)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001872
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001873 return changed_files[filename]
1874
1875 def GetBaseFile(self, filename):
1876 base_filename = self.GetBaseFilename(filename)
1877 base_content = ""
1878 new_content = None
Joe Gregorio003b6e42013-02-13 15:42:19 -05001879
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001880 status = self.PerforceActionToSvnStatus(self.GetAction(filename))
Joe Gregorio003b6e42013-02-13 15:42:19 -05001881
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001882 if status != "A":
1883 revision = self.GetBaseRevision(base_filename)
1884 if not revision:
1885 ErrorExit("Couldn't find base revision for file %s" % filename)
1886 is_base_binary = self.IsBaseBinary(base_filename)
1887 base_content = self.GetFileContent(base_filename,
1888 revision,
1889 is_base_binary)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001890
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001891 is_binary = self.IsPendingBinary(filename)
1892 if status != "D" and status != "SKIP":
1893 relpath = self.GetLocalFilename(filename)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001894 if is_binary:
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001895 new_content = open(relpath, "rb").read()
Joe Gregorio003b6e42013-02-13 15:42:19 -05001896
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001897 return base_content, new_content, is_binary, status
1898
Joe Gregorioe754eb82010-08-20 10:56:32 -04001899# NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync.
1900def SplitPatch(data):
1901 """Splits a patch into separate pieces for each file.
1902
1903 Args:
1904 data: A string containing the output of svn diff.
1905
1906 Returns:
1907 A list of 2-tuple (filename, text) where text is the svn diff output
1908 pertaining to filename.
1909 """
1910 patches = []
1911 filename = None
1912 diff = []
1913 for line in data.splitlines(True):
1914 new_filename = None
1915 if line.startswith('Index:'):
1916 unused, new_filename = line.split(':', 1)
1917 new_filename = new_filename.strip()
1918 elif line.startswith('Property changes on:'):
1919 unused, temp_filename = line.split(':', 1)
1920 # When a file is modified, paths use '/' between directories, however
1921 # when a property is modified '\' is used on Windows. Make them the same
1922 # otherwise the file shows up twice.
1923 temp_filename = temp_filename.strip().replace('\\', '/')
1924 if temp_filename != filename:
1925 # File has property changes but no modifications, create a new diff.
1926 new_filename = temp_filename
1927 if new_filename:
1928 if filename and diff:
1929 patches.append((filename, ''.join(diff)))
1930 filename = new_filename
1931 diff = [line]
1932 continue
1933 if diff is not None:
1934 diff.append(line)
1935 if filename and diff:
1936 patches.append((filename, ''.join(diff)))
1937 return patches
1938
1939
1940def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
1941 """Uploads a separate patch for each file in the diff output.
1942
1943 Returns a list of [patch_key, filename] for each file.
1944 """
1945 patches = SplitPatch(data)
1946 rv = []
1947 for patch in patches:
1948 if len(patch[1]) > MAX_UPLOAD_SIZE:
1949 print ("Not uploading the patch for " + patch[0] +
1950 " because the file is too large.")
1951 continue
1952 form_fields = [("filename", patch[0])]
1953 if not options.download_base:
1954 form_fields.append(("content_upload", "1"))
1955 files = [("data", "data.diff", patch[1])]
1956 ctype, body = EncodeMultipartFormData(form_fields, files)
1957 url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
1958 print "Uploading patch for " + patch[0]
1959 response_body = rpc_server.Send(url, body, content_type=ctype)
1960 lines = response_body.splitlines()
1961 if not lines or lines[0] != "OK":
1962 StatusUpdate(" --> %s" % response_body)
1963 sys.exit(1)
1964 rv.append([lines[1], patch[0]])
1965 return rv
1966
1967
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001968def GuessVCSName(options):
Joe Gregorioe754eb82010-08-20 10:56:32 -04001969 """Helper to guess the version control system.
1970
1971 This examines the current directory, guesses which VersionControlSystem
1972 we're using, and returns an string indicating which VCS is detected.
1973
1974 Returns:
1975 A pair (vcs, output). vcs is a string indicating which VCS was detected
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001976 and is one of VCS_GIT, VCS_MERCURIAL, VCS_SUBVERSION, VCS_PERFORCE,
1977 VCS_CVS, or VCS_UNKNOWN.
1978 Since local perforce repositories can't be easily detected, this method
1979 will only guess VCS_PERFORCE if any perforce options have been specified.
Joe Gregorioe754eb82010-08-20 10:56:32 -04001980 output is a string containing any interesting output from the vcs
1981 detection routine, or None if there is nothing interesting.
1982 """
Joe Gregorio30a08fd2011-04-11 11:33:42 -04001983 for attribute, value in options.__dict__.iteritems():
1984 if attribute.startswith("p4") and value != None:
1985 return (VCS_PERFORCE, None)
Joe Gregorio003b6e42013-02-13 15:42:19 -05001986
1987 def RunDetectCommand(vcs_type, command):
1988 """Helper to detect VCS by executing command.
1989
1990 Returns:
1991 A pair (vcs, output) or None. Throws exception on error.
1992 """
1993 try:
1994 out, returncode = RunShellWithReturnCode(command)
1995 if returncode == 0:
1996 return (vcs_type, out.strip())
1997 except OSError, (errcode, message):
1998 if errcode != errno.ENOENT: # command not found code
1999 raise
2000
Joe Gregorioe754eb82010-08-20 10:56:32 -04002001 # Mercurial has a command to get the base directory of a repository
2002 # Try running it, but don't die if we don't have hg installed.
2003 # NOTE: we try Mercurial first as it can sit on top of an SVN working copy.
Joe Gregorio003b6e42013-02-13 15:42:19 -05002004 res = RunDetectCommand(VCS_MERCURIAL, ["hg", "root"])
2005 if res != None:
2006 return res
Joe Gregorioe754eb82010-08-20 10:56:32 -04002007
Joe Gregorio003b6e42013-02-13 15:42:19 -05002008 # Subversion from 1.7 has a single centralized .svn folder
2009 # ( see http://subversion.apache.org/docs/release-notes/1.7.html#wc-ng )
2010 # That's why we use 'svn info' instead of checking for .svn dir
2011 res = RunDetectCommand(VCS_SUBVERSION, ["svn", "info"])
2012 if res != None:
2013 return res
Joe Gregorioe754eb82010-08-20 10:56:32 -04002014
2015 # Git has a command to test if you're in a git tree.
2016 # Try running it, but don't die if we don't have git installed.
Joe Gregorio003b6e42013-02-13 15:42:19 -05002017 res = RunDetectCommand(VCS_GIT, ["git", "rev-parse",
2018 "--is-inside-work-tree"])
2019 if res != None:
2020 return res
Joe Gregorioe754eb82010-08-20 10:56:32 -04002021
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002022 # detect CVS repos use `cvs status && $? == 0` rules
Joe Gregorio003b6e42013-02-13 15:42:19 -05002023 res = RunDetectCommand(VCS_CVS, ["cvs", "status"])
2024 if res != None:
2025 return res
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002026
Joe Gregorioe754eb82010-08-20 10:56:32 -04002027 return (VCS_UNKNOWN, None)
2028
2029
2030def GuessVCS(options):
2031 """Helper to guess the version control system.
2032
2033 This verifies any user-specified VersionControlSystem (by command line
2034 or environment variable). If the user didn't specify one, this examines
2035 the current directory, guesses which VersionControlSystem we're using,
2036 and returns an instance of the appropriate class. Exit with an error
2037 if we can't figure it out.
2038
2039 Returns:
2040 A VersionControlSystem instance. Exits if the VCS can't be guessed.
2041 """
2042 vcs = options.vcs
2043 if not vcs:
2044 vcs = os.environ.get("CODEREVIEW_VCS")
2045 if vcs:
2046 v = VCS_ABBREVIATIONS.get(vcs.lower())
2047 if v is None:
2048 ErrorExit("Unknown version control system %r specified." % vcs)
2049 (vcs, extra_output) = (v, None)
2050 else:
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002051 (vcs, extra_output) = GuessVCSName(options)
Joe Gregorioe754eb82010-08-20 10:56:32 -04002052
2053 if vcs == VCS_MERCURIAL:
2054 if extra_output is None:
2055 extra_output = RunShell(["hg", "root"]).strip()
2056 return MercurialVCS(options, extra_output)
2057 elif vcs == VCS_SUBVERSION:
2058 return SubversionVCS(options)
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002059 elif vcs == VCS_PERFORCE:
2060 return PerforceVCS(options)
Joe Gregorioe754eb82010-08-20 10:56:32 -04002061 elif vcs == VCS_GIT:
2062 return GitVCS(options)
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002063 elif vcs == VCS_CVS:
2064 return CVSVCS(options)
Joe Gregorioe754eb82010-08-20 10:56:32 -04002065
2066 ErrorExit(("Could not guess version control system. "
2067 "Are you in a working copy directory?"))
2068
2069
2070def CheckReviewer(reviewer):
2071 """Validate a reviewer -- either a nickname or an email addres.
2072
2073 Args:
2074 reviewer: A nickname or an email address.
2075
2076 Calls ErrorExit() if it is an invalid email address.
2077 """
2078 if "@" not in reviewer:
2079 return # Assume nickname
2080 parts = reviewer.split("@")
2081 if len(parts) > 2:
2082 ErrorExit("Invalid email address: %r" % reviewer)
2083 assert len(parts) == 2
2084 if "." not in parts[1]:
2085 ErrorExit("Invalid email address: %r" % reviewer)
2086
2087
2088def LoadSubversionAutoProperties():
2089 """Returns the content of [auto-props] section of Subversion's config file as
2090 a dictionary.
2091
2092 Returns:
2093 A dictionary whose key-value pair corresponds the [auto-props] section's
2094 key-value pair.
2095 In following cases, returns empty dictionary:
2096 - config file doesn't exist, or
2097 - 'enable-auto-props' is not set to 'true-like-value' in [miscellany].
2098 """
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002099 if os.name == 'nt':
2100 subversion_config = os.environ.get("APPDATA") + "\\Subversion\\config"
2101 else:
2102 subversion_config = os.path.expanduser("~/.subversion/config")
Joe Gregorioe754eb82010-08-20 10:56:32 -04002103 if not os.path.exists(subversion_config):
2104 return {}
2105 config = ConfigParser.ConfigParser()
2106 config.read(subversion_config)
2107 if (config.has_section("miscellany") and
2108 config.has_option("miscellany", "enable-auto-props") and
2109 config.getboolean("miscellany", "enable-auto-props") and
2110 config.has_section("auto-props")):
2111 props = {}
2112 for file_pattern in config.options("auto-props"):
2113 props[file_pattern] = ParseSubversionPropertyValues(
2114 config.get("auto-props", file_pattern))
2115 return props
2116 else:
2117 return {}
2118
2119def ParseSubversionPropertyValues(props):
2120 """Parse the given property value which comes from [auto-props] section and
2121 returns a list whose element is a (svn_prop_key, svn_prop_value) pair.
2122
2123 See the following doctest for example.
2124
2125 >>> ParseSubversionPropertyValues('svn:eol-style=LF')
2126 [('svn:eol-style', 'LF')]
2127 >>> ParseSubversionPropertyValues('svn:mime-type=image/jpeg')
2128 [('svn:mime-type', 'image/jpeg')]
2129 >>> ParseSubversionPropertyValues('svn:eol-style=LF;svn:executable')
2130 [('svn:eol-style', 'LF'), ('svn:executable', '*')]
2131 """
2132 key_value_pairs = []
2133 for prop in props.split(";"):
2134 key_value = prop.split("=")
2135 assert len(key_value) <= 2
2136 if len(key_value) == 1:
2137 # If value is not given, use '*' as a Subversion's convention.
2138 key_value_pairs.append((key_value[0], "*"))
2139 else:
2140 key_value_pairs.append((key_value[0], key_value[1]))
2141 return key_value_pairs
2142
2143
2144def GetSubversionPropertyChanges(filename):
2145 """Return a Subversion's 'Property changes on ...' string, which is used in
2146 the patch file.
2147
2148 Args:
2149 filename: filename whose property might be set by [auto-props] config.
2150
2151 Returns:
2152 A string like 'Property changes on |filename| ...' if given |filename|
2153 matches any entries in [auto-props] section. None, otherwise.
2154 """
2155 global svn_auto_props_map
2156 if svn_auto_props_map is None:
2157 svn_auto_props_map = LoadSubversionAutoProperties()
2158
2159 all_props = []
2160 for file_pattern, props in svn_auto_props_map.items():
2161 if fnmatch.fnmatch(filename, file_pattern):
2162 all_props.extend(props)
2163 if all_props:
2164 return FormatSubversionPropertyChanges(filename, all_props)
2165 return None
2166
2167
2168def FormatSubversionPropertyChanges(filename, props):
2169 """Returns Subversion's 'Property changes on ...' strings using given filename
2170 and properties.
2171
2172 Args:
2173 filename: filename
2174 props: A list whose element is a (svn_prop_key, svn_prop_value) pair.
2175
2176 Returns:
2177 A string which can be used in the patch file for Subversion.
2178
2179 See the following doctest for example.
2180
2181 >>> print FormatSubversionPropertyChanges('foo.cc', [('svn:eol-style', 'LF')])
2182 Property changes on: foo.cc
2183 ___________________________________________________________________
2184 Added: svn:eol-style
2185 + LF
2186 <BLANKLINE>
2187 """
2188 prop_changes_lines = [
2189 "Property changes on: %s" % filename,
2190 "___________________________________________________________________"]
2191 for key, value in props:
2192 prop_changes_lines.append("Added: " + key)
2193 prop_changes_lines.append(" + " + value)
2194 return "\n".join(prop_changes_lines) + "\n"
2195
2196
2197def RealMain(argv, data=None):
2198 """The real main function.
2199
2200 Args:
2201 argv: Command line arguments.
2202 data: Diff contents. If None (default) the diff is generated by
2203 the VersionControlSystem implementation returned by GuessVCS().
2204
2205 Returns:
2206 A 2-tuple (issue id, patchset id).
2207 The patchset id is None if the base files are not uploaded by this
2208 script (applies only to SVN checkouts).
2209 """
Joe Gregorioe754eb82010-08-20 10:56:32 -04002210 options, args = parser.parse_args(argv[1:])
Joe Gregorio003b6e42013-02-13 15:42:19 -05002211 if options.help:
2212 if options.verbose < 2:
2213 # hide Perforce options
2214 parser.epilog = "Use '--help -v' to show additional Perforce options."
2215 parser.option_groups.remove(parser.get_option_group('--p4_port'))
2216 parser.print_help()
2217 sys.exit(0)
2218
Joe Gregorioe754eb82010-08-20 10:56:32 -04002219 global verbosity
2220 verbosity = options.verbose
2221 if verbosity >= 3:
2222 logging.getLogger().setLevel(logging.DEBUG)
2223 elif verbosity >= 2:
2224 logging.getLogger().setLevel(logging.INFO)
2225
2226 vcs = GuessVCS(options)
2227
2228 base = options.base_url
2229 if isinstance(vcs, SubversionVCS):
2230 # Guessing the base field is only supported for Subversion.
2231 # Note: Fetching base files may become deprecated in future releases.
2232 guessed_base = vcs.GuessBase(options.download_base)
2233 if base:
2234 if guessed_base and base != guessed_base:
2235 print "Using base URL \"%s\" from --base_url instead of \"%s\"" % \
2236 (base, guessed_base)
2237 else:
2238 base = guessed_base
2239
2240 if not base and options.download_base:
2241 options.download_base = True
2242 logging.info("Enabled upload of base file")
2243 if not options.assume_yes:
2244 vcs.CheckForUnknownFiles()
2245 if data is None:
2246 data = vcs.GenerateDiff(args)
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002247 data = vcs.PostProcessDiff(data)
2248 if options.print_diffs:
2249 print "Rietveld diff start:*****"
2250 print data
2251 print "Rietveld diff end:*****"
Joe Gregorioe754eb82010-08-20 10:56:32 -04002252 files = vcs.GetBaseFiles(data)
2253 if verbosity >= 1:
2254 print "Upload server:", options.server, "(change with -s/--server)"
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002255 rpc_server = GetRpcServer(options.server,
2256 options.email,
2257 options.host,
2258 options.save_cookies,
2259 options.account_type)
Joe Gregorio003b6e42013-02-13 15:42:19 -05002260 form_fields = []
2261
2262 repo_guid = vcs.GetGUID()
2263 if repo_guid:
2264 form_fields.append(("repo_guid", repo_guid))
Joe Gregorioe754eb82010-08-20 10:56:32 -04002265 if base:
Joe Gregorio30a08fd2011-04-11 11:33:42 -04002266 b = urlparse.urlparse(base)
2267 username, netloc = urllib.splituser(b.netloc)
2268 if username:
2269 logging.info("Removed username from base URL")
2270 base = urlparse.urlunparse((b.scheme, netloc, b.path, b.params,
2271 b.query, b.fragment))
Joe Gregorioe754eb82010-08-20 10:56:32 -04002272 form_fields.append(("base", base))
2273 if options.issue:
2274 form_fields.append(("issue", str(options.issue)))
2275 if options.email:
2276 form_fields.append(("user", options.email))
2277 if options.reviewers:
2278 for reviewer in options.reviewers.split(','):
2279 CheckReviewer(reviewer)
2280 form_fields.append(("reviewers", options.reviewers))
2281 if options.cc:
2282 for cc in options.cc.split(','):
2283 CheckReviewer(cc)
2284 form_fields.append(("cc", options.cc))
Joe Gregorio003b6e42013-02-13 15:42:19 -05002285
2286 # Process --message, --title and --file.
2287 message = options.message or ""
2288 title = options.title or ""
2289 if options.file:
2290 if options.message:
2291 ErrorExit("Can't specify both message and message file options")
2292 file = open(options.file, 'r')
2293 message = file.read()
Joe Gregorioe754eb82010-08-20 10:56:32 -04002294 file.close()
Joe Gregorio003b6e42013-02-13 15:42:19 -05002295 if options.issue:
2296 prompt = "Title describing this patch set: "
2297 else:
2298 prompt = "New issue subject: "
2299 title = (
2300 title or message.split('\n', 1)[0].strip() or raw_input(prompt).strip())
2301 if not title and not options.issue:
2302 ErrorExit("A non-empty title is required for a new issue")
2303 # For existing issues, it's fine to give a patchset an empty name. Rietveld
2304 # doesn't accept that so use a whitespace.
2305 title = title or " "
2306 if len(title) > 100:
2307 title = title[:99] + '…'
2308 if title and not options.issue:
2309 message = message or title
2310
2311 form_fields.append(("subject", title))
2312 # If it's a new issue send message as description. Otherwise a new
2313 # message is created below on upload_complete.
2314 if message and not options.issue:
2315 form_fields.append(("description", message))
2316
Joe Gregorioe754eb82010-08-20 10:56:32 -04002317 # Send a hash of all the base file so the server can determine if a copy
2318 # already exists in an earlier patchset.
2319 base_hashes = ""
2320 for file, info in files.iteritems():
2321 if not info[0] is None:
2322 checksum = md5(info[0]).hexdigest()
2323 if base_hashes:
2324 base_hashes += "|"
2325 base_hashes += checksum + ":" + file
2326 form_fields.append(("base_hashes", base_hashes))
2327 if options.private:
2328 if options.issue:
2329 print "Warning: Private flag ignored when updating an existing issue."
2330 else:
2331 form_fields.append(("private", "1"))
Joe Gregorio003b6e42013-02-13 15:42:19 -05002332 if options.send_patch:
2333 options.send_mail = True
Joe Gregorioe754eb82010-08-20 10:56:32 -04002334 if not options.download_base:
2335 form_fields.append(("content_upload", "1"))
2336 if len(data) > MAX_UPLOAD_SIZE:
2337 print "Patch is large, so uploading file patches separately."
2338 uploaded_diff_file = []
2339 form_fields.append(("separate_patches", "1"))
2340 else:
2341 uploaded_diff_file = [("data", "data.diff", data)]
2342 ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file)
2343 response_body = rpc_server.Send("/upload", body, content_type=ctype)
2344 patchset = None
2345 if not options.download_base or not uploaded_diff_file:
2346 lines = response_body.splitlines()
2347 if len(lines) >= 2:
2348 msg = lines[0]
2349 patchset = lines[1].strip()
2350 patches = [x.split(" ", 1) for x in lines[2:]]
2351 else:
2352 msg = response_body
2353 else:
2354 msg = response_body
2355 StatusUpdate(msg)
2356 if not response_body.startswith("Issue created.") and \
2357 not response_body.startswith("Issue updated."):
2358 sys.exit(0)
2359 issue = msg[msg.rfind("/")+1:]
2360
2361 if not uploaded_diff_file:
2362 result = UploadSeparatePatches(issue, rpc_server, patchset, data, options)
2363 if not options.download_base:
2364 patches = result
2365
2366 if not options.download_base:
2367 vcs.UploadBaseFiles(issue, rpc_server, patches, patchset, options, files)
Joe Gregorio003b6e42013-02-13 15:42:19 -05002368
2369 payload = {} # payload for final request
2370 if options.send_mail:
2371 payload["send_mail"] = "yes"
2372 if options.send_patch:
2373 payload["attach_patch"] = "yes"
2374 if options.issue and message:
2375 payload["message"] = message
2376 payload = urllib.urlencode(payload)
2377 rpc_server.Send("/" + issue + "/upload_complete/" + (patchset or ""),
2378 payload=payload)
Joe Gregorioe754eb82010-08-20 10:56:32 -04002379 return issue, patchset
2380
2381
2382def main():
2383 try:
Joe Gregorio003b6e42013-02-13 15:42:19 -05002384 logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:"
2385 "%(lineno)s %(message)s "))
2386 os.environ['LC_ALL'] = 'C'
Joe Gregorioe754eb82010-08-20 10:56:32 -04002387 RealMain(sys.argv)
2388 except KeyboardInterrupt:
2389 print
2390 StatusUpdate("Interrupted.")
2391 sys.exit(1)
2392
2393
2394if __name__ == "__main__":
2395 main()