Tests/Unit/DiffBase.Tests.ps1

#Requires -Module Pester

<#
Regression test for the diff base of Get-ChangedFiles / Get-FileDiff (Billiet PR 23865, build 114832):
master moved while the PR build ran, and the review diffed origin/master (now) against the build's
merge commit, so files the PR never touched were reviewed.

Repo built here:
    B ---------------- X master (X renames App/Old/Moved.al and edits App/C.al, after M)
     \ \
      P ---------- M M = refs/pull/1/merge = merge of P into B (first parent B)
P changes App/A.al and adds App/New.al. Expected review: exactly those two files.

AICR_MODULE_ROOT can point at another copy of the module (e.g. an archive of origin/main) to prove
the old two-dot code fails this test.
#>


BeforeAll {
    # Publish-ToGallery.ps1 -WhatIf runs this test; the scratch repo must still be created
    $WhatIfPreference = $false
    $PSDefaultParameterValues['*:WhatIf'] = $false
    $moduleRoot = if ($env:AICR_MODULE_ROOT) { $env:AICR_MODULE_ROOT } else { Join-Path $PSScriptRoot '..' '..' }
    # Only the diff functions (and the shared helper when it exists - the old code has none)
    foreach ($relative in @('Public/Get-ChangedFiles.ps1', 'Private/Get-FileDiff.ps1', 'Private/Resolve-DiffRange.ps1')) {
        $path = Join-Path $moduleRoot $relative
        if (Test-Path $path) { . $path }
    }

    function Invoke-TestGit {
        # Plain $args: a ValueFromRemainingArguments parameter would bind git flags such as -m
        $Path = $args[0]
        $Arguments = @($args | Select-Object -Skip 1)
        $output = & git -C $Path -c user.name=test -c user.email=test@localhost -c init.defaultBranch=master -c advice.detachedHead=false @Arguments 2>&1
        if ($LASTEXITCODE -ne 0) { throw "git $($Arguments -join ' ') failed: $($output -join "`n")" }
        return @($output | ForEach-Object { "$_" })
    }

    function Set-TestFile {
        param([string]$Root, [string]$RelativePath, [string]$Content)
        $full = Join-Path $Root $RelativePath
        New-Item -ItemType Directory -Path (Split-Path $full) -Force | Out-Null
        Set-Content -Path $full -Value $Content -NoNewline
    }

    $script:Root = Join-Path ([System.IO.Path]::GetTempPath()) "aicr-diffbase-$([guid]::NewGuid().ToString('N'))"
    $origin = Join-Path $script:Root 'origin'
    New-Item -ItemType Directory -Path $origin -Force | Out-Null
    Invoke-TestGit $origin init --quiet | Out-Null
    Invoke-TestGit $origin config uploadpack.allowAnySHA1InWant true | Out-Null

    # B: base on master
    Set-TestFile $origin 'App/A.al' "codeunit 50100 A`n{`n procedure One()`n begin`n end;`n}`n"
    Set-TestFile $origin 'App/C.al' "codeunit 50102 C`n{`n}`n"
    Set-TestFile $origin 'App/Old/Moved.al' "codeunit 50103 Moved`n{`n procedure Keep()`n begin`n end;`n}`n"
    Invoke-TestGit $origin add -A | Out-Null
    Invoke-TestGit $origin commit --quiet -m 'B' | Out-Null
    $script:B = @(Invoke-TestGit $origin rev-parse HEAD)[0]

    # P: the PR
    Invoke-TestGit $origin checkout --quiet -b pr | Out-Null
    Set-TestFile $origin 'App/A.al' "codeunit 50100 A`n{`n procedure One()`n begin`n Message('PR');`n end;`n}`n"
    Set-TestFile $origin 'App/New.al' "codeunit 50101 New`n{`n}`n"
    Invoke-TestGit $origin add -A | Out-Null
    Invoke-TestGit $origin commit --quiet -m 'P' | Out-Null
    $script:P = @(Invoke-TestGit $origin rev-parse HEAD)[0]

    # M: the build's merge commit, first parent B
    Invoke-TestGit $origin checkout --quiet --detach $script:B | Out-Null
    Invoke-TestGit $origin merge --quiet --no-ff --no-edit -m 'Merge pull request 1 into master' pr | Out-Null
    $script:M = @(Invoke-TestGit $origin rev-parse HEAD)[0]
    Invoke-TestGit $origin update-ref refs/pull/1/merge $script:M | Out-Null

    # X: master moves on after M (another PR: folder reorganisation + an edit)
    Invoke-TestGit $origin checkout --quiet master | Out-Null
    New-Item -ItemType Directory -Path (Join-Path $origin "App/Reorg") -Force | Out-Null
    Invoke-TestGit $origin mv App/Old/Moved.al App/Reorg/Moved.al | Out-Null
    Set-TestFile $origin 'App/C.al' "codeunit 50102 C`n{`n procedure Two()`n begin`n end;`n}`n"
    Invoke-TestGit $origin add -A | Out-Null
    Invoke-TestGit $origin commit --quiet -m 'X: reorganise folders' | Out-Null
    $script:X = @(Invoke-TestGit $origin rev-parse HEAD)[0]

    # The agent's review clone: master as it is NOW (X), plus the build commit fetched by SHA
    $script:Agent = Join-Path $script:Root 'agent'
    Invoke-TestGit $script:Root clone --quiet --single-branch --branch master "file://$origin" agent | Out-Null
    Invoke-TestGit $script:Agent fetch --quiet origin $script:M | Out-Null
    Invoke-TestGit $script:Agent fetch --quiet origin "pr:refs/remotes/origin/pr" | Out-Null

    $script:EnvNames = @('BUILD_REASON', 'BUILD_SOURCEVERSION', 'SYSTEM_PULLREQUEST_TARGETBRANCH')
    $script:SavedEnv = @{}
    foreach ($name in $script:EnvNames) { $script:SavedEnv[$name] = [Environment]::GetEnvironmentVariable($name) }
    $script:SavedLocation = Get-Location
}

AfterAll {
    $PSDefaultParameterValues.Remove('*:WhatIf')
    foreach ($name in $script:EnvNames) { [Environment]::SetEnvironmentVariable($name, $script:SavedEnv[$name]) }
    Set-Location $script:SavedLocation
    if ($script:Root -and (Test-Path $script:Root)) { Remove-Item $script:Root -Recurse -Force -ErrorAction SilentlyContinue }
}

Describe 'Diff base when the target branch moved during the PR build' {
    BeforeEach {
        Set-Location $script:Agent
        Invoke-TestGit $script:Agent checkout --quiet --detach $script:M | Out-Null
        $env:BUILD_REASON = 'PullRequest'
        $env:SYSTEM_PULLREQUEST_TARGETBRANCH = 'refs/heads/master'
        $env:BUILD_SOURCEVERSION = $script:M
    }

    It 'setup: origin/master in the clone is already past the merge commit' {
        @(Invoke-TestGit $script:Agent rev-parse origin/master)[0] | Should -Be $script:X
        @(Invoke-TestGit $script:Agent rev-parse "$($script:M)^1")[0] | Should -Be $script:B
    }

    It 'Get-ChangedFiles returns only the files the PR changed' {
        $files = @(Get-ChangedFiles -BaseBranch 'origin/master' -FileExtensions @('.al') 6>$null)
        ($files | Sort-Object) | Should -Be @('App/A.al', 'App/New.al')
    }

    It 'Get-ChangedFiles does not move origin/master (no review-time refetch in the PR path)' {
        Invoke-TestGit $script:Agent update-ref refs/remotes/origin/master $script:B | Out-Null
        try {
            Get-ChangedFiles -BaseBranch 'origin/master' -FileExtensions @('.al') 6>$null | Out-Null
            @(Invoke-TestGit $script:Agent rev-parse origin/master)[0] | Should -Be $script:B
        } finally {
            Invoke-TestGit $script:Agent update-ref refs/remotes/origin/master $script:X | Out-Null
        }
    }

    It 'Get-FileDiff sends the PR change of A.al, not the reverse of master''s later edits' {
        $diff = Get-FileDiff -FilePath 'App/A.al' -BaseBranch 'master'
        ($diff.FullDiff -join "`n") | Should -Match "\+\s+Message\('PR'\);"
        ($diff.FullDiff -join "`n") | Should -Not -Match 'Moved|Two\(\)'
    }

    It 'Get-FileDiff returns nothing for a file only master changed after the merge' {
        Get-FileDiff -FilePath 'App/C.al' -BaseBranch 'master' | Should -BeNullOrEmpty
    }

    It 'Get-FileDiff shows a new file in full' {
        $diff = Get-FileDiff -FilePath 'App/New.al' -BaseBranch 'master'
        ($diff.FullDiff -join "`n") | Should -Match '^--- /dev/null'
    }

    It 'Resolve-DiffRange names the first parent as the base' {
        $range = Resolve-DiffRange -BaseBranch 'origin/master'
        $range.Skip | Should -BeFalse
        $range.Mode | Should -Be 'MergeParent'
        $range.Base | Should -Be $script:B
        $range.Compare | Should -Be $script:M
    }
}

Describe 'Diff base outside a PR merge commit' {
    BeforeEach {
        Set-Location $script:Agent
        $env:SYSTEM_PULLREQUEST_TARGETBRANCH = $null
    }

    It 'CI build on the feature branch: three-dot against the target branch' {
        Invoke-TestGit $script:Agent checkout --quiet --detach $script:P | Out-Null
        $env:BUILD_REASON = 'IndividualCI'
        $env:BUILD_SOURCEVERSION = $script:P
        $files = @(Get-ChangedFiles -BaseBranch 'origin/master' -FileExtensions @('.al') 6>$null)
        ($files | Sort-Object) | Should -Be @('App/A.al', 'App/New.al')
    }

    It 'merge commit outside a PR build: three-dot, not merge parent 1' {
        Invoke-TestGit $script:Agent checkout --quiet --detach $script:M | Out-Null
        $env:BUILD_REASON = 'Manual'
        $env:BUILD_SOURCEVERSION = $script:M
        $range = Resolve-DiffRange -BaseBranch 'origin/master'
        $range.Mode | Should -Be 'MergeBase'
        $range.Base | Should -Be $script:B
    }

    It 'build commit not in the clone: skip with a warning, never another commit' {
        $env:BUILD_REASON = 'PullRequest'
        $env:BUILD_SOURCEVERSION = '0123456789abcdef0123456789abcdef01234567'
        $files = @(Get-ChangedFiles -BaseBranch 'origin/master' -FileExtensions @('.al') 6>$null)
        $files.Count | Should -Be 0
        (Resolve-DiffRange -BaseBranch 'origin/master').Skip | Should -BeTrue
    }
}