88# a downstream PR with those references in the title.
99#
1010# When all PRs can be taken as-is, the sync branch points directly at
11- # upstream HEAD (fast path). When some PRs are skipped, the script
12- # cherry-picks only the selected merge commits onto the downstream branch.
11+ # upstream HEAD (fast path). When some PRs must be skipped, the full
12+ # upstream history is pushed and revert commits are added on top for
13+ # each skipped PR, preserving all original commit SHAs (required so
14+ # that git merge-base can find the correct sync point on the next run).
15+ #
1316# Merge conflicts are detected and reported in the PR body without
1417# attempting automatic resolution.
1518#
3639# ./hack/upstream-sync.sh
3740#
3841# Flags:
39- # --dry-run Run all read-only steps (fetch, analyze, log) but skip
40- # pushing branches and creating/updating PRs.
41- # --keep-worktree Do not remove the temporary git worktree after pushing.
42- # Useful for inspecting or fixing conflicts locally.
42+ # --dry-run Run all read-only steps (fetch, analyze, log) but
43+ # skip pushing branches and creating/updating PRs.
44+ # --keep-worktree Do not remove the temporary git worktree after
45+ # pushing. Useful for inspecting or fixing conflicts.
46+ # --new-pr Skip existing PR detection and always create a new
47+ # PR/branch. Useful when you lack permission to update
48+ # an existing PR.
49+ # --branch-suffix=TEXT Append -TEXT to the generated branch name (e.g.
50+ # --branch-suffix=v2 → upstream-sync-2026-04-07-v2).
51+ # Handy with --new-pr when the date-based name already
52+ # exists on the remote.
4353#
4454# Environment variables:
4555# UPSTREAM_REMOTE Git remote name for the upstream repo (default: upstream)
@@ -75,10 +85,14 @@ set -euo pipefail
7585# --- Parse flags ---
7686DRY_RUN=false
7787KEEP_WORKTREE=false
88+ FORCE_NEW_PR=false
89+ BRANCH_SUFFIX=" "
7890for arg in " $@ " ; do
7991 case " $arg " in
8092 --dry-run) DRY_RUN=true ;;
8193 --keep-worktree) KEEP_WORKTREE=true ;;
94+ --new-pr) FORCE_NEW_PR=true ;;
95+ --branch-suffix=* ) BRANCH_SUFFIX=" ${arg#* =} " ;;
8296 * ) echo " Unknown argument: $arg " >&2 ; exit 1 ;;
8397 esac
8498done
@@ -130,7 +144,7 @@ MERGE_BASE=""
130144UPSTREAM_HEAD=" "
131145FILTERED_PRS=" "
132146BUG_LIST=" "
133- SYNC_COMMITS =" "
147+ REVERT_COMMITS =" "
134148SKIPPED_PRS=" []"
135149HAS_SKIPS=false
136150CONFLICT_FILES=" "
@@ -310,8 +324,8 @@ filter_skipped() {
310324 if [ -n " $skip_reason " ]; then
311325 log " SKIP #${pr_number} - ${pr_title} (${skip_reason} )"
312326 skipped_prs=$( echo " $skipped_prs " | jq \
313- --arg n " $pr_number " --arg t " $pr_title " --arg r " $skip_reason " \
314- ' . + [{"number":($n|tonumber),"title":$t,"reason":$r}]' )
327+ --arg n " $pr_number " --arg t " $pr_title " --arg r " $skip_reason " --arg s " $pr_sha " \
328+ ' . + [{"number":($n|tonumber),"title":$t,"reason":$r,"sha":$s }]' )
315329 HAS_SKIPS=true
316330 else
317331 kept_prs=$( echo " $kept_prs " | jq --argjson pr " $( echo " $FILTERED_PRS " | jq " .[$i ]" ) " ' . + [$pr]' )
@@ -322,7 +336,7 @@ filter_skipped() {
322336 FILTERED_PRS=" $kept_prs "
323337
324338 if [ " $HAS_SKIPS " = true ]; then
325- SYNC_COMMITS =$( echo " $FILTERED_PRS " | jq -r ' .[].mergeCommit.oid ' )
339+ REVERT_COMMITS =$( echo " $SKIPPED_PRS " | jq -r ' .[].sha ' )
326340 local kept_count skipped_count
327341 kept_count=$( echo " $FILTERED_PRS " | jq ' length' )
328342 skipped_count=$( echo " $SKIPPED_PRS " | jq ' length' )
@@ -345,7 +359,7 @@ scan_bugs() {
345359 " ${MERGE_BASE} ..${UPSTREAM_REMOTE} /${UPSTREAM_BRANCH} " | grep -oE " $BUG_PATTERN " || true)
346360
347361 BUG_LIST=$( printf ' %s\n' " $bugs_from_titles " " $bugs_from_bodies " " $bugs_from_commits " " $bugs_from_trailers " \
348- | grep -E " ^${BUG_PATTERN} $" | sort -u | sed ' :a;N;$!ba;s/\n /, /g' || true)
362+ | grep -E " ^${BUG_PATTERN} $" | sort -u | paste -sd ' , ' - | sed ' s/, /, /g' || true)
349363
350364 if [ -n " $BUG_LIST " ]; then
351365 log " Bugs found:"
@@ -372,7 +386,7 @@ scan_bugs_from_jira() {
372386 known_pr_numbers=$( echo " $FILTERED_PRS " | jq -r ' .[].number' 2> /dev/null | sort -u)
373387 if [ -z " $known_pr_numbers " ]; then
374388 known_pr_numbers=$( git log --format=%s " ${MERGE_BASE} ..${UPSTREAM_REMOTE} /${UPSTREAM_BRANCH} " \
375- | grep -oP ' (?<= Merge pull request #)\d+ ' | sort -u || true)
389+ | sed -n ' s/.* Merge pull request #\([0-9]*\).*/\1/p ' | sort -u || true)
376390 fi
377391
378392 if [ -z " $known_pr_numbers " ]; then
@@ -420,7 +434,7 @@ scan_bugs_from_jira() {
420434
421435 for url in $pr_urls ; do
422436 local pr_num
423- pr_num=$( echo " $url " | grep -oP ' (?<=/ pull/)\d+ ' )
437+ pr_num=$( echo " $url " | sed -n ' s/.*\/ pull\/\([0-9]*\).*/\1/p ' )
424438 if echo " $known_pr_numbers " | grep -qx " $pr_num " ; then
425439 log " ${key} linked to upstream PR #${pr_num} "
426440 jira_bugs+=" ${key} " $' \n '
@@ -451,10 +465,15 @@ scan_bugs_from_jira() {
451465 existing_bugs=$( echo " $BUG_LIST " | tr ' ,' ' \n' | sed ' s/^ //' )
452466 fi
453467 BUG_LIST=$( printf ' %s\n%s' " $existing_bugs " " $unique_jira_bugs " \
454- | grep -E " ^${BUG_PATTERN} $" | sort -u | sed ' :a;N;$!ba;s/\n /, /g' || true)
468+ | grep -E " ^${BUG_PATTERN} $" | sort -u | paste -sd ' , ' - | sed ' s/, /, /g' || true)
455469}
456470
457471check_existing_sync_pr () {
472+ if [ " $FORCE_NEW_PR " = true ]; then
473+ log " Skipping existing PR check (--new-pr)"
474+ return 0
475+ fi
476+
458477 log " Checking for existing open sync PR (branch prefix: ${SYNC_BRANCH_PREFIX} )..."
459478
460479 local existing_pr
@@ -515,22 +534,28 @@ push_sync_branch() {
515534 force_flag=" --force"
516535 else
517536 branch_name=" ${SYNC_BRANCH_PREFIX} $( date +%Y-%m-%d) "
537+ if [ -n " $BRANCH_SUFFIX " ]; then
538+ branch_name=" ${branch_name} -${BRANCH_SUFFIX} "
539+ fi
518540 EXISTING_BRANCH=" $branch_name "
519541 fi
520542
521543 worktree_dir=" ${WORKTREE_ROOT} /${branch_name} "
522544
545+ local skip_count=0
523546 if [ " $HAS_SKIPS " = true ]; then
524- log " Skipped PRs detected: cherry-picking selected commits onto ${DOWNSTREAM_REMOTE} /${DOWNSTREAM_BRANCH} ..."
525- else
526- log " Pushing ${branch_name} to ${PUSH_REMOTE} (upstream HEAD: ${UPSTREAM_HEAD} )..."
547+ skip_count=$( echo " $REVERT_COMMITS " | wc -w | tr -d ' ' )
548+ fi
549+
550+ log " Pushing ${branch_name} to ${PUSH_REMOTE} (upstream HEAD: ${UPSTREAM_HEAD} )..."
551+ if [ " $HAS_SKIPS " = true ]; then
552+ log " Will revert ${skip_count} skipped merge commit(s)"
527553 fi
528554
529555 if [ " $DRY_RUN " = true ]; then
556+ log " Would create worktree at ${worktree_dir} , push ${branch_name} to ${PUSH_REMOTE} "
530557 if [ " $HAS_SKIPS " = true ]; then
531- log " Would cherry-pick $( echo " $SYNC_COMMITS " | wc -w | tr -d ' ' ) commits onto ${DOWNSTREAM_REMOTE} /${DOWNSTREAM_BRANCH} "
532- else
533- log " Would create worktree at ${worktree_dir} , push ${branch_name} to ${PUSH_REMOTE} "
558+ log " Would revert: $( echo " $REVERT_COMMITS " | tr ' \n' ' ' ) "
534559 fi
535560 if [ " $KEEP_WORKTREE " = true ]; then
536561 log " Worktree would be kept at ${worktree_dir} "
@@ -540,47 +565,31 @@ push_sync_branch() {
540565
541566 cleanup_worktree " $worktree_dir " " $branch_name "
542567
543- if [ " $HAS_SKIPS " = true ]; then
544- git branch " $branch_name " " ${DOWNSTREAM_REMOTE} /${DOWNSTREAM_BRANCH} "
545- git worktree add " $worktree_dir " " $branch_name "
568+ git branch " $branch_name " " $UPSTREAM_HEAD "
569+ git worktree add " $worktree_dir " " $branch_name "
546570
571+ if [ " $HAS_SKIPS " = true ]; then
547572 local original_dir
548573 original_dir=$( pwd)
549574 cd " $worktree_dir "
550575
551- local cherry_ok=true
552- for sha in $SYNC_COMMITS ; do
553- local cp_flags=" "
554- local parent_count
555- parent_count=$( git cat-file -p " $sha " | grep -c ' ^parent' || echo 1)
556- if [ " $parent_count " -gt 1 ]; then
557- cp_flags=" -m 1"
558- fi
559-
560- if ! git cherry-pick $cp_flags --no-commit " $sha " 2> /dev/null; then
561- log " WARNING: Cherry-pick of ${sha} had conflicts"
562- local conflicting
563- conflicting=$( git diff --name-only --diff-filter=U 2> /dev/null || true)
564- if [ -n " $conflicting " ]; then
565- CONFLICT_FILES=$( printf ' %s\n%s' " $CONFLICT_FILES " " $conflicting " | sort -u | sed ' /^$/d' )
566- echo " $conflicting " | while IFS= read -r f; do
567- log " conflict: $f "
568- done
569- fi
570- git cherry-pick --abort 2> /dev/null || git reset --hard HEAD
571- cherry_ok=false
572- continue
576+ for sha in $REVERT_COMMITS ; do
577+ log " Reverting skipped merge commit ${sha: 0: 9} ..."
578+ if git revert -m 1 --no-commit " $sha " 2> /dev/null; then
579+ local orig_subject
580+ orig_subject=$( git log -1 --format=%s " $sha " )
581+ git commit -m " downstream-only: Revert \" ${orig_subject} \" " 2> /dev/null
582+ else
583+ log " WARNING: Revert of ${sha: 0: 9} had conflicts, aborting revert"
584+ git revert --abort 2> /dev/null || true
573585 fi
574- git commit --no-edit -m " cherry-pick upstream $( git log -1 --format=%s " $sha " ) " 2> /dev/null || true
575586 done
576587
577588 cd " $original_dir "
578- else
579- git branch " $branch_name " " $UPSTREAM_HEAD "
580- git worktree add " $worktree_dir " " $branch_name "
581- detect_conflicts " $worktree_dir "
582589 fi
583590
591+ detect_conflicts " $worktree_dir "
592+
584593 local original_dir
585594 original_dir=$( pwd)
586595 cd " $worktree_dir "
0 commit comments