Tests/GitEasy.ShowChange.Tests.ps1

BeforeAll {
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$ModulePath  = Join-Path $ProjectRoot 'GitEasy.psd1'

function Invoke-TestGit {
    <#
    .DESCRIPTION
    Test helper. Runs git with the given arguments and returns exit code and output.
    Steps:
    1. Run git capturing combined output.
    2. Throw on non-zero exit unless -AllowFailure is set.
    3. Return a result object with the exit code and output array.
    #>

    param(
        [Parameter(Mandatory)]
        [string[]]$ArgumentList,

        [switch]$AllowFailure
    )

    $oldPreference = $ErrorActionPreference

    try {
        $ErrorActionPreference = 'Continue'
        $output = & git @ArgumentList 2>&1
        $exitCode = $LASTEXITCODE
    }
    finally {
        $ErrorActionPreference = $oldPreference
    }

    if (($exitCode -ne 0) -and (-not $AllowFailure)) {
        throw "Git failed: git $($ArgumentList -join ' ')`n$($output -join [Environment]::NewLine)"
    }

    return [PSCustomObject]@{
        ExitCode = $exitCode
        Output   = @($output)
    }
}

function New-TestRepositoryWithCommit {
    <#
    .DESCRIPTION
    Test helper. Creates a git repository with one saved point for test isolation.
    Steps:
    1. Create the target directory.
    2. Initialize a new repository and set test-safe user name and email.
    3. Write a README file, stage it, and create the first saved point.
    #>

    param([Parameter(Mandatory)] [string]$Path)

    New-Item -Path $Path -ItemType Directory -Force | Out-Null
    Push-Location -LiteralPath $Path

    try {
        Invoke-TestGit -ArgumentList @('init') | Out-Null
        Invoke-TestGit -ArgumentList @('config', 'user.name', 'GitEasy Pester') | Out-Null
        Invoke-TestGit -ArgumentList @('config', 'user.email', 'giteasy-pester@example.invalid') | Out-Null
        Set-Content -LiteralPath (Join-Path $Path 'README.md') -Value 'baseline content' -Encoding UTF8
        Invoke-TestGit -ArgumentList @('add', '-A') | Out-Null
        Invoke-TestGit -ArgumentList @('commit', '-m', 'baseline') | Out-Null
    }
    finally {
        Pop-Location
    }
}
}

