diff --git a/tools/tools/git/Makefile b/tools/tools/git/Makefile new file mode 100644 --- /dev/null +++ b/tools/tools/git/Makefile @@ -0,0 +1,4 @@ +SCRIPTS= git-arc.sh +BINDIR?= /usr/local/bin + +.include diff --git a/tools/tools/git/git-arc.sh b/tools/tools/git/git-arc.sh new file mode 100644 --- /dev/null +++ b/tools/tools/git/git-arc.sh @@ -0,0 +1,596 @@ +#!/bin/sh +# +# SPDX-License-Identifier: BSD-2-Clause-FreeBSD +# +# Copyright (c) 2019-2021 Mark Johnston +# Copyright (c) 2021 John Baldwin +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# + +# TODO: +# - roll back after errors or SIGINT +# - current checkout +# - created revs +# - main (for git arc stage) + +warn() +{ + echo "$(basename $0): $1" >&2 +} + +err() +{ + warn "$1" + exit 1 +} + +err_usage() +{ + cat >&2 <<__EOF__ +Usage: git arc [-vy] + +Commands: + create [-l] [-r [,...]] [-s subscriber[,...]] [|] + list | + patch [ ...] + stage [-b branch] [|] + update + +Description: + Create or manage FreeBSD Phabricator reviews based on git commits. There + is a one-to one relationship between git commits and Differential revisions, + and the Differential revision title must match the summary line of the + corresponding commit. In particular, commit summaries must be unique across + all open Differential revisions authored by you. + + The first parameter must be a verb. The available verbs are: + + create -- Create new Differential revisions from the specified commits. + list -- Print the associated Differential revisions for the specified + commits. + patch -- Try to apply a patch from a Differential revision to the + currently checked out tree. + stage -- Prepare a series of commits to be pushed to the upstream FreeBSD + repository. The commits are cherry-picked to a branch (main by + default), review tags are added to the commit log message, and + the log message is opened in an editor for any last-minute + updates. The commits need not have associated Differential + revisions. + update -- Synchronize the Differential revisions associated with the + specified commits. Currently only the diff is updated; the + review description and other metadata is not synchronized. + + The typical end-to-end usage looks something like this: + + $ git commit -m "kern: Rewrite in Rust" + $ git arc create HEAD + + $ git commit --amend + $ git arc update HEAD + + $ git arc stage HEAD + $ git push freebsd HEAD:main + +Config Variables: + These are manipulated by git-config(1). + + arc.assume_yes [bool] + -- Assume a "yes" answer to all prompts instead of + prompting the user. Equivalent to the -y flag. + + arc.browse [bool] -- Try to open newly created reviews in a browser tab. + Defaults to false. + + arc.list [bool] -- Always use "list mode" (-l) with create. In this + mode, the list of git revisions to create reviews for + is listed with a single prompt before creating + reviews. The diffs for individual commits are not + shown. + + arc.verbose [bool] -- Verbose output. Equivalent to the -v flag. + +Examples: + Create a Phabricator review using the contents of the most recent commit in + your git checkout. The commit title is used as the review title, the commit + log message is used as the review description, markj@FreeBSD.org is added as + a reviewer. + + $ git arc create -r markj HEAD + + Create a series of Phabricator reviews for each of HEAD~2, HEAD~ and HEAD. + Pairs of consecutive commits are linked into a patch stack. Note that the + first commit in the specified range is excluded. + + $ git arc create HEAD~3..HEAD + + Update the review corresponding to commit b409afcfedcdda. The title of the + commit must be the same as it was when the review was created. The review + description is not automatically updated. + + $ git arc update b409afcfedcdda + + Apply the patch in review D12345 to the currently checked-out tree, and stage + it. + + $ git arc patch D12345 + + List the status of reviews for all the commits in the branch "feature": + + $ git arc list main..feature + +__EOF__ + + exit 1 +} + +diff2phid() +{ + local diff + + diff=$1 + if ! expr "$diff" : 'D[1-9][0-9]*$' >/dev/null; then + err "invalid diff ID $diff" + fi + + echo '{"names":["'$diff'"]}' | + arc call-conduit -- phid.lookup | + jq -r "select(.response != []) | .response.${diff}.phid" +} + +diff2status() +{ + local diff tmp status summary + + diff=$1 + if ! expr "$diff" : 'D[1-9][0-9]*$' >/dev/null; then + err "invalid diff ID $diff" + fi + + tmp=$(mktemp) + echo '{"names":["'$diff'"]}' | + arc call-conduit -- phid.lookup > $tmp + status=$(jq -r "select(.response != []) | .response.${diff}.status" < $tmp) + summary=$(jq -r "select(.response != []) | + .response.${diff}.fullName" < $tmp) + printf "%-14s %s\n" "${status}" "${summary}" +} + +log2diff() +{ + local diff + + diff=$(git show -s --format=%B $commit | + sed -nE '/^Differential Revision:[[:space:]]+(https:\/\/reviews.freebsd.org\/)?(D[0-9]+)$/{s//\2/;p;}') + if [ -n "$diff" ] && [ $(echo "$diff" | wc -l) -eq 1 ]; then + echo $diff + else + echo + fi +} + +commit2diff() +{ + local commit diff title + + commit=$1 + + # First, look for a valid differential reference in the commit + # log. + diff=$(log2diff $commit) + if [ -n "$diff" ]; then + echo $diff + return + fi + + # Second, search the open reviews returned by 'arc list' looking + # for a subject match. + title=$(git show -s --format=%s $commit) + diff=$(arc list | fgrep "$title" | egrep -o 'D[1-9][0-9]*:' | tr -d ':') + if [ -z "$diff" ]; then + err "could not find review for '${title}'" + elif [ $(echo "$diff" | wc -l) -ne 1 ]; then + err "found multiple reviews with the same title" + fi + + echo $diff +} + +create_one_review() +{ + local childphid commit dir doprompt msg parent parentphid reviewers + local subscribers + + commit=$1 + reviewers=$2 + subscribers=$3 + parent=$4 + doprompt=$5 + + if [ "$doprompt" ] && ! show_and_prompt $commit; then + return 1 + fi + + git checkout -q $commit + + dir=$(git rev-parse --git-dir)/arc + mkdir -p "$dir" + + msg=${dir}/create-message + git show -s --format='%B' $commit > $msg + printf "\nTest Plan:\n" >> $msg + printf "\nReviewers:\n" >> $msg + printf "${reviewers}\n" >> $msg + printf "\nSubscribers:\n" >> $msg + printf "${subscribers}\n" >> $msg + + yes | env EDITOR=true \ + arc diff --never-apply-patches --create --allow-untracked $BROWSE HEAD~ + [ $? -eq 0 ] || err "could not create Phabricator diff" + + if [ -n "$parent" ]; then + diff=$(commit2diff $commit) + [ -n "$diff" ] || err "failed to look up review ID for $commit" + + childphid=$(diff2phid $diff) + parentphid=$(diff2phid $parent) + echo '{ + "objectIdentifier": "'${childphid}'", + "transactions": [ + { + "type": "parents.add", + "value": ["'${parentphid}'"] + } + ]}' | + arc call-conduit -- differential.revision.edit >&3 + fi + return 0 +} + +# Get a list of reviewers who accepted the specified diff. +diff2reviewers() +{ + local diff phid reviewid userids + + diff=$1 + reviewid=$(diff2phid $diff) + userids=$( \ + echo '{ + "constraints": {"phids": ["'$reviewid'"]}, + "attachments": {"reviewers": true} + }' | + arc call-conduit -- differential.revision.search | + jq '.response.data[0].attachments.reviewers.reviewers[] | select(.status == "accepted").reviewerPHID') + if [ -n "$userids" ]; then + echo '{ + "constraints": {"phids": ['$(echo -n $userids | tr '[:space:]' ',')']} + }' | + arc call-conduit -- user.search | + jq -r '.response.data[].fields.username' + fi +} + +prompt() +{ + local resp + + if [ "$ASSUME_YES" ]; then + return 1 + fi + + printf "\nDoes this look OK? [y/N] " + read resp + + case $resp in + [Yy]) + return 0 + ;; + *) + return 1 + ;; + esac +} + +show_and_prompt() +{ + local commit + + commit=$1 + + git show $commit + prompt +} + +save_head() +{ + local commit orig + + if ! orig=$(git symbolic-ref --short -q HEAD); then + orig=$(git show -s --pretty=%H HEAD) + fi + echo $orig +} + +restore_head() +{ + local orig + + orig=$1 + git checkout -q $orig +} + +build_commit_list() +{ + local chash _commits commits + + for chash in $@; do + _commits=$(git rev-parse "${chash}") + if ! git cat-file -e "${chash}"'^{commit}' >/dev/null 2>&1; then + _commits=$(git rev-list $_commits | tail -r) + fi + [ -n "$_commits" ] || err "invalid commit ID ${chash}" + commits="$commits $_commits" + done + echo $commits +} + +gitarc::create() +{ + local commit commits doprompt list o orig prev reviewers + local subscribers + + list= + if eval $(git config --bool --default false --get arc.list); then + list=1 + fi + doprompt=1 + while getopts lr:s: o; do + case "$o" in + l) + list=1 + ;; + r) + reviewers="$OPTARG" + ;; + s) + subscribers="$OPTARG" + ;; + *) + err_usage + ;; + esac + done + shift $((OPTIND-1)) + + commits=$(build_commit_list $@) + + if [ "$list" ]; then + for commit in ${commits}; do + git --no-pager show --oneline --no-patch $commit + done | git_pager + if ! prompt; then + return + fi + doprompt= + fi + + orig=$(save_head) + prev="" + for commit in ${commits}; do + if create_one_review "$commit" "$reviewers" "$subscribers" "$prev" \ + "$doprompt"; then + prev=$(commit2diff "$commit") + else + prev="" + fi + done + restore_head $orig +} + +gitarc::list() +{ + local chash commit commits diff title + + commits=$(build_commit_list $@) + + for commit in $commits; do + chash=$(git show -s --format='%C(auto)%h' $commit) + echo -n "${chash} " + + diff=$(log2diff $commit) + if [ -n "$diff" ]; then + diff2status $diff + continue + fi + + # This does not use commit2diff as it needs to handle errors + # differently and keep the entire status. The extra 'cat' + # after 'fgrep' avoids erroring due to -e. + title=$(git show -s --format=%s $commit) + diff=$(arc list | fgrep "$title" | cat) + if [ -z "$diff" ]; then + echo "No Review : $title" + elif [ $(echo "$diff" | wc -l) -ne 1 ]; then + echo -n "Ambiguous Reviews: " + echo "$diff" | egrep -o 'D[1-9][0-9]*:' | tr -d ':' \ + | paste -sd ',' - | sed 's/,/, /g' + else + echo "$diff" | sed -e 's/^[^ ]* *//' + fi + done +} + +gitarc::patch() +{ + local rev + + if [ $# -eq 0 ]; then + err_usage + fi + + for rev in $@; do + arc patch --skip-dependencies --nocommit --nobranch --force $rev + echo "Applying ${rev}..." + [ $? -eq 0 ] || break + done +} + +gitarc::stage() +{ + local branch commit commits diff reviewers tmp + + branch=main + while getopts b: o; do + case "$o" in + b) + branch="$OPTARG" + ;; + *) + err_usage + ;; + esac + done + shift $((OPTIND-1)) + + commits=$(build_commit_list $@) + + if [ "$branch" = "main" ]; then + git checkout -q main + else + git checkout -q -b ${branch} main + fi + + tmp=$(mktemp) + for commit in $commits; do + git show -s --format=%B $commit > $tmp + diff=$(arc list | fgrep "$(git show -s --format=%s $commit)" | + egrep -o 'D[1-9][0-9]*:' | tr -d ':') + if [ -n "$diff" ]; then + # XXX this leaves an extra newline in some cases. + reviewers=$(diff2reviewers $diff | sed '/^$/d' | paste -sd ',' - | sed 's/,/, /g') + if [ -n "$reviewers" ]; then + printf "Reviewed by:\t${reviewers}\n" >> $tmp + fi + printf "Differential Revision:\thttps://reviews.freebsd.org/${diff}" >> $tmp + fi + if ! git cherry-pick --no-commit ${commit}; then + warn "Failed to apply $(git rev-parse --short ${commit}). Are you staging patches in the wrong order?" + git checkout -f + break + fi + git commit --edit --file $tmp + done +} + +gitarc::update() +{ + local commit diff orig + + commit=$1 + diff=$(commit2diff $commit) + + if ! show_and_prompt $commit; then + return + fi + + orig=$(save_head) + git checkout -q $commit + + # The linter is stupid and applies patches to the working copy. + # This would be tolerable if it didn't try to correct "misspelled" variable + # names. + arc diff --allow-untracked --never-apply-patches --update $diff HEAD~ + + restore_head $orig +} + +set -e + +ASSUME_YES= +if eval $(git config --bool --default false --get arc.assume-yes); then + ASSUME_YES=1 +fi + +VERBOSE= +while getopts vy o; do + case "$o" in + v) + VERBOSE=1 + ;; + y) + ASSUME_YES=1 + ;; + *) + err_usage + ;; + esac +done +shift $((OPTIND-1)) + +[ $# -ge 1 ] || err_usage + +which arc >/dev/null 2>&1 || err "arc is required, install devel/arcanist" +which jq >/dev/null 2>&1 || err "jq is required, install textproc/jq" + +if [ "$VERBOSE" ]; then + exec 3>&1 +else + exec 3> /dev/null +fi + +case "$1" in +create|list|patch|stage|update) + ;; +*) + err_usage + ;; +esac +verb=$1 +shift + +# All subcommands require at least one parameter. +if [ $# -eq 0 ]; then + err_usage +fi + +# Pull in some git helper functions. +git_sh_setup=$(git --exec-path)/git-sh-setup +[ -f "$git_sh_setup" ] || err "cannot find git-sh-setup" +SUBDIRECTORY_OK=y +USAGE= +. "$git_sh_setup" + +# Bail if the working tree is unclean, except for "list" and "patch" +# operations. +case $verb in +list|patch) + ;; +*) + require_clean_work_tree $verb + ;; +esac + +if eval $(git config --bool --default false --get arc.browse); then + BROWSE=--browse +fi + +gitarc::${verb} $@