Fix inter-branch merge PRs silently going stale after the first run - #16673
Fix inter-branch merge PRs silently going stale after the first run#16673PureWeen wants to merge 5 commits into
Conversation
6d20d47 to
a0f3105
Compare
|
@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" } |
There was a problem hiding this comment.
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.
0b75f0b to
3e898de
Compare
| } | ||
|
|
||
| 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':" |
There was a problem hiding this comment.
I don't think this is good enough. Teams aren't going to review this close enough to catch issues.
mmitche
left a comment
There was a problem hiding this comment.
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.
3e898de to
3e39955
Compare
2a54873 to
8293b4b
Compare
There was a problem hiding this comment.
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
ResetToTargetPathsis set, falling back to the original source-only branch on any conflict. - Introduces
Get-NonBotExtraCommitsplus a refreshed-fetch /ls-remotecheck 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
ResetFilesToTargetBranchto the caller, and trims/filters theResetToTargetPathspattern 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
| # 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" } | ||
| } |
| $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 |
| } 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
69863e5 to
61602df
Compare
| # 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
There was a problem hiding this comment.
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
| 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 | ||
| } |
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
0b1afe6 to
cd630ad
Compare
There was a problem hiding this comment.
Copilot's findings
Comments suppressed due to low confidence (1)
.github/workflows/scripts/inter-branch-merge.ps1:122
RemoteBranchExiststhrows an error that interpolates$lsRemoteOutputdirectly. Sincegit ls-remoteoutput is typically an array of lines, string interpolation can collapse toSystem.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
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
There was a problem hiding this comment.
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 mergeexit code as a conflict scenario and continues (green) after aborting. Ifgit mergefails 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
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:
Whenever the pushed branch is not an ancestor of what the next run builds, the push cannot fast-forward and is rejected:
The job reports success, and repos using
-QuietCommentsget 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.
ResetToTargetPathsis configured. TheReset 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
maintip, show it clearly: the first pushed reset commit177d5e64, the second builtb99c21d027and 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 noResetToTargetPathsanywhere, 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:
merge-base --is-ancestorgate 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.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.--force-with-lease. The existing-PR path stays a plain fast-forward push.RemoteBranchExiststhrows ongit ls-remotefailure 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
ResetToTargetPathsMerging 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.
ResetFilesToTargetBranchruns 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.TryResolveResetPathConflictstakes 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::, 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.mainResetToTargetPathsfile → PR still updatesResetToTargetPaths→ PR still updates-QuietComments)ResetToTargetPaths→ still fast-forwards30/30 assertions pass on this branch;
mainfails 9.