Changeset View
Changeset View
Standalone View
Standalone View
tools/tools/git/git-arc.sh
- This file was added.
#!/bin/sh | |||||
# | |||||
# SPDX-License-Identifier: BSD-2-Clause-FreeBSD | |||||
# | |||||
# Copyright (c) 2019-2021 Mark Johnston <markj@FreeBSD.org> | |||||
# Copyright (c) 2021 John Baldwin <jhb@FreeBSD.org> | |||||
# | |||||
# 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] <command> <arguments> | |||||
debdrup: I would recommend s/verb/command/, as that's the syntax used in other places, for example… | |||||
Commands: | |||||
create [-l] [-r <reviewer1>[,<reviewer2>...]] [-s subscriber[,...]] [<commit>|<commit range>] | |||||
list <commit>|<commit range> | |||||
patch <diff1> [<diff2> ...] | |||||
stage [-b branch] [<commit>|<commit range>] | |||||
update <commit> | |||||
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 | |||||
<Make changes to the diff based on reviewer feedback.> | |||||
$ git commit --amend | |||||
$ git arc update HEAD | |||||
<Now that all reviewers are happy, it's time to push.> | |||||
$ git arc stage HEAD | |||||
$ git push freebsd HEAD:main | |||||
Done Inline ActionsProbably s/upstream/freebsd/ here to match our other git docs. jhb: Probably s/upstream/freebsd/ here to match our other git docs. | |||||
Not Done Inline ActionsAgreed. imp: Agreed. | |||||
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 | |||||
# 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} $@ |
I would recommend s/verb/command/, as that's the syntax used in other places, for example camcontrol(8), which uses (sub)commands too.