Skip to content

ci: add git hooks for local CI parity#960

Merged
rjmurillo merged 5 commits intomainfrom
ci/add-git-hooks-local-ci-parity
Mar 1, 2026
Merged

ci: add git hooks for local CI parity#960
rjmurillo merged 5 commits intomainfrom
ci/add-git-hooks-local-ci-parity

Conversation

@rjmurillo
Copy link
Copy Markdown
Owner

@rjmurillo rjmurillo commented Mar 1, 2026

Summary

  • Add pre-commit and pre-push git hooks that catch linting, formatting, and build issues locally before they reach CI
  • Auto-configure hooks on first dotnet build/dotnet restore via MSBuild target with sentinel file
  • Architecture: bash shims delegate to PowerShell Core scripts, matching existing cross-platform scripting convention

Type of Change

  • CI/CD or infrastructure change
  • Documentation update

What Changed

New files

File Purpose
.githooks/pre-commit Bash shim, checks for pwsh, delegates to PowerShell
.githooks/pre-push Bash shim, same pattern
.githooks/hooks/Invoke-PreCommit.ps1 Staged-file checks: C# format (auto-fix), markdownlint (auto-fix), yamllint, JSON, shellcheck, actionlint
.githooks/hooks/Invoke-PrePush.ps1 Build with PedanticMode + test suite, sets DOTNET_ROLL_FORWARD=LatestMajor
.githooks/lib/LintHelpers.ps1 Shared functions: Test-ToolAvailable, Get-StagedFiles, Invoke-AutoFix, Write-Section, Write-Result
build/targets/githooks/GitHooks.targets MSBuild auto-setup with sentinel file at .git/hooks/.githooks-configured

Modified files

