Wipe out merged local branches and worktrees
git-wipe provides the git wipe command: it detects branches merged
into your main branch(es) and offers to delete them -- both locally and on
configured remotes. It also handles orphaned worktree cleanup.
- Delete local and remote branches that have been merged
- Read-only inventory of branches and worktrees (
git wipe status) -- no fetch, no prompts, no changes - Worktree cleanup: unified prompt for branches with worktrees and orphaned worktrees
- Respects locked worktrees: skips removal with an informational message
- Min-age guard (
--min-age): never removes a worktree created too recently - Glob pattern support for protected branches (e.g.
release/*) - Per-branch protection via git config (
branch.<name>.wipe-protected) - Ignore branch patterns entirely (
wipe.ignore) -- never fetched, never analysed - Multiple merge detection strategies (fast merge, rebase-aware via
git cherry, tree SHA comparison, empty three-dot diff, patch-ID matching, simulated merge, squash-merge detection, and deleted-upstream detection) - Tunable detection thoroughness with
--effort <1-3>(speed vs accuracy) - Parallel analysis (
--jobs, defaults to the CPU count) with byte-identical results at any job count - Automatic fast-forward of target branches before detection (with
--no-pullto skip) - Optional worktrunk integration for worktree removal (triggers pre/post-remove hooks)
- Interactive setup wizard on first run
- JSON output (
--json) for scripting and integration - Configuration stored in git config (
[wipe]section) - Safety-first:
--force-with-leasefor remote deletions
Homebrew:
brew install noirbizarre/tap/git-wipeArch Linux, from the AUR — prebuilt (git-wipe-bin), built from the
release source (git-wipe) or from main (git-wipe-git):
paru -S git-wipe-binFrom crates.io:
cargo install git-wipeOr grab a prebuilt binary for your platform from the latest release.
From a checkout:
cargo install --path .The crate, the binary and the git subcommand all share one name: installing
git-wipe puts a git-wipe binary on your PATH, which git dispatches as
git wipe.
Git rewrites git wipe --help into git help wipe, which runs man git-wipe.
That only works once the man page is installed — otherwise git reports
"No manual entry for git-wipe". The Homebrew and AUR packages install the
pages and the shell completions for you; cargo install places the binary
alone, so with it they have to be installed separately.
From a checkout, mise does both, plus zsh completions:
mise run setupPrebuilt release archives ship them under man/ and completions/. Install by
hand with:
cp man/*.1 ~/.local/share/man/man1/Make sure that directory is on your MANPATH (most distributions add
~/.local/share/man automatically). Then:
git wipe --help # full manual
man git-wipe-config-setWithout a man page installed, git wipe -h still prints the short help, and
git-wipe --help (with a dash, bypassing git's dispatch) prints the long one.
Completions are generated for bash, zsh, fish, elvish and PowerShell; drop the relevant file into your shell's completion directory.
Run git wipe with no arguments: it works interactively, showing what it found
and prompting before every destructive step.
# Interactive mode (prompts for confirmation at each step)
git wipe
# Show what would be done, change nothing
git wipe --dry-run
# Unattended: auto-confirm everything
git wipe --yes
# Also delete branches whose upstream branch was deleted
git wipe -y --delete-gone
# Also force-remove worktrees with uncommitted changes or unmerged commits
git wipe -y --force
# Machine-readable output (implies --yes)
git wipe --json
# Look without touching: a read-only inventory (alias: git wipe list)
git wipe statusThe rest of the flags tune scope (--local-only, --no-worktrees, …), merge
detection (--effort, --jobs) and worktree safety (--min-age). Rather than
repeat them
here, where they would drift, the complete reference is generated from the same
definition as the binary: run git wipe -h for the summary, or man git-wipe
for the full manual — see
Man pages and completions above.
git wipe status (alias git wipe list) answers "what is lying around in this
repository?" without doing anything about it. It never fetches, never prompts
and never modifies a thing, so it is safe to run from a shell prompt, a hook or
a script — including in a repository git-wipe has never been configured in,
where it falls back to protecting main and master instead of starting the
setup wizard.
$ git wipe status
AGE STATUS BRANCH PATH
34w merged,clean blog-archive ~/src/blog-archive
30w merged,dirty submit-to-registry ~/src/islamabad
29w orphan,clean (detached) ~/src/tree-sitter-x
2h unmerged * migrate-vfs ~/src/slicc
3h merged,gone fix/typo -One line per worktree, plus one per local branch that has no worktree, sorted
oldest first. The current branch is marked with *. A - in the PATH column
means the branch is not checked out anywhere; ? in AGE means the age could
not be established.
The listing goes to stdout so it can be piped and grepped; warnings and the detection spinner go to stderr.
Statuses combine, and are rendered most-actionable first:
| Status | Meaning |
|---|---|
orphan |
The worktree has no live branch: its branch ref is gone, or it is detached |
locked |
The worktree is locked (git worktree lock) |
merged |
Detected as merged into a protected branch, using the same strategies as a wipe run |
gone |
The configured upstream no longer exists |
unmerged |
Holds commits absent from every merge target |
dirty |
The working tree has uncommitted changes |
clean |
The working tree has no uncommitted change |
merged and unmerged are exclusive, as are clean and dirty. A branch
without a worktree gets neither clean nor dirty — there is no working tree
to inspect.
Two filters narrow the listing:
--mergedkeeps only entries detected as merged.--min-age <DURATION>keeps only entries at least that old. Note the difference from a wipe run, where--min-ageis a safety guard on worktree removal: here it is a display filter, and the configuredwipe.minageis deliberately not inherited so a safety setting cannot silently truncate the inventory. Entries whose age could not be established are always kept.
Two caveats worth knowing:
- Because
statusnever fetches,gonereflects the remote-tracking refs as they are on disk, and is only as fresh as your lastgit fetch --prune. A warning says so whenever agoneentry is reported. - Merge detection is the slow part, and honours
--effortand--jobsexactly as a wipe run does, so the two always agree on what "merged" means. Use--effort 1for a fast, ancestor-only answer.
Branches matched by wipe.ignore are not listed: git-wipe treats them as
non-existent everywhere else.
--json emits the same inventory as a document (see below), and --no-color
disables ANSI styling in every command.
--json prints a single JSON document describing everything that was detected
and done. Human-readable logs are suppressed; stdout carries the document alone,
so it is safe to pipe. The document is pretty-printed on a terminal and compact
when piped or redirected.
git wipe --json | jq '.summary'
git wipe --json --dry-run | jq '.local.branches[] | select(.reason == "gone")'
git wipe config list --json | jq '.protected'Because prompts would hang a non-interactive caller, --json implies --yes:
deleted-upstream branches stay opt-in behind --delete-gone, forced removal of
dirty worktrees stays opt-in behind --force, and a repository
that has never been configured is an error rather than a setup wizard (run
git wipe once interactively first).
| Field | Description |
|---|---|
version |
Schema version, currently 1 |
status |
success or error |
dry_run |
Whether --dry-run was in effect |
effort |
The effective merge-detection level (1-3) |
min_age |
The effective minimum worktree age, e.g. "0s" or "2h" |
jobs |
The effective number of concurrent git probes used during analysis |
fetch |
Phase 1: per-remote fetch/prune outcome |
pull |
Phase 2: per-branch fast-forward outcome |
local |
Phase 3: merged/gone candidates, plus per-branch and per-worktree outcomes |
remotes |
Phase 4: merged branches and deletion outcome per remote |
warnings |
Non-fatal messages surfaced during the run |
errors |
Failed operations, each with action, target, kind (network, auth, other) and message |
summary |
local_branches_deleted, remote_branches_deleted, worktrees_removed, errors |
Item statuses are updated, deleted, removed, skipped, locked,
too_young, failed or dry_run. A fatal error still yields a document (with
status: "error") and a non-zero exit code.
git wipe status --json shares the same versioned schema, but carries no
action or outcome field — status never acts:
git wipe status --json | jq '.entries[] | select(.status | index("merged"))'
git wipe status --json | jq -r '.entries[] | select(.age_seconds > 2592000) | .branch'| Field | Description |
|---|---|
version |
Schema version, currently 1 |
status |
success or error |
entries |
The inventory, oldest first |
warnings |
Non-fatal messages, e.g. stale deleted-upstream detection |
errors |
Failed operations, same shape as above |
Each entry has:
| Field | Description |
|---|---|
kind |
worktree, orphan or branch (a branch with no worktree) |
branch |
Branch name; absent for a detached-HEAD worktree |
path |
Absolute worktree path; absent for a branch with no worktree |
age_seconds |
Age in seconds, or null when it could not be established |
status |
The statuses that apply, in the order the STATUS column renders them |
current |
Whether it is checked out in the worktree the command ran from |
protected |
Whether a protected pattern matches it |
git wipe config reads and writes the [wipe] settings described in
Configuration below:
# Display current configuration
git wipe config list
# Re-run the interactive setup wizard
git wipe config setup
# Set a configuration value directly
git wipe config set worktrunk false
# Add a protected branch pattern
git wipe config add-protected 'release/*'
# Ignore a single branch
git wipe config ignore experimentEach of these has a counterpart: add-/remove- pairs for the protected,
ignore and remote patterns, and protect/unprotect, ignore/unignore
for individual branches. git wipe config -h lists them all, and each has its
own man page (man git-wipe-config-set).
Important
This project was called git-synchronizer and its command was git sync,
which stored its settings in a [sync] section. Nothing is read from [sync]
any more, and there is no automatic migration: a repository configured under
the old name reads as unconfigured and drops you into the setup wizard.
To carry an existing configuration over, rename the section and the per-branch flags:
git config --get-regexp '^sync\.' # what you have
git config --get-regexp '^branch\..*\.sync-' # and the per-branch flagsthen re-enter them under wipe.* / branch.<name>.wipe-*, or edit
.git/config directly and change [sync] to [wipe], sync-protected to
wipe-protected and sync-ignored to wipe-ignored.
Configuration is read from the [wipe] section of your git config at any
scope (local, global or system). Every git wipe config subcommand writes
to the repository-local .git/config:
[wipe]
protected = main
protected = master
protected = release/*
ignore = wip/*
remote = origin
worktrunk = true
effort = 3
minage = 2h
jobs = 8| Key | Type | Description |
|---|---|---|
protected |
multi-value | Glob patterns for branches that should never be deleted |
ignore |
multi-value | Glob patterns for branches git-wipe ignores entirely |
remote |
multi-value | Remotes to delete branches from (omit for all remotes) |
worktrunk |
bool | Enable/disable worktrunk for worktree removal. When omitted, auto-detects (see below) |
effort |
1-3 |
How thorough merge detection should be. Defaults to 2; --effort overrides it |
minage |
duration | Minimum age a worktree must have before it may be removed, e.g. 30s, 2h, 7d. Defaults to 0s (no guard); --min-age overrides it |
jobs |
integer >= 1 | How many read-only git probes analysis may run at once. Defaults to the CPU count; --jobs overrides it, --verbose forces 1 |
When worktrunk is unset, git-wipe enables it only if the repository has a
[worktrunk] config section and wt is on $PATH; it then asks once per
run before using it, and enables it without asking under --yes. If either
condition is missing, worktrunk is not used.
Individual branches can also be protected via the standard [branch]
config namespace:
[branch "develop"]
wipe-protected = trueA per-branch protected branch is excluded from deletion candidates and also serves as a merge target (branches merged into it are flagged for cleanup).
Ignored branches are invisible to git-wipe: they are never fetched, never become merge targets, never appear as deletion candidates, and their worktrees are left alone. Use them for branches git-wipe has no business touching, such as long-lived spikes or vendor branches.
Patterns go in wipe.ignore, and individual branches can carry the flag
directly:
[branch "experiment"]
wipe-ignored = trueIgnoring takes precedence over protection. A branch matching both
wipe.protected and wipe.ignore is ignored, which also means it is not
used as a merge target.
Exclusion at fetch time is implemented with negative refspecs
(^refs/heads/wip/*), which require git 2.29 or later and understand a
single * wildcard only. Richer glob patterns (?, character classes,
alternates) are still fetched, then filtered out by the same matcher used
everywhere else. Note that when at least one ignore pattern is expressible as a
refspec (a single * and no other metacharacters), the fetch uses an explicit
refspec and therefore bypasses a custom remote.<name>.fetch setting. With only
richer patterns, the default fetch — and your custom refspec — is left intact.
On first run (when no [wipe] config section exists), an interactive
setup wizard runs automatically:
- Auto-detects local branches and pre-selects well-known ones (
main,master,develop,development) - Asks for additional protected patterns (e.g.
release/*) - Asks for branch patterns to ignore entirely (e.g.
wip/*) - Lists available remotes and asks which ones to operate on
- If worktrunk (
wt) is detected on$PATH, asks whether to use it for worktree removal
The cleanup runs in four sequential phases, each of which can be skipped via CLI flags:
-
Fetch & prune remotes -- runs
git fetch --prune <remote>for each configured remote (every remote whenwipe.remoteis unset), pruning deleted remote-tracking branches. Skipped with--no-fetch. -
Pull / fast-forward target branches -- fast-forwards each protected branch to its remote-tracking upstream so that merge detection operates on up-to-date refs. The strategy varies depending on the branch state:
- Current branch:
git pull --ff-onlyin the working directory. - Checked out in another worktree:
git pull --ff-onlyrun from that worktree directory (works with both plain git and worktrunk-managed worktrees). - Not checked out:
git fetch <remote> <ref>:<branch>to update the local ref without any checkout.
Branches without upstream tracking info are silently skipped. If a fast-forward fails (e.g. the branch has diverged), a warning is printed and the remaining branches are still processed. Skipped with
--no-pull. - Current branch:
-
Delete merged local branches & clean worktrees -- identifies branches merged into any protected branch (both glob-pattern and per-branch protected) using several complementary strategies, applied from cheapest to most expensive and stopping as soon as one matches. How many of them run is controlled by
--effort(orwipe.effort), each level including the previous ones. The same levels drive remote-branch detection in step 4:Effort 1 (fastest)
- Standard detection:
git branch --merged <target>catches fast-forward and regular merges.
Effort 2 (default)
- Rebase-aware detection:
git cherry <target> <branch>catches rebased branches by checking whether every commit has already been applied upstream. - Tree SHA comparison: compares
git rev-parse <ref>^{tree}between the target and branch -- the cheapest content-equality check. - Empty three-dot diff:
git diff --quiet <target>...<branch>catches branches whose own commits net out to no content change relative to their fork point (a commit and its revert, a pure history rewrite, a branch created but never meaningfully advanced).
Effort 3 (most thorough, noticeably slower)
- Patch-ID matching: compares
git patch-id --stablefingerprints of the branch's commits against those recently applied on the target, catching branches re-applied under different SHAs (rebase + reword, partial cherry-pick, history rewrite). - Simulated merge:
git merge-tree --write-tree <target> <branch>-- if merging the branch would produce exactly the target's current tree, the branch adds nothing. Handles squash merges even after the target has advanced with unrelated changes. - Squash-merge detection: compares the patch-ID of the branch's combined diff against the target's recent commits, catching multi-commit branches collapsed into a single squash commit.
Deleted-upstream detection (below) is independent of the effort level and always runs.
Every one of these probes is a separate
gitprocess, and they are independent of one another, so git-wipe runs up to--jobsof them at a time (defaulting to the CPU count). This is a wall-clock optimisation only: each detection pass collects its verdicts before acting on any of them, so the candidate list, the warnings and their order are identical at any job count.--jobs 1restores strictly serial execution, and--verboseforces it so the echoed commands stay in the order they ran.Only read-only inspection is parallelised. Fetching, pulling, deleting branches and removing worktrees always run one at a time: concurrent writers to the same repository corrupt its state.
Per-branch protected branches also serve as merge targets, so branches merged into them are detected as candidates too.
In addition, branches whose upstream tracking branch no longer exists are reported as a separate category. This is the footprint left by a merged pull request whose remote branch was deleted, and it is often the only remaining signal for branches squash-merged into a target that has since advanced far enough for the content-based strategies above to lose the trail. Because a deleted upstream does not prove the branch was merged (someone may simply have deleted an unmerged remote branch), these entries are listed unchecked in the multiselect and are never auto-selected by
--yesunless you also pass--delete-gone. Detection requires up-to-date remote-tracking refs, so it only runs after a successfulfetch --prune, or in--dry-runwhere a warning notes the results may be stale.All cleanup items are presented in a single unified multiselect: merged branches (with their worktree path shown when applicable), branches with a deleted upstream, and orphan worktrees (worktrees whose branch no longer exists locally). Merged branches default to selected; deleted-upstream branches and orphan worktrees default to unselected.
Two cases are handled outside that multiselect. Worktrees that are dirty (uncommitted or untracked changes) or hold unmerged commits are collected into a second multiselect for forced removal, defaulting to unselected; anything left unselected there is skipped entirely, with neither the worktree removed nor the branch deleted.
--forcedrives this prompt and nothing else: interactively it pre-selects every entry (each can still be unchecked), and under--yesit force-removes them all without prompting.--yeson its own — including via--json— skips them, so a non-interactive run never destroys uncommitted work. Separately, a selected branch whose commits are unreachable from any merge target is force-deleted automatically, with an informational line rather than a prompt.For selected branches that have worktrees, the worktree is removed first, then the branch is deleted with
git branch -D(force-delete is safe here because the branch is already verified as merged into a protected target). Selected orphan worktrees are also removed in the same pass. When worktrunk is enabled (via--worktrunkflag,wipe.worktrunkconfig, or auto-detection), removal is delegated towt removeso that pre/post-remove hooks are triggered. Otherwise falls back togit worktree remove.Locked worktrees (via
git worktree lock) are automatically skipped with an informational message -- this also prevents their branch from being deleted, since git refuses to delete a branch checked out in any worktree. Worktrees created less than--min-ageago (orwipe.minage) are skipped the same way. Age is measured from the creation time of the worktree's administrative directory (.git/worktrees/<id>), not from its branch tip, so a worktree you just created from an up-to-date default branch is protected even though that branch counts as merged. The guard is disabled by default (0s). Skipped with--remote-only. Worktree cleanup is skipped with--no-worktrees. - Standard detection:
-
Delete merged remote branches -- for each configured remote, identifies merged remote-tracking branches. The user selects which to delete, and they are removed with
git push --delete --force-with-leasefor safety.Remote detection runs the same strategies at the same effort levels as step 3, so squash- and rebase-merged remote branches are reported too. The one deliberate difference: the content-based strategies compare against the remote-tracking counterparts of the protected branches (
origin/main), not their local ones. A branch merged into a localmainyou have not pushed yet is still live on the remote and is not offered for deletion there. Which branches are protected or ignored is still read from your local configuration. Skipped with--local-only.
git wipe status reuses the phase-3 detection alone, and skips phases 1, 2 and
4 entirely — that is what makes it read-only.
flowchart TD
Start([git wipe]) --> LoadConfig[Load configuration]
LoadConfig --> FirstRun{First run?}
FirstRun -- Yes --> Setup[Interactive setup wizard]
Setup --> FetchCheck
FirstRun -- No --> FetchCheck
FetchCheck{--no-fetch?}
FetchCheck -- No --> Fetch[Fetch & prune remotes]
Fetch --> PullCheck
FetchCheck -- Yes --> PullCheck
PullCheck{--no-pull?}
PullCheck -- No --> Pull[Fast-forward target branches]
Pull --> PullCurrent[Current branch:\ngit pull --ff-only]
Pull --> PullWT[In worktree:\ngit pull --ff-only from that worktree]
Pull --> PullFetch[Not checked out:\ngit fetch remote ref:branch]
PullCurrent --> LocalCheck
PullWT --> LocalCheck
PullFetch --> LocalCheck
PullCheck -- Yes --> LocalCheck
LocalCheck{--remote-only?}
LocalCheck -- No --> FindLocal[Find merged local branches\n+ orphan worktrees]
FindLocal --> Merged["Standard detection\ngit branch --merged\n(effort 1+)"]
FindLocal --> Cherry["Rebase-aware detection\ngit cherry\n(effort 2+)"]
FindLocal --> TreeSHA["Tree SHA comparison\n(effort 2+)"]
FindLocal --> EmptyDiff["Empty-diff detection\ngit diff --quiet\n(effort 2+)"]
FindLocal --> PatchID["Patch-ID matching\ngit patch-id\n(effort 3)"]
FindLocal --> SimMerge["Simulated merge\ngit merge-tree --write-tree\n(effort 3)"]
FindLocal --> Squash["Squash-merge detection\ncombined patch-id\n(effort 3)"]
FindLocal --> GoneUpstream[Deleted-upstream detection\nrequires a fetch]
FindLocal --> Orphans[Find orphan worktrees]
Merged --> SelectLocal[Unified multiselect:\nbranches + worktrees]
Cherry --> SelectLocal
TreeSHA --> SelectLocal
EmptyDiff --> SelectLocal
PatchID --> SelectLocal
SimMerge --> SelectLocal
Squash --> SelectLocal
GoneUpstream --> SelectLocal
Orphans --> SelectLocal
SelectLocal --> RemoveWT[Remove selected worktrees]
RemoveWT --> UseWT{Worktrunk\nenabled?}
UseWT -- Yes --> WTRemove[wt remove]
UseWT -- No --> GitWTRemove[git worktree remove]
WTRemove --> DeleteLocal[Delete local branches\ngit branch -D]
GitWTRemove --> DeleteLocal
DeleteLocal --> RemoteCheck
LocalCheck -- Yes --> RemoteCheck
RemoteCheck{--local-only?}
RemoteCheck -- No --> FindRemote["For each remote:\nfind merged remote branches\n(same strategies, vs remote/target)"]
FindRemote --> SelectRemote[User selects branches]
SelectRemote --> DeleteRemote[Delete remote branches\ngit push --delete --force-with-lease]
DeleteRemote --> Done
RemoteCheck -- Yes --> Done
Done([Done])
This project uses mise for task management. Start by installing the toolchain and the git hooks:
mise install # Install the pinned toolchain and tools
prek install # Install the pre-commit and commit-msg git hooksThe hooks are what enforce formatting, clippy and the commit convention locally; without them the first feedback comes from CI.
mise run build # Build the project
mise run build:release # Build in release mode
mise run test # Run tests with cargo-nextest
mise run lint # Run clippy
mise run lint:actions # Lint the GitHub Actions workflows
mise run fmt # Format code
mise run fmt-check # Check formatting without rewriting files
mise run check # Run all checks (fmt-check + lint + test)
mise run cover # Generate lcov coverage report
mise run cover:html # Generate HTML coverage report
mise run changelog # Preview the next version and changelog
mise run ship:validate # Validate the gh-ship release setup
mise run man # Collect the generated man pages and completions
mise run setup # Install the binary locallyConventional Commits, enforced by
commitlint via the prek commit-msg hook and re-checked by the CI lint job.
The changelog and the next version number are derived from them, so the type
and scope matter.
Orchestrated by gh-ship, with the
version and the changelog produced by git-cliff
(cliff.toml). gh-ship never versions and never writes changelogs; it drives
the lifecycle:
- push to
main→gh ship prepareopens or updates the Release PR on therelease/nextbranch, carrying theCargo.tomlbump and the changelog; - review the changelog and merge that PR;
gh ship releasetags the merge commit asvX.Y.Z, drafts the release, attaches the cross-compiled binaries, publishes the crate to crates.io, and only then makes the release public.
Maintainers do not tag by hand. gh ship validate runs in CI, so a workflow
that stops satisfying the contract fails on a pull request rather than
mid-release.
MIT