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.

View on GitHub The seven leak paths →

Terminal demo: a commit containing a personal name and a private email address is blocked by the pre-commit hook, which lists three pattern hits with file and line; after deleting the flagged line, the same commit passes. All names in the demo are fictional.
A staged file carrying a (fictional) personal name and private email: the commit is blocked with three hits itemised. Delete the line and the same commit passes.

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.

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

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

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

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

All products →

← Back to products