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