xref: /freebsd/tools/tools/git/git-arc.sh (revision 97939604f447de0d06e6cc2c15f19c6f7be30cd2)
1#!/bin/sh
2#
3# SPDX-License-Identifier: BSD-2-Clause
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
46cleanup()
47{
48    rc=$?
49    rm -fr "$GITARC_TMPDIR"
50    trap - EXIT
51    exit $rc
52}
53
54err_usage()
55{
56    cat >&2 <<__EOF__
57Usage: git arc [-vy] <command> <arguments>
58
59Commands:
60  create [-l] [-r <reviewer1>[,<reviewer2>...]] [-s subscriber[,...]] [<commit>|<commit range>]
61  list <commit>|<commit range>
62  patch [-bc] <diff1> [<diff2> ...]
63  stage [-b branch] [<commit>|<commit range>]
64  update [-l] [-m message] [<commit>|<commit range>]
65
66Description:
67  Create or manage FreeBSD Phabricator reviews based on git commits.  There
68  is a one-to one relationship between git commits and Differential revisions,
69  and the Differential revision title must match the summary line of the
70  corresponding commit.  In particular, commit summaries must be unique across
71  all open Differential revisions authored by you.
72
73  The first parameter must be a verb.  The available verbs are:
74
75    create -- Create new Differential revisions from the specified commits.
76    list   -- Print the associated Differential revisions for the specified
77              commits.
78    patch  -- Apply patches from Differential revisions.  By default, patches
79              are applied to the currently checked-out tree, unless -b is
80              supplied, in which case a new branch is first created.  The -c
81              option commits the applied patch using the review's metadata.
82    stage  -- Prepare a series of commits to be pushed to the upstream FreeBSD
83              repository.  The commits are cherry-picked to a branch (main by
84              default), review tags are added to the commit log message, and
85              the log message is opened in an editor for any last-minute
86              updates.  The commits need not have associated Differential
87              revisions.
88    update -- Synchronize the Differential revisions associated with the
89              specified commits.  Currently only the diff is updated; the
90              review description and other metadata is not synchronized.
91
92  The typical end-to-end usage looks something like this:
93
94    $ git commit -m "kern: Rewrite in Rust"
95    $ git arc create HEAD
96    <Make changes to the diff based on reviewer feedback.>
97    $ git commit --amend
98    $ git arc update HEAD
99    <Now that all reviewers are happy, it's time to push.>
100    $ git arc stage HEAD
101    $ git push freebsd HEAD:main
102
103Config Variables:
104  These are manipulated by git-config(1).
105
106    arc.assume_yes [bool]
107                       -- Assume a "yes" answer to all prompts instead of
108                          prompting the user.  Equivalent to the -y flag.
109
110    arc.browse [bool]  -- Try to open newly created reviews in a browser tab.
111                          Defaults to false.
112
113    arc.list [bool]    -- Always use "list mode" (-l) with create and update.
114                          In this mode, the list of git revisions to use
115                          is listed with a single prompt before creating or
116                          updating reviews.  The diffs for individual commits
117                          are not shown.
118
119    arc.verbose [bool] -- Verbose output.  Equivalent to the -v flag.
120
121Examples:
122  Create a Phabricator review using the contents of the most recent commit in
123  your git checkout.  The commit title is used as the review title, the commit
124  log message is used as the review description, markj@FreeBSD.org is added as
125  a reviewer. Also, the "Jails" reviewer group is added using its hashtag.
126
127  $ git arc create -r markj,#jails HEAD
128
129  Create a series of Phabricator reviews for each of HEAD~2, HEAD~ and HEAD.
130  Pairs of consecutive commits are linked into a patch stack.  Note that the
131  first commit in the specified range is excluded.
132
133  $ git arc create HEAD~3..HEAD
134
135  Update the review corresponding to commit b409afcfedcdda.  The title of the
136  commit must be the same as it was when the review was created.  The review
137  description is not automatically updated.
138
139  $ git arc update b409afcfedcdda
140
141  Apply the patch in review D12345 to the currently checked-out tree, and stage
142  it.
143
144  $ git arc patch D12345
145
146  Apply the patch in review D12345 to the currently checked-out tree, and
147  commit it using the review's title, summary and author.
148
149  $ git arc patch -c D12345
150
151  Apply the patches in reviews D12345 and D12346 in a new branch, and commit
152  them using the review titles, summaries and authors.
153
154  $ git arc patch -bc D12345 D12346
155
156  List the status of reviews for all the commits in the branch "feature":
157
158  $ git arc list main..feature
159
160__EOF__
161
162    exit 1
163}
164
165# Use xmktemp instead of mktemp when creating temporary files.
166xmktemp()
167{
168    mktemp "${GITARC_TMPDIR:?}/tmp.XXXXXXXXXX" || exit 1
169}
170
171#
172# Fetch the value of a boolean config variable ($1) and return true
173# (0) if the variable is true.  The default value to use if the
174# variable is not set is passed in $2.
175#
176get_bool_config()
177{
178    test "$(git config --bool --get $1 2>/dev/null || echo $2)" != "false"
179}
180
181#
182# Invoke the actual arc command.  This allows us to only rely on the
183# devel/arcanist-lib port, which installs the actual script, rather than
184# the devel/arcanist-port, which installs a symlink in ${LOCALBASE}/bin
185# but conflicts with the archivers/arc port.
186#
187: ${LOCALBASE:=$(sysctl -n user.localbase)}
188: ${LOCALBASE:=/usr/local}
189: ${ARC_CMD:=${LOCALBASE}/lib/php/arcanist/bin/arc}
190arc()
191{
192    ${ARC_CMD} "$@"
193}
194
195#
196# Filter the output of call-conduit to remove the warnings that are generated
197# for some installations where openssl module is mysteriously installed twice so
198# a warning is generated. It's likely a local config error, but we should work
199# in the face of that.
200#
201arc_call_conduit()
202{
203    arc call-conduit "$@" | grep -v '^Warning: '
204}
205
206#
207# Filter the output of arc list to remove the warnings as above, as well as
208# the bolding sequence (the color sequence remains intact).
209#
210arc_list()
211{
212    arc list "$@" | grep -v '^Warning: ' | sed -E 's/\x1b\[1m//g;s/\x1b\[m//g'
213}
214
215diff2phid()
216{
217    local diff
218
219    diff=$1
220    if ! expr "$diff" : 'D[1-9][0-9]*$' >/dev/null; then
221        err "invalid diff ID $diff"
222    fi
223
224    echo '{"names":["'"$diff"'"]}' |
225        arc_call_conduit -- phid.lookup |
226        jq -r "select(.response != []) | .response.${diff}.phid"
227}
228
229diff2status()
230{
231    local diff tmp status summary
232
233    diff=$1
234    if ! expr "$diff" : 'D[1-9][0-9]*$' >/dev/null; then
235        err "invalid diff ID $diff"
236    fi
237
238    tmp=$(xmktemp)
239    echo '{"names":["'"$diff"'"]}' |
240        arc_call_conduit -- phid.lookup > "$tmp"
241    status=$(jq -r "select(.response != []) | .response.${diff}.status" < "$tmp")
242    summary=$(jq -r "select(.response != []) |
243        .response.${diff}.fullName" < "$tmp")
244    printf "%-14s %s\n" "${status}" "${summary}"
245}
246
247log2diff()
248{
249    local diff
250
251    diff=$(git show -s --format=%B "$commit" |
252        sed -nE '/^Differential Revision:[[:space:]]+(https:\/\/reviews.freebsd.org\/)?(D[0-9]+)$/{s//\2/;p;}')
253    if [ -n "$diff" ] && [ "$(echo "$diff" | wc -l)" -eq 1 ]; then
254        echo "$diff"
255    else
256        echo
257    fi
258}
259
260# Look for an open revision with a title equal to the input string.  Return
261# a possibly empty list of Differential revision IDs.
262title2diff()
263{
264    local title
265
266    title=$(echo "$1" | sed 's/"/\\"/g')
267    arc_list --no-ansi |
268        awk -F': ' '{
269            if (substr($0, index($0, FS) + length(FS)) == "'"$title"'") {
270                print substr($1, match($1, "D[1-9][0-9]*"))
271            }
272        }'
273}
274
275commit2diff()
276{
277    local commit diff title
278
279    commit=$1
280
281    # First, look for a valid differential reference in the commit
282    # log.
283    diff=$(log2diff "$commit")
284    if [ -n "$diff" ]; then
285        echo "$diff"
286        return
287    fi
288
289    # Second, search the open reviews returned by 'arc list' looking
290    # for a subject match.
291    title=$(git show -s --format=%s "$commit")
292    diff=$(title2diff "$title")
293    if [ -z "$diff" ]; then
294        err "could not find review for '${title}'"
295    elif [ "$(echo "$diff" | wc -l)" -ne 1 ]; then
296        err "found multiple reviews with the same title"
297    fi
298
299    echo "$diff"
300}
301
302create_one_review()
303{
304    local childphid commit doprompt msg parent parentphid reviewers
305    local subscribers
306
307    commit=$1
308    reviewers=$2
309    subscribers=$3
310    parent=$4
311    doprompt=$5
312
313    if [ "$doprompt" ] && ! show_and_prompt "$commit"; then
314        return 1
315    fi
316
317    msg=$(xmktemp)
318    git show -s --format='%B' "$commit" > "$msg"
319    printf "\nTest Plan:\n" >> "$msg"
320    printf "\nReviewers:\n" >> "$msg"
321    printf "%s\n" "${reviewers}" >> "$msg"
322    printf "\nSubscribers:\n" >> "$msg"
323    printf "%s\n" "${subscribers}" >> "$msg"
324
325    yes | EDITOR=true \
326        arc diff --message-file "$msg" --never-apply-patches --create \
327        --allow-untracked $BROWSE --head "$commit" "${commit}~"
328    [ $? -eq 0 ] || err "could not create Phabricator diff"
329
330    if [ -n "$parent" ]; then
331        diff=$(commit2diff "$commit")
332        [ -n "$diff" ] || err "failed to look up review ID for $commit"
333
334        childphid=$(diff2phid "$diff")
335        parentphid=$(diff2phid "$parent")
336        echo '{
337            "objectIdentifier": "'"${childphid}"'",
338            "transactions": [
339                {
340                    "type": "parents.add",
341                    "value": ["'"${parentphid}"'"]
342                }
343            ]}' |
344            arc_call_conduit -- differential.revision.edit >&3
345    fi
346    return 0
347}
348
349# Get a list of reviewers who accepted the specified diff.
350diff2reviewers()
351{
352    local diff reviewid userids
353
354    diff=$1
355    reviewid=$(diff2phid "$diff")
356    userids=$( \
357        echo '{
358        "constraints": {"phids": ["'"$reviewid"'"]},
359        "attachments": {"reviewers": true}
360        }' |
361        arc_call_conduit -- differential.revision.search |
362        jq '.response.data[0].attachments.reviewers.reviewers[] | select(.status == "accepted").reviewerPHID')
363    if [ -n "$userids" ]; then
364        echo '{
365        "constraints": {"phids": ['"$(echo $userids | tr '[:blank:]' ',')"']}
366        }' |
367        arc_call_conduit -- user.search |
368        jq -r '.response.data[].fields.username'
369    fi
370}
371
372prompt()
373{
374    local resp
375
376    if [ "$ASSUME_YES" ]; then
377        return 0
378    fi
379
380    printf "\nDoes this look OK? [y/N] "
381    read -r resp
382
383    case $resp in
384    [Yy])
385        return 0
386        ;;
387    *)
388        return 1
389        ;;
390    esac
391}
392
393show_and_prompt()
394{
395    local commit
396
397    commit=$1
398
399    git show "$commit"
400    prompt
401}
402
403build_commit_list()
404{
405    local chash _commits commits
406
407    for chash in "$@"; do
408        _commits=$(git rev-parse "${chash}")
409        if ! git cat-file -e "${chash}"'^{commit}' >/dev/null 2>&1; then
410            # shellcheck disable=SC2086
411            _commits=$(git rev-list --reverse $_commits)
412        fi
413        [ -n "$_commits" ] || err "invalid commit ID ${chash}"
414        commits="$commits $_commits"
415    done
416    echo "$commits"
417}
418
419gitarc__create()
420{
421    local commit commits doprompt list o prev reviewers subscribers
422
423    list=
424    prev=""
425    if get_bool_config arc.list false; then
426        list=1
427    fi
428    doprompt=1
429    while getopts lp:r:s: o; do
430        case "$o" in
431        l)
432            list=1
433            ;;
434        p)
435            prev="$OPTARG"
436            ;;
437        r)
438            reviewers="$OPTARG"
439            ;;
440        s)
441            subscribers="$OPTARG"
442            ;;
443        *)
444            err_usage
445            ;;
446        esac
447    done
448    shift $((OPTIND-1))
449
450    commits=$(build_commit_list "$@")
451
452    if [ "$list" ]; then
453        for commit in ${commits}; do
454            git --no-pager show --oneline --no-patch "$commit"
455        done | git_pager
456        if ! prompt; then
457            return
458        fi
459        doprompt=
460    fi
461
462    for commit in ${commits}; do
463        if create_one_review "$commit" "$reviewers" "$subscribers" "$prev" \
464            "$doprompt"; then
465            prev=$(commit2diff "$commit")
466        else
467            prev=""
468        fi
469    done
470}
471
472gitarc__list()
473{
474    local chash commit commits diff openrevs title
475
476    commits=$(build_commit_list "$@")
477    openrevs=$(arc_list --ansi)
478
479    for commit in $commits; do
480        chash=$(git show -s --format='%C(auto)%h' "$commit")
481        printf "%s" "${chash} "
482
483        diff=$(log2diff "$commit")
484        if [ -n "$diff" ]; then
485            diff2status "$diff"
486            continue
487        fi
488
489        # This does not use commit2diff as it needs to handle errors
490        # differently and keep the entire status.
491        title=$(git show -s --format=%s "$commit")
492        diff=$(echo "$openrevs" | \
493            awk -F'D[1-9][0-9]*: ' \
494            '{if ($2 == "'"$(echo "$title" | sed 's/"/\\"/g')"'") print $0}')
495        if [ -z "$diff" ]; then
496            echo "No Review            : $title"
497        elif [ "$(echo "$diff" | wc -l)" -ne 1 ]; then
498            printf "%s" "Ambiguous Reviews: "
499            echo "$diff" | grep -E -o 'D[1-9][0-9]*:' | tr -d ':' \
500                | paste -sd ',' - | sed 's/,/, /g'
501        else
502            echo "$diff" | sed -e 's/^[^ ]* *//'
503        fi
504    done
505}
506
507# Try to guess our way to a good author name. The DWIM is strong in this
508# function, but these heuristics seem to generally produce the right results, in
509# the sample of src commits I checked out.
510find_author()
511{
512    local addr name email author_addr author_name
513
514    addr="$1"
515    name="$2"
516    author_addr="$3"
517    author_name="$4"
518
519    # The Phabricator interface doesn't have a simple way to get author name and
520    # address, so we have to try a number of heuristics to get the right result.
521
522    # Choice 1: It's a FreeBSD committer. These folks have no '.' in their phab
523    # username/addr. Sampled data in phab suggests that there's a high rate of
524    # these people having their local config pointing at something other than
525    # freebsd.org (which isn't surprising for ports committers getting src
526    # commits reviewed).
527    case "${addr}" in
528    *.*) ;;             # external user
529    *)
530        echo "${name} <${addr}@FreeBSD.org>"
531        return
532        ;;
533    esac
534
535    # Choice 2: author_addr and author_name were set in the bundle, so use
536    # that. We may need to filter some known bogus ones, should they crop up.
537    if [ -n "$author_name" -a -n "$author_addr" ]; then
538        echo "${author_name} <${author_addr}>"
539        return
540    fi
541
542    # Choice 3: We can find this user in the FreeBSD repo. They've submited
543    # something before, and they happened to use an email that's somewhat
544    # similar to their phab username.
545    email=$(git log -1 --author "$(echo ${addr} | tr _ .)" --pretty="%aN <%aE>")
546    if [ -n "${email}" ]; then
547        echo "${email}"
548        return
549    fi
550
551    # Choice 4: We know this user. They've committed before, and they happened
552    # to use the same name, unless the name has the word 'user' in it. This
553    # might not be a good idea, since names can be somewhat common (there
554    # are two Andrew Turners that have contributed to FreeBSD, for example).
555    if ! (echo "${name}" | grep -w "[Uu]ser" -q); then
556        email=$(git log -1 --author "${name}" --pretty="%aN <%aE>")
557        if [ -n "$email" ]; then
558            echo "$email"
559            return
560        fi
561    fi
562
563    # Choice 5: Wing it as best we can. In this scenario, we replace the last _
564    # with a @, and call it the email address...
565    # Annoying fun fact: Phab replaces all non alpha-numerics with _, so we
566    # don't know if the prior _ are _ or + or any number of other characters.
567    # Since there's issues here, prompt
568    a=$(printf "%s <%s>\n" "${name}" $(echo "$addr" | sed -e 's/\(.*\)_/\1@/'))
569    echo "Making best guess: Turning ${addr} to ${a}" >&2
570    if ! prompt; then
571        echo "ABORT"
572        return
573    fi
574    echo "${a}"
575}
576
577patch_branch()
578{
579    local base new suffix
580
581    if [ $# -eq 1 ]; then
582        base="gitarc-$1"
583    else
584        base="gitarc-$(printf "%s-" "$@" | sed 's/-$//')"
585    fi
586
587    new="$base"
588    suffix=1
589    while git show-ref --quiet --branches "$new"; do
590        new="${base}_$suffix"
591        suffix=$((suffix + 1))
592    done
593
594    git checkout -b "$new"
595}
596
597patch_commit()
598{
599    local diff reviewid review_data authorid user_data user_addr user_name
600    local diff_data author_addr author_name author tmp
601
602    diff=$1
603    reviewid=$(diff2phid "$diff")
604    # Get the author phid for this patch
605    review_data=$(xmktemp)
606    echo '{"constraints": {"phids": ["'"$reviewid"'"]}}' | \
607        arc_call_conduit -- differential.revision.search > "$review_data"
608    authorid=$(jq -r '.response.data[].fields.authorPHID' "$review_data")
609    # Get metadata about the user that submitted this patch
610    user_data=$(xmktemp)
611    echo '{"constraints": {"phids": ["'"$authorid"'"]}}' | \
612        arc_call_conduit -- user.search | \
613        jq -r '.response.data[].fields' > "$user_data"
614    user_addr=$(jq -r '.username' "$user_data")
615    user_name=$(jq -r '.realName' "$user_data")
616    # Dig the data out of querydiffs api endpoint, although it's deprecated,
617    # since it's one of the few places we can get email addresses. It's unclear
618    # if we can expect multiple difference ones of these. Some records don't
619    # have this data, so we remove all the 'null's. We sort the results and
620    # remove duplicates 'just to be sure' since we've not seen multiple
621    # records that match.
622    diff_data=$(xmktemp)
623    echo '{"revisionIDs": [ '"${diff#D}"' ]}' | \
624        arc_call_conduit -- differential.querydiffs |
625        jq -r '.response | flatten | .[]' > "$diff_data"
626    # If the differential revision has multiple revisions, just take the first
627    # non-null value we get.
628    author_addr=$(jq -r ".authorEmail?" "$diff_data" | grep -v '^null$' | head -n 1)
629    author_name=$(jq -r ".authorName?" "$diff_data" | grep -v '^null$' | head -n 1)
630
631    author=$(find_author "$user_addr" "$user_name" "$author_addr" "$author_name")
632
633    # If we had to guess, and the user didn't want to guess, abort
634    if [ "${author}" = "ABORT" ]; then
635        warn "Not committing due to uncertainty over author name"
636        exit 1
637    fi
638
639    tmp=$(xmktemp)
640    jq -r '.response.data[].fields.title' "$review_data" > "$tmp"
641    echo >> "$tmp"
642    jq -r '.response.data[].fields.summary' "$review_data" >> "$tmp"
643    echo >> "$tmp"
644    # XXX this leaves an extra newline in some cases.
645    reviewers=$(diff2reviewers "$diff" | sed '/^$/d' | paste -sd ',' - | sed 's/,/, /g')
646    if [ -n "$reviewers" ]; then
647        printf "Reviewed by:\t%s\n" "${reviewers}" >> "$tmp"
648    fi
649    # XXX TODO refactor with gitarc__stage maybe?
650    printf "Differential Revision:\thttps://reviews.freebsd.org/%s\n" "${diff}" >> "$tmp"
651    git commit --author "${author}" --file "$tmp"
652}
653
654gitarc__patch()
655{
656    local branch commit rev
657
658    branch=false
659    commit=false
660    while getopts bc o; do
661        case "$o" in
662        b)
663            require_clean_work_tree "patch -b"
664            branch=true
665            ;;
666        c)
667            require_clean_work_tree "patch -c"
668            commit=true
669            ;;
670        *)
671            err_usage
672            ;;
673        esac
674    done
675    shift $((OPTIND-1))
676
677    if [ $# -eq 0 ]; then
678        err_usage
679    fi
680
681    if ${branch}; then
682        patch_branch "$@"
683    fi
684    for rev in "$@"; do
685        if ! arc patch --skip-dependencies --nobranch --nocommit --force "$rev"; then
686            break
687        fi
688        echo "Applying ${rev}..."
689        if ${commit}; then
690            patch_commit $rev
691        fi
692    done
693}
694
695gitarc__stage()
696{
697    local author branch commit commits diff reviewers title tmp
698
699    branch=main
700    while getopts b: o; do
701        case "$o" in
702        b)
703            branch="$OPTARG"
704            ;;
705        *)
706            err_usage
707            ;;
708        esac
709    done
710    shift $((OPTIND-1))
711
712    commits=$(build_commit_list "$@")
713
714    if [ "$branch" = "main" ]; then
715        git checkout -q main
716    else
717        git checkout -q -b "${branch}" main
718    fi
719
720    tmp=$(xmktemp)
721    for commit in $commits; do
722        git show -s --format=%B "$commit" > "$tmp"
723        title=$(git show -s --format=%s "$commit")
724        diff=$(title2diff "$title")
725        if [ -n "$diff" ]; then
726            # XXX this leaves an extra newline in some cases.
727            reviewers=$(diff2reviewers "$diff" | sed '/^$/d' | paste -sd ',' - | sed 's/,/, /g')
728            if [ -n "$reviewers" ]; then
729                printf "Reviewed by:\t%s\n" "${reviewers}" >> "$tmp"
730            fi
731            printf "Differential Revision:\thttps://reviews.freebsd.org/%s" "${diff}" >> "$tmp"
732        fi
733        author=$(git show -s --format='%an <%ae>' "${commit}")
734        if ! git cherry-pick --no-commit "${commit}"; then
735            warn "Failed to apply $(git rev-parse --short "${commit}").  Are you staging patches in the wrong order?"
736            git checkout -f
737            break
738        fi
739        git commit --edit --file "$tmp" --author "${author}"
740    done
741}
742
743gitarc__update()
744{
745    local commit commits diff doprompt have_msg list o msg
746
747    list=
748    if get_bool_config arc.list false; then
749        list=1
750    fi
751    doprompt=1
752    while getopts lm: o; do
753        case "$o" in
754        l)
755            list=1
756            ;;
757        m)
758            msg="$OPTARG"
759            have_msg=1
760            ;;
761        *)
762            err_usage
763            ;;
764        esac
765    done
766    shift $((OPTIND-1))
767
768    commits=$(build_commit_list "$@")
769
770    if [ "$list" ]; then
771        for commit in ${commits}; do
772            git --no-pager show --oneline --no-patch "$commit"
773        done | git_pager
774        if ! prompt; then
775            return
776        fi
777        doprompt=
778    fi
779
780    for commit in ${commits}; do
781        diff=$(commit2diff "$commit")
782
783        if [ "$doprompt" ] && ! show_and_prompt "$commit"; then
784            break
785        fi
786
787        # The linter is stupid and applies patches to the working copy.
788        # This would be tolerable if it didn't try to correct "misspelled" variable
789        # names.
790        if [ -n "$have_msg" ]; then
791            arc diff --message "$msg" --allow-untracked --never-apply-patches \
792                --update "$diff" --head "$commit" "${commit}~"
793        else
794            arc diff --allow-untracked --never-apply-patches --update "$diff" \
795                --head "$commit" "${commit}~"
796        fi
797    done
798}
799
800set -e
801
802ASSUME_YES=
803if get_bool_config arc.assume-yes false; then
804    ASSUME_YES=1
805fi
806
807VERBOSE=
808while getopts vy o; do
809    case "$o" in
810    v)
811        VERBOSE=1
812        ;;
813    y)
814        ASSUME_YES=1
815        ;;
816    *)
817        err_usage
818        ;;
819    esac
820done
821shift $((OPTIND-1))
822
823[ $# -ge 1 ] || err_usage
824
825[ -x "${ARC_CMD}" ] || err "arc is required, install devel/arcanist-lib"
826which jq >/dev/null 2>&1 || err "jq is required, install textproc/jq"
827
828if [ "$VERBOSE" ]; then
829    exec 3>&1
830else
831    exec 3> /dev/null
832fi
833
834case "$1" in
835create|list|patch|stage|update)
836    ;;
837*)
838    err_usage
839    ;;
840esac
841verb=$1
842shift
843
844# All subcommands require at least one parameter.
845if [ $# -eq 0 ]; then
846    err_usage
847fi
848
849# Pull in some git helper functions.
850git_sh_setup=$(git --exec-path)/git-sh-setup
851[ -f "$git_sh_setup" ] || err "cannot find git-sh-setup"
852SUBDIRECTORY_OK=y
853USAGE=
854# shellcheck disable=SC1090
855. "$git_sh_setup"
856
857# git commands use GIT_EDITOR instead of EDITOR, so try to provide consistent
858# behaviour.  Ditto for PAGER.  This makes git-arc play nicer with editor
859# plugins like vim-fugitive.
860if [ -n "$GIT_EDITOR" ]; then
861    EDITOR=$GIT_EDITOR
862fi
863if [ -n "$GIT_PAGER" ]; then
864    PAGER=$GIT_PAGER
865fi
866
867# Bail if the working tree is unclean, except for "list" and "patch"
868# operations.
869case $verb in
870list|patch)
871    ;;
872*)
873    require_clean_work_tree "$verb"
874    ;;
875esac
876
877if get_bool_config arc.browse false; then
878    BROWSE=--browse
879fi
880
881GITARC_TMPDIR=$(mktemp -d) || exit 1
882trap cleanup EXIT HUP INT QUIT TRAP USR1 TERM
883
884gitarc__"${verb}" "$@"
885