summaryrefslogtreecommitdiff
path: root/git-phab
blob: 399e46ba51131ed55ca3f50e4ae46d9406e05d22 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
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 Exception as e:
            summary += "Failed: push wip branch: %s\n" % e

    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)