Skip to content
168 changes: 156 additions & 12 deletions .github/workflows/scripts/inter-branch-merge.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,32 @@ function GetCommitterGitHubName($sha) {
return $null
}

function Get-NonBotExtraCommits($localRef, $remoteRef) {
# Returns commit SHAs reachable from $remoteRef but not from $localRef
# that were NOT authored by github-actions[bot]. This lets us safely
# force-push over our own prior bot merge commits while still protecting
# human-pushed commits.
[string[]] $extraShas = & git rev-list "$localRef..$remoteRef" 2>$null
if (-not $extraShas -or $extraShas.Count -eq 0) {
return @()
}

$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
Comment on lines +133 to +146
}

function ResetFilesToTargetBranch($patterns, $targetBranch) {
if (-not $patterns -or $patterns.Count -eq 0) {
return
Expand All @@ -126,10 +152,7 @@ function ResetFilesToTargetBranch($patterns, $targetBranch) {
return
}

# Configure git user for the commit
# Use GitHub Actions bot identity
Invoke-Block { & git config user.name "github-actions[bot]" }
Invoke-Block { & git config user.email "41898282+github-actions[bot]@users.noreply.github.com" }
# Note: git user.name/email must already be configured by the caller

# Track which patterns had changes
$processedPatterns = @()
Expand Down Expand Up @@ -220,13 +243,71 @@ try {
Write-Host $committersList

$mergeBranchName = "merge/$MergeFromBranch-to-$MergeToBranch"
Invoke-Block { & git checkout -B $mergeBranchName }

# Reset specified files to target branch if ResetToTargetPaths is configured
# Track whether we created a merge commit (affects push strategy and PR comment).
# A merge commit is only created when the merge is conflict-free; if there are
# ANY conflicts we fall back to a source-only branch so that GitHub surfaces the
# real conflicts to the reviewer. We deliberately do NOT auto-resolve conflicts
# client-side, even within ResetToTargetPaths patterns, because client-side
# resolution can hide conflicts the reviewer needs to see.
$createdMergeCommit = $false
[string[]] $conflictFiles = @()

# When ResetToTargetPaths is configured, we attempt to create a proper merge commit
# so that the target branch content is included. If the merge has any conflicts we
# fall back to the original source-only behavior so GitHub's merge button surfaces
# them to the reviewer.
if ($ResetToTargetPaths) {
$patterns = $ResetToTargetPaths -split ";"
# Configure git user for the merge commit
Invoke-Block { & git config user.name "github-actions[bot]" }
Invoke-Block { & git config user.email "41898282+github-actions[bot]@users.noreply.github.com" }

$patterns = ($ResetToTargetPaths -split ";") | % { $_.Trim() } | ? { $_ }

# Start from the target branch and merge source into it
Invoke-Block { & git checkout -B $mergeBranchName "origin/$MergeToBranch" }

# 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 +318 to +350

ResetFilesToTargetBranch $patterns $MergeToBranch
}
else {
# Without ResetToTargetPaths, the original behavior is fine: create a branch
# from the source and let GitHub's merge button do the actual merge.
Invoke-Block { & git checkout -B $mergeBranchName "origin/$MergeFromBranch" }
}

$remoteName = 'origin'
$prOwnerName = $RepoOwner
Expand Down Expand Up @@ -277,18 +358,81 @@ try {

try {
if ($PSCmdlet.ShouldProcess("Update remote branch $mergeBranchName on $remoteName")) {
Invoke-Block { & git push $remoteName "${mergeBranchName}:${mergeBranchName}" }
# Check whether the remote branch exists before fetching, so we can
# distinguish "first push to this branch" from "fetch failed".
& git ls-remote --exit-code --heads $remoteName $mergeBranchName 2>$null | Out-Null
$remoteBranchExists = ($LASTEXITCODE -eq 0)

if ($remoteBranchExists) {
# Refresh the remote tracking ref so the safety check below uses
# current remote state. Fail closed if fetch fails for any reason
# other than the branch not existing.
& git fetch $remoteName $mergeBranchName 2>&1 | Write-Host
if ($LASTEXITCODE -ne 0) {
throw "Failed to fetch '$mergeBranchName' from $remoteName (exit code $LASTEXITCODE). Refusing to push without an up-to-date view of the remote branch."
}
}

if ($createdMergeCommit) {
# Merge commits create non-fast-forwardable history on each run,
# so we need --force to update the branch. Before force-pushing,
# check the remote for commits that aren't reachable from our
# local branch AND weren't authored by the bot — those would be
# manual changes a contributor pushed to the PR branch.
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"
}
}
Invoke-Block { & git push --force $remoteName "${mergeBranchName}:${mergeBranchName}" }
} 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}" }
}
}
Comment on lines +433 to +469
}
$prUpdatedSuccess = $true
}
catch {
Write-Warning "Failed to update existing PR"
Write-Warning "Failed to update existing PR: $_"
}

$prMessage = if ($prUpdatedSuccess) {
"This pull request has been updated.`n`n$committersList"
# Build the PR update comment. Tell reviewers which merge path was taken so
# they know whether GitHub's diff reflects a real merge or a source-only
# branch (i.e. whether they need to use the merge button to resolve
# conflicts).
if ($prUpdatedSuccess) {
if ($createdMergeCommit) {
$pathDescription = "This pull request was updated with a clean merge commit (no conflicts)."
} elseif ($conflictFiles -and $conflictFiles.Count -gt 0) {
$conflictList = ($conflictFiles | % { " - ``$_``" }) -join "`n"
$pathDescription = @"
This pull request was updated **without** a merge commit because the following file(s) had conflicts that must be resolved manually via GitHub's merge button:

$conflictList
"@
} else {
$pathDescription = "This pull request was updated."
}
$prMessage = "$pathDescription`n`n$committersList"
} else {
@"
$prMessage = @"
:x: Uh oh, this pull request could not be updated automatically. New commits were pushed to $MergeFromBranch, but I could not automatically push those to $mergeBranchName to update this PR.
You may need to fix this problem by merging branches with this PR. Contact .NET Core Engineering if you are not sure what to do about this.
"@
Expand Down