<# .SYNOPSIS Commit-message gate for CI: run the `gitlint` pre-commit hook over every commit in a pushed / pull-request range. .DESCRIPTION Why this file exists (2026-10-11 CI audit): CI always reports ONE hook fewer than .pre-commit-config.yaml defines (measured 2026-10-12: master 18 defined / 17 reported, v2-on-ui 26 / 25). The missing one is `gitlint`: its hook definition declares `stages: [commit-msg]`, while `pre-commit run --all-files` only runs the `pre-commit` stage. So the commit-message rule from AGENTS.md (title < 100 chars, body lines <= 80) was enforced on developer machines and NEVER in CI -- a silent gate gap, not a failure. pre-commit cannot check "the commits of this push" on its own, so this script feeds each commit message to the hook through `--hook-stage commit-msg --commit-msg-filename `, one commit at a time, and aggregates the result. Message files are written INSIDE the workspace (under ci-artifacts/, which is gitignored) for two hard reasons: * pre-commit rejects a message path on another drive than the repo ("path is on mount 'C:', start on mount 'D:'" -- measured here); * they must be UTF-8 WITHOUT BOM, because a BOM counts as a character of the title and would distort the 100-char rule. .PARAMETER BaseSha Exclusive lower bound. Empty, all-zero (branch creation / deletion), or an unreachable SHA (force push) falls back to the newest -MaxCommits commits. .PARAMETER HeadSha Inclusive upper bound. Defaults to HEAD. .PARAMETER MaxCommits Upper bound on how many commits are checked, so a big range cannot run for hours. Merge commits are skipped (their messages are generated by git, and the local hook is what guards hand-written ones). .PARAMETER PythonExe Interpreter that provides pre-commit. Defaults to $env:PYTHON, then to scripts/ci_find_python.ps1 auto-discovery. .OUTPUTS None. Diagnostics go to the host as GitHub workflow commands. Exit codes: 0 = all messages valid, 1 = at least one invalid, 2 = setup error. .NOTES Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936). #> param( [string]$BaseSha = '', [string]$HeadSha = 'HEAD', [int]$MaxCommits = 50, [string]$PythonExe = '' ) $ErrorActionPreference = 'Continue' $repoRoot = Split-Path -Parent $PSScriptRoot Push-Location -LiteralPath $repoRoot try { # git emits UTF-8 commit messages; PS 5.1 would otherwise decode them with # the console code page (CP936) and mangle every Chinese title. $savedConsoleEncoding = [Console]::OutputEncoding try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { } try { # --- resolve the interpreter ---------------------------------------- if ([string]::IsNullOrWhiteSpace($PythonExe)) { $PythonExe = $env:PYTHON } if ([string]::IsNullOrWhiteSpace($PythonExe)) { $PythonExe = & (Join-Path $PSScriptRoot 'ci_find_python.ps1') -NeedPreCommit if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($PythonExe)) { Write-Host '::error::gitlint: no Python with pre-commit available' exit 2 } } $PythonExe = "$PythonExe".Trim() Write-Host "gitlint: using python $PythonExe" # --- git identity guard --------------------------------------------- # Measured on CI 2026-10-12 (task 31): actions/checkout points HOME at # a per-job temp dir and the SYSTEM account has no global identity, so # gitlint aborted with "Missing git configuration: please set # user.name" and exit code 254 BEFORE reading a single rule. Every push # failed this gate no matter how good the message was, and the log made # it look like a message violation. # Inject a throwaway identity through GIT_CONFIG_* (git >= 2.31): it is # inherited by the child git processes only, so NO file is written and # a developer machine keeps its own identity untouched. gitlint needs # an identity merely to start; it does not judge the author. $savedPref = $ErrorActionPreference $ErrorActionPreference = 'Continue' $idName = git config user.name 2>$null $idMail = git config user.email 2>$null $ErrorActionPreference = $savedPref if (-not "$idName".Trim() -or -not "$idMail".Trim()) { if ($env:GIT_CONFIG_COUNT) { Write-Host '::warning::gitlint: GIT_CONFIG_* already set by the caller -- leaving it alone' } else { $env:GIT_CONFIG_COUNT = '2' $env:GIT_CONFIG_KEY_0 = 'user.name' $env:GIT_CONFIG_VALUE_0 = 'dtu-ci' $env:GIT_CONFIG_KEY_1 = 'user.email' $env:GIT_CONFIG_VALUE_1 = 'dtu-ci@invalid.local' Write-Host '::warning::gitlint: no git identity here -- injected a throwaway one via GIT_CONFIG_* (needed only so gitlint can start)' } } # --- resolve the commit range --------------------------------------- $range = '' $cleanBase = "$BaseSha".Trim() if ($cleanBase -match '^[0-9a-fA-F]{40}$' -and $cleanBase -notmatch '^0+$') { $probe = "$cleanBase^{commit}" git cat-file -e $probe 2>$null if ($LASTEXITCODE -eq 0) { $range = "$cleanBase..$HeadSha" } else { Write-Host "::warning::gitlint: base $cleanBase is not reachable in this clone -- falling back to the newest $MaxCommits commit(s)" } } $savedPref = $ErrorActionPreference $ErrorActionPreference = 'Continue' if ($range) { $shas = git log --no-merges --format=%H $range 2>$null } else { $shas = git log --no-merges --format=%H -n $MaxCommits $HeadSha 2>$null } $ErrorActionPreference = $savedPref $shas = @($shas | Where-Object { $_ -and "$_".Trim() }) if ($shas.Count -eq 0) { Write-Host '::notice::gitlint: no non-merge commits in range -- nothing to check' exit 0 } if ($shas.Count -gt $MaxCommits) { Write-Host "::warning::gitlint: range has $($shas.Count) commits, checking the newest $MaxCommits" $shas = $shas[0..($MaxCommits - 1)] } # --- message files: inside the workspace, UTF-8 without BOM --------- $msgDir = Join-Path $repoRoot 'ci-artifacts\gitlint' New-Item -ItemType Directory -Force -Path $msgDir | Out-Null $utf8NoBom = New-Object System.Text.UTF8Encoding($false) $checked = 0 $failed = 0 $gateBroken = $false foreach ($sha in $shas) { $sha = "$sha".Trim() $checked++ $savedPref = $ErrorActionPreference $ErrorActionPreference = 'Continue' $body = git log -1 --format=%B $sha 2>$null $ErrorActionPreference = $savedPref $titleLine = (git log -1 --format=%s $sha 2>$null | Select-Object -First 1) $msgPath = Join-Path $msgDir "$sha.txt" $text = (($body | Out-String) -replace "`r`n", "`n").TrimEnd("`n") [System.IO.File]::WriteAllText($msgPath, $text, $utf8NoBom) Write-Host "----------------------------------------" Write-Host "commit $sha title: $titleLine" $savedPref = $ErrorActionPreference $ErrorActionPreference = 'Continue' # ForEach-Object { "$_" } unwraps the ErrorRecords that PS 5.1's # 2>&1 builds for every native stderr line, without disturbing # $LASTEXITCODE (docs/CI guide, section 7.3). $hookOut = & $PythonExe -s -m pre_commit run --hook-stage commit-msg ` --commit-msg-filename $msgPath gitlint 2>&1 | ForEach-Object { "$_" } $rc = $LASTEXITCODE $ErrorActionPreference = $savedPref $hookOut | ForEach-Object { Write-Host $_ } if ($rc -eq 254 -or ($hookOut -match 'Missing git configuration')) { # The gate itself could not run. Never report that as a # message violation -- it is a runner/environment fault and # needs a different fix (see the identity guard above). Write-Host "::error::gitlint: GATE BROKEN (exit $rc, git config error) at $($sha.Substring(0, 8)) -- NOT a message violation" $gateBroken = $true } elseif ($rc -ne 0) { $failed++ Write-Host "::error::gitlint: commit $($sha.Substring(0, 8)) rejected -- `"$titleLine`"" } } $summary = "gitlint: $checked commit message(s) checked, $failed failed" Write-Host "::notice::$summary" if ($env:GITHUB_STEP_SUMMARY) { Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value '### Commit message gate (gitlint)' -Encoding utf8 Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8 } if ($gateBroken) { Write-Host '::error::commit message gate could not RUN -- fix the runner git identity; do NOT read this as a message violation' exit 2 } if ($failed -gt 0) { Write-Host '::error::commit message gate failed -- see the gitlint output above for the exact rule id (rules in .gitlint; AGENTS.md: title <= 100 chars, body lines <= 80)' exit 1 } exit 0 } finally { try { [Console]::OutputEncoding = $savedConsoleEncoding } catch { } } } finally { Pop-Location }