xref: /freebsd/tools/tools/git/git-arc.sh (revision 681ce946)
1#!/bin/sh
2#
3# SPDX-License-Identifier: BSD-2-Clause-FreeBSD
4#
5# Copyright (c) 2019-2021 Mark Johnston <markj@FreeBSD.org>
6# Copyright (c) 2021 John Baldwin <jhb@FreeBSD.org>
7#
8# Redistribution and use in source and binary forms, with or without
9# modification, are permitted provided that the following conditions are
10# met:
11# 1. Redistributions of source code must retain the above copyright
12#    notice, this list of conditions and the following disclaimer.
13# 2. Redistributions in binary form must reproduce the above copyright
14#    notice, this list of conditions and the following disclaimer in
15#    the documentation and/or other materials provided with the distribution.
16#
17# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
18# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
21# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
23# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
24# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
26# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
27# SUCH DAMAGE.
28#
29
30# TODO:
31# - roll back after errors or SIGINT
32#   - created revs
33#   - main (for git arc stage)
34
35warn()
36{
37    echo "$(basename "$0"): $1" >&2
38}
39
40err()
41{
42    warn "$1"
43    exit 1
44}
45
46err_usage()
47{
48    cat >&2 <<__EOF__
49Usage: git arc [-vy] <command> <arguments>
50
51Commands:
52  create [-l] [-r <reviewer1>[,<reviewer2>...]] [-s subscriber[,...]] [<commit>|<commit range>]
53  list <commit>|<commit range>
54  patch <diff1> [<diff2> ...]
55  stage [-b branch] [<commit>|<commit range>]
56  update [<commit>|<commit range>]
57
58Description:
59  Create or manage FreeBSD Phabricator reviews based on git commits.  There
60  is a one-to one relationship between git commits and Differential revisions,
61  and the Differential revision title must match the summary line of the
62  corresponding commit.  In particular, commit summaries must be unique across
63  all open Differential revisions authored by you.
64
65  The first parameter must be a verb.  The available verbs are:
66
67    create -- Create new Differential revisions from the specified commits.
68    list   -- Print the associated Differential revisions for the specified
69              commits.
70    patch  -- Try to apply a patch from a Differential revision to the
71              currently checked out tree.
72    stage  -- Prepare a series of commits to be pushed to the upstream FreeBSD
73              repository.  The commits are cherry-picked to a branch (main by
74              default), review tags are added to the commit log message, and
75              the log message is opened in an editor for any last-minute
76              updates.  The commits need not have associated Differential
77              revisions.
78    update -- Synchronize the Differential revisions associated with the
79              specified commits.  Currently only the diff is updated; the
80              review description and other metadata is not synchronized.
81
82  The typical end-to-end usage looks something like this:
83
84    $ git commit -m "kern: Rewrite in Rust"
85    $ git arc create HEAD
86    <Make changes to the diff based on reviewer feedback.>
87    $ git commit --amend
88    $ git arc update HEAD
89    <Now that all reviewers are happy, it's time to push.>
90    $ git arc stage HEAD
91    $ git push freebsd HEAD:main
92
93Config Variables:
94  These are manipulated by git-config(1).
95
96    arc.assume_yes [bool]
97                       -- Assume a "yes" answer to all prompts instead of
98                          prompting the user.  Equivalent to the -y flag.
99
100    arc.browse [bool]  -- Try to open newly created reviews in a browser tab.
101                          Defaults to false.
102
103    arc.list [bool]    -- Always use "list mode" (-l) with create.  In this
104                          mode, the list of git revisions to create reviews for
105                          is listed with a single prompt before creating
106                          reviews.  The diffs for individual commits are not
107                          shown.
108
109    arc.verbose [bool] -- Verbose output.  Equivalent to the -v flag.
110
111Examples:
112  Create a Phabricator review using the contents of the most recent commit in
113  your git checkout.  The commit title is used as the review title, the commit
114  log message is used as the review description, markj@FreeBSD.org is added as
115  a reviewer.
116
117  $ git arc create -r markj HEAD
118
119  Create a series of Phabricator reviews for each of HEAD~2, HEAD~ and HEAD.
120  Pairs of consecutive commits are linked into a patch stack.  Note that the
121  first commit in the specified range is excluded.
122
123  $ git arc create HEAD~3..HEAD
124
125  Update the review corresponding to commit b409afcfedcdda.  The title of the
126  commit must be the same as it was when the review was created.  The review
127  description is not automatically updated.
128
129  $ git arc update b409afcfedcdda
130
131  Apply the patch in review D12345 to the currently checked-out tree, and stage
132  it.
133
134  $ git arc patch D12345
135
136  List the status of reviews for all the commits in the branch "feature":
137
138  $ git arc list main..feature
139
140__EOF__
141
142    exit 1
143}
144
145diff2phid()
146{
147    local diff
148
149    diff=$1
150    if ! expr "$diff" : 'D[1-9][0-9]*$' >/dev/null; then
151        err "invalid diff ID $diff"
152    fi
153
154    echo '{"names":["'"$diff"'"]}' |
155        arc call-conduit -- phid.lookup |
156        jq -r "select(.response != []) | .response.${diff}.phid"
157}
158
159diff2status()
160{
161    local diff tmp status summary
162
163    diff=$1
164    if ! expr "$diff" : 'D[1-9][0-9]*$' >/dev/null; then
165        err "invalid diff ID $diff"
166    fi
167
168    tmp=$(mktemp)
169    echo '{"names":["'"$diff"'"]}' |
170        arc call-conduit -- phid.lookup > "$tmp"
171    status=$(jq -r "select(.response != []) | .response.${diff}.status" < "$tmp")
172    summary=$(jq -r "select(.response != []) |
173         .response.${diff}.fullName" < "$tmp")
174    printf "%-14s %s\n" "${status}" "${summary}"
175}
176
177log2diff()
178{
179    local diff
180
181    diff=$(git show -s --format=%B "$commit" |
182        sed -nE '/^Differential Revision:[[:space:]]+(https:\/\/reviews.freebsd.org\/)?(D[0-9]+)$/{s//\2/;p;}')
183    if [ -n "$diff" ] && [ "$(echo "$diff" | wc -l)" -eq 1 ]; then
184        echo "$diff"
185    else
186        echo
187    fi
188}
189
190commit2diff()
191{
192    local commit diff title
193
194    commit=$1
195
196    # First, look for a valid differential reference in the commit
197    # log.
198    diff=$(log2diff "$commit")
199    if [ -n "$diff" ]; then
200        echo "$diff"
201        return
202    fi
203
204    # Second, search the open reviews returned by 'arc list' looking
205    # for a subject match.
206    title=$(git show -s --format=%s "$commit")
207    diff=$(arc list | grep -F "$title" | grep -E -o 'D[1-9][0-9]*:' | tr -d ':')
208    if [ -z "$diff" ]; then
209        err "could not find review for '${title}'"
210    elif [ "$(echo "$diff" | wc -l)" -ne 1 ]; then
211        err "found multiple reviews with the same title"
212    fi
213
214    echo "$diff"
215}
216
217create_one_review()
218{
219    local childphid commit doprompt msg parent parentphid reviewers
220    local subscribers
221
222    commit=$1
223    reviewers=$2
224    subscribers=$3
225    parent=$4
226    doprompt=$5
227
228    if [ "$doprompt" ] && ! show_and_prompt "$commit"; then
229        return 1
230    fi
231
232    git checkout -q "$commit"
233
234    msg=$(mktemp)
235    git show -s --format='%B' "$commit" > "$msg"
236    printf "\nTest Plan:\n" >> "$msg"
237    printf "\nReviewers:\n" >> "$msg"
238    printf "%s\n" "${reviewers}" >> "$msg"
239    printf "\nSubscribers:\n" >> "$msg"
240    printf "%s\n" "${subscribers}" >> "$msg"
241
242    yes | env EDITOR=true \
243        arc diff --message-file "$msg" --never-apply-patches --create --allow-untracked $BROWSE HEAD~
244    [ $? -eq 0 ] || err "could not create Phabricator diff"
245
246    if [ -n "$parent" ]; then
247        diff=$(commit2diff "$commit")
248        [ -n "$diff" ] || err "failed to look up review ID for $commit"
249
250        childphid=$(diff2phid "$diff")
251        parentphid=$(diff2phid "$parent")
252        echo '{
253            "objectIdentifier": "'"${childphid}"'",
254            "transactions": [
255                {
256                    "type": "parents.add",
257                    "value": ["'"${parentphid}"'"]
258                }
259             ]}' |
260            arc call-conduit -- differential.revision.edit >&3
261    fi
262    rm -f "$msg"
263    return 0
264}
265
266# Get a list of reviewers who accepted the specified diff.
267diff2reviewers()
268{
269    local diff reviewid userids
270
271    diff=$1
272    reviewid=$(diff2phid "$diff")
273    userids=$( \
274        echo '{
275                  "constraints": {"phids": ["'"$reviewid"'"]},
276                  "attachments": {"reviewers": true}
277              }' |
278        arc call-conduit -- differential.revision.search |
279        jq '.response.data[0].attachments.reviewers.reviewers[] | select(.status == "accepted").reviewerPHID')
280    if [ -n "$userids" ]; then
281        echo '{
282                  "constraints": {"phids": ['"$(echo -n "$userids" | tr '[:space:]' ',')"']}
283              }' |
284            arc call-conduit -- user.search |
285            jq -r '.response.data[].fields.username'
286    fi
287}
288
289prompt()
290{
291    local resp
292
293    if [ "$ASSUME_YES" ]; then
294        return 1
295    fi
296
297    printf "\nDoes this look OK? [y/N] "
298    read -r resp
299
300    case $resp in
301    [Yy])
302        return 0
303        ;;
304    *)
305        return 1
306        ;;
307    esac
308}
309
310show_and_prompt()
311{
312    local commit
313
314    commit=$1
315
316    git show "$commit"
317    prompt
318}
319
320save_head()
321{
322    local orig
323
324    if ! orig=$(git symbolic-ref --short -q HEAD); then
325        orig=$(git show -s --pretty=%H HEAD)
326    fi
327    SAVED_HEAD=$orig
328}
329
330restore_head()
331{
332    if [ -n "$SAVED_HEAD" ]; then
333        git checkout -q "$SAVED_HEAD"
334        SAVED_HEAD=
335    fi
336}
337
338build_commit_list()
339{
340    local chash _commits commits
341
342    for chash in "$@"; do
343        _commits=$(git rev-parse "${chash}")
344        if ! git cat-file -e "${chash}"'^{commit}' >/dev/null 2>&1; then
345            # shellcheck disable=SC2086
346            _commits=$(git rev-list $_commits | tail -r)
347        fi
348        [ -n "$_commits" ] || err "invalid commit ID ${chash}"
349        commits="$commits $_commits"
350    done
351    echo "$commits"
352}
353
354gitarc__create()
355{
356    local commit commits doprompt list o prev reviewers subscribers
357
358    list=
359    prev=""
360    if [ "$(git config --bool --get arc.list 2>/dev/null || echo false)" != "false" ]; then
361        list=1
362    fi
363    doprompt=1
364    while getopts lp:r:s: o; do
365        case "$o" in
366        l)
367            list=1
368            ;;
369	p)
370	    prev="$OPTARG"
371	    ;;
372        r)
373            reviewers="$OPTARG"
374            ;;
375        s)
376            subscribers="$OPTARG"
377            ;;
378        *)
379            err_usage
380            ;;
381        esac
382    done
383    shift $((OPTIND-1))
384
385    commits=$(build_commit_list "$@")
386
387    if [ "$list" ]; then
388        for commit in ${commits}; do
389            git --no-pager show --oneline --no-patch "$commit"
390        done | git_pager
391        if ! prompt; then
392            return
393        fi
394        doprompt=
395    fi
396
397    save_head
398    for commit in ${commits}; do
399        if create_one_review "$commit" "$reviewers" "$subscribers" "$prev" \
400                             "$doprompt"; then
401            prev=$(commit2diff "$commit")
402        else
403            prev=""
404        fi
405    done
406    restore_head
407}
408
409gitarc__list()
410{
411    local chash commit commits diff title
412
413    commits=$(build_commit_list "$@")
414
415    for commit in $commits; do
416        chash=$(git show -s --format='%C(auto)%h' "$commit")
417        echo -n "${chash} "
418
419        diff=$(log2diff "$commit")
420        if [ -n "$diff" ]; then
421                diff2status "$diff"
422                continue
423        fi
424
425        # This does not use commit2diff as it needs to handle errors
426        # differently and keep the entire status.  The extra 'cat'
427        # after 'fgrep' avoids erroring due to -e.
428        title=$(git show -s --format=%s "$commit")
429        diff=$(arc list | grep -F "$title" | cat)
430        if [ -z "$diff" ]; then
431            echo "No Review      : $title"
432        elif [ "$(echo "$diff" | wc -l)" -ne 1 ]; then
433            echo -n "Ambiguous Reviews: "
434            echo "$diff" | grep -E -o 'D[1-9][0-9]*:' | tr -d ':' \
435                | paste -sd ',' - | sed 's/,/, /g'
436        else
437            echo "$diff" | sed -e 's/^[^ ]* *//'
438        fi
439    done
440}
441
442gitarc__patch()
443{
444    local rev
445
446    if [ $# -eq 0 ]; then
447        err_usage
448    fi
449
450    for rev in "$@"; do
451        arc patch --skip-dependencies --nocommit --nobranch --force "$rev"
452        echo "Applying ${rev}..."
453        [ $? -eq 0 ] || break
454    done
455}
456
457gitarc__stage()
458{
459    local author branch commit commits diff reviewers tmp
460
461    branch=main
462    while getopts b: o; do
463        case "$o" in
464        b)
465            branch="$OPTARG"
466            ;;
467        *)
468            err_usage
469            ;;
470        esac
471    done
472    shift $((OPTIND-1))
473
474    commits=$(build_commit_list "$@")
475
476    if [ "$branch" = "main" ]; then
477        git checkout -q main
478    else
479        git checkout -q -b "${branch}" main
480    fi
481
482    tmp=$(mktemp)
483    for commit in $commits; do
484        git show -s --format=%B "$commit" > "$tmp"
485        diff=$(arc list | grep -F "$(git show -s --format=%s "$commit")" |
486            grep -E -o 'D[1-9][0-9]*:' | tr -d ':')
487        if [ -n "$diff" ]; then
488            # XXX this leaves an extra newline in some cases.
489            reviewers=$(diff2reviewers "$diff" | sed '/^$/d' | paste -sd ',' - | sed 's/,/, /g')
490            if [ -n "$reviewers" ]; then
491                printf "Reviewed by:\t%s\n" "${reviewers}" >> "$tmp"
492            fi
493            printf "Differential Revision:\thttps://reviews.freebsd.org/%s" "${diff}" >> "$tmp"
494        fi
495        author=$(git show -s --format='%an <%ae>' "${commit}")
496        if ! git cherry-pick --no-commit "${commit}"; then
497            warn "Failed to apply $(git rev-parse --short "${commit}").  Are you staging patches in the wrong order?"
498            git checkout -f
499            break
500        fi
501        git commit --edit --file "$tmp" --author "${author}"
502    done
503}
504
505gitarc__update()
506{
507    local commit commits diff
508
509    commits=$(build_commit_list "$@")
510    save_head
511    for commit in ${commits}; do
512        diff=$(commit2diff "$commit")
513
514        if ! show_and_prompt "$commit"; then
515            break
516        fi
517
518        git checkout -q "$commit"
519
520        # The linter is stupid and applies patches to the working copy.
521        # This would be tolerable if it didn't try to correct "misspelled" variable
522        # names.
523        arc diff --allow-untracked --never-apply-patches --update "$diff" HEAD~
524    done
525    restore_head
526}
527
528set -e
529
530ASSUME_YES=
531if [ "$(git config --bool --get arc.assume-yes 2>/dev/null || echo false)" != "false" ]; then
532    ASSUME_YES=1
533fi
534
535VERBOSE=
536while getopts vy o; do
537    case "$o" in
538    v)
539        VERBOSE=1
540        ;;
541    y)
542        ASSUME_YES=1
543        ;;
544    *)
545        err_usage
546        ;;
547    esac
548done
549shift $((OPTIND-1))
550
551[ $# -ge 1 ] || err_usage
552
553which arc >/dev/null 2>&1 || err "arc is required, install devel/arcanist"
554which jq >/dev/null 2>&1 || err "jq is required, install textproc/jq"
555
556if [ "$VERBOSE" ]; then
557    exec 3>&1
558else
559    exec 3> /dev/null
560fi
561
562case "$1" in
563create|list|patch|stage|update)
564    ;;
565*)
566    err_usage
567    ;;
568esac
569verb=$1
570shift
571
572# All subcommands require at least one parameter.
573if [ $# -eq 0 ]; then
574    err_usage
575fi
576
577# Pull in some git helper functions.
578git_sh_setup=$(git --exec-path)/git-sh-setup
579[ -f "$git_sh_setup" ] || err "cannot find git-sh-setup"
580SUBDIRECTORY_OK=y
581USAGE=
582# shellcheck disable=SC1090
583. "$git_sh_setup"
584
585# Bail if the working tree is unclean, except for "list" and "patch"
586# operations.
587case $verb in
588list|patch)
589    ;;
590*)
591    require_clean_work_tree "$verb"
592    ;;
593esac
594
595if [ "$(git config --bool --get arc.browse 2>/dev/null || echo false)" != "false" ]; then
596    BROWSE=--browse
597fi
598
599trap restore_head EXIT INT
600
601gitarc__"${verb}" "$@"
602