xref: /freebsd/tools/tools/git/git-arc.sh (revision 19261079)
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    if [ "$(git config --bool --get arc.list 2>/dev/null || echo false)" != "false" ]; then
360        list=1
361    fi
362    doprompt=1
363    while getopts lr:s: o; do
364        case "$o" in
365        l)
366            list=1
367            ;;
368        r)
369            reviewers="$OPTARG"
370            ;;
371        s)
372            subscribers="$OPTARG"
373            ;;
374        *)
375            err_usage
376            ;;
377        esac
378    done
379    shift $((OPTIND-1))
380
381    commits=$(build_commit_list "$@")
382
383    if [ "$list" ]; then
384        for commit in ${commits}; do
385            git --no-pager show --oneline --no-patch "$commit"
386        done | git_pager
387        if ! prompt; then
388            return
389        fi
390        doprompt=
391    fi
392
393    save_head
394    prev=""
395    for commit in ${commits}; do
396        if create_one_review "$commit" "$reviewers" "$subscribers" "$prev" \
397                             "$doprompt"; then
398            prev=$(commit2diff "$commit")
399        else
400            prev=""
401        fi
402    done
403    restore_head
404}
405
406gitarc__list()
407{
408    local chash commit commits diff title
409
410    commits=$(build_commit_list "$@")
411
412    for commit in $commits; do
413        chash=$(git show -s --format='%C(auto)%h' "$commit")
414        echo -n "${chash} "
415
416        diff=$(log2diff "$commit")
417        if [ -n "$diff" ]; then
418                diff2status "$diff"
419                continue
420        fi
421
422        # This does not use commit2diff as it needs to handle errors
423        # differently and keep the entire status.  The extra 'cat'
424        # after 'fgrep' avoids erroring due to -e.
425        title=$(git show -s --format=%s "$commit")
426        diff=$(arc list | grep -F "$title" | cat)
427        if [ -z "$diff" ]; then
428            echo "No Review      : $title"
429        elif [ "$(echo "$diff" | wc -l)" -ne 1 ]; then
430            echo -n "Ambiguous Reviews: "
431            echo "$diff" | grep -E -o 'D[1-9][0-9]*:' | tr -d ':' \
432                | paste -sd ',' - | sed 's/,/, /g'
433        else
434            echo "$diff" | sed -e 's/^[^ ]* *//'
435        fi
436    done
437}
438
439gitarc__patch()
440{
441    local rev
442
443    if [ $# -eq 0 ]; then
444        err_usage
445    fi
446
447    for rev in "$@"; do
448        arc patch --skip-dependencies --nocommit --nobranch --force "$rev"
449        echo "Applying ${rev}..."
450        [ $? -eq 0 ] || break
451    done
452}
453
454gitarc__stage()
455{
456    local author branch commit commits diff reviewers tmp
457
458    branch=main
459    while getopts b: o; do
460        case "$o" in
461        b)
462            branch="$OPTARG"
463            ;;
464        *)
465            err_usage
466            ;;
467        esac
468    done
469    shift $((OPTIND-1))
470
471    commits=$(build_commit_list "$@")
472
473    if [ "$branch" = "main" ]; then
474        git checkout -q main
475    else
476        git checkout -q -b "${branch}" main
477    fi
478
479    tmp=$(mktemp)
480    for commit in $commits; do
481        git show -s --format=%B "$commit" > "$tmp"
482        diff=$(arc list | grep -F "$(git show -s --format=%s "$commit")" |
483            grep -E -o 'D[1-9][0-9]*:' | tr -d ':')
484        if [ -n "$diff" ]; then
485            # XXX this leaves an extra newline in some cases.
486            reviewers=$(diff2reviewers "$diff" | sed '/^$/d' | paste -sd ',' - | sed 's/,/, /g')
487            if [ -n "$reviewers" ]; then
488                printf "Reviewed by:\t%s\n" "${reviewers}" >> "$tmp"
489            fi
490            printf "Differential Revision:\thttps://reviews.freebsd.org/%s" "${diff}" >> "$tmp"
491        fi
492        author=$(git show -s --format='%an <%ae>' "${commit}")
493        if ! git cherry-pick --no-commit "${commit}"; then
494            warn "Failed to apply $(git rev-parse --short "${commit}").  Are you staging patches in the wrong order?"
495            git checkout -f
496            break
497        fi
498        git commit --edit --file "$tmp" --author "${author}"
499    done
500}
501
502gitarc__update()
503{
504    local commit commits diff
505
506    commits=$(build_commit_list "$@")
507    save_head
508    for commit in ${commits}; do
509        diff=$(commit2diff "$commit")
510
511        if ! show_and_prompt "$commit"; then
512            break
513        fi
514
515        git checkout -q "$commit"
516
517        # The linter is stupid and applies patches to the working copy.
518        # This would be tolerable if it didn't try to correct "misspelled" variable
519        # names.
520        arc diff --allow-untracked --never-apply-patches --update "$diff" HEAD~
521    done
522    restore_head
523}
524
525set -e
526
527ASSUME_YES=
528if [ "$(git config --bool --get arc.assume-yes 2>/dev/null || echo false)" != "false" ]; then
529    ASSUME_YES=1
530fi
531
532VERBOSE=
533while getopts vy o; do
534    case "$o" in
535    v)
536        VERBOSE=1
537        ;;
538    y)
539        ASSUME_YES=1
540        ;;
541    *)
542        err_usage
543        ;;
544    esac
545done
546shift $((OPTIND-1))
547
548[ $# -ge 1 ] || err_usage
549
550which arc >/dev/null 2>&1 || err "arc is required, install devel/arcanist"
551which jq >/dev/null 2>&1 || err "jq is required, install textproc/jq"
552
553if [ "$VERBOSE" ]; then
554    exec 3>&1
555else
556    exec 3> /dev/null
557fi
558
559case "$1" in
560create|list|patch|stage|update)
561    ;;
562*)
563    err_usage
564    ;;
565esac
566verb=$1
567shift
568
569# All subcommands require at least one parameter.
570if [ $# -eq 0 ]; then
571    err_usage
572fi
573
574# Pull in some git helper functions.
575git_sh_setup=$(git --exec-path)/git-sh-setup
576[ -f "$git_sh_setup" ] || err "cannot find git-sh-setup"
577SUBDIRECTORY_OK=y
578USAGE=
579# shellcheck disable=SC1090
580. "$git_sh_setup"
581
582# Bail if the working tree is unclean, except for "list" and "patch"
583# operations.
584case $verb in
585list|patch)
586    ;;
587*)
588    require_clean_work_tree "$verb"
589    ;;
590esac
591
592if [ "$(git config --bool --get arc.browse 2>/dev/null || echo false)" != "false" ]; then
593    BROWSE=--browse
594fi
595
596trap restore_head EXIT INT
597
598gitarc__"${verb}" "$@"
599