Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions Pull-SDLC.ai.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 29 additions & 3 deletions Pull-SDLC.ai.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <sha or empty>; Source = <state|grep|bootstrap> }.
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,
Expand Down Expand Up @@ -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
Expand Down
Loading