git-cleanup
Interactively clean up git branches that are merged, done in your issue tracker, or stale.
git-cleanup fetches and prunes origin, gathers every local and remote branch (author,
age, merged status, ahead/behind counts vs upstream, and linked issue status), then opens
a full-screen TUI with three tabs. Branches is one table of all branches where each row
carries an action you control. Worktrees lists every git worktree with its branch,
its count of uncommitted changes, and whether it is broken or locked. Stashes lists
every stash with its message, origin branch, age, and file count, alongside a live diff
pane so you can read a stash before deciding its fate.
Branches
- delete — removes the branch locally and on origin (whatever exists). Your branches that are merged or whose issue is done come pre-marked.
- delete-local — removes only your local branch and leaves
origin/<branch>in place. For treating local branches as your active workspace: clear one off your machine because the remote still has it. Nobody else is affected. Pressda second time to get here. Branches that exist on only one side skip this action —deleteis already single-sided. - archive — creates a tag
archive/<branch>at the tip (pushed for remote branches), then deletes the branch. Restore any time withgit checkout -b <branch> archive/<branch>. - keep — the default; nothing happens.
Worktrees
- remove — runs
git worktree remove, with--forcewhen the worktree has uncommitted changes. A worktree whose directory is already gone is cleared withgit worktree pruneinstead. Worktrees whose branch is merged or issue-done come pre-marked, as do broken entries — but a worktree with uncommitted changes never does. - keep — the default; nothing happens.
Removing a worktree never deletes its branch — do that on the Branches tab. Worktree
removals run before branch deletions, so marking both in one session works: git branch -d refuses a branch that is checked out anywhere, and the review screen tells you
which deletions are waiting on which removal.
Stashes
- drop — discards the stash without restoring it. Recoverable: git keeps the commit
reachable until the next
gc, and the review screen prints thegit stash store <sha>you would need. - pop — restores the stash into your working tree, then removes it from the list.
- apply — restores it but keeps it in the list.
- keep — the default; nothing happens.
Stashes are never pre-marked — a stash is uncommitted work by definition, there is no "merged" signal to key off, and an old stash is exactly the one you forgot about but may still want. Only one pop or apply per run is allowed: a restore is the only action here that writes to your working tree, and a failed one leaves it dirty, which would make every restore after it fail too. Drops are unlimited.
Press Enter to review everything grouped (with a prominent warning for anything deleted
on origin or removed with --force), confirm, and it executes. Quit with q and nothing
changes.
Keys
| Key | Action |
|---|---|
| ↑/↓, PgUp/PgDn | Move |
[ / ] |
Previous / next tab |
b / w / t |
Jump straight to Branches / Worktrees / Stashes |
space |
Cycle keep → delete → delete-local → archive (Worktrees: toggle keep ⇄ remove; Stashes: cycle keep → drop → pop → apply) |
d |
Mark delete; press again to toggle between delete and delete-local (Worktrees: mark remove; Stashes: mark drop) |
a / k |
Mark archive / keep (Stashes: a marks apply) |
p |
Mark pop (Stashes only) |
o |
Open the branch's compare page on origin (vs the default branch) |
/ |
Live filter (same syntax as --filter) |
s |
Live sort (same syntax as --sort) |
r |
Reset filter & sort to defaults |
Ctrl+D / Ctrl+U |
Scroll the stash diff pane |
Enter |
Review and confirm |
q / Esc |
Quit without changes |
The footer always shows the keys for the tab you are on, and advertises [/] for tab
switching; b/w/t work too but stay out of the footer to keep it to one line. /, s,
r, and o are branch-only — worktree and stash lists are short enough not to need
filtering or sorting, and stashes must never be reordered, because their stash@{N}
numbering is positional.
On the Stashes tab the diff pane sits to the right of the table at 100 columns or wider, and moves below it on narrower terminals so the table always has room for every column.
Filter and sort changes are remembered per repository, so your view comes back the next
time you run git-cleanup there. r resets (and forgets) them.
A delete-local branch still exists on origin, so the next scan lists it again as a
remote-only row. If you treat local branches as your active workspace, filter to local
(press /, type local) to see only branches you actually have checked out — that filter
is remembered per repo, so the branches you have cleared off your machine stay out of view.
Jira is the built-in issue tracker for now; the provider layer is designed so GitHub
Issues, Linear, etc. can be added later. The scan pipeline (git_cleanup.core.scan_repo
planner.recommend_actions) is UI-free and importable, so CI jobs can generate branch reports from the same data.
Install
Requires Python 3.14+ and git.
$ uv tool install git-cleanup # install the CLI on your PATH
$ uvx git-cleanup # or run it without installing
You can also install it with pip install git-cleanup if you prefer.
Usage
$ uvx git-cleanup # in any git repo with an origin remote
$ uvx git-cleanup --dry-run # preview everything, change nothing
Flags
| Flag | Effect |
|---|---|
--dry-run |
Full run with zero mutations — prints [dry-run] would delete ... instead |
--no-fetch |
Skip the initial git fetch --prune origin |
--all |
Pre-mark other authors' cleanup-eligible branches for deletion too |
--sort COLS |
Sort columns, comma-separated, - prefix for descending — e.g. --sort=-age,status,author. Columns: branch, local, remote, worktree, sync, author, age, merged, issue, status |
--filter TERMS |
Only show branches matching all terms — e.g. --filter 'mine,age>6m,status!=done'. A bare word matches any text column (--filter brent). Flags: mine, merged, local, remote, gone, worktree (prefix ! to negate); age>N/age<N/age>=N/age<=N in days or with d/m/y suffix; substring matches branch=X, author=X, issue=X, status=X (!= excludes). ` |
--config PATH |
Use an alternate config file |
--version |
Print the version |
Interactive runs default to your last-used filter and sort in that repository; an
explicit --sort/--filter wins for that session without overwriting the saved view.
Non-interactive runs (pipes, CI) use only explicit flags.
worktree is a boolean filter flag, so the bare word worktree now means "has a
worktree" rather than a substring search across the text columns, and !worktree means
"has no worktree". To search for the literal string, use branch=worktree.
Branches are matched to issues by extracting an issue key (e.g. ABC-123) from the
branch name, case-insensitively. Branches without a key just show no issue info.
Safety
- The current branch, the default branch, and protected branches can never be marked for deletion or archiving.
- Nothing happens until you review the grouped summary and confirm it; quitting the TUI changes nothing.
- Remote deletions are called out in their own red-bordered warning on the review screen, and local deletions that would lose unpushed commits are flagged.
delete-localrows never appear in that warning (nothing leaves origin); they are listed with theorigin/<branch>they are keeping.- Non-interactive runs (pipes, CI) never mutate anything — they print the tables and exit.
- If Jira is unreachable or unconfigured, the tool degrades to git-only info (merged status still works).
For worktrees specifically:
- The main worktree, the worktree you are currently in, and locked worktrees can never be marked — git cannot remove them, and the tab says so when you try.
- A worktree with uncommitted changes can be marked by hand, but it is never pre-marked,
it is flagged in its own red-bordered panel on the review screen, and removing it passes
--force, which discards that work irrecoverably. git worktree pruneis repo-wide, so clearing one broken entry clears them all. The dry-run note and the summary report git's own list rather than the count you marked.- Worktree removals execute before any branch deletion, and removing a worktree never deletes its branch.
- If listing worktrees fails for an unrelated reason, the run degrades to branches-only with a warning on stderr.
For stashes specifically:
- Nothing is ever pre-marked, and only one pop/apply is allowed per run.
- A
stash@{N}selector is a reflog position, not an id: droppingstash@{1}renumbers{2}to{1}. Marked stashes are therefore executed in descending index order, so the ones not yet touched never move, and each is re-checked against the commit it pointed at during the scan — a stash that changed underneath you (say, popped in another terminal) is skipped with a warning rather than acted on. - Dropping is recoverable until git's next
gc: the review screen prints thegit stash store <sha>that would bring one back. - git allows restoring a stash onto a different branch than it was made on with no warning of its own, so the tab colors the mismatch and the review screen spells it out.
- A failed pop always leaves the stash in the list. If it conflicted, the conflict markers are in your files and the stash is still there to retry or drop by hand.
- A restore lands in whichever worktree you ran
git-cleanupfrom, sincerefs/stashis repo-global but the restore writes to the current working tree. - If listing stashes fails, the run degrades to no Stashes tab with a warning on stderr.
Configuration
~/.config/git-cleanup/config.toml (respects $XDG_CONFIG_HOME):
[tracker]
provider = "jira" # or "none" to disable issue lookups
[jira]
url = "https://yourcompany.atlassian.net"
email = "you@yourcompany.com"
api_token = "..." # create one at https://id.atlassian.com/manage-profile/security/api-tokens
[cleanup]
protected_branches = ["main", "master", "develop"]
done_statuses = [] # extra status names to treat as done, e.g. ["Won't Do"]
archive_age_days = 90 # minimum age for the archive prompt
Any setting can be overridden per repository with a [repos."<path>"] table in the
same file — the key is the repository's root path (~ expands), and its values merge
key-by-key over the global sections:
[repos."~/Code/some-repo".cleanup]
protected_branches = ["main", "staging"]
archive_age_days = 30
[repos."~/Code/other-repo".tracker]
provider = "none"
Environment variables JIRA_URL, JIRA_EMAIL, and JIRA_API_TOKEN override the
config file, including repo overrides.
The Worktrees tab adds no configuration of its own: archive_age_days also drives the
stale highlight on its Age column, and since that is already per-repo overridable, a
monorepo with long-lived worktrees just raises it. Note that protected_branches does
not prevent removing a worktree — removing a checkout never touches a ref — but a
protected branch's worktree is never pre-marked, only manually markable.
Worktree listing uses git worktree list --porcelain -z (git 2.36+), falling back to the
newline form on older git.
Filter/sort views chosen in the TUI are saved per repository in
$XDG_STATE_HOME/git-cleanup/state.json (default ~/.local/state/git-cleanup/state.json).
Development
$ uv sync
$ uv run pytest
$ uv run git-cleanup --dry-run
Tests run against real temporary git repositories and a mocked Jira API — no network or credentials needed.
With just installed, just format, just lint, and
just test wrap the common tasks, and just pre_commit runs all three. Releases are
cut with just version_bump <major|minor|patch> followed by pushing the tag — see
CLAUDE.md for the full release process.
License
MIT. Provided as is, without warranty of any kind — see the LICENSE file for the full text.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file git_cleanup-0.2.0.tar.gz.
File metadata
- Download URL: git_cleanup-0.2.0.tar.gz
- Upload date:
- Size: 44.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c52721272effcf91d0387d190d3c57205dee0daf0ba3ff6ce898357647788f82
|
|
| MD5 |
7e1c947cce10635e56a6cbf770f57ecd
|
|
| BLAKE2b-256 |
6a8c18abab121d0b67fcf1e1a740f9e8d9de29e5cb1ef5fee9ca6b815c559bbe
|
Provenance
The following attestation bundles were made for git_cleanup-0.2.0.tar.gz:
Publisher:
release.yml on epicserve/git-cleanup
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
git_cleanup-0.2.0.tar.gz -
Subject digest:
c52721272effcf91d0387d190d3c57205dee0daf0ba3ff6ce898357647788f82 - Sigstore transparency entry: 2581361611
- Sigstore integration time:
-
Permalink:
epicserve/git-cleanup@810ee38032beb090630737acbccdb9203e45bd05 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/epicserve
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@810ee38032beb090630737acbccdb9203e45bd05 -
Trigger Event:
push
-
Statement type:
File details
Details for the file git_cleanup-0.2.0-py3-none-any.whl.
File metadata
- Download URL: git_cleanup-0.2.0-py3-none-any.whl
- Upload date:
- Size: 51.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b8409bbfc230c6fbc1a28defd7f6aff8da352d9b145d6639b78e66c304f29894
|
|
| MD5 |
a4eee233d00d6977f4a40409e5945ab3
|
|
| BLAKE2b-256 |
e36572ca3bc9ebe72a67cd61d0a74de7878a7d2aa04c6b665eb85dc8c7d23e4a
|
Provenance
The following attestation bundles were made for git_cleanup-0.2.0-py3-none-any.whl:
Publisher:
release.yml on epicserve/git-cleanup
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
git_cleanup-0.2.0-py3-none-any.whl -
Subject digest:
b8409bbfc230c6fbc1a28defd7f6aff8da352d9b145d6639b78e66c304f29894 - Sigstore transparency entry: 2581361617
- Sigstore integration time:
-
Permalink:
epicserve/git-cleanup@810ee38032beb090630737acbccdb9203e45bd05 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/epicserve
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@810ee38032beb090630737acbccdb9203e45bd05 -
Trigger Event:
push
-
Statement type: