A repo holds code, config, schemas and reasoning. It should never hold records or the real identities of third parties. Private repos are not an exception: git history is permanent and travels with every clone, visibility is one settings toggle from public, and your client’s customers and subcontractors never agreed to be in your repo at all. If you work with Claude Code or any other assistant, everything in the repo is also something the model reads.
I learned this the expensive way on a client repo that listed its credentials to ignore one path at a time. A byte-identical copy of a service-account key sat unignored for the life of the project because nobody had written a rule for that exact filename.
The five rules
- Deny data and secrets by directory, never file by file.
data/,secrets/, one line each in.gitignore. A new file in a denied directory is safe by default. A new file that needs a new rule is a leak waiting for someone to forget. - De-identify docs before the first commit. The client can appear by name (it is their project, and they agreed). Everyone else gets a stable pseudonym:
CLIENT-028,SUB-1,PARTY-A. Street addresses, personal emails, phone numbers, EINs and SSNs are removed, not aliased. Dollar amounts, dates and vendor business names can usually stay; they are the substance of the work and are not identifying once the people are gone. - The re-identification map is the secret. It lives outside the repo (I keep one per client in a never-committed secrets folder). The de-identification script is committed; the key is not. That split is the whole design.
- Config that joins against real data is a secret too. If pseudonymizing a file would break matching (job registries, alias tables, account maps), do not pseudonymize it. Gitignore it and commit a
.exampletwin that carries the schema with invented values. - Check before committing, not after. Run the script in report mode over everything you are about to commit and confirm zero rewrites. After a commit it is too late: the fix becomes a history rewrite and a force-push, and every existing clone keeps the data.
Timing matters more than thoroughness. Doing this before git init costs nothing. Doing it after costs a history rewrite.
The map
# identity_map.yaml (lives OUTSIDE the repo, never committed)
people:
Jane Realname: SUB-1
Jane: SUB-1
emails:
jane@example.com: "[email removed]"
job_names:
Smith kitchen remodel: CLIENT-028
addresses:
"123 Real Street": "[address removed]"
redact:
"12-3456789": "[EIN removed]"
The script
Longest match first, so Jane Realname is replaced before bare Jane can break it apart. Report-only unless you pass --apply.
"""Apply an identity map to text files so docs can be committed safely."""
import argparse, pathlib, re, sys
import yaml # pip install pyyaml
SECTIONS = ["people", "emails", "job_names", "addresses", "redact"]
SUFFIXES = {".md", ".yaml", ".yml", ".py", ".txt", ".json", ".sh"}
def load_pairs(path):
doc = yaml.safe_load(path.read_text()) or {}
pairs = [(str(r), str(a)) for s in SECTIONS for r, a in (doc.get(s) or {}).items()]
return sorted(pairs, key=lambda p: len(p[0]), reverse=True)
def scrub(text, pairs):
hits = {}
for real, alias in pairs:
pat = re.escape(real)
if real[0].isalnum() and real[-1].isalnum():
pat = rf"(?<![A-Za-z0-9]){pat}(?![A-Za-z0-9])"
text, n = re.subn(pat, alias, text)
if n:
hits[real] = hits.get(real, 0) + n
return text, hits
def main():
p = argparse.ArgumentParser()
p.add_argument("--map", type=pathlib.Path, required=True)
p.add_argument("target", type=pathlib.Path)
p.add_argument("--apply", action="store_true")
a = p.parse_args()
pairs = load_pairs(a.map)
files = [a.target] if a.target.is_file() else [f for f in a.target.rglob("*") if f.suffix in SUFFIXES]
total = 0
for f in files:
new, hits = scrub(f.read_text(errors="ignore"), pairs)
if hits:
total += sum(hits.values())
print(f"{f}: {hits}")
if a.apply:
f.write_text(new)
print(f"{total} replacement(s){' applied' if a.apply else ' found (report only)'}")
return 1 if total and not a.apply else 0
if __name__ == "__main__":
sys.exit(main())
The exit code is 1 when report mode finds anything, so it can sit in a pre-commit hook and block the commit.
Tell your assistant
One paragraph in your project’s CLAUDE.md does most of the work: “data/ and secrets/ are gitignored by directory and must stay that way. Docs use pseudonyms from the identity map. Never write a real third-party name, email, or address into a committed file.” The model follows written rules far better than remembered ones.