xref: /freebsd/tools/tools/git/git-arc.sh (revision f374ba41)
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 [-m message] [<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=$(echo $1 | sed 's/"/\\"/g')
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    msg=$(mktemp)
249    git show -s --format='%B' "$commit" > "$msg"
250    printf "\nTest Plan:\n" >> "$msg"
251    printf "\nReviewers:\n" >> "$msg"
252    printf "%s\n" "${reviewers}" >> "$msg"
253    printf "\nSubscribers:\n" >> "$msg"
254    printf "%s\n" "${subscribers}" >> "$msg"
255
256    yes | env EDITOR=true \
257        arc diff --message-file "$msg" --never-apply-patches --create \
258        --allow-untracked $BROWSE --head "$commit" "${commit}~"
259    [ $? -eq 0 ] || err "could not create Phabricator diff"
260
261    if [ -n "$parent" ]; then
262        diff=$(commit2diff "$commit")
263        [ -n "$diff" ] || err "failed to look up review ID for $commit"
264
265        childphid=$(diff2phid "$diff")
266        parentphid=$(diff2phid "$parent")
267        echo '{
268            "objectIdentifier": "'"${childphid}"'",
269            "transactions": [
270                {
271                    "type": "parents.add",
272                    "value": ["'"${parentphid}"'"]
273                }
274             ]}' |
275            arc call-conduit -- differential.revision.edit >&3
276    fi
277    rm -f "$msg"
278    return 0
279}
280
281# Get a list of reviewers who accepted the specified diff.
282diff2reviewers()
283{
284    local diff reviewid userids
285
286    diff=$1
287    reviewid=$(diff2phid "$diff")
288    userids=$( \
289        echo '{
290                  "constraints": {"phids": ["'"$reviewid"'"]},
291                  "attachments": {"reviewers": true}
292              }' |
293        arc call-conduit -- differential.revision.search |
294        jq '.response.data[0].attachments.reviewers.reviewers[] | select(.status == "accepted").reviewerPHID')
295    if [ -n "$userids" ]; then
296        echo '{
297                  "constraints": {"phids": ['"$(echo -n "$userids" | tr '[:space:]' ',')"']}
298              }' |
299            arc call-conduit -- user.search |
300            jq -r '.response.data[].fields.username'
301    fi
302}
303
304prompt()
305{
306    local resp
307
308    if [ "$ASSUME_YES" ]; then
309        return 0
310    fi
311
312    printf "\nDoes this look OK? [y/N] "
313    read -r resp
314
315    case $resp in
316    [Yy])
317        return 0
318        ;;
319    *)
320        return 1
321        ;;
322    esac
323}
324
325show_and_prompt()
326{
327    local commit
328
329    commit=$1
330
331    git show "$commit"
332    prompt
333}
334
335build_commit_list()
336{
337    local chash _commits commits
338
339    for chash in "$@"; do
340        _commits=$(git rev-parse "${chash}")
341        if ! git cat-file -e "${chash}"'^{commit}' >/dev/null 2>&1; then
342            # shellcheck disable=SC2086
343            _commits=$(git rev-list $_commits | tail -r)
344        fi
345        [ -n "$_commits" ] || err "invalid commit ID ${chash}"
346        commits="$commits $_commits"
347    done
348    echo "$commits"
349}
350
351gitarc__create()
352{
353    local commit commits doprompt list o prev reviewers subscribers
354
355    list=
356    prev=""
357    if [ "$(git config --bool --get arc.list 2>/dev/null || echo false)" != "false" ]; then
358        list=1
359    fi
360    doprompt=1
361    while getopts lp:r:s: o; do
362        case "$o" in
363        l)
364            list=1
365            ;;
366        p)
367            prev="$OPTARG"
368            ;;
369        r)
370            reviewers="$OPTARG"
371            ;;
372        s)
373            subscribers="$OPTARG"
374            ;;
375        *)
376            err_usage
377            ;;
378        esac
379    done
380    shift $((OPTIND-1))
381
382    commits=$(build_commit_list "$@")
383
384    if [ "$list" ]; then
385        for commit in ${commits}; do
386            git --no-pager show --oneline --no-patch "$commit"
387        done | git_pager
388        if ! prompt; then
389            return
390        fi
391        doprompt=
392    fi
393
394    for commit in ${commits}; do
395        if create_one_review "$commit" "$reviewers" "$subscribers" "$prev" \
396                             "$doprompt"; then
397            prev=$(commit2diff "$commit")
398        else
399            prev=""
400        fi
401    done
402}
403
404gitarc__list()
405{
406    local chash commit commits diff openrevs title
407
408    commits=$(build_commit_list "$@")
409    openrevs=$(arc list)
410
411    for commit in $commits; do
412        chash=$(git show -s --format='%C(auto)%h' "$commit")
413        echo -n "${chash} "
414
415        diff=$(log2diff "$commit")
416        if [ -n "$diff" ]; then
417                diff2status "$diff"
418                continue
419        fi
420
421        # This does not use commit2diff as it needs to handle errors
422        # differently and keep the entire status.
423        title=$(git show -s --format=%s "$commit")
424        diff=$(echo "$openrevs" | \
425            awk -F'D[1-9][0-9]*:\.\\[m ' \
426                '{if ($2 == "'"$(echo $title | sed 's/"/\\"/g')"'") print $0}')
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 title 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        title=$(git show -s --format=%s "$commit")
483        diff=$(title2diff "$title")
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 have_msg msg
505
506    while getopts m: o; do
507        case "$o" in
508        m)
509            msg="$OPTARG"
510            have_msg=1
511            ;;
512        *)
513            err_usage
514            ;;
515        esac
516    done
517    shift $((OPTIND-1))
518
519    commits=$(build_commit_list "$@")
520    for commit in ${commits}; do
521        diff=$(commit2diff "$commit")
522
523        if ! show_and_prompt "$commit"; then
524            break
525        fi
526
527        # The linter is stupid and applies patches to the working copy.
528        # This would be tolerable if it didn't try to correct "misspelled" variable
529        # names.
530        if [ -n "$have_msg" ]; then
531            arc diff --message "$msg" --allow-untracked --never-apply-patches \
532                --update "$diff" --head "$commit" "${commit}~"
533        else
534            arc diff --allow-untracked --never-apply-patches --update "$diff" \
535                --head "$commit" "${commit}~"
536        fi
537    done
538}
539
540set -e
541
542ASSUME_YES=
543if [ "$(git config --bool --get arc.assume-yes 2>/dev/null || echo false)" != "false" ]; then
544    ASSUME_YES=1
545fi
546
547VERBOSE=
548while getopts vy o; do
549    case "$o" in
550    v)
551        VERBOSE=1
552        ;;
553    y)
554        ASSUME_YES=1
555        ;;
556    *)
557        err_usage
558        ;;
559    esac
560done
561shift $((OPTIND-1))
562
563[ $# -ge 1 ] || err_usage
564
565which arc >/dev/null 2>&1 || err "arc is required, install devel/arcanist"
566which jq >/dev/null 2>&1 || err "jq is required, install textproc/jq"
567
568if [ "$VERBOSE" ]; then
569    exec 3>&1
570else
571    exec 3> /dev/null
572fi
573
574case "$1" in
575create|list|patch|stage|update)
576    ;;
577*)
578    err_usage
579    ;;
580esac
581verb=$1
582shift
583
584# All subcommands require at least one parameter.
585if [ $# -eq 0 ]; then
586    err_usage
587fi
588
589# Pull in some git helper functions.
590git_sh_setup=$(git --exec-path)/git-sh-setup
591[ -f "$git_sh_setup" ] || err "cannot find git-sh-setup"
592SUBDIRECTORY_OK=y
593USAGE=
594# shellcheck disable=SC1090
595. "$git_sh_setup"
596
597# git commands use GIT_EDITOR instead of EDITOR, so try to provide consistent
598# behaviour.  Ditto for PAGER.  This makes git-arc play nicer with editor
599# plugins like vim-fugitive.
600if [ -n "$GIT_EDITOR" ]; then
601    EDITOR=$GIT_EDITOR
602fi
603if [ -n "$GIT_PAGER" ]; then
604    PAGER=$GIT_PAGER
605fi
606
607# Bail if the working tree is unclean, except for "list" and "patch"
608# operations.
609case $verb in
610list|patch)
611    ;;
612*)
613    require_clean_work_tree "$verb"
614    ;;
615esac
616
617if [ "$(git config --bool --get arc.browse 2>/dev/null || echo false)" != "false" ]; then
618    BROWSE=--browse
619fi
620
621gitarc__"${verb}" "$@"
622