util/scripts/Reconcile-ProjectStandards.ps1

218 lines
6.9 KiB
PowerShell

[CmdletBinding()]
param(
[string]$RootPath = "C:\Users\stefan\git",
[string]$StandardsRepoPath = "C:\Users\stefan\git\project-standards",
[string[]]$Exclude = @('project-standards'),
[switch]$CheckOnly,
[switch]$Watch,
[int]$IntervalSeconds = 60
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$agentsTemplate = Join-Path $StandardsRepoPath 'templates/AGENTS.base.md'
$dodTemplate = Join-Path $StandardsRepoPath 'templates/DEFINITION_OF_DONE.base.md'
$gitIgnoreTemplate = Join-Path $StandardsRepoPath 'templates/.gitignore.base'
$gitAttributesTemplate = Join-Path $StandardsRepoPath 'templates/.gitattributes.base'
$editorConfigTemplate = Join-Path $StandardsRepoPath 'templates/.editorconfig.base'
$preCommitHookTemplate = Join-Path $StandardsRepoPath 'templates/pre-commit.base.sh'
$hooksReadmeTemplate = Join-Path $StandardsRepoPath 'templates/.githooks.README.base.md'
if (-not (Test-Path -Path $agentsTemplate -PathType Leaf)) {
throw "AGENTS template not found: $agentsTemplate"
}
if (-not (Test-Path -Path $dodTemplate -PathType Leaf)) {
throw "DoD template not found: $dodTemplate"
}
if (-not (Test-Path -Path $gitIgnoreTemplate -PathType Leaf)) {
throw "gitignore template not found: $gitIgnoreTemplate"
}
if (-not (Test-Path -Path $gitAttributesTemplate -PathType Leaf)) {
throw "gitattributes template not found: $gitAttributesTemplate"
}
if (-not (Test-Path -Path $editorConfigTemplate -PathType Leaf)) {
throw "editorconfig template not found: $editorConfigTemplate"
}
if (-not (Test-Path -Path $preCommitHookTemplate -PathType Leaf)) {
throw "pre-commit hook template not found: $preCommitHookTemplate"
}
if (-not (Test-Path -Path $hooksReadmeTemplate -PathType Leaf)) {
throw "hooks readme template not found: $hooksReadmeTemplate"
}
if ($IntervalSeconds -lt 5) {
throw 'IntervalSeconds must be >= 5.'
}
function Get-ContentHashOrMissing {
param([string]$Path)
if (-not (Test-Path -Path $Path -PathType Leaf)) {
return '__MISSING__'
}
return (Get-FileHash -Path $Path -Algorithm SHA256).Hash
}
function Ensure-FileFromTemplate {
param(
[string]$Template,
[string]$Target,
[switch]$OnlyCheck
)
$templateHash = Get-ContentHashOrMissing -Path $Template
$targetHash = Get-ContentHashOrMissing -Path $Target
if ($templateHash -eq $targetHash) {
return 'ok'
}
if ($OnlyCheck) {
return 'drift'
}
$parent = Split-Path -Parent $Target
if (-not (Test-Path -Path $parent -PathType Container)) {
New-Item -ItemType Directory -Path $parent | Out-Null
}
Copy-Item -Path $Template -Destination $Target -Force
return 'updated'
}
function Ensure-GitIgnoreEntriesFromTemplate {
param(
[string]$TemplatePath,
[string]$GitIgnorePath,
[switch]$OnlyCheck
)
$requiredEntries = Get-Content -Path $TemplatePath | Where-Object {
$_.Trim() -and -not $_.Trim().StartsWith('#')
}
if (-not (Test-Path -Path $GitIgnorePath -PathType Leaf)) {
if ($OnlyCheck) {
return 'drift'
}
New-Item -ItemType File -Path $GitIgnorePath | Out-Null
}
$lines = Get-Content -Path $GitIgnorePath
$missingEntry = $false
foreach ($entry in $requiredEntries) {
if ($lines -contains $entry) {
continue
}
$missingEntry = $true
if (-not $OnlyCheck) {
Add-Content -Path $GitIgnorePath -Value $entry
$lines += $entry
}
}
if (-not $missingEntry) {
return 'ok'
}
if ($OnlyCheck) {
return 'drift'
}
return 'updated'
}
function Invoke-ReconcileOnce {
param([switch]$OnlyCheck)
$resolvedRoot = (Resolve-Path -Path $RootPath -ErrorAction Stop).Path
$repos = Get-ChildItem -Path $resolvedRoot -Directory | Where-Object {
$Exclude -notcontains $_.Name
}
$summary = [ordered]@{
scanned = 0
updated = 0
drift = 0
}
foreach ($repo in $repos) {
$repoPath = $repo.FullName
$agentsTarget = Join-Path $repoPath 'AGENTS.md'
$dodTarget = Join-Path (Join-Path $repoPath 'docs') 'DEFINITION_OF_DONE.md'
$gitIgnoreTarget = Join-Path $repoPath '.gitignore'
$gitAttributesTarget = Join-Path $repoPath '.gitattributes'
$editorConfigTarget = Join-Path $repoPath '.editorconfig'
$preCommitHookTarget = Join-Path (Join-Path $repoPath '.githooks') 'pre-commit'
$hooksReadmeTarget = Join-Path (Join-Path $repoPath '.githooks') 'README.md'
$summary.scanned++
$agentsState = Ensure-FileFromTemplate -Template $agentsTemplate -Target $agentsTarget -OnlyCheck:$OnlyCheck
$dodState = Ensure-FileFromTemplate -Template $dodTemplate -Target $dodTarget -OnlyCheck:$OnlyCheck
$gitAttributesState = Ensure-FileFromTemplate -Template $gitAttributesTemplate -Target $gitAttributesTarget -OnlyCheck:$OnlyCheck
$editorConfigState = Ensure-FileFromTemplate -Template $editorConfigTemplate -Target $editorConfigTarget -OnlyCheck:$OnlyCheck
$preCommitHookState = Ensure-FileFromTemplate -Template $preCommitHookTemplate -Target $preCommitHookTarget -OnlyCheck:$OnlyCheck
$hooksReadmeState = Ensure-FileFromTemplate -Template $hooksReadmeTemplate -Target $hooksReadmeTarget -OnlyCheck:$OnlyCheck
$gitIgnoreState = Ensure-GitIgnoreEntriesFromTemplate -TemplatePath $gitIgnoreTemplate -GitIgnorePath $gitIgnoreTarget -OnlyCheck:$OnlyCheck
if (
$agentsState -eq 'updated' -or
$dodState -eq 'updated' -or
$gitAttributesState -eq 'updated' -or
$editorConfigState -eq 'updated' -or
$preCommitHookState -eq 'updated' -or
$hooksReadmeState -eq 'updated' -or
$gitIgnoreState -eq 'updated'
) {
$summary.updated++
Write-Host "UPDATED: $repoPath"
continue
}
if (
$agentsState -eq 'drift' -or
$dodState -eq 'drift' -or
$gitAttributesState -eq 'drift' -or
$editorConfigState -eq 'drift' -or
$preCommitHookState -eq 'drift' -or
$hooksReadmeState -eq 'drift' -or
$gitIgnoreState -eq 'drift'
) {
$summary.drift++
Write-Host "DRIFT: $repoPath"
continue
}
Write-Host "OK: $repoPath"
}
Write-Host "Summary -> scanned=$($summary.scanned), updated=$($summary.updated), drift=$($summary.drift)"
return $summary
}
if ($Watch) {
while ($true) {
$now = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host "[$now] Reconciling project standards..."
[void](Invoke-ReconcileOnce -OnlyCheck:$CheckOnly)
Start-Sleep -Seconds $IntervalSeconds
}
}
$result = Invoke-ReconcileOnce -OnlyCheck:$CheckOnly
if ($CheckOnly -and $result.drift -gt 0) {
exit 1
}