Commit cc51cd03 authored by chase@chromium.org's avatar chase@chromium.org

Move git-cl into depot_tools.

BUG=none
TEST=git-cl works after move
Review URL: http://codereview.chromium.org/5012006

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/depot_tools@70011 0039d316-1c4b-4281-b951-d872f2087c98
parent dcd1522a
......@@ -4,23 +4,6 @@
# found in the LICENSE file.
base_dir=$(dirname "$0")
repo="$base_dir/git_cl_repo"
url="http://git.chromium.org/git/git-cl.git"
cur_url=$(git config -f "$repo/.git/config" remote.origin.url)
"$base_dir"/update_depot_tools
if [ -e "$repo" -a "$cur_url" != "$url" ]; then
# Always override "origin"
(cd "$repo"; git remote set-url origin $url)
fi
if [ ! -f "$repo/git-cl" ]; then
git clone $url $repo -q
elif [ ! -e "$repo/.git" ]; then
echo "$0: $repo does not appear to be a git repo"
elif [ "X$DEPOT_TOOLS_UPDATE" != "X0" ]; then
(cd "$repo"; git pull -q)
fi
$repo/git-cl "$@"
"$base_dir"/git_cl/git-cl "$@"
Copyright (c) 2008 Evan Martin <martine@danga.com>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the author nor the names of contributors may be
used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# Copyright (c) 2010 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Top-level presubmit script for depot tools.
See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts for
details on the presubmit API built into gcl.
"""
def CheckChangeOnUpload(input_api, output_api):
return RunTests(input_api, output_api)
def CheckChangeOnCommit(input_api, output_api):
return RunTests(input_api, output_api)
def RunTests(input_api, output_api):
"""Run all the shells scripts in the directory test.
Also verify the GAE python SDK is available, fetches Rietveld if necessary and
start a test instance to test against.
"""
# They are not exposed from InputApi.
from os import listdir, pathsep
import socket
import time
# Shortcuts
join = input_api.os_path.join
error = output_api.PresubmitError
# Paths
sdk_path = input_api.os_path.abspath(join('..', '..', 'google_appengine'))
dev_app = join(sdk_path, 'dev_appserver.py')
rietveld = join('test', 'rietveld')
django_path = join(rietveld, 'django')
# Generate a friendly environment.
env = input_api.environ.copy()
env['LANGUAGE'] = 'en'
if env.get('PYTHONPATH'):
env['PYTHONPATH'] = (env['PYTHONPATH'].rstrip(pathsep) + pathsep +
django_path)
else:
env['PYTHONPATH'] = django_path
def call(*args, **kwargs):
kwargs['env'] = env
x = input_api.subprocess.Popen(*args, **kwargs)
x.communicate()
return x.returncode == 0
def test_port(port):
s = socket.socket()
try:
return s.connect_ex(('127.0.0.1', port)) == 0
finally:
s.close()
# First, verify the Google AppEngine SDK is available.
if not input_api.os_path.isfile(dev_app):
return [error('Install google_appengine sdk in %s' % sdk_path)]
# Second, checkout rietveld and django if not available.
if not input_api.os_path.isdir(rietveld):
print('Checking out rietveld...')
if not call(['svn', 'co', '-q',
'http://rietveld.googlecode.com/svn/trunk@563',
rietveld]):
return [error('Failed to checkout rietveld')]
if not input_api.os_path.isdir(django_path):
print('Checking out django...')
if not call(
['svn', 'co', '-q',
'http://code.djangoproject.com/'
'svn/django/branches/releases/1.0.X/django@13637',
django_path]):
return [error('Failed to checkout django')]
# Test to find an available port starting at 8080.
port = 8080
while test_port(port) and port < 65000:
port += 1
if port == 65000:
return [error('Having issues finding an available port')]
verbose = False
if verbose:
stdout = None
stderr = None
else:
stdout = input_api.subprocess.PIPE
stderr = input_api.subprocess.PIPE
output = []
test_server = input_api.subprocess.Popen(
[dev_app, rietveld, '--port=%d' % port,
'--datastore_path=' + join(rietveld, 'tmp.db'), '-c'],
stdout=stdout, stderr=stderr, env=env)
try:
# Loop until port 127.0.0.1:port opens or the process dies.
while not test_port(port):
test_server.poll()
if test_server.returncode is not None:
output.append(error('Test rietveld instance failed early'))
break
time.sleep(0.001)
test_path = input_api.os_path.abspath('test')
for test in listdir(test_path):
# push-from-logs and rename fails for now. Remove from this list once they
# work.
if (test in ('push-from-logs.sh', 'rename.sh', 'test-lib.sh') or
not test.endswith('.sh')):
continue
print('Running %s' % test)
if not call([join(test_path, test)], cwd=test_path, stdout=stdout):
output.append(error('%s failed' % test))
finally:
test_server.kill()
return output
# git-cl -- a git-command for integrating reviews on Rietveld
# Copyright (C) 2008 Evan Martin <martine@danga.com>
== Background
Rietveld, also known as http://codereview.appspot.com, is a nice tool
for code reviews. You upload a patch (and some other data) and it lets
others comment on your patch.
For more on how this all works conceptually, please see README.codereview.
The remainder of this document is the nuts and bolts of using git-cl.
== Install
Copy (symlink) it into your path somewhere, along with Rietveld
upload.py.
== Setup
Run this from your git checkout and answer some questions:
$ git cl config
== How to use it
Make a new branch. Write some code. Commit it locally. Send it for
review:
$ git cl upload
By default, it diffs against whatever branch the current branch is
tracking (see "git checkout --track"). An optional last argument is
passed to "git diff", allowing reviews against other heads.
You'll be asked some questions, and the review issue number will be
associated with your current git branch, so subsequent calls to upload
will update that review rather than making a new one.
== git-svn integration
Review looks good? Commit the code:
$ git cl dcommit
This does a git-svn dcommit, with a twist: all changes in the diff
will be squashed into a single commit, and the description of the commit
is taken directly from the Rietveld description. This command also accepts
arguments to "git diff", much like upload.
Try "git cl dcommit --help" for more options.
== Extra commands
Print some status info:
$ git cl status
Edit the issue association on the current branch:
$ git cl issue 1234
Patch in a review:
$ git cl patch <url to full patch>
Try "git cl patch --help" for more options.
vim: tw=72 :
The git-cl README describes the git-cl command set. This document
describes how code review and git work together in general, intended
for people familiar with git but unfamiliar with the code review
process supported by Rietveld.
== Concepts and terms
A Rietveld review is for discussion of a single change or patch. You
upload a proposed change, the reviewer comments on your change, and
then you can upload a revised version of your change. Rietveld stores
the history of uploaded patches as well as the comments, and can
compute diffs in between these patches. The history of a patch is
very much like a small branch in git, but since Rietveld is
VCS-agnostic the concepts don't map perfectly. The identifier for a
single review+patches+comments in Rietveld is called an "issue".
Rietveld provides a basic uploader that understands git. This program
is used by git-cl, and is included in the git-cl repo as upload.py.
== Basic interaction with git
The fundamental problem you encounter when you try to mix git and code
review is that with git it's nice to commit code locally, while during
a code review you're often requested to change something about your
code. There are a few different ways you can handle this workflow
with git:
1) Rewriting a single commit. Say the origin commit is O, and you
commit your initial work in a commit A, making your history like
O--A. After review comments, you commit --amend, effectively
erasing A and making a new commit A', so history is now O--A'.
(Equivalently, you can use git reset --soft or git rebase -i.)
2) Writing follow-up commits. Initial work is again in A, and after
review comments, you write a new commit B so your history looks
like O--A--B. When you upload the revised patch, you upload the
diff of O..B, not A..B; you always upload the full diff of what
you're proposing to change.
The Rietveld patch uploader just takes arguments to "git diff", so
either of the above workflows work fine. If all you want to do is
upload a patch, you can use the upload.py provided by Rietveld with
arguments like this:
upload.py --server server.com <args to "git diff">
The first time you upload, it creates a new issue; for follow-ups on
the same issue, you need to provide the issue number:
upload.py --server server.com --issue 1234 <args to "git diff">
== git-cl to the rescue
git-cl simplifies the above in the following ways:
1) "git cl config" puts a persistent --server setting in your .git/config.
2) The first time you upload an issue, the issue number is associated with
the current *branch*. If you upload again, it will upload on the same
issue. (Note that this association is tied to a branch, not a commit,
which means you need a separate branch per review.)
3) If your branch is "tracking" (in the "git checkout --track" sense)
another one (like origin/master), calls to "git cl upload" will
diff against that branch by default. (You can still pass arguments
to "git diff" on the command line, if necessary.)
In the common case, this means that calling simply "git cl upload"
will always upload the correct diff to the correct place.
== Patch series
The above is all you need to know for working on a single patch.
Things get much more complicated when you have a series of commits
that you want to get reviewed. Say your history looks like
O--A--B--C. If you want to upload that as a single review, everything
works just as above.
But what if you upload each of A, B, and C as separate reviews?
What if you then need to change A?
1) One option is rewriting history: write a new commit A', then use
git rebase -i to insert that diff in as O--A--A'--B--C as well as
squash it. This is sometimes not possible if B and C have touched
some lines affected by A'.
2) Another option, and the one espoused by software like topgit, is for
you to have separate branches for A, B, and C, and after writing A'
you merge it into each of those branches. (topgit automates this
merging process.) This is also what is recommended by git-cl, which
likes having different branch identifiers to hang the issue number
off of. Your history ends up looking like:
O---A---B---C
\ \ \
A'--B'--C'
Which is ugly, but it accurately tracks the real history of your work, can
be thrown away at the end by committing A+A' as a single "squash" commit.
In practice, this comes up pretty rarely. Suggestions for better workflows
are welcome.
Most of the tests require a local Rietveld server.
To set this up:
Method 1: Let the presubmit script do the work for you.
$ git cl presubmit
Method 2: Manual.
1) Check out a copy of Rietveld:
$ svn checkout http://rietveld.googlecode.com/svn/trunk/ rietveld
(Use git-svn if you must, but man is it slow.)
2) Get the Google App Engine SDK:
http://code.google.com/appengine/downloads.html
3) To run Rietveld you will need Django 1.0, which is not included
with the App Engine SDK. Go to http://www.djangoproject.com/download ,
download a Django from the 1.0 series (it's in the sidebar on the right),
untar it, then
$ export PYTHONPATH=`pwd`/Django-1.0.4
4) Run Rietveld:
$ /path/to/appengine/sdk/dev_appserver.py /path/to/rietveld
(If using one of the App Engine launchers, be sure to use port 8080
for this project.)
And then, finally, run the tests.
#!/usr/bin/python
# git-cl -- a git-command for integrating reviews on Rietveld
# Copyright (C) 2008 Evan Martin <martine@danga.com>
import sys
import git_cl
if __name__ == '__main__':
sys.exit(git_cl.main(sys.argv[1:]))
#!/usr/bin/python
# git-cl -- a git-command for integrating reviews on Rietveld
# Copyright (C) 2008 Evan Martin <martine@danga.com>
import errno
import logging
import optparse
import os
import re
import subprocess
import sys
import tempfile
import textwrap
import upload
import urllib2
try:
import readline
except ImportError:
pass
try:
# Add the parent directory in case it's a depot_tools checkout.
depot_tools_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(depot_tools_path)
import breakpad
except ImportError:
pass
DEFAULT_SERVER = 'http://codereview.appspot.com'
PREDCOMMIT_HOOK = '.git/hooks/pre-cl-dcommit'
PREUPLOAD_HOOK = '.git/hooks/pre-cl-upload'
DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
def DieWithError(message):
print >>sys.stderr, message
sys.exit(1)
def Popen(cmd, **kwargs):
"""Wrapper for subprocess.Popen() that logs and watch for cygwin issues"""
logging.info('Popen: ' + ' '.join(cmd))
try:
return subprocess.Popen(cmd, **kwargs)
except OSError, e:
if e.errno == errno.EAGAIN and sys.platform == 'cygwin':
DieWithError(
'Visit '
'http://code.google.com/p/chromium/wiki/CygwinDllRemappingFailure to '
'learn how to fix this error; you need to rebase your cygwin dlls')
raise
def RunCommand(cmd, error_ok=False, error_message=None,
redirect_stdout=True, swallow_stderr=False):
if redirect_stdout:
stdout = subprocess.PIPE
else:
stdout = None
if swallow_stderr:
stderr = subprocess.PIPE
else:
stderr = None
proc = Popen(cmd, stdout=stdout, stderr=stderr)
output = proc.communicate()[0]
if not error_ok and proc.returncode != 0:
DieWithError('Command "%s" failed.\n' % (' '.join(cmd)) +
(error_message or output or ''))
return output
def RunGit(args, **kwargs):
cmd = ['git'] + args
return RunCommand(cmd, **kwargs)
def RunGitWithCode(args):
proc = Popen(['git'] + args, stdout=subprocess.PIPE)
output = proc.communicate()[0]
return proc.returncode, output
def usage(more):
def hook(fn):
fn.usage_more = more
return fn
return hook
def FixUrl(server):
"""Fix a server url to defaults protocol to http:// if none is specified."""
if not server:
return server
if not re.match(r'[a-z]+\://.*', server):
return 'http://' + server
return server
class Settings(object):
def __init__(self):
self.default_server = None
self.cc = None
self.root = None
self.is_git_svn = None
self.svn_branch = None
self.tree_status_url = None
self.viewvc_url = None
self.updated = False
def LazyUpdateIfNeeded(self):
"""Updates the settings from a codereview.settings file, if available."""
if not self.updated:
cr_settings_file = FindCodereviewSettingsFile()
if cr_settings_file:
LoadCodereviewSettingsFromFile(cr_settings_file)
self.updated = True
def GetDefaultServerUrl(self, error_ok=False):
if not self.default_server:
self.LazyUpdateIfNeeded()
self.default_server = FixUrl(self._GetConfig('rietveld.server',
error_ok=True))
if error_ok:
return self.default_server
if not self.default_server:
error_message = ('Could not find settings file. You must configure '
'your review setup by running "git cl config".')
self.default_server = FixUrl(self._GetConfig(
'rietveld.server', error_message=error_message))
return self.default_server
def GetCCList(self):
"""Return the users cc'd on this CL.
Return is a string suitable for passing to gcl with the --cc flag.
"""
if self.cc is None:
self.cc = self._GetConfig('rietveld.cc', error_ok=True)
more_cc = self._GetConfig('rietveld.extracc', error_ok=True)
if more_cc is not None:
self.cc += ',' + more_cc
return self.cc
def GetRoot(self):
if not self.root:
self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
return self.root
def GetIsGitSvn(self):
"""Return true if this repo looks like it's using git-svn."""
if self.is_git_svn is None:
# If you have any "svn-remote.*" config keys, we think you're using svn.
self.is_git_svn = RunGitWithCode(
['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
return self.is_git_svn
def GetSVNBranch(self):
if self.svn_branch is None:
if not self.GetIsGitSvn():
DieWithError('Repo doesn\'t appear to be a git-svn repo.')
# Try to figure out which remote branch we're based on.
# Strategy:
# 1) find all git-svn branches and note their svn URLs.
# 2) iterate through our branch history and match up the URLs.
# regexp matching the git-svn line that contains the URL.
git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
# Get the refname and svn url for all refs/remotes/*.
remotes = RunGit(['for-each-ref', '--format=%(refname)',
'refs/remotes']).splitlines()
svn_refs = {}
for ref in remotes:
match = git_svn_re.search(RunGit(['cat-file', '-p', ref]))
# Prefer origin/HEAD over all others.
if match and (match.group(1) not in svn_refs or
ref == "refs/remotes/origin/HEAD"):
svn_refs[match.group(1)] = ref
if len(svn_refs) == 1:
# Only one svn branch exists -- seems like a good candidate.
self.svn_branch = svn_refs.values()[0]
elif len(svn_refs) > 1:
# We have more than one remote branch available. We don't
# want to go through all of history, so read a line from the
# pipe at a time.
# The -100 is an arbitrary limit so we don't search forever.
cmd = ['git', 'log', '-100', '--pretty=medium']
proc = Popen(cmd, stdout=subprocess.PIPE)
for line in proc.stdout:
match = git_svn_re.match(line)
if match:
url = match.group(1)
if url in svn_refs:
self.svn_branch = svn_refs[url]
proc.stdout.close() # Cut pipe.
break
if not self.svn_branch:
DieWithError('Can\'t guess svn branch -- try specifying it on the '
'command line')
return self.svn_branch
def GetTreeStatusUrl(self, error_ok=False):
if not self.tree_status_url:
error_message = ('You must configure your tree status URL by running '
'"git cl config".')
self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
error_ok=error_ok,
error_message=error_message)
return self.tree_status_url
def GetViewVCUrl(self):
if not self.viewvc_url:
self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
return self.viewvc_url
def _GetConfig(self, param, **kwargs):
self.LazyUpdateIfNeeded()
return RunGit(['config', param], **kwargs).strip()
settings = Settings()
did_migrate_check = False
def CheckForMigration():
"""Migrate from the old issue format, if found.
We used to store the branch<->issue mapping in a file in .git, but it's
better to store it in the .git/config, since deleting a branch deletes that
branch's entry there.
"""
# Don't run more than once.
global did_migrate_check
if did_migrate_check:
return
gitdir = RunGit(['rev-parse', '--git-dir']).strip()
storepath = os.path.join(gitdir, 'cl-mapping')
if os.path.exists(storepath):
print "old-style git-cl mapping file (%s) found; migrating." % storepath
store = open(storepath, 'r')
for line in store:
branch, issue = line.strip().split()
RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
issue])
store.close()
os.remove(storepath)
did_migrate_check = True
def ShortBranchName(branch):
"""Convert a name like 'refs/heads/foo' to just 'foo'."""
return branch.replace('refs/heads/', '')
class Changelist(object):
def __init__(self, branchref=None):
# Poke settings so we get the "configure your server" message if necessary.
settings.GetDefaultServerUrl()
self.branchref = branchref
if self.branchref:
self.branch = ShortBranchName(self.branchref)
else:
self.branch = None
self.rietveld_server = None
self.upstream_branch = None
self.has_issue = False
self.issue = None
self.has_description = False
self.description = None
self.has_patchset = False
self.patchset = None
def GetBranch(self):
"""Returns the short branch name, e.g. 'master'."""
if not self.branch:
self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
self.branch = ShortBranchName(self.branchref)
return self.branch
def GetBranchRef(self):
"""Returns the full branch name, e.g. 'refs/heads/master'."""
self.GetBranch() # Poke the lazy loader.
return self.branchref
def FetchUpstreamTuple(self):
"""Returns a tuple containg remote and remote ref,
e.g. 'origin', 'refs/heads/master'
"""
remote = '.'
branch = self.GetBranch()
upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
error_ok=True).strip()
if upstream_branch:
remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
else:
# Fall back on trying a git-svn upstream branch.
if settings.GetIsGitSvn():
upstream_branch = settings.GetSVNBranch()
else:
# Else, try to guess the origin remote.
remote_branches = RunGit(['branch', '-r']).split()
if 'origin/master' in remote_branches:
# Fall back on origin/master if it exits.
remote = 'origin'
upstream_branch = 'refs/heads/master'
elif 'origin/trunk' in remote_branches:
# Fall back on origin/trunk if it exists. Generally a shared
# git-svn clone
remote = 'origin'
upstream_branch = 'refs/heads/trunk'
else:
DieWithError("""Unable to determine default branch to diff against.
Either pass complete "git diff"-style arguments, like
git cl upload origin/master
or verify this branch is set up to track another (via the --track argument to
"git checkout -b ...").""")
return remote, upstream_branch
def GetUpstreamBranch(self):
if self.upstream_branch is None:
remote, upstream_branch = self.FetchUpstreamTuple()
if remote is not '.':
upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
self.upstream_branch = upstream_branch
return self.upstream_branch
def GetRemoteUrl(self):
"""Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
Returns None if there is no remote.
"""
remote = self.FetchUpstreamTuple()[0]
if remote == '.':
return None
return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
def GetIssue(self):
if not self.has_issue:
CheckForMigration()
issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
if issue:
self.issue = issue
self.rietveld_server = FixUrl(RunGit(
['config', self._RietveldServer()], error_ok=True).strip())
else:
self.issue = None
if not self.rietveld_server:
self.rietveld_server = settings.GetDefaultServerUrl()
self.has_issue = True
return self.issue
def GetRietveldServer(self):
self.GetIssue()
return self.rietveld_server
def GetIssueURL(self):
"""Get the URL for a particular issue."""
return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
def GetDescription(self, pretty=False):
if not self.has_description:
if self.GetIssue():
path = '/' + self.GetIssue() + '/description'
rpc_server = self._RpcServer()
self.description = rpc_server.Send(path).strip()
self.has_description = True
if pretty:
wrapper = textwrap.TextWrapper()
wrapper.initial_indent = wrapper.subsequent_indent = ' '
return wrapper.fill(self.description)
return self.description
def GetPatchset(self):
if not self.has_patchset:
patchset = RunGit(['config', self._PatchsetSetting()],
error_ok=True).strip()
if patchset:
self.patchset = patchset
else:
self.patchset = None
self.has_patchset = True
return self.patchset
def SetPatchset(self, patchset):
"""Set this branch's patchset. If patchset=0, clears the patchset."""
if patchset:
RunGit(['config', self._PatchsetSetting(), str(patchset)])
else:
RunGit(['config', '--unset', self._PatchsetSetting()],
swallow_stderr=True, error_ok=True)
self.has_patchset = False
def SetIssue(self, issue):
"""Set this branch's issue. If issue=0, clears the issue."""
if issue:
RunGit(['config', self._IssueSetting(), str(issue)])
if self.rietveld_server:
RunGit(['config', self._RietveldServer(), self.rietveld_server])
else:
RunGit(['config', '--unset', self._IssueSetting()])
self.SetPatchset(0)
self.has_issue = False
def CloseIssue(self):
rpc_server = self._RpcServer()
# Newer versions of Rietveld require us to pass an XSRF token to POST, so
# we fetch it from the server. (The version used by Chromium has been
# modified so the token isn't required when closing an issue.)
xsrf_token = rpc_server.Send('/xsrf_token',
extra_headers={'X-Requesting-XSRF-Token': '1'})
# You cannot close an issue with a GET.
# We pass an empty string for the data so it is a POST rather than a GET.
data = [("description", self.description),
("xsrf_token", xsrf_token)]
ctype, body = upload.EncodeMultipartFormData(data, [])
rpc_server.Send('/' + self.GetIssue() + '/close', body, ctype)
def _RpcServer(self):
"""Returns an upload.RpcServer() to access this review's rietveld instance.
"""
server = self.GetRietveldServer()
return upload.GetRpcServer(server, save_cookies=True)
def _IssueSetting(self):
"""Return the git setting that stores this change's issue."""
return 'branch.%s.rietveldissue' % self.GetBranch()
def _PatchsetSetting(self):
"""Return the git setting that stores this change's most recent patchset."""
return 'branch.%s.rietveldpatchset' % self.GetBranch()
def _RietveldServer(self):
"""Returns the git setting that stores this change's rietveld server."""
return 'branch.%s.rietveldserver' % self.GetBranch()
def GetCodereviewSettingsInteractively():
"""Prompt the user for settings."""
server = settings.GetDefaultServerUrl(error_ok=True)
prompt = 'Rietveld server (host[:port])'
prompt += ' [%s]' % (server or DEFAULT_SERVER)
newserver = raw_input(prompt + ': ')
if not server and not newserver:
newserver = DEFAULT_SERVER
if newserver and newserver != server:
RunGit(['config', 'rietveld.server', newserver])
def SetProperty(initial, caption, name):
prompt = caption
if initial:
prompt += ' ("x" to clear) [%s]' % initial
new_val = raw_input(prompt + ': ')
if new_val == 'x':
RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
elif new_val and new_val != initial:
RunGit(['config', 'rietveld.' + name, new_val])
SetProperty(settings.GetCCList(), 'CC list', 'cc')
SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
'tree-status-url')
SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
# TODO: configure a default branch to diff against, rather than this
# svn-based hackery.
def FindCodereviewSettingsFile(filename='codereview.settings'):
"""Finds the given file starting in the cwd and going up.
Only looks up to the top of the repository unless an
'inherit-review-settings-ok' file exists in the root of the repository.
"""
inherit_ok_file = 'inherit-review-settings-ok'
cwd = os.getcwd()
root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
if os.path.isfile(os.path.join(root, inherit_ok_file)):
root = '/'
while True:
if filename in os.listdir(cwd):
if os.path.isfile(os.path.join(cwd, filename)):
return open(os.path.join(cwd, filename))
if cwd == root:
break
cwd = os.path.dirname(cwd)
def LoadCodereviewSettingsFromFile(fileobj):
"""Parse a codereview.settings file and updates hooks."""
def DownloadToFile(url, filename):
filename = os.path.join(settings.GetRoot(), filename)
contents = urllib2.urlopen(url).read()
fileobj = open(filename, 'w')
fileobj.write(contents)
fileobj.close()
os.chmod(filename, 0755)
return 0
keyvals = {}
for line in fileobj.read().splitlines():
if not line or line.startswith("#"):
continue
k, v = line.split(": ", 1)
keyvals[k] = v
def GetProperty(name):
return keyvals.get(name)
def SetProperty(name, setting, unset_error_ok=False):
fullname = 'rietveld.' + name
if setting in keyvals:
RunGit(['config', fullname, keyvals[setting]])
else:
RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
SetProperty('server', 'CODE_REVIEW_SERVER')
# Only server setting is required. Other settings can be absent.
# In that case, we ignore errors raised during option deletion attempt.
SetProperty('cc', 'CC_LIST', unset_error_ok=True)
SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
#should be of the form
#PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
#ORIGIN_URL_CONFIG: http://src.chromium.org/git
RunGit(['config', keyvals['PUSH_URL_CONFIG'],
keyvals['ORIGIN_URL_CONFIG']])
# Update the hooks if the local hook files aren't present already.
if GetProperty('GITCL_PREUPLOAD') and not os.path.isfile(PREUPLOAD_HOOK):
DownloadToFile(GetProperty('GITCL_PREUPLOAD'), PREUPLOAD_HOOK)
if GetProperty('GITCL_PREDCOMMIT') and not os.path.isfile(PREDCOMMIT_HOOK):
DownloadToFile(GetProperty('GITCL_PREDCOMMIT'), PREDCOMMIT_HOOK)
return 0
@usage('[repo root containing codereview.settings]')
def CMDconfig(parser, args):
"""edit configuration for this tree"""
(options, args) = parser.parse_args(args)
if len(args) == 0:
GetCodereviewSettingsInteractively()
return 0
url = args[0]
if not url.endswith('codereview.settings'):
url = os.path.join(url, 'codereview.settings')
# Load code review settings and download hooks (if available).
LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
return 0
def CMDstatus(parser, args):
"""show status of changelists"""
parser.add_option('--field',
help='print only specific field (desc|id|patch|url)')
(options, args) = parser.parse_args(args)
# TODO: maybe make show_branches a flag if necessary.
show_branches = not options.field
if show_branches:
branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
if branches:
print 'Branches associated with reviews:'
for branch in sorted(branches.splitlines()):
cl = Changelist(branchref=branch)
print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
cl = Changelist()
if options.field:
if options.field.startswith('desc'):
print cl.GetDescription()
elif options.field == 'id':
issueid = cl.GetIssue()
if issueid:
print issueid
elif options.field == 'patch':
patchset = cl.GetPatchset()
if patchset:
print patchset
elif options.field == 'url':
url = cl.GetIssueURL()
if url:
print url
else:
print
print 'Current branch:',
if not cl.GetIssue():
print 'no issue assigned.'
return 0
print cl.GetBranch()
print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
print 'Issue description:'
print cl.GetDescription(pretty=True)
return 0
@usage('[issue_number]')
def CMDissue(parser, args):
"""Set or display the current code review issue number.
Pass issue number 0 to clear the current issue.
"""
(options, args) = parser.parse_args(args)
cl = Changelist()
if len(args) > 0:
try:
issue = int(args[0])
except ValueError:
DieWithError('Pass a number to set the issue or none to list it.\n'
'Maybe you want to run git cl status?')
cl.SetIssue(issue)
print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
return 0
def CreateDescriptionFromLog(args):
"""Pulls out the commit log to use as a base for the CL description."""
log_args = []
if len(args) == 1 and not args[0].endswith('.'):
log_args = [args[0] + '..']
elif len(args) == 1 and args[0].endswith('...'):
log_args = [args[0][:-1]]
elif len(args) == 2:
log_args = [args[0] + '..' + args[1]]
else:
log_args = args[:] # Hope for the best!
return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
def UserEditedLog(starting_text):
"""Given some starting text, let the user edit it and return the result."""
editor = os.getenv('EDITOR', 'vi')
(file_handle, filename) = tempfile.mkstemp()
fileobj = os.fdopen(file_handle, 'w')
fileobj.write(starting_text)
fileobj.close()
ret = subprocess.call(editor + ' ' + filename, shell=True)
if ret != 0:
os.remove(filename)
return
fileobj = open(filename)
text = fileobj.read()
fileobj.close()
os.remove(filename)
stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
return stripcomment_re.sub('', text).strip()
def RunHook(hook, upstream_branch, error_ok=False):
"""Run a given hook if it exists. By default, we fail on errors."""
hook = '%s/%s' % (settings.GetRoot(), hook)
if not os.path.exists(hook):
return
return RunCommand([hook, upstream_branch], error_ok=error_ok,
redirect_stdout=False)
def CMDpresubmit(parser, args):
"""run presubmit tests on the current changelist"""
parser.add_option('--upload', action='store_true',
help='Run upload hook instead of the push/dcommit hook')
(options, args) = parser.parse_args(args)
# Make sure index is up-to-date before running diff-index.
RunGit(['update-index', '--refresh', '-q'], error_ok=True)
if RunGit(['diff-index', 'HEAD']):
# TODO(maruel): Is this really necessary?
print 'Cannot presubmit with a dirty tree. You must commit locally first.'
return 1
if args:
base_branch = args[0]
else:
# Default to diffing against the "upstream" branch.
base_branch = Changelist().GetUpstreamBranch()
if options.upload:
print '*** Presubmit checks for UPLOAD would report: ***'
return not RunHook(PREUPLOAD_HOOK, upstream_branch=base_branch,
error_ok=True)
else:
print '*** Presubmit checks for DCOMMIT would report: ***'
return not RunHook(PREDCOMMIT_HOOK, upstream_branch=base_branch,
error_ok=True)
@usage('[args to "git diff"]')
def CMDupload(parser, args):
"""upload the current changelist to codereview"""
parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
help='bypass upload presubmit hook')
parser.add_option('-m', dest='message', help='message for patch')
parser.add_option('-r', '--reviewers',
help='reviewer email addresses')
parser.add_option('--send-mail', action='store_true',
help='send email to reviewer immediately')
parser.add_option("--emulate_svn_auto_props", action="store_true",
dest="emulate_svn_auto_props",
help="Emulate Subversion's auto properties feature.")
parser.add_option("--desc_from_logs", action="store_true",
dest="from_logs",
help="""Squashes git commit logs into change description and
uses message as subject""")
(options, args) = parser.parse_args(args)
# Make sure index is up-to-date before running diff-index.
RunGit(['update-index', '--refresh', '-q'], error_ok=True)
if RunGit(['diff-index', 'HEAD']):
print 'Cannot upload with a dirty tree. You must commit locally first.'
return 1
cl = Changelist()
if args:
base_branch = args[0]
else:
# Default to diffing against the "upstream" branch.
base_branch = cl.GetUpstreamBranch()
args = [base_branch + "..."]
if not options.bypass_hooks:
RunHook(PREUPLOAD_HOOK, upstream_branch=base_branch, error_ok=False)
# --no-ext-diff is broken in some versions of Git, so try to work around
# this by overriding the environment (but there is still a problem if the
# git config key "diff.external" is used).
env = os.environ.copy()
if 'GIT_EXTERNAL_DIFF' in env:
del env['GIT_EXTERNAL_DIFF']
subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
env=env)
upload_args = ['--assume_yes'] # Don't ask about untracked files.
upload_args.extend(['--server', cl.GetRietveldServer()])
if options.reviewers:
upload_args.extend(['--reviewers', options.reviewers])
upload_args.extend(['--cc', settings.GetCCList()])
if options.emulate_svn_auto_props:
upload_args.append('--emulate_svn_auto_props')
if options.send_mail:
if not options.reviewers:
DieWithError("Must specify reviewers to send email.")
upload_args.append('--send_mail')
if options.from_logs and not options.message:
print 'Must set message for subject line if using desc_from_logs'
return 1
change_desc = None
if cl.GetIssue():
if options.message:
upload_args.extend(['--message', options.message])
upload_args.extend(['--issue', cl.GetIssue()])
print ("This branch is associated with issue %s. "
"Adding patch to that issue." % cl.GetIssue())
else:
log_desc = CreateDescriptionFromLog(args)
if options.from_logs:
# Uses logs as description and message as subject.
subject = options.message
change_desc = subject + '\n\n' + log_desc
else:
initial_text = """# Enter a description of the change.
# This will displayed on the codereview site.
# The first line will also be used as the subject of the review.
"""
if 'BUG=' not in log_desc:
log_desc += '\nBUG='
if 'TEST=' not in log_desc:
log_desc += '\nTEST='
change_desc = UserEditedLog(initial_text + log_desc)
subject = ''
if change_desc:
subject = change_desc.splitlines()[0]
if not change_desc:
print "Description is empty; aborting."
return 1
upload_args.extend(['--message', subject])
upload_args.extend(['--description', change_desc])
# Include the upstream repo's URL in the change -- this is useful for
# projects that have their source spread across multiple repos.
remote_url = None
if settings.GetIsGitSvn():
data = RunGit(['svn', 'info'])
if data:
keys = dict(line.split(': ', 1) for line in data.splitlines()
if ': ' in line)
remote_url = keys.get('URL', None)
else:
if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
remote_url = (cl.GetRemoteUrl() + '@'
+ cl.GetUpstreamBranch().split('/')[-1])
if remote_url:
upload_args.extend(['--base_url', remote_url])
try:
issue, patchset = upload.RealMain(['upload'] + upload_args + args)
except:
# If we got an exception after the user typed a description for their
# change, back up the description before re-raising.
if change_desc:
backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
print '\nGot exception while uploading -- saving description to %s\n' \
% backup_path
backup_file = open(backup_path, 'w')
backup_file.write(change_desc)
backup_file.close()
raise
if not cl.GetIssue():
cl.SetIssue(issue)
cl.SetPatchset(patchset)
return 0
def SendUpstream(parser, args, cmd):
"""Common code for CmdPush and CmdDCommit
Squashed commit into a single.
Updates changelog with metadata (e.g. pointer to review).
Pushes/dcommits the code upstream.
Updates review and closes.
"""
parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
help='bypass upload presubmit hook')
parser.add_option('-m', dest='message',
help="override review description")
parser.add_option('-f', action='store_true', dest='force',
help="force yes to questions (don't prompt)")
parser.add_option('-c', dest='contributor',
help="external contributor for patch (appended to " +
"description and used as author for git). Should be " +
"formatted as 'First Last <email@example.com>'")
parser.add_option('--tbr', action='store_true', dest='tbr',
help="short for 'to be reviewed', commit branch " +
"even without uploading for review")
(options, args) = parser.parse_args(args)
cl = Changelist()
if not args or cmd == 'push':
# Default to merging against our best guess of the upstream branch.
args = [cl.GetUpstreamBranch()]
base_branch = args[0]
if RunGit(['diff-index', 'HEAD']):
print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
return 1
# This rev-list syntax means "show all commits not in my branch that
# are in base_branch".
upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
base_branch]).splitlines()
if upstream_commits:
print ('Base branch "%s" has %d commits '
'not in this branch.' % (base_branch, len(upstream_commits)))
print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
return 1
if cmd == 'dcommit':
# This is the revision `svn dcommit` will commit on top of.
svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
'--pretty=format:%H'])
extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
if extra_commits:
print ('This branch has %d additional commits not upstreamed yet.'
% len(extra_commits.splitlines()))
print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
'before attempting to %s.' % (base_branch, cmd))
return 1
if not options.force and not options.bypass_hooks:
RunHook(PREDCOMMIT_HOOK, upstream_branch=base_branch, error_ok=False)
if cmd == 'dcommit':
# Check the tree status if the tree status URL is set.
status = GetTreeStatus()
if 'closed' == status:
print ('The tree is closed. Please wait for it to reopen. Use '
'"git cl dcommit -f" to commit on a closed tree.')
return 1
elif 'unknown' == status:
print ('Unable to determine tree status. Please verify manually and '
'use "git cl dcommit -f" to commit on a closed tree.')
description = options.message
if not options.tbr:
# It is important to have these checks early. Not only for user
# convenience, but also because the cl object then caches the correct values
# of these fields even as we're juggling branches for setting up the commit.
if not cl.GetIssue():
print 'Current issue unknown -- has this branch been uploaded?'
print 'Use --tbr to commit without review.'
return 1
if not description:
description = cl.GetDescription()
if not description:
print 'No description set.'
print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
return 1
description += "\n\nReview URL: %s" % cl.GetIssueURL()
else:
if not description:
# Submitting TBR. See if there's already a description in Rietveld, else
# create a template description. Eitherway, give the user a chance to edit
# it to fill in the TBR= field.
if cl.GetIssue():
description = cl.GetDescription()
if not description:
description = """# Enter a description of the change.
# This will be used as the change log for the commit.
"""
description += CreateDescriptionFromLog(args)
description = UserEditedLog(description + '\nTBR=')
if not description:
print "Description empty; aborting."
return 1
if options.contributor:
if not re.match('^.*\s<\S+@\S+>$', options.contributor):
print "Please provide contibutor as 'First Last <email@example.com>'"
return 1
description += "\nPatch from %s." % options.contributor
print 'Description:', repr(description)
branches = [base_branch, cl.GetBranchRef()]
if not options.force:
subprocess.call(['git', 'diff', '--stat'] + branches)
raw_input("About to commit; enter to confirm.")
# We want to squash all this branch's commits into one commit with the
# proper description.
# We do this by doing a "merge --squash" into a new commit branch, then
# dcommitting that.
MERGE_BRANCH = 'git-cl-commit'
# Delete the merge branch if it already exists.
if RunGitWithCode(['show-ref', '--quiet', '--verify',
'refs/heads/' + MERGE_BRANCH])[0] == 0:
RunGit(['branch', '-D', MERGE_BRANCH])
# We might be in a directory that's present in this branch but not in the
# trunk. Move up to the top of the tree so that git commands that expect a
# valid CWD won't fail after we check out the merge branch.
rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
if rel_base_path:
os.chdir(rel_base_path)
# Stuff our change into the merge branch.
# We wrap in a try...finally block so if anything goes wrong,
# we clean up the branches.
try:
RunGit(['checkout', '-q', '-b', MERGE_BRANCH, base_branch])
RunGit(['merge', '--squash', cl.GetBranchRef()])
if options.contributor:
RunGit(['commit', '--author', options.contributor, '-m', description])
else:
RunGit(['commit', '-m', description])
if cmd == 'push':
# push the merge branch.
remote, branch = cl.FetchUpstreamTuple()
retcode, output = RunGitWithCode(
['push', '--porcelain', remote, 'HEAD:%s' % branch])
logging.debug(output)
else:
# dcommit the merge branch.
output = RunGit(['svn', 'dcommit', '--no-rebase'])
finally:
# And then swap back to the original branch and clean up.
RunGit(['checkout', '-q', cl.GetBranch()])
RunGit(['branch', '-D', MERGE_BRANCH])
if cl.GetIssue():
if cmd == 'dcommit' and 'Committed r' in output:
revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
elif cmd == 'push' and retcode == 0:
revision = output.splitlines()[1].split('\t')[2].split('..')[1]
else:
return 1
viewvc_url = settings.GetViewVCUrl()
if viewvc_url and revision:
cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
print ('Closing issue '
'(you may be prompted for your codereview password)...')
cl.CloseIssue()
cl.SetIssue(0)
return 0
@usage('[upstream branch to apply against]')
def CMDdcommit(parser, args):
"""commit the current changelist via git-svn"""
if not settings.GetIsGitSvn():
print('This doesn\'t appear to be an SVN repository.')
print('Are you sure you didn\'t mean \'git cl push\'?')
raw_input('[Press enter to dcommit or ctrl-C to quit]')
return SendUpstream(parser, args, 'dcommit')
@usage('[upstream branch to apply against]')
def CMDpush(parser, args):
"""commit the current changelist via git"""
if settings.GetIsGitSvn():
print('This appears to be an SVN repository.')
print('Are you sure you didn\'t mean \'git cl dcommit\'?')
raw_input('[Press enter to push or ctrl-C to quit]')
return SendUpstream(parser, args, 'push')
@usage('<patch url or issue id>')
def CMDpatch(parser, args):
"""patch in a code review"""
parser.add_option('-b', dest='newbranch',
help='create a new branch off trunk for the patch')
parser.add_option('-f', action='store_true', dest='force',
help='with -b, clobber any existing branch')
parser.add_option('--reject', action='store_true', dest='reject',
help='allow failed patches and spew .rej files')
parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
help="don't commit after patch applies")
(options, args) = parser.parse_args(args)
if len(args) != 1:
parser.print_help()
return 1
input = args[0]
if re.match(r'\d+', input):
# Input is an issue id. Figure out the URL.
issue = input
server = settings.GetDefaultServerUrl()
fetch = urllib2.urlopen('%s/%s' % (server, issue)).read()
m = re.search(r'/download/issue[0-9]+_[0-9]+.diff', fetch)
if not m:
DieWithError('Must pass an issue ID or full URL for '
'\'Download raw patch set\'')
url = '%s%s' % (server, m.group(0).strip())
else:
# Assume it's a URL to the patch. Default to http.
input = FixUrl(input)
match = re.match(r'.*?/issue(\d+)_\d+.diff', input)
if match:
issue = match.group(1)
url = input
else:
DieWithError('Must pass an issue ID or full URL for '
'\'Download raw patch set\'')
if options.newbranch:
if options.force:
RunGit(['branch', '-D', options.newbranch],
swallow_stderr=True, error_ok=True)
RunGit(['checkout', '-b', options.newbranch,
Changelist().GetUpstreamBranch()])
# Switch up to the top-level directory, if necessary, in preparation for
# applying the patch.
top = RunGit(['rev-parse', '--show-cdup']).strip()
if top:
os.chdir(top)
patch_data = urllib2.urlopen(url).read()
# Git patches have a/ at the beginning of source paths. We strip that out
# with a sed script rather than the -p flag to patch so we can feed either
# Git or svn-style patches into the same apply command.
# re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
patch_data = sed_proc.communicate(patch_data)[0]
if sed_proc.returncode:
DieWithError('Git patch mungling failed.')
logging.info(patch_data)
# We use "git apply" to apply the patch instead of "patch" so that we can
# pick up file adds.
# The --index flag means: also insert into the index (so we catch adds).
cmd = ['git', 'apply', '--index', '-p0']
if options.reject:
cmd.append('--reject')
patch_proc = Popen(cmd, stdin=subprocess.PIPE)
patch_proc.communicate(patch_data)
if patch_proc.returncode:
DieWithError('Failed to apply the patch')
# If we had an issue, commit the current state and register the issue.
if not options.nocommit:
RunGit(['commit', '-m', 'patch from issue %s' % issue])
cl = Changelist()
cl.SetIssue(issue)
print "Committed patch."
else:
print "Patch applied to index."
return 0
def CMDrebase(parser, args):
"""rebase current branch on top of svn repo"""
# Provide a wrapper for git svn rebase to help avoid accidental
# git svn dcommit.
# It's the only command that doesn't use parser at all since we just defer
# execution to git-svn.
RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
return 0
def GetTreeStatus():
"""Fetches the tree status and returns either 'open', 'closed',
'unknown' or 'unset'."""
url = settings.GetTreeStatusUrl(error_ok=True)
if url:
status = urllib2.urlopen(url).read().lower()
if status.find('closed') != -1 or status == '0':
return 'closed'
elif status.find('open') != -1 or status == '1':
return 'open'
return 'unknown'
return 'unset'
def GetTreeStatusReason():
"""Fetches the tree status from a json url and returns the message
with the reason for the tree to be opened or closed."""
# Don't import it at file level since simplejson is not installed by default
# on python 2.5 and it is only used for git-cl tree which isn't often used,
# forcing everyone to install simplejson isn't efficient.
try:
import simplejson as json
except ImportError:
try:
import json
# Some versions of python2.5 have an incomplete json module. Check to make
# sure loads exists.
json.loads
except (ImportError, AttributeError):
print >> sys.stderr, 'Please install simplejson'
sys.exit(1)
json_url = 'http://chromium-status.appspot.com/current?format=json'
connection = urllib2.urlopen(json_url)
status = json.loads(connection.read())
connection.close()
return status['message']
def CMDtree(parser, args):
"""show the status of the tree"""
(options, args) = parser.parse_args(args)
status = GetTreeStatus()
if 'unset' == status:
print 'You must configure your tree status URL by running "git cl config".'
return 2
print "The tree is %s" % status
print
print GetTreeStatusReason()
if status != 'open':
return 1
return 0
def CMDupstream(parser, args):
"""print the name of the upstream branch, if any"""
(options, args) = parser.parse_args(args)
cl = Changelist()
print cl.GetUpstreamBranch()
return 0
def Command(name):
return getattr(sys.modules[__name__], 'CMD' + name, None)
def CMDhelp(parser, args):
"""print list of commands or help for a specific command"""
(options, args) = parser.parse_args(args)
if len(args) == 1:
return main(args + ['--help'])
parser.print_help()
return 0
def GenUsage(parser, command):
"""Modify an OptParse object with the function's documentation."""
obj = Command(command)
more = getattr(obj, 'usage_more', '')
if command == 'help':
command = '<command>'
else:
# OptParser.description prefer nicely non-formatted strings.
parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
def main(argv):
"""Doesn't parse the arguments here, just find the right subcommand to
execute."""
# Do it late so all commands are listed.
CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
# Create the option parse and add --verbose support.
parser = optparse.OptionParser()
parser.add_option('-v', '--verbose', action='store_true')
old_parser_args = parser.parse_args
def Parse(args):
options, args = old_parser_args(args)
if options.verbose:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.WARNING)
return options, args
parser.parse_args = Parse
if argv:
command = Command(argv[0])
if command:
# "fix" the usage and the description now that we know the subcommand.
GenUsage(parser, argv[0])
try:
return command(parser, argv[1:])
except urllib2.HTTPError, e:
if e.code != 500:
raise
DieWithError(
('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
# Not a known command. Default to help.
GenUsage(parser, 'help')
return CMDhelp(parser, argv)
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
#!/bin/bash
# Check that abandoning a branch also abandons its issue.
set -e
. ./test-lib.sh
setup_initsvn
setup_gitsvn
(
set -e
cd git-svn
git config rietveld.server localhost:8080
# Create a branch and give it an issue.
git checkout -q -b abandoned
echo "some work done on a branch" >> test
git add test; git commit -q -m "branch work"
export EDITOR=/bin/true
test_expect_success "upload succeeds" \
"$GIT_CL upload -m test master... | grep -q 'Issue created'"
# Switch back to master, delete the branch.
git checkout master
git branch -D abandoned
# Verify that "status" doesn't know about it anymore.
# The "exit" trickiness is inverting the exit status of grep.
test_expect_success "git-cl status dropped abandoned branch" \
"$GIT_CL status | grep -q abandoned && exit 1 || exit 0"
)
SUCCESS=$?
cleanup
if [ $SUCCESS == 0 ]; then
echo PASS
fi
#!/bin/bash
set -e
. ./test-lib.sh
setup_initsvn
setup_gitsvn
(
set -e
cd git-svn
git checkout -q -b work
echo "some work done on a branch" >> test
git add test; git commit -q -m "branch work"
echo "some other work done on a branch" >> test
git add test; git commit -q -m "branch work"
test_expect_success "git-cl upload wants a server" \
"$GIT_CL upload 2>&1 | grep -q 'You must configure'"
git config rietveld.server localhost:8080
test_expect_success "git-cl status has no issue" \
"$GIT_CL status | grep -q 'no issue'"
# Prevent the editor from coming up when you upload.
export EDITOR=$(which true)
test_expect_success "upload succeeds (needs a server running on localhost)" \
"$GIT_CL upload -m test master... | grep -q 'Issue created'"
test_expect_success "git-cl status now knows the issue" \
"$GIT_CL status | grep -q 'Issue number'"
# Push a description to this URL.
URL=$($GIT_CL status | sed -ne '/Issue number/s/[^(]*(\(.*\))/\1/p')
curl --cookie dev_appserver_login="test@example.com:False" \
--data-urlencode subject="test" \
--data-urlencode description="foo-quux" \
--data-urlencode xsrf_token="$(print_xsrf_token)" \
$URL/edit
test_expect_success "git-cl dcommits ok" \
"$GIT_CL dcommit -f"
git checkout -q master
git svn -q rebase >/dev/null 2>&1
test_expect_success "dcommitted code has proper description" \
"git show | grep -q 'foo-quux'"
test_expect_success "issue no longer has a branch" \
"git cl status | grep -q 'work: None'"
test_expect_success "upstream svn has our commit" \
"svn log $REPO_URL 2>/dev/null | grep -q 'foo-quux'"
)
SUCCESS=$?
cleanup
if [ $SUCCESS == 0 ]; then
echo PASS
fi
#!/bin/bash
# Tests the "preupload and predcommit hooks" functionality, which lets you run
# hooks by installing a script into .git/hooks/pre-cl-* first.
set -e
. ./test-lib.sh
setup_initsvn
setup_gitsvn
(
set -e
cd git-svn
# We need a server set up, but we don't use it.
git config rietveld.server localhost:1
# Install a pre-cl-upload hook.
echo "#!/bin/bash" > .git/hooks/pre-cl-upload
echo "echo 'sample preupload fail'" >> .git/hooks/pre-cl-upload
echo "exit 1" >> .git/hooks/pre-cl-upload
chmod 755 .git/hooks/pre-cl-upload
# Install a pre-cl-dcommit hook.
echo "#!/bin/bash" > .git/hooks/pre-cl-dcommit
echo "echo 'sample predcommit fail'" >> .git/hooks/pre-cl-dcommit
echo "exit 1" >> .git/hooks/pre-cl-dcommit
chmod 755 .git/hooks/pre-cl-dcommit
echo "some work done" >> test
git add test; git commit -q -m "work"
# Verify git cl upload fails.
test_expect_failure "git-cl upload hook fails" "$GIT_CL upload master"
# Verify git cl upload fails.
test_expect_failure "git-cl dcommit hook fails" "$GIT_CL dcommit master"
)
SUCCESS=$?
#cleanup
if [ $SUCCESS == 0 ]; then
echo PASS
fi
#!/bin/bash
set -e
. ./test-lib.sh
setup_initgit
setup_gitgit
(
set -e
cd git-git
git checkout -q --track -b work origin
echo "some work done on a branch" >> test
git add test; git commit -q -m "branch work"
echo "some other work done on a branch" >> test
git add test; git commit -q -m "branch work"
test_expect_success "git-cl upload wants a server" \
"$GIT_CL upload 2>&1 | grep -q 'You must configure'"
git config rietveld.server localhost:8080
test_expect_success "git-cl status has no issue" \
"$GIT_CL status | grep -q 'no issue'"
# Prevent the editor from coming up when you upload.
export EDITOR=$(which true)
test_expect_success "upload succeeds (needs a server running on localhost)" \
"$GIT_CL upload -m test master... | grep -q 'Issue created'"
test_expect_success "git-cl status now knows the issue" \
"$GIT_CL status | grep -q 'Issue number'"
# Push a description to this URL.
URL=$($GIT_CL status | sed -ne '/Issue number/s/[^(]*(\(.*\))/\1/p')
curl --cookie dev_appserver_login="test@example.com:False" \
--data-urlencode subject="test" \
--data-urlencode description="foo-quux" \
--data-urlencode xsrf_token="$(print_xsrf_token)" \
$URL/edit
test_expect_success "Base URL contains branch name" \
"curl -s $($GIT_CL status --field=url) | grep 'URL:' | grep -q '@master'"
test_expect_success "git-cl push ok" \
"$GIT_CL push -f"
git checkout -q master > /dev/null 2>&1
git pull -q > /dev/null 2>&1
test_expect_success "committed code has proper description" \
"git show | grep -q 'foo-quux'"
test_expect_success "issue no longer has a branch" \
"git cl status | grep -q 'work: None'"
cd $GITREPO_PATH
test_expect_success "upstream repo has our commit" \
"git log master 2>/dev/null | grep -q 'foo-quux'"
)
SUCCESS=$?
cleanup
if [ $SUCCESS == 0 ]; then
echo PASS
fi
#!/bin/bash
set -e
. ./test-lib.sh
setup_initgit
setup_gitgit
(
set -e
cd git-git
git checkout -q --track -b work origin
echo "some work done on a branch" >> test
git add test; git commit -q -m "branch work"
echo "some other work done on a branch" >> test
git add test; git commit -q -m "branch work"
test_expect_success "git-cl upload wants a server" \
"$GIT_CL upload 2>&1 | grep -q 'You must configure'"
git config rietveld.server localhost:8080
test_expect_success "git-cl status has no issue" \
"$GIT_CL status | grep -q 'no issue'"
# Prevent the editor from coming up when you upload.
export EDITOR=$(which true)
test_expect_success "upload succeeds (needs a server running on localhost)" \
"$GIT_CL upload -m test --desc_from_logs master... | \
grep -q 'Issue created'"
test_expect_success "git-cl status now knows the issue" \
"$GIT_CL status | grep -q 'Issue number'"
# Check to see if the description contains the local commit messages.
# Should contain 'branch work' x 2.
test_expect_success "git-cl status has the right description for the log" \
"$GIT_CL status --field desc | [ $( egrep -q '^branch work$' -c ) -eq 2 ]
test_expect_success "git-cl status has the right subject from message" \
"$GIT_CL status --field desc | \
[ $( egrep -q '^test$' --byte-offset) | grep '^0:' ]
test_expect_success "git-cl push ok" \
"$GIT_CL push -f"
git checkout -q master > /dev/null 2>&1
git pull -q > /dev/null 2>&1
test_expect_success "committed code has proper description" \
"git show | [ $( egrep -q '^branch work$' -c ) -eq 2 ]
test_expect_success "issue no longer has a branch" \
"git cl status | grep -q 'work: None'"
cd $GITREPO_PATH
test_expect_success "upstream repo has our commit" \
"git log master 2>/dev/null | [ $( egrep -q '^branch work$' -c ) -eq 2 ]
)
SUCCESS=$?
cleanup
if [ $SUCCESS == 0 ]; then
echo PASS
fi
#!/bin/bash
# Renaming a file should show up as a rename in the review.
set -e
. ./test-lib.sh
setup_initsvn
setup_gitsvn
(
set -e
cd git-svn
git config rietveld.server localhost:8080
# Create a branch, rename a file, upload it.
git checkout -q -b rename
git mv test test2
git commit -q -m "renamed"
export EDITOR=/bin/true
test_expect_success "upload succeeds" \
"$GIT_CL upload -m test master... | grep -q 'Issue created'"
# Look at the uploaded patch and verify it is a rename patch.
echo "Rename test not fully implemented yet. :("
exit 1
)
SUCCESS=$?
cleanup
if [ $SUCCESS == 0 ]; then
echo PASS
fi
#!/bin/bash
# We should save a change's description when an upload fails.
set -e
. ./test-lib.sh
# Back up any previously-saved description the user might have.
BACKUP_FILE="$HOME/.git_cl_description_backup"
BACKUP_FILE_TMP="$BACKUP_FILE.tmp"
if [ -e "$BACKUP_FILE" ]; then
mv "$BACKUP_FILE" "$BACKUP_FILE_TMP"
fi
setup_initgit
setup_gitgit
(
set -e
cd git-git
DESC="this is the description"
# Create a branch and check in a file.
git checkout -q --track -b work origin
echo foo >> test
git add test; git commit -q -m "$DESC"
# Try to upload the change to an unresolvable hostname; git-cl should fail.
export EDITOR=/bin/true
git config rietveld.server bogus.example.com:80
test_expect_failure "uploading to bogus server" "$GIT_CL upload 2>/dev/null"
# Check that the change's description was saved.
test_expect_success "description was backed up" \
"grep -q '$DESC' '$BACKUP_FILE'"
)
SUCCESS=$?
cleanup
# Restore the previously-saved description.
rm -f "$BACKUP_FILE"
if [ -e "$BACKUP_FILE_TMP" ]; then
mv "$BACKUP_FILE_TMP" "$BACKUP_FILE"
fi
if [ $SUCCESS == 0 ]; then
echo PASS
fi
#!/bin/bash
# Check that we're able to submit from a directory that doesn't exist on the
# trunk. This tests for a previous bug where we ended up with an invalid CWD
# after switching to the merge branch.
set -e
. ./test-lib.sh
setup_initsvn
setup_gitsvn
(
set -e
cd git-svn
git config rietveld.server localhost:8080
# Create a branch and give it an issue.
git checkout -q -b new
mkdir dir
cd dir
echo "some work done on a branch" >> test
git add test; git commit -q -m "branch work"
export EDITOR=/bin/true
test_expect_success "upload succeeds" \
"$GIT_CL upload -m test master... | grep -q 'Issue created'"
test_expect_success "git-cl dcommits ok" \
"$GIT_CL dcommit -f"
)
SUCCESS=$?
cleanup
if [ $SUCCESS == 0 ]; then
echo PASS
fi
#!/bin/bash
# Tests the "tbr" functionality, which lets you submit without uploading
# first.
set -e
. ./test-lib.sh
setup_initsvn
setup_gitsvn
(
set -e
cd git-svn
# We need a server set up, but we don't use it.
git config rietveld.server localhost:1
echo "some work done" >> test
git add test; git commit -q -m "work"
test_expect_success "git-cl dcommit tbr without an issue" \
"$GIT_CL dcommit -f --tbr -m 'foo-quux'"
git svn -q rebase >/dev/null 2>&1
test_expect_success "dcommitted code has proper description" \
"git show | grep -q 'foo-quux'"
test_expect_success "upstream svn has our commit" \
"svn log $REPO_URL 2>/dev/null | grep -q 'foo-quux'"
)
SUCCESS=$?
cleanup
if [ $SUCCESS == 0 ]; then
echo PASS
fi
#!/bin/bash
# Abort on error.
set -e
PWD=`pwd`
REPO_URL=file://$PWD/svnrepo
GITREPO_PATH=$PWD/gitrepo
GITREPO_URL=file://$GITREPO_PATH
GIT_CL=$PWD/../git-cl
# Set up an SVN repo that has a few commits to trunk.
setup_initsvn() {
echo "Setting up test SVN repo..."
rm -rf svnrepo
svnadmin create svnrepo
rm -rf svn
svn co -q $REPO_URL svn
(
cd svn
echo "test" > test
svn add -q test
svn commit -q -m "initial commit"
echo "test2" >> test
svn commit -q -m "second commit"
)
}
# Set up a git-svn checkout of the repo.
setup_gitsvn() {
echo "Setting up test git-svn repo..."
rm -rf git-svn
# There appears to be no way to make git-svn completely shut up, so we
# redirect its output.
git svn -q clone $REPO_URL git-svn >/dev/null 2>&1
}
# Set up a git repo that has a few commits to master.
setup_initgit() {
echo "Setting up test upstream git repo..."
rm -rf gitrepo
mkdir gitrepo
(
cd gitrepo
git init -q
echo "test" > test
git add test
git commit -qam "initial commit"
echo "test2" >> test
git commit -qam "second commit"
# Hack: make sure master is not the current branch
# otherwise push will give a warning
git checkout -q -b foo
)
}
# Set up a git checkout of the repo.
setup_gitgit() {
echo "Setting up test git repo..."
rm -rf git-git
git clone -q $GITREPO_URL git-git
}
cleanup() {
rm -rf gitrepo svnrepo svn git-git git-svn
}
# Usage: test_expect_success "description of test" "test code".
test_expect_success() {
echo "TESTING: $1"
exit_code=0
sh -c "$2" || exit_code=$?
if [ $exit_code != 0 ]; then
echo "FAILURE: $1"
return $exit_code
fi
}
# Usage: test_expect_failure "description of test" "test code".
test_expect_failure() {
echo "TESTING: $1"
exit_code=0
sh -c "$2" || exit_code=$?
if [ $exit_code = 0 ]; then
echo "SUCCESS, BUT EXPECTED FAILURE: $1"
return $exit_code
fi
}
# Grab the XSRF token from the review server and print it to stdout.
print_xsrf_token() {
curl --cookie dev_appserver_login="test@example.com:False" \
--header 'X-Requesting-XSRF-Token: 1' \
http://localhost:8080/xsrf_token 2>/dev/null
}
#!/bin/bash
set -e
. ./test-lib.sh
setup_initgit
setup_gitgit
(
set -e
cd git-git
git checkout -q -b work HEAD^
git checkout -q -t -b work2 work
echo "some work done on a branch that tracks a local branch" >> test
git add test; git commit -q -m "local tracking branch work"
git config rietveld.server localhost:8080
# Prevent the editor from coming up when you upload.
export EDITOR=/bin/true
test_expect_success "upload succeeds (needs a server running on localhost)" \
"$GIT_CL upload -m test | grep -q 'Issue created'"
)
SUCCESS=$?
cleanup
if [ $SUCCESS == 0 ]; then
echo PASS
fi
#!/bin/bash
set -e
. ./test-lib.sh
setup_initgit
setup_gitgit
(
set -e
cd git-git
git checkout -q -b work HEAD^
echo "some work done on a branch" >> test
git add test; git commit -q -m "branch work"
git config rietveld.server localhost:8080
# Prevent the editor from coming up when you upload.
export EDITOR=/bin/true
test_expect_success "upload succeeds (needs a server running on localhost)" \
"$GIT_CL upload -m test | grep -q 'Issue created'"
test_expect_failure "description shouldn't contain unrelated commits" \
"$GIT_CL status | grep -q 'second commit'"
)
SUCCESS=$?
cleanup
if [ $SUCCESS == 0 ]; then
echo PASS
fi
#!/usr/bin/env python
#
# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tool for uploading diffs from a version control system to the codereview app.
Usage summary: upload.py [options] [-- diff_options] [path...]
Diff options are passed to the diff command of the underlying system.
Supported version control systems:
Git
Mercurial
Subversion
It is important for Git/Mercurial users to specify a tree/node/branch to diff
against by using the '--rev' option.
"""
# This code is derived from appcfg.py in the App Engine SDK (open source),
# and from ASPN recipe #146306.
import ConfigParser
import cookielib
import fnmatch
import getpass
import logging
import mimetypes
import optparse
import os
import re
import socket
import subprocess
import sys
import urllib
import urllib2
import urlparse
# The md5 module was deprecated in Python 2.5.
try:
from hashlib import md5
except ImportError:
from md5 import md5
try:
import readline
except ImportError:
pass
try:
import keyring
except ImportError:
keyring = None
# The logging verbosity:
# 0: Errors only.
# 1: Status messages.
# 2: Info logs.
# 3: Debug logs.
verbosity = 1
# The account type used for authentication.
# This line could be changed by the review server (see handler for
# upload.py).
AUTH_ACCOUNT_TYPE = "GOOGLE"
# URL of the default review server. As for AUTH_ACCOUNT_TYPE, this line could be
# changed by the review server (see handler for upload.py).
DEFAULT_REVIEW_SERVER = "codereview.appspot.com"
# Max size of patch or base file.
MAX_UPLOAD_SIZE = 900 * 1024
# Constants for version control names. Used by GuessVCSName.
VCS_GIT = "Git"
VCS_MERCURIAL = "Mercurial"
VCS_SUBVERSION = "Subversion"
VCS_UNKNOWN = "Unknown"
# whitelist for non-binary filetypes which do not start with "text/"
# .mm (Objective-C) shows up as application/x-freemind on my Linux box.
TEXT_MIMETYPES = ['application/javascript', 'application/x-javascript',
'application/xml', 'application/x-freemind',
'application/x-sh']
VCS_ABBREVIATIONS = {
VCS_MERCURIAL.lower(): VCS_MERCURIAL,
"hg": VCS_MERCURIAL,
VCS_SUBVERSION.lower(): VCS_SUBVERSION,
"svn": VCS_SUBVERSION,
VCS_GIT.lower(): VCS_GIT,
}
# The result of parsing Subversion's [auto-props] setting.
svn_auto_props_map = None
def GetEmail(prompt):
"""Prompts the user for their email address and returns it.
The last used email address is saved to a file and offered up as a suggestion
to the user. If the user presses enter without typing in anything the last
used email address is used. If the user enters a new address, it is saved
for next time we prompt.
"""
last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
last_email = ""
if os.path.exists(last_email_file_name):
try:
last_email_file = open(last_email_file_name, "r")
last_email = last_email_file.readline().strip("\n")
last_email_file.close()
prompt += " [%s]" % last_email
except IOError, e:
pass
email = raw_input(prompt + ": ").strip()
if email:
try:
last_email_file = open(last_email_file_name, "w")
last_email_file.write(email)
last_email_file.close()
except IOError, e:
pass
else:
email = last_email
return email
def StatusUpdate(msg):
"""Print a status message to stdout.
If 'verbosity' is greater than 0, print the message.
Args:
msg: The string to print.
"""
if verbosity > 0:
print msg
def ErrorExit(msg):
"""Print an error message to stderr and exit."""
print >>sys.stderr, msg
sys.exit(1)
class ClientLoginError(urllib2.HTTPError):
"""Raised to indicate there was an error authenticating with ClientLogin."""
def __init__(self, url, code, msg, headers, args):
urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
self.args = args
self.reason = args["Error"]
class AbstractRpcServer(object):
"""Provides a common interface for a simple RPC server."""
def __init__(self, host, auth_function, host_override=None, extra_headers={},
save_cookies=False, account_type=AUTH_ACCOUNT_TYPE):
"""Creates a new HttpRpcServer.
Args:
host: The host to send requests to.
auth_function: A function that takes no arguments and returns an
(email, password) tuple when called. Will be called if authentication
is required.
host_override: The host header to send to the server (defaults to host).
extra_headers: A dict of extra headers to append to every request.
save_cookies: If True, save the authentication cookies to local disk.
If False, use an in-memory cookiejar instead. Subclasses must
implement this functionality. Defaults to False.
account_type: Account type used for authentication. Defaults to
AUTH_ACCOUNT_TYPE.
"""
self.host = host
if (not self.host.startswith("http://") and
not self.host.startswith("https://")):
self.host = "http://" + self.host
assert re.match(r'^[a-z]+://[a-z0-9\.-_]+(|:[0-9]+)$', self.host), (
'%s is malformed' % host)
self.host_override = host_override
self.auth_function = auth_function
self.authenticated = False
self.extra_headers = extra_headers
self.save_cookies = save_cookies
self.account_type = account_type
self.opener = self._GetOpener()
if self.host_override:
logging.info("Server: %s; Host: %s", self.host, self.host_override)
else:
logging.info("Server: %s", self.host)
def _GetOpener(self):
"""Returns an OpenerDirector for making HTTP requests.
Returns:
A urllib2.OpenerDirector object.
"""
raise NotImplementedError()
def _CreateRequest(self, url, data=None):
"""Creates a new urllib request."""
logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
req = urllib2.Request(url, data=data)
if self.host_override:
req.add_header("Host", self.host_override)
for key, value in self.extra_headers.iteritems():
req.add_header(key, value)
return req
def _GetAuthToken(self, host, email, password):
"""Uses ClientLogin to authenticate the user, returning an auth token.
Args:
host: Host to get a token against.
email: The user's email address
password: The user's password
Raises:
ClientLoginError: If there was an error authenticating with ClientLogin.
HTTPError: If there was some other form of HTTP error.
Returns:
The authentication token returned by ClientLogin.
"""
account_type = self.account_type
if host.endswith(".google.com"):
# Needed for use inside Google.
account_type = "HOSTED"
req = self._CreateRequest(
url="https://www.google.com/accounts/ClientLogin",
data=urllib.urlencode({
"Email": email,
"Passwd": password,
"service": "ah",
"source": "rietveld-codereview-upload",
"accountType": account_type,
}),
)
try:
response = self.opener.open(req)
response_body = response.read()
response_dict = dict(x.split("=")
for x in response_body.split("\n") if x)
return response_dict["Auth"]
except urllib2.HTTPError, e:
if e.code == 403:
body = e.read()
response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
raise ClientLoginError(req.get_full_url(), e.code, e.msg,
e.headers, response_dict)
else:
raise
def _GetAuthCookie(self, host, auth_token):
"""Fetches authentication cookies for an authentication token.
Args:
host: The host to get a cookie against. Because of 301, it may be a
different host than self.host.
auth_token: The authentication token returned by ClientLogin.
Raises:
HTTPError: If there was an error fetching the authentication cookies.
"""
# This is a dummy value to allow us to identify when we're successful.
continue_location = "http://localhost/"
args = {"continue": continue_location, "auth": auth_token}
tries = 0
url = "%s/_ah/login?%s" % (host, urllib.urlencode(args))
while tries < 3:
tries += 1
req = self._CreateRequest(url)
try:
response = self.opener.open(req)
except urllib2.HTTPError, e:
response = e
if e.code == 301:
# Handle permanent redirect manually.
url = e.info()["location"]
continue
break
if (response.code != 302 or
response.info()["location"] != continue_location):
raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg,
response.headers, response.fp)
self.authenticated = True
def _Authenticate(self, host):
"""Authenticates the user.
Args:
host: The host to get a cookie against. Because of 301, it may be a
different host than self.host.
The authentication process works as follows:
1) We get a username and password from the user
2) We use ClientLogin to obtain an AUTH token for the user
(see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
3) We pass the auth token to /_ah/login on the server to obtain an
authentication cookie. If login was successful, it tries to redirect
us to the URL we provided.
If we attempt to access the upload API without first obtaining an
authentication cookie, it returns a 401 response (or a 302) and
directs us to authenticate ourselves with ClientLogin.
"""
for i in range(3):
credentials = self.auth_function()
try:
auth_token = self._GetAuthToken(host, credentials[0], credentials[1])
except ClientLoginError, e:
if e.reason == "BadAuthentication":
print >>sys.stderr, "Invalid username or password."
continue
if e.reason == "CaptchaRequired":
print >>sys.stderr, (
"Please go to\n"
"https://www.google.com/accounts/DisplayUnlockCaptcha\n"
"and verify you are a human. Then try again.\n"
"If you are using a Google Apps account the URL is:\n"
"https://www.google.com/a/yourdomain.com/UnlockCaptcha")
break
if e.reason == "NotVerified":
print >>sys.stderr, "Account not verified."
break
if e.reason == "TermsNotAgreed":
print >>sys.stderr, "User has not agreed to TOS."
break
if e.reason == "AccountDeleted":
print >>sys.stderr, "The user account has been deleted."
break
if e.reason == "AccountDisabled":
print >>sys.stderr, "The user account has been disabled."
break
if e.reason == "ServiceDisabled":
print >>sys.stderr, ("The user's access to the service has been "
"disabled.")
break
if e.reason == "ServiceUnavailable":
print >>sys.stderr, "The service is not available; try again later."
break
raise
self._GetAuthCookie(host, auth_token)
return
def Send(self, request_path, payload=None,
content_type="application/octet-stream",
timeout=None,
extra_headers=None,
**kwargs):
"""Sends an RPC and returns the response.
Args:
request_path: The path to send the request to, eg /api/appversion/create.
payload: The body of the request, or None to send an empty request.
content_type: The Content-Type header to use.
timeout: timeout in seconds; default None i.e. no timeout.
(Note: for large requests on OS X, the timeout doesn't work right.)
extra_headers: Dict containing additional HTTP headers that should be
included in the request (string header names mapped to their values),
or None to not include any additional headers.
kwargs: Any keyword arguments are converted into query string parameters.
Returns:
The response body, as a string.
"""
# TODO: Don't require authentication. Let the server say
# whether it is necessary.
if not self.authenticated:
self._Authenticate(self.host)
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(timeout)
try:
tries = 0
args = dict(kwargs)
url = "%s%s" % (self.host, request_path)
if args:
url += "?" + urllib.urlencode(args)
while True:
tries += 1
req = self._CreateRequest(url=url, data=payload)
req.add_header("Content-Type", content_type)
if extra_headers:
for header, value in extra_headers.items():
req.add_header(header, value)
try:
f = self.opener.open(req)
response = f.read()
f.close()
return response
except urllib2.HTTPError, e:
if tries > 3:
raise
elif e.code == 401 or e.code == 302:
url_loc = urlparse.urlparse(url)
self._Authenticate('%s://%s' % (url_loc[0], url_loc[1]))
## elif e.code >= 500 and e.code < 600:
## # Server Error - try again.
## continue
elif e.code == 301:
# Handle permanent redirect manually.
url = e.info()["location"]
else:
raise
finally:
socket.setdefaulttimeout(old_timeout)
class HttpRpcServer(AbstractRpcServer):
"""Provides a simplified RPC-style interface for HTTP requests."""
def _Authenticate(self, *args):
"""Save the cookie jar after authentication."""
super(HttpRpcServer, self)._Authenticate(*args)
if self.save_cookies:
StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
self.cookie_jar.save()
def _GetOpener(self):
"""Returns an OpenerDirector that supports cookies and ignores redirects.
Returns:
A urllib2.OpenerDirector object.
"""
opener = urllib2.OpenerDirector()
opener.add_handler(urllib2.ProxyHandler())
opener.add_handler(urllib2.UnknownHandler())
opener.add_handler(urllib2.HTTPHandler())
opener.add_handler(urllib2.HTTPDefaultErrorHandler())
opener.add_handler(urllib2.HTTPSHandler())
opener.add_handler(urllib2.HTTPErrorProcessor())
if self.save_cookies:
self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies")
self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
if os.path.exists(self.cookie_file):
try:
self.cookie_jar.load()
self.authenticated = True
StatusUpdate("Loaded authentication cookies from %s" %
self.cookie_file)
except (cookielib.LoadError, IOError):
# Failed to load cookies - just ignore them.
pass
else:
# Create an empty cookie file with mode 600
fd = os.open(self.cookie_file, os.O_CREAT, 0600)
os.close(fd)
# Always chmod the cookie file
os.chmod(self.cookie_file, 0600)
else:
# Don't save cookies across runs of update.py.
self.cookie_jar = cookielib.CookieJar()
opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
return opener
parser = optparse.OptionParser(
usage="%prog [options] [-- diff_options] [path...]")
parser.add_option("-y", "--assume_yes", action="store_true",
dest="assume_yes", default=False,
help="Assume that the answer to yes/no questions is 'yes'.")
# Logging
group = parser.add_option_group("Logging options")
group.add_option("-q", "--quiet", action="store_const", const=0,
dest="verbose", help="Print errors only.")
group.add_option("-v", "--verbose", action="store_const", const=2,
dest="verbose", default=1,
help="Print info level logs.")
group.add_option("--noisy", action="store_const", const=3,
dest="verbose", help="Print all logs.")
# Review server
group = parser.add_option_group("Review server options")
group.add_option("-s", "--server", action="store", dest="server",
default=DEFAULT_REVIEW_SERVER,
metavar="SERVER",
help=("The server to upload to. The format is host[:port]. "
"Defaults to '%default'."))
group.add_option("-e", "--email", action="store", dest="email",
metavar="EMAIL", default=None,
help="The username to use. Will prompt if omitted.")
group.add_option("-H", "--host", action="store", dest="host",
metavar="HOST", default=None,
help="Overrides the Host header sent with all RPCs.")
group.add_option("--no_cookies", action="store_false",
dest="save_cookies", default=True,
help="Do not save authentication cookies to local disk.")
group.add_option("--account_type", action="store", dest="account_type",
metavar="TYPE", default=AUTH_ACCOUNT_TYPE,
choices=["GOOGLE", "HOSTED"],
help=("Override the default account type "
"(defaults to '%default', "
"valid choices are 'GOOGLE' and 'HOSTED')."))
# Issue
group = parser.add_option_group("Issue options")
group.add_option("-d", "--description", action="store", dest="description",
metavar="DESCRIPTION", default=None,
help="Optional description when creating an issue.")
group.add_option("-f", "--description_file", action="store",
dest="description_file", metavar="DESCRIPTION_FILE",
default=None,
help="Optional path of a file that contains "
"the description when creating an issue.")
group.add_option("-r", "--reviewers", action="store", dest="reviewers",
metavar="REVIEWERS", default=None,
help="Add reviewers (comma separated email addresses).")
group.add_option("--cc", action="store", dest="cc",
metavar="CC", default=None,
help="Add CC (comma separated email addresses).")
group.add_option("--private", action="store_true", dest="private",
default=False,
help="Make the issue restricted to reviewers and those CCed")
# Upload options
group = parser.add_option_group("Patch options")
group.add_option("-m", "--message", action="store", dest="message",
metavar="MESSAGE", default=None,
help="A message to identify the patch. "
"Will prompt if omitted.")
group.add_option("-i", "--issue", type="int", action="store",
metavar="ISSUE", default=None,
help="Issue number to which to add. Defaults to new issue.")
group.add_option("--base_url", action="store", dest="base_url", default=None,
help="Base repository URL (listed as \"Base URL\" when "
"viewing issue). If omitted, will be guessed automatically "
"for SVN repos and left blank for others.")
group.add_option("--download_base", action="store_true",
dest="download_base", default=False,
help="Base files will be downloaded by the server "
"(side-by-side diffs may not work on files with CRs).")
group.add_option("--rev", action="store", dest="revision",
metavar="REV", default=None,
help="Base revision/branch/tree to diff against. Use "
"rev1:rev2 range to review already committed changeset.")
group.add_option("--send_mail", action="store_true",
dest="send_mail", default=False,
help="Send notification email to reviewers.")
group.add_option("--vcs", action="store", dest="vcs",
metavar="VCS", default=None,
help=("Version control system (optional, usually upload.py "
"already guesses the right VCS)."))
group.add_option("--emulate_svn_auto_props", action="store_true",
dest="emulate_svn_auto_props", default=False,
help=("Emulate Subversion's auto properties feature."))
def GetRpcServer(server, email=None, host_override=None, save_cookies=True,
account_type=AUTH_ACCOUNT_TYPE):
"""Returns an instance of an AbstractRpcServer.
Args:
server: String containing the review server URL.
email: String containing user's email address.
host_override: If not None, string containing an alternate hostname to use
in the host header.
save_cookies: Whether authentication cookies should be saved to disk.
account_type: Account type for authentication, either 'GOOGLE'
or 'HOSTED'. Defaults to AUTH_ACCOUNT_TYPE.
Returns:
A new AbstractRpcServer, on which RPC calls can be made.
"""
rpc_server_class = HttpRpcServer
# If this is the dev_appserver, use fake authentication.
host = (host_override or server).lower()
if re.match(r'(http://)?localhost([:/]|$)', host):
if email is None:
email = "test@example.com"
logging.info("Using debug user %s. Override with --email" % email)
server = rpc_server_class(
server,
lambda: (email, "password"),
host_override=host_override,
extra_headers={"Cookie":
'dev_appserver_login="%s:False"' % email},
save_cookies=save_cookies,
account_type=account_type)
# Don't try to talk to ClientLogin.
server.authenticated = True
return server
def GetUserCredentials():
"""Prompts the user for a username and password."""
# Create a local alias to the email variable to avoid Python's crazy
# scoping rules.
local_email = email
if local_email is None:
local_email = GetEmail("Email (login for uploading to %s)" % server)
password = None
if keyring:
password = keyring.get_password(host, local_email)
if password is not None:
print "Using password from system keyring."
else:
password = getpass.getpass("Password for %s: " % local_email)
if keyring:
answer = raw_input("Store password in system keyring?(y/N) ").strip()
if answer == "y":
keyring.set_password(host, local_email, password)
return (local_email, password)
return rpc_server_class(server,
GetUserCredentials,
host_override=host_override,
save_cookies=save_cookies)
def EncodeMultipartFormData(fields, files):
"""Encode form fields for multipart/form-data.
Args:
fields: A sequence of (name, value) elements for regular form fields.
files: A sequence of (name, filename, value) elements for data to be
uploaded as files.
Returns:
(content_type, body) ready for httplib.HTTP instance.
Source:
http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
"""
BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
CRLF = '\r\n'
lines = []
for (key, value) in fields:
lines.append('--' + BOUNDARY)
lines.append('Content-Disposition: form-data; name="%s"' % key)
lines.append('')
if isinstance(value, unicode):
value = value.encode('utf-8')
lines.append(value)
for (key, filename, value) in files:
lines.append('--' + BOUNDARY)
lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' %
(key, filename))
lines.append('Content-Type: %s' % GetContentType(filename))
lines.append('')
if isinstance(value, unicode):
value = value.encode('utf-8')
lines.append(value)
lines.append('--' + BOUNDARY + '--')
lines.append('')
body = CRLF.join(lines)
content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
return content_type, body
def GetContentType(filename):
"""Helper to guess the content-type from the filename."""
return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
# Use a shell for subcommands on Windows to get a PATH search.
use_shell = sys.platform.startswith("win")
def RunShellWithReturnCode(command, print_output=False,
universal_newlines=True,
env=os.environ):
"""Executes a command and returns the output from stdout and the return code.
Args:
command: Command to execute.
print_output: If True, the output is printed to stdout.
If False, both stdout and stderr are ignored.
universal_newlines: Use universal_newlines flag (default: True).
Returns:
Tuple (output, return code)
"""
logging.info("Running %s", command)
p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
shell=use_shell, universal_newlines=universal_newlines,
env=env)
if print_output:
output_array = []
while True:
line = p.stdout.readline()
if not line:
break
print line.strip("\n")
output_array.append(line)
output = "".join(output_array)
else:
output = p.stdout.read()
p.wait()
errout = p.stderr.read()
if print_output and errout:
print >>sys.stderr, errout
p.stdout.close()
p.stderr.close()
return output, p.returncode
def RunShell(command, silent_ok=False, universal_newlines=True,
print_output=False, env=os.environ):
data, retcode = RunShellWithReturnCode(command, print_output,
universal_newlines, env)
if retcode:
ErrorExit("Got error status from %s:\n%s" % (command, data))
if not silent_ok and not data:
ErrorExit("No output from %s" % command)
return data
class VersionControlSystem(object):
"""Abstract base class providing an interface to the VCS."""
def __init__(self, options):
"""Constructor.
Args:
options: Command line options.
"""
self.options = options
def PostProcessDiff(self, diff):
"""Return the diff with any special post processing this VCS needs, e.g.
to include an svn-style "Index:"."""
return diff
def GenerateDiff(self, args):
"""Return the current diff as a string.
Args:
args: Extra arguments to pass to the diff command.
"""
raise NotImplementedError(
"abstract method -- subclass %s must override" % self.__class__)
def GetUnknownFiles(self):
"""Return a list of files unknown to the VCS."""
raise NotImplementedError(
"abstract method -- subclass %s must override" % self.__class__)
def CheckForUnknownFiles(self):
"""Show an "are you sure?" prompt if there are unknown files."""
unknown_files = self.GetUnknownFiles()
if unknown_files:
print "The following files are not added to version control:"
for line in unknown_files:
print line
prompt = "Are you sure to continue?(y/N) "
answer = raw_input(prompt).strip()
if answer != "y":
ErrorExit("User aborted")
def GetBaseFile(self, filename):
"""Get the content of the upstream version of a file.
Returns:
A tuple (base_content, new_content, is_binary, status)
base_content: The contents of the base file.
new_content: For text files, this is empty. For binary files, this is
the contents of the new file, since the diff output won't contain
information to reconstruct the current file.
is_binary: True iff the file is binary.
status: The status of the file.
"""
raise NotImplementedError(
"abstract method -- subclass %s must override" % self.__class__)
def GetBaseFiles(self, diff):
"""Helper that calls GetBase file for each file in the patch.
Returns:
A dictionary that maps from filename to GetBaseFile's tuple. Filenames
are retrieved based on lines that start with "Index:" or
"Property changes on:".
"""
files = {}
for line in diff.splitlines(True):
if line.startswith('Index:') or line.startswith('Property changes on:'):
unused, filename = line.split(':', 1)
# On Windows if a file has property changes its filename uses '\'
# instead of '/'.
filename = filename.strip().replace('\\', '/')
files[filename] = self.GetBaseFile(filename)
return files
def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
files):
"""Uploads the base files (and if necessary, the current ones as well)."""
def UploadFile(filename, file_id, content, is_binary, status, is_base):
"""Uploads a file to the server."""
file_too_large = False
if is_base:
type = "base"
else:
type = "current"
if len(content) > MAX_UPLOAD_SIZE:
print ("Not uploading the %s file for %s because it's too large." %
(type, filename))
file_too_large = True
content = ""
checksum = md5(content).hexdigest()
if options.verbose > 0 and not file_too_large:
print "Uploading %s file for %s" % (type, filename)
url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
form_fields = [("filename", filename),
("status", status),
("checksum", checksum),
("is_binary", str(is_binary)),
("is_current", str(not is_base)),
]
if file_too_large:
form_fields.append(("file_too_large", "1"))
if options.email:
form_fields.append(("user", options.email))
ctype, body = EncodeMultipartFormData(form_fields,
[("data", filename, content)])
response_body = rpc_server.Send(url, body,
content_type=ctype)
if not response_body.startswith("OK"):
StatusUpdate(" --> %s" % response_body)
sys.exit(1)
patches = dict()
[patches.setdefault(v, k) for k, v in patch_list]
for filename in patches.keys():
base_content, new_content, is_binary, status = files[filename]
file_id_str = patches.get(filename)
if file_id_str.find("nobase") != -1:
base_content = None
file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
file_id = int(file_id_str)
if base_content != None:
UploadFile(filename, file_id, base_content, is_binary, status, True)
if new_content != None:
UploadFile(filename, file_id, new_content, is_binary, status, False)
def IsImage(self, filename):
"""Returns true if the filename has an image extension."""
mimetype = mimetypes.guess_type(filename)[0]
if not mimetype:
return False
return mimetype.startswith("image/")
def IsBinary(self, filename):
"""Returns true if the guessed mimetyped isnt't in text group."""
mimetype = mimetypes.guess_type(filename)[0]
if not mimetype:
return False # e.g. README, "real" binaries usually have an extension
# special case for text files which don't start with text/
if mimetype in TEXT_MIMETYPES:
return False
return not mimetype.startswith("text/")
class SubversionVCS(VersionControlSystem):
"""Implementation of the VersionControlSystem interface for Subversion."""
def __init__(self, options):
super(SubversionVCS, self).__init__(options)
if self.options.revision:
match = re.match(r"(\d+)(:(\d+))?", self.options.revision)
if not match:
ErrorExit("Invalid Subversion revision %s." % self.options.revision)
self.rev_start = match.group(1)
self.rev_end = match.group(3)
else:
self.rev_start = self.rev_end = None
# Cache output from "svn list -r REVNO dirname".
# Keys: dirname, Values: 2-tuple (ouput for start rev and end rev).
self.svnls_cache = {}
# Base URL is required to fetch files deleted in an older revision.
# Result is cached to not guess it over and over again in GetBaseFile().
required = self.options.download_base or self.options.revision is not None
self.svn_base = self._GuessBase(required)
def GuessBase(self, required):
"""Wrapper for _GuessBase."""
return self.svn_base
def _GuessBase(self, required):
"""Returns base URL for current diff.
Args:
required: If true, exits if the url can't be guessed, otherwise None is
returned.
"""
info = RunShell(["svn", "info"])
for line in info.splitlines():
if line.startswith("URL: "):
url = line.split()[1]
scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
username, netloc = urllib.splituser(netloc)
if username:
logging.info("Removed username from base URL")
guess = ""
if netloc == "svn.python.org" and scheme == "svn+ssh":
path = "projects" + path
scheme = "http"
guess = "Python "
elif netloc.endswith(".googlecode.com"):
scheme = "http"
guess = "Google Code "
path = path + "/"
base = urlparse.urlunparse((scheme, netloc, path, params,
query, fragment))
logging.info("Guessed %sbase = %s", guess, base)
return base
if required:
ErrorExit("Can't find URL in output from svn info")
return None
def GenerateDiff(self, args):
cmd = ["svn", "diff"]
if self.options.revision:
cmd += ["-r", self.options.revision]
cmd.extend(args)
data = RunShell(cmd)
count = 0
for line in data.splitlines():
if line.startswith("Index:") or line.startswith("Property changes on:"):
count += 1
logging.info(line)
if not count:
ErrorExit("No valid patches found in output from svn diff")
return data
def _CollapseKeywords(self, content, keyword_str):
"""Collapses SVN keywords."""
# svn cat translates keywords but svn diff doesn't. As a result of this
# behavior patching.PatchChunks() fails with a chunk mismatch error.
# This part was originally written by the Review Board development team
# who had the same problem (http://reviews.review-board.org/r/276/).
# Mapping of keywords to known aliases
svn_keywords = {
# Standard keywords
'Date': ['Date', 'LastChangedDate'],
'Revision': ['Revision', 'LastChangedRevision', 'Rev'],
'Author': ['Author', 'LastChangedBy'],
'HeadURL': ['HeadURL', 'URL'],
'Id': ['Id'],
# Aliases
'LastChangedDate': ['LastChangedDate', 'Date'],
'LastChangedRevision': ['LastChangedRevision', 'Rev', 'Revision'],
'LastChangedBy': ['LastChangedBy', 'Author'],
'URL': ['URL', 'HeadURL'],
}
def repl(m):
if m.group(2):
return "$%s::%s$" % (m.group(1), " " * len(m.group(3)))
return "$%s$" % m.group(1)
keywords = [keyword
for name in keyword_str.split(" ")
for keyword in svn_keywords.get(name, [])]
return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content)
def GetUnknownFiles(self):
status = RunShell(["svn", "status", "--ignore-externals"], silent_ok=True)
unknown_files = []
for line in status.split("\n"):
if line and line[0] == "?":
unknown_files.append(line)
return unknown_files
def ReadFile(self, filename):
"""Returns the contents of a file."""
file = open(filename, 'rb')
result = ""
try:
result = file.read()
finally:
file.close()
return result
def GetStatus(self, filename):
"""Returns the status of a file."""
if not self.options.revision:
status = RunShell(["svn", "status", "--ignore-externals", filename])
if not status:
ErrorExit("svn status returned no output for %s" % filename)
status_lines = status.splitlines()
# If file is in a cl, the output will begin with
# "\n--- Changelist 'cl_name':\n". See
# http://svn.collab.net/repos/svn/trunk/notes/changelist-design.txt
if (len(status_lines) == 3 and
not status_lines[0] and
status_lines[1].startswith("--- Changelist")):
status = status_lines[2]
else:
status = status_lines[0]
# If we have a revision to diff against we need to run "svn list"
# for the old and the new revision and compare the results to get
# the correct status for a file.
else:
dirname, relfilename = os.path.split(filename)
if dirname not in self.svnls_cache:
cmd = ["svn", "list", "-r", self.rev_start, dirname or "."]
out, returncode = RunShellWithReturnCode(cmd)
if returncode:
ErrorExit("Failed to get status for %s." % filename)
old_files = out.splitlines()
args = ["svn", "list"]
if self.rev_end:
args += ["-r", self.rev_end]
cmd = args + [dirname or "."]
out, returncode = RunShellWithReturnCode(cmd)
if returncode:
ErrorExit("Failed to run command %s" % cmd)
self.svnls_cache[dirname] = (old_files, out.splitlines())
old_files, new_files = self.svnls_cache[dirname]
if relfilename in old_files and relfilename not in new_files:
status = "D "
elif relfilename in old_files and relfilename in new_files:
status = "M "
else:
status = "A "
return status
def GetBaseFile(self, filename):
status = self.GetStatus(filename)
base_content = None
new_content = None
# If a file is copied its status will be "A +", which signifies
# "addition-with-history". See "svn st" for more information. We need to
# upload the original file or else diff parsing will fail if the file was
# edited.
if status[0] == "A" and status[3] != "+":
# We'll need to upload the new content if we're adding a binary file
# since diff's output won't contain it.
mimetype = RunShell(["svn", "propget", "svn:mime-type", filename],
silent_ok=True)
base_content = ""
is_binary = bool(mimetype) and not mimetype.startswith("text/")
if is_binary and self.IsImage(filename):
new_content = self.ReadFile(filename)
elif (status[0] in ("M", "D", "R") or
(status[0] == "A" and status[3] == "+") or # Copied file.
(status[0] == " " and status[1] == "M")): # Property change.
args = []
if self.options.revision:
url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
else:
# Don't change filename, it's needed later.
url = filename
args += ["-r", "BASE"]
cmd = ["svn"] + args + ["propget", "svn:mime-type", url]
mimetype, returncode = RunShellWithReturnCode(cmd)
if returncode:
# File does not exist in the requested revision.
# Reset mimetype, it contains an error message.
mimetype = ""
get_base = False
is_binary = bool(mimetype) and not mimetype.startswith("text/")
if status[0] == " ":
# Empty base content just to force an upload.
base_content = ""
elif is_binary:
if self.IsImage(filename):
get_base = True
if status[0] == "M":
if not self.rev_end:
new_content = self.ReadFile(filename)
else:
url = "%s/%s@%s" % (self.svn_base, filename, self.rev_end)
new_content = RunShell(["svn", "cat", url],
universal_newlines=True, silent_ok=True)
else:
base_content = ""
else:
get_base = True
if get_base:
if is_binary:
universal_newlines = False
else:
universal_newlines = True
if self.rev_start:
# "svn cat -r REV delete_file.txt" doesn't work. cat requires
# the full URL with "@REV" appended instead of using "-r" option.
url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
base_content = RunShell(["svn", "cat", url],
universal_newlines=universal_newlines,
silent_ok=True)
else:
base_content, ret_code = RunShellWithReturnCode(
["svn", "cat", filename], universal_newlines=universal_newlines)
if ret_code and status[0] == "R":
# It's a replaced file without local history (see issue208).
# The base file needs to be fetched from the server.
url = "%s/%s" % (self.svn_base, filename)
base_content = RunShell(["svn", "cat", url],
universal_newlines=universal_newlines,
silent_ok=True)
elif ret_code:
ErrorExit("Got error status from 'svn cat %s'" % filename)
if not is_binary:
args = []
if self.rev_start:
url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
else:
url = filename
args += ["-r", "BASE"]
cmd = ["svn"] + args + ["propget", "svn:keywords", url]
keywords, returncode = RunShellWithReturnCode(cmd)
if keywords and not returncode:
base_content = self._CollapseKeywords(base_content, keywords)
else:
StatusUpdate("svn status returned unexpected output: %s" % status)
sys.exit(1)
return base_content, new_content, is_binary, status[0:5]
class GitVCS(VersionControlSystem):
"""Implementation of the VersionControlSystem interface for Git."""
def __init__(self, options):
super(GitVCS, self).__init__(options)
# Map of filename -> (hash before, hash after) of base file.
# Hashes for "no such file" are represented as None.
self.hashes = {}
# Map of new filename -> old filename for renames.
self.renames = {}
def PostProcessDiff(self, gitdiff):
"""Converts the diff output to include an svn-style "Index:" line as well
as record the hashes of the files, so we can upload them along with our
diff."""
# Special used by git to indicate "no such content".
NULL_HASH = "0"*40
def IsFileNew(filename):
return filename in self.hashes and self.hashes[filename][0] is None
def AddSubversionPropertyChange(filename):
"""Add svn's property change information into the patch if given file is
new file.
We use Subversion's auto-props setting to retrieve its property.
See http://svnbook.red-bean.com/en/1.1/ch07.html#svn-ch-7-sect-1.3.2 for
Subversion's [auto-props] setting.
"""
if self.options.emulate_svn_auto_props and IsFileNew(filename):
svnprops = GetSubversionPropertyChanges(filename)
if svnprops:
svndiff.append("\n" + svnprops + "\n")
svndiff = []
filecount = 0
filename = None
for line in gitdiff.splitlines():
match = re.match(r"diff --git a/(.*) b/(.*)$", line)
if match:
# Add auto property here for previously seen file.
if filename is not None:
AddSubversionPropertyChange(filename)
filecount += 1
# Intentionally use the "after" filename so we can show renames.
filename = match.group(2)
svndiff.append("Index: %s\n" % filename)
if match.group(1) != match.group(2):
self.renames[match.group(2)] = match.group(1)
else:
# The "index" line in a git diff looks like this (long hashes elided):
# index 82c0d44..b2cee3f 100755
# We want to save the left hash, as that identifies the base file.
match = re.match(r"index (\w+)\.\.(\w+)", line)
if match:
before, after = (match.group(1), match.group(2))
if before == NULL_HASH:
before = None
if after == NULL_HASH:
after = None
self.hashes[filename] = (before, after)
svndiff.append(line + "\n")
if not filecount:
ErrorExit("No valid patches found in output from git diff")
# Add auto property for the last seen file.
assert filename is not None
AddSubversionPropertyChange(filename)
return "".join(svndiff)
def GenerateDiff(self, extra_args):
extra_args = extra_args[:]
if self.options.revision:
if ":" in self.options.revision:
extra_args = self.options.revision.split(":", 1) + extra_args
else:
extra_args = [self.options.revision] + extra_args
# --no-ext-diff is broken in some versions of Git, so try to work around
# this by overriding the environment (but there is still a problem if the
# git config key "diff.external" is used).
env = os.environ.copy()
if 'GIT_EXTERNAL_DIFF' in env: del env['GIT_EXTERNAL_DIFF']
return RunShell(["git", "diff", "--no-ext-diff", "--full-index", "-M"]
+ extra_args, env=env)
def GetUnknownFiles(self):
status = RunShell(["git", "ls-files", "--exclude-standard", "--others"],
silent_ok=True)
return status.splitlines()
def GetFileContent(self, file_hash, is_binary):
"""Returns the content of a file identified by its git hash."""
data, retcode = RunShellWithReturnCode(["git", "show", file_hash],
universal_newlines=not is_binary)
if retcode:
ErrorExit("Got error status from 'git show %s'" % file_hash)
return data
def GetBaseFile(self, filename):
hash_before, hash_after = self.hashes.get(filename, (None,None))
base_content = None
new_content = None
is_binary = self.IsBinary(filename)
status = None
if filename in self.renames:
status = "A +" # Match svn attribute name for renames.
if filename not in self.hashes:
# If a rename doesn't change the content, we never get a hash.
base_content = RunShell(["git", "show", "HEAD:" + filename])
elif not hash_before:
status = "A"
base_content = ""
elif not hash_after:
status = "D"
else:
status = "M"
is_image = self.IsImage(filename)
# Grab the before/after content if we need it.
# We should include file contents if it's text or it's an image.
if not is_binary or is_image:
# Grab the base content if we don't have it already.
if base_content is None and hash_before:
base_content = self.GetFileContent(hash_before, is_binary)
# Only include the "after" file if it's an image; otherwise it
# it is reconstructed from the diff.
if is_image and hash_after:
new_content = self.GetFileContent(hash_after, is_binary)
return (base_content, new_content, is_binary, status)
class MercurialVCS(VersionControlSystem):
"""Implementation of the VersionControlSystem interface for Mercurial."""
def __init__(self, options, repo_dir):
super(MercurialVCS, self).__init__(options)
# Absolute path to repository (we can be in a subdir)
self.repo_dir = os.path.normpath(repo_dir)
# Compute the subdir
cwd = os.path.normpath(os.getcwd())
assert cwd.startswith(self.repo_dir)
self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/")
if self.options.revision:
self.base_rev = self.options.revision
else:
self.base_rev = RunShell(["hg", "parent", "-q"]).split(':')[1].strip()
def _GetRelPath(self, filename):
"""Get relative path of a file according to the current directory,
given its logical path in the repo."""
assert filename.startswith(self.subdir), (filename, self.subdir)
return filename[len(self.subdir):].lstrip(r"\/")
def GenerateDiff(self, extra_args):
cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
data = RunShell(cmd, silent_ok=True)
svndiff = []
filecount = 0
for line in data.splitlines():
m = re.match("diff --git a/(\S+) b/(\S+)", line)
if m:
# Modify line to make it look like as it comes from svn diff.
# With this modification no changes on the server side are required
# to make upload.py work with Mercurial repos.
# NOTE: for proper handling of moved/copied files, we have to use
# the second filename.
filename = m.group(2)
svndiff.append("Index: %s" % filename)
svndiff.append("=" * 67)
filecount += 1
logging.info(line)
else:
svndiff.append(line)
if not filecount:
ErrorExit("No valid patches found in output from hg diff")
return "\n".join(svndiff) + "\n"
def GetUnknownFiles(self):
"""Return a list of files unknown to the VCS."""
args = []
status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
silent_ok=True)
unknown_files = []
for line in status.splitlines():
st, fn = line.split(" ", 1)
if st == "?":
unknown_files.append(fn)
return unknown_files
def GetBaseFile(self, filename):
# "hg status" and "hg cat" both take a path relative to the current subdir
# rather than to the repo root, but "hg diff" has given us the full path
# to the repo root.
base_content = ""
new_content = None
is_binary = False
oldrelpath = relpath = self._GetRelPath(filename)
# "hg status -C" returns two lines for moved/copied files, one otherwise
out = RunShell(["hg", "status", "-C", "--rev", self.base_rev, relpath])
out = out.splitlines()
# HACK: strip error message about missing file/directory if it isn't in
# the working copy
if out[0].startswith('%s: ' % relpath):
out = out[1:]
status, _ = out[0].split(' ', 1)
if len(out) > 1 and status == "A":
# Moved/copied => considered as modified, use old filename to
# retrieve base contents
oldrelpath = out[1].strip()
status = "M"
if ":" in self.base_rev:
base_rev = self.base_rev.split(":", 1)[0]
else:
base_rev = self.base_rev
if status != "A":
base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
silent_ok=True)
is_binary = "\0" in base_content # Mercurial's heuristic
if status != "R":
new_content = open(relpath, "rb").read()
is_binary = is_binary or "\0" in new_content
if is_binary and base_content:
# Fetch again without converting newlines
base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
silent_ok=True, universal_newlines=False)
if not is_binary or not self.IsImage(relpath):
new_content = None
return base_content, new_content, is_binary, status
# NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync.
def SplitPatch(data):
"""Splits a patch into separate pieces for each file.
Args:
data: A string containing the output of svn diff.
Returns:
A list of 2-tuple (filename, text) where text is the svn diff output
pertaining to filename.
"""
patches = []
filename = None
diff = []
for line in data.splitlines(True):
new_filename = None
if line.startswith('Index:'):
unused, new_filename = line.split(':', 1)
new_filename = new_filename.strip()
elif line.startswith('Property changes on:'):
unused, temp_filename = line.split(':', 1)
# When a file is modified, paths use '/' between directories, however
# when a property is modified '\' is used on Windows. Make them the same
# otherwise the file shows up twice.
temp_filename = temp_filename.strip().replace('\\', '/')
if temp_filename != filename:
# File has property changes but no modifications, create a new diff.
new_filename = temp_filename
if new_filename:
if filename and diff:
patches.append((filename, ''.join(diff)))
filename = new_filename
diff = [line]
continue
if diff is not None:
diff.append(line)
if filename and diff:
patches.append((filename, ''.join(diff)))
return patches
def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
"""Uploads a separate patch for each file in the diff output.
Returns a list of [patch_key, filename] for each file.
"""
patches = SplitPatch(data)
rv = []
for patch in patches:
if len(patch[1]) > MAX_UPLOAD_SIZE:
print ("Not uploading the patch for " + patch[0] +
" because the file is too large.")
continue
form_fields = [("filename", patch[0])]
if not options.download_base:
form_fields.append(("content_upload", "1"))
files = [("data", "data.diff", patch[1])]
ctype, body = EncodeMultipartFormData(form_fields, files)
url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
print "Uploading patch for " + patch[0]
response_body = rpc_server.Send(url, body, content_type=ctype)
lines = response_body.splitlines()
if not lines or lines[0] != "OK":
StatusUpdate(" --> %s" % response_body)
sys.exit(1)
rv.append([lines[1], patch[0]])
return rv
def GuessVCSName():
"""Helper to guess the version control system.
This examines the current directory, guesses which VersionControlSystem
we're using, and returns an string indicating which VCS is detected.
Returns:
A pair (vcs, output). vcs is a string indicating which VCS was detected
and is one of VCS_GIT, VCS_MERCURIAL, VCS_SUBVERSION, or VCS_UNKNOWN.
output is a string containing any interesting output from the vcs
detection routine, or None if there is nothing interesting.
"""
# Mercurial has a command to get the base directory of a repository
# Try running it, but don't die if we don't have hg installed.
# NOTE: we try Mercurial first as it can sit on top of an SVN working copy.
try:
out, returncode = RunShellWithReturnCode(["hg", "root"])
if returncode == 0:
return (VCS_MERCURIAL, out.strip())
except OSError, (errno, message):
if errno != 2: # ENOENT -- they don't have hg installed.
raise
# Subversion has a .svn in all working directories.
if os.path.isdir('.svn'):
logging.info("Guessed VCS = Subversion")
return (VCS_SUBVERSION, None)
# Git has a command to test if you're in a git tree.
# Try running it, but don't die if we don't have git installed.
try:
out, returncode = RunShellWithReturnCode(["git", "rev-parse",
"--is-inside-work-tree"])
if returncode == 0:
return (VCS_GIT, None)
except OSError, (errno, message):
if errno != 2: # ENOENT -- they don't have git installed.
raise
return (VCS_UNKNOWN, None)
def GuessVCS(options):
"""Helper to guess the version control system.
This verifies any user-specified VersionControlSystem (by command line
or environment variable). If the user didn't specify one, this examines
the current directory, guesses which VersionControlSystem we're using,
and returns an instance of the appropriate class. Exit with an error
if we can't figure it out.
Returns:
A VersionControlSystem instance. Exits if the VCS can't be guessed.
"""
vcs = options.vcs
if not vcs:
vcs = os.environ.get("CODEREVIEW_VCS")
if vcs:
v = VCS_ABBREVIATIONS.get(vcs.lower())
if v is None:
ErrorExit("Unknown version control system %r specified." % vcs)
(vcs, extra_output) = (v, None)
else:
(vcs, extra_output) = GuessVCSName()
if vcs == VCS_MERCURIAL:
if extra_output is None:
extra_output = RunShell(["hg", "root"]).strip()
return MercurialVCS(options, extra_output)
elif vcs == VCS_SUBVERSION:
return SubversionVCS(options)
elif vcs == VCS_GIT:
return GitVCS(options)
ErrorExit(("Could not guess version control system. "
"Are you in a working copy directory?"))
def CheckReviewer(reviewer):
"""Validate a reviewer -- either a nickname or an email addres.
Args:
reviewer: A nickname or an email address.
Calls ErrorExit() if it is an invalid email address.
"""
if "@" not in reviewer:
return # Assume nickname
parts = reviewer.split("@")
if len(parts) > 2:
ErrorExit("Invalid email address: %r" % reviewer)
assert len(parts) == 2
if "." not in parts[1]:
ErrorExit("Invalid email address: %r" % reviewer)
def LoadSubversionAutoProperties():
"""Returns the content of [auto-props] section of Subversion's config file as
a dictionary.
Returns:
A dictionary whose key-value pair corresponds the [auto-props] section's
key-value pair.
In following cases, returns empty dictionary:
- config file doesn't exist, or
- 'enable-auto-props' is not set to 'true-like-value' in [miscellany].
"""
if os.name == 'nt':
subversion_config = os.environ.get("APPDATA") + "\\Subversion\\config"
else:
subversion_config = os.path.expanduser("~/.subversion/config")
if not os.path.exists(subversion_config):
return {}
config = ConfigParser.ConfigParser()
config.read(subversion_config)
if (config.has_section("miscellany") and
config.has_option("miscellany", "enable-auto-props") and
config.getboolean("miscellany", "enable-auto-props") and
config.has_section("auto-props")):
props = {}
for file_pattern in config.options("auto-props"):
props[file_pattern] = ParseSubversionPropertyValues(
config.get("auto-props", file_pattern))
return props
else:
return {}
def ParseSubversionPropertyValues(props):
"""Parse the given property value which comes from [auto-props] section and
returns a list whose element is a (svn_prop_key, svn_prop_value) pair.
See the following doctest for example.
>>> ParseSubversionPropertyValues('svn:eol-style=LF')
[('svn:eol-style', 'LF')]
>>> ParseSubversionPropertyValues('svn:mime-type=image/jpeg')
[('svn:mime-type', 'image/jpeg')]
>>> ParseSubversionPropertyValues('svn:eol-style=LF;svn:executable')
[('svn:eol-style', 'LF'), ('svn:executable', '*')]
"""
key_value_pairs = []
for prop in props.split(";"):
key_value = prop.split("=")
assert len(key_value) <= 2
if len(key_value) == 1:
# If value is not given, use '*' as a Subversion's convention.
key_value_pairs.append((key_value[0], "*"))
else:
key_value_pairs.append((key_value[0], key_value[1]))
return key_value_pairs
def GetSubversionPropertyChanges(filename):
"""Return a Subversion's 'Property changes on ...' string, which is used in
the patch file.
Args:
filename: filename whose property might be set by [auto-props] config.
Returns:
A string like 'Property changes on |filename| ...' if given |filename|
matches any entries in [auto-props] section. None, otherwise.
"""
global svn_auto_props_map
if svn_auto_props_map is None:
svn_auto_props_map = LoadSubversionAutoProperties()
all_props = []
for file_pattern, props in svn_auto_props_map.items():
if fnmatch.fnmatch(filename, file_pattern):
all_props.extend(props)
if all_props:
return FormatSubversionPropertyChanges(filename, all_props)
return None
def FormatSubversionPropertyChanges(filename, props):
"""Returns Subversion's 'Property changes on ...' strings using given filename
and properties.
Args:
filename: filename
props: A list whose element is a (svn_prop_key, svn_prop_value) pair.
Returns:
A string which can be used in the patch file for Subversion.
See the following doctest for example.
>>> print FormatSubversionPropertyChanges('foo.cc', [('svn:eol-style', 'LF')])
Property changes on: foo.cc
___________________________________________________________________
Added: svn:eol-style
+ LF
<BLANKLINE>
"""
prop_changes_lines = [
"Property changes on: %s" % filename,
"___________________________________________________________________"]
for key, value in props:
prop_changes_lines.append("Added: " + key)
prop_changes_lines.append(" + " + value)
return "\n".join(prop_changes_lines) + "\n"
def RealMain(argv, data=None):
"""The real main function.
Args:
argv: Command line arguments.
data: Diff contents. If None (default) the diff is generated by
the VersionControlSystem implementation returned by GuessVCS().
Returns:
A 2-tuple (issue id, patchset id).
The patchset id is None if the base files are not uploaded by this
script (applies only to SVN checkouts).
"""
logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:"
"%(lineno)s %(message)s "))
os.environ['LC_ALL'] = 'C'
options, args = parser.parse_args(argv[1:])
global verbosity
verbosity = options.verbose
if verbosity >= 3:
logging.getLogger().setLevel(logging.DEBUG)
elif verbosity >= 2:
logging.getLogger().setLevel(logging.INFO)
vcs = GuessVCS(options)
base = options.base_url
if isinstance(vcs, SubversionVCS):
# Guessing the base field is only supported for Subversion.
# Note: Fetching base files may become deprecated in future releases.
guessed_base = vcs.GuessBase(options.download_base)
if base:
if guessed_base and base != guessed_base:
print "Using base URL \"%s\" from --base_url instead of \"%s\"" % \
(base, guessed_base)
else:
base = guessed_base
if not base and options.download_base:
options.download_base = True
logging.info("Enabled upload of base file")
if not options.assume_yes:
vcs.CheckForUnknownFiles()
if data is None:
data = vcs.GenerateDiff(args)
data = vcs.PostProcessDiff(data)
files = vcs.GetBaseFiles(data)
if verbosity >= 1:
print "Upload server:", options.server, "(change with -s/--server)"
if options.issue:
prompt = "Message describing this patch set: "
else:
prompt = "New issue subject: "
message = options.message or raw_input(prompt).strip()
if not message:
ErrorExit("A non-empty message is required")
rpc_server = GetRpcServer(options.server,
options.email,
options.host,
options.save_cookies,
options.account_type)
form_fields = [("subject", message)]
if base:
form_fields.append(("base", base))
if options.issue:
form_fields.append(("issue", str(options.issue)))
if options.email:
form_fields.append(("user", options.email))
if options.reviewers:
for reviewer in options.reviewers.split(','):
CheckReviewer(reviewer)
form_fields.append(("reviewers", options.reviewers))
if options.cc:
for cc in options.cc.split(','):
CheckReviewer(cc)
form_fields.append(("cc", options.cc))
description = options.description
if options.description_file:
if options.description:
ErrorExit("Can't specify description and description_file")
file = open(options.description_file, 'r')
description = file.read()
file.close()
if description:
form_fields.append(("description", description))
# Send a hash of all the base file so the server can determine if a copy
# already exists in an earlier patchset.
base_hashes = ""
for file, info in files.iteritems():
if not info[0] is None:
checksum = md5(info[0]).hexdigest()
if base_hashes:
base_hashes += "|"
base_hashes += checksum + ":" + file
form_fields.append(("base_hashes", base_hashes))
if options.private:
if options.issue:
print "Warning: Private flag ignored when updating an existing issue."
else:
form_fields.append(("private", "1"))
# If we're uploading base files, don't send the email before the uploads, so
# that it contains the file status.
if options.send_mail and options.download_base:
form_fields.append(("send_mail", "1"))
if not options.download_base:
form_fields.append(("content_upload", "1"))
if len(data) > MAX_UPLOAD_SIZE:
print "Patch is large, so uploading file patches separately."
uploaded_diff_file = []
form_fields.append(("separate_patches", "1"))
else:
uploaded_diff_file = [("data", "data.diff", data)]
ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file)
response_body = rpc_server.Send("/upload", body, content_type=ctype)
patchset = None
if not options.download_base or not uploaded_diff_file:
lines = response_body.splitlines()
if len(lines) >= 2:
msg = lines[0]
patchset = lines[1].strip()
patches = [x.split(" ", 1) for x in lines[2:]]
else:
msg = response_body
else:
msg = response_body
StatusUpdate(msg)
if not response_body.startswith("Issue created.") and \
not response_body.startswith("Issue updated."):
sys.exit(0)
issue = msg[msg.rfind("/")+1:]
if not uploaded_diff_file:
result = UploadSeparatePatches(issue, rpc_server, patchset, data, options)
if not options.download_base:
patches = result
if not options.download_base:
vcs.UploadBaseFiles(issue, rpc_server, patches, patchset, options, files)
if options.send_mail:
rpc_server.Send("/" + issue + "/mail", payload="")
return issue, patchset
def main():
try:
RealMain(sys.argv)
except KeyboardInterrupt:
print
StatusUpdate("Interrupted.")
sys.exit(1)
if __name__ == "__main__":
main()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment