Skip to content

Fix inter-branch merge PRs silently going stale after the first run - #16673

Draft
PureWeen wants to merge 5 commits into
mainfrom
fix/inter-branch-merge-missing-merge
Draft

Fix inter-branch merge PRs silently going stale after the first run#16673
PureWeen wants to merge 5 commits into
mainfrom
fix/inter-branch-merge-missing-merge

Conversation

@PureWeen

@PureWeen PureWeen commented Apr 2, 2026

Copy link
Copy Markdown
Member

Problem

An inter-branch merge PR is updated exactly once and then silently stops updating. Every subsequent run is dropped without failing the job.

Each run rebuilds the merge branch from scratch:

git checkout -B $mergeBranchName                    # from the source branch tip
ResetFilesToTargetBranch $patterns $MergeToBranch   # NEW commit, NEW sha, every run
git push origin $mergeBranchName:$mergeBranchName   # plain push, no force

Whenever the pushed branch is not an ancestor of what the next run builds, the push cannot fast-forward and is rejected:

git push origin merge/main-to-net11.0:merge/main-to-net11.0
 ! [rejected]  merge/main-to-net11.0 -> merge/main-to-net11.0 (non-fast-forward)
The existing PR branch 'merge/main-to-net11.0' has diverged and cannot be fast-forwarded;
leaving the existing PR unchanged.

The job reports success, and repos using -QuietComments get no PR comment either, so there is no signal at all. The PR just quietly goes stale until someone notices and merges by hand.

This is not new behavior from #17091 — the previous code also plain-pushed and only logged a warning. #17091 made the skip explicit and benign, which is correct given the branch it was handed, but the branch should not have been non-fast-forwardable in the first place.

Two ways a branch becomes non-fast-forwardable

1. ResetToTargetPaths is configured. The Reset files to <target> commit is regenerated every run, so its parent is the source branch tip and it is never a descendant of the commit pushed last time. The branch therefore can never fast-forward — this fires on every single run.

