repo-purge
August 8, 2026 · View on GitHub
Every git working copy under one directory, as a single alphabetical list, with the ones you no longer want deleted — locally, on GitHub, or both.
Repos tend to end up nested a level or two inside topic folders (smart-home/,
homelab-infra/, …). Both programs flatten that, so a repo is easy to find
whether or not you remember which group it was filed under.
Two front ends over one engine:
repo-purge | a window: sortable table, canned filters, tick and delete |
ghmaintainer | the terminal version: a flat listing, or a one-at-a-time review |
They share the scan, the GitHub resolution, the warnings, the confirmation and the audit log — so neither can be the one with the weaker safeguards.
Install
./build-deb.sh
sudo apt install ./dist/repo-purge_*.deb
The package installs the source under /opt/repo-purge and pulls its runtime
dependencies from apt — no pip and no venv. PySide6 is a Recommends, so the
CLI is installable on a headless box without dragging in Qt.
Or run it straight out of a checkout, which installs nothing at all:
bin/repo-purge
bin/ghmaintainer list
ghmaintainer list
ghmaintainer list # every repository, alphabetically
ghmaintainer list --filter no-remote # only the ones that exist nowhere else
ghmaintainer list --sizes --sort size # what is actually taking up the disk
ghmaintainer list --group smart-home
ghmaintainer list --json # for anything that has to parse it
ghmaintainer list --no-github # skip the GitHub lookup — much faster
Filters: all, no-remote, dirty, clean, missing, renamed,
redirected, shared. Orders: name, group, size, state.
ghmaintainer review
One repository per screen and one keystroke each — for the pass where the question is "should this still exist?" rather than "where is that repo?".
[41/303] osint-project-mgmt
path ~/repos/github/osint/osint-project-mgmt
group osint
remote owner/osint-data → owner/OSINT-Project-Manager
github private
state 3 modified, 1 unpushed
branch main 412.6M
Deleting this locally loses work that exists nowhere else.
[Return] keep [l] local [r] remote [b] both [d] defer [u] undo [?] help [q] quit
| Key | |
|---|---|
Return, space, k | keep, and move on |
l | delete the local working copy |
r | delete the repository on GitHub |
b | both |
d | defer — ask again at the end of the pass |
u | undo the last decision and step back to it |
o / g | open the folder, or the GitHub page |
f | stop here and act on what has been decided |
q | abandon everything |
Nothing is deleted while you go through. Decisions accumulate and the whole
set is confirmed once at the end, which is what makes u a real undo rather
than a trip to the trash. Return and space keep, on the principle that the key
a finger reaches for by reflex should not be able to delete anything.
Deferred repos come round once more at the end of the pass. Anything still deferred after that is remembered:
ghmaintainer review --deferred
And for when you already know the answer:
ghmaintainer delete OldThing --local --remote
ghmaintainer delete OldThing --local --dry-run
Settings
Stored per-user in ~/.config/repo-purge/config.toml, not in the repository —
nothing here has your scan root baked into it. Nothing is written until you
change something; until then the defaults are simply in effect.
ghmaintainer config # what is in effect, and from where
ghmaintainer config set root ~/code # scan somewhere else
ghmaintainer config set use_trash false # delete rather than trash
ghmaintainer config init # write the file out, with comments
| Key | Default | |
|---|---|---|
root | ~/repos/github | directory whose subtree is scanned |
include_nested | false | list repos found inside other repos |
check_github | true | resolve remotes against GitHub during a scan |
use_trash | true | local deletions go to the trash via gio |
confirm_deletes | true | require the confirmation phrase |
remote_cache_ttl | 21600 | seconds to reuse the cached gh repo list |
state_dir | ~/.local/state/repo-purge | audit log, deferred list |
cache_dir | ~/.cache/repo-purge | cached GitHub listing |
REPO_PURGE_CONFIG=/path/to/file overrides the location, which is what the
tests use. The window reads the same file for its checkbox defaults; changing a
checkbox there is for that session only.
The window
| Column | Meaning |
|---|---|
| Repository | Folder name. (remote: X) appears when the folder and the GitHub repo are named differently |
| Group | Topic folder it sits in |
| Remote | owner/name from origin. A → B means GitHub redirects A to B |
| Visibility | public, private, missing, or ? if unverified |
| State | clean, or what makes it dirty: modified files, unpushed commits, stashes, untracked files |
| Size | Disk usage of the whole working copy |
| Branch | Current branch |
Filter with the search box (case- and punctuation-insensitive), narrow by group, or pick one of the canned views. Right-click a row to open the folder, open it on GitHub, or copy its path.
Deleting
Whichever front end you use, the same things stand between you and a mistake:
- Local deletions go to the trash by default (
gio trash), so a mistake is recoverable. Turn it off withuse_trash = falseor--no-trash. - The remote is deleted before the local copy. If GitHub refuses, you still have a working copy to retry from; the other order can leave a repo that exists in neither place.
- A confirmation phrase that contains the count —
TRASH 4,DELETE REMOTE 2. If the number is not the number you thought you had picked, that is the moment to stop. - Deletion is refused outside the scan root, and on the scan root itself.
- Nested repos are hidden by default. Submodules and vendored clones break their parent if deleted on their own.
- A "delete both" on a repo with no GitHub remote is refused outright, local half included — for a repo that exists nowhere else, a half-completed delete takes the only copy.
- The redirect check, below.
- Every action is appended to
~/.local/state/repo-purge/actions.logas JSON: timestamp, repo, action, outcome, path, remote.
Remote deletion needs the delete_repo scope. Both programs say so on startup
if the token lacks it:
gh auth refresh -h github.com -s delete_repo
The redirect trap
GitHub keeps redirecting a repository's old name after a rename. So an
origin URL that has gone stale still answers 200, and gh repo delete owner/old-name deletes whatever that name points at now — a different
repository from the one whose name you read.
Six checkouts here were in exactly that state. One had an origin that resolved to a completely unrelated repository, so deleting "it" would have destroyed that other one instead.
So the canonical owner/name is resolved before anything is offered for
deletion, shown as origin → actual, and it is the name after the arrow
that gets deleted. The same check catches two checkouts sharing one origin,
where deleting the remote once orphans both.
Requirements
python3 3.11+ (for tomllib), git, and gh logged in for anything touching
GitHub. PySide6 for the window only. gio supplies the trash; without it,
local deletion is permanent and the option is disabled.
Tests
python3 -m unittest discover -s tests
46 tests over name normalisation, remote URL parsing, discovery and nesting, the redirect target, the deletion refusals, settings round-tripping, every warning in the plan builder, and the review loop's key handling driven end to end. Nothing in them touches the network or deletes outside a temp directory.
More on how the pieces actually behave — verified against the live API rather
than assumed — in docs/findings.md.
Related
../Dev-Env-Sweeper is the same idea one level down: it reclaims space inside
repos by deleting rebuildable .venv and node_modules directories, leaving
the repos themselves alone. Reach for that one first — it frees disk without
losing anything. repo-purge is for when the repo itself should go.