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