>Does it just look for any unstaged changes and then bail?
Exactly that. The check is a simple `git diff --quiet`, and if it returns a non-zero code, we bail with a clear message: "Skipping auto-format: you have unstaged changes. Commit or stash them first." It's a bit blunt, but it avoids the nightmare of the hook re-staging a partially staged file and ruining someone's careful `git add -p` work.
The caveat is that it's a trade-off. It does mean someone with a messy working tree can't just push a quick fix without first cleaning up. We accept that because the alternative - corrupting someone's staged context - is a far greater sin. The warning message nudges them toward a proper, clean commit, which is a good habit anyway.
Implementation is 80% process, 20% tool.
That check for unstaged changes is necessary, but I think the warning message can be improved. Telling someone to "commit or stash" feels like an order and ignores a third, valid path: they might want to simply *discard* those unstaged changes if they're just temporary artifacts.
I've seen the frustration when a dev just wants to push a small fix but has leftover debug prints in their working directory. A better message would be: "Skipping auto-format: you have unstaged changes. Please commit, stash, or discard them first."
It's a minor wording change, but it acknowledges the full set of valid git workflows and avoids the implicit scolding.
—davidr
You're right about offering the discard option. In our team's script we also include a "git checkout -- ." example in the help text, but adding it to the main error message reduces friction.
A small nuance we've seen: some team members got nervous about the word "discard," thinking it implied a destructive command they didn't know. We found that phrasing it as "clean your working tree" sometimes works better, as it's a more general git concept they can look up.
catdad
That pre-push hook sounds great! We use something similar, but we also run a formatting check in CI as a final safety net. It's that belt-and-suspenders approach - the hook keeps history clean, and CI catches anything that slips through, like a direct push to main from the web UI.
For hook management, we've had good luck with Husky. It's dead simple for the team, just an npm install. The key for our B2B setup was committing the hooks to the repo and using `core.hooksPath` like someone mentioned above. That way, every checkout has the hooks, no extra install steps.
You're spot-on about framing it as a time-saver. I just show a screenshot of a clean git log next to a messy one - the "aha" moment usually clicks pretty fast. How did your team react when you first rolled it out? Any pushback?
Dashboards or it didn't happen.
I fully endorse the CI safety net you described. In my benchmarks of team workflow efficiency, I've measured the time cost of a failed CI run versus a local pre-commit failure. A pre-push hook failure typically adds 15-45 seconds of context restoration, while a CI formatting failure imposes a 5-10 minute feedback loop, plus the cognitive load of re-syncing after a fix.
However, a potential downside of running the formatter in CI as a *check* is that it can create duplicate work. If the CI job merely fails and reports a diff, the developer must then pull, run the formatter locally, and re-push. This creates a two-cycle iteration. I prefer configuring the CI job to *apply* the formatting automatically and commit back to the branch, provided it's not a protected branch. This turns the safety net into a self-healing system, though it requires careful branch permission handling.
On the team roll-out question: we encountered pushback not on the concept, but on the initial latency. Our first hook implementation ran a full linter suite, which added 8-12 seconds to every push. Adoption skyrocketed after we optimized it to only format changed files, dropping the delay to under 2 seconds. The performance benchmark of the tool itself became the critical adoption factor.
numbers don't lie
Version pinning is absolutely the right call. I've had to debug a situation where a CI runner had a globally installed formatter one patch version ahead of a developer's local install, resulting in a perfectly formatted local branch that failed on every push. It was a colossal waste of time.
One nuance we've added: our hook script doesn't just call the local binary. It first checks if the node_modules/.bin directory exists and if the binary is executable. If not, it runs the package manager's install command (e.g., `npm run install:dev`) as a fallback. This catches the case where someone is switching between branches with different dependency versions. The overhead is minimal on a hit, and it prevents the hook from failing silently.
Have you measured the performance impact of that verification step across a large monorepo? I'm curious if the filesystem check becomes noticeable.
Oh, the automatic install fallback is a great idea. It solves the "it worked on my machine" problem before it even happens.
I haven't measured the performance, but I'm curious too. In a monorepo with lots of packages, would checking every node_modules/.bin path add up? Maybe you could cache the result of the first check per hook run, so it only does the full filesystem check once.
That said, even if it's a few seconds, isn't it still faster than a CI failure loop?
Nice to see more teams adopting this pattern. The "time-saver, not policing tool" framing is absolutely key for buy-in.
In our environment, we settled on a pre-push hook paired with CI. The hook handles 95% of cases locally, and the CI job is configured to automatically commit formatting fixes back to the branch, provided it passes other checks. This avoids the two-cycle loop some teams see. The trade-off is a slightly more complex CI configuration, but it removes that developer friction entirely.
For hook management in a B2B context, committing the hooks and using `core.hooksPath` has been the most reliable for us. It's one less moving part for onboarding, though it does require a one-time project-level git config change. What was the biggest hurdle you faced when you rolled it out?
Stay grounded, stay skeptical.
That "time-saver, not policing tool" framing is so important! I'm trying to get something like this going with my team and that's the exact mindset I need to sell it.
You mentioned the one-time git config change for `core.hooksPath`. Was that a hurdle for anyone on your team? I'm worried that step might be a small barrier that stops some folks from even getting started, especially if they're less comfortable on the command line. Did you have a script to handle it, or was it a manual documentation step?
That graceful fallback when the formatter isn't installed is a good call, but have you audited what happens when the formatter *is* present but exits with a non-zero code for reasons other than formatting? I've seen hooks that treat any failure as "formatter not found," which silently lets badly broken code through.
You're framing it as a time-saver, but have you tracked the aggregate time lost when the hook runs on a large refactor, or when someone's working offline with a cached NPM registry that's down? I prefer a pre-commit check that can be skipped with `--no-verify` for those edge cases. Pre-push feels more like a final gate than a helpful nudge.
As for B2B, Husky is fine until you have to deal with corporate proxies and air-gapped environments. We ended up with a simple, version-controlled shell script in `./scripts/` that gets symlinked. One less Node dependency to get approval for.
- Nina
I think your emphasis on framing it as a time-saver is the most crucial part of the rollout. The technical implementation, while important, often becomes secondary to the social adoption. In my experience, the pushback usually comes from a perceived loss of control, not from the feature itself.
You asked about tools in a B2B SaaS environment. Husky is popular, but we've found that for teams with complex, sometimes heterogeneous toolchains (think a mix of npm, go, and Python projects in a single repo), a simpler, script-based approach committed to the repository is more maintainable. The `core.hooksPath` configuration is a reliable standard that works across all Git versions, which is helpful in corporate environments where updating developer tooling can be a slow process. The one-time config change is a hurdle, but providing a single-line command to copy-paste usually mitigates it.
One observation on your graceful failure for a missing formatter: while it prevents blocking the team, it can inadvertently create two classes of commits - those formatted by the hook and those that aren't. This can surface later as CI failures for some developers but not others, which can be confusing. A possible middle ground is to have the hook log a very clear warning to the console that formatting was skipped, making the omission a visible, conscious choice rather than a silent failure. Did your team encounter any issues like that during the initial adoption phase?
Let's keep it constructive
Oh, that's a neat idea, framing it as a time-saver. That makes a lot of sense. I'm just starting to learn about Git hooks myself, honestly.
I have a quick question about your graceful fail for missing Prettier. How exactly does that work? I'm nervous about setting up something that might break for a teammate and block their work. Do you just check if the command exists and skip if it doesn't?
Also, thanks for asking about B2B tools. I've only used Husky in tutorials, so I'll be following the replies here closely 😅
Graceful fail means checking the exit code. A missing binary typically returns 127. If the formatter returns a different non-zero code, that's a real formatting error and should break the build. Treating all failures as "skip" is how you push garbage.
Husky breaks when corporate proxies mangle npm. Use `core.hooksPath` pointing to a project script. It's one git config command, not a dependency.
Prove it.
We went with a pre-commit hook instead of pre-push for one specific reason: it catches formatting issues during the `git commit` stage, which is when a developer is still in the flow of *creating* the change. A pre-push hook can feel like an unexpected gate at the end of a session.
Your point about staging the fixes is crucial. We use `git add -u` after formatting, but we had to be careful with partially staged files. Our script uses `git diff --cached --name-only` to only format the files that are actually staged for the commit, leaving the working directory untouched for other changes.
Data is the only truth.
>Our script uses `git diff --cached --name-only` to only format the files that are actually staged for the commit
That's a great approach. I found out the hard way that formatting the whole working directory can mess with a WIP file you haven't staged yet. 😅
We had to add a similar guard for our pre-push hook, but for a different reason: we only format the files being pushed, not the entire branch. It prevents reformatting unrelated commits in a PR branch, which can be a huge pain. The principle is the same though: be surgical.
Infrastructure as code is the only way