From 3628b6063328ea272254eb39d0fea9dbabd4dfe7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 16 May 2026 15:20:28 -0700 Subject: [PATCH] fix(sync): honor -WhatIf in Resolve-SyncAnchor bootstrap prompt Resolve-SyncAnchor used a custom Read-Host prompt that fired even under -WhatIf, forcing the user to answer `Proceed with bootstrap? [y/N]` interactively during a dry-run. -WhatIf must never block on user input. Changes: - Add [CmdletBinding(SupportsShouldProcess)] to Resolve-SyncAnchor. - When `False` is set, auto-return the bootstrap anchor with a `What if: would prompt to bootstrap; proceeding with dry-run preview` notice so the rest of the flow prints the would-be op list. - Replace Read-Host with a new Confirm-SyncBootstrap helper that wraps `.ShouldContinue` -- idiomatic PowerShell yes/no prompt; mockable in tests. Tests: - 4 new tests under `Describe 'Resolve-SyncAnchor -- WhatIf semantics (#114)'` covering WhatIf short-circuit, ShouldContinue invocation, decline path, and an end-to-end Invoke-PullSDLC -WhatIf on a fresh repo without prompting or writing state. - All 70 Pester tests pass. Closes #114 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Pull-SDLC.ai.Tests.ps1 | 73 ++++++++++++++++++++++++++++++++++++++++++ Pull-SDLC.ai.ps1 | 32 ++++++++++++++++-- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/Pull-SDLC.ai.Tests.ps1 b/Pull-SDLC.ai.Tests.ps1 index feca55f..44d5524 100644 --- a/Pull-SDLC.ai.Tests.ps1 +++ b/Pull-SDLC.ai.Tests.ps1 @@ -581,6 +581,79 @@ Describe 'Resolve-SyncAnchor -Bootstrap regression' { } } +Describe 'Resolve-SyncAnchor -- WhatIf semantics (#114)' { + + BeforeEach { + $script:wifRoot = Join-Path $TestDrive ("whatif-anchor-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $script:wifRoot -Force | Out-Null + Push-Location $script:wifRoot + try { + git init -q -b main + git config user.email c@c.c + git config user.name c + 'x' | Out-File -Encoding utf8 README.md -NoNewline + git add -A | Out-Null + git commit -q -m seed + } finally { Pop-Location } + } + + It 'auto-bootstraps without prompting when $WhatIfPreference is set' { + Mock -CommandName Read-Host -MockWith { throw 'Read-Host must not be invoked under -WhatIf' } + Mock -CommandName Confirm-SyncBootstrap -MockWith { throw 'Confirm-SyncBootstrap must not be invoked under -WhatIf' } + + $WhatIfPreference = $true + $anchor = Resolve-SyncAnchor -RepoRoot $script:wifRoot + + $anchor | Should -Not -BeNullOrEmpty + $anchor.Source | Should -Be 'bootstrap' + $anchor.Sha | Should -Be '' + Should -Invoke -CommandName Read-Host -Times 0 -Exactly + Should -Invoke -CommandName Confirm-SyncBootstrap -Times 0 -Exactly + } + + It 'uses Confirm-SyncBootstrap (not Read-Host) for the interactive prompt' { + Mock -CommandName Read-Host -MockWith { throw 'Read-Host should be replaced by Confirm-SyncBootstrap' } + Mock -CommandName Confirm-SyncBootstrap -MockWith { $true } + + $anchor = Resolve-SyncAnchor -RepoRoot $script:wifRoot + + $anchor.Source | Should -Be 'bootstrap' + $anchor.Sha | Should -Be '' + Should -Invoke -CommandName Confirm-SyncBootstrap -Times 1 -Exactly + Should -Invoke -CommandName Read-Host -Times 0 -Exactly + } + + It 'returns $null when Confirm-SyncBootstrap declines' { + Mock -CommandName Read-Host -MockWith { throw 'Read-Host should be replaced by Confirm-SyncBootstrap' } + Mock -CommandName Confirm-SyncBootstrap -MockWith { $false } + + $anchor = Resolve-SyncAnchor -RepoRoot $script:wifRoot + + $anchor | Should -BeNullOrEmpty + Should -Invoke -CommandName Confirm-SyncBootstrap -Times 1 -Exactly + } + + It 'Invoke-PullSDLC -WhatIf on a fresh repo exits 0 without prompting or writing state' { + $fx = New-DiffReplayFixture -Root (Join-Path $TestDrive ("whatif-e2e-" + [guid]::NewGuid().ToString('N'))) ` + -Seed { + New-Item -ItemType Directory -Path .github/agents -Force | Out-Null + 'baseline-claude' | Out-File -Encoding utf8 CLAUDE.md -NoNewline + 'aaa' | Out-File -Encoding utf8 .github/agents/a.md -NoNewline + } + # Strip any state/grep anchor so the no-anchor path is the only way forward. + $stateFile = Join-Path $fx.Consumer '.sdlc-ai-sync.json' + if (Test-Path $stateFile) { Remove-Item $stateFile -Force } + + Mock -CommandName Read-Host -MockWith { throw 'Read-Host must not be invoked under -WhatIf' } + + $rc = Invoke-PullSDLC -RepoRoot $fx.Consumer -RemoteName 'sdlc.ai' -NoFetch -WhatIf -AllowDefaultBranch + + $rc | Should -Be 0 + Test-Path $stateFile | Should -BeFalse + Should -Invoke -CommandName Read-Host -Times 0 -Exactly + } +} + Describe 'Invoke-PullSDLC auto-worktree mode' { BeforeEach { diff --git a/Pull-SDLC.ai.ps1 b/Pull-SDLC.ai.ps1 index 5a04345..454e4c6 100644 --- a/Pull-SDLC.ai.ps1 +++ b/Pull-SDLC.ai.ps1 @@ -228,14 +228,37 @@ function Set-SdlcSyncState { [System.IO.File]::WriteAllText($absPath, $json, (New-Object System.Text.UTF8Encoding $false)) } +function Confirm-SyncBootstrap { + <# + .SYNOPSIS + Asks the user (via $PSCmdlet.ShouldContinue) whether to bootstrap a + first-time sync from upstream HEAD. Extracted as its own function so + tests can mock the prompt deterministically. + .OUTPUTS + [bool] $true if the user accepts; $false otherwise. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)]$PSCmdletRef + ) + $message = 'No .sdlc-ai-sync.json and no prior sync commit found. Bootstrap will perform a full refresh from upstream HEAD (empty-tree anchor).' + $caption = 'Proceed with bootstrap?' + return $PSCmdletRef.ShouldContinue($message, $caption) +} + function Resolve-SyncAnchor { <# .SYNOPSIS Determines the anchor SHA. Returns @{ Sha = ; Source = }. Returns $null if no anchor could be determined and -Bootstrap / -NoPrompt not set and user declines the prompt. + .DESCRIPTION + Under $WhatIfPreference, the bootstrap prompt is skipped and the + function auto-returns a bootstrap anchor so the caller's dry-run can + proceed to enumerate the would-be op list. The real file writes and + sync commit are still gated by ShouldProcess in Invoke-PullSDLC. #> - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess = $true)] param( [string]$RepoRoot = '.', [switch]$Bootstrap, @@ -263,8 +286,11 @@ function Resolve-SyncAnchor { Write-Host '' Write-Host 'No .sdlc-ai-sync.json and no prior sync commit found.' -ForegroundColor Yellow Write-Host 'Bootstrap will perform a full refresh from upstream HEAD (empty-tree anchor).' -ForegroundColor Yellow - $ans = Read-Host 'Proceed with bootstrap? [y/N]' - if ($ans -match '^[Yy]') { + if ($WhatIfPreference) { + Write-Host 'What if: would prompt to bootstrap; proceeding with dry-run preview.' -ForegroundColor DarkGray + return @{ Sha = ''; Source = 'bootstrap' } + } + if (Confirm-SyncBootstrap -PSCmdletRef $PSCmdlet) { return @{ Sha = ''; Source = 'bootstrap' } } return $null