Describe 'Show-Change' {
    BeforeAll {
        Remove-Module GitEasy -Force -ErrorAction SilentlyContinue
        Import-Module $ModulePath -Force
    }

    BeforeEach {
        $script:Stem     = [guid]::NewGuid().ToString('N').Substring(0, 8)
        $script:TempRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("GitEasy_SC_$script:Stem")

        New-TestRepositoryWithCommit -Path $script:TempRepo
        Push-Location -LiteralPath $script:TempRepo
    }

    AfterEach {
        Pop-Location
        Remove-Item -LiteralPath $script:TempRepo -Recurse -Force -ErrorAction SilentlyContinue
    }

    It 'returns no diff text when the working area is clean' {
        $r = Show-Change
        $hasContent = $false
        foreach ($entry in @($r)) {
            if ($entry -and ($entry | Get-Member -Name 'Diff' -ErrorAction SilentlyContinue)) {
                if (-not [string]::IsNullOrWhiteSpace($entry.Diff)) { $hasContent = $true }
            }
        }
        $hasContent | Should -Be $false
    }

    It 'reports unstaged changes as structured objects with Path and Diff' {
        $target = Join-Path $script:TempRepo 'README.md'
        Set-Content -LiteralPath $target -Value "baseline content`r`nadded line" -Encoding UTF8

        $r = @(Show-Change)
        $r.Count -gt 0 | Should -Be $true
        $entry = $r | Select-Object -First 1
        ($entry.PSObject.Properties.Name -contains 'Path') | Should -Be $true
        ($entry.PSObject.Properties.Name -contains 'Diff') | Should -Be $true
        $entry.Path | Should -Match 'README\.md'
        $entry.Diff | Should -Match 'added line'
    }

    It 'filters by -Path' {
        Set-Content -LiteralPath (Join-Path $script:TempRepo 'a.txt') -Value 'a content' -Encoding UTF8
        Set-Content -LiteralPath (Join-Path $script:TempRepo 'b.txt') -Value 'b content' -Encoding UTF8
        Invoke-TestGit -ArgumentList @('add', '-A') | Out-Null
        Invoke-TestGit -ArgumentList @('commit', '-m', 'add a and b') | Out-Null

        Set-Content -LiteralPath (Join-Path $script:TempRepo 'a.txt') -Value 'a edited' -Encoding UTF8
        Set-Content -LiteralPath (Join-Path $script:TempRepo 'b.txt') -Value 'b edited' -Encoding UTF8

        $r = @(Show-Change -Path 'a.txt')
        $r.Count | Should -Be 1
        $r[0].Path | Should -Match 'a\.txt'
    }

    It '-NextSave shows changes already prepared for the next saved point' {
        $target = Join-Path $script:TempRepo 'README.md'
        Set-Content -LiteralPath $target -Value "baseline content`r`nprepared change" -Encoding UTF8
        Invoke-TestGit -ArgumentList @('add', '-A') | Out-Null

        $unprepared = @(Show-Change)
        $prepared   = @(Show-Change -NextSave)

        $unprepared.Count | Should -Be 0
        $prepared.Count -gt 0 | Should -Be $true
        ($prepared | Select-Object -First 1).Diff | Should -Match 'prepared change'
    }

    It '-Compact returns short summary instead of full diff' {
        $target = Join-Path $script:TempRepo 'README.md'
        Set-Content -LiteralPath $target -Value "baseline content`r`nshort change" -Encoding UTF8

        $r = Show-Change -Compact
        $r | Should -Not -BeNullOrEmpty
        ($r.PSObject.Properties.Name -contains 'Summary') | Should -Be $true
        $r.Summary | Should -Match 'README\.md'
    }

    It 'plain-English error when not inside a project folder' {
        $outside = Join-Path ([System.IO.Path]::GetTempPath()) ("GitEasy_SC_outside_" + [guid]::NewGuid().ToString('N').Substring(0,8))
        New-Item -Path $outside -ItemType Directory -Force | Out-Null
        Push-Location -LiteralPath $outside

        $thrown = $null
        try {
            $r = Show-Change
            if ($r -is [System.Management.Automation.ErrorRecord]) {
                $thrown = $r
            }
        } catch { $thrown = $_ }

        Pop-Location
        Remove-Item -LiteralPath $outside -Recurse -Force -ErrorAction SilentlyContinue

        $thrown | Should -Not -BeNullOrEmpty
    }
}