Two runs 45 seconds apart in dotnet/maui, from the same main tip, show it clearly: the first pushed reset commit 177d5e64, the second built b99c21d027 and was rejected. The PR needed manual merges on Jul 24, Jul 25 (#36786) and Jul 27 to stay current.

2. Someone pushes a commit to the merge branch. This affects repos that do not use ResetToTargetPaths. Resolving conflicts by pushing to the merge branch is exactly what the PR body this script generates instructs contributors to do. Once that commit exists the branch has diverged from the source tip, and every later run is rejected the same way. Verified end to end: with no ResetToTargetPaths anywhere, a human commit on the branch causes the next source commit to never reach the PR.

Nothing is destroyed in either case — the push is rejected rather than forced, so work already on the branch survives. What is lost is every subsequent source commit, silently and indefinitely.

Fix

When an open PR already exists for the merge branch and that branch has diverged from the source tip, merge the source branch into that branch instead of recreating it:

if ($matchingPr -and (RemoteBranchExists $remoteName $mergeBranchName)) {
    git fetch origin refs/heads/$mergeBranchName:refs/remotes/origin/$mergeBranchName

    # Nothing on the branch to preserve? Recreate it from the tip, exactly as before.
    git merge-base --is-ancestor refs/remotes/origin/$mergeBranchName refs/remotes/origin/$MergeFromBranch
    if ($LASTEXITCODE -eq 1) {
        git checkout -B $mergeBranchName refs/remotes/origin/$mergeBranchName
        git merge --no-edit refs/remotes/origin/$MergeFromBranch
    }
}
  • The push fast-forwards, so the PR keeps updating.
  • Conflict resolutions pushed to the PR branch by hand are preserved rather than discarded.
  • The merge-base --is-ancestor gate keeps the blast radius small. When the branch has not diverged there is nothing on it to preserve, so the script recreates it from the source tip and the run is byte-identical to today. Only genuinely diverged branches take the new path.
  • No conflicts are auto-resolved outside ResetToTargetPaths. No -X ours, no -X theirs. If any conflicted file falls outside the configured patterns, the merge is aborted and the code falls back to the previous behavior — the push is rejected and the PR is left untouched for a human, exactly as today.
  • The in-place update only happens when an open PR is found, so a leftover branch from an already-merged PR is still replaced rather than resurrected.
  • No force-push, no --force-with-lease. The existing-PR path stays a plain fast-forward push.

RemoteBranchExists throws on git ls-remote failure rather than assuming "no branch", so an auth or network blip cannot cause the branch to be silently recreated. The fetch is allowed to fail only when a re-query confirms the branch was deleted between the check and the fetch; auth and network failures still fail the run.

Conflicts inside ResetToTargetPaths

Merging the source branch in conflicts whenever the source branch changes a file the reset commit pinned to the target branch. In dotnet/maui that is eng/Versions.props, which dependency flow changes constantly — so without handling this, the merge would abort on most runs and the PR would still go stale.

Those files are not conflicts anyone adjudicates. ResetFilesToTargetBranch runs immediately afterwards and overwrites those exact paths with the target branch's content on every run, independent of how the merge turned out. The final content is therefore already deterministic and policy-driven; a human resolving the conflict by hand could not arrive at a different answer.

TryResolveResetPathConflicts takes the target branch's version for conflicted files covered by the configured patterns and lets the merge complete. It refuses to resolve, and the merge is aborted, when:

  • any conflicted file falls outside the patterns (the uncovered paths are logged by name),
  • taking the target's version fails for any file — e.g. the file was renamed into a covered glob and does not exist on the target branch,
  • any conflict remains after resolving,
  • the patterns use pathspec magic (anything starting with :, such as :(attr:...)), which can select different files depending on repository state.

The resulting merge commit still carries git's own # Conflicts: note in its message, so the conflict remains visible in history.

Testing

An end-to-end harness runs the real script against real git repositories with a local bare repo as the remote and the GitHub API mocked.

The bug reproduces against unmodified main — same rejection message as the MAUI production log, and the new source commit never reaches the PR.

Scenario main this PR
New source commits reach an existing PR
Source changes a ResetToTargetPaths file → PR still updates
Human commit on the branch, no ResetToTargetPaths → PR still updates
Human conflict-resolution commit preserved
Undiverged branch → takes the pre-existing rebuild path, no merge commit
Conflict outside the patterns → abort, PR untouched, job succeeds
A stale or aborted merge emits an Actions annotation (survives -QuietComments)
Conflict inside and outside → abort, nothing resolved
No merge left in progress after abort
Without ResetToTargetPaths → still fast-forwards
No open PR → stale branch replaced, not resurrected

30/30 assertions pass on this branch; main fails 9.

@PureWeen
PureWeen force-pushed the fix/inter-branch-merge-missing-merge branch 3 times, most recently from 6d20d47 to a0f3105 Compare April 2, 2026 20:43
@ViktorHofer

Copy link
Copy Markdown
Member

@mmitche PTAL - I noticed myself that ResetToTargetPaths doesn't fully work yet.

# Merge source branch. Use -X theirs to auto-resolve conflicts in favor of
# the source branch, since ResetToTargetPaths will overwrite target-wins files
# in the next step anyway.
Invoke-Block { & git merge --no-ff "origin/$MergeFromBranch" -X theirs -m "Merge branch '$MergeFromBranch' into $MergeToBranch" }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is correct. ResetToTargetPaths doesn't overwrite all possible files from the target, only those that are specified in the pattern. This merges in the source branch and auto-resolves conflicts, which may not be the correct behavior as some of those files may not be in the ResetToTargetPaths list.

@PureWeen
PureWeen force-pushed the fix/inter-branch-merge-missing-merge branch from 0b75f0b to 3e898de Compare April 24, 2026 16:57
}

