diff --git a/Pull-SDLC.ai.Tests.ps1 b/Pull-SDLC.ai.Tests.ps1 index d479b60..bf52358 100644 --- a/Pull-SDLC.ai.Tests.ps1 +++ b/Pull-SDLC.ai.Tests.ps1 @@ -2439,6 +2439,71 @@ Describe 'Invoke-SetupGitHubSsh' -Skip:(-not $IsWindows) { ($addCalls | Where-Object { $_ -match '\bgit@github\.com:$' }) | Should -Not -BeNullOrEmpty # The already-present https value must NOT be re-added. ($addCalls | Where-Object { $_ -match '\bhttps://github\.com/$' }) | Should -BeNullOrEmpty - } -} + } +} + +Describe 'Test-LocalDriftOnManagedPaths (issue #178: EOL false positive)' { + BeforeEach { + $script:driftRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("drift-eol-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $script:driftRepo | Out-Null + Push-Location $script:driftRepo + git init -q -b main + git config user.email 't@t.t' + git config user.name 't' + # Disable autocrlf so the test controls blob bytes deterministically. + git config core.autocrlf false + } + + AfterEach { + Pop-Location + Remove-Item -Recurse -Force -LiteralPath $script:driftRepo -ErrorAction SilentlyContinue + } + + It 'does NOT report drift when HEAD differs from the anchor only by CRLF/LF line endings' { + # Anchor commit: managed file stored with LF (the upstream form). + [System.IO.File]::WriteAllText((Join-Path $script:driftRepo 'CLAUDE.md'), "line one`nline two`n") + git add -A | Out-Null + git commit -q -m 'anchor (LF)' + $anchor = (git rev-parse HEAD).Trim() + # HEAD commit: identical content re-committed with CRLF (a Windows consumer). + [System.IO.File]::WriteAllText((Join-Path $script:driftRepo 'CLAUDE.md'), "line one`r`nline two`r`n") + git add -A | Out-Null + git commit -q -m 'consumer sync (CRLF)' + + # Sanity: the raw blob SHAs really do differ (otherwise the test is vacuous). + (git rev-parse "HEAD:CLAUDE.md").Trim() | Should -Not -Be (git rev-parse "${anchor}:CLAUDE.md").Trim() + + $drift = @(Test-LocalDriftOnManagedPaths -Anchor $anchor -ManagedPaths 'CLAUDE.md' -RepoRoot $script:driftRepo) + $drift.Count | Should -Be 0 + } + + It 'reports drift when HEAD has a genuine content change beyond line endings' { + [System.IO.File]::WriteAllText((Join-Path $script:driftRepo 'CLAUDE.md'), "line one`nline two`n") + git add -A | Out-Null + git commit -q -m 'anchor' + $anchor = (git rev-parse HEAD).Trim() + [System.IO.File]::WriteAllText((Join-Path $script:driftRepo 'CLAUDE.md'), "line one`nEDITED line two`n") + git add -A | Out-Null + git commit -q -m 'real local edit' + + $drift = @(Test-LocalDriftOnManagedPaths -Anchor $anchor -ManagedPaths 'CLAUDE.md' -RepoRoot $script:driftRepo) + $drift.Count | Should -Be 1 + $drift[0].Path | Should -Be 'CLAUDE.md' + } + + It 'reports drift for an upstream-deleted file the consumer still has committed' { + # Anchor does NOT contain CLAUDE.md (simulates a file deleted upstream). + [System.IO.File]::WriteAllText((Join-Path $script:driftRepo 'README.txt'), "seed`n") + git add -A | Out-Null + git commit -q -m 'anchor without managed file' + $anchor = (git rev-parse HEAD).Trim() + [System.IO.File]::WriteAllText((Join-Path $script:driftRepo 'CLAUDE.md'), "orphaned content`n") + git add -A | Out-Null + git commit -q -m 'consumer still carries deleted file' + + $drift = @(Test-LocalDriftOnManagedPaths -Anchor $anchor -ManagedPaths 'CLAUDE.md' -RepoRoot $script:driftRepo) + $drift.Count | Should -Be 1 + $drift[0].Path | Should -Be 'CLAUDE.md' + } +} diff --git a/Pull-SDLC.ai.ps1 b/Pull-SDLC.ai.ps1 index 78fdbaf..1e99711 100644 --- a/Pull-SDLC.ai.ps1 +++ b/Pull-SDLC.ai.ps1 @@ -1081,6 +1081,15 @@ function Test-LocalDriftOnManagedPaths { For each upstream-managed path that currently exists at HEAD, compares its HEAD blob to the same path's blob at $Anchor. Returns an array of @{ Path = ...; Commit = ' ' } for drift entries. + .DESCRIPTION + A fast blob-SHA comparison is used as a pre-filter. Because blob SHAs are + end-of-line sensitive, a Windows consumer whose git committed the synced + files with CRLF will have HEAD blobs that differ from the upstream LF + blobs even though the content is identical. To avoid this false positive, + any path whose blobs differ is confirmed with an EOL-insensitive content + diff (git diff --ignore-cr-at-eol); only paths that still differ after + ignoring CR-at-EOL are reported as drift. Genuine divergence -- including + upstream-deleted files the consumer still has committed -- is preserved. #> [CmdletBinding()] param( @@ -1101,6 +1110,10 @@ function Test-LocalDriftOnManagedPaths { $headSha = (& git rev-parse "HEAD:$p" 2>$null) $anchorSha = (& git rev-parse "${Anchor}:$p" 2>$null) if ($headSha -and $headSha -ne $anchorSha) { + # Blobs differ. Confirm this is a real content change and not just + # CRLF/LF normalization before flagging it as a policy violation. + & git diff --quiet --ignore-cr-at-eol $Anchor HEAD -- $p 2>$null + if ($LASTEXITCODE -eq 0) { continue } $log = (& git log -1 --pretty='%h %s' -- $p 2>$null) -join '' $drift.Add(@{ Path = $p; Commit = $log }) | Out-Null }