Skip to content
Back to skills

Repo Scrub

CSecurity

Scan a GitHub repo's full history for secrets and large files, scrub what the user picks, rewrite history safely, then flip it private → public.

  • 17 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 18, 2026
ai-agentspythonrustgobashexpressawsgitapi

Works with

  • claude code
  • cli
  • api

Security analysis

C60/100
  • highPerforms destructive filesystem operations
  • criticalSends environment variables or credentials to an external URL
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 8 files and shows the line behind each finding

Scanned October 18, 2026

npx -y skills add KrishP147/skills --skill repo-scrub --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Repo Scrub?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Repo Scrub
[![Security: C — Skills Directory](https://www.skillsdirectory.com/api/skills/krishp147-repo-scrub/badge)](https://www.skillsdirectory.com/skills/krishp147-repo-scrub)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: repo-scrub
description: Scan a GitHub repo's full history for secrets and large files, scrub what the user picks, rewrite history safely, then flip it private → public.
argument-hint: "[owner/repo] (default: current repo)"
disable-model-invocation: true
---

Going public is one-way: forks, caches and scrapers copy history within minutes, and GitHub offers no hook on its visibility toggle. So all cleanup happens *before* the flip, and you flip it yourself with `gh`.

Two rules hold throughout:
- **Gate**: each numbered gate below needs the user's explicit go-ahead.
- **Complete report**: every secret and large blob found is shown, including ones the user keeps.

`<tmp>` = `$(mktemp -d)`, created once and echoed to the user; reuse that same path in every step (Git Bash's `/tmp` and Windows `%TEMP%` differ for Python). `<tmp>/scrub-work` holds secret-bearing files. Delete it (step 12) on every exit, early ones included.

## 1. Prereqs and target

- `gh auth status`; else user runs `gh auth login`.
- `gitleaks version`; missing → Windows `winget install --id Gitleaks.Gitleaks -e`, mac/linux `brew install gitleaks`. v8.19+ uses `gitleaks git`; older uses `gitleaks detect --source . --log-opts="--all -m"`.
- `git filter-repo --version`; missing → `pip install git-filter-repo` (mac: or brew). Trust the version command, not pip's exit code: a `--user` install can land off PATH.

Missing tool → give the install command and stop.

`<repo>` = the argument, else `origin`'s repo per the `next` skill's `BOARD.md`, Target repo section (never a bare `gh` call: on a fork it hits the parent). `gh repo view <repo> --json name,owner,isPrivate,defaultBranchRef` → confirm the repo, and that a rewrite + force-push is OK if anything gets scrubbed.

In the user's clone:
- **Other remotes**: `git remote -v`; per remote other than `origin`, `git ls-remote --heads --tags <remote>`. Each is its own copy of the history: its branches are scanned (step 3) and rewritten (step 7), and it gets the rewritten history (step 9, each push on a yes). A branch only there is not local-only. List each remote with its branches.
- **Submodules**: `git log --all -m -p -- .gitmodules | grep -E '^\+\s*url'`. Each is a separate repo whose history this scan never enters: tell the user to `/repo-scrub` each one before this repo goes public (a private one breaks `--recurse-submodules` clones). Flag a relative URL (`./`, `../`): it resolves against wherever the superproject was cloned from, so public clones need that sibling repo public at the same path; else use an absolute URL.

## 2. LFS bail-out

`gh api repos/<o>/<r>/contents/.gitattributes --jq .content | base64 -d | grep -i lfs` (404 = no LFS). LFS-tracked paths → stop: `filter-repo` has no LFS awareness; this needs manual handling. This only sees the root file on the default branch, so repeat the check on clone #1 (step 3): `git log --all -m -p -- '*.gitattributes' | grep -i filter=lfs` catches nested files and other branches. A hit there → stop too.

## 3. Scan (clone #1)

```
mkdir -p <tmp>/scrub-work
git clone <url> <tmp>/scan-clone && cd <tmp>/scan-clone
git fetch <remote-url> '+refs/heads/*:refs/remotes/<remote>/*'   # per other remote (step 1)
git count-objects -vH > <tmp>/scrub-work/before-size.txt
gitleaks git . --log-opts="--all -m" --report-format json --report-path <tmp>/scrub-work/gitleaks-report.json --no-banner
git filter-repo --analyze
python <skill-dir>/scripts/summarize_findings.py --gitleaks <tmp>/scrub-work/gitleaks-report.json \
  --analysis-dir .git/filter-repo/analysis --out <tmp>/scrub-work/findings.json
```

- Run the LFS history check from step 2 here, in `scan-clone`, before trusting the scan.
- gitleaks exit 1 = findings, not failure.
- `-m` on `--log-opts` and on every `git log -S`/`--grep` here and in step 8: without it a change made only in a merge commit (a conflict resolution) is never diffed, so a secret added there scans clean.
- `summarize_findings.py` merges gitleaks + `.git/filter-repo/analysis/` into `findings.json`: secrets `S1…` (deduped by rule + value, or by location when the value is empty; fingerprint `secret_sha256` only), large blobs `L1…` (bytes accumulated across every version). Use it; the analysis tables are easy to misread.
- **Personal strings** (names, emails) from `/repo-showcase` or the user: gitleaks has no rule for them. Per string, `git log --all -m -S'<string>' --oneline` (blob hits) and `git log --all -m -F -i --grep='<string>' --oneline` (commit-message hits). Each hit is a finding `P1…` (Detail: `personal string`, plus the commit count); refer to it by ID from here on.
- **Identities and messages**: `git log --all --format='%an %ae %cn %ce%n%B' | grep -Eo '[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]+' | sort -u` and `git log --all --format='%an <%ae>%n%cn <%ce>' | sort -u`. Drop the owner's own (ask). Each other email is checked as a personal string; each other author/committer identity is a `P` finding (Detail: `author identity`), unless `/repo-showcase` handed over "author identities stay".
- **Signatures**: `git log --all --format='%h %G?' | grep -v ' N$'` (signed commits) and `git for-each-ref refs/tags --format='%(if)%(contents:signature)%(then)%(refname:short)%(end)'` (signed tags). filter-repo drops every signature it rewrites.
- `--analyze` spends this clone (filter-repo refuses a second run). Delete `scan-clone` once `findings.json` exists; keep `gitleaks-report.json` until step 12 (step 7 recovers values from it).

## 4. Report

```
3 secrets across 2 files, 2 large blobs totaling 410 MB (accumulated across history)

ID | Type   | Path(s)                  | Detail                 | Commits
S1 | secret | config/dev.env (+1 more) | AWS Access Key         | 3
L1 | large  | assets/demo.mov          | 340 MB (all versions)  | -
P1 | personal | src/cli.js             | personal string        | 2
P2 | personal | (commit identity)        | author identity        | 4

Remotes: origin, mirror (branch only there: release/x). Submodules: vendor/lib (../lib.git, relative).
Signed: 2 commits, 1 tag (v1.0.0). A rewrite drops them: tags can be re-signed (step 8), commits stay unsigned.
```

Raw secret values stay out of the table. Reveal one only on request, looked up by fingerprint in `gitleaks-report.json`.

## 5. Select

Per ID or bulk ("all secrets", "over 50MB", a path glob). Echo back the final **scrub** and **keep** lists. Silence is not "scrub everything".

## 6. Gates before any rewrite

- **Rotation** (any secret selected): walk the user through [ROTATE.md](ROTATE.md). **Gate**: rotation confirmed done.
- **Collaborators**:
  ```
  gh api repos/<o>/<r>/collaborators --jq '.[].login'
  gh api repos/<o>/<r>/invitations
  gh pr list -R <o>/<r> --state open --json number,title,headRefName,author
  gh api repos/<o>/<r>/forks --jq length
  ```
  Anyone affected → draft a notice (what, why, re-clone from scratch, which PRs need recreating); user posts it or skips. Forks can't be messaged; report the count. **Gate**: go-ahead to rewrite.

## 7. Rewrite (clone #2)

```
git clone <url> <tmp>/rewrite-clone && cd <tmp>/rewrite-clone
git fetch <remote-url> '+refs/heads/<b>:refs/remotes/origin/<b>'   # per branch only on another remote
git repack -ad                                                    # after any such fetch: filter-repo wants one fresh pack
python <skill-dir>/scripts/build_replace_text.py --gitleaks <tmp>/scrub-work/gitleaks-report.json \
  --findings <tmp>/scrub-work/findings.json --ids S1 S3 --out <tmp>/scrub-work/expressions.txt
```

Fetching into `origin/<b>` lets filter-repo turn it into a local branch like the rest; a name on two remotes with different tips → ask which one. The script writes `--replace-text` expressions without printing a value; no `S` selected → drop `--ids` and it writes an empty file for the `P` lines. Each selected `P` finding adds one line, `literal:<string>==>REDACTED`, appended to `expressions.txt` by a file edit (no `echo` of the string); a commit-message hit also needs `--replace-message <tmp>/scrub-work/expressions.txt` in the call below. `literal:` never touches author/committer fields: each selected `author identity` gets a line `New Name <new@email> Old Name <old@email>` (names bare, emails in `<>`; the user picks the replacement) in `<tmp>/scrub-work/mailmap`, and the call adds `--mailmap <tmp>/scrub-work/mailmap`. An identity the user keeps → record `author identities stay: <identity>` in the step-8 summary. Location-only findings (`secret_sha256: null`) have no value to replace; it skips them, so scrub those by `--path` or by hand. Large blobs, per finding: strip entirely, or strip history but keep the current file (re-add it in a commit after the rewrite: path/size stripping removes HEAD's copy too).

One combined call, only the flags for what was selected:

```
git filter-repo --replace-text <tmp>/scrub-work/expressions.txt --path <paths> --invert-paths
```

(`--strip-blobs-bigger-than <size>` when a threshold describes the selection better.)

## 8. Verify (still reversible)

`git log --oneline -10`, `gitleaks git . --log-opts="--all -m" --no-banner`, `git count-objects -vH`. Spot-check stripped paths are gone. Per scrubbed `P` finding, `git log --all -m -S'<string>' --oneline` (and the `--grep` form for message hits) prints nothing, and so does `git log --all --format='%an %ae %cn %ce %B' | grep -F -i '<string>'` per scrubbed string or identity email (`-S` never sees identities). Show before/after commit count and size. **Gate**.

Per tag signed before (step 3), ask to re-sign it with the user's own key (`user.signingkey`): `git tag -l --format='%(contents:subject)%0a%0a%(contents:body)' <tag> > <tmp>/scrub-work/tag.msg && git tag -f -s <tag> '<tag>^{commit}' -F <tmp>/scrub-work/tag.msg`, then `git tag -v <tag>`. No → it ships unsigned; say so.

## 9. Force-push

Only after the step-6 notice went out (or was confirmed unnecessary). filter-repo removed `origin`:

```
git remote add origin <url>
git push origin --force --all && git push origin --force --tags
```

Per other remote from step 1, each on its own yes: `git remote add <remote> <url> && git push <remote> --force <each branch it holds>` (plus `--tags` if it held tags). One the user declines keeps the old history and the secret: say so.

A hook or permission rule that blocks the push → show the user these exact commands to run themselves (in Claude Code, prefixed with `!`), then continue at step 10 once they confirm.

`--all` covers non-default branches only because `filter-repo` turned the fresh clone's `origin/*` into local branches. Run `git branch` before pushing and confirm every branch you mean to keep is listed; a missing one is not pushed, and keeps its old history on the remote. Branches fetched from another remote at step 7 are local too: then push `origin` its own branches by name instead of `--all`.

## 10. Post-push

**Every local copy of this repo, the user's own included**, still holds the old commits (and the secret). A plain `git pull` would merge the old history back in and the next push would re-leak it. Per copy, either:

- **Re-clone** (simplest): copy uncommitted work, stashes (`git stash show -p --include-untracked stash@{n}`) and unpushed branches and tags (`git bundle create`) out of the old clone first, and keep it until they are carried over.
- **Reset in place**: follow [POST-PUSH.md](POST-PUSH.md). It parks the WIP first, saves every stash entry (untracked parts included) and every local-only branch and tag outside the repo, resets the pushed branches, re-creates the stashes on the new history and checks them against the saved copies, re-points a tag whose commit was rebased, and deletes a branch or tag only on a yes. Its `reflog expire` and `gc` run before its final steps: a warning per branch or tag kept as is (old history, secret included, never push it) and a gated offer to delete the save folder. Never a bare `git reflog expire --all`: it wipes the stash list.

Tell collaborators the same (the step-6 notice).

`gh pr list -R <o>/<r> --state open` → name PRs that must be closed and recreated. Tell the user: old commits stay reachable on GitHub (via `refs/pull/*`, direct SHA, cached views) until GitHub Support garbage-collects them. Rotation protects the secret; the push is hygiene.

## 11. Flip visibility

State `gh`'s consequences first: stars/watchers lost, forks detached, push rulesets disabled, Actions history becomes public. **Gate**, separate from the push.

```
gh repo edit <o>/<r> --visibility public --accept-visibility-change-consequences
gh repo view <o>/<r> --json isPrivate
```

## 12. Cleanup

```
rm -rf <tmp>/scrub-work <tmp>/scan-clone <tmp>/rewrite-clone
```

Files in this skill

  • POST-PUSH.md8.3 KB
  • README.md9.3 KB
  • ROTATE.md923 B
  • SKILL.md12.5 KB
  • scripts/build_replace_text.py3.7 KB
  • scripts/summarize_findings.py6.7 KB
  • scripts/test_build_replace_text.py6.2 KB
  • scripts/test_summarize_findings.py9.4 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.