if ($outsidePatternConflicts.Count -gt 0) {
Write-Host -f Yellow "WARNING: The following conflicting files are NOT in ResetToTargetPaths and will be auto-resolved in favor of '$MergeFromBranch':"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is good enough. Teams aren't going to review this close enough to catch issues.

@mmitche mmitche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the same problem where the changed files is listed incorrectly, right? When I did research into this the other week, basically the conclusion I got is that this a GitHub UX issue primarily, and if you attempt to resolve on the client side via merge prior to push (since you can't partially resolve), then you're just inviting the possibility of hiding conflicts.

I don't think that's an acceptable tradeoff.

@PureWeen
PureWeen force-pushed the fix/inter-branch-merge-missing-merge branch from 3e898de to 3e39955 Compare April 24, 2026 19:24
@PureWeen
PureWeen marked this pull request as draft April 24, 2026 21:15
Copilot AI review requested due to automatic review settings May 18, 2026 18:22
@PureWeen
PureWeen force-pushed the fix/inter-branch-merge-missing-merge branch from 2a54873 to 8293b4b Compare May 18, 2026 18:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR rewrites the inter-branch-merge.ps1 automation so that when ResetToTargetPaths is configured, the bot attempts to produce a real two-parent merge commit (target ← source) instead of a source-only branch with files later overwritten. It also adds safety guards around force-pushes so the bot does not overwrite manual pushes to the PR branch, and surfaces the chosen merge path in the PR update comment.

Changes:

  • Adds a clean-merge attempt rooted on the target branch (with source merged in) when ResetToTargetPaths is set, falling back to the original source-only branch on any conflict.
  • Introduces Get-NonBotExtraCommits plus a refreshed-fetch / ls-remote check before force-pushing to avoid clobbering human commits, and updates the PR comment to describe which merge path was taken.
  • Moves the git bot identity configuration out of ResetFilesToTargetBranch to the caller, and trims/filters the ResetToTargetPaths pattern list.
Show a summary per file
File Description
.github/workflows/scripts/inter-branch-merge.ps1 Implements the three-path (PR description) / two-path (actual code) merge strategy, adds force-push safety guards via Get-NonBotExtraCommits, and rewrites the PR update comment to indicate which path was taken.

Copilot's findings

  • Files reviewed: 1/1 changed files
  • Comments generated: 4

Comment on lines +270 to +302
# Try a clean merge. We do NOT pass -X ours / -X theirs anywhere in this
# script — any conflict must surface to the reviewer via the source-only
# fallback below.
$mergeOutput = & git merge --no-ff "origin/$MergeFromBranch" -m "Merge branch '$MergeFromBranch' into $MergeToBranch" 2>&1
$mergeExitCode = $LASTEXITCODE

# Always log merge output for CI diagnostics
if ($mergeOutput) {
$mergeOutput | Write-Host
}

if ($mergeExitCode -eq 0) {
$createdMergeCommit = $true
} else {
# Capture conflict file list before aborting so we can surface it.
[string[]] $conflictFiles = & git -c core.quotePath=false diff --name-only --diff-filter=U

# Abort the conflicted merge before proceeding.
# Use plain call (not Invoke-Block) because git merge --abort exits 128
# if there is no merge-in-progress (e.g. a non-conflict git failure).
& git merge --abort 2>&1 | Write-Host

if (-not $conflictFiles -or $conflictFiles.Count -eq 0) {
Write-Host -f Yellow "Merge failed with exit code $mergeExitCode but no conflicts were detected."
Write-Host -f Yellow "Falling back to source-only branch."
} else {
Write-Host -f Yellow "Merge produced conflicts in the following files:"
$conflictFiles | % { Write-Host -f Yellow " - $_" }
Write-Host -f Yellow "Falling back to source-only branch so GitHub surfaces these conflicts in the PR."
}

Invoke-Block { & git checkout -B $mergeBranchName "origin/$MergeFromBranch" }
}
Comment on lines +125 to +138
$botEmail = '41898282+github-actions[bot]@users.noreply.github.com'
$nonBot = @()
foreach ($sha in $extraShas) {
$authorEmail = & git show -s --format='%ae' $sha 2>$null
if ($LASTEXITCODE -ne 0 -or -not $authorEmail) {
# Couldn't read commit metadata — be conservative and treat as non-bot.
$nonBot += $sha
continue
}
if ($authorEmail.Trim() -ne $botEmail) {
$nonBot += $sha
}
}
return $nonBot
Invoke-Block { & git checkout -B $mergeBranchName "origin/$MergeFromBranch" }
}

ResetFilesToTargetBranch $patterns $MergeToBranch
Comment on lines +391 to +408
} else {
# Try non-force push first. If it fails (e.g. remote diverged from
# a previous merge-commit run), retry with --force after checking
# for human-pushed commits (same guard as the merge-commit path).
& git push $remoteName "${mergeBranchName}:${mergeBranchName}" 2>&1 | Write-Host
if ($LASTEXITCODE -ne 0) {
if ($remoteBranchExists) {
[string[]] $extraCommits = Get-NonBotExtraCommits $mergeBranchName "origin/$mergeBranchName"
if ($extraCommits -and $extraCommits.Count -gt 0) {
Write-Warning "Remote branch '$mergeBranchName' has $($extraCommits.Count) non-bot commit(s) not in the local branch. Skipping force push to avoid overwriting manual changes."
$extraCommits | % { Write-Warning " $_" }
throw "Remote branch has unmerged human commits"
}
}
Write-Host "Non-force push failed (likely diverged history). Retrying with --force..."
Invoke-Block { & git push --force $remoteName "${mergeBranchName}:${mergeBranchName}" }
}
}
With ResetToTargetPaths configured, the merge branch is rebuilt from the
source branch tip on every run and the "Reset files to <target>" commit is
regenerated with a new SHA. The resulting branch is never a descendant of
what was pushed on the previous run, so `git push` is rejected as
non-fast-forward and the open PR silently stops being updated -- permanently,
since every later run reproduces the same non-descendant history.

