Skip to content

Commit 836fec0

Browse files
feat: Add PR skip detection with revert strategy and new flags
Add automatic detection of upstream-only PRs (files not present downstream), .upstream-sync-ignore pattern matching, and manual SKIP_PRS/SKIP_COMMITS env vars. Skipped PRs are reverted on top of the full upstream history, preserving all original commit SHAs so git merge-base continues to work on subsequent runs. Revert commits are prefixed with "downstream-only:" for easy identification. Add --new-pr flag to force creating a new PR when lacking permissions to update an existing one, and --branch-suffix=TEXT to disambiguate branch names. Fix macOS compatibility: replace GNU sed multi-line join with portable paste+sed, and replace grep -oP with sed equivalents. Generated-by: Cursor
1 parent 029fbef commit 836fec0

1 file changed

Lines changed: 59 additions & 50 deletions

File tree

hack/upstream-sync.sh

Lines changed: 59 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
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
#
@@ -36,10 +39,17 @@
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 ---
7686
DRY_RUN=false
7787
KEEP_WORKTREE=false
88+
FORCE_NEW_PR=false
89+
BRANCH_SUFFIX=""
7890
for 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
8498
done
@@ -130,7 +144,7 @@ MERGE_BASE=""
130144
UPSTREAM_HEAD=""
131145
FILTERED_PRS=""
132146
BUG_LIST=""
133-
SYNC_COMMITS=""
147+
REVERT_COMMITS=""
134148
SKIPPED_PRS="[]"
135149
HAS_SKIPS=false
136150
CONFLICT_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

457471
check_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

Comments
 (0)