Describe 'Get-Updates' {
    BeforeAll {
        Remove-Module GitEasy -Force -ErrorAction SilentlyContinue
        Import-Module $ModulePath -Force
    }

    BeforeEach {
        $script:Stem      = [guid]::NewGuid().ToString('N').Substring(0, 8)
        $script:TempBare  = Join-Path ([System.IO.Path]::GetTempPath()) ("GitEasy_GU_$($script:Stem)_remote.git")
        $script:TempA     = Join-Path ([System.IO.Path]::GetTempPath()) ("GitEasy_GU_$($script:Stem)_a")
        $script:TempB     = Join-Path ([System.IO.Path]::GetTempPath()) ("GitEasy_GU_$($script:Stem)_b")
        $script:TempLogs  = Join-Path ([System.IO.Path]::GetTempPath()) ("GitEasy_GU_$($script:Stem)_logs")

        # Bare remote
        New-Item -Path $script:TempBare -ItemType Directory -Force | Out-Null
        Push-Location -LiteralPath $script:TempBare
        Invoke-TestGit -ArgumentList @('init', '--bare') | Out-Null
        Pop-Location

        # Repo A — pushes a baseline to the bare
        New-TestRepositoryWithCommit -Path $script:TempA
        Push-Location -LiteralPath $script:TempA
        Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', $script:TempBare) | Out-Null
        Invoke-TestGit -ArgumentList @('push', '-u', 'origin', 'master') -AllowFailure | Out-Null
        if ($LASTEXITCODE -ne 0) {
            Invoke-TestGit -ArgumentList @('push', '-u', 'origin', 'main') | Out-Null
        }
        Pop-Location

        # Repo B — clones from bare
        Push-Location -LiteralPath ([System.IO.Path]::GetTempPath())
        Invoke-TestGit -ArgumentList @('clone', $script:TempBare, $script:TempB) | Out-Null
        Pop-Location
        Push-Location -LiteralPath $script:TempB
        Invoke-TestGit -ArgumentList @('config', 'user.name', 'B') | Out-Null
        Invoke-TestGit -ArgumentList @('config', 'user.email', 'b@example.invalid') | Out-Null
        Pop-Location

        New-Item -Path $script:TempLogs -ItemType Directory -Force | Out-Null
        $env:GITEASY_LOG_PATH = $script:TempLogs
    }

    AfterEach {
        Remove-Item Env:\GITEASY_LOG_PATH -ErrorAction SilentlyContinue
        Remove-Item -LiteralPath $script:TempBare -Recurse -Force -ErrorAction SilentlyContinue
        Remove-Item -LiteralPath $script:TempA -Recurse -Force -ErrorAction SilentlyContinue
        Remove-Item -LiteralPath $script:TempB -Recurse -Force -ErrorAction SilentlyContinue
        Remove-Item -LiteralPath $script:TempLogs -Recurse -Force -ErrorAction SilentlyContinue
    }

    It 'reports zero new saved points when no peer activity has happened' {
        Push-Location -LiteralPath $script:TempB
        try {
            $r = Get-Updates
            $r | Should -Not -BeNullOrEmpty
            $r.NewSavedPoints | Should -Be 0
        }
        finally {
            Pop-Location
        }
    }

    It 'detects new saved points pushed to the published location by a peer' {
        # A pushes a new commit to bare
        Push-Location -LiteralPath $script:TempA
        Set-Content -LiteralPath (Join-Path $script:TempA 'peer.txt') -Value 'peer change' -Encoding UTF8
        Invoke-TestGit -ArgumentList @('add', '-A') | Out-Null
        Invoke-TestGit -ArgumentList @('commit', '-m', 'peer commit') | Out-Null
        Invoke-TestGit -ArgumentList @('push') | Out-Null
        Pop-Location

        # B fetches via Get-Updates and should see 1 new saved point
        Push-Location -LiteralPath $script:TempB
        try {
            $r = Get-Updates
            $r.NewSavedPoints | Should -Be 1
        }
        finally {
            Pop-Location
        }
    }

    It 'returns a structured object with Repository, Remote, NewSavedPoints, Message' {
        Push-Location -LiteralPath $script:TempB
        try {
            $r = Get-Updates
            ($r.PSObject.Properties.Name -contains 'Repository')      | Should -Be $true
            ($r.PSObject.Properties.Name -contains 'Remote')          | Should -Be $true
            ($r.PSObject.Properties.Name -contains 'NewSavedPoints')  | Should -Be $true
            ($r.PSObject.Properties.Name -contains 'Message')         | Should -Be $true
        }
        finally {
            Pop-Location
        }
    }

    It 'every invocation writes a log file' {
        Push-Location -LiteralPath $script:TempB
        try {
            Get-Updates | Out-Null
        }
        finally {
            Pop-Location
        }

        $logs = @(Get-ChildItem -LiteralPath $script:TempLogs -Filter 'Get-Updates-*.log' -File)
        $logs.Count -gt 0 | Should -Be $true
        $body = Get-Content -LiteralPath ($logs | Sort-Object LastWriteTime | Select-Object -Last 1).FullName -Raw
        $body | Should -Match 'Outcome: SUCCESS'
    }

    It 'plain-English error when no published location is configured' {
        # Remove origin from B
        Push-Location -LiteralPath $script:TempB
        try {
            Invoke-TestGit -ArgumentList @('remote', 'remove', 'origin') | Out-Null

            $thrown = $null
            try { Get-Updates } catch { $thrown = $_ }

            $thrown | Should -Not -BeNullOrEmpty
            $userMessage = $thrown.Exception.Message -replace '(?ms)Details:.*$',''
            $userMessage | Should -Not -Match '(?i)\bupstream\b'
            $userMessage | Should -Not -Match '(?i)\bHEAD\b'
        }
        finally {
            Pop-Location
        }
    }
}