Tests/GitEasy.AssertSafeSave.Tests.ps1
|
BeforeAll { $ProjectRoot = Split-Path -Parent $PSScriptRoot $ModulePath = Join-Path $ProjectRoot 'GitEasy.psd1' # --------------------------------------------------------------------------- # Invoke-TestGit / New-TestRepository — same setup helpers used by # GitEasy.SaveWork.Tests.ps1. Assert-GESafeSave is exercised transitively # there through Save-Work; this suite asserts its own contract directly. # --------------------------------------------------------------------------- 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-TestRepository { <# .DESCRIPTION Test helper. Creates a minimal git repository for test isolation. Steps: 1. Create the target directory. 2. Initialize a new repository, then set test-safe user name and email. #> 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 } finally { Pop-Location } } # Commit a file with raw git (no Save-Work — keep this suite independent of # the command it guards). function Add-TestCommit { <# .DESCRIPTION Test helper. Writes a file and records it as a saved point in the given repository. Steps: 1. Write the file content to the target path. 2. Stage all changes and record the saved point with the given message. #> param( [Parameter(Mandatory)][string]$Repo, [Parameter(Mandatory)][string]$FileName, [Parameter(Mandatory)][string]$Content, [Parameter(Mandatory)][string]$Message ) Set-Content -LiteralPath (Join-Path $Repo $FileName) -Value $Content -Encoding UTF8 Push-Location -LiteralPath $Repo try { Invoke-TestGit -ArgumentList @('add', '-A') | Out-Null Invoke-TestGit -ArgumentList @('commit', '-m', $Message) | Out-Null } finally { Pop-Location } } function Get-TestCurrentBranch { <# .DESCRIPTION Test helper. Returns the active working area name for the given repository. Steps: 1. Query the symbolic reference for the current working area. 2. Return the first line of output. #> param([Parameter(Mandatory)][string]$Path) Push-Location -LiteralPath $Path try { $r = Invoke-TestGit -ArgumentList @('symbolic-ref', '--short', 'HEAD') -AllowFailure return ($r.Output | Select-Object -First 1) } finally { Pop-Location } } } Describe 'Assert-GESafeSave — behavioral contract' { 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_ASS_$script:Stem") New-TestRepository -Path $script:TempRepo Push-Location -LiteralPath $script:TempRepo } AfterEach { Pop-Location Remove-Item -LiteralPath $script:TempRepo -Recurse -Force -ErrorAction SilentlyContinue } # ----------------------------------------------------------------------- # Safe workspace — the success path returns the Boolean $true. # ----------------------------------------------------------------------- Context 'Safe workspace' { It 'returns $true in a clean repo with a commit' { Add-TestCommit -Repo $script:TempRepo -FileName 'README.md' -Content 'base' -Message 'baseline' $result = InModuleScope GitEasy { Assert-GESafeSave } $result | Should -Be $true } It 'returns $true when called from a subdirectory of the repo' { Add-TestCommit -Repo $script:TempRepo -FileName 'README.md' -Content 'base' -Message 'baseline' $sub = Join-Path $script:TempRepo 'src' New-Item -Path $sub -ItemType Directory -Force | Out-Null Push-Location -LiteralPath $sub try { $result = InModuleScope GitEasy { Assert-GESafeSave } } finally { Pop-Location } $result | Should -Be $true } It 'returns exactly the Boolean $true, not a richer object' { Add-TestCommit -Repo $script:TempRepo -FileName 'README.md' -Content 'base' -Message 'baseline' $result = InModuleScope GitEasy { Assert-GESafeSave } ($result -is [bool]) | Should -Be $true $result | Should -Be $true } } # ----------------------------------------------------------------------- # Not a saveable workspace — Get-GERepoRoot throws OR yields an empty # root. Both collapse to the same plain-English message. # ----------------------------------------------------------------------- Context 'Not a saveable workspace' { It 'throws a plain-English error when not inside any repository' { $nonRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("GitEasy_NR_$([guid]::NewGuid().ToString('N').Substring(0,8))") New-Item -Path $nonRepo -ItemType Directory -Force | Out-Null $thrown = $null Push-Location -LiteralPath $nonRepo try { try { InModuleScope GitEasy { Assert-GESafeSave } } catch { $thrown = $_ } } finally { Pop-Location Remove-Item -LiteralPath $nonRepo -Recurse -Force -ErrorAction SilentlyContinue } $thrown | Should -Not -BeNullOrEmpty $thrown.Exception.Message | Should -Match 'saveable workspace' } It 'the not-a-workspace message contains no raw git jargon' { $nonRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("GitEasy_NR_$([guid]::NewGuid().ToString('N').Substring(0,8))") New-Item -Path $nonRepo -ItemType Directory -Force | Out-Null $thrown = $null Push-Location -LiteralPath $nonRepo try { try { InModuleScope GitEasy { Assert-GESafeSave } } catch { $thrown = $_ } } finally { Pop-Location Remove-Item -LiteralPath $nonRepo -Recurse -Force -ErrorAction SilentlyContinue } $thrown | Should -Not -BeNullOrEmpty $thrown.Exception.Message | Should -Not -Match '(?i)\bgit\b' } It 'throws the workspace message when the repo root resolves empty' { $msg = $null try { InModuleScope GitEasy { Mock Get-GERepoRoot { ' ' } Assert-GESafeSave -Path 'C:\does-not-matter' } } catch { $msg = $_.Exception.Message } $msg | Should -Match 'saveable workspace' } } # ----------------------------------------------------------------------- # Busy repository — a real merge conflict leaves MERGE_HEAD, so the # busy check (which runs before the conflict check) fires first. # ----------------------------------------------------------------------- Context 'Busy repository (operation in progress)' { It 'throws when a merge is in progress' { Add-TestCommit -Repo $script:TempRepo -FileName 'shared.txt' -Content "A`n" -Message 'base' $baseBranch = Get-TestCurrentBranch -Path $script:TempRepo Invoke-TestGit -ArgumentList @('checkout', '-b', 'feature') | Out-Null Add-TestCommit -Repo $script:TempRepo -FileName 'shared.txt' -Content "B`n" -Message 'feature' Invoke-TestGit -ArgumentList @('checkout', $baseBranch) | Out-Null Add-TestCommit -Repo $script:TempRepo -FileName 'shared.txt' -Content "C`n" -Message 'base2' $merge = Invoke-TestGit -ArgumentList @('merge', 'feature') -AllowFailure @($merge.Output | Where-Object { $_ -match 'CONFLICT' }).Count -gt 0 | Should -Be $true $thrown = $null try { InModuleScope GitEasy { Assert-GESafeSave } } catch { $thrown = $_ } $thrown | Should -Not -BeNullOrEmpty $thrown.Exception.Message | Should -Match 'in progress' } It 'the busy message contains no raw git jargon' { Add-TestCommit -Repo $script:TempRepo -FileName 'shared.txt' -Content "A`n" -Message 'base' $baseBranch = Get-TestCurrentBranch -Path $script:TempRepo Invoke-TestGit -ArgumentList @('checkout', '-b', 'feature') | Out-Null Add-TestCommit -Repo $script:TempRepo -FileName 'shared.txt' -Content "B`n" -Message 'feature' Invoke-TestGit -ArgumentList @('checkout', $baseBranch) | Out-Null Add-TestCommit -Repo $script:TempRepo -FileName 'shared.txt' -Content "C`n" -Message 'base2' Invoke-TestGit -ArgumentList @('merge', 'feature') -AllowFailure | Out-Null $thrown = $null try { InModuleScope GitEasy { Assert-GESafeSave } } catch { $thrown = $_ } $thrown | Should -Not -BeNullOrEmpty $thrown.Exception.Message | Should -Not -Match '(?i)\bgit\b' } } # ----------------------------------------------------------------------- # Unfinished conflicts — isolated from the busy state. Real git cannot # produce unmerged paths without an in-progress operation, so the # conflict-only branch is reached by mocking the two inner helpers. # ----------------------------------------------------------------------- Context 'Unfinished conflicts (isolated from busy state)' { It 'throws and lists the conflicted files when not busy but conflicts exist' { $msg = $null try { InModuleScope GitEasy { Mock Get-GERepoRoot { 'C:\fake-root' } Mock Test-GERepositoryBusy { [PSCustomObject]@{ IsBusy = $false; Operations = @() } } Mock Get-GEConflictFile { @('conflicted.txt', 'src/Other.cs') } Assert-GESafeSave -Path 'C:\fake-root' } } catch { $msg = $_.Exception.Message } $msg | Should -Match 'unfinished conflicts' $msg | Should -Match 'conflicted\.txt' $msg | Should -Match 'Other\.cs' } It 'the conflict message contains no raw git jargon' { $msg = $null try { InModuleScope GitEasy { Mock Get-GERepoRoot { 'C:\fake-root' } Mock Test-GERepositoryBusy { [PSCustomObject]@{ IsBusy = $false; Operations = @() } } Mock Get-GEConflictFile { @('conflicted.txt') } Assert-GESafeSave -Path 'C:\fake-root' } } catch { $msg = $_.Exception.Message } $msg | Should -Not -BeNullOrEmpty $msg | Should -Not -Match '(?i)\bgit\b' } } } |