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-purgea window: sortable table, canned filters, tick and delete
ghmaintainerthe 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, kkeep, and move on
ldelete the local working copy
rdelete the repository on GitHub
bboth
ddefer — ask again at the end of the pass
uundo the last decision and step back to it
o / gopen the folder, or the GitHub page
fstop here and act on what has been decided
qabandon 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
KeyDefault
root~/repos/githubdirectory whose subtree is scanned
include_nestedfalselist repos found inside other repos
check_githubtrueresolve remotes against GitHub during a scan
use_trashtruelocal deletions go to the trash via gio
confirm_deletestruerequire the confirmation phrase
remote_cache_ttl21600seconds to reuse the cached gh repo list
state_dir~/.local/state/repo-purgeaudit log, deferred list
cache_dir~/.cache/repo-purgecached 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

ColumnMeaning
RepositoryFolder name. (remote: X) appears when the folder and the GitHub repo are named differently
GroupTopic folder it sits in
Remoteowner/name from origin. A → B means GitHub redirects A to B
Visibilitypublic, private, missing, or ? if unverified
Stateclean, or what makes it dirty: modified files, unpushed commits, stashes, untracked files
SizeDisk usage of the whole working copy
BranchCurrent 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 with use_trash = false or --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 countTRASH 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.log as 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.

../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.