Tests/RemoveGitBranches.Tests.ps1
|
BeforeAll { $ModuleManifest = Join-Path (Split-Path -Parent $PSScriptRoot) 'RemoveGitBranches.psd1' Import-Module $ModuleManifest -Force function New-TestRepo { $suffix = [guid]::NewGuid().ToString('N') $remoteDir = Join-Path $TestDrive "$suffix-origin.git" $seedDir = Join-Path $TestDrive "$suffix-seed" $localDir = Join-Path $TestDrive "$suffix-work" git init -q --bare -b main $remoteDir | Out-Null git init -q -b main $seedDir | Out-Null Push-Location $seedDir git config user.email test@example.com git config user.name 'Test User' git config commit.gpgsign false git commit -q --allow-empty -m init git remote add origin $remoteDir git push -q origin main Pop-Location Remove-Item -Recurse -Force $seedDir git clone -q $remoteDir $localDir 2>$null | Out-Null Push-Location $localDir git config user.email test@example.com git config user.name 'Test User' git config commit.gpgsign false Pop-Location return $localDir } function New-LocalBranch([string]$Name) { git branch -q $Name main } function New-TrackedBranch([string]$Name) { git branch -q $Name main git push -q origin $Name 2>$null } } Describe 'Remove-GitBranches' { BeforeEach { $repo = New-TestRepo Push-Location $repo } AfterEach { Pop-Location } It '-Help prints usage without requiring a git repository' { Pop-Location Push-Location $TestDrive $output = Remove-GitBranches -Help *>&1 | Out-String $output | Should -Match 'USAGE' } It 'fails outside a git repository' { Pop-Location $outside = Join-Path $TestDrive 'not-a-repo' New-Item -ItemType Directory -Path $outside -Force | Out-Null Push-Location $outside $output = Remove-GitBranches -Yes *>&1 | Out-String $output | Should -Match 'Not inside a Git repository' } It 'reports nothing to delete when only the default branch exists' { $output = Remove-GitBranches -Yes *>&1 | Out-String $output | Should -Match 'No local branches to delete' } It 'deletes a local branch that never had a remote' { New-LocalBranch -Name 'orphan/no-remote' Remove-GitBranches -Yes *>&1 | Out-Null git branch --list 'orphan/no-remote' | Should -BeNullOrEmpty } It 'fetches and prunes, then deletes a branch whose remote counterpart was removed' { New-TrackedBranch -Name 'feature/merged' Remove-GitBranches -Yes *>&1 | Out-Null git branch --list 'feature/merged' | Should -Not -BeNullOrEmpty git push -q origin --delete feature/merged 2>$null Remove-GitBranches -Yes *>&1 | Out-Null git branch --list 'feature/merged' | Should -BeNullOrEmpty } It 'never deletes a local branch named master, even without a remote' { git branch -q master main Remove-GitBranches -Yes *>&1 | Out-Null git branch --list master | Should -Not -BeNullOrEmpty } It 'does not delete the current branch by default when declined' { git checkout -q -b feature/current main Mock -ModuleName RemoveGitBranches Read-Host { 'n' } $output = Remove-GitBranches *>&1 | Out-String $output | Should -Match 'No local branches to delete' git branch --list feature/current | Should -Not -BeNullOrEmpty } It 'confirms current-branch deletion, switches to default branch, then deletes it' { git checkout -q -b feature/current main Mock -ModuleName RemoveGitBranches Read-Host { 'y' } $output = Remove-GitBranches *>&1 | Out-String $output | Should -Match 'deleted successfully' (git rev-parse --abbrev-ref HEAD) | Should -Be 'main' git branch --list feature/current | Should -BeNullOrEmpty } It '-Yes deletes the current branch without prompting' { git checkout -q -b feature/current main Mock -ModuleName RemoveGitBranches Read-Host { throw 'Read-Host should not be called when -Yes is set' } Remove-GitBranches -Yes *>&1 | Out-Null (git rev-parse --abbrev-ref HEAD) | Should -Be 'main' git branch --list feature/current | Should -BeNullOrEmpty Should -Invoke -ModuleName RemoveGitBranches Read-Host -Times 0 } It '-DeleteCurrent skips the first prompt but still confirms deletion' { git checkout -q -b feature/current main Mock -ModuleName RemoveGitBranches Read-Host { 'y' } Remove-GitBranches -DeleteCurrent *>&1 | Out-Null git branch --list feature/current | Should -BeNullOrEmpty } It 'does not claim success and leaves current branch untouched when switch and checkout fallback both fail' { Set-Content -Path (Join-Path $repo 'shared.txt') -Value 'main-content' -NoNewline git add shared.txt git commit -q -m 'seed shared file' git checkout -q -b feature/current main Set-Content -Path (Join-Path $repo 'shared.txt') -Value 'feature-content' -NoNewline git add shared.txt git commit -q -m 'diverge shared file on feature branch' Set-Content -Path (Join-Path $repo 'shared.txt') -Value 'local-uncommitted-change' -NoNewline $output = Remove-GitBranches -DeleteCurrent -Yes *>&1 | Out-String $output | Should -Not -Match 'deleted successfully' (git branch --show-current) | Should -Be 'feature/current' (Get-Content (Join-Path $repo 'shared.txt') -Raw) | Should -Be 'local-uncommitted-change' } It 'aborts with an error when -DeleteCurrent has no default branch to switch to' { git branch -m main trunk Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $repo '.git/refs/remotes/origin/HEAD') git checkout -q -b feature/current trunk $output = Remove-GitBranches -DeleteCurrent -Yes *>&1 | Out-String $output | Should -Match 'default branch' (git rev-parse --abbrev-ref HEAD) | Should -Be 'feature/current' } It 'handles a local branch with a non-ASCII name' { New-LocalBranch -Name 'función/日本語' $output = Remove-GitBranches -Yes *>&1 | Out-String $output | Should -Match ([regex]::Escape('función/日本語')) git branch --list 'función/日本語' | Should -BeNullOrEmpty } # --- Additional behavioral guards --- It 'does not claim success when a candidate branch is checked out in a linked worktree, and the branch survives' -Tag 'survive-worktree-checked-out-branch' { New-LocalBranch -Name 'orphan/in-worktree' $worktree = Join-Path $TestDrive ('linked-wt-' + [guid]::NewGuid().ToString('N')) git worktree add -q $worktree 'orphan/in-worktree' 2>$null | Out-Null $output = Remove-GitBranches -Yes *>&1 | Out-String $output | Should -Not -Match 'deleted successfully' git branch --list 'orphan/in-worktree' | Should -Not -BeNullOrEmpty } It 'aborts instead of reporting a normal outcome when fetch/prune fails against an unreachable remote' -Tag 'abort-on-failed-fetch' { git remote set-url origin (Join-Path $TestDrive 'does-not-exist.git') $previousPrompt = $env:GIT_TERMINAL_PROMPT $env:GIT_TERMINAL_PROMPT = '0' try { $output = Remove-GitBranches -Yes *>&1 | Out-String } finally { $env:GIT_TERMINAL_PROMPT = $previousPrompt } $output | Should -Match 'Failed to fetch' $output | Should -Not -Match 'No local branches to delete' $output | Should -Not -Match 'deleted successfully' } It 'does not terminate with an unhandled error when HEAD is detached, stays detached, and still deletes the orphan' -Tag 'survive-detached-head' { New-LocalBranch -Name 'orphan/no-remote' git checkout -q --detach HEAD { Remove-GitBranches -Yes *>&1 | Out-Null } | Should -Not -Throw (git rev-parse --abbrev-ref HEAD) | Should -Be 'HEAD' git branch --list 'orphan/no-remote' | Should -BeNullOrEmpty } It 'keeps a branch tracked on a non-origin remote (only remote named upstream)' -Tag 'resolve-non-origin-remote' { git remote rename origin upstream git branch -q feature/tracked main git push -q upstream feature/tracked 2>$null git fetch -q upstream 2>$null Remove-GitBranches -Yes *>&1 | Out-Null git branch --list feature/tracked | Should -Not -BeNullOrEmpty } It 'does not delete the resolved default branch (origin/HEAD target) when it has no remote counterpart' -Tag 'protect-resolved-default-branch' { New-TrackedBranch -Name 'develop' New-TrackedBranch -Name 'feature/keep' git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/develop git checkout -q feature/keep git branch -q -D main git push -q origin --delete develop 2>$null Remove-GitBranches -Yes *>&1 | Out-Null git branch --list develop | Should -Not -BeNullOrEmpty } It "declines deletion when the final prompt is answered 'no', not just 'n'" -Tag 'decline-on-any-n-answer' { New-LocalBranch -Name 'orphan/no-remote' Mock -ModuleName RemoveGitBranches Read-Host { 'no' } $output = Remove-GitBranches *>&1 | Out-String $output | Should -Match 'No local branches were deleted' git branch --list 'orphan/no-remote' | Should -Not -BeNullOrEmpty } It 'rejects an unknown parameter at binding and deletes nothing' -Tag 'reject-unknown-flag' { New-LocalBranch -Name 'orphan/no-remote' { Remove-GitBranches -DryRun -Yes } | Should -Throw -ErrorId 'NamedParameterNotFound,Remove-GitBranches' git branch --list 'orphan/no-remote' | Should -Not -BeNullOrEmpty } It 'surfaces failure when an early branch in a multi-branch batch fails to delete' -Tag 'survive-worktree-checked-out-branch' { New-LocalBranch -Name 'aaa/in-worktree' New-LocalBranch -Name 'zzz/orphan' $worktree = Join-Path $TestDrive ('linked-wt-' + [guid]::NewGuid().ToString('N')) git worktree add -q $worktree 'aaa/in-worktree' 2>$null | Out-Null $output = Remove-GitBranches -Yes *>&1 | Out-String $output | Should -Not -Match 'deleted successfully' git branch --list 'aaa/in-worktree' | Should -Not -BeNullOrEmpty git branch --list 'zzz/orphan' | Should -BeNullOrEmpty } It 'keeps a branch tracked on origin when a second, alphabetically-earlier remote is also configured' -Tag 'resolve-non-origin-remote' { New-TrackedBranch -Name 'feature/tracked' $secondRemote = Join-Path $TestDrive ('aremote-' + [guid]::NewGuid().ToString('N') + '.git') git init -q --bare $secondRemote | Out-Null git remote add aremote $secondRemote git fetch -q aremote 2>$null Remove-GitBranches -Yes *>&1 | Out-Null git branch --list 'feature/tracked' | Should -Not -BeNullOrEmpty } It 'keeps the current branch (and stays on it) when it is tracked only on a later-sorting remote' -Tag 'resolve-non-origin-remote' { $secondRemote = Join-Path $TestDrive ('aaa-' + [guid]::NewGuid().ToString('N') + '.git') git init -q --bare $secondRemote | Out-Null git remote add aaa $secondRemote git fetch -q aaa 2>$null New-TrackedBranch -Name 'feature/current' git checkout -q feature/current New-LocalBranch -Name 'orphan/x' Remove-GitBranches -Yes *>&1 | Out-Null (git rev-parse --abbrev-ref HEAD) | Should -Be 'feature/current' git branch --list 'feature/current' | Should -Not -BeNullOrEmpty } } Describe 'Remove-GitBranches module initialization' { BeforeAll { $script:ModuleManifest = Join-Path (Split-Path -Parent $PSScriptRoot) 'RemoveGitBranches.psd1' $script:ChildEngine = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh' } else { 'powershell.exe' } # PATH with every directory holding a git executable removed. Assigned to # the child process only (never to $env:PATH here), so the parent Pester # process keeps its own git resolution intact. $script:PathWithoutGit = ( $env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -and -not ( Get-ChildItem -Path $_ -Filter 'git.*' -File -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -eq 'git' } ) } ) -join [IO.Path]::PathSeparator } It 'aborts import with a clear error when git is absent from PATH' -Tag 'refuse-load-without-git' { $childScript = Join-Path $TestDrive 'import-without-git.ps1' @' param([string]$ManifestPath, [string]$NewPath) $env:PATH = $NewPath Import-Module $ManifestPath -Force '@ | Set-Content -Path $childScript -Encoding utf8 $stdoutFile = Join-Path $TestDrive 'init-guard.stdout.txt' $stderrFile = Join-Path $TestDrive 'init-guard.stderr.txt' $childProcess = Start-Process -FilePath $ChildEngine ` -ArgumentList @('-NoProfile', '-NonInteractive', '-File', $childScript, '-ManifestPath', $ModuleManifest, '-NewPath', $PathWithoutGit) ` -NoNewWindow -PassThru ` -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile if (-not $childProcess.WaitForExit(30000)) { Stop-Process -Id $childProcess.Id -Force -ErrorAction SilentlyContinue throw "Child $ChildEngine process did not exit within 30 seconds while importing with git hidden from PATH." } $childOutput = @( (Get-Content -Path $stdoutFile -Raw -ErrorAction SilentlyContinue) (Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue) ) -join "`n" $childOutput | Should -Match 'Git is not installed or not in the system PATH' } It 'throws (not merely warns) and leaves no usable command when git is absent from PATH' -Tag 'refuse-load-without-git' { $childScript = Join-Path $TestDrive 'load-verdict-without-git.ps1' @' param([string]$ManifestPath, [string]$NewPath) $env:PATH = $NewPath $imported = $true try { Import-Module $ManifestPath -Force -ErrorAction Stop } catch { $imported = $false Write-Output 'RGB_IMPORT_REFUSED' } if ($imported) { $help = try { Remove-GitBranches -Help *>&1 | Out-String } catch { '' } if ($help -match 'USAGE') { Write-Output 'RGB_FULLY_USABLE' } else { Write-Output 'RGB_IMPORTED_NOT_USABLE' } } '@ | Set-Content -Path $childScript -Encoding utf8 $stdoutFile = Join-Path $TestDrive 'load-verdict.stdout.txt' $stderrFile = Join-Path $TestDrive 'load-verdict.stderr.txt' $childProcess = Start-Process -FilePath $ChildEngine ` -ArgumentList @('-NoProfile', '-NonInteractive', '-File', $childScript, '-ManifestPath', $ModuleManifest, '-NewPath', $PathWithoutGit) ` -NoNewWindow -PassThru ` -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile if (-not $childProcess.WaitForExit(30000)) { Stop-Process -Id $childProcess.Id -Force -ErrorAction SilentlyContinue throw "Child $ChildEngine process did not exit within 30 seconds while importing with git hidden from PATH." } $childOutput = @( (Get-Content -Path $stdoutFile -Raw -ErrorAction SilentlyContinue) (Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue) ) -join "`n" $childOutput | Should -Match 'RGB_IMPORT_REFUSED' $childOutput | Should -Not -Match 'RGB_FULLY_USABLE' } } |