Observed in dotnet/maui, where the PR had to be manually merged three times
in four days to keep it current:

    git push origin merge/main-to-net11.0:merge/main-to-net11.0
     ! [rejected]  merge/main-to-net11.0 -> merge/main-to-net11.0 (non-fast-forward)
    The existing PR branch 'merge/main-to-net11.0' has diverged and cannot be
    fast-forwarded; leaving the existing PR unchanged.

The job reports success, and repos using -QuietComments get no notification
at all, so the PR just quietly goes stale.

When an open PR already exists for the merge branch, merge the source branch
into that branch rather than recreating it. The push then fast-forwards, and
conflict resolutions pushed to the PR branch by hand are preserved instead of
being discarded. If that merge conflicts, abort and fall back to the previous
behavior: the push is rejected and the PR is left untouched for someone to
resolve by hand. No -X ours/-X theirs, so conflicts are never auto-resolved.

The branch is only updated in place when an open PR is found, so a leftover
branch from an already-merged PR is still replaced rather than resurrected.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 48123697-9362-44e7-8e77-5e94353630fb
Copilot AI review requested due to automatic review settings July 27, 2026 21:34
@PureWeen
PureWeen force-pushed the fix/inter-branch-merge-missing-merge branch from 69863e5 to 61602df Compare July 27, 2026 21:34
@PureWeen PureWeen changed the title Fix inter-branch-merge to create proper merge commit when using ResetToTargetPaths Fix inter-branch-merge PR silently going stale when using ResetToTargetPaths Jul 27, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 1/1 changed files
  • Comments generated: 1

# branch produces history that is not a descendant of what was pushed on the previous run --
# with ResetToTargetPaths the "Reset files to <target>" commit is regenerated with a new SHA
# every run, so the branch can never fast-forward -- and the push below is rejected as
# non-fast-forward, leaving the PR silently un-updated from then on. Merging into the existing
…incremental merge

Merging the source branch into the existing merge branch conflicts whenever the source
branch changes a file that ResetToTargetPaths pins to the target branch -- for MAUI that
is eng/Versions.props, which dependency flow changes constantly -- so the merge aborted
and the PR still went stale.

Those files are not conflicts anyone resolves: the script overwrites those exact paths
with the target branch's content on every run regardless. Take the target's version for
conflicted files covered by the configured patterns and finish the merge. A single
conflict outside those patterns still aborts the merge and leaves the PR untouched.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 48123697-9362-44e7-8e77-5e94353630fb
Copilot AI review requested due to automatic review settings July 27, 2026 21:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

Comments suppressed due to low confidence (1)

.github/workflows/scripts/inter-branch-merge.ps1:376

  • PR description states that if merging into an existing PR branch conflicts, the merge is aborted and the script falls back to the previous behavior (leaving the PR untouched). The code here adds a new behavior that auto-resolves conflicts when all conflicted files fall under ResetToTargetPaths (by checking out the target branch versions and committing). Please either update the PR description to reflect this new conflict-resolution behavior, or remove this auto-resolve path if the intent is to always require manual resolution on any conflict.
        # No -X ours/-X theirs: nothing outside ResetToTargetPaths is ever auto-resolved.
        $mergeOutput = & git merge --no-edit "refs/remotes/$remoteName/$MergeFromBranch" 2>&1
        $mergeExitCode = $LASTEXITCODE

        if ($mergeOutput) {
            $mergeOutput | Write-Host
        }

        if ($mergeExitCode -eq 0) {
            $updatedExistingBranch = $true
        }
        elseif ($ResetToTargetPaths -and (TryResolveResetPathConflicts ($ResetToTargetPaths -split ";") $MergeToBranch)) {
            # Every conflicted file was one this script overwrites with the target branch's content
            # anyway, and it has been set to that content. Finish the merge.
            Invoke-Block { & git commit --no-edit }
            $updatedExistingBranch = $true
        }
  • Files reviewed: 1/1 changed files
  • Comments generated: 1

Comment on lines +115 to +125
function RemoteBranchExists($remoteName, $branchName) {
$lsRemoteOutput = & git ls-remote --heads $remoteName "refs/heads/$branchName" 2>&1
if ($LASTEXITCODE -ne 0) {
# Fail loudly instead of assuming the branch is missing: treating an auth or network
# failure as "branch does not exist" would silently recreate the branch and discard
# whatever is already on the PR.
throw "Failed to query '$remoteName' for branch '$branchName'. Output: $lsRemoteOutput"
}

return [bool]$lsRemoteOutput
}
Copilot AI review requested due to automatic review settings July 27, 2026 21:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

