Skip to content

Add optional pre-commit hook for code formatting#5178

Open
jamescrosswell wants to merge 22 commits into
mainfrom
feature/pre-commit-formatting-hooks
Open

Add optional pre-commit hook for code formatting#5178
jamescrosswell wants to merge 22 commits into
mainfrom
feature/pre-commit-formatting-hooks

Conversation

@jamescrosswell

@jamescrosswell jamescrosswell commented May 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an optional pre-commit git hook that auto-fixes code formatting before allowing commits. This helps catch formatting issues early and reduces CI failures.

Implementation

New files:

  • .githooks/pre-commit: Hook script that runs dotnet format against staged .cs files only

Updated files:

  • dev.cs: Adds setup-hooks and remove-hooks commands to opt in/out

Behavior:

  1. Developer runs ./dev.cs setup-hooks once to enable the hook
  2. On each commit, the pre-commit hook runs
  3. If unstaged changes are present → hook skips with a warning (dotnet format operates on the working tree, so partially staged files can't be handled safely)
  4. Otherwise, dotnet format runs against staged .cs files only and auto-fixes any formatting issues
  5. If fixes were applied → commit fails, developer stages the fixes (git add -u) and commits again
  6. Can bypass with git commit --no-verify if needed
  7. Run ./dev.cs remove-hooks to opt out

Design decisions:

  • Auto-fix: Hook applies formatting fixes rather than just reporting them — developer only needs to git add -u and recommit rather than manually running dotnet format
  • Staged files only: Uses --include scoped to staged .cs files so only what's being committed gets formatted
  • Skip on unstaged changes: If the working tree isn't clean, the hook exits with a warning rather than risking modification of unstaged hunks in partially staged files
  • Native git hooks: No external dependencies (Husky.NET not needed)
  • Opt-in via dev.cs: Discoverable through the existing dev CLI rather than a separate shell script

Addresses

Fixes #4980

Next Steps

This PR makes the hook optional. After battle testing for a bit, we could:

  • Document in CONTRIBUTING.md
  • Add other pre-commit checks (analyzers, etc.)

🤖 Generated with Claude Code

Adds a pre-commit git hook that verifies code formatting before allowing commits.
This helps catch formatting issues early and reduces CI failures.

Implementation:
- `.githooks/pre-commit`: Runs `dotnet format --verify-no-changes`
- `scripts/setup-hooks.sh`: One-time setup script to configure git hooks
- Updated CONTRIBUTING.md with setup instructions

The hook is optional but recommended. If formatting issues are detected, the commit
is prevented and the developer is prompted to run `dotnet format` manually.

The hook uses the same dotnet format command as CI, ensuring consistency.

Addresses #4980

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Comment thread .githooks/pre-commit Outdated
@codecov

codecov Bot commented May 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.21%. Comparing base (32a55e3) to head (d604b31).
⚠️ Report is 110 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5178      +/-   ##
==========================================
+ Coverage   74.06%   74.21%   +0.15%     
==========================================
  Files         501      509       +8     
  Lines       18113    18386     +273     
  Branches     3521     3600      +79     
==========================================
+ Hits        13415    13645     +230     
- Misses       3838     3868      +30     
- Partials      860      873      +13     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

jamescrosswell and others added 3 commits May 4, 2026 09:40
Previously the hook piped dotnet format output through grep to check for
"formatted" string. This ignored the actual exit code, which meant:
- If dotnet format failed for unrelated reasons (missing SDK, etc) but
  didn't output "formatted", the hook would pass incorrectly
- The implementation diverged from how dotnet format is intended to work

Now uses --verify-no-changes' exit code directly (non-zero when formatting
is needed), which is more reliable and matches the tool's design.

Addresses Warden feedback in review comment.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
ModuleInitializer is legitimately used in test projects for test setup
(VerifyHttp initialization, EF provider registration, etc.). Test projects
are effectively application code, not libraries, so this usage is appropriate.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Changed the hook to replicate CI's approach:
- Stash unstaged changes with --keep-index
- Run dotnet format (no --verify-no-changes, matches CI exactly)
- Check git diff to detect formatting changes
- Restore stashed changes

