xref: /freebsd/tools/tools/git/git-arc.sh (revision 1323ec57)
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
190# Look for an open revision with a title equal to the input string.  Return
191# a possibly empty list of Differential revision IDs.
192title2diff()
193{
194    local title
195
196    title=$1
197    # arc list output always includes ANSI escape sequences, strip them.
198    arc list | sed 's/\x1b\[[0-9;]*m//g' | \
199        awk -F': ' '{
200            if (substr($0, index($0, FS) + length(FS)) == "'"$title"'") {
201                print substr($1, match($1, "D[1-9][0-9]*"))
202            }
203        }'
204}
205
206commit2diff()
207{
208    local commit diff title
209
210    commit=$1
211
212    # First, look for a valid differential reference in the commit
213    # log.
214    diff=$(log2diff "$commit")
215    if [ -n "$diff" ]; then
216        echo "$diff"
217        return
218    fi
219
220    # Second, search the open reviews returned by 'arc list' looking
221    # for a subject match.
222    title=$(git show -s --format=%s "$commit")
223    diff=$(title2diff "$title")
224    if [ -z "$diff" ]; then
225        err "could not find review for '${title}'"
226    elif [ "$(echo "$diff" | wc -l)" -ne 1 ]; then
227        err "found multiple reviews with the same title"
228    fi
229
230    echo "$diff"
231}
232
233create_one_review()
234{
235    local childphid commit doprompt msg parent parentphid reviewers
236    local subscribers
237
238    commit=$1
239    reviewers=$2
240    subscribers=$3
241    parent=$4
242    doprompt=$5
243
244    if [ "$doprompt" ] && ! show_and_prompt "$commit"; then
245        return 1
246    fi
247
248    git checkout -q "$commit"
249
250    msg=$(mktemp)
251    git show -s --format='%B' "$commit" > "$msg"
252    printf "\nTest Plan:\n" >> "$msg"
253    printf "\nReviewers:\n" >> "$msg"
254    printf "%s\n" "${reviewers}" >> "$msg"
255    printf "\nSubscribers:\n" >> "$msg"
256    printf "%s\n" "${subscribers}" >> "$msg"
257
258    yes | env EDITOR=true \
259        arc diff --message-file "$msg" --never-apply-patches --create --allow-untracked $BROWSE HEAD~
260    [ $? -eq 0 ] || err "could not create Phabricator diff"
261
262    if [ -n "$parent" ]; then
263        diff=$(commit2diff "$commit")
264        [ -n "$diff" ] || err "failed to look up review ID for $commit"
265
266        childphid=$(diff2phid "$diff")
267        parentphid=$(diff2phid "$parent")
268        echo '{
269            "objectIdentifier": "'"${childphid}"'",
270            "transactions": [
271                {
272                    "type": "parents.add",
273                    "value": ["'"${parentphid}"'"]
274                }
275             ]}' |
276            arc call-conduit -- differential.revision.edit >&3
277    fi
278    rm -f "$msg"
279    return 0
280}
281
282# Get a list of reviewers who accepted the specified diff.
283diff2reviewers()
284{
285    local diff reviewid userids
286
287    diff=$1
288    reviewid=$(diff2phid "$diff")
289    userids=$( \
290        echo '{
291                  "constraints": {"phids": ["'"$reviewid"'"]},
292                  "attachments": {"reviewers": true}
293              }' |
294        arc call-conduit -- differential.revision.search |
295        jq '.response.data[0].attachments.reviewers.reviewers[] | select(.status == "accepted").reviewerPHID')
296    if [ -n "$userids" ]; then
297        echo '{
298                  "constraints": {"phids": ['"$(echo -n "$userids" | tr '[:space:]' ',')"']}
299              }' |
300            arc call-conduit -- user.search |
301            jq -r '.response.data[].fields.username'
302    fi
303}
304
305prompt()
306{
307    local resp
308
309    if [ "$ASSUME_YES" ]; then
310        return 1
311    fi
312
313    printf "\nDoes this look OK? [y/N] "
314    read -r resp
315
316    case $resp in
317    [Yy])
318        return 0
319        ;;
320    *)
321        return 1
322        ;;
323    esac
324}
325
326show_and_prompt()
327{
328    local commit
329
330    commit=$1
331
332    git show "$commit"
333    prompt
334}
335
336save_head()
337{
338    local orig
339
340    if ! orig=$(git symbolic-ref --short -q HEAD); then
341        orig=$(git show -s --pretty=%H HEAD)
342    fi
343    SAVED_HEAD=$orig
344}
345
346restore_head()
347{
348    if [ -n "$SAVED_HEAD" ]; then
349        git checkout -q "$SAVED_HEAD"
350        SAVED_HEAD=
351    fi
352}
353
354build_commit_list()
355{
356    local chash _commits commits
357
358    for chash in "$@"; do
359        _commits=$(git rev-parse "${chash}")
360        if ! git cat-file -e "${chash}"'^{commit}' >/dev/null 2>&1; then
361            # shellcheck disable=SC2086
362            _commits=$(git rev-list $_commits | tail -r)
363        fi
364        [ -n "$_commits" ] || err "invalid commit ID ${chash}"
365        commits="$commits $_commits"
366    done
367    echo "$commits"
368}
369
370gitarc__create()
371{
372    local commit commits doprompt list o prev reviewers subscribers
373
374    list=
375    prev=""
376    if [ "$(git config --bool --get arc.list 2>/dev/null || echo false)" != "false" ]; then
377        list=1
378    fi
379    doprompt=1
380    while getopts lp:r:s: o; do
381        case "$o" in
382        l)
383            list=1
384            ;;
385	p)
386	    prev="$OPTARG"
387	    ;;
388        r)
389            reviewers="$OPTARG"
390            ;;
391        s)
392            subscribers="$OPTARG"
393            ;;
394        *)
395            err_usage
396            ;;
397        esac
398    done
399    shift $((OPTIND-1))
400
401    commits=$(build_commit_list "$@")
402
403    if [ "$list" ]; then
404        for commit in ${commits}; do
405            git --no-pager show --oneline --no-patch "$commit"
406        done | git_pager
407        if ! prompt; then
408            return
409        fi
410        doprompt=
411    fi
412
413    save_head
414    for commit in ${commits}; do
415        if create_one_review "$commit" "$reviewers" "$subscribers" "$prev" \
416                             "$doprompt"; then
417            prev=$(commit2diff "$commit")
418        else
419            prev=""
420        fi
421    done
422    restore_head
423}
424
425gitarc__list()
426{
427    local chash commit commits diff openrevs title
428
429    commits=$(build_commit_list "$@")
430    openrevs=$(arc list)
431
432    for commit in $commits; do
433        chash=$(git show -s --format='%C(auto)%h' "$commit")
434        echo -n "${chash} "
435
436        diff=$(log2diff "$commit")
437        if [ -n "$diff" ]; then
438                diff2status "$diff"
439                continue
440        fi
441
442        # This does not use commit2diff as it needs to handle errors
443        # differently and keep the entire status.
444        title=$(git show -s --format=%s "$commit")
445        diff=$(echo "$openrevs" | \
446            awk -F'D[1-9][0-9]*:\.\\[m ' '{if ($2 == "'"$title"'") print $0}')
447        if [ -z "$diff" ]; then
448            echo "No Review      : $title"
449        elif [ "$(echo "$diff" | wc -l)" -ne 1 ]; then
450            echo -n "Ambiguous Reviews: "
451            echo "$diff" | grep -E -o 'D[1-9][0-9]*:' | tr -d ':' \
452                | paste -sd ',' - | sed 's/,/, /g'
453        else
454            echo "$diff" | sed -e 's/^[^ ]* *//'
455        fi
456    done
457}
458
459gitarc__patch()
460{
461    local rev
462
463    if [ $# -eq 0 ]; then
464        err_usage
465    fi
466
467    for rev in "$@"; do
468        arc patch --skip-dependencies --nocommit --nobranch --force "$rev"
469        echo "Applying ${rev}..."
470        [ $? -eq 0 ] || break
471    done
472}
473
474gitarc__stage()
475{
476    local author branch commit commits diff reviewers title tmp
477
478    branch=main
479    while getopts b: o; do
480        case "$o" in
481        b)
482            branch="$OPTARG"
483            ;;
484        *)
485            err_usage
486            ;;
487        esac
488    done
489    shift $((OPTIND-1))
490
491    commits=$(build_commit_list "$@")
492
493    if [ "$branch" = "main" ]; then
494        git checkout -q main
495    else
496        git checkout -q -b "${branch}" main
497    fi
498
499    tmp=$(mktemp)
500    for commit in $commits; do
501        git show -s --format=%B "$commit" > "$tmp"
502        title=$(git show -s --format=%s "$commit")
503        diff=$(title2diff "$title")
504        if [ -n "$diff" ]; then
505            # XXX this leaves an extra newline in some cases.
506            reviewers=$(diff2reviewers "$diff" | sed '/^$/d' | paste -sd ',' - | sed 's/,/, /g')
507            if [ -n "$reviewers" ]; then
508                printf "Reviewed by:\t%s\n" "${reviewers}" >> "$tmp"
509            fi
510            printf "Differential Revision:\thttps://reviews.freebsd.org/%s" "${diff}" >> "$tmp"
511        fi
512        author=$(git show -s --format='%an <%ae>' "${commit}")
513        if ! git cherry-pick --no-commit "${commit}"; then
514            warn "Failed to apply $(git rev-parse --short "${commit}").  Are you staging patches in the wrong order?"
515            git checkout -f
516            break
517        fi
518        git commit --edit --file "$tmp" --author "${author}"
519    done
520}
521
522gitarc__update()
523{
524    local commit commits diff
525
526    commits=$(build_commit_list "$@")
527    save_head
528    for commit in ${commits}; do
529        diff=$(commit2diff "$commit")
530
531        if ! show_and_prompt "$commit"; then
532            break
533        fi
534
535        git checkout -q "$commit"
536
537        # The linter is stupid and applies patches to the working copy.
538        # This would be tolerable if it didn't try to correct "misspelled" variable
539        # names.
540        arc diff --allow-untracked --never-apply-patches --update "$diff" HEAD~
541    done
542    restore_head
543}
544
545set -e
546
547ASSUME_YES=
548if [ "$(git config --bool --get arc.assume-yes 2>/dev/null || echo false)" != "false" ]; then
549    ASSUME_YES=1
550fi
551
552VERBOSE=
553while getopts vy o; do
554    case "$o" in
555    v)
556        VERBOSE=1
557        ;;
558    y)
559        ASSUME_YES=1
560        ;;
561    *)
562        err_usage
563        ;;
564    esac
565done
566shift $((OPTIND-1))
567
568[ $# -ge 1 ] || err_usage
569
570which arc >/dev/null 2>&1 || err "arc is required, install devel/arcanist"
571which jq >/dev/null 2>&1 || err "jq is required, install textproc/jq"
572
573if [ "$VERBOSE" ]; then
574    exec 3>&1
575else
576    exec 3> /dev/null
577fi
578
579case "$1" in
580create|list|patch|stage|update)
581    ;;
582*)
583    err_usage
584    ;;
585esac
586verb=$1
587shift
588
589# All subcommands require at least one parameter.
590if [ $# -eq 0 ]; then
591    err_usage
592fi
593
594# Pull in some git helper functions.
595git_sh_setup=$(git --exec-path)/git-sh-setup
596[ -f "$git_sh_setup" ] || err "cannot find git-sh-setup"
597SUBDIRECTORY_OK=y
598USAGE=
599# shellcheck disable=SC1090
600. "$git_sh_setup"
601
602# Bail if the working tree is unclean, except for "list" and "patch"
603# operations.
604case $verb in
605list|patch)
606    ;;
607*)
608    require_clean_work_tree "$verb"
609    ;;
610esac
611
612if [ "$(git config --bool --get arc.browse 2>/dev/null || echo false)" != "false" ]; then
613    BROWSE=--browse
614fi
615
616trap restore_head EXIT INT
617
618gitarc__"${verb}" "$@"
619