@@ -21,6 +21,37 @@ source "$SCRIPT_DIR/command_utils.sh"
2121
2222CONFLICT_LABEL=" autorestack-needs-conflict-resolution"
2323
24+ # Machine-readable marker embedded (invisibly) in the conflict comment so the
25+ # conflict-resolved run can recover the exact stack state. We rely on this rather
26+ # than re-deriving it from `gh pr list --head <base>`: when the base is a
27+ # long-lived integration branch that heuristic returns an unrelated ancient merge
28+ # (e.g. a years-old release merge), and it cannot tell whether a human retargeted
29+ # the PR in the meantime.
30+ STATE_MARKER_PREFIX=" <!-- autorestack-state:"
31+
32+ # Args: base-branch target-branch squash-hash. Branch names and hashes contain no
33+ # spaces, so a space-separated key=value list parses back unambiguously.
34+ format_state_marker () {
35+ printf ' %s base=%s target=%s squash=%s -->' \
36+ " $STATE_MARKER_PREFIX " " $1 " " $2 " " $3 "
37+ }
38+
39+ # Echoes the most recent state-marker line found in our PR comments, or nothing.
40+ read_state_marker () {
41+ local PR_BRANCH=" $1 "
42+ gh pr view " $PR_BRANCH " --json comments --jq ' .comments[].body' 2> /dev/null \
43+ | { grep -F " $STATE_MARKER_PREFIX " || true ; } | tail -n1
44+ }
45+
46+ # Args: a marker line. Echoes "base target squash".
47+ parse_state_marker () {
48+ local LINE=" $1 "
49+ printf ' %s %s %s\n' \
50+ " $( sed -n ' s/.* base=\([^ ]*\).*/\1/p' <<< " $LINE" ) " \
51+ " $( sed -n ' s/.* target=\([^ ]*\).*/\1/p' <<< " $LINE" ) " \
52+ " $( sed -n ' s/.* squash=\([^ ]*\).*/\1/p' <<< " $LINE" ) "
53+ }
54+
2455# Allow replacing git and gh
2556[ -v GIT ] && git () { " $GIT " " $@ " ; }
2657[ -v GH ] && gh () { " $GH " " $@ " ; }
@@ -128,6 +159,8 @@ update_direct_target() {
128159 echo ' ```'
129160 echo
130161 echo " Once you push, this action will resume and finish updating this pull request."
162+ echo
163+ format_state_marker " $MERGED_BRANCH " " $TARGET_BRANCH " " $( git rev-parse SQUASH_COMMIT) "
131164 } | log_cmd gh pr comment " $BRANCH " -F -
132165 # Create the label if it doesn't exist, then add it to the PR
133166 gh label create " $CONFLICT_LABEL " --description " PR needs manual conflict resolution" --color " d73a4a" 2> /dev/null || true
@@ -190,54 +223,73 @@ continue_after_resolution() {
190223
191224 echo " Found conflict label on $PR_BRANCH , continuing stack update..."
192225
193- # Get the current base branch (the old base that was kept during conflict)
194- local OLD_BASE
195- OLD_BASE=$( gh pr view " $PR_BRANCH " --json baseRefName --jq ' .baseRefName' )
196- echo " Current base branch: $OLD_BASE "
197-
198226 # The synchronize payload is the child PR, so SQUASH_COMMIT / MERGED_BRANCH /
199227 # TARGET_BRANCH from the original squash-merge run are not in the environment.
200- # Reconstruct them from the merged parent PR: OLD_BASE is the parent branch,
201- # and the merged PR whose head is OLD_BASE gives the new target (its base) and
202- # the squash commit (its merge commit).
203- local NEW_TARGET SQUASH_HASH
204- read -r NEW_TARGET SQUASH_HASH < <( gh pr list --head " $OLD_BASE " --state merged \
205- --json baseRefName,mergeCommit --jq ' .[0] | "\(.baseRefName // "") \(.mergeCommit.oid // "")"' )
206-
207- if [[ -z " $NEW_TARGET " || -z " $SQUASH_HASH " ]]; then
208- echo " ⚠️ Could not find where '$OLD_BASE ' was merged to; skipping base branch and deletion updates"
209- # Don't update base or delete old branch - leave things as they are
210- else
211- echo " Old base '$OLD_BASE ' was merged to '$NEW_TARGET ' as $SQUASH_HASH "
212-
213- # The squash-merge run pushed the base merge and asked the user to resolve
214- # the pre-squash merge, but it never recorded the squash itself. Finish
215- # that now: re-run the same merge sequence as the squash-merge path. With
216- # the user's resolution in place the base merge and pre-squash merge are
217- # no-ops; only the "-s ours" squash record gets applied, keeping the diff
218- # against the new base clean. has_squash_commit makes this idempotent.
219- log_cmd git update-ref SQUASH_COMMIT " $SQUASH_HASH "
220- MERGED_BRANCH=" $OLD_BASE "
221- TARGET_BRANCH=" $NEW_TARGET "
222- if ! update_direct_target " $PR_BRANCH " " $NEW_TARGET " ; then
223- echo " ⚠️ '$PR_BRANCH ' still conflicts; re-posted the conflict comment, will retry on next push"
224- return 1
225- fi
226- log_cmd git push origin " $PR_BRANCH "
228+ # Recover them from the marker the squash-merge run left in the conflict
229+ # comment.
230+ local MARKER
231+ MARKER=$( read_state_marker " $PR_BRANCH " )
232+ if [[ -z " $MARKER " ]]; then
233+ echo " ⚠️ No autorestack state marker on $PR_BRANCH ; cannot resume safely. Removing the label."
234+ { echo " ℹ️ autorestack could not find its state marker on this PR, so it will not update the stack automatically. If this PR still needs its base updated, do it manually." ; } \
235+ | log_cmd gh pr comment " $PR_BRANCH " -F -
236+ log_cmd gh pr edit " $PR_BRANCH " --remove-label " $CONFLICT_LABEL "
237+ return
238+ fi
227239
228- # Remove the conflict label
240+ local RECORDED_BASE NEW_TARGET SQUASH_HASH
241+ read -r RECORDED_BASE NEW_TARGET SQUASH_HASH < <( parse_state_marker " $MARKER " )
242+ echo " Recorded state: base=$RECORDED_BASE target=$NEW_TARGET squash=$SQUASH_HASH "
243+
244+ # The base we left the PR on while waiting for conflict resolution was the
245+ # merged parent branch. If it no longer matches, a human retargeted the PR
246+ # (e.g. straight onto the integration branch); we are no longer the authority
247+ # on its base, so we step back without touching the branch. Doing this BEFORE
248+ # any mutation is what stops the failure mode where we pushed a merge built
249+ # against a bogus target and then crashed.
250+ local CURRENT_BASE
251+ CURRENT_BASE=$( gh pr view " $PR_BRANCH " --json baseRefName --jq ' .baseRefName' )
252+ if [[ " $CURRENT_BASE " != " $RECORDED_BASE " ]]; then
253+ echo " ⚠️ Base of $PR_BRANCH changed manually ($RECORDED_BASE -> $CURRENT_BASE ); not updating the stack."
254+ { echo " ℹ️ The base branch of this PR was changed manually, so autorestack stepped back and will not update it automatically." ; } \
255+ | log_cmd gh pr comment " $PR_BRANCH " -F -
229256 log_cmd gh pr edit " $PR_BRANCH " --remove-label " $CONFLICT_LABEL "
257+ return
258+ fi
230259
231- # Update the PR's base branch to the new target
232- log_cmd gh pr edit " $PR_BRANCH " --base " $NEW_TARGET "
260+ # Defense in depth: never act on a target branch that no longer exists.
261+ if ! git rev-parse --verify --quiet " origin/$NEW_TARGET " > /dev/null; then
262+ echo " ⚠️ Recorded target branch '$NEW_TARGET ' no longer exists; leaving $PR_BRANCH untouched."
263+ return
264+ fi
233265
234- # Check if old base branch should be deleted
235- if has_sibling_conflicts " $OLD_BASE " " $PR_BRANCH " ; then
236- echo " ⚠️ Keeping branch '$OLD_BASE ' - still referenced by other conflicted PRs"
237- else
238- echo " Deleting old base branch '$OLD_BASE ' (no other PRs depend on it)"
239- log_cmd git push origin " :$OLD_BASE " || echo " ⚠️ Could not delete '$OLD_BASE ' (may already be deleted)"
240- fi
266+ # The squash-merge run pushed the base merge and asked the user to resolve the
267+ # pre-squash merge, but it never recorded the squash itself. Finish that now:
268+ # re-run the same merge sequence as the squash-merge path. With the user's
269+ # resolution in place the base merge and pre-squash merge are no-ops; only the
270+ # "-s ours" squash record gets applied, keeping the diff against the new base
271+ # clean. has_squash_commit makes this idempotent.
272+ log_cmd git update-ref SQUASH_COMMIT " $SQUASH_HASH "
273+ MERGED_BRANCH=" $RECORDED_BASE "
274+ TARGET_BRANCH=" $NEW_TARGET "
275+ if ! update_direct_target " $PR_BRANCH " " $NEW_TARGET " ; then
276+ echo " ⚠️ '$PR_BRANCH ' still conflicts; re-posted the conflict comment, will retry on next push"
277+ return 1
278+ fi
279+
280+ # Order matters: push the cleaned-up head, then retarget the base, and only
281+ # then drop the label. The retarget is the step that previously failed; if
282+ # anything here fails the label stays, so the next push resumes.
283+ log_cmd git push origin " $PR_BRANCH "
284+ log_cmd gh pr edit " $PR_BRANCH " --base " $NEW_TARGET "
285+ log_cmd gh pr edit " $PR_BRANCH " --remove-label " $CONFLICT_LABEL "
286+
287+ # Check if old base branch should be deleted
288+ if has_sibling_conflicts " $RECORDED_BASE " " $PR_BRANCH " ; then
289+ echo " ⚠️ Keeping branch '$RECORDED_BASE ' - still referenced by other conflicted PRs"
290+ else
291+ echo " Deleting old base branch '$RECORDED_BASE ' (no other PRs depend on it)"
292+ log_cmd git push origin " :$RECORDED_BASE " || echo " ⚠️ Could not delete '$RECORDED_BASE ' (may already be deleted)"
241293 fi
242294}
243295
0 commit comments