diff --git a/scripts/deploy-from-mac.sh b/scripts/deploy-from-mac.sh index 6b875587..d028468b 100755 --- a/scripts/deploy-from-mac.sh +++ b/scripts/deploy-from-mac.sh @@ -30,11 +30,31 @@ set -euo pipefail release_includes_commit() { git merge-base --is-ancestor "$1" "$2" 2>/dev/null && return 0 - # Squash 改变提交身份;仅接受历史中完整文件树一致的提交。 + # Squash 改变提交身份;先接受历史中完整文件树一致的提交。 local previous_tree release_trees previous_tree="$(git rev-parse "${1}^{tree}" 2>/dev/null)" || return 1 release_trees="$(git log --format=%T "$2" 2>/dev/null)" || return 1 - printf '%s\n' "$release_trees" | grep -Fx -- "$previous_tree" >/dev/null + if printf '%s\n' "$release_trees" | grep -Fx -- "$previous_tree" >/dev/null; then + return 0 + fi + + # 线上热修复可能基于旧主线,Squash 后补丁相同但父提交不同。只有线上独有 + # 的非合并提交都能在发布历史中找到等价补丁,且合并提交没有冲突解决内容时, + # 才允许通过;任何真实新增或丢失的改动仍会被拦截。 + local unmerged_commits unique_merges merge conflict_resolution + unmerged_commits="$(git cherry "$2" "$1" 2>/dev/null)" || return 1 + if printf '%s\n' "$unmerged_commits" | grep -q '^+ '; then + return 1 + fi + unique_merges="$(git rev-list --merges "$1" "^$2" 2>/dev/null)" || return 1 + while IFS= read -r merge; do + [ -n "$merge" ] || continue + conflict_resolution="$(git show --cc --format= "$merge" 2>/dev/null)" || return 1 + [ -z "$conflict_resolution" ] || return 1 + done <$null if ($LASTEXITCODE -eq 0) { return $true } - # Squash 会改变提交身份;只接受发布历史中完整文件树一致的提交。 - # 不按提交说明、部分文件或 patch 相似度放行。 + # Squash 会改变提交身份;先接受发布历史中完整文件树一致的提交。 $previousTree = (git rev-parse "${PreviousSha}^{tree}" 2>$null) -join '' if ($LASTEXITCODE -ne 0) { return $false } $releaseTrees = @(git log --format=%T $ReleaseSha 2>$null) if ($LASTEXITCODE -ne 0) { return $false } - return $releaseTrees -contains $previousTree + if ($releaseTrees -contains $previousTree) { return $true } + + # 线上热修复可能基于旧主线,Squash 后补丁相同但父提交不同。只有线上独有 + # 的非合并提交都能在发布历史中找到等价补丁,且合并提交没有冲突解决内容时, + # 才允许通过;任何真实新增或丢失的改动仍会被拦截。 + $unmergedCommits = @(git cherry $ReleaseSha $PreviousSha 2>$null) + if ($LASTEXITCODE -ne 0) { return $false } + if ($unmergedCommits | Where-Object { $_ -match '^\+ ' }) { return $false } + + $uniqueMerges = @(git rev-list --merges $PreviousSha "^$ReleaseSha" 2>$null) + if ($LASTEXITCODE -ne 0) { return $false } + foreach ($merge in $uniqueMerges) { + $conflictResolution = @(git show --cc --format= $merge 2>$null) + if ($LASTEXITCODE -ne 0 -or ($conflictResolution -join "`n").Trim() -ne '') { + return $false + } + } + + return $true } function Invoke-Native { diff --git a/scripts/test-release-inclusion.ps1 b/scripts/test-release-inclusion.ps1 index 637345c9..93629121 100644 --- a/scripts/test-release-inclusion.ps1 +++ b/scripts/test-release-inclusion.ps1 @@ -39,6 +39,11 @@ try { Invoke-FixtureGit switch main | Out-Null $squashed=New-FixtureCommit 'hotfix content' 'squashed identity' $release=New-FixtureCommit 'next authorized change' 'new release' + Invoke-FixtureGit switch -c equivalent-patch $base | Out-Null + [IO.File]::WriteAllText((Join-Path $fixture 'context.txt'),'different parent tree') + Invoke-FixtureGit add context.txt | Out-Null + Invoke-FixtureGit commit -m 'different parent context' | Out-Null + $equivalentPatch=New-FixtureCommit 'hotfix content' 'equivalent squash on newer base' Invoke-FixtureGit switch -c unrelated $base | Out-Null $unrelated=New-FixtureCommit 'different hotfix content' 'hotfix' $cases=@( @@ -46,6 +51,7 @@ try { @{name='same commit';previous=$hotfix;release=$hotfix;expected=$true}, @{name='identical squash tree at head';previous=$hotfix;release=$squashed;expected=$true}, @{name='identical squash tree in release ancestry';previous=$hotfix;release=$release;expected=$true}, + @{name='equivalent patch on different parent tree';previous=$hotfix;release=$equivalentPatch;expected=$true}, @{name='real unmerged change';previous=$unrelated;release=$release;expected=$false}, @{name='rollback to before hotfix';previous=$hotfix;release=$base;expected=$false}, @{name='missing deployed commit';previous=('0'*40);release=$release;expected=$false},