diff options
-rw-r--r-- | README | 72 | ||||
-rwxr-xr-x | git-phab | 352 |
2 files changed, 424 insertions, 0 deletions
@@ -0,0 +1,72 @@ +INSTALL +======= + +Copy or symlink executables into your $PATH. + ln -s $PWD/git-phab ~/.local/bin/ + +REQUIREMENTS +============ + + - pip3 install GitPython + - arcanist + +DESCRIPTION +=========== + +Git subcommand to integrate with phabricator. + +The 'attach' command creates a new differential for each commit in the provided +range. Commit messages will be rewritten to include the URL of the newly created +Differential (no other information will be added to the message). If a commit +message already contains the URL of a Differential it will be updated instead of +creating a new one. + +The 'attach' command can also push the current HEAD as a new branch on a remote +repository. The branch will be named 'wip/phab/T123' when the '--task T123' +argument is given or if current branch's name is in the form "T123" or +"T123-description". Note that the remote URI should always be readable by anyone +so you might want to set it up like: + + git remote add publish git://foo + git remote set-url --push publish git+ssh://foo + git config phab.remote publish + +The 'fetch' command pulls the branch referenced by a task. + +Notes: + - Repository must be clean, use 'git stash' if needed. + - Range must not contain merge commits, use 'git rebase' if needed. + - Range must be contained in the current branch, but can stop before HEAD. In + that case commits after the end of your range will be cherry-picked after + attaching commits to Differential. + - If attaching one of the commits fails (e.g. lint is unhappy with your coding + style) the original commit will still be picked and it will still attempt to + attach next commits. So it will never leave your repository in unconsistent + state. Failed commits will be shown in a summary at the end of the process + and can then be attached separately after being fixed with for example + 'git commit --amend' or 'git rebase -i'. + - Only one branch can be associated per task. + +Examples: + # Attach all commits since origin/master + git phab attach + + # Attach only the top commit + git phab attach HEAD + + # Attach all commits since origin/master, excluding top commit + git phab attach origin/master..HEAD^ + + # Attach top 3 patches, link them to a task, and set reviewers + git phab attach --reviewers xclaesse,smcv --task T123 HEAD~3.. + + # Push current branch to origin/wip/phab/T123 + git config phab.remote origin + git phab attach --task T123 + + # Fetch a branch associated with the task T123 + git phab fetch T123 + +The 'log' command shows the list of commits in a range together with their +Differential link if any. The range definition is the same as for 'attach' +command, and is thus useful to know what attach would do. diff --git a/git-phab b/git-phab new file mode 100755 index 0000000..cf05284 --- /dev/null +++ b/git-phab @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# git-phab - git subcommand to integrate with phabricator +# +# Copyright (C) 2008 Owen Taylor +# Copyright (C) 2015 Xavier Claessens <xavier.claessens@collabora.com> +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, If not, see +# http://www.gnu.org/licenses/. + +import subprocess +import argparse +import git +import os +import re +import sys +import json + + +repo = git.Repo(os.getcwd()) + + +# Copied from git-bz +def die(message): + print(message, file=sys.stderr) + sys.exit(1) + + +# Copied from git-bz +def prompt(message): + try: + while True: + # Using print here could result in Python adding a stray space + # before the next print + sys.stdout.write(message + " [yn] ") + sys.stdout.flush() + line = sys.stdin.readline().strip() + if line == 'y' or line == 'Y': + return True + elif line == 'n' or line == 'N': + return False + except KeyboardInterrupt: + # Ctrl+C doesn’t cause a newline + sys.stdout.write("\n") + return False + + +def get_commits(commit_or_revision_range): + try: + # See if the argument identifies a single revision + commits = [repo.rev_parse(commit_or_revision_range)] + except: + # If not, assume the argument is a range + try: + commits = list(repo.iter_commits(commit_or_revision_range)) + except: + # If not again, the argument must be invalid — perhaps the user has + # accidentally specified a bug number but not a revision. + commits = [] + + if len(commits) == 0: + die(("'%s' does not name any commits. Use HEAD to specify just the " + "last commit") % commit_or_revision_range) + + return commits + + +def get_differential_link(commit): + m = re.search('(^Differential Revision: )(.*)$', + commit.message, re.MULTILINE) + return None if m is None else m.group(2) + + +def format_commit(commit): + link = get_differential_link(commit) + d = "N/A" if link is None else link[link.rfind('/') + 1:] + return u"%s %s — %s" % (commit.hexsha[:7], d, commit.summary) + + +def print_commit(commit): + print(format_commit(commit)) + + +def conduit(cmd, args): + output = subprocess.check_output('arc call-conduit ' + cmd, + input=bytes(json.dumps(args), 'utf-8'), + shell=True) + return json.loads(output.decode('utf-8')) + + +def get_wip_branch(args, fetch=False): + remote = args.remote + branch = "wip/phab/" + args.task + + if fetch and not remote: + try: + reply = conduit('maniphest.query', { + "ids": [int(args.task[1:])] + }) + props = list(reply['response'].values())[0] + uri = props['auxiliary']['std:maniphest:git:uri-branch'] + remote, branch = uri.split('#') + print("Git URI: %s, branch: %s" % (remote, branch)) + except: + print("Couldn't get git URI from Maniphest") + + if not remote: + remote = repo.config_reader().get_value('phab', 'remote') + + return branch, remote + + +def validate_args(args): + if args.task and not re.fullmatch('T[0-9]+', args.task): + die(("Task '%s' is not in the correct format. " + "Expecting 'T123'." % args.task)) + + +def parse_commit_msg(msg): + subject = None + body = [] + fields = [] + + for line in msg.split('\n'): + if not subject: + subject = line + elif line.startswith('Differential Revision: '): + fields.append(line) + else: + body.append(line) + + return subject, body, fields + + +def do_attach(args): + if repo.is_dirty(): + die("Repository is dirty. Aborting.") + + validate_args(args) + + # Oldest commit is last in the list + commits = get_commits(args.revision_range) + all_commits = list(repo.iter_commits(commits[-1].hexsha + "^..HEAD")) + + # Sanity checks + for c in commits: + if c not in all_commits: + die("'%s' is not in current tree. Aborting." % c.hexsha) + if len(c.parents) > 1: + die("'%s' is a merge commit. Aborting." % c.hexsha) + + # Try to guess the task from branch name + if not args.task: + name = str(repo.head.reference) + m = re.search('(^T[0-9]+)($|-.*)', name) + if m is not None: + args.task = m.group(1) + + # Ask confirmation before doing any harm + for c in commits: + print_commit(c) + if not prompt("Attach above commits?" if not args.task else + "Attach above commits to task %s?" % args.task): + print("Aborting") + sys.exit(0) + + orig_commit = repo.head.commit + orig_branch = repo.head.reference + + options = ['--allow-untracked', + '--config history.immutable=false', + '--verbatim'] + if args.reviewers: + options.append('--reviewers ' + args.reviewers) + if args.message: + options.append('--message ' + args.message) + arc_cmd = 'arc diff %s HEAD~1' % (' '.join(options)) + + summary = "" + + try: + # Detach HEAD from the branch; this gives a cleaner reflog for the + # branch + print("Moving to starting point") + repo.head.reference = commits[-1].parents[0] + repo.head.reset(index=True, working_tree=True) + + last_revision_id = None + for commit in reversed(all_commits): + repo.git.cherry_pick(commit.hexsha) + + if commit in commits: + # Add extra info in the commit msg. It is important that + # phabricator fields are last, after all common git fields like + # 'Reviewed-by:', etc. Note that "Depends on" is not a field + # and is parsed from the body part. + subject, body, fields = parse_commit_msg(commit.message) + if last_revision_id: + body.append("Depends on %s" % last_revision_id) + if args.task: + fields.append("Maniphest Tasks: %s" % args.task) + + msg = '\n\n'.join([subject, + '\n'.join(body), + '\n'.join(fields)]) + + repo.head.commit = repo.head.commit.parents[0] + repo.head.commit = repo.index.commit(msg) + + print("attach " + commit.hexsha) + try: + subprocess.check_call(arc_cmd, shell=True) + except: + print("Command '%s' failed. Continuing." % arc_cmd) + summary += "Failed: %s\n" % format_commit(commit) + repo.head.commit = commit + continue + + # arc diff modified our commit message. Re-commit it with the + # original message, adding only the "Differential Revision:" + # line. + msg = commit.message + orig_link = get_differential_link(commit) + new_link = get_differential_link(repo.head.commit) + if orig_link is None and new_link is not None: + msg = msg + '\nDifferential Revision: ' + new_link + summary += "New: " + else: + summary += "Updated: " + + repo.head.commit = repo.head.commit.parents[0] + repo.head.commit = repo.index.commit(msg) + last_revision_id = new_link.split("/")[-1] + + summary += format_commit(repo.head.commit) + "\n" + else: + print("pick " + commit.hexsha) + summary += "Picked: %s\n" % format_commit(commit) + + if orig_branch is not None: + orig_branch.commit = repo.head.commit + repo.head.reference = orig_branch + except: + print("Cleaning up back to original state on error") + repo.head.commit = orig_commit + if orig_branch is not None: + orig_branch.commit = orig_commit + repo.head.reference = orig_branch + repo.head.reset(index=True, working_tree=True) + raise + + if args.task: + try: + branch, remote_name = get_wip_branch(args) + remote = repo.remote(remote_name) + if prompt('Push HEAD to %s/%s?' % (remote, branch)): + remote.push('HEAD:refs/heads/' + branch, force=True) + summary += "Branch pushed to %s/%s\n" % (remote, branch) + + try: + uri = "%s#%s" % (remote.config_reader.get('url'), branch) + conduit('maniphest.update', { + "id": int(args.task[1:]), + "auxiliary": { + "std:maniphest:git:uri-branch": uri + } + }) + except: + print("Failed to set std:maniphest:git:uri-branch") + + except: + summary += "Failed: push wip branch\n" + + print("\n\nSummary:") + print(summary) + + +def do_log(args): + commits = get_commits(args.revision_range) + for c in commits: + print_commit(c) + + +def do_fetch(args): + validate_args(args) + + branch, remote = get_wip_branch(args, fetch=True) + if not remote: + die("No remote location defined. Aborting.") + + repo.git.fetch(remote, "%s:%s" % (branch, args.task), force=True) + print("Branch %s created." % args.task) + + +parser = argparse.ArgumentParser(description='Phabricator integration.') +subparsers = parser.add_subparsers() + +attach_parser = subparsers.add_parser( + 'attach', help="Generate a Differential for each commit") +attach_parser.add_argument( + '--reviewers', '-r', metavar='<username1,username2,...>', + help="A list of reviewers") +attach_parser.add_argument( + '--message', '-m', metavar='<message>', + help=("When updating a revision, use the specified message instead of " + "prompting")) +attach_parser.add_argument( + '--task', '-t', metavar='<T123>', + help=("Set the task this Differential refers to")) +attach_parser.add_argument( + '--remote', metavar='<remote>', + help=("A remote repository to push to. " + "Overrides 'phab.remote' configuration.")) +attach_parser.add_argument( + 'revision_range', metavar='<revision range>', + nargs='?', default='origin/master..', + help="commit or revision range to attach (Default: 'origin/master..')") +attach_parser.set_defaults(func=do_attach) + +log_parser = subparsers.add_parser( + 'log', help="Show commit logs with their differential ID") +log_parser.add_argument( + 'revision_range', metavar='<revision range>', + nargs='?', default='origin/master..', + help="commit or revision range to show (Default: 'origin/master..')") +log_parser.set_defaults(func=do_log) + +fetch_parser = subparsers.add_parser( + 'fetch', help="Fetch a task's branch") +fetch_parser.add_argument( + '--remote', metavar='<remote>', + help=("A remote repository to fetch from. " + "Overrides 'phab.remote' configuration.")) +fetch_parser.add_argument( + 'task', metavar='<T123>', + help="The task to fetch") +fetch_parser.set_defaults(func=do_fetch) + +args = parser.parse_args() +args.func(args) |