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 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 ' '{if ($2 == "'"$title"'") print $0}') 426 if [ -z "$diff" ]; then 427 echo "No Review : $title" 428 elif [ "$(echo "$diff" | wc -l)" -ne 1 ]; then 429 echo -n "Ambiguous Reviews: " 430 echo "$diff" | grep -E -o 'D[1-9][0-9]*:' | tr -d ':' \ 431 | paste -sd ',' - | sed 's/,/, /g' 432 else 433 echo "$diff" | sed -e 's/^[^ ]* *//' 434 fi 435 done 436} 437 438gitarc__patch() 439{ 440 local rev 441 442 if [ $# -eq 0 ]; then 443 err_usage 444 fi 445 446 for rev in "$@"; do 447 arc patch --skip-dependencies --nocommit --nobranch --force "$rev" 448 echo "Applying ${rev}..." 449 [ $? -eq 0 ] || break 450 done 451} 452 453gitarc__stage() 454{ 455 local author branch commit commits diff reviewers title tmp 456 457 branch=main 458 while getopts b: o; do 459 case "$o" in 460 b) 461 branch="$OPTARG" 462 ;; 463 *) 464 err_usage 465 ;; 466 esac 467 done 468 shift $((OPTIND-1)) 469 470 commits=$(build_commit_list "$@") 471 472 if [ "$branch" = "main" ]; then 473 git checkout -q main 474 else 475 git checkout -q -b "${branch}" main 476 fi 477 478 tmp=$(mktemp) 479 for commit in $commits; do 480 git show -s --format=%B "$commit" > "$tmp" 481 title=$(git show -s --format=%s "$commit") 482 diff=$(title2diff "$title") 483 if [ -n "$diff" ]; then 484 # XXX this leaves an extra newline in some cases. 485 reviewers=$(diff2reviewers "$diff" | sed '/^$/d' | paste -sd ',' - | sed 's/,/, /g') 486 if [ -n "$reviewers" ]; then 487 printf "Reviewed by:\t%s\n" "${reviewers}" >> "$tmp" 488 fi 489 printf "Differential Revision:\thttps://reviews.freebsd.org/%s" "${diff}" >> "$tmp" 490 fi 491 author=$(git show -s --format='%an <%ae>' "${commit}") 492 if ! git cherry-pick --no-commit "${commit}"; then 493 warn "Failed to apply $(git rev-parse --short "${commit}"). Are you staging patches in the wrong order?" 494 git checkout -f 495 break 496 fi 497 git commit --edit --file "$tmp" --author "${author}" 498 done 499} 500 501gitarc__update() 502{ 503 local commit commits diff 504 505 commits=$(build_commit_list "$@") 506 for commit in ${commits}; do 507 diff=$(commit2diff "$commit") 508 509 if ! show_and_prompt "$commit"; then 510 break 511 fi 512 513 # The linter is stupid and applies patches to the working copy. 514 # This would be tolerable if it didn't try to correct "misspelled" variable 515 # names. 516 arc diff --allow-untracked --never-apply-patches --update "$diff" \ 517 --head "$commit" "${commit}~" 518 done 519} 520 521set -e 522 523ASSUME_YES= 524if [ "$(git config --bool --get arc.assume-yes 2>/dev/null || echo false)" != "false" ]; then 525 ASSUME_YES=1 526fi 527 528VERBOSE= 529while getopts vy o; do 530 case "$o" in 531 v) 532 VERBOSE=1 533 ;; 534 y) 535 ASSUME_YES=1 536 ;; 537 *) 538 err_usage 539 ;; 540 esac 541done 542shift $((OPTIND-1)) 543 544[ $# -ge 1 ] || err_usage 545 546which arc >/dev/null 2>&1 || err "arc is required, install devel/arcanist" 547which jq >/dev/null 2>&1 || err "jq is required, install textproc/jq" 548 549if [ "$VERBOSE" ]; then 550 exec 3>&1 551else 552 exec 3> /dev/null 553fi 554 555case "$1" in 556create|list|patch|stage|update) 557 ;; 558*) 559 err_usage 560 ;; 561esac 562verb=$1 563shift 564 565# All subcommands require at least one parameter. 566if [ $# -eq 0 ]; then 567 err_usage 568fi 569 570# Pull in some git helper functions. 571git_sh_setup=$(git --exec-path)/git-sh-setup 572[ -f "$git_sh_setup" ] || err "cannot find git-sh-setup" 573SUBDIRECTORY_OK=y 574USAGE= 575# shellcheck disable=SC1090 576. "$git_sh_setup" 577 578# git commands use GIT_EDITOR instead of EDITOR, so try to provide consistent 579# behaviour. Ditto for PAGER. This makes git-arc play nicer with editor 580# plugins like vim-fugitive. 581if [ -n "$GIT_EDITOR" ]; then 582 EDITOR=$GIT_EDITOR 583fi 584if [ -n "$GIT_PAGER" ]; then 585 PAGER=$GIT_PAGER 586fi 587 588# Bail if the working tree is unclean, except for "list" and "patch" 589# operations. 590case $verb in 591list|patch) 592 ;; 593*) 594 require_clean_work_tree "$verb" 595 ;; 596esac 597 598if [ "$(git config --bool --get arc.browse 2>/dev/null || echo false)" != "false" ]; then 599 BROWSE=--browse 600fi 601 602gitarc__"${verb}" "$@" 603