Tests/GitEasy.SaveWork.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-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 } } function New-TestBareRemote { <# .DESCRIPTION Test helper. Creates a bare repository to serve as a published location in tests. Steps: 1. Create the target directory. 2. Initialize a bare repository. #> param([Parameter(Mandatory)] [string]$Path) New-Item -Path $Path -ItemType Directory -Force | Out-Null Push-Location -LiteralPath $Path try { Invoke-TestGit -ArgumentList @('init', '--bare') | 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 'Save-Work — new 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_SW_$script:Stem") $script:TempBare = Join-Path ([System.IO.Path]::GetTempPath()) ("GitEasy_SW_$($script:Stem)_remote.git") $script:TempLogs = Join-Path ([System.IO.Path]::GetTempPath()) ("GitEasy_SW_$($script:Stem)_logs") New-TestRepository -Path $script:TempRepo New-TestBareRemote -Path $script:TempBare New-Item -Path $script:TempLogs -ItemType Directory -Force | Out-Null $env:GITEASY_LOG_PATH = $script:TempLogs Push-Location -LiteralPath $script:TempRepo } AfterEach { Pop-Location Remove-Item Env:\GITEASY_LOG_PATH -ErrorAction SilentlyContinue Remove-Item -LiteralPath $script:TempRepo -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $script:TempBare -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $script:TempLogs -Recurse -Force -ErrorAction SilentlyContinue } It 'creates the first commit when called with NoPush' { Set-Content -LiteralPath (Join-Path $script:TempRepo 'README.md') -Value 'first save' -Encoding UTF8 Save-Work 'initial commit' -NoPush $log = Invoke-TestGit -ArgumentList @('log', '--oneline', '-1') ($log.Output -join ' ') | Should -Match 'initial commit' $status = Invoke-TestGit -ArgumentList @('status', '--porcelain=v1') @($status.Output | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count | Should -Be 0 } It 'reports nothing to save when working tree is clean and branch is up to date' { Set-Content -LiteralPath (Join-Path $script:TempRepo 'README.md') -Value 'baseline content' -Encoding UTF8 Save-Work 'baseline' -NoPush $before = (Invoke-TestGit -ArgumentList @('rev-parse', 'HEAD')).Output | Select-Object -First 1 $messages = & { Save-Work 'should-noop' -NoPush } *>&1 $after = (Invoke-TestGit -ArgumentList @('rev-parse', 'HEAD')).Output | Select-Object -First 1 $after | Should -Be $before ($messages -join ' ') | Should -Match 'No changes to save' } It 'publishes a clean branch that is ahead of the remote' { Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', $script:TempBare) | Out-Null Set-Content -LiteralPath (Join-Path $script:TempRepo 'a.txt') -Value 'first commit' -Encoding UTF8 Save-Work 'first work' -NoPush Set-Content -LiteralPath (Join-Path $script:TempRepo 'b.txt') -Value 'second commit' -Encoding UTF8 Save-Work 'second work' -NoPush Save-Work $remoteRefs = Invoke-TestGit -ArgumentList @('ls-remote', $script:TempBare) @($remoteRefs.Output | Where-Object { $_ -match 'refs/heads/' }).Count -gt 0 | Should -Be $true } It 'first save with no upstream auto-publishes when a remote exists' { Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', $script:TempBare) | Out-Null Set-Content -LiteralPath (Join-Path $script:TempRepo 'README.md') -Value 'first save with publish' -Encoding UTF8 Save-Work 'auto-publish first save' $remoteRefs = Invoke-TestGit -ArgumentList @('ls-remote', $script:TempBare) @($remoteRefs.Output | Where-Object { $_ -match 'refs/heads/' }).Count -gt 0 | Should -Be $true } It 'NoPush leaves work local even when a remote is configured' { Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', $script:TempBare) | Out-Null Set-Content -LiteralPath (Join-Path $script:TempRepo 'README.md') -Value 'np test' -Encoding UTF8 Save-Work 'local only' -NoPush $remoteRefs = Invoke-TestGit -ArgumentList @('ls-remote', $script:TempBare) @($remoteRefs.Output | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count | Should -Be 0 } It 'commit messages have no UTF-8 BOM' { Set-Content -LiteralPath (Join-Path $script:TempRepo 'README.md') -Value 'bom test' -Encoding UTF8 Save-Work 'bom-free message' -NoPush $sha = (Invoke-TestGit -ArgumentList @('rev-parse', 'HEAD')).Output | Select-Object -First 1 $cat = Invoke-TestGit -ArgumentList @('cat-file', 'commit', $sha) $blankIndex = -1 for ($i = 0; $i -lt $cat.Output.Count; $i++) { if ([string]::IsNullOrWhiteSpace($cat.Output[$i])) { $blankIndex = $i; break } } $messageStart = if ($blankIndex -ge 0) { $blankIndex + 1 } else { 0 } $message = ($cat.Output[$messageStart..($cat.Output.Count - 1)] -join "`n").Trim() $message | Should -Be 'bom-free message' $bytes = [System.Text.Encoding]::UTF8.GetBytes($message) ($bytes.Count -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) | Should -Be $false } It 'LF-only files do not block save when autocrlf is enabled' { Invoke-TestGit -ArgumentList @('config', 'core.autocrlf', 'true') | Out-Null $lfPath = Join-Path $script:TempRepo 'lf.txt' [System.IO.File]::WriteAllText($lfPath, "line one`nline two`n", [System.Text.UTF8Encoding]::new($false)) $thrown = $null try { Save-Work 'lf-warning test' -NoPush } catch { $thrown = $_ } $thrown | Should -BeNullOrEmpty } It 'real merge conflicts block save with a plain-English message' { Set-Content -LiteralPath (Join-Path $script:TempRepo 'shared.txt') -Value "version A`n" -Encoding UTF8 Save-Work 'baseline for merge conflict' -NoPush $baseBranch = Get-TestCurrentBranch -Path $script:TempRepo Invoke-TestGit -ArgumentList @('checkout', '-b', 'feature') | Out-Null Set-Content -LiteralPath (Join-Path $script:TempRepo 'shared.txt') -Value "version B`n" -Encoding UTF8 Save-Work 'feature change' -NoPush Invoke-TestGit -ArgumentList @('checkout', $baseBranch) | Out-Null Set-Content -LiteralPath (Join-Path $script:TempRepo 'shared.txt') -Value "version C`n" -Encoding UTF8 Save-Work 'base change' -NoPush $merge = Invoke-TestGit -ArgumentList @('merge', 'feature') -AllowFailure @($merge.Output | Where-Object { $_ -match 'CONFLICT' }).Count -gt 0 | Should -Be $true $thrown = $null try { Save-Work 'attempt save during conflict' -NoPush } catch { $thrown = $_ } $thrown | Should -Not -BeNullOrEmpty $thrown.Exception.Message | Should -Not -Match '(?i)\bgit\b' } It 'every save invocation writes a log file with SUCCESS outcome' { Set-Content -LiteralPath (Join-Path $script:TempRepo 'README.md') -Value 'log test' -Encoding UTF8 Save-Work 'logs-success' -NoPush $logs = @(Get-ChildItem -LiteralPath $script:TempLogs -Filter 'Save-Work-*.log' -File) $logs.Count | Should -Be 1 $body = Get-Content -LiteralPath $logs[0].FullName -Raw $body | Should -Match 'Outcome: SUCCESS' $body | Should -Match 'Command:\s*Save-Work' } It 'logs failures and surfaces the path in the thrown message' { Set-Content -LiteralPath (Join-Path $script:TempRepo 'README.md') -Value 'fail test' -Encoding UTF8 Save-Work 'baseline for failure' -NoPush $bogusUrl = 'file:///' + (Join-Path ([System.IO.Path]::GetTempPath()) ('does-not-exist-' + [guid]::NewGuid().ToString('N') + '.git')).Replace('\','/') Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', $bogusUrl) | Out-Null Set-Content -LiteralPath (Join-Path $script:TempRepo 'change.txt') -Value 'next change' -Encoding UTF8 $thrown = $null try { Save-Work 'will-fail-on-publish' } catch { $thrown = $_ } $thrown | Should -Not -BeNullOrEmpty $thrown.Exception.Message | Should -Match '(?i)Details:' $logs = @(Get-ChildItem -LiteralPath $script:TempLogs -Filter 'Save-Work-*.log' -File | Sort-Object LastWriteTime) $logs.Count -gt 0 | Should -Be $true $body = Get-Content -LiteralPath $logs[-1].FullName -Raw $body | Should -Match 'Outcome: FAILURE' } It 'plain-English error message does not contain the word git' { Set-Content -LiteralPath (Join-Path $script:TempRepo 'README.md') -Value 'plain test' -Encoding UTF8 Save-Work 'plain baseline' -NoPush $bogusUrl = 'file:///' + (Join-Path ([System.IO.Path]::GetTempPath()) ('does-not-exist-' + [guid]::NewGuid().ToString('N') + '.git')).Replace('\','/') Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', $bogusUrl) | Out-Null Set-Content -LiteralPath (Join-Path $script:TempRepo 'change2.txt') -Value 'plain change' -Encoding UTF8 $thrown = $null try { Save-Work 'will-fail-plain' } catch { $thrown = $_ } $thrown | Should -Not -BeNullOrEmpty $userMessage = $thrown.Exception.Message -replace '(?ms)Details:.*$','' $userMessage | Should -Not -Match '(?i)\bgit\b' $userMessage | Should -Not -Match '(?i)\bupstream\b' $userMessage | Should -Not -Match '(?i)\bHEAD\b' $userMessage | Should -Not -Match '(?i)\brefspec\b' } It 'busy repo state (active merge) blocks save with a plain-English message' { Set-Content -LiteralPath (Join-Path $script:TempRepo 'shared.txt') -Value "busy A`n" -Encoding UTF8 Save-Work 'busy baseline' -NoPush $baseBranch = Get-TestCurrentBranch -Path $script:TempRepo Invoke-TestGit -ArgumentList @('checkout', '-b', 'busyfeat') | Out-Null Set-Content -LiteralPath (Join-Path $script:TempRepo 'shared.txt') -Value "busy B`n" -Encoding UTF8 Save-Work 'busyfeat change' -NoPush Invoke-TestGit -ArgumentList @('checkout', $baseBranch) | Out-Null Set-Content -LiteralPath (Join-Path $script:TempRepo 'shared.txt') -Value "busy C`n" -Encoding UTF8 Save-Work 'base busy change' -NoPush $merge = Invoke-TestGit -ArgumentList @('merge', 'busyfeat') -AllowFailure @($merge.Output | Where-Object { $_ -match 'CONFLICT' }).Count -gt 0 | Should -Be $true $thrown = $null try { Save-Work 'attempt during busy' -NoPush } catch { $thrown = $_ } $thrown | Should -Not -BeNullOrEmpty } } |