diff --git a/.github/workflows/scripts/inter-branch-merge.ps1 b/.github/workflows/scripts/inter-branch-merge.ps1 index c05c2c87895..02a91baaba4 100644 --- a/.github/workflows/scripts/inter-branch-merge.ps1 +++ b/.github/workflows/scripts/inter-branch-merge.ps1 @@ -126,10 +126,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 = @() @@ -220,13 +217,97 @@ 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 for PR updates) + $createdMergeCommit = $false + + # When ResetToTargetPaths is configured, we attempt to create a proper merge commit + # so that the target branch content is included. If there are conflicts outside + # the pattern list, we fall back to the original source-only behavior so that + # GitHub's merge button surfaces the real conflicts 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 first + $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 { + # Get list of conflicting files + [string[]] $conflictFiles = & git 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) { + # Merge failed but produced no conflicts — unexpected git error. + # Fall back to source-only branch and let GitHub handle it. + 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." + Invoke-Block { & git checkout -B $mergeBranchName "origin/$MergeFromBranch" } + } else { + Write-Host "Merge produced conflicts. Checking if all conflicts are within ResetToTargetPaths..." + + # Check which conflicts fall outside ResetToTargetPaths patterns + $outsidePatternConflicts = @() + foreach ($file in $conflictFiles) { + $covered = $false + foreach ($pattern in $patterns) { + if ($file -like $pattern) { + $covered = $true + break + } + } + if (-not $covered) { + $outsidePatternConflicts += $file + } + } + + if ($outsidePatternConflicts.Count -eq 0) { + # All conflicts are in ResetToTargetPaths files which will be overwritten + # by target branch content anyway, so it's safe to auto-resolve them. + # Use -X ours (favor target) as a safety net: if ResetFilesToTargetBranch + # misses a file due to pathspec differences, the merge commit already + # has the target version rather than silently carrying source content. + Write-Host "All conflicts are within ResetToTargetPaths patterns. Auto-resolving with -X ours (favor target)..." + Invoke-Block { & git merge --no-ff "origin/$MergeFromBranch" -X ours -m "Merge branch '$MergeFromBranch' into $MergeToBranch" } + $createdMergeCommit = $true + } else { + # There are conflicts outside ResetToTargetPaths. We must NOT auto-resolve + # these because it would hide real conflicts from the PR reviewer. + # Fall back to the original behavior: create branch from source HEAD + # so GitHub's merge button will surface the conflicts. + Write-Host -f Yellow "Conflicts detected outside ResetToTargetPaths patterns:" + $outsidePatternConflicts | % { 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" } + } + } + } + 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 } + } $remoteName = 'origin' $prOwnerName = $RepoOwner @@ -277,7 +358,36 @@ try { try { if ($PSCmdlet.ShouldProcess("Update remote branch $mergeBranchName on $remoteName")) { - Invoke-Block { & git push $remoteName "${mergeBranchName}:${mergeBranchName}" } + # Refresh the remote tracking ref for the merge branch so the + # human-commit safety check below uses current remote state. + & git fetch $remoteName $mergeBranchName 2>$null + + if ($createdMergeCommit) { + # Merge commits create non-fast-forwardable history on each run, + # so we need --force to update the branch. But first check if the + # remote has human-pushed commits that aren't in our local branch + # to avoid silently overwriting manual conflict resolutions. + [string[]] $extraCommits = & git rev-list "$mergeBranchName..origin/$mergeBranchName" 2>$null + if ($extraCommits -and $extraCommits.Count -gt 0) { + Write-Warning "Remote branch '$mergeBranchName' has $($extraCommits.Count) commit(s) not in the local branch. Skipping force push to avoid overwriting manual changes." + throw "Remote branch has unmerged 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) { + [string[]] $extraCommits = & git rev-list "$mergeBranchName..origin/$mergeBranchName" 2>$null + if ($extraCommits -and $extraCommits.Count -gt 0) { + Write-Warning "Remote branch '$mergeBranchName' has $($extraCommits.Count) commit(s) not in the local branch. Skipping force push to avoid overwriting manual changes." + throw "Remote branch has unmerged commits" + } + Write-Host "Non-force push failed (likely diverged history). Retrying with --force..." + Invoke-Block { & git push --force $remoteName "${mergeBranchName}:${mergeBranchName}" } + } + } } $prUpdatedSuccess = $true }