File Change
Directory.Build.targets Added import for GitHooks.targets
.gitattributes Added eol=lf rule for .githooks/**
CONTRIBUTING.md Added step 6 documenting hooks, fixed step 5 tool name from markdownlint-cli to markdownlint-cli2, added table separator spacing for MD060

Design Decisions

  • Bash shim + PowerShell pattern: Git requires shell scripts as hook entry points. Thin bash shims (4 lines) delegate to PowerShell Core for all logic, matching build/scripts/perf/*.ps1 convention.
  • Auto-fix philosophy: Tools that support auto-fix (dotnet format, markdownlint-cli2) run in fix mode and re-stage corrected files. Lint-only tools fail with actionable errors.
  • Missing tools skip with warning: Optional tools (markdownlint-cli2, yamllint, shellcheck, actionlint) degrade gracefully. Only pwsh and dotnet are hard requirements.
  • Sentinel file: .git/hooks/.githooks-configured prevents repeated git config calls on every build. Delete to re-trigger.
  • DOTNET_ROLL_FORWARD: Pre-push hook sets LatestMajor so tests run on machines with newer .NET runtimes than the target TFM.

Test Plan

  • dotnet build shows "Git hooks configured" on first run
  • Second dotnet build does NOT show message (sentinel works)
  • git config core.hooksPath returns .githooks
  • Pre-commit hook runs markdownlint-cli2 and catches MD060 violations
  • Pre-commit hook auto-fixes and re-stages markdown files
  • Pre-push hook runs build (passed) and tests (2504 passed)
  • All PowerShell scripts pass syntax check
  • git commit --no-verify bypasses hooks

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced automated pre-commit and pre-push Git hooks for code quality enforcement.
    • Validates C# formatting, Markdown, YAML, JSON, Shell scripts, and GitHub Actions workflows.
    • Automatically configures hooks on first dotnet build or restore.
  • Documentation

    • Enhanced contribution guidelines with Git hooks overview and optional tooling installation guidance.

rjmurillo and others added 2 commits March 1, 2026 14:30
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 1, 2026 20:42
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 1, 2026

📝 Walkthrough

Walkthrough

This PR introduces a complete git hooks system to enforce code quality across multiple file types (C#, Markdown, YAML, JSON, shell, workflows) during commits, plus build and test verification before push. Includes PowerShell hook scripts, helper utilities, MSBuild auto-configuration, bash wrappers, and documentation.

Changes

Cohort / File(s) Summary
Git Configuration
.gitattributes
Adds LF line ending enforcement for git hooks directory and explicit language directive for .vscode JSON files.
Pre-commit Hook
.githooks/pre-commit, .githooks/hooks/Invoke-PreCommit.ps1
Bash wrapper and PowerShell implementation for pre-commit validation of C#, Markdown, YAML, JSON, shell, and GitHub workflow files with staged file filtering and linting/auto-fix steps.
Pre-push Hook
.githooks/pre-push, .githooks/hooks/Invoke-PrePush.ps1
Bash wrapper and PowerShell implementation to build solution and run tests before push, with failure detection and bypass guidance.
Hook Utilities
.githooks/lib/LintHelpers.ps1
Shared PowerShell helper functions for tool availability checking, staged file filtering, safe auto-fix execution, and hook failure tracking.
Build System Integration
Directory.Build.targets, build/targets/githooks/GitHooks.targets
MSBuild target to auto-configure git hooks on first build/restore via git config core.hooksPath with sentinel-based deduplication and graceful failure handling.
Documentation
CONTRIBUTING.md
Updated contribution guidelines documenting git hooks auto-configuration, required/optional tools, checks performed, and bypass instructions.

Sequence Diagrams

sequenceDiagram
    participant Dev as Developer
    participant Git as Git
    participant Bash as Bash Wrapper
    participant PowerShell as PowerShell Hook
    participant Tools as Linting Tools<br/>(dotnet, markdownlint, yamllint, etc.)
    
    Dev->>Git: git commit
    Git->>Bash: Execute .githooks/pre-commit
    Bash->>Bash: Find repo root
    Bash->>Bash: Check pwsh availability
    Bash->>PowerShell: Invoke Invoke-PreCommit.ps1
    PowerShell->>PowerShell: Collect staged files by type
    
    PowerShell->>Tools: dotnet format (C#)
    Tools-->>PowerShell: Format result
    PowerShell->>Tools: markdownlint (Markdown)
    Tools-->>PowerShell: Lint result
    PowerShell->>Tools: yamllint (YAML)
    Tools-->>PowerShell: Lint result
    PowerShell->>Tools: json.tool (JSON)
    Tools-->>PowerShell: Validate result
    PowerShell->>Tools: shellcheck (Shell)
    Tools-->>PowerShell: Lint result
    PowerShell->>Tools: actionlint (Workflows)
    Tools-->>PowerShell: Lint result
    
    alt All checks pass
        PowerShell-->>Bash: Exit 0
        Bash-->>Git: Success
        Git-->>Dev: Commit allowed
    else Any check fails
        PowerShell-->>Bash: Exit 1
        Bash-->>Git: Failure
        Git-->>Dev: Commit blocked (bypass with --no-verify)
    end
Loading
sequenceDiagram
    participant Dev as Developer
    participant Git as Git
    participant Bash as Bash Wrapper
    participant PowerShell as PowerShell Hook
    participant Build as dotnet build
    participant Test as dotnet test
    
    Dev->>Git: git push
    Git->>Bash: Execute .githooks/pre-push
    Bash->>Bash: Find repo root
    Bash->>Bash: Check pwsh availability
    Bash->>PowerShell: Invoke Invoke-PrePush.ps1
    PowerShell->>PowerShell: Set DOTNET_ROLL_FORWARD
    
    PowerShell->>Build: dotnet build (PedanticMode=true)
    Build-->>PowerShell: Build status
    
    alt Build succeeds
        PowerShell->>Test: dotnet test (with runsettings)
        Test-->>PowerShell: Test status
        alt Tests pass
            PowerShell-->>Bash: Exit 0
            Bash-->>Git: Success
            Git-->>Dev: Push allowed
        else Tests fail
            PowerShell-->>Bash: Exit 1
            Bash-->>Git: Failure
            Git-->>Dev: Push blocked (bypass with --no-verify)
        end
    else Build fails
        PowerShell-->>Bash: Exit 1
        Bash-->>Git: Failure
        Git-->>Dev: Push blocked (bypass with --no-verify)
    end
Loading
sequenceDiagram
    participant Dev as Developer
    participant MSBuild as MSBuild
    participant Git as git config
    participant Sentinel as Sentinel File
    
    Dev->>MSBuild: dotnet build/restore
    MSBuild->>MSBuild: Check if design-time/CI build
    MSBuild->>MSBuild: Check if .git exists
    MSBuild->>Sentinel: Check if hooks already configured
    
    alt Not configured yet
        MSBuild->>Git: git config core.hooksPath .githooks
        Git-->>MSBuild: Exit code
        
        alt Configuration succeeds (exit 0)
            MSBuild->>Sentinel: Create .git/hooks/.githooks-configured
            MSBuild->>Dev: Display configuration message
        else Configuration fails (non-zero)
            MSBuild->>Dev: Display warning with manual setup instructions
        end
    else Already configured
        MSBuild->>MSBuild: Skip (sentinel exists)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

feature

Suggested reviewers

  • MattKotsenas
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: introducing git hooks to enable local CI parity by catching issues before CI runs.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ci/add-git-hooks-local-ci-parity

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the developer workflow by integrating Git hooks directly into the repository. The primary goal is to shift CI checks left, catching linting, formatting, and build issues locally before they are pushed, thereby improving code quality and reducing feedback cycles. The hooks are designed for cross-platform compatibility and auto-configure seamlessly for developers, ensuring a consistent development environment.

Highlights

  • Git Hooks Implementation: Introduced pre-commit and pre-push Git hooks to enforce local CI parity, catching linting, formatting, and build issues before they reach the CI pipeline.
  • Automatic Configuration: Implemented automatic configuration of Git hooks via an MSBuild target, triggered on the first dotnet build or dotnet restore operation, using a sentinel file for one-time setup.
  • Cross-Platform Scripting: Designed the hooks using cross-platform PowerShell Core scripts, invoked by thin Bash shims, aligning with existing repository scripting conventions.
  • Pre-Commit Checks: The pre-commit hook performs C# formatting (with auto-fix), Markdown linting (with auto-fix), YAML linting, JSON validation, Shell script linting, and GitHub Actions linting on staged files.
  • Pre-Push Checks: The pre-push hook executes a full build with pedantic mode and runs the test suite, setting DOTNET_ROLL_FORWARD=LatestMajor to ensure compatibility with newer .NET runtimes.
  • Documentation Update: Updated CONTRIBUTING.md to document the new Git hooks, their auto-configuration, the checks they perform, and how to install optional tools.
Changelog
  • .gitattributes
    • Ensured .githooks/** files use LF line endings for cross-platform consistency.
  • .githooks/hooks/Invoke-PreCommit.ps1
    • Added a PowerShell script to perform various pre-commit checks including C# formatting, Markdown linting, YAML linting, JSON validation, Shell script linting, and GitHub Actions linting.
  • .githooks/hooks/Invoke-PrePush.ps1
    • Added a PowerShell script to execute a full build with pedantic mode and run the test suite before pushing, setting DOTNET_ROLL_FORWARD.
  • .githooks/lib/LintHelpers.ps1
    • Added a PowerShell script containing helper functions for Git hooks, such as checking tool availability, getting staged files, invoking auto-fix, and writing formatted output.
  • .githooks/pre-commit
    • Added a Bash shim to invoke the Invoke-PreCommit.ps1 PowerShell script as a pre-commit hook.
  • .githooks/pre-push
    • Added a Bash shim to invoke the Invoke-PrePush.ps1 PowerShell script as a pre-push hook.
  • CONTRIBUTING.md
    • Updated documentation to include details about the new Git hooks, their auto-configuration, the checks they perform, and how to install optional tools.
    • Corrected the name of the markdownlint tool from markdownlint-cli to markdownlint-cli2.
  • Directory.Build.targets
    • Imported the new GitHooks.targets file to enable automatic Git hook configuration.
  • build/targets/githooks/GitHooks.targets
    • Added an MSBuild target to automatically configure Git to use the .githooks/ directory as the hooks path, using a sentinel file to ensure it runs only once.
Activity
  • No human activity has been recorded on this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

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 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust system of local git hooks to enforce linting, formatting, and build checks, aiming for parity with the CI pipeline. The implementation is well-designed, using bash shims to delegate to PowerShell scripts and an MSBuild target for automatic hook configuration. The changes significantly improve the local development workflow by catching issues early. I have a couple of suggestions to further enhance the new hook scripts: one to improve efficiency by avoiding a redundant git command, and another to improve the usability of the pre-push hook by displaying detailed error messages on failure.

Comment on lines +12 to +29
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)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The output from dotnet build and dotnet test is currently being redirected but not captured or displayed on failure. This means if a build or test fails, the user will see the failure message from the hook but not the underlying error from dotnet. It's better to capture the output and print it when the command fails, similar to how it's handled in the Invoke-PreCommit.ps1 script. This provides immediate, actionable feedback to the developer.

try {
    # --- Build ---
    Write-Section "Build"
    $buildOutput = 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 $buildOutput
        Write-Host "  Build failed. Skipping tests." -ForegroundColor Yellow
    }
    else {
        # --- Tests ---
        Write-Section "Tests"
        $runSettings = Join-Path $repoRoot "build/targets/tests/test.runsettings"
        $testOutput = 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) {
            Write-Host $testOutput
        }
    }
}

Comment on lines +45 to +105
$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
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To improve efficiency, you can avoid calling Get-StagedFiles for 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 unnecessary git diff command.

    $allYamlFiles = Get-StagedFiles -Extensions @('.yml', '.yaml')
    if ($allYamlFiles.Count -gt 0) {
        Write-Section "YAML Lint"
        if (Test-ToolAvailable -Command "yamllint" -InstallHint "pip install yamllint") {
            $fullPaths = $allYamlFiles | 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 = $allYamlFiles | 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
            }
        }
    }

@codacy-production
Copy link
Copy Markdown

codacy-production bot commented Mar 1, 2026

Coverage summary from Codacy

See diff coverage on Codacy

Coverage variation Diff coverage
+0.00% (target: -1.00%) (target: 95.00%)
Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (f3a8c8a) 2072 1843 88.95%
Head commit (c321a83) 2072 (+0) 1843 (+0) 88.95% (+0.00%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#960) 0 0 ∅ (not applicable)

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

See your quality gate settings    Change summary preferences

@deepsource-io
Copy link
Copy Markdown

deepsource-io bot commented Mar 1, 2026

DeepSource Code Review

We reviewed changes in f3a8c8a...c321a83 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
C# Mar 1, 2026 9:06p.m. Review ↗

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds locally enforced git hooks to catch formatting/lint/build/test issues before CI, with MSBuild-based auto-configuration and contributor documentation updates.

Changes:

  • Introduces .githooks/ hook shims plus PowerShell implementations for pre-commit (lint/auto-fix) and pre-push (build/test).
  • Adds an MSBuild target to auto-configure core.hooksPath on first restore/build using a sentinel.
  • Updates .gitattributes and CONTRIBUTING.md to support hook line endings and document the workflow/tools.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
build/targets/githooks/GitHooks.targets MSBuild target to auto-configure core.hooksPath and write a sentinel file
Directory.Build.targets Imports the new GitHooks MSBuild target
.githooks/pre-commit Bash shim delegating to PowerShell pre-commit logic
.githooks/pre-push Bash shim delegating to PowerShell pre-push logic
.githooks/hooks/Invoke-PreCommit.ps1 Runs staged-file format/lint checks (some auto-fix + re-stage)
.githooks/hooks/Invoke-PrePush.ps1 Runs build (PedanticMode) and tests before push
.githooks/lib/LintHelpers.ps1 Shared helper functions for hooks (staged file selection, output, auto-fix restage)
.gitattributes Enforces LF for .githooks/** and fixes JSON-with-comments linguist line indentation
CONTRIBUTING.md Documents hook behavior and updates markdownlint tool reference

Comment thread build/targets/githooks/GitHooks.targets Outdated
Comment on lines +14 to +20
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." />
Copy link

Copilot AI Mar 1, 2026

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.

Suggested change
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 uses AI. Check for mistakes.
Comment thread build/targets/githooks/GitHooks.targets Outdated
Comment on lines +6 to +20
<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"
WorkingDirectory="$(RepoRoot)"
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." />
Copy link

Copilot AI Mar 1, 2026

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).

Copilot uses AI. Check for mistakes.
Condition=" '$(DesignTimeBuild)' != 'true' AND Exists('$(RepoRoot).git') AND !Exists('$(RepoRoot).git/hooks/.githooks-configured') ">

<Exec
Command="git config core.hooksPath .githooks"
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

git config core.hooksPath .githooks will overwrite any existing core.hooksPath the developer may already have for this clone. To avoid clobbering local configuration, consider checking the current value first and only setting it when it’s empty (or already .githooks).

Suggested change
Command="git config core.hooksPath .githooks"
Command="git config core.hooksPath || git config core.hooksPath .githooks"

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +65
function Invoke-AutoFix {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Label,

[Parameter(Mandatory)]
[scriptblock]$FixCommand,

[Parameter(Mandatory)]
[string[]]$Files
)
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invoke-AutoFix accepts a Label parameter but never uses it. Either use it for consistent output (e.g., in a section header / log line) or remove it to avoid dead parameters.

Copilot uses AI. Check for mistakes.
Comment thread .githooks/hooks/Invoke-PreCommit.ps1 Outdated
Comment on lines +63 to +74
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
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON validation step only checks for a python3 command. On Windows, Python is commonly available as python (without python3), so JSON validation will be skipped even when Python is installed. Consider probing for python3 or python (and then using whichever exists) to make this check reliably cross-platform.

Copilot uses AI. Check for mistakes.
Comment thread .githooks/hooks/Invoke-PreCommit.ps1 Outdated
Comment on lines +78 to +85
# --- 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)
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shell lint step only considers staged files ending in .sh or .bash. The hook shims themselves (e.g., .githooks/pre-commit, .githooks/pre-push) have no extension, so changes to those scripts won’t be checked by shellcheck. Consider extending the file selection to include those paths (or using a shebang-based detection) so the hook scripts are covered too.

Copilot uses AI. Check for mistakes.
Comment thread build/targets/githooks/GitHooks.targets Outdated
Comment on lines +9 to +18
Condition=" '$(DesignTimeBuild)' != 'true' AND Exists('$(RepoRoot).git') AND !Exists('$(RepoRoot).git/hooks/.githooks-configured') ">

<Exec
Command="git config core.hooksPath .githooks"
WorkingDirectory="$(RepoRoot)"
IgnoreExitCode="true" />

<Touch
Files="$(RepoRoot).git/hooks/.githooks-configured"
AlwaysCreate="true" />
Copy link

Copilot AI Mar 1, 2026

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 uses AI. Check for mistakes.
@rjmurillo
Copy link
Copy Markdown
Owner Author

@MattKotsenas This is something we should consider for all git repos. High value for humans and AI

@MattKotsenas
Copy link
Copy Markdown
Collaborator

I'd love to spend more time thinking about something. The idea is righteous, but currently hooks don't compose very well in my experience.

  • You can't easily combine repo level and user level hooks
  • Like TreatWarningsAsErrors, there's a POC mode where you don't want them, and a "presubmit" where you do (prepush is the closest)

A while ago I looked at lefthook and Husky.NET, but at the time they didn't feel "seamless" enough...

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Sentinel created even when git config fails silently
    • Added exit code capture from git config and conditioned sentinel file creation and success message on exit code 0, with a warning on failure.
  • ✅ Fixed: Auto-fix re-stages files with unstaged working-tree changes
    • Added detection of pre-existing unstaged changes before running auto-fix, and excluded those files from auto-staging with a warning to the developer.
Preview (b121d3f537)
diff --git a/.gitattributes b/.gitattributes
--- 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
--- /dev/null
+++ b/.githooks/hooks/Invoke-PreCommit.ps1
@@ -1,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
+        }
+    }
+
+    # 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
+    Write-Host $_.ScriptStackTrace
+    $script:HookExitCode = 1
+}
+
+if ($script:HookExitCode -ne 0) {
+    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
new file mode 100644
--- /dev/null
+++ b/.githooks/hooks/Invoke-PrePush.ps1
@@ -1,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."
+    }
+    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"
+        }
+    }
+}
+catch {
+    Write-Host $_ -ForegroundColor Red
+    Write-Host $_.ScriptStackTrace
+    $script:HookExitCode = 1
+}
+
+if ($script:HookExitCode -ne 0) {
+    Write-Host "Bypass: git push --no-verify" -ForegroundColor Yellow
+}
+
+exit $script:HookExitCode

diff --git a/.githooks/lib/LintHelpers.ps1 b/.githooks/lib/LintHelpers.ps1
new file mode 100644
--- /dev/null
+++ b/.githooks/lib/LintHelpers.ps1
@@ -1,0 +1,93 @@
+# 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
+    )
+
+    # 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) {
+        $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."
+        }
+    }
+}
+
+function Set-HookFailed {
+    [CmdletBinding()]
+    param(
+        [Parameter(Mandatory)]
+        [string]$Check
+    )
+
+    Write-Host "FAIL: $Check" -ForegroundColor Red
+    $script:HookExitCode = 1
+}

diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100644
--- /dev/null
+++ b/.githooks/pre-commit
@@ -1,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 100644
--- /dev/null
+++ b/.githooks/pre-push
@@ -1,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
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -48,8 +48,39 @@
 
 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`).
+
+   **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
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -6,4 +6,5 @@
   <Import Project="build/targets/tests/Tests.targets" />
   <Import Project="build/targets/codeanalysis/CodeAnalysis.targets" />
   <Import Project="build/targets/packaging/Packaging.targets" />
+  <Import Project="build/targets/githooks/GitHooks.targets" />
 </Project>

diff --git a/build/targets/githooks/GitHooks.targets b/build/targets/githooks/GitHooks.targets
new file mode 100644
--- /dev/null
+++ b/build/targets/githooks/GitHooks.targets
@@ -1,0 +1,32 @@
+<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"
+      WorkingDirectory="$(RepoRoot)"
+      IgnoreExitCode="true">
+      <Output TaskParameter="ExitCode" PropertyName="_GitHooksExitCode" />
+    </Exec>
+
+    <Touch
+      Condition=" '$(_GitHooksExitCode)' == '0' "
+      Files="$(RepoRoot).git/hooks/.githooks-configured"
+      AlwaysCreate="true" />
+
+    <Message
+      Condition=" '$(_GitHooksExitCode)' == '0' "
+      Importance="high"
+      Text="Git hooks configured. Run 'git config --unset core.hooksPath' to disable." />
+
+    <Warning
+      Condition=" '$(_GitHooksExitCode)' != '0' "
+      Text="Failed to configure git hooks (exit code $(_GitHooksExitCode)). Run 'git config core.hooksPath .githooks' manually." />
+  </Target>
+</Project>

Comment thread build/targets/githooks/GitHooks.targets Outdated
Comment thread .githooks/lib/LintHelpers.ps1
cursoragent and others added 2 commits March 1, 2026 21:01
- 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
- 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 <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@rjmurillo-bot rjmurillo-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: CI git hooks for local CI parity

Fixes applied:

  1. CI exclusion: Added '' != 'true' condition to GitHooks.targets to skip hook configuration in CI.
  2. POSIX compliance: Added trailing newline to .gitattributes.
  3. Deduplication: Reuse $yamlFiles for GitHub Actions workflow filtering instead of calling Get-StagedFiles twice.

Items addressed from bot reviews:

  • GitHooks.targets sentinel bug (Copilot): Already handled correctly. The Touch task has Condition="'$(_GitHooksExitCode)' == '0'" and Exec captures exit code via <Output TaskParameter="ExitCode">. Copilot's review was incorrect.
  • CI exclusion (Copilot): Fixed.
  • Duplicate Get-StagedFiles (Gemini): Fixed.

Remaining known issues (pre-existing, not introduced by this PR):

  • python3 not found on Windows (use python fallback)
  • Worktree/submodule sentinel path edge case
  • Codacy static analysis has 10 new issues (likely related to PowerShell conventions)

Copy link
Copy Markdown
Collaborator

@rjmurillo-bot rjmurillo-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: CI git hooks for local CI parity

Fixes applied:

  1. CI exclusion: Added ContinuousIntegrationBuild condition to GitHooks.targets to skip hook configuration in CI.
  2. POSIX compliance: Added trailing newline to .gitattributes.
  3. Deduplication: Reuse yamlFiles for GitHub Actions workflow filtering instead of calling Get-StagedFiles twice.

Items addressed from bot reviews:

  • GitHooks.targets sentinel bug (Copilot): Already handled correctly. The Touch task has the exit code condition and Exec captures it. Copilot's review was incorrect.
  • CI exclusion (Copilot): Fixed.
  • Duplicate Get-StagedFiles (Gemini): Fixed.

@coderabbitai coderabbitai bot added the feature label Mar 1, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.githooks/pre-commit:
- Around line 1-2: The shebang and REPO_ROOT assignment are fine but add
defensive error handling around the REPO_ROOT="$(git rev-parse --show-toplevel)"
command: test whether git rev-parse succeeded and if not print a clear
diagnostic and exit nonzero; update the hook to capture the command exit status
(or use a conditional assignment) and ensure REPO_ROOT is non-empty before
continuing so the script fails fast with a helpful message.

ℹ️ Review info

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f3a8c8a and c321a83.

📒 Files selected for processing (9)
  • .gitattributes
  • .githooks/hooks/Invoke-PreCommit.ps1
  • .githooks/hooks/Invoke-PrePush.ps1
  • .githooks/lib/LintHelpers.ps1
  • .githooks/pre-commit
  • .githooks/pre-push
  • CONTRIBUTING.md
  • Directory.Build.targets
  • build/targets/githooks/GitHooks.targets

Comment thread .githooks/pre-commit
Comment on lines +1 to +2
#!/usr/bin/env bash
REPO_ROOT="$(git rev-parse --show-toplevel)"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 git rev-parse --show-toplevel follow bash best practices. Since this runs as a git hook, the command should always succeed.

Optional: Add defensive error handling

While unlikely to fail in a git hook context, defensive error handling would provide clearer diagnostics if git rev-parse unexpectedly fails:

 #!/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
Verify each finding against the current code and only fix it if needed.

In @.githooks/pre-commit around lines 1 - 2, The shebang and REPO_ROOT
assignment are fine but add defensive error handling around the REPO_ROOT="$(git
rev-parse --show-toplevel)" command: test whether git rev-parse succeeded and if
not print a clear diagnostic and exit nonzero; update the hook to capture the
command exit status (or use a conditional assignment) and ensure REPO_ROOT is
non-empty before continuing so the script fails fast with a helpful message.

Copy link
Copy Markdown
Collaborator

@rjmurillo-bot rjmurillo-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All required CI checks pass. Git hooks infrastructure is ready.

@rjmurillo rjmurillo merged commit cdfcf69 into main Mar 1, 2026
42 of 43 checks passed
@rjmurillo rjmurillo deleted the ci/add-git-hooks-local-ci-parity branch March 1, 2026 21:41
@rjmurillo rjmurillo added this to the vNext milestone Mar 1, 2026
rjmurillo added a commit that referenced this pull request Mar 4, 2026
## Summary

Addresses all bot review feedback from #960 on the git hooks
implementation.

## Changes

**`build/targets/githooks/GitHooks.targets`** (4 issues fixed):
- Query `git config core.hooksPath` directly instead of sentinel file.
Works in worktrees and submodules where `.git` is a file.
- Only set `core.hooksPath` when not already configured. Respects
developer's existing value.
- Skip in CI via `ContinuousIntegrationBuild` condition.
- Only print success message when `git config` actually succeeds
(captures exit code).

**`.githooks/lib/LintHelpers.ps1`** (1 issue fixed):
- `Invoke-AutoFix` now snapshots pre-existing unstaged changes before
running the fix tool. Files with unstaged changes are not re-staged,
preventing silent inclusion of partial staging in commits.

**`.githooks/hooks/Invoke-PreCommit.ps1`** (2 issues fixed):
- YAML file list fetched once and reused for both `yamllint` and
`actionlint` (avoids duplicate `git diff`).
- JSON validation probes for `python3` then `python` (Windows lacks
`python3` alias).

**`.githooks/hooks/Invoke-PrePush.ps1`** (1 issue fixed):
- Build and test output captured into variables, only displayed on
failure.

## Test Plan

- [x] PowerShell syntax check passes on all 3 scripts
- [x] MSBuild target sets `core.hooksPath` when unset
- [x] MSBuild target skips silently when already set
- [x] Pre-commit hook produces zero output on clean commit

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
  * Enhanced error messaging for build and test failures
  * Improved Python detection for JSON linting
  * Strengthened git hooks configuration for worktrees and submodules
  * Better GitHub Actions workflow file validation

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Richard Murillo <rjmurillo@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants