Add optional pre-commit hook for code formatting#5178
Conversation
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>
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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>
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>
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>
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>
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>
- 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>
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>
| INCLUDE_ARGS=() | ||
| while IFS= read -r f; do | ||
| INCLUDE_ARGS+=(--include "$f") | ||
| done < <(git diff --cached --name-only --diff-filter=ACM | grep '\.cs$' || true) |
There was a problem hiding this comment.
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. ✅
| fi | ||
|
|
||
| FORMAT_OUTPUT=$(dotnet format Sentry.slnx --no-restore \ | ||
| "${INCLUDE_ARGS[@]}" \ |
There was a problem hiding this comment.
praise: great optimization ... dotnet format on the "full" solution takes quite a while
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>
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
❌ 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.
| echo " git commit" | ||
| echo "" | ||
| exit 1 | ||
| fi |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit b5e1c98. Configure here.
There was a problem hiding this comment.
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.
| { | ||
| Console.WriteLine("[dev] Configuring git hooks path to .githooks/"); | ||
| return RunStepAsync("git config core.hooksPath", "git", "config core.hooksPath .githooks", options.DryRun); | ||
| } |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit b5e1c98. Configure here.
There was a problem hiding this comment.
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>


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 runsdotnet formatagainst staged.csfiles onlyUpdated files:
dev.cs: Addssetup-hooksandremove-hookscommands to opt in/outBehavior:
./dev.cs setup-hooksonce to enable the hookdotnet formatruns against staged.csfiles only and auto-fixes any formatting issuesgit add -u) and commits againgit commit --no-verifyif needed./dev.cs remove-hooksto opt outDesign decisions:
git add -uand recommit rather than manually runningdotnet format--includescoped to staged.csfiles so only what's being committed gets formattedAddresses
Fixes #4980
Next Steps
This PR makes the hook optional. After battle testing for a bit, we could:
🤖 Generated with Claude Code