# gh poi: deleting merged branches when the merge was a squash

> gh poi is a GitHub CLI extension that removes local branches whose pull requests have been merged, including the case where a squash merge left no merge commit for git to find. It comes with a dry run, a branch lock, and a deep scan that walks remotes and history rather than just the tip commit.

**seachicken/gh-poi** — ✨ Safely clean up your local branches

- Repository: https://github.com/seachicken/gh-poi
- Website: https://dev.to/seachicken/safely-clean-up-your-local-branches-9i3
- Stars: 1,023 · Forks: 41
- Language: Go
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/seachicken-gh-poi

## The gap is squash merges, not old branches

gh poi is a GitHub CLI extension to safely clean up local branches you no longer need, and the README is specific about the problem it solves.

Daily development makes it difficult to know which branch is active when there are many unnecessary branches left locally, which causes a small amount of stress. If you squash merge a pull request, there is no history of the merge to the default branch, so you have to force delete the branch to clean it up, and you have to be careful not to accidentally delete the active branch.

That last sentence is the crux. The tool git ships with, git branch --delete-merged, works by looking at the commit graph. It finds branches whose commits are reachable from the default branch. A squash merge collapses the whole branch into a single new commit with a different hash, so none of the branch's original commits are ancestors of the default branch, and git cannot see that the work landed. The usual workaround is to force delete, and force deletion is exactly the operation where a mistake costs you an afternoon.

gh poi takes a different route. It asks GitHub what the pull request's state was, and links the local branch to that pull request using the commits it contains. The README's claim is that it makes it possible to automatically determine which branches have been merged and clean up the local environment without worry.

So the tool is not a better branch lister. It is a different source of truth for the same question, and the difference matters most in exactly the case git handles worst.

## The flags, and what quick mode actually checks

The usage list is short, and two of the options carry the real design decisions.

```bash
gh extension install seachicken/gh-poi
```

That is the whole installation, since gh extensions are installed with the gh CLI itself.

The commands are gh poi to delete the merged local branches, gh poi --state (closed|merged) to specify the PR state to delete with merged as the default, gh poi --scan (quick|deep) to choose the scan mode with quick as the default, gh poi --dry-run to show branches to delete without actually deleting, gh poi --debug to enable debug logs, and gh poi lock <branchname>... and gh poi unlock <branchname>... to protect branches from deletion.

The two scan modes are the most important pair.

quick is fast, checks the origin and upstream remotes, and identifies pull requests using only the latest commit on each branch. deep is comprehensive, scans all registered remotes, and performs a deeper history check to link branches to pull requests, ensuring no potential matches are missed across multiple forks.

The README adds a note that poi ensures safe deletion in both modes, which is a different claim from equal behaviour. The quick mode is a heuristic that can miss a match; the deep mode is the one that does not miss.

The fork case is where this bites. If you work across a fork, the pull request lives on the fork's remote and the merge happened upstream, and a tip-commit-only lookup against origin and upstream will not find it. A branch that was merged and should be deleted will survive, quietly, until the list is long again.

The --dry-run flag is the one to reach for first on any repository you do not control the remotes of.

## The comparison table with git branch --delete-merged

The FAQ includes a table that is the most useful thing in the README, because it maps scenarios to tools rather than making a general claim about which is better.

Merged into the upstream branch with an associated GitHub pull request: both tools handle it. Merged into the upstream branch without an associated GitHub pull request: git handles it and gh poi does not, because without a pull request there is no GitHub state to ask about. Squash-merged with an associated GitHub pull request: gh poi handles it and git does not, which is the whole point of the tool. Squash-merged locally without an associated GitHub pull request: neither handles it, and that is a genuine limitation of both. A pull request closed without merging: gh poi handles it with the --state closed option and git does not, which is a useful extra because an abandoned pull request leaves a branch behind forever. A merged branch in a clean, inactive linked worktree: gh poi handles it and git does not. And keeping a merged branch: both have a mechanism, gh poi lock <name> for the former and the branch.<name>.deleteMerged=false git config for the latter.

The two rows where gh poi loses are the informative ones. It needs a pull request, because a pull request is where the merge state lives. If your team merges directly to the default branch without a pull request, gh poi has nothing to query and will not delete those branches.

The worktree row is worth reading twice, because a linked worktree holding a branch that was merged is a case where the branch is still checked out somewhere and deleting it is destructive in a way that a plain list would not reveal.

## The lock command, and the Go implementation

The lock and unlock commands are the escape hatch, and they are the right shape for the problem.

gh poi lock <branchname>... locks branches to prevent them from being deleted, and gh poi unlock <branchname>... allows them to be deleted again. The comparison table gives the git equivalent for keeping a merged branch, which is the branch.<name>.deleteMerged=false configuration.

A lock is more expressive than that git setting, because it takes a list of branch names. That is what makes it usable in practice: a branch that holds an experiment, a branch a colleague is still on, or a branch you are keeping as a reference can be locked once and then left alone, rather than being re-deleted every time you run the tool and having to remember to exclude it.

The implementation is Go, with the module at github.com/seachicken/gh-poi on Go 1.25 with toolchain go1.25.4. The direct dependencies are few and all small. briandowns/spinner at 1.18.1 is the terminal spinner, cli/safeexec at 1.0.1 is the safe way to run an external command by name rather than by resolved path, fatih/color at 1.13.0 handles terminal colour, stretchr/testify is the test framework, and go.uber.org/mock provides the generated mocks.

cli/safeexec is the one to notice. A tool whose whole job is to delete local branches shells out to git, and safeexec is the mechanism that avoids a PATH lookup attack, where a malicious binary earlier in the PATH gets run instead of git. For a tool that deletes things, that is a reasonable thing to depend on.

The repository layout shows a conventional Go CLI. There is a cmd/ directory for the commands, a conn/ directory for the GitHub connection layer, shared/ for shared code, mocks/ for the generated mocks, a gh-poi directory, main.go at the root, and main_e2e_test.go, which is the end-to-end test. The .goreleaser.yml at the root handles the release builds.

## A name from Japanese, and an alias for it

The last FAQ entry is short and it tells you something about the project's tone.

Why the name poi? Because poi means feel free to throw it away in Japanese. And if you prefer an alias, you can change it with gh alias set, for example gh alias set clean-branches poi.

The name choice is the point of the tool in a small way. The problem the README describes is described as causing a small amount of stress, and the tool frames deleting a branch as throwing something away rather than as a destructive git operation. That framing is what makes the --dry-run and lock defaults feel like a natural workflow rather than a safety lecture.

The gh alias set suggestion is also practical. Since the tool is installed as a gh extension, it already lives behind gh, and a two-word alias like clean-branches poi is easier to type in a hurry than gh poi.

The project is MIT licensed, with the LICENSE file at the root. The badges tell you the rest: a CI workflow for the build, a codecov coverage badge against the main branch, and a CII Best Practices badge pointing at the Core Infrastructure Initiative project, which is a third-party assessment of things like a documented build process and a security policy.

The release history shows v0.18.4 from 2026-08-30, v0.18.3 from 2026-07-26 and v0.18.2 from 2026-07-17, and the last push was on 2026-10-03, so the tree is being worked on.

## Conclusion

gh poi suits a developer who squashes merges and finds git branch --delete-merged unable to see that their work landed, because the tool asks GitHub for the pull request state rather than inferring it from commit history. It is the wrong tool if you already have a branch naming or worktree convention that keeps the local list short, and it is the wrong choice if your workflow never squash merges, since plain git covers that case. Before you run it on a repository with unusual remote setup, try gh poi --dry-run first and then gh poi --scan deep, since the quick mode only inspects the origin and upstream remotes and the latest commit on each branch.

## FAQ

### How do I install gh poi?

Run gh extension install seachicken/gh-poi. It is a GitHub CLI extension, so it is installed and invoked through the gh command itself.

### What is the difference between the quick and deep scan modes in gh poi?

quick is fast, checks the origin and upstream remotes, and identifies pull requests using only the latest commit on each branch. deep scans all registered remotes and performs a deeper history check to link branches to pull requests, so no potential matches are missed across multiple forks.

### Can gh poi delete a branch that was squash merged?

Yes, when the squash merge had an associated GitHub pull request, which is the case the tool exists for. The comparison table shows git branch --delete-merged does not handle that scenario, and that neither tool handles a squash merge done locally with no pull request.

### How do I keep gh poi from deleting a branch?

Use gh poi lock <branchname>... to lock branches against deletion and gh poi unlock <branchname>... to release them. The git equivalent for keeping a merged branch is the branch.<name>.deleteMerged=false configuration.

### What does gh poi --dry-run do?

It shows the branches that would be deleted without actually deleting them, which is the sensible first command to run on a repository whose remotes you do not fully control.

## Sources

- [License: MIT](https://github.com/seachicken/gh-poi/blob/main/LICENSE)
- [Project website](https://dev.to/seachicken/safely-clean-up-your-local-branches-9i3)
- [README](https://github.com/seachicken/gh-poi/blob/main/README.md)
- [Releases](https://github.com/seachicken/gh-poi/releases)
- [seachicken/gh-poi on GitHub](https://github.com/seachicken/gh-poi)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/seachicken-gh-poi