Pathspec magic such as :(attr:...) selects files based on repository state, so the set
of files a pattern covers can change as a result of resolving an earlier file. Which
files get resolved would then no longer match which files the reset overwrites. Only
ordinary paths and globs are safe to reason about, so anything else falls back to
leaving the merge to a human.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 48123697-9362-44e7-8e77-5e94353630fb
Copilot AI review requested due to automatic review settings July 27, 2026 21:56
@PureWeen
PureWeen force-pushed the fix/inter-branch-merge-missing-merge branch from 0b1afe6 to cd630ad Compare July 27, 2026 21:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

Comments suppressed due to low confidence (1)

.github/workflows/scripts/inter-branch-merge.ps1:122

  • RemoteBranchExists throws an error that interpolates $lsRemoteOutput directly. Since git ls-remote output is typically an array of lines, string interpolation can collapse to System.Object[], making the failure hard to diagnose. Convert the output to a string before including it in the exception message.
function RemoteBranchExists($remoteName, $branchName) {
    $lsRemoteOutput = & git ls-remote --heads $remoteName "refs/heads/$branchName" 2>&1
    if ($LASTEXITCODE -ne 0) {
        # Fail loudly instead of assuming the branch is missing: treating an auth or network
        # failure as "branch does not exist" would silently recreate the branch and discard
        # whatever is already on the PR.
        throw "Failed to query '$remoteName' for branch '$branchName'. Output: $lsRemoteOutput"
    }
  • Files reviewed: 1/1 changed files
  • Comments generated: 0 new

Two changes that shrink the blast radius of this fix:

- Gate the incremental merge on `git merge-base --is-ancestor`. When the
  merge branch is still an ancestor of the source tip there is nothing on it
  to preserve, so the script recreates it from the tip exactly as it always
  has. The overwhelming majority of runs now take a byte-identical code path
  to the one they took before this change; only genuinely diverged branches
  take the new path.

- Fetching the merge branch is no longer an `Invoke-Block`. The branch can be
  deleted between the existence check and the fetch, and a hard failure there
  would fail the job. The failure is only treated as "deleted" when the remote
  confirms the branch is gone, so auth and network failures still fail loudly.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 48123697-9362-44e7-8e77-5e94353630fb
Copilot AI review requested due to automatic review settings July 28, 2026 02:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 1/1 changed files
  • Comments generated: 0 new

@PureWeen PureWeen changed the title Fix inter-branch-merge PR silently going stale when using ResetToTargetPaths Fix inter-branch merge PRs silently going stale after the first run Jul 28, 2026
A conflict outside ResetToTargetPaths aborts the merge, recreates the branch
from the source tip, and the push is then rejected as a non-fast-forward. That
rejection is deliberately classified as benign so the job stays green, and the
repos most likely to hit it run with -QuietComments, which suppresses the PR
comment. The only remaining signal was a log line nobody reads.

Emit `::warning::` annotations at both exits. They are runner directives rather
than API calls, so they survive -QuietComments and show up on the run summary,
which makes a stuck PR visible without failing the job.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 48123697-9362-44e7-8e77-5e94353630fb
Copilot AI review requested due to automatic review settings July 28, 2026 17:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

Comments suppressed due to low confidence (2)

.github/workflows/scripts/inter-branch-merge.ps1:124

  • RemoteBranchExists treats any non-empty ls-remote output as proof the branch exists, but the command captures stderr (2>&1). If git emits warnings on stderr (e.g. redirects), this can return true even when the ref is missing, leading to an incorrect fetch/throw path.
    return [bool]$lsRemoteOutput

.github/workflows/scripts/inter-branch-merge.ps1:435

  • The merge-into-existing-branch path treats any non-zero git merge exit code as a conflict scenario and continues (green) after aborting. If git merge fails for a non-conflict reason (bad ref, repo state, etc.), this would silently leave the PR stale instead of failing the workflow.
            else {
                # Abort and fall back to recreating the branch from the source tip. That is what
                # this script did before this branch existed: the push below is rejected as
                # non-fast-forward and the existing PR is left untouched for manual resolution.
                # Plain call, not Invoke-Block: `git merge --abort` exits non-zero when the merge
  • Files reviewed: 1/1 changed files
  • Comments generated: 0 new

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants