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