diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0dbd8c2..74ad615e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,18 @@ jobs: working-directory: polyphony run: pwsh -NoProfile -File tests/lint-prose-children.ps1 -Format github + - name: Lint (no tracked .polyphony/ state — Rev 4.2 invariant) + # Post-Rev 4.2 (PRs #251/#252/#254/#255) the polyphony per-root + # state (run.yaml, run.lock) lives EXCLUSIVELY at + # /polyphony//. The worktree-tracked + # `.polyphony/` directory is forbidden. `.gitignore` covers + # un-tracked state but does NOT prevent already-tracked paths + # from being committed nor `git add -f` overrides — this lint + # is the explicit invariant gate. + shell: pwsh + working-directory: polyphony + run: pwsh -NoProfile -File tests/lint-no-tracked-polyphony-state.ps1 + - name: Install Pester shell: pwsh run: Install-Module -Name Pester -Force -SkipPublisherCheck -Scope CurrentUser diff --git a/.gitignore b/.gitignore index 6a7f66c0..23aa294b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,13 @@ # Twig CLI tool local data .twig/ +# Polyphony per-root state lives under /polyphony// +# (run.yaml, run.lock). The legacy worktree-tracked location is forbidden +# post-Rev 4.2 (PRs #251/#252/#254/#255). Any reappearance of this directory +# in the worktree is a stale artifact from a pre-Rev-4.2 binary or a manual +# mistake — it should never be committed. +.polyphony/ + # User-specific files *.rsuser *.suo diff --git a/.polyphony/run.yaml b/.polyphony/run.yaml deleted file mode 100644 index 79405458..00000000 --- a/.polyphony/run.yaml +++ /dev/null @@ -1,13 +0,0 @@ -schema: 1 -root_id: 3066 -platform_project: dev.azure.com/dangreen-msft/Polyphony -created_at: 2026-05-09T21:41:13.2745090Z -created_by: dangreen -branch_model_version: 1 -plan_generations: {} -topology_hash: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -merge_groups: [] -rebases: [] -human_approvals: [] -retired_merge_group_ids: [] -merged_plan_prs: [] diff --git a/tests/lint-no-tracked-polyphony-state.Tests.ps1 b/tests/lint-no-tracked-polyphony-state.Tests.ps1 new file mode 100644 index 00000000..cb0e37fa --- /dev/null +++ b/tests/lint-no-tracked-polyphony-state.Tests.ps1 @@ -0,0 +1,117 @@ +BeforeAll { + $script:LintScriptPath = Join-Path $PSScriptRoot 'lint-no-tracked-polyphony-state.ps1' + + function New-TempRepo { + param([scriptblock] $Setup) + $repo = Join-Path ([System.IO.Path]::GetTempPath()) "no-tracked-polyphony-$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -ItemType Directory -Path $repo -Force | Out-Null + Push-Location -LiteralPath $repo + try { + git init --quiet 2>&1 | Out-Null + git config user.email "test@example.com" + git config user.name "test" + git config commit.gpgsign false + & $Setup + } finally { + Pop-Location + } + return $repo + } + + function Invoke-Lint { + param([string] $RepoRoot) + $output = pwsh -NoProfile -File $script:LintScriptPath -RepoRoot $RepoRoot 2>&1 + return @{ Output = ($output -join "`n"); ExitCode = $global:LASTEXITCODE } + } +} + +Describe 'lint-no-tracked-polyphony-state.ps1' { + + It 'PASSes when no .polyphony/ paths are tracked' { + $repo = New-TempRepo { + Set-Content -LiteralPath 'README.md' -Value '# test' + git add README.md 2>&1 | Out-Null + git commit --quiet -m 'init' 2>&1 | Out-Null + } + try { + $r = Invoke-Lint -RepoRoot $repo + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'PASS' + } finally { Remove-Item -LiteralPath $repo -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'PASSes when .polyphony/ exists on disk but is not tracked' { + $repo = New-TempRepo { + Set-Content -LiteralPath '.gitignore' -Value '.polyphony/' + New-Item -ItemType Directory -Path '.polyphony' | Out-Null + Set-Content -LiteralPath '.polyphony/run.yaml' -Value 'schema: 1' + git add .gitignore 2>&1 | Out-Null + git commit --quiet -m 'add gitignore' 2>&1 | Out-Null + } + try { + $r = Invoke-Lint -RepoRoot $repo + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'PASS' + } finally { Remove-Item -LiteralPath $repo -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'FAILs when .polyphony/run.yaml is tracked' { + $repo = New-TempRepo { + New-Item -ItemType Directory -Path '.polyphony' | Out-Null + Set-Content -LiteralPath '.polyphony/run.yaml' -Value 'schema: 1' + git add -f .polyphony/run.yaml 2>&1 | Out-Null + git commit --quiet -m 'leak' 2>&1 | Out-Null + } + try { + $r = Invoke-Lint -RepoRoot $repo + $r.ExitCode | Should -Be 1 + $r.Output | Should -Match 'FAIL' + $r.Output | Should -Match '.polyphony/run.yaml' + $r.Output | Should -Match 'Rev 4.2' + } finally { Remove-Item -LiteralPath $repo -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'FAILs when .polyphony/run.lock is tracked' { + $repo = New-TempRepo { + New-Item -ItemType Directory -Path '.polyphony' | Out-Null + Set-Content -LiteralPath '.polyphony/run.lock' -Value 'pid: 1234' + git add -f .polyphony/run.lock 2>&1 | Out-Null + git commit --quiet -m 'leak' 2>&1 | Out-Null + } + try { + $r = Invoke-Lint -RepoRoot $repo + $r.ExitCode | Should -Be 1 + $r.Output | Should -Match 'FAIL' + $r.Output | Should -Match '.polyphony/run.lock' + } finally { Remove-Item -LiteralPath $repo -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'FAILs and lists every offending path when multiple are tracked' { + $repo = New-TempRepo { + New-Item -ItemType Directory -Path '.polyphony' | Out-Null + Set-Content -LiteralPath '.polyphony/run.yaml' -Value 'schema: 1' + Set-Content -LiteralPath '.polyphony/run.lock' -Value 'pid: 1' + New-Item -ItemType Directory -Path '.polyphony/sub' | Out-Null + Set-Content -LiteralPath '.polyphony/sub/extra.txt' -Value 'x' + git add -f .polyphony 2>&1 | Out-Null + git commit --quiet -m 'leak' 2>&1 | Out-Null + } + try { + $r = Invoke-Lint -RepoRoot $repo + $r.ExitCode | Should -Be 1 + $r.Output | Should -Match '.polyphony/run.yaml' + $r.Output | Should -Match '.polyphony/run.lock' + $r.Output | Should -Match '.polyphony/sub/extra.txt' + } finally { Remove-Item -LiteralPath $repo -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'EXITs 2 when run outside a git repo' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) "no-git-$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -ItemType Directory -Path $tmp -Force | Out-Null + try { + $r = Invoke-Lint -RepoRoot $tmp + $r.ExitCode | Should -Be 2 + $r.Output | Should -Match 'FATAL' + } finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } + } +} diff --git a/tests/lint-no-tracked-polyphony-state.ps1 b/tests/lint-no-tracked-polyphony-state.ps1 new file mode 100644 index 00000000..e6e2303f --- /dev/null +++ b/tests/lint-no-tracked-polyphony-state.ps1 @@ -0,0 +1,89 @@ +<# +.SYNOPSIS + CI lint — fails if the worktree-tracked `.polyphony/` directory reappears. + +.DESCRIPTION + Post-Rev 4.2 (PRs #251/#252/#254/#255) the polyphony per-root state + (`run.yaml`, `run.lock`) lives EXCLUSIVELY at + `/polyphony//` and must never be tracked in + the worktree. The legacy `.polyphony/` directory is in `.gitignore`, + but `.gitignore` does NOT prevent already-tracked paths from being + committed nor does it prevent `git add -f` overrides. + + This lint walks the index (via `git ls-files`) and fails if any path + under `.polyphony/` is tracked. The check is fast (single git plumbing + call) and catches: + + - A revert/rebase that resurrects the deleted file. + - A pre-Rev-4.2 binary that writes `.polyphony/run.yaml` and a + contributor who runs `git add -f` to commit it. + - A worktree carry-over (the original AB#3067 dogfood failure + symptom) being mistakenly committed. + +.PARAMETER RepoRoot + Repository root. Default: parent of this script. + +.OUTPUTS + Exit 0 — clean (no `.polyphony/` paths tracked). + Exit 1 — at least one `.polyphony/` path is tracked. Prints the + offending paths and remediation guidance. +#> + +[CmdletBinding()] +param( + [string] $RepoRoot +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not $RepoRoot) { + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +} + +if (-not (Test-Path -LiteralPath (Join-Path $RepoRoot '.git'))) { + Write-Host "FATAL: $RepoRoot does not contain a .git directory" -ForegroundColor Red + exit 2 +} + +Push-Location -LiteralPath $RepoRoot +try { + # `git ls-files .polyphony/` lists every tracked path under the + # directory. Empty output means nothing is tracked — the desired + # state. `--error-unmatch` is intentionally NOT used (it would + # error when zero paths match, which is the success case here). + $tracked = git ls-files .polyphony/ 2>$null +} finally { + Pop-Location +} + +if ($LASTEXITCODE -ne 0) { + Write-Host "FATAL: 'git ls-files .polyphony/' failed (exit $LASTEXITCODE)" -ForegroundColor Red + exit 2 +} + +$paths = @($tracked | Where-Object { $_ -and $_.Trim() }) + +if ($paths.Count -eq 0) { + Write-Host "PASS: no `.polyphony/` paths tracked in the worktree (Rev 4.2 invariant holds)" -ForegroundColor Green + exit 0 +} + +Write-Host "" +Write-Host "FAIL: Rev 4.2 invariant violated — `.polyphony/` paths are tracked:" -ForegroundColor Red +foreach ($p in $paths) { + Write-Host " - $p" -ForegroundColor Red +} +Write-Host "" +Write-Host "Per Rev 4.2 (PRs #251/#252/#254/#255), polyphony per-root state lives" -ForegroundColor Cyan +Write-Host "EXCLUSIVELY at /polyphony//. The worktree" -ForegroundColor Cyan +Write-Host "`.polyphony/` directory must never be committed." -ForegroundColor Cyan +Write-Host "" +Write-Host "Remediation:" -ForegroundColor Cyan +Write-Host " 1. Confirm `.polyphony/` is in `.gitignore`." -ForegroundColor Cyan +Write-Host " 2. Untrack the offending paths:" -ForegroundColor Cyan +Write-Host " git rm --cached -r .polyphony/" -ForegroundColor Cyan +Write-Host " 3. Verify your local polyphony binary is post-Rev-4.2 (PR #255 or later)." -ForegroundColor Cyan +Write-Host " A pre-Rev-4.2 binary will keep regenerating the worktree state file." -ForegroundColor Cyan +Write-Host "" +exit 1