agentleFS
eisbaweisbaw/mped-skills/skills/scrub-for-public/SKILL.md

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

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
  1. Very specific:

    Every .NOT_PUBLIC file is added to the exclusion set, including the root one.

    line 71
  2. Distinctive:

    Do this first. A survey run on the agent's own assumptions finds only the categories the agent already imagined, and then reports …

    line 95
  3. Very specific:

    Write the answers down as you get them.

    line 128
  4. Distinctive:

    Look for: corporate identities in history.

    line 147
  5. Very specific:

    Run identities.sh <repo> with no mailmap.

    line 177
  6. Distinctive:

    Before invoking anything destructive the script compares the clone's toplevel against the source's and exits non-zero if they match.

    line 291
  7. Very specific:

    Run directories live under /tmp.

    line 361
  8. Very specific:

    Always delete through cleanup.sh, never a bare rm -rf.

    line 366
  9. Distinctive:

    commit count before and after, and how many commits the rewrite pruned.

    line 375
  10. Distinctive:

    every keyword with its hit count, zeros included.

    line 376
  11. Distinctive:

    Keep bash out of this file beyond the invocation block above.

    line 409
  12. Very specific:

    Never push the scrubbed branch from a repository that also contains the private history.

    line 415
  13. Distinctive:

    Run a dedicated secret scanner over the scrubbed branch as well.

    line 445

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 Codename

keywords: 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 Name

The 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 programme

Look 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_PUBLIC in each accepted directory, its keywords: drawn from what the survey actually found there — not a generic list;
  • a root .NOT_PUBLIC only if individual files need excluding (also-exclude:) or a repo-wide term or mailmap: 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:

RepoOperationsMutating?
Sourcestatus, rev-parse, ls-files, log (read) · clone from itNo
Sourcefetch <clone> HEAD:$BRANCH (stage 4)Yes — additive only: creates one ref, adds objects. Rejects rather than overwrites an existing branch.
Throwaway clonefilter-repo and every audit commandYes, 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 HEAD

Say 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 -p skips 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 --force on the working repository to skip the clone, or skipping step 2's source-vs-target guard because the -C path "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 trap before the clone exists.
  • Calling rm -rf on the run directory directly instead of through cleanup.sh. An unset variable turns rm -rf "$RUN" into rm -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. REDACTED lands 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_PUBLIC as "exclude everything", or computing the repo root as an exclusion path.
  • Leaving .NOT_PUBLIC files 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 find instead of git ls-files, sweeping in untracked directories that were never in history.
  • Skipping the confirmation stop in step 1 because the paths "look obviously right".

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.