diff --git a/llvm/utils/git-svn/git-llvm b/llvm/utils/git-svn/git-llvm --- a/llvm/utils/git-svn/git-llvm +++ b/llvm/utils/git-svn/git-llvm @@ -600,6 +600,18 @@ "run 'git llvm push' when ready") +def cmd_diff(args): + '''Uploads local changes to Phabricator for review. + + By default, this will find and upload all changes in the current branch + since the upstream base. + ''' + # Defer the import to here, so that nothing in phab_cmd (et. al.) can break + # users of the other commands. + import phab_cmd + phab_cmd.run(args.commit, args.preview, args.update, VERBOSE) + + if __name__ == '__main__': if not program_exists('svn'): die('error: git-llvm needs svn command, but svn is not installed.') @@ -668,6 +680,29 @@ help='git_commit_hash for which we will look up the svn revision id.') parser_svn_lookup.set_defaults(func=cmd_svn_lookup) + parser_diff = subcommands.add_parser( + 'diff', description=cmd_diff.__doc__, + help='Upload a diff to Phabricator (https://reviews.llvm.org).') + parser_diff.add_argument( + 'commit', + nargs='?', + help='If specified, the merge-base of this commit will be used to diff ' + 'the local client.') + parser_diff.add_argument( + '--preview', + '--only', + action='store_true', + help='Instead of creating a differential revision, only create a diff. ' + '(Unlike Arcanist, no linting is done for --preview.)') + parser_diff.add_argument( + '--update', + type=int, + help='Update this revision.') + # TODO: + # --ccs + # --reviewers + parser_diff.set_defaults(func=cmd_diff) + args = p.parse_args(argv) VERBOSE = args.verbose QUIET = args.quiet diff --git a/llvm/utils/git-svn/phab_cmd.py b/llvm/utils/git-svn/phab_cmd.py new file mode 100644 --- /dev/null +++ b/llvm/utils/git-svn/phab_cmd.py @@ -0,0 +1,180 @@ +# ======- phab_cmd.py - Local client utilities ---------*- python -*--========# +# +# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. +# See https://llvm.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ==------------------------------------------------------------------------==# + +"""Overall subcommand for uploading a diff to Phabricator. + +This module implements the general top-level logic when running `git llvm diff`. + +""" + +from __future__ import print_function + +# Python 2/3 notes: +# +# Anything "textual" should generally be UTF-8. As a general rule, things that +# might be bytes in Python 3 should always be `.decode`d. There are some places +# where things should not be decoded immediately; these cases should generally +# be documented with a comment. +# +# Since we don't want to depend on external modules, the library differences are +# handled directly, instead of using six (or similar). + +import os +import subprocess +import sys + +import phab_conduit +import phab_local + + +git = phab_local.git + + +################################################################################ +# Background +################################################################################ + +# This command should be usable in lieu of `arc diff`, for example on systems +# where php is not available. The goal is not to be a fully-featured +# replacement, since a lot of things can be simplified if we know the working +# tree looks more or less like the LLVM monorepo. +# +# `arc` behavior +# ------------------------------------------------------------------------------ +# +# For a change to LLVM, the general flow reported by `arc --verbose diff` is: +# +# 1. Check for a valid HEAD^. +# +# 2. Find '@{upstream}' and its merge-base. +# +# If the current branch does not track upstream, arc prompts for the base +# to use. Something similar is done in `get_upstream_base`, below. +# +# 3. Find all the commits between the upstream merge-base and HEAD. +# The equivalent is returned by `get_local_commits`. +# +# (From this point, let's assume there is a single commit to upload.) +# +# 4a. Search existing revisions for the commit. +# +# Phabricator stores special hashes: +# gttr: the git tree hash +# gtcm: the git commit hash +# (The local hashes can be used to query the API.) +# +# 4b. If no revisions matched the hashes, parse the commit message for the tags +# that Phabricator usually addes. +# (This is done by API call, too.) +# +# 4c. If no matching revision was found, a new one will be needed later. +# +# 5. Create the diff. +# A "diff" is a separate object from a "differential revision." The arc cli +# can create a standalone "diff" on Phabricator (`arc diff --preview`), or +# the web UI upload can also create a standalone diff. +# +# 6. A bunch of lint checks and Harbormaster stuff usually runs next. +# +# 7. The local commits are attached to the diff. +# +# This is the local HEAD, remote base, and tree. These show up in the UI +# in the "Commits" tab of the revision. +# +# 8. If a new revision is going to be created, then arc opens an editor with +# a template based on the commit message. +# +# The template is generated by API call, based on the commit title and +# +# 9. The revision is updated to use the newly-created diff. +# +# +# This command's behavior +# ------------------------------------------------------------------------------ +# +# In general, this command does less than `arc diff`, even for the constrained +# LLVM monorepo layout. +# +# A few general principles do seem useful, though: +# +# - Include as much usable data as we can. For example, we should make sure to +# include the baseline hashes in the diff, so that it can be more easily +# patched by others. +# +# - Cover everything that is needed. This includes updating commit messages so +# they will be understood by Phabricator after commit. +# +# - Try not to diverge too far from `arc diff` behavior. We're not going to +# implement everything; but, for the common cases, we don't want to set up any +# barriers (or any sort of surprises) for folks familiar with Arcanist. + + +################################################################################ +# Subcommand +################################################################################ + +def run(base_ref, diff_only, revision, VERBOSE): + """Uploads a diff.""" + arcrc = phab_local.get_arcrc() + arcconfig = phab_local.get_arcconfig() + + head_commit = git('rev-parse', 'HEAD') + local_branch = phab_local.get_symbolic_ref(head_commit) + + # Determine what we should diff against. + if not base_ref: + base_ref = phab_local.get_upstream_base() + merge_base = git('merge-base', head_commit, base_ref) + + if VERBOSE: + print('Local revisions:') + print('HEAD', head_commit) + print('branch', local_branch) + print('merge_base', merge_base) + + # It should be possible to upload sequential diffs for commits, but for now, + # we'll just treat a range of commits as a single diff. + diff_commits = phab_local.get_local_commits(merge_base) + if not diff_commits: + print('Error: no commits were found for the remote merge-base.', + file=sys.stderr) + sys.exit(1) + + diff_text = git('diff', + '--no-ext-diff', + '--color=never', + '--src-prefix=a/', + '--dst-prefix=b/', + '-U32767', # Include the whole file. + '--find-renames', + '--find-copies', + merge_base, + head_commit) + + with phab_conduit.connect_repository(arcrc, arcconfig) as api: + diff_info = api.create_raw_diff(diff_text) + api.update_diff_local_commits(diff_info['id'], diff_commits) + + if diff_only: + print('Created diff:', diff_info['uri']) + return + + # If we didn't get a specific revision, see if there is one in the + # commit message. + if not revision: + commit_fields = api.parse_commit_message(diff_commits[-1].message) + if commit_fields: + revision = commit_fields['revisionID'] # actually a URI + + # If we already know the revision to update, then do so. + if revision: + edit_result = api.edit_revision( + revid=revision, diff_phid=diff_info['phid']) + info = api.lookup_revision(edit_result['id']) + print('Updated revision:', info['uri']) + return diff --git a/llvm/utils/git-svn/phab_conduit.py b/llvm/utils/git-svn/phab_conduit.py new file mode 100644 --- /dev/null +++ b/llvm/utils/git-svn/phab_conduit.py @@ -0,0 +1,503 @@ +# ======- phab_conduit.py - Phabricator API ------------*- python -*--========# +# +# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. +# See https://llvm.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ==------------------------------------------------------------------------==# + +"""Helper library for interacting with Phabricator. + +The Conduit API is Phabricator's HTTPS-accessible API. There is an API browser +interface for the methods: + + https://reviews.llvm.org/conduit/ + +The APIs are generally close to what is available in the Web UI, but there are +some oddities across versions of Phabricator, and some Conduit calls are marked +as "Frozen" (i.e., "deprecated") but are still necessary. + +Most of the logic below is gleaned from the API browser (above) and the output +of `arc --verbose`. (Some is by trial-and-error, too.) Comments are generally +left for reproduction, although the major caveat is that this is only based on +certain versions of Phabricator and Arcanist. (That means this may need changes +in the future, as LLVM's Phabricator instance is upgraded.) + +This module is implemented using only Python standard library modules. This +ensures that this library can be used with (hopefully) zero setup. +""" + +from __future__ import print_function + +import base64 +import contextlib +import http.client +import io +import json +import os +import re +import ssl +import subprocess +import sys +import urllib + +# Python 2/3 notes: +# +# Anything "textual" should generally be UTF-8. As a general rule, things that +# might be bytes in Python 3 should always be `.decode`d. There are some places +# where things should not be decoded immediately; these cases should generally +# be documented with a comment. +# +# Since we don't want to depend on external modules, the library differences are +# handled directly, instead of using six (or similar). + +try: + import collections.abc as collections_abc # py3 +except ImportError: + import collections as collections_abc # py2 + +try: + # Python 3: + _urlparse = urllib.parse.urlparse + _urlencode = urllib.parse.urlencode + _urljoin = urllib.parse.urljoin +except AttributeError: + # Pyhon 2: + _urlencode = urllib.urlencode + import urlparse + _urljoin = urlparse.urljoin + _urlparse = urlparse.urlparse + +try: + # Python 2: + _ = unicode + _is_string = lambda x: isinstance(x, (str, unicode)) +except NameError: + # Python 3: + _is_string = lambda x: isinstance(x, str) + + +################################################################################ +# Exception types +################################################################################ + + +class Error(Exception): + """An exception raised by phabricator integration.""" + + +class ConnectionError(Error): + """An error trying to communicate with Phabricator.""" + + +class ConduitError(Error): + """An error returned from the conduit API.""" + + +################################################################################ +# Phabricator HTTPS connection +################################################################################ + + +@contextlib.contextmanager +def connect_repository(arcrc, arcconfig, ssl_ctx=None): + """Connects to Phabricator based on .arcrc and .arcconfig. + + Args: + arcrc: the user's parsed .arcrc file. + arcconfig: the repo's parsed .arcconfig file. + ssl_ctx: (optional) a pre-constructed SSLContext. + + Yields: + An API object, which is connected to Phabricator. + """ + + # Read this repository's arcconfig to find the Conduit address: + if 'phabricator.uri' in arcconfig: + conduit = arcconfig['phabricator.uri'] + elif 'conduit_uri' in arcconfig: + conduit = arcconfig['conduit_uri'] + else: + raise Error('.arcconfig did not have a conduit URL') + conduit = conduit + 'api/' + + # Get the callsign, too: + if 'repository.callsign' not in arcconfig: + raise Error('.arcconfig did not have a repo callsign') + callsign = arcconfig['repository.callsign'] + + # Now look for the conduit URI in the user's .arcrc to find the API token: + for host, options in arcrc.get('hosts', {}).items(): + if host == conduit and 'token' in options: + api_token = options['token'] + break + else: + raise Error('API token not found in .arcrc', conduit) + + if ssl_ctx is None: + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_SSLv23) + ssl_ctx.set_default_verify_paths() + url = _urlparse(conduit) + conn = http.client.HTTPSConnection(url.netloc, url.port, timeout=300, + context=ssl_ctx) + conn.connect() + try: + yield API(url, conn, api_token, callsign) + finally: + conn.close() + + +################################################################################ +# Phabricator API utilities +################################################################################ + + +def _conduit_quote(p, prefix=None): + """Simple recursive encoding in the format expected by Conduit. + + Conduit requests use PHP-style parameter encoding instead of standard URL + encoding. For example, the parameters: + {'param': {'dict_key': ['v1', 'v2']}} + would be: + param[dict_key][0]=v1 + param[dict_key][1]=v2 + (Which then need to be URL-encoded.) + + Args: + p: the parameter to encode (probably should be a dict) + prefix: the prefix to this object. + + Yields: + (key, value) pairs of the query params to send. + """ + if isinstance(p, collections_abc.Mapping): + for key in p: + if prefix is None: + new_prefix = key + else: + new_prefix = '%s[%s]' % (prefix, key) + for k, v in _conduit_quote(p[key], new_prefix): + yield k, v + elif _is_string(p): + yield prefix, p + elif isinstance(p, collections_abc.Iterable): + for i, value in enumerate(p): + if prefix is None: + new_prefix = str(i) + else: + new_prefix = '%s[%d]' % (prefix, i) + for k, v in _conduit_quote(value, new_prefix): + yield k, v + else: + yield prefix, p + + +################################################################################ +# Phabricator API +################################################################################ + + +class API(object): + """Wrapper for Phabricator's Conduit API. + + The Conduit API is the HTTP(S)-based interface to Phabricator. Its methods + can be viewed on the Phabricator installation. For LLVM, see: + + https://reviews.llvm.org/conduit/ + + The methods in this class are generally Python wrappers around formatting + the request, and checking for errors in the reply. + + This class is not meant to be a comprehensive Conduit API wrapper. There are + other Python libraries for that; rather, this class is only meant to support + what is needed to send diffs to LLVM's Phabricator instance, using only the + Python standard library. + """ + + def __init__(self, url, conn, api_token, callsign): + """Initializer. + + Args: + url: the base URL. + conn: the connected HTTPSConnection. + api_token: the user's Conduit API token. + callsign: the repository callsign that will be used. + """ + self._url = url + self._conn = conn + self._api_token = api_token + self._repo = self.lookup_repo(callsign) + + def _query(self, method, params): + """Sends a query to Conduit. + + Args: + method: the Conduit method name, like 'conduit.query'. + params: a dict of the parameters to send. + + Returns: + The response, parsed from JSON. + + Raises: + ConnectionError: if the HTTP(S) response was not OK. + ConduitError: if the response had an error_code. + """ + # Prepare and send the request: + method_url = _urljoin(self._url.geturl(), method) + params['api.token'] = self._api_token + body = _urlencode(list(_conduit_quote(params))).encode() + self._conn.request('POST', method_url, body=body) + + # Check the reply for errors. + http_resp = self._conn.getresponse() + if http_resp.status != http.client.OK: + raise ConnectionError('HTTP error', http_resp.status) + + resp = json.load(http_resp) + if resp['error_code']: + raise ConduitError('Phabricator API error', resp['error_code'], + resp.get('error_info', '')) + + # The result was OK: return it. + return resp['result'] + + def lookup_repo(self, callsign): + """Returns information for the repository with the given callsign. + + Args: + callsign: the repository callsign. + + Returns: + A dict, like: + { + 'id': 123, + 'type': 'REPO', + 'phid': 'PHID-REPO-abc', + 'fields': { + 'name': 'Repo Long Display Name', + 'vcs': 'git', + 'callsign': 'X', + 'shortName': 'short-name', + }, + } + """ + response = self._query( + 'diffusion.repository.search', + params={ + 'queryKey': 'active', + 'constraints': {'callsigns': [callsign]}, + }, + ) + results = response['data'] + if len(results) != 1: + raise Error('Expected 1 repo for callsign', callsign, len(results)) + return results[0] + + def lookup_revision_by_hash(self, commit, tree): + """Search for a revision by commit or tree hash.""" + hashes = [] + if commit: hashes.append(['gtcm', commit]) + if tree: hashes.append(['gttr', tree]) + response = self._query('differential.query', {'commitHashes': hashes}) + if not response: + return None + return response[-1] + + def lookup_revision(self, revision): + """Search for a revision by ID.""" + response = self._query('differential.query', {'ids': [revision]}) + if not response: + return None + return response[-1] + + def create_raw_diff(self, diff): + """Creates a raw diff from unified diff output. + + `base` is the commit for this diff, like: + git rev-parse HEAD + + `branch` is the local branch name, as reported by: + git rev-parse --abbrev-ref --symbolic-full-name 'HEAD' + + `onto` is the remote branch name, as reported by: + git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' + + Args: + diff: unified diff output. + + Returns: + A dict, like: + { + 'phid': 'PHID-DIFF-abc', + 'id': 123, + 'uri': 'https://reviews.llvm.org/differential/diff/123/', + } + """ + params = { + 'repositoryPHID': self._repo['phid'], + 'diff': diff, + } + diff_info = self._query('differential.createrawdiff', params) + return diff_info + + def update_diff_local_commits(self, diff_id, local_commits): + """Updates local commit information on the diff. + + The dicts for `local_commits` should have some or all of these fields: + + commit - the hash of the commit object + tree - the hash of the tree object + parents - the hash of parent commits + time - timestamp of the commit + author - author's name + authorEmail - author's email address + summary - one-line summary + message - longer description + + (See local_workspace.py for helpers.) + + Args: + diff_id: the id of the diff. + local_commits: a list of dicts. + + Returns: + API response. + """ + params = { + 'diff_id': diff_id, + 'name': 'local:commits', + 'data': { + info.commit: info + for info in local_commits + }, + } + result = self._query('differential.setdiffproperty', params) + + def get_commit_message(self, title, summary, revision_id=None): + """Returns a commit message with details for the given revision. + + Args: + title: the title for the revision. + summary: the summary for the revision. + revision_id: (optional) get the template for the given revision. + + Returns: + A string, the new commit message with Phabricator details filled in. + This can be used, for example, to amend a commit. + """ + params = { + 'fields': { + 'title': title, + 'summary': summary, + }, + } + if revision_id is not None: + params['revision_id'] = revision_id + params['edit'] = 'edit' + else: + params['edit'] = 'create' + + return self._query( + 'differential.getcommitmessage', + params, + ) + + def parse_commit_message(self, commit_message): + """Parses `commit_message` for Differential fields and returns them. + + Phabricator looks for certain tags in the commit message, for example, + to auto-close the differential when it is committed. The logic is fairly + straightforward in the obvious cases, but using the API call ensures we + match the expected behavior precisely. + """ + result = self._query( + 'differential.parsecommitmessage', + {'corpus': commit_message, 'partial': True}, + ) + return result['fields'] + + def edit_revision(self, diff_phid, commit_message=None, revid=None): + """Updates (or creates) a differential revision with the given diff. + + If a new revision is created, you may need to amend the local commit + message to reflect `get_commit_message`. Otherwise, the revision may not + be closed automatically on commit. + + Example: + + edit_revision(diff_phid='PHID-DIFF-abc', + commit_message=textwrap.dedent('''\\ + Revision title + + Revision summary. + + Differential Revision: https://foo/D234''') + + This example updates revision D234. The diff will be set to + PHID-DIFF-abc, and the title and description will be updated. + + Example: + + edit_revision(revid=234, diff_phid='PHID-DIFF-abc') + + This example updates revision D234. The diff will be set to + PHID-DIFF-abc, and the title and description will not be changed. + + Args: + diffid: the 'id' returned from `create_raw_diff`. + commit_message: optional string, the commit message to use. If it + has a revision ID tagged, then `revid` is not needed. + revid: optional int, the revision ID. + + Returns: + The revision object, which is a dict like: + { + 'id': 234, + 'phid': 'PHID-DREV-abc', + } + """ + if revid is None and commit_message is None: + raise Error('Need a commit message for new revisions') + + params = { + 'transactions': [ + {'type': 'update', 'value': diff_phid}, + ], + } + + if commit_message is not None: + parsed_message = self.parse_commit_message(commit_message) + params['transactions'].extend([ + {'type': 'title', 'value': parsed_message['title']}, + {'type': 'summary', 'value': parsed_message['summary']}, + ]) + if revid is None: + revid = parsed_message.get('revisionID', None) + + if revid is not None: + params['objectIdentifier'] = revid + + result = self._query('differential.revision.edit', params) + return result['object'] + + +def main(): + import local_workspace + arcrc = local_workspace.get_arcrc() + arcconfig = local_workspace.get_arcconfig() + + from textwrap import dedent + with connect_repository(arcrc, arcconfig) as phab: + #print(phab.create_raw_diff(subprocess.check_output(['git', 'diff', '--cached']))) + #print(phab.get_commit_message(63329)) + #print(phab.edit_revision('PHID-DIFF-slm674rgdy63vd3vyxvg', dedent("""\ + # Revision title + # + # Updated revision summary + # + # Differential Revision: https://reviews.llvm.org/D67691"""))) + pass + + +if __name__ == '__main__': + main() diff --git a/llvm/utils/git-svn/phab_local.py b/llvm/utils/git-svn/phab_local.py new file mode 100644 --- /dev/null +++ b/llvm/utils/git-svn/phab_local.py @@ -0,0 +1,216 @@ +# ======- phab_local.py - Local client utilities -------*- python -*--========# +# +# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. +# See https://llvm.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ==------------------------------------------------------------------------==# + +"""Helpers for local Git client (with pending changes). + +This file contains helpers for determining what to send to Phabricator. + +The Conduit API (see: phab_conduit.py) has some specific corner cases of what +needs to be sent to get the best result in the web UI. Some of the helpers below +may seem quite specific, but the goal is to get to exactly what is needed. +""" + +from __future__ import print_function + +import collections +import json +import os +import subprocess +import sys + +# Python 2/3 notes: +# +# Anything "textual" should generally be UTF-8. As a general rule, things that +# might be bytes in Python 3 should always be `.decode`d. There are some places +# where things should not be decoded immediately; these cases should generally +# be documented with a comment. +# +# Since we don't want to depend on external modules, the library differences are +# handled directly, instead of using six (or similar). + + +################################################################################ +# Exception types +################################################################################ + +class Error(Exception): + """Exception inside local_workspace.""" + + +class ReferenceError(Error): + """Invalid reference.""" + + +################################################################################ +# Config files +################################################################################ + + +def get_arcrc(): + """Loads (from JSON) and returns the user's .arcrc file.""" + if sys.platform == 'win32': + arcrc_path = os.path.join(os.getenv('APPDATA'), '.arcrc') + else: + arcrc_path = os.path.join(os.getenv('HOME'), '.arcrc') + + with open(arcrc_path) as fh: + return json.load(fh) + +def get_arcconfig(): + """Loads (from JSON) and returns the working copy's .arcconfig file.""" + git_dir = git('rev-parse', '--show-toplevel') + with open(os.path.join(git_dir, '.arcconfig')) as fh: + return json.load(fh) + + +################################################################################ +# Git helpers +################################################################################ + +def git(*argv, **kwargs): + # Python 3 allows keyword args after *args. + decode = kwargs.get('decode', True) + trim = kwargs.get('trim', True) + output = subprocess.check_output(('git',) + argv) + # Py 2/3: we may not want to immediately decode output (e.g., in git_tab). + if decode: + output = output.decode('utf-8') + if trim: + output = output.strip() + return output + + +def git_tab(*argv, **kwargs): + """Splits output and returns as a list-of-records. + + The 'fieldsep' and 'recsep' arguments should match the format that git will + use. For example: + + git_tabulate('log', '--format=%H%x01%T%00', '-m', '2', + fieldsep='\x01', recsep='\x00') + ==> [['123a...', '234b...'], + ['345c...', '567d...']] + + Args: + argv: args to git (after 'git'). + kwargs: + fieldsep: the separator between fields. + recsep: the separator between records. + trim: if True (the default), trim a leading newline from records. This + is useful for cases where the formatted output contains a newline + in addition to the separator in the format string. + skip_empty: if True (the default), empty records will be skipped. + + Returns: + A list of lists. + """ + # Python 3 allows keyword args after *args. + # Py 2/3: since command output is in bytes in Python 3, the separators are + # explicitly byte strings. + fieldsep = kwargs.get('fieldsep', b' ') + recsep = kwargs.get('recsep', b'\n') + trim = kwargs.get('trim', True) + skip_empty = kwargs.get('skip_empty', True) + + # Py 2/3: Don't decode output immedately, since our separators might not be + # valid UTF-8. However, make sure to decode later so that the strings are + # how we otherwise expect. + output = git(*argv, decode=False) + records = [] + for line in output.split(recsep): + if skip_empty and not line.strip(): + continue + if trim: + line = line.lstrip(b'\r\n') + records.append([x.decode('utf-8') for x in line.split(fieldsep)]) + return records + + +def get_symbolic_ref(ref): + """Returns the symbolic name for the given ref.""" + try: + return git('rev-parse', '--abbrev-ref', '--symbolic-full-name', + ref).strip() + except subprocess.CalledProcessError as e: + raise ReferenceError(ref, e.output) + + +def get_ref_hash(ref): + """Returns the hash of the given named ref.""" + try: + return git('rev-parse', ref).strip() + except subprocess.CalledProcessError as e: + raise ReferenceError(ref, e.stdout) + + +def get_upstream_base(): + """Returns the upstream base for this working copy.""" + try: + upstream_ref = get_symbolic_ref('@{upstream}') + except ReferenceError: + # Prompt below. + pass + else: + return upstream_ref + + print('No upstream is configured for current branch.') + print() + tracked = git_tab( + 'for-each-ref', + ('--format=%(if)%(upstream)%(then)' + '%(refname:short) %(upstream:short)' + '%(end)'), + 'refs/heads') + if tracked: + print('These branches have an upstream set:') + max_width = max(len(x) for x, _ in tracked) + for branch, upstream in tracked: + print(' {0:{w}} [{1}]'.format(branch, upstream, w=max_width)) + print() + + print('Consider running `git branch -u origin/master` (or similar) ' + 'to set a default for this branch.') + print() + upstream_ref = input('Upstream branch [origin/master]: ') + if not upstream_ref: + upstream_ref = 'origin/master' + + return upstream_ref + + +# LocalCommit is information about a commit taken from the local workspace. +# +# The various fields can be read from `git log`. The formats are below. +CommitInfo = collections.namedtuple( + 'CommitInfo', + ['commit', # %H + 'tree', # %T + 'parents', # %P + 'time', # %at + 'author', # %an + 'authorEmail', # %aE + 'summary', # %s + 'message', # %b + ]) + + +def git_log(*refs): + """Returns `CommitInfo`s for 'git log' output.""" + commit_info_format = '%x01'.join(['%H', '%T', '%P', '%at', '%an', '%aE', + '%s', '%b']) + '%x00' + return [ + CommitInfo._make(x) + for x in git_tab('log', ('--format='+commit_info_format), *refs, + fieldsep=b'\x01', recsep=b'\x00') + ] + + +def get_local_commits(base): + """Returns information about local commits (not on the upstream ref).""" + head = get_ref_hash('HEAD') + return git_log(head, '--not', base)