This avoids false failures from analyzer warnings (IDE1006, CA2255, etc.)
and only fails on actual formatting changes that would fail CI.

The stash approach handles mixed staged/unstaged changes cleanly - we only
check formatting on what's being committed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Comment thread .githooks/pre-commit Outdated
Comment thread .githooks/pre-commit Outdated
Comment thread .githooks/pre-commit Outdated
Replace the manual stash-pop block with a `trap ... EXIT` handler so
unstaged changes are restored even when `dotnet format` fails and
`set -e` causes early termination. Also replace the silent
`2>/dev/null || true` swallow with an explicit failure message so
developers are notified when a stash-pop conflict leaves their changes
in the stash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread .githooks/pre-commit
jamescrosswell and others added 2 commits May 4, 2026 11:19
When git stash pop fails due to a conflict, git leaves conflict markers
in the affected files and keeps the stash entry — running stash pop
again won't help. Update the warning to tell the developer to resolve
the conflict markers first, then drop the stash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The stash/unstash dance was error-prone (data loss on format failure,
silent conflicts on pop). Just run dotnet format and block the commit
if it made changes — the developer handles their own working tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread .githooks/pre-commit Outdated
Comment thread .githooks/pre-commit Outdated
Comment thread .githooks/pre-commit Outdated
Comment thread .githooks/pre-commit Outdated
jamescrosswell and others added 3 commits May 4, 2026 12:22
Adds `./dev.cs setup-hooks` as a discoverable way to configure git to
use the repo's pre-commit hooks from .githooks/. Calling git config
directly in C# means it works on Windows too, replacing the Unix-only
scripts/setup-hooks.sh approach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Superseded by `./dev.cs setup-hooks` which does the same thing
cross-platform with no duplicate logic to keep in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use `--include` with the list of staged .cs files so the format run
only touches what's actually being committed. Unstaged WIP is left
completely alone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread .githooks/pre-commit Outdated
Comment thread .githooks/pre-commit Outdated
jamescrosswell and others added 4 commits May 4, 2026 13:20
grep exits 1 on no matches, which set -e would treat as a failure.
Adding || true to the assignment makes this robust regardless of
pipefail settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Symmetric counterpart to setup-hooks — runs git config --unset
core.hooksPath to restore default git hooks behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the space-separated string + unquoted expansion with a bash
array, passing each staged file as its own --include argument. This
correctly handles paths containing spaces or shell metacharacters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dotnet format operates on the working tree, not the index, so partially
staged files would have their unstaged hunks modified too. Skip with a
warning instead — the check still runs on clean commits (the common
case).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jamescrosswell jamescrosswell marked this pull request as ready for review May 5, 2026 04:23
@jamescrosswell jamescrosswell requested a review from Flash0ver as a code owner May 5, 2026 04:23
Comment thread CONTRIBUTING.md Outdated
Comment thread CONTRIBUTING.md Outdated
Comment thread .githooks/pre-commit
jamescrosswell and others added 2 commits May 6, 2026 17:52
- Replace removed scripts/setup-hooks.sh with ./dev.cs setup-hooks
- Correct hook behaviour: auto-fixes formatting rather than verify-only
- Document the unstaged changes skip behaviour
- Add ./dev.cs remove-hooks opt-out instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Redirecting all output to /dev/null meant build errors or analyzer
failures silently blocked the commit with no explanation. Capture
output and print it only when dotnet format exits non-zero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread .githooks/pre-commit
Comment thread .githooks/pre-commit
Comment thread .githooks/pre-commit
Comment thread .githooks/pre-commit
Comment thread dev.cs
Extensionless hook scripts fell through to * text=auto, risking CRLF
line endings on Windows checkouts which break the bash shebang.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jamescrosswell jamescrosswell added the skip-changelog Suppress automatic changelog generation via Craft label May 27, 2026
Comment thread dev.cs
Comment thread CONTRIBUTING.md Outdated
Comment thread .editorconfig Outdated
Comment thread .githooks/pre-commit
INCLUDE_ARGS=()
while IFS= read -r f; do
INCLUDE_ARGS+=(--include "$f")
done < <(git diff --cached --name-only --diff-filter=ACM | grep '\.cs$' || true)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

note: --diff-filter=ACM

I wasn't sure if filter --diff-filter=ACM also includes files that are renamed, but also contain changes. I gave it a quick test and it seems that they are. ✅

Comment thread .githooks/pre-commit
fi

FORMAT_OUTPUT=$(dotnet format Sentry.slnx --no-restore \
"${INCLUDE_ARGS[@]}" \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

praise: great optimization ... dotnet format on the "full" solution takes quite a while

Comment thread .editorconfig Outdated
Addresses review feedback: keep installation/solution setup sections
first, before the editing-workflow content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: James Crosswell <james.crosswell@gmail.com>
@github-actions github-actions Bot added the risk: medium PR risk score: medium label Jul 10, 2026
Comment thread .githooks/pre-commit Outdated
jamescrosswell and others added 2 commits July 10, 2026 12:17
CA2255 is now disabled globally via .editorconfig, so the inline
suppressions on the ModuleInitializer methods are no longer needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: James Crosswell <james.crosswell@gmail.com>
Relocate the [test/**/*.cs] section from the root .editorconfig into a
dedicated test/.editorconfig, matching the src/.editorconfig convention
from #5230 so diagnostic suppressions are consistently discoverable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: James Crosswell <james.crosswell@gmail.com>
The ./**/*OptionsSetup.cs exclude pattern was unquoted, so the shell
attempted pathname expansion. It currently works only because the
pattern matches nothing (files sit deeper than the non-globstar shell
glob reaches) and bash passes the literal through to dotnet format.
Quoting makes this robust: the literal pattern always reaches
dotnet format, which handles the ** recursion itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: James Crosswell <james.crosswell@gmail.com>
@jamescrosswell jamescrosswell requested a review from Flash0ver July 10, 2026 00:31

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b5e1c98. Configure here.

Comment thread .githooks/pre-commit
echo " git commit"
echo ""
exit 1
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Autofix enables skip bypass

High Severity

After dotnet format writes fixes, the working tree is dirty and the hook exits with failure. A follow-up git commit without staging those fixes hits the unstaged-changes guard, exits successfully, and lets the original unformatted staged content through. The formatting changes stay unstaged while the bad formatting is committed, so the hook fails open on the exact retry path it tells developers to use if they omit git add -u.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b5e1c98. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is intentional rather than a defect. The unstaged-changes guard exists specifically so dotnet format never touches work-in-progress, and it prints a visible ⚠️ Skipping format check rather than failing silently. This is an opt-in convenience hook — CI remains the actual formatting gate — so on the (documented) retry path the worst case is a formatting-only CI failure, not committing broken code. Enforcing formatting on a dirty tree would require a stash-based partial-format dance that risks mangling WIP, which we deliberately avoid. Leaving as-is.

Comment thread dev.cs
Comment thread dev.cs
{
Console.WriteLine("[dev] Configuring git hooks path to .githooks/");
return RunStepAsync("git config core.hooksPath", "git", "config core.hooksPath .githooks", options.DryRun);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Setup skips executable bit

Medium Severity

setup-hooks only sets core.hooksPath and never ensures .githooks/pre-commit is executable. On macOS/Linux, Git silently ignores a non-executable hook, so after ./dev.cs setup-hooks commits proceed with no formatting check and only a brief ignored-hook hint. Contributors can believe the optional hook is enabled when it is not running at all.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b5e1c98. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not applicable here — .githooks/pre-commit is committed with mode 100755 (executable), so git checks it out executable on clone/checkout. setup-hooks therefore doesn't need to chmod it, and on Windows git runs hooks via its bundled shell regardless of the exec bit. No change needed.

git config --unset exits with code 5 when the key doesn't exist, which
caused remove-hooks to report failure when run twice or before
setup-hooks. Treat exit code 5 as success, since an absent
core.hooksPath is the desired end state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: James Crosswell <james.crosswell@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium skip-changelog Suppress automatic changelog generation via Craft

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Can we run analysers anddotnet format as pre-commit hooks?

2 participants