diff --git a/git-cl b/git-cl index 5730c3ab8..4ff057f9f 100755 --- a/git-cl +++ b/git-cl @@ -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 "$@" diff --git a/git_cl/LICENSE b/git_cl/LICENSE new file mode 100644 index 000000000..e1c8ab4f0 --- /dev/null +++ b/git_cl/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2008 Evan Martin +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. diff --git a/git_cl/PRESUBMIT.py b/git_cl/PRESUBMIT.py new file mode 100644 index 000000000..6c7506929 --- /dev/null +++ b/git_cl/PRESUBMIT.py @@ -0,0 +1,124 @@ +# 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 diff --git a/git_cl/README b/git_cl/README new file mode 100644 index 000000000..092495271 --- /dev/null +++ b/git_cl/README @@ -0,0 +1,52 @@ +# git-cl -- a git-command for integrating reviews on Rietveld +# Copyright (C) 2008 Evan Martin + +== 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 +Try "git cl patch --help" for more options. + +vim: tw=72 : diff --git a/git_cl/README.codereview b/git_cl/README.codereview new file mode 100644 index 000000000..cf1d3ad3d --- /dev/null +++ b/git_cl/README.codereview @@ -0,0 +1,99 @@ +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 + +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 + +== 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. diff --git a/git_cl/README.testing b/git_cl/README.testing new file mode 100644 index 000000000..a7989ac3c --- /dev/null +++ b/git_cl/README.testing @@ -0,0 +1,23 @@ +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. diff --git a/git_cl/__init__.py b/git_cl/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/git_cl/git-cl b/git_cl/git-cl new file mode 100755 index 000000000..449c6e029 --- /dev/null +++ b/git_cl/git-cl @@ -0,0 +1,10 @@ +#!/usr/bin/python +# git-cl -- a git-command for integrating reviews on Rietveld +# Copyright (C) 2008 Evan Martin + +import sys + +import git_cl + +if __name__ == '__main__': + sys.exit(git_cl.main(sys.argv[1:])) diff --git a/git_cl/git_cl.py b/git_cl/git_cl.py new file mode 100644 index 000000000..ea1238261 --- /dev/null +++ b/git_cl/git_cl.py @@ -0,0 +1,1254 @@ +#!/usr/bin/python +# git-cl -- a git-command for integrating reviews on Rietveld +# Copyright (C) 2008 Evan Martin + +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 '") + 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 '" + 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('') +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 = '' + 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:])) diff --git a/git_cl/test/abandon.sh b/git_cl/test/abandon.sh new file mode 100755 index 000000000..b964059b0 --- /dev/null +++ b/git_cl/test/abandon.sh @@ -0,0 +1,41 @@ +#!/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 diff --git a/git_cl/test/basic.sh b/git_cl/test/basic.sh new file mode 100755 index 000000000..cbf3c0221 --- /dev/null +++ b/git_cl/test/basic.sh @@ -0,0 +1,64 @@ +#!/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 diff --git a/git_cl/test/hooks.sh b/git_cl/test/hooks.sh new file mode 100755 index 000000000..b21ce6d72 --- /dev/null +++ b/git_cl/test/hooks.sh @@ -0,0 +1,47 @@ +#!/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 diff --git a/git_cl/test/push-basic.sh b/git_cl/test/push-basic.sh new file mode 100755 index 000000000..e9876e173 --- /dev/null +++ b/git_cl/test/push-basic.sh @@ -0,0 +1,68 @@ +#!/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 diff --git a/git_cl/test/push-from-logs.sh b/git_cl/test/push-from-logs.sh new file mode 100755 index 000000000..264082c3f --- /dev/null +++ b/git_cl/test/push-from-logs.sh @@ -0,0 +1,67 @@ +#!/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 diff --git a/git_cl/test/rename.sh b/git_cl/test/rename.sh new file mode 100755 index 000000000..7ce265a1e --- /dev/null +++ b/git_cl/test/rename.sh @@ -0,0 +1,36 @@ +#!/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 diff --git a/git_cl/test/save-description-on-failure.sh b/git_cl/test/save-description-on-failure.sh new file mode 100755 index 000000000..d4a09817f --- /dev/null +++ b/git_cl/test/save-description-on-failure.sh @@ -0,0 +1,52 @@ +#!/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 diff --git a/git_cl/test/submit-from-new-dir.sh b/git_cl/test/submit-from-new-dir.sh new file mode 100755 index 000000000..4480cbe02 --- /dev/null +++ b/git_cl/test/submit-from-new-dir.sh @@ -0,0 +1,38 @@ +#!/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 diff --git a/git_cl/test/tbr.sh b/git_cl/test/tbr.sh new file mode 100755 index 000000000..d6caf69fb --- /dev/null +++ b/git_cl/test/tbr.sh @@ -0,0 +1,39 @@ +#!/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 diff --git a/git_cl/test/test-lib.sh b/git_cl/test/test-lib.sh new file mode 100644 index 000000000..b9194737a --- /dev/null +++ b/git_cl/test/test-lib.sh @@ -0,0 +1,97 @@ +#!/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 +} diff --git a/git_cl/test/upload-local-tracking-branch.sh b/git_cl/test/upload-local-tracking-branch.sh new file mode 100755 index 000000000..3fc759af1 --- /dev/null +++ b/git_cl/test/upload-local-tracking-branch.sh @@ -0,0 +1,31 @@ +#!/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 diff --git a/git_cl/test/upload-stale.sh b/git_cl/test/upload-stale.sh new file mode 100755 index 000000000..e65b06689 --- /dev/null +++ b/git_cl/test/upload-stale.sh @@ -0,0 +1,33 @@ +#!/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 diff --git a/git_cl/upload.py b/git_cl/upload.py new file mode 100644 index 000000000..f7a2bf5d9 --- /dev/null +++ b/git_cl/upload.py @@ -0,0 +1,1786 @@ +#!/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 + + """ + 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()