-
Notifications
You must be signed in to change notification settings - Fork 4
ci: add git hooks for local CI parity #960
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
79a6865
1d4a345
d730ae9
b121d3f
c321a83
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| [CmdletBinding()] | ||
| param() | ||
|
|
||
| $repoRoot = git rev-parse --show-toplevel | ||
| . "$PSScriptRoot/../lib/LintHelpers.ps1" | ||
|
|
||
| try { | ||
| # C# formatting (auto-fix + re-stage) | ||
| $csFiles = Get-StagedFiles -Extensions @('.cs') | ||
| if ($csFiles.Count -gt 0) { | ||
| $includePaths = ($csFiles | ForEach-Object { "--include", $_ }) | ||
| Invoke-AutoFix -Files $csFiles -FixCommand { | ||
| dotnet format "$repoRoot/Moq.Analyzers.sln" --verbosity quiet @includePaths 2>&1 | Out-Null | ||
| } | ||
| $output = dotnet format "$repoRoot/Moq.Analyzers.sln" --verify-no-changes --verbosity quiet @includePaths 2>&1 | ||
| if ($LASTEXITCODE -ne 0) { | ||
| Set-HookFailed -Check "dotnet format" | ||
| Write-Host $output | ||
|
Check warning on line 18 in .githooks/hooks/Invoke-PreCommit.ps1
|
||
| } | ||
| } | ||
|
|
||
| # Markdown linting (auto-fix + re-stage) | ||
| $mdFiles = Get-StagedFiles -Extensions @('.md') | ||
| if ($mdFiles.Count -gt 0) { | ||
| if (Test-ToolAvailable -Command "markdownlint-cli2" -InstallHint "npm install -g markdownlint-cli2") { | ||
| $fullPaths = $mdFiles | ForEach-Object { Join-Path $repoRoot $_ } | ||
| Invoke-AutoFix -Files $mdFiles -FixCommand { | ||
| markdownlint-cli2 --fix $fullPaths 2>&1 | Out-Null | ||
| } | ||
| $output = markdownlint-cli2 $fullPaths 2>&1 | ||
| if ($LASTEXITCODE -ne 0) { | ||
| Set-HookFailed -Check "markdownlint-cli2" | ||
| Write-Host $output | ||
| } | ||
| } | ||
| } | ||
|
|
||
| # YAML linting (lint only) | ||
| $yamlFiles = Get-StagedFiles -Extensions @('.yml', '.yaml') | ||
| if ($yamlFiles.Count -gt 0) { | ||
| 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 | ||
| if ($LASTEXITCODE -ne 0) { | ||
| Set-HookFailed -Check "yamllint" | ||
| 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) { | ||
| if (Test-ToolAvailable -Command "python3" -InstallHint "https://www.python.org/downloads/") { | ||
| foreach ($file in $jsonFiles) { | ||
| $fullPath = Join-Path $repoRoot $file | ||
| $output = python3 -m json.tool $fullPath 2>&1 | ||
| if ($LASTEXITCODE -ne 0) { | ||
| Set-HookFailed -Check "json: $file" | ||
| Write-Host $output | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| # Shell script linting (lint only) | ||
| $shellFiles = Get-StagedFiles -Extensions @('.sh', '.bash') | ||
| if ($shellFiles.Count -gt 0) { | ||
| if (Test-ToolAvailable -Command "shellcheck" -InstallHint "https://github.com/koalaman/shellcheck#installing") { | ||
| $fullPaths = $shellFiles | ForEach-Object { Join-Path $repoRoot $_ } | ||
| $output = shellcheck $fullPaths 2>&1 | ||
| if ($LASTEXITCODE -ne 0) { | ||
| Set-HookFailed -Check "shellcheck" | ||
| 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) { | ||
| if (Test-ToolAvailable -Command "actionlint" -InstallHint "https://github.com/rhysd/actionlint#install") { | ||
| $fullPaths = $workflowFiles | ForEach-Object { Join-Path $repoRoot $_ } | ||
| $output = actionlint $fullPaths 2>&1 | ||
| if ($LASTEXITCODE -ne 0) { | ||
| Set-HookFailed -Check "actionlint" | ||
| Write-Host $output | ||
| } | ||
| } | ||
| } | ||
| } | ||
| catch { | ||
| Write-Host $_ -ForegroundColor Red | ||
|
Check warning on line 95 in .githooks/hooks/Invoke-PreCommit.ps1
|
||
| Write-Host $_.ScriptStackTrace | ||
|
Check warning on line 96 in .githooks/hooks/Invoke-PreCommit.ps1
|
||
| $script:HookExitCode = 1 | ||
| } | ||
|
|
||
| if ($script:HookExitCode -ne 0) { | ||
| Write-Host "Bypass: git commit --no-verify" -ForegroundColor Yellow | ||
|
Check warning on line 101 in .githooks/hooks/Invoke-PreCommit.ps1
|
||
| } | ||
|
|
||
| exit $script:HookExitCode | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| [CmdletBinding()] | ||
| 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" | ||
|
|
||
| try { | ||
| dotnet build (Join-Path $repoRoot "Moq.Analyzers.sln") /p:PedanticMode=true --verbosity quiet 2>&1 | ||
| $buildPassed = $LASTEXITCODE -eq 0 | ||
|
|
||
| if (-not $buildPassed) { | ||
| Set-HookFailed -Check "dotnet build" | ||
| Write-Host "Build failed. Skipping tests." | ||
|
Check warning on line 16 in .githooks/hooks/Invoke-PrePush.ps1
|
||
| } | ||
| else { | ||
| $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 | ||
| if ($LASTEXITCODE -ne 0) { | ||
| Set-HookFailed -Check "dotnet test" | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+10
to
+25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The output from |
||
| catch { | ||
| Write-Host $_ -ForegroundColor Red | ||
|
Check warning on line 27 in .githooks/hooks/Invoke-PrePush.ps1
|
||
| Write-Host $_.ScriptStackTrace | ||
|
Check warning on line 28 in .githooks/hooks/Invoke-PrePush.ps1
|
||
| $script:HookExitCode = 1 | ||
| } | ||
|
|
||
| if ($script:HookExitCode -ne 0) { | ||
| Write-Host "Bypass: git push --no-verify" -ForegroundColor Yellow | ||
|
Check warning on line 33 in .githooks/hooks/Invoke-PrePush.ps1
|
||
| } | ||
|
|
||
| exit $script:HookExitCode | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| # 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" | ||
| } | ||
|
|
||
| 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 @($matched) | ||
| } | ||
|
|
||
| function Invoke-AutoFix { | ||
| [CmdletBinding()] | ||
| param( | ||
| [Parameter(Mandatory)] | ||
| [scriptblock]$FixCommand, | ||
|
|
||
| [Parameter(Mandatory)] | ||
| [string[]]$Files | ||
| ) | ||
|
Comment on lines
+50
to
+58
|
||
|
|
||
| & $FixCommand | ||
|
|
||
| $modified = git diff --name-only -- $Files | ||
| if ($modified) { | ||
| Write-Host "Auto-fixed: $($modified -join ', ')" | ||
|
Check warning on line 64 in .githooks/lib/LintHelpers.ps1
|
||
| git add $modified | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function Set-HookFailed { | ||
| [CmdletBinding()] | ||
| param( | ||
| [Parameter(Mandatory)] | ||
| [string]$Check | ||
| ) | ||
|
|
||
| Write-Host "FAIL: $Check" -ForegroundColor Red | ||
|
Check warning on line 76 in .githooks/lib/LintHelpers.ps1
|
||
| $script:HookExitCode = 1 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| #!/usr/bin/env bash | ||
| REPO_ROOT="$(git rev-parse --show-toplevel)" | ||
|
Comment on lines
+1
to
+2
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Clean shebang and repo discovery implementation. The portable shebang and use of Optional: Add defensive error handlingWhile unlikely to fail in a git hook context, defensive error handling would provide clearer diagnostics if #!/usr/bin/env bash
-REPO_ROOT="$(git rev-parse --show-toplevel)"
+REPO_ROOT="$(git rev-parse --show-toplevel 2>&1)" || { echo "Error: Failed to determine repository root"; exit 1; }🤖 Prompt for AI Agents |
||
| 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" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,22 @@ | ||||||||||||||||||||||||||||||||||||||||||
| <Project> | ||||||||||||||||||||||||||||||||||||||||||
| <!-- | ||||||||||||||||||||||||||||||||||||||||||
| Automatically configure git to use .githooks/ as the hooks directory. | ||||||||||||||||||||||||||||||||||||||||||
| Runs once per clone via sentinel file. Degrades gracefully in CI or non-git contexts. | ||||||||||||||||||||||||||||||||||||||||||
| --> | ||||||||||||||||||||||||||||||||||||||||||
| <Target | ||||||||||||||||||||||||||||||||||||||||||
| Name="ConfigureGitHooks" | ||||||||||||||||||||||||||||||||||||||||||
| BeforeTargets="Restore;Build" | ||||||||||||||||||||||||||||||||||||||||||
| Condition=" '$(DesignTimeBuild)' != 'true' AND Exists('$(RepoRoot).git') AND !Exists('$(RepoRoot).git/hooks/.githooks-configured') "> | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| <Exec | ||||||||||||||||||||||||||||||||||||||||||
| Command="git config core.hooksPath .githooks" | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
| Command="git config core.hooksPath .githooks" | |
| Command="git config core.hooksPath || git config core.hooksPath .githooks" |
Copilot
AI
Mar 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The sentinel file is written under $(RepoRoot).git/hooks/…, but in git worktrees and submodules .git is often a file (pointing at the real gitdir), so $(RepoRoot).git/hooks is not a valid directory. This can make the Touch task fail and break dotnet build/restore. Consider placing the sentinel somewhere that is always a real directory in the working tree (e.g., under $(RepoRoot).githooks/) or resolve the hooks dir via git rev-parse --git-path hooks and write the sentinel there.
Copilot
AI
Mar 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exec has IgnoreExitCode="true", but the target always writes the sentinel and prints the "configured" message afterward. If git config fails (e.g., git not on PATH, permissions, CI restrictions), this will permanently suppress future attempts while leaving hooks unconfigured. Capture the Exec exit code and only write the sentinel / show the message when it succeeds.
| IgnoreExitCode="true" /> | |
| <Touch | |
| Files="$(RepoRoot).git/hooks/.githooks-configured" | |
| AlwaysCreate="true" /> | |
| <Message Importance="high" Text="Git hooks configured. Run 'git config --unset core.hooksPath' to disable." /> | |
| IgnoreExitCode="true"> | |
| <Output TaskParameter="ExitCode" PropertyName="GitConfigExitCode" /> | |
| </Exec> | |
| <Touch | |
| Condition=" '$(GitConfigExitCode)' == '0' " | |
| Files="$(RepoRoot).git/hooks/.githooks-configured" | |
| AlwaysCreate="true" /> | |
| <Message | |
| Condition=" '$(GitConfigExitCode)' == '0' " | |
| Importance="high" | |
| Text="Git hooks configured. Run 'git config --unset core.hooksPath' to disable." /> |
Copilot
AI
Mar 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This target will run in CI as long as .git exists, which mutates the repo’s local git config and adds noise to build logs. Since the purpose is local developer workflow, consider skipping when $(ContinuousIntegrationBuild) is true (this repo already uses that property to drive CI-only behavior).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To improve efficiency, you can avoid calling
Get-StagedFilesfor YAML files twice. You can get the list of YAML files once, store it in a variable, and then use it for both the general YAML linting and the GitHub Actions linting. This avoids running an unnecessarygit diffcommand.