scrub-for-public
Skill11 starsChanged 3 months agoMITView on GitHub
Produce a publishable branch of a repository with private directories removed from the whole history, not just the tip. Finds .NOT_PUBLIC markers, rewrites history with git-filter-repo (run from nix-shell) in a throwaway clone, audits the rewritten history for leaked keywords with git log -p, and lands the result as public-scrubbed/$YYMMDD-HHMMSS/$original_sha. Use before open-sourcing a repo, before pushing an internal repo to a public remote, or when asked to strip vendor/employer-specific material from history.
- Deletes or force-pushes
- Commits and pushes
In this repository18 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- android-app-reverserSkill
- android-emulator-app-live-inspectSkill
- aula-pii-scanSkill
- de-ai-proseSkill
- de-ai-repoSkill
- drawio-figure-reviewerSkill
- explainer-videoSkill
- justfile-hygieneSkill
- model-raceSkill
- nix-scaffoldSkill
- phase1-prd-grillSkill
- phase2-backlog-snowballSkill
- phase3-backlog-ralphSkill
- readme-improverSkill
- refactor-smellerSkill
- xterm-tmux-controlSkill
- zellij-controlSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
13 rules. About 0 are common, 7 are distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈7
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- could be automatic: a setting could enforce it
- Very specific: line 71
Every .NOT_PUBLIC file is added to the exclusion set, including the root one.
- Distinctive: line 95
Do this first. A survey run on the agent's own assumptions finds only the categories the agent already imagined, and then reports …
- Very specific: line 128
Write the answers down as you get them.
- Distinctive: line 147
Look for: corporate identities in history.
- Very specific: line 177
Run identities.sh <repo> with no mailmap.
- Distinctive: line 291
Before invoking anything destructive the script compares the clone's toplevel against the source's and exits non-zero if they match.
- Very specific: line 361
Run directories live under /tmp.
- Very specific: line 366
Always delete through cleanup.sh, never a bare rm -rf.
- Distinctive: line 375
commit count before and after, and how many commits the rewrite pruned.
- Distinctive: line 376
every keyword with its hit count, zeros included.
- Distinctive: line 409
Keep bash out of this file beyond the invocation block above.
- Very specific: line 415
Never push the scrubbed branch from a repository that also contains the private history.
- Distinctive: line 445
Run a dedicated secret scanner over the scrubbed branch as well.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
scrub-for-public — publishable history, with the audit that proves nothing
Deleting a directory in a new commit does not remove it from a repository. The blobs stay reachable from every earlier commit, and anyone who clones gets them. This skill removes marked directories from the entire history and then goes looking for what the removal missed.
The output is a branch, never a push. The human decides what gets published.
Read this before running it
A keyword audit is not a proof of absence. It finds private material that survives under a name you thought to grep for. It cannot find paraphrases, a colleague's surname, an internal project codename nobody wrote down, a screenshot, or a hostname in a binary. Report the audit as evidence, never as a clearance. A clean audit means "these terms are gone", not "this is safe to publish".
Rewriting history changes every commit SHA from the first rewritten commit onward. The scrubbed branch shares no commits with the original. That is why the branch name records the original tip SHA — it is the only link back.
Marker format
A .NOT_PUBLIC file in a directory means: this directory and everything under
it is excluded from the scrubbed history. The file's presence is the
exclusion. Its contents are the audit terms.
# .NOT_PUBLIC — this directory is excluded from public-scrubbed history.
keywords:
acme-cli
acme.example.com
Some Internal Codenamekeywords: is an indented list, one term per line, read literally (they become
alternatives in one grep -Einw pattern, so . and - need no escaping).
Choose terms that are distinctive on their own. A keyword that is a substring of
an ordinary word will drown the audit in false positives; see the note on -w
in step 3.
The root marker is a manifest, never an exclusion
A .NOT_PUBLIC at the repository root would, under the rule above, exclude the
whole repository. It never does. A root marker exists so individual files —
which have no folder of their own to mark — can still be excluded:
also-exclude:
agents/some-internal-agent.md
keywords:
Internal Program NameThe skill must refuse to derive a directory exclusion from a root marker, and
must fail loudly if it ever computes . or the repo root as an exclusion path.
also-exclude: entries are repo-relative paths and may be files or directories.
Markers exclude themselves
Every .NOT_PUBLIC file is added to the exclusion set, including the root one.
Otherwise the root manifest survives the scrub and publishes the exact keyword
list the scrub existed to remove. This is not optional.
First run in a repo — onboarding
A repo with no .NOT_PUBLIC markers gives the skill nothing to do. Creating
them is a conversation, not a command. Deciding what is private is a
judgement about the project, its owner and its audience, and the two ways of
getting it wrong fail in opposite directions:
- an over-broad marker silently deletes real history from the published branch;
- a missing one publishes exactly the material the scrub existed to remove.
Neither is detectable afterwards. The audit only ever checks the terms it was
given, so a category nobody thought of passes cleanly. That is why there is no
suggest.sh here and no auto-marking: a scanner that writes markers on its own
would be the most dangerous thing in this skill.
The pattern to follow is the one identities.sh already uses — enumerate,
refuse to guess, hand the human something to approve.
1. Ask what sensitive means here, before looking for it
Do this first. A survey run on the agent's own assumptions finds only the categories the agent already imagined, and then reports confidently on the rest — the blind spot propagates into the "what I decided not to propose" list and reads as coverage.
What counts as sensitive is not a property of the repo. It depends on who is publishing, to whom, and what a leak would cost them. Ask:
- What is the published branch for, and who will read it? A portfolio piece, a paper's artifact, an open-source release and a handover to a client all tolerate different things.
- What would you not want in it? Let them answer in their own words first. Offer categories only as prompts afterwards, never as the definition: employer or client identity · third-party personal data · customer names under NDA · unreleased products, roadmaps, codenames · infrastructure topology and hostnames · credentials and tokens · your own identity, if you publish pseudonymously · anything under NDA, export control or a data-protection duty.
- What is the consequence if something slips? Embarrassment, a contract breach, someone's job, someone's safety. This calibrates everything downstream: high stakes justify over-marking, low stakes make an over-broad marker the bigger cost.
- What must survive? Publishing exists to show something. Naming it up front stops the scrub eating the point.
- Has any of this already been public? A term already on a public site is not worth redacting, and redacting it garbles the text for no gain.
Expect "I am not sure" to some of this, and treat that as normal rather than a failure. People recognise their own sensitive material far more reliably than they can enumerate it cold. That is what the survey is for — it comes back with concrete findings that prompt recognition, and the answers get refined. The loop is ask → survey → show → ask again, not a questionnaire followed by a scan.
Write the answers down as you get them. They become the accept/reject criterion
in step 3 and the seed vocabulary for every marker's keywords:, and a later
reader of those markers needs to know why a term is on the list.
2. Survey for signals
Now go looking — for the categories the human named, and for these as a supplement. They are a starting point, not the definition; every project hides its private material somewhere slightly different, and the human's answers in step 1 outrank this list wherever they disagree.
git log --format='%ae%n%ce' | sort -u # who authored it, and under what domain
git log --format='%s' | head -50 # what the messages name
rg -oi '[a-z0-9.-]+\.(internal|corp|local|lan|intra)\b' | sort -u
rg -oi 'https?://[a-z0-9.-]+' | sort -u # portals, registries, ticket systems
ls -d */ && git ls-files | cut -d/ -f1 | sort -u # directory names carrying a vendor or programmeLook for: corporate identities in history; hostnames on non-public TLDs; private package registries in lockfiles and CI config; directories named after an employer, product or programme; wrappers around internal-only CLIs; ticket IDs and portal links in docs; anything a colleague would recognise and an outsider could not resolve.
3. Propose, with the evidence attached
Bring back a table — candidate path, why it is a candidate with a file:line
citation, and the keywords that path suggests. Never "this looks internal".
The human is being asked to approve a deletion from history; they need to see
what you saw.
Say which candidates you are unsure about, and separately what you looked at and decided not to propose. A survey that only reports hits reads as complete when it is a sample.
4. Let the human decide, then write the markers
They accept, reject or add, judged against what they said in step 1 rather than against your instinct. Only then write files:
- a
.NOT_PUBLICin each accepted directory, itskeywords:drawn from what the survey actually found there — not a generic list; - a root
.NOT_PUBLIConly if individual files need excluding (also-exclude:) or a repo-wide term ormailmap:belongs somewhere. Read the root-marker rule above first: it is a manifest, never an exclusion.
5. Settle the identity
Run identities.sh <repo> with no mailmap. It lists every author and committer
that would be stamped on the published commits. Ask what each should become —
the candidates are the identities already in the list, if one of them is
already public — and write the agreed mailmap: block. Do not infer it from
git config; an author rewrite is not something to guess at.
6. Commit the markers, then run the pipeline
preflight.sh requires a clean tree, so the markers must be committed first.
From here the normal procedure applies.
What not to mark
Over-marking produces a published branch with the interesting parts missing,
which is a real cost and not a safe default. A path belongs in a marker when it
would leak something; generic infrastructure that merely touches a private
system usually does not. When a directory is mostly public with one private
file, prefer also-exclude: on that file over marking the directory.
Procedure
Each stage is a script under scripts/. Run them in order from the repo you
want to scrub. The scripts are the implementation; this file is the reasoning —
if the two ever disagree, the scripts are what runs.
SCRIPTS=~/.claude/skills/scrub-for-public/scripts
eval "$("$SCRIPTS/preflight.sh" .)" # sets SRC, ORIGINAL_SHA, ORIGINAL_REF, REMOTES
RUN=$("$SCRIPTS/new-rundir.sh")
trap '"$SCRIPTS/cleanup.sh" "$RUN"' EXIT # arm this before the clone exists
"$SCRIPTS/discover.sh" . "$RUN" # then STOP and read the output
"$SCRIPTS/redact-plan.sh" . "$RUN" # blocks while any identity is unmapped
"$SCRIPTS/rewrite.sh" . "$RUN"
"$SCRIPTS/audit.sh" "$RUN" # exits 1 if anything survives
"$SCRIPTS/land.sh" . "$RUN" "$ORIGINAL_SHA"0. preflight.sh <repo> — refuse to start on a bad footing
Requires a clean working tree. The scrub cannot lose uncommitted work — the
clone only takes committed history — but shipping a branch that silently omits
staged changes is worse than refusing. Prints SRC, ORIGINAL_SHA,
ORIGINAL_REF and REMOTES for the caller to eval, and warns when the repo
has a remote, because the scrubbed branch must never be pushed to one carrying
the private history.
1. discover.sh <repo> <rundir> — resolve exclusions and keywords
Writes <rundir>/paths and <rundir>/keywords, and prints both.
Markers come from git ls-files, not find — an untracked marker inside a
build directory was never in history and must not silently widen the rewrite. A
directory marker contributes its dirname; the root marker contributes only its
also-exclude: entries; every marker contributes itself. Any path resolving to
., /, or outside the repo aborts the run.
Stop here and read the output before continuing. A wrong path silently deletes real history, and no later stage can tell a mistake from an intention.
1b. redact-plan.sh <repo> <rundir> — plan what path removal cannot reach
Path filtering removes whole files. It cannot touch a private term written
inside a file that stays, a commit message, or an author identity — and in
practice that is where the leaks are. Writes three inputs for rewrite.sh:
replace-text (blob contents), replace-message (commit and tag messages), and
mailmap (identities), then prints them for review.
Entries are word-boundaried and case-insensitive regexes, not literals.
--replace-text defaults to literal substring matching, which would rewrite
"operation" while redacting a keyword like Ration and quietly corrupt
unrelated prose. The (?i) flag is equally load-bearing: audit.sh greps with
-i, so a case-sensitive redaction lets a capitalised occurrence through and
the audit then reports a leak the redaction was never going to catch.
Redaction is more invasive than path removal. It edits files that survive
into the published branch, so a term appearing inside code becomes REDACTED
there — which can change meaning or break a script. Review the plan, then
review the resulting diff, not just the audit.
1c. identities.sh <repo> [mailmap] — who the commits will be stamped by
Rewriting history does not rewrite who wrote it. Author and committer carry across verbatim, so an internal address appears on every synthesized commit and republishes an employer as loudly as any hostname. Author and committer are separate fields and routinely differ — a rebase, a squash, a patch applied on someone's behalf — so both are enumerated, with counts.
Given a mailmap, exits 1 while any address is unmapped, and redact-plan.sh
refuses to produce a plan in that state. What an identity should become is a
human decision, so the stage lists the candidates and stops rather than
guessing.
One trap it closes: git mailmap has a one-entry form, Proper Name
<commit@email>, that corrects the name and leaves the address untouched.
That reads as coverage and publishes the address anyway, so coverage requires at
least two <...> fields with the target differing from the commit address.
Declare the mapping in any .NOT_PUBLIC marker — the marker is itself excluded,
so the mapping never ships:
mailmap:
Public Name <public@example.com> Commit Name <internal@example.com>2. rewrite.sh <repo> <rundir> — strip the paths from the whole history
Clones with --no-local, which forces a real object copy instead of hardlinks,
so a rewrite in the clone cannot reach back into the source's object store. It
also gives git-filter-repo the fresh clone it insists on, which is how the
skill avoids --force.
Before invoking anything destructive the script compares the clone's toplevel against the source's and exits non-zero if they match. Prose telling a future reader to "clone first" is not a control; a comparison that fails is.
Runs one filter-repo invocation covering path removal and any redaction
the plan produced. One pass is not a style choice: the tool records that it has
run against a repo and refuses a second pass without --force, and reaching for
--force to chain stages would discard the check that keeps it off the source
repo.
Uses git-filter-repo from PATH when present, otherwise nix-shell -p
git-filter-repo. If the project ships its own shell.nix or flake.nix
providing it, prefer that. --paths-from-file entries are path prefixes, which
is what a directory exclusion wants; commits left empty are pruned
automatically. Prints the before/after/pruned commit counts.
3. audit.sh <rundir> — look for what the removal missed
Exits 1 if any keyword survives. That non-zero exit is the point of the
stage: it means the private material was also written outside the excluded
directories — another file referencing the internal CLI, a CLAUDE.md
mentioning the portal, a commit message quoting a hostname.
Searches patches and commit messages (git log -p covers both), then runs
-G per keyword so the report names the introducing commit rather than merely
proving a match exists. -G and not -S: -S is a literal substring search
with no word-boundary option, so it contradicts the -w grep and reports
leaks that are not leaks. Also checks what log -p does not cover: tags, stray
refs, and author/committer identities — an internal email address in every
commit republishes an employer as loudly as a hostname does.
-w is not optional, and the reason is concrete: a product named Ration
matches every "operation" and "iteration", and a repo full of those words
reports dozens of leaks that are not leaks. An audit nobody can read is an
audit nobody acts on. Keywords inside hyphenated or dotted strings still match, because -
and . are not word characters. If a keyword genuinely is a substring of a
common word, grep that one term separately without -w and say so — do not
drop -w for the whole run.
On a failure: add the leaking path to a marker and rerun from stage 2, or report the exact commit and file needing a content rewrite. Never hand back a branch with unexplained hits.
4. land.sh <repo> <rundir> <original_sha> — land the branch
Creates public-scrubbed/$(date +%y%m%d-%H%M%S)/$original_sha. The timestamp,
rather than a bare date, keeps two runs on the same day from colliding while a
marker set is still being tuned — each gets its own branch to compare.
This is the only write the skill makes to the source repository, and it is purely additive: one new ref plus the objects reachable from it. No existing ref is moved, rewritten, or deleted; the original branch still holds the private history, which is intended — this repo is not the thing being published.
The refspec has no leading +, deliberately. If the branch exists and the new
history is not a fast-forward, git rejects the fetch and the script fails rather
than replacing a branch someone may already have published from. To replace one,
delete it explicitly first. With a timestamped name that guard rarely fires —
it is the backstop for a reused name, not the routine path.
Cost worth knowing: the fetched objects are a near-duplicate of the repo's
content and are pinned by the new branch, so they are never garbage collected.
On a large repo that roughly doubles .git. Deleting the branch releases them.
5. cleanup.sh <rundir> — remove the run directory
Between the clone and the rewrite, the run directory holds a complete copy of
the private history, so it must not survive an aborted run. Arm the trap
before the clone exists, not after.
Run directories live under /tmp. Worth knowing when a run fails early: the
directory holds a full clone, and /tmp is a RAM-backed tmpfs on many systems,
so a repo larger than available /tmp fails at creation rather than somewhere
confusing mid-rewrite.
Always delete through cleanup.sh, never a bare rm -rf. What it refuses, and
why, is documented at safe_rmrf in scripts/lib.sh.
6. Report
State plainly:
- the exclusion set, and how each path was derived (directory marker / root manifest / the markers themselves);
- commit count before and after, and how many commits the rewrite pruned;
- every keyword with its hit count, zeros included;
- the branch name and the original SHA it was cut from;
- the surfaces not checked (see limitations), so the human knows the shape of the residual risk;
- that the source repo received one new ref and nothing else;
- the publishing warning below.
What this skill touches (blast radius)
Stated explicitly so a reader does not have to trace it:
| Repo | Operations | Mutating? |
| Source | status, rev-parse, ls-files, log (read) · clone from it | No |
| Source | fetch <clone> HEAD:$BRANCH (stage 4) | Yes — additive only: creates one ref, adds objects. Rejects rather than overwrites an existing branch. |
| Throwaway clone | filter-repo and every audit command | Yes, and it is discarded |
Nothing in this skill deletes a ref, moves an existing branch, rewrites the
source's history, force-pushes, or touches the working tree. git filter-repo
— the only destructive tool involved — never runs with the source as its
target, and stage 2's guard exits non-zero if it ever would.
Maintaining the scripts
scripts/*.sh must pass shellcheck -x clean:
nix-shell -p shellcheck --run 'shellcheck -x -P scripts scripts/*.sh'-P scripts is needed so -x can resolve lib.sh when shellcheck runs from
the skill root rather than from inside scripts/.
Keep bash out of this file beyond the invocation block above. Two copies of the same logic in a skill and its scripts drift, and the prose copy is the one that silently stops being true.
Publishing (the step this skill does not take)
Never push the scrubbed branch from a repository that also contains the private
history. One git push --all, one misconfigured push.default, one helpful
IDE button, and the private refs go out alongside it.
Publish from a clone of the branch alone:
git clone --single-branch --branch "$BRANCH" . /tmp/publish
cd /tmp/publish && git remote add origin <public-url> && git push -u origin HEADSay this in the report every time. It is the step where the whole exercise gets undone.
Limitations — state these in every report
- The keyword audit only finds what it was told to look for. Paraphrases, codenames, personal names, and anything private-by-context are invisible to it.
- Binary and generated content is not searched.
git log -pskips binary diffs; an internal hostname inside a PDF, image, or compiled artifact survives untouched. - Only the marked paths are removed. Content about the private material living elsewhere in the repo stays, which is exactly what step 3 exists to surface.
- The original repository is unchanged. Its history, reflog, and stash still contain everything. This skill produces a publishable branch; it is not a redaction of your local repo.
.gitignored files were never in history, so they are not scrubbed and not audited — a locally-present secrets file is out of scope here.- Run a dedicated secret scanner over the scrubbed branch as well. Path removal and keyword grep do not detect credentials.
Anti-patterns
- Running
git filter-repo --forceon the working repository to skip the clone, or skipping step 2's source-vs-target guard because the-Cpath "looks right". - Adding
+to the step 4 refspec to make a branch-name collision go away. The rejection is the control; forcing past it can replace a branch already published. - Leaving the temp clone behind on an aborted run — it holds a full copy of the
private history between the clone and the rewrite. Arm the
trapbefore the clone exists. - Calling
rm -rfon the run directory directly instead of throughcleanup.sh. An unset variable turnsrm -rf "$RUN"intorm -rf "", and the next careless edit turns it into something worse. - Using a real keyword as an example anywhere in this skill's own files.
It leaks before the scrub, and the scrub then replaces it with
REDACTED, garbling the sentence that explained the behaviour. Observed twice. Every example here is fictional on purpose — keep it that way. - Landing a redacted branch without reading the diff.
REDACTEDlands in files that survive, so it can change meaning or break a script; the audit passing says nothing about whether the result still makes sense. - Re-inlining a stage's bash into this file. The scripts are the implementation; a second copy here drifts and the prose copy is the one that silently stops being true.
- Treating a root
.NOT_PUBLICas "exclude everything", or computing the repo root as an exclusion path. - Leaving
.NOT_PUBLICfiles in the scrubbed output, publishing the keyword list that names what was hidden. - Reporting "audit clean, safe to publish". The audit clears terms, not repos.
- Surveying a repo for private material before asking the human what private means to them. The scan then finds only the categories you already imagined, and reports on everything else with unearned confidence.
- Treating "I am not sure what is sensitive" as a blocker. It is the normal answer; survey, show concrete findings, and ask again.
- Pushing the branch, or offering to. The human publishes.
- Deriving the exclusion set with
findinstead ofgit ls-files, sweeping in untracked directories that were never in history. - Skipping the confirmation stop in step 1 because the paths "look obviously right".
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

