OSS · Bash CLI · Git Hooks
private-lint
Git history never forgets. Stop private names before they enter it.
A git-hook gate that catches personal and private names (family names, personal emails, client and employer domains) before commit and push. Pure Bash, no dependencies. Detection patterns live only on your machine, never in any repo, and a full-history audit subcommand covers the moment before you flip a repository public.
Seven paths a name leaks through
"Scan the diff" was not enough. Rolling the tool out across 25 public repositories surfaced real hits on paths a diff scan never sees. One gate per path:
| Path | Gate | What it looks at |
|---|---|---|
| Staged diff + file names | pre-commit | Added lines and staged path names (a screenshot saved under someone’s name leaks through the file name alone) |
| Commit message | commit-msg | Every line of the message body, so "fix as Yamada suggested" is stopped before it becomes history |
| Push range diff | pre-push | Diffs and file names of commits the remote has not seen; new branches check everything unreached by any remote |
| Push destination | pre-push | Remote name and URL: a client name in the repo slug slips through any amount of content scanning |
| Author identity | pre-commit | The effective author/committer email resolved by git, matched against an allowlist; unset counts as a violation |
| What is already in history | audit (manual) | Full-history diffs, all file names, repo name, remote URLs and the current HEAD, swept before going public |
| The escape hatches themselves | design | allow rules, inline markers and audit baselines each apply one level narrower than the field they sit next to |
Install
Clone and point it at a repo. install is idempotent and inserts itself ahead of existing hooks, so an early exit in an old hook cannot bypass the gate. install-template covers every future clone via init.templateDir.
git clone https://github.com/kenimo49/private-lint.git
cd private-lint
mkdir -p ~/.config/private-lint
$EDITOR ~/.config/private-lint/patterns # 1 ERE per line; never committed anywhere
./private-lint install ~/repos/<repo> # idempotent; inserts itself before existing hooks
echo "mode=block" > ~/repos/<repo>/.privatelintrc # public repos: block
./private-lint install-template # auto-install into every future clone / init Usage
Every hook can also be run by hand, and audit is the pre-publication sweep:
./private-lint check --staged # pre-commit, by hand
./private-lint check --message <file> # commit-msg, by hand
./private-lint check --push-dest <name> <url> # remote name / URL
./private-lint audit ~/repos/<repo> # full history + repo name + remote + HEAD
./private-lint status # effective mode, hooks, pattern count
PRIVATE_LINT=off git commit ... # intentional bypass Four design calls
The interesting decisions are all about which way each mechanism fails.
-
Gates fail open, the audit fails closed
On a machine with no pattern file, the hooks pass everything through: an allowlist that blocks by default would freeze every machine it lands on before anyone distributes patterns to it. audit inverts this and exits with an error when patterns are missing, because "I audited it" with zero patterns loaded is the most dangerous output the tool can produce.
-
identities is an allowlist, not a denylist
Patterns say what must not appear. identities says which author emails may commit. The inversion exists because a repo with no user.email configured silently inherits a global or auto-detected address, and that is exactly how a personal address ends up in a public history.
-
Three of the seven paths caught real leaks during rollout
The push-destination check, the identity check and the full-history audit each flagged a real hit while the tool was being deployed across existing repositories. They are not hypothetical defenses.
-
Escape hatches are scoped one level narrower
The inline marker (private-lint:allow) is ignored on generated metadata lines such as file names and remote URLs, so it cannot be smuggled into fields it should not cover. Audit baselines silence history hits only: a name in the current repo name or remote URL stays loud, because that is a live exposure, not an accepted old one.
Related developer tools
- hook-chain-lens A read-only CLI that reads every Claude Code hook scope (user, project, local, plugin) and prints what will actually fire in this cwd, in what order, from where.
- quake-lens Earthquake statistics CLI + MCP server in pure-stdlib Python: Gutenberg-Richter b-value (Aki MLE) and Omori-Utsu aftershock decay (Ogata MLE) from public catalogs.
- rhythm-lens Measures the rhythm of Japanese, English and Portuguese Markdown (sentence-length burstiness, paragraph structure) against measured human/AI distributions from two published papers. A writing feedback instrument, not an AI detector.
- claude-shift Multi-account Claude Code manager. Switch the active login, sync the two-file auth Claude Code actually reads (~/.claude/.credentials.json + ~/.claude.json), and watch 5-hour and weekly usage across all accounts. CLI + local API + Chrome extension + browser Web UI + Tauri v2 desktop.