From 79a6865ec9084a65237ed1b876fbf49afe46849d Mon Sep 17 00:00:00 2001 From: Richard Murillo <6811113+rjmurillo@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:30:49 -0600 Subject: [PATCH 1/5] ci: add git hooks for local CI parity Add pre-commit and pre-push hooks that catch linting and formatting issues locally before they reach CI. Hooks auto-configure on first `dotnet build` via MSBuild target with sentinel file. Pre-commit: C# format and markdownlint auto-fix with re-stage, yamllint/shellcheck/actionlint/JSON lint-only checks. Pre-push: build with PedanticMode + test suite. Architecture: bash shims delegate to PowerShell Core scripts, matching project's existing cross-platform scripting convention. Missing optional tools skip with warnings. Co-Authored-By: Claude Opus 4.6 --- .gitattributes | 5 +- .githooks/hooks/Invoke-PreCommit.ps1 | 118 ++++++++++++++++++++++++ .githooks/hooks/Invoke-PrePush.ps1 | 38 ++++++++ .githooks/lib/LintHelpers.ps1 | 105 +++++++++++++++++++++ .githooks/pre-commit | 4 + .githooks/pre-push | 4 + CONTRIBUTING.md | 31 +++++++ Directory.Build.targets | 1 + build/targets/githooks/GitHooks.targets | 22 +++++ 9 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 .githooks/hooks/Invoke-PreCommit.ps1 create mode 100644 .githooks/hooks/Invoke-PrePush.ps1 create mode 100644 .githooks/lib/LintHelpers.ps1 create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-push create mode 100644 build/targets/githooks/GitHooks.targets diff --git a/.gitattributes b/.gitattributes index b7ad090e8..5080a30f8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -114,4 +114,7 @@ # Fix syntax highlighting on GitHub to allow comments -.vscode/*.json linguist-language=JSON-with-Comments \ No newline at end of file +.vscode/*.json linguist-language=JSON-with-Comments + +# Git hooks must use LF line endings (Git Bash on Windows requires it) +.githooks/** text eol=lf \ No newline at end of file diff --git a/.githooks/hooks/Invoke-PreCommit.ps1 b/.githooks/hooks/Invoke-PreCommit.ps1 new file mode 100644 index 000000000..63802fc1c --- /dev/null +++ b/.githooks/hooks/Invoke-PreCommit.ps1 @@ -0,0 +1,118 @@ +[CmdletBinding()] +param() + +$repoRoot = git rev-parse --show-toplevel +. "$PSScriptRoot/../lib/LintHelpers.ps1" + +Write-Host "Running pre-commit checks..." -ForegroundColor Cyan + +try { + # --- C# formatting (auto-fix + re-stage) --- + $csFiles = Get-StagedFiles -Extensions @('.cs') + if ($csFiles.Count -gt 0) { + Write-Section "C# Formatting" + $includePaths = ($csFiles | ForEach-Object { "--include", $_ }) + Invoke-AutoFix -Label "dotnet format" -Files $csFiles -FixCommand { + dotnet format "$repoRoot/Moq.Analyzers.sln" --verbosity quiet @includePaths 2>&1 | Out-Null + } + # Verify formatting is clean after fix + $output = dotnet format "$repoRoot/Moq.Analyzers.sln" --verify-no-changes --verbosity quiet @includePaths 2>&1 + Write-Result -Check "dotnet format" -Passed ($LASTEXITCODE -eq 0) + if ($LASTEXITCODE -ne 0) { + Write-Host $output + } + } + + # --- Markdown linting (auto-fix + re-stage) --- + $mdFiles = Get-StagedFiles -Extensions @('.md') + if ($mdFiles.Count -gt 0) { + Write-Section "Markdown Lint" + if (Test-ToolAvailable -Command "markdownlint-cli2" -InstallHint "npm install -g markdownlint-cli2") { + $fullPaths = $mdFiles | ForEach-Object { Join-Path $repoRoot $_ } + Invoke-AutoFix -Label "markdownlint-cli2" -Files $mdFiles -FixCommand { + markdownlint-cli2 --fix $fullPaths 2>&1 | Out-Null + } + # Verify lint is clean after fix + $output = markdownlint-cli2 $fullPaths 2>&1 + Write-Result -Check "markdownlint-cli2" -Passed ($LASTEXITCODE -eq 0) + if ($LASTEXITCODE -ne 0) { + Write-Host $output + } + } + } + + # --- YAML linting (lint only) --- + $yamlFiles = Get-StagedFiles -Extensions @('.yml', '.yaml') + if ($yamlFiles.Count -gt 0) { + Write-Section "YAML Lint" + if (Test-ToolAvailable -Command "yamllint" -InstallHint "pip install yamllint") { + $fullPaths = $yamlFiles | ForEach-Object { Join-Path $repoRoot $_ } + $output = yamllint -c (Join-Path $repoRoot ".yamllint.yml") $fullPaths 2>&1 + Write-Result -Check "yamllint" -Passed ($LASTEXITCODE -eq 0) + if ($LASTEXITCODE -ne 0) { + Write-Host $output + } + } + } + + # --- JSON linting (lint only, exclude .verified.json) --- + $jsonFiles = Get-StagedFiles -Extensions @('.json') + $jsonFiles = $jsonFiles | Where-Object { $_ -notmatch '\.verified\.json$' } + if ($jsonFiles.Count -gt 0) { + Write-Section "JSON Lint" + if (Test-ToolAvailable -Command "python3" -InstallHint "https://www.python.org/downloads/") { + $jsonPassed = $true + foreach ($file in $jsonFiles) { + $fullPath = Join-Path $repoRoot $file + $output = python3 -m json.tool $fullPath 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host " Invalid JSON: $file" + Write-Host $output + $jsonPassed = $false + } + } + Write-Result -Check "json validation" -Passed $jsonPassed + } + } + + # --- Shell script linting (lint only) --- + $shellFiles = Get-StagedFiles -Extensions @('.sh', '.bash') + if ($shellFiles.Count -gt 0) { + Write-Section "Shell Lint" + if (Test-ToolAvailable -Command "shellcheck" -InstallHint "https://github.com/koalaman/shellcheck#installing") { + $fullPaths = $shellFiles | ForEach-Object { Join-Path $repoRoot $_ } + $output = shellcheck $fullPaths 2>&1 + Write-Result -Check "shellcheck" -Passed ($LASTEXITCODE -eq 0) + if ($LASTEXITCODE -ne 0) { + Write-Host $output + } + } + } + + # --- GitHub Actions linting (lint only) --- + $workflowFiles = Get-StagedFiles -Extensions @('.yml', '.yaml') + $workflowFiles = $workflowFiles | Where-Object { $_ -match '^\.github/workflows/' } + if ($workflowFiles.Count -gt 0) { + Write-Section "GitHub Actions Lint" + if (Test-ToolAvailable -Command "actionlint" -InstallHint "https://github.com/rhysd/actionlint#install") { + $fullPaths = $workflowFiles | ForEach-Object { Join-Path $repoRoot $_ } + $output = actionlint $fullPaths 2>&1 + Write-Result -Check "actionlint" -Passed ($LASTEXITCODE -eq 0) + if ($LASTEXITCODE -ne 0) { + Write-Host $output + } + } + } +} +catch { + Write-Host $_ -ForegroundColor Red + Write-Host $_.ScriptStackTrace + $script:HookExitCode = 1 +} + +if ($script:HookExitCode -ne 0) { + Write-Host "" + Write-Host "Pre-commit checks failed. Fix the errors above or use --no-verify to bypass." -ForegroundColor Yellow +} + +exit $script:HookExitCode diff --git a/.githooks/hooks/Invoke-PrePush.ps1 b/.githooks/hooks/Invoke-PrePush.ps1 new file mode 100644 index 000000000..2666882fa --- /dev/null +++ b/.githooks/hooks/Invoke-PrePush.ps1 @@ -0,0 +1,38 @@ +[CmdletBinding()] +param() + +$repoRoot = git rev-parse --show-toplevel +. "$PSScriptRoot/../lib/LintHelpers.ps1" + +Write-Host "Running pre-push checks..." -ForegroundColor Cyan + +try { + # --- Build --- + Write-Section "Build" + dotnet build (Join-Path $repoRoot "Moq.Analyzers.sln") /p:PedanticMode=true --verbosity quiet 2>&1 + $buildPassed = $LASTEXITCODE -eq 0 + Write-Result -Check "dotnet build" -Passed $buildPassed + + if (-not $buildPassed) { + Write-Host " Build failed. Skipping tests." -ForegroundColor Yellow + } + else { + # --- Tests --- + Write-Section "Tests" + $runSettings = Join-Path $repoRoot "build/targets/tests/test.runsettings" + dotnet test (Join-Path $repoRoot "Moq.Analyzers.sln") --no-build --settings $runSettings --verbosity quiet 2>&1 + Write-Result -Check "dotnet test" -Passed ($LASTEXITCODE -eq 0) + } +} +catch { + Write-Host $_ -ForegroundColor Red + Write-Host $_.ScriptStackTrace + $script:HookExitCode = 1 +} + +if ($script:HookExitCode -ne 0) { + Write-Host "" + Write-Host "Pre-push checks failed. Fix the errors above or use --no-verify to bypass." -ForegroundColor Yellow +} + +exit $script:HookExitCode diff --git a/.githooks/lib/LintHelpers.ps1 b/.githooks/lib/LintHelpers.ps1 new file mode 100644 index 000000000..bb98a1553 --- /dev/null +++ b/.githooks/lib/LintHelpers.ps1 @@ -0,0 +1,105 @@ +# Shared helper functions for git hook scripts. +# Dot-source this file: . "$PSScriptRoot/../lib/LintHelpers.ps1" + +$script:HookExitCode = 0 + +function Test-ToolAvailable { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Command, + + [Parameter()] + [string]$InstallHint + ) + + if (Get-Command $Command -ErrorAction SilentlyContinue) { + return $true + } + + if ($InstallHint) { + Write-Warning " $Command not found. Install: $InstallHint" + } + else { + Write-Warning " $Command not found. Skipping." + } + + return $false +} + +function Get-StagedFiles { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string[]]$Extensions + ) + + $stagedFiles = git diff --cached --name-only --diff-filter=d + if (-not $stagedFiles) { + return @() + } + + $patterns = $Extensions | ForEach-Object { [regex]::Escape($_) + '$' } + $combined = ($patterns -join '|') + + $matched = $stagedFiles | Where-Object { $_ -match $combined } + if (-not $matched) { + return @() + } + + # Return as array even for single match + return @($matched) +} + +function Invoke-AutoFix { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Label, + + [Parameter(Mandatory)] + [scriptblock]$FixCommand, + + [Parameter(Mandatory)] + [string[]]$Files + ) + + & $FixCommand + + # Check if any staged files were modified by the fix + $modified = git diff --name-only -- $Files + if ($modified) { + Write-Host " Auto-fixed and re-staging: $($modified -join ', ')" + git add $modified + } +} + +function Write-Section { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Title + ) + + Write-Host "" + Write-Host "--- $Title ---" -ForegroundColor Cyan +} + +function Write-Result { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Check, + + [Parameter(Mandatory)] + [bool]$Passed + ) + + if ($Passed) { + Write-Host " PASS: $Check" -ForegroundColor Green + } + else { + Write-Host " FAIL: $Check" -ForegroundColor Red + $script:HookExitCode = 1 + } +} diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..c88561eba --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +REPO_ROOT="$(git rev-parse --show-toplevel)" +command -v pwsh >/dev/null 2>&1 || { echo "pwsh not found. Install PowerShell Core: https://aka.ms/powershell"; exit 1; } +exec pwsh -NoProfile -NonInteractive -File "$REPO_ROOT/.githooks/hooks/Invoke-PreCommit.ps1" diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 000000000..873f5658f --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +REPO_ROOT="$(git rev-parse --show-toplevel)" +command -v pwsh >/dev/null 2>&1 || { echo "pwsh not found. Install PowerShell Core: https://aka.ms/powershell"; exit 1; } +exec pwsh -NoProfile -NonInteractive -File "$REPO_ROOT/.githooks/hooks/Invoke-PrePush.ps1" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88754842c..ed9830087 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,37 @@ This project adheres to the [Contributor Covenant Code of Conduct](CODE-OF-CONDU - [yamllint](https://yamllint.readthedocs.io/) (Python): `pip install yamllint` - [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli) (Node.js): `npm install -g markdownlint-cli` +6. **Git hooks** auto-configure on first `dotnet build` or `dotnet restore`. + Hooks require [PowerShell Core](https://aka.ms/powershell) (`pwsh`). + + **What each hook checks:** + + | Hook | Check | Mode | Tool | + |------|-------|------|------| + | pre-commit | C# formatting | Auto-fix + re-stage | `dotnet format` | + | pre-commit | Markdown lint | Auto-fix + re-stage | `markdownlint-cli2` | + | pre-commit | YAML lint | Lint only | `yamllint` | + | pre-commit | JSON validation | Lint only | `python3 -m json.tool` | + | pre-commit | Shell scripts | Lint only | `shellcheck` | + | pre-commit | GitHub Actions | Lint only | `actionlint` | + | pre-push | Build | Fail on error | `dotnet build` | + | pre-push | Tests | Fail on error | `dotnet test` | + + **Optional tool installation:** + + ```bash + npm install -g markdownlint-cli2 # Markdown auto-fix + pip install yamllint # YAML lint + # shellcheck: apt install shellcheck / brew install shellcheck + # actionlint: go install github.com/rhysd/actionlint/cmd/actionlint@latest + ``` + + Missing optional tools are skipped with a warning. C# formatting via `dotnet format` is always available. + + Bypass hooks for WIP commits: `git commit --no-verify` + + Manual setup (if auto-configure fails): `git config core.hooksPath .githooks` + ## Universal Agent Success Principles for Project Maintainers > **IMPORTANT:** These guidelines help project maintainers create environments where AI agents can be more successful, regardless of the specific agent platform or tools being used. diff --git a/Directory.Build.targets b/Directory.Build.targets index 80401b7c1..e477f0287 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -6,4 +6,5 @@ + diff --git a/build/targets/githooks/GitHooks.targets b/build/targets/githooks/GitHooks.targets new file mode 100644 index 000000000..16fb05a0c --- /dev/null +++ b/build/targets/githooks/GitHooks.targets @@ -0,0 +1,22 @@ + + + + + + + + + + + From 1d4a3458d34c7e1e3c5deb3d80bcf7404dc99a94 Mon Sep 17 00:00:00 2001 From: Richard Murillo <6811113+rjmurillo@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:37:31 -0600 Subject: [PATCH 2/5] fix: correct markdownlint tool name and add DOTNET_ROLL_FORWARD Update CONTRIBUTING.md step 5 from markdownlint-cli to markdownlint-cli2 to match the hook implementation. Fix table separator spacing for MD060 compliance. Set DOTNET_ROLL_FORWARD=LatestMajor in pre-push hook so tests run on machines with newer .NET runtimes than the target TFM. Co-Authored-By: Claude Opus 4.6 --- .githooks/hooks/Invoke-PrePush.ps1 | 3 +++ CONTRIBUTING.md | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.githooks/hooks/Invoke-PrePush.ps1 b/.githooks/hooks/Invoke-PrePush.ps1 index 2666882fa..e80eb4d20 100644 --- a/.githooks/hooks/Invoke-PrePush.ps1 +++ b/.githooks/hooks/Invoke-PrePush.ps1 @@ -4,6 +4,9 @@ param() $repoRoot = git rev-parse --show-toplevel . "$PSScriptRoot/../lib/LintHelpers.ps1" +# Allow newer .NET runtimes to run tests targeting older TFMs +$env:DOTNET_ROLL_FORWARD = "LatestMajor" + Write-Host "Running pre-push checks..." -ForegroundColor Cyan try { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed9830087..88ae7846c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ This project adheres to the [Contributor Covenant Code of Conduct](CODE-OF-CONDU 5. **Install linting tools** (for local validation): - [yamllint](https://yamllint.readthedocs.io/) (Python): `pip install yamllint` - - [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli) (Node.js): `npm install -g markdownlint-cli` + - [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) (Node.js): `npm install -g markdownlint-cli2` 6. **Git hooks** auto-configure on first `dotnet build` or `dotnet restore`. Hooks require [PowerShell Core](https://aka.ms/powershell) (`pwsh`). @@ -56,7 +56,7 @@ This project adheres to the [Contributor Covenant Code of Conduct](CODE-OF-CONDU **What each hook checks:** | Hook | Check | Mode | Tool | - |------|-------|------|------| + | ------ | ------- | ------ | ------ | | pre-commit | C# formatting | Auto-fix + re-stage | `dotnet format` | | pre-commit | Markdown lint | Auto-fix + re-stage | `markdownlint-cli2` | | pre-commit | YAML lint | Lint only | `yamllint` | From d730ae9bfa81d3e5847418fe0071f0d2cf757330 Mon Sep 17 00:00:00 2001 From: Richard Murillo <6811113+rjmurillo@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:44:01 -0600 Subject: [PATCH 3/5] refactor: make git hooks silent on success Only print on failure or when action is needed (auto-fix, missing tool). Remove banners, section headers, and PASS messages. Co-Authored-By: Claude Opus 4.6 --- .githooks/hooks/Invoke-PreCommit.ps1 | 44 ++++++++++------------------ .githooks/hooks/Invoke-PrePush.ps1 | 17 ++++------- .githooks/lib/LintHelpers.ps1 | 39 ++++-------------------- 3 files changed, 27 insertions(+), 73 deletions(-) diff --git a/.githooks/hooks/Invoke-PreCommit.ps1 b/.githooks/hooks/Invoke-PreCommit.ps1 index 63802fc1c..7ce4bc7b9 100644 --- a/.githooks/hooks/Invoke-PreCommit.ps1 +++ b/.githooks/hooks/Invoke-PreCommit.ps1 @@ -4,101 +4,88 @@ param() $repoRoot = git rev-parse --show-toplevel . "$PSScriptRoot/../lib/LintHelpers.ps1" -Write-Host "Running pre-commit checks..." -ForegroundColor Cyan - try { - # --- C# formatting (auto-fix + re-stage) --- + # C# formatting (auto-fix + re-stage) $csFiles = Get-StagedFiles -Extensions @('.cs') if ($csFiles.Count -gt 0) { - Write-Section "C# Formatting" $includePaths = ($csFiles | ForEach-Object { "--include", $_ }) - Invoke-AutoFix -Label "dotnet format" -Files $csFiles -FixCommand { + Invoke-AutoFix -Files $csFiles -FixCommand { dotnet format "$repoRoot/Moq.Analyzers.sln" --verbosity quiet @includePaths 2>&1 | Out-Null } - # Verify formatting is clean after fix $output = dotnet format "$repoRoot/Moq.Analyzers.sln" --verify-no-changes --verbosity quiet @includePaths 2>&1 - Write-Result -Check "dotnet format" -Passed ($LASTEXITCODE -eq 0) if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "dotnet format" Write-Host $output } } - # --- Markdown linting (auto-fix + re-stage) --- + # Markdown linting (auto-fix + re-stage) $mdFiles = Get-StagedFiles -Extensions @('.md') if ($mdFiles.Count -gt 0) { - Write-Section "Markdown Lint" if (Test-ToolAvailable -Command "markdownlint-cli2" -InstallHint "npm install -g markdownlint-cli2") { $fullPaths = $mdFiles | ForEach-Object { Join-Path $repoRoot $_ } - Invoke-AutoFix -Label "markdownlint-cli2" -Files $mdFiles -FixCommand { + Invoke-AutoFix -Files $mdFiles -FixCommand { markdownlint-cli2 --fix $fullPaths 2>&1 | Out-Null } - # Verify lint is clean after fix $output = markdownlint-cli2 $fullPaths 2>&1 - Write-Result -Check "markdownlint-cli2" -Passed ($LASTEXITCODE -eq 0) if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "markdownlint-cli2" Write-Host $output } } } - # --- YAML linting (lint only) --- + # YAML linting (lint only) $yamlFiles = Get-StagedFiles -Extensions @('.yml', '.yaml') if ($yamlFiles.Count -gt 0) { - Write-Section "YAML Lint" if (Test-ToolAvailable -Command "yamllint" -InstallHint "pip install yamllint") { $fullPaths = $yamlFiles | ForEach-Object { Join-Path $repoRoot $_ } $output = yamllint -c (Join-Path $repoRoot ".yamllint.yml") $fullPaths 2>&1 - Write-Result -Check "yamllint" -Passed ($LASTEXITCODE -eq 0) if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "yamllint" Write-Host $output } } } - # --- JSON linting (lint only, exclude .verified.json) --- + # JSON linting (lint only, exclude .verified.json) $jsonFiles = Get-StagedFiles -Extensions @('.json') $jsonFiles = $jsonFiles | Where-Object { $_ -notmatch '\.verified\.json$' } if ($jsonFiles.Count -gt 0) { - Write-Section "JSON Lint" if (Test-ToolAvailable -Command "python3" -InstallHint "https://www.python.org/downloads/") { - $jsonPassed = $true foreach ($file in $jsonFiles) { $fullPath = Join-Path $repoRoot $file $output = python3 -m json.tool $fullPath 2>&1 if ($LASTEXITCODE -ne 0) { - Write-Host " Invalid JSON: $file" + Set-HookFailed -Check "json: $file" Write-Host $output - $jsonPassed = $false } } - Write-Result -Check "json validation" -Passed $jsonPassed } } - # --- Shell script linting (lint only) --- + # Shell script linting (lint only) $shellFiles = Get-StagedFiles -Extensions @('.sh', '.bash') if ($shellFiles.Count -gt 0) { - Write-Section "Shell Lint" if (Test-ToolAvailable -Command "shellcheck" -InstallHint "https://github.com/koalaman/shellcheck#installing") { $fullPaths = $shellFiles | ForEach-Object { Join-Path $repoRoot $_ } $output = shellcheck $fullPaths 2>&1 - Write-Result -Check "shellcheck" -Passed ($LASTEXITCODE -eq 0) if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "shellcheck" Write-Host $output } } } - # --- GitHub Actions linting (lint only) --- + # GitHub Actions linting (lint only) $workflowFiles = Get-StagedFiles -Extensions @('.yml', '.yaml') $workflowFiles = $workflowFiles | Where-Object { $_ -match '^\.github/workflows/' } if ($workflowFiles.Count -gt 0) { - Write-Section "GitHub Actions Lint" if (Test-ToolAvailable -Command "actionlint" -InstallHint "https://github.com/rhysd/actionlint#install") { $fullPaths = $workflowFiles | ForEach-Object { Join-Path $repoRoot $_ } $output = actionlint $fullPaths 2>&1 - Write-Result -Check "actionlint" -Passed ($LASTEXITCODE -eq 0) if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "actionlint" Write-Host $output } } @@ -111,8 +98,7 @@ catch { } if ($script:HookExitCode -ne 0) { - Write-Host "" - Write-Host "Pre-commit checks failed. Fix the errors above or use --no-verify to bypass." -ForegroundColor Yellow + Write-Host "Bypass: git commit --no-verify" -ForegroundColor Yellow } exit $script:HookExitCode diff --git a/.githooks/hooks/Invoke-PrePush.ps1 b/.githooks/hooks/Invoke-PrePush.ps1 index e80eb4d20..a9722f47e 100644 --- a/.githooks/hooks/Invoke-PrePush.ps1 +++ b/.githooks/hooks/Invoke-PrePush.ps1 @@ -7,24 +7,20 @@ $repoRoot = git rev-parse --show-toplevel # Allow newer .NET runtimes to run tests targeting older TFMs $env:DOTNET_ROLL_FORWARD = "LatestMajor" -Write-Host "Running pre-push checks..." -ForegroundColor Cyan - try { - # --- Build --- - Write-Section "Build" dotnet build (Join-Path $repoRoot "Moq.Analyzers.sln") /p:PedanticMode=true --verbosity quiet 2>&1 $buildPassed = $LASTEXITCODE -eq 0 - Write-Result -Check "dotnet build" -Passed $buildPassed if (-not $buildPassed) { - Write-Host " Build failed. Skipping tests." -ForegroundColor Yellow + Set-HookFailed -Check "dotnet build" + Write-Host "Build failed. Skipping tests." } else { - # --- Tests --- - Write-Section "Tests" $runSettings = Join-Path $repoRoot "build/targets/tests/test.runsettings" dotnet test (Join-Path $repoRoot "Moq.Analyzers.sln") --no-build --settings $runSettings --verbosity quiet 2>&1 - Write-Result -Check "dotnet test" -Passed ($LASTEXITCODE -eq 0) + if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "dotnet test" + } } } catch { @@ -34,8 +30,7 @@ catch { } if ($script:HookExitCode -ne 0) { - Write-Host "" - Write-Host "Pre-push checks failed. Fix the errors above or use --no-verify to bypass." -ForegroundColor Yellow + Write-Host "Bypass: git push --no-verify" -ForegroundColor Yellow } exit $script:HookExitCode diff --git a/.githooks/lib/LintHelpers.ps1 b/.githooks/lib/LintHelpers.ps1 index bb98a1553..defa5cef6 100644 --- a/.githooks/lib/LintHelpers.ps1 +++ b/.githooks/lib/LintHelpers.ps1 @@ -18,10 +18,7 @@ function Test-ToolAvailable { } if ($InstallHint) { - Write-Warning " $Command not found. Install: $InstallHint" - } - else { - Write-Warning " $Command not found. Skipping." + Write-Warning "$Command not found. Install: $InstallHint" } return $false @@ -47,16 +44,12 @@ function Get-StagedFiles { return @() } - # Return as array even for single match return @($matched) } function Invoke-AutoFix { [CmdletBinding()] param( - [Parameter(Mandatory)] - [string]$Label, - [Parameter(Mandatory)] [scriptblock]$FixCommand, @@ -66,40 +59,20 @@ function Invoke-AutoFix { & $FixCommand - # Check if any staged files were modified by the fix $modified = git diff --name-only -- $Files if ($modified) { - Write-Host " Auto-fixed and re-staging: $($modified -join ', ')" + Write-Host "Auto-fixed: $($modified -join ', ')" git add $modified } } -function Write-Section { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$Title - ) - - Write-Host "" - Write-Host "--- $Title ---" -ForegroundColor Cyan -} - -function Write-Result { +function Set-HookFailed { [CmdletBinding()] param( [Parameter(Mandatory)] - [string]$Check, - - [Parameter(Mandatory)] - [bool]$Passed + [string]$Check ) - if ($Passed) { - Write-Host " PASS: $Check" -ForegroundColor Green - } - else { - Write-Host " FAIL: $Check" -ForegroundColor Red - $script:HookExitCode = 1 - } + Write-Host "FAIL: $Check" -ForegroundColor Red + $script:HookExitCode = 1 } From b121d3f537816b6a4a404aef9ec4ba18549072cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Mar 2026 21:01:52 +0000 Subject: [PATCH 4/5] fix: prevent silent failures in git hooks configuration - GitHooks.targets: Capture git config exit code and only create sentinel file on success, preventing silent misconfiguration - LintHelpers.ps1: Detect files with pre-existing unstaged changes before auto-fix to avoid inadvertently staging partial changes --- .githooks/lib/LintHelpers.ps1 | 19 +++++++++++++++++-- build/targets/githooks/GitHooks.targets | 14 ++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.githooks/lib/LintHelpers.ps1 b/.githooks/lib/LintHelpers.ps1 index defa5cef6..bc0abbd72 100644 --- a/.githooks/lib/LintHelpers.ps1 +++ b/.githooks/lib/LintHelpers.ps1 @@ -57,12 +57,27 @@ function Invoke-AutoFix { [string[]]$Files ) + # Detect files with pre-existing unstaged changes (e.g., from partial staging via git add -p). + # These files should NOT be auto-staged to avoid inadvertently including changes the developer + # deliberately excluded from the commit. + $preExistingUnstaged = @(git diff --name-only -- $Files) + & $FixCommand $modified = git diff --name-only -- $Files if ($modified) { - Write-Host "Auto-fixed: $($modified -join ', ')" - git add $modified + $safeToStage = @($modified | Where-Object { $_ -notin $preExistingUnstaged }) + $unsafeToStage = @($modified | Where-Object { $_ -in $preExistingUnstaged }) + + if ($safeToStage) { + Write-Host "Auto-fixed: $($safeToStage -join ', ')" + git add $safeToStage + } + + if ($unsafeToStage) { + Write-Warning "Auto-fixed but NOT staged (had pre-existing unstaged changes): $($unsafeToStage -join ', ')" + Write-Warning "Please review and stage these files manually." + } } } diff --git a/build/targets/githooks/GitHooks.targets b/build/targets/githooks/GitHooks.targets index 16fb05a0c..663175039 100644 --- a/build/targets/githooks/GitHooks.targets +++ b/build/targets/githooks/GitHooks.targets @@ -11,12 +11,22 @@ + IgnoreExitCode="true"> + + - + + + From c321a83bbe0451fa07036f395e303b3d25d2588f Mon Sep 17 00:00:00 2001 From: Richard Murillo <6811113+rjmurillo@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:06:18 -0600 Subject: [PATCH 5/5] fix: improve git hooks reliability and correctness - Skip hook configuration in CI (ContinuousIntegrationBuild) - Add trailing newline to .gitattributes (POSIX compliance) - Deduplicate Get-StagedFiles call for YAML/workflow linting Co-Authored-By: Claude Opus 4.6 --- .gitattributes | 2 +- .githooks/hooks/Invoke-PreCommit.ps1 | 5 ++--- build/targets/githooks/GitHooks.targets | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.gitattributes b/.gitattributes index 5080a30f8..3be007a89 100644 --- a/.gitattributes +++ b/.gitattributes @@ -117,4 +117,4 @@ .vscode/*.json linguist-language=JSON-with-Comments # Git hooks must use LF line endings (Git Bash on Windows requires it) -.githooks/** text eol=lf \ No newline at end of file +.githooks/** text eol=lf diff --git a/.githooks/hooks/Invoke-PreCommit.ps1 b/.githooks/hooks/Invoke-PreCommit.ps1 index 7ce4bc7b9..512e87ebd 100644 --- a/.githooks/hooks/Invoke-PreCommit.ps1 +++ b/.githooks/hooks/Invoke-PreCommit.ps1 @@ -77,9 +77,8 @@ try { } } - # GitHub Actions linting (lint only) - $workflowFiles = Get-StagedFiles -Extensions @('.yml', '.yaml') - $workflowFiles = $workflowFiles | Where-Object { $_ -match '^\.github/workflows/' } + # GitHub Actions linting (lint only, reuses $yamlFiles from YAML lint step) + $workflowFiles = $yamlFiles | Where-Object { $_ -match '^\.github/workflows/' } if ($workflowFiles.Count -gt 0) { if (Test-ToolAvailable -Command "actionlint" -InstallHint "https://github.com/rhysd/actionlint#install") { $fullPaths = $workflowFiles | ForEach-Object { Join-Path $repoRoot $_ } diff --git a/build/targets/githooks/GitHooks.targets b/build/targets/githooks/GitHooks.targets index 663175039..9fbeb0244 100644 --- a/build/targets/githooks/GitHooks.targets +++ b/build/targets/githooks/GitHooks.targets @@ -6,7 +6,7 @@ + Condition=" '$(DesignTimeBuild)' != 'true' AND '$(ContinuousIntegrationBuild)' != 'true' AND Exists('$(RepoRoot).git') AND !Exists('$(RepoRoot).git/hooks/.githooks-configured') ">