Before (or after) brew uninstall, find out who outside Homebrew still uses a formula. Read-only: it never removes anything. Offline, too: the tool itself never connects to the network or uploads anything; it only reads files on your Mac and queries your local brew.
brew uninstall also removes the dependencies that were "only installed for" the formula you removed. Homebrew's dependency graph is accurate for Homebrew, but it cannot see the other users on your Mac: pipx tools and project virtualenvs bound to a Homebrew Python, launchd jobs, shell and tool configs, scripts that hard-code /opt/homebrew/opt/..., compiled apps that link a Homebrew library, git hooks. brew uses --installed answers "which formulae depend on it"; brew-leftovers answers "who else does".
Two real incidents behind this tool: removing one CLI took python@3.12 with it, which broke a pipx tool and two project virtualenvs that were only noticed seven weeks later; removing another CLI took ripgrep with it, which some apps call at run time.
Output of a real run against a test fixture (mock brew; the fake home folder is shown as /Users/me):
$ brew leftovers
Checking 3 formula(e) (read-only; scans project folders, config files and apps; usually a few minutes, longer with large project folders)…
────────────────────────────────────────
🔴 in-use python@3.14: something still uses it -> keep
· python-env: /Users/me/.local/pipx/venvs/pip-audit /Users/me/Projects/site/.venv
· git-hook: /Users/me/Projects/site/.git/hooks/pre-commit
────────────────────────────────────────
🟡 keep ripgrep: no users found in the scanned locations (which does not prove there are none), but it provides commands (rg) -> keep by default
· note: some apps and scripts run a command only when needed, which a file scan cannot see; confirm before removing
────────────────────────────────────────
🟢 removable libyaml: library only, no users found in the scanned locations -> may be removed (confirm first)
────────────────────────────────────────
Summary: in-use 1 / unverified 0 / keep 1 / removable 1 / not-installed 0 / cask 0
Scanned: brew dependents; Python environments (pipx, project .venv, poetry/uv, pre-commit); ~/.local/bin, LaunchAgents/Daemons, shell and git/ssh/npm/pip configs; git hooks; project folders (~/Projects); apps and compiled binaries; running processes; open files; symlinks
Not scanned: other user accounts; external drives; cloud-synced folders not under a project root; uses on other machines
Reinstall if removed by mistake: brew install libyaml
Remove only after confirming (nothing is removed by this script): HOMEBREW_NO_AUTOREMOVE=1 brew uninstall libyaml
Mark as wanted (in use) so brew stops listing them as removable: brew tab --installed-on-request python@3.14
If you decide to keep these, mark them as wanted: brew tab --installed-on-request ripgrep
With no arguments it checks everything brew autoremove --dry-run would remove. With names it checks those formulae, which is the thing to do before brew uninstall something:
$ brew leftovers python@3.14 ripgrep
Every result line starts with a stable label; --json uses the same strings.
| Label | Meaning | What to do |
|---|---|---|
in-use |
Something outside Homebrew uses it; the evidence is listed | keep |
unverified |
A brew query or a scan failed, timed out, or could not read a file or folder, so nobody can vouch for it | keep |
keep |
No user found in the scanned locations, but the formula provides commands or data files (shell plugins, completions, certificates, configs) that programs may use only at run time | keep by default |
removable |
Library only, and no user found in the scanned locations | may be removed, after you confirm |
not-installed |
Homebrew has nothing by that name | nothing |
cask |
An app; only command-line formulae are checked | keep |
The bias is deliberate: proving that nothing uses a library is impossible, so every doubt resolves to "keep". If you remove something by mistake, brew install <name> brings it back.
| Evidence kind | Locations |
|---|---|
brew-dependents |
brew uses --installed |
python-env |
pyvenv.cfg files under pipx, ~/.virtualenvs, poetry and uv caches, pyenv, pre-commit's cache and your project folders, whose home points at the formula |
config-file |
~/.local/bin scripts, shell startup files, ~/.config, git/ssh/npm/pip/bundler/cargo/docker/colima/lima configs, VS Code-family settings, Claude Code and Codex settings, /etc/paths* |
git-hook |
<project>/.git/hooks/* and the global core.hooksPath directory (pre-commit hard-codes the Python it was installed with) |
launchd |
~/Library/LaunchAgents, /Library/LaunchAgents, /Library/LaunchDaemons, Automator workflows; binary plists are converted before matching |
crontab |
your crontab |
symlink |
links under ~/.local, ~/bin, ~/.config, LaunchAgents and home dotfiles that point into the Homebrew prefix |
project-file |
scripts and configuration files in your project folders (.sh, .py, Makefile, Dockerfile, CMakeLists.txt, package.json scripts, ...), including extension-less scripts |
binary |
Mach-O executables and libraries that link the formula: /Applications, ~/Applications, ~/.local/bin, ~/.cargo/bin, ~/go/bin, language runtimes (rbenv, pyenv, mise/asdf, nvm, volta, bun, gems), virtualenv site-packages, @rpath references |
process, open-file |
running processes and lsof |
Project folders default to ~/Desktop, ~/Documents, ~/Developer, ~/Projects, ~/code and ~/src (those that exist). Only file names are ever printed, never file contents.
It also lists broken Python environments: virtualenvs whose Homebrew Python is already gone, which is exactly the damage this tool exists to prevent.
Requirements: macOS, Homebrew, the python3 that ships with macOS Command Line Tools (used for the binary scan and --json), and coreutils for gtimeout (brew install coreutils). Without timeout/gtimeout nothing is cut short and the tool treats the missing time limit as a warning, so every formula that is not proven in-use comes out unverified and nothing is ever reported removable.
git clone https://github.com/ryanchou1994/brew-leftovers.git
ln -s "$PWD/brew-leftovers/brew-leftovers" ~/.local/bin/brew-leftovers # any directory on your PATH
brew leftovers --version
Homebrew runs any executable named brew-<something> found on PATH as brew <something>, so once the file is on your PATH both brew leftovers and brew-leftovers work. brew help leftovers shows the short help.
A verdict of in-use does not change Homebrew's own bookkeeping: the formula is still "installed as a dependency" in brew's eyes, so brew autoremove will list it again next time, and a manual brew autoremove on a machine without HOMEBREW_NO_AUTOREMOVE=1 would still remove it. Homebrew's way to say "I want this" is:
brew tab --installed-on-request <formula>
brew-leftovers prints that command, filled in, for every formula it judged in-use, and a second, clearly separate line for the keep ones in case you decide to keep them. It only prints the command; it never runs it. (brew tab exists since Homebrew 4.3.6; on older versions the lines are simply not printed.)
Recommended companion setting, so that brew uninstall, brew upgrade and brew cleanup stop removing dependencies on their own:
echo 'HOMEBREW_NO_AUTOREMOVE=1' >> "$(brew --prefix)/etc/homebrew/brew.env"
That file is read by brew itself, so it also covers launchd jobs and non-interactive shells. Note that a manual brew autoremove ignores this setting; that is what brew leftovers is for.
brew leftovers --json [<formula> ...]
prints one JSON object (schema_version 1). Adding fields will not bump the schema version; changing or removing one will. Abridged example (one result shown, some arrays shortened):
{
"schema_version": 1,
"tool": "brew-leftovers",
"version": "1.0.1",
"mode": "autoremove-list",
"brew_prefix": "/opt/homebrew",
"results": [
{
"name": "python@3.14",
"canonical": "python@3.14",
"verdict": "in-use",
"evidence": [
{"kind": "python-env", "items": ["/Users/me/Projects/site/.venv"]},
{"kind": "git-hook", "items": ["/Users/me/Projects/site/.git/hooks/pre-commit"]}
],
"warnings": [],
"provides": {"commands": ["python3.14"], "linked": true, "data_files": true},
"installed_on_request": false
}
],
"summary": {"in-use": 1, "unverified": 0, "keep": 0, "removable": 0, "not-installed": 0, "cask": 0},
"warnings": [],
"suggestions": {
"mark_wanted": "brew tab --installed-on-request python@3.14",
"mark_wanted_if_keeping": null,
"reinstall": null,
"remove_after_confirming": null
},
"scanned": {"project_roots": ["/Users/me/Projects"], "not_scanned": ["other user accounts", "..."]},
"broken_python_envs": []
}mode is autoremove-list (no arguments) or explicit. Evidence lists use the same caps as the text output: at most six items for most kinds, four for symlink and three for process; python-env, launchd and brew-dependents are not capped (crontab and open-file always have a single item). Exit status is 0 whenever the check ran, whatever the verdicts; 2 when it could not run (no brew, bad arguments, not macOS, no python3 for --json).
Assistants are good at brew uninstall and bad at noticing what else went away. Two things work well:
- Tell the assistant to run
brew leftovers <name>before anybrew uninstall <name>, andbrew leftovers --jsonafterwards, and to treat anything butremovableas "keep". The ASCII labels and--jsonexist so that it does not have to read emoji. - With Claude Code, a
PreToolUsehook onBashcan remind it. A minimal version:
#!/usr/bin/env bash
# ~/.claude/hooks/brew-uninstall-reminder.sh — add it to settings.json under hooks.PreToolUse (matcher "Bash")
cmd=$(python3 -c 'import json,sys; print((json.load(sys.stdin).get("tool_input") or {}).get("command",""))')
case "$cmd" in
*"brew uninstall"*|*"brew remove"*|*"brew rm"*|*"brew autoremove"*)
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"Run `brew leftovers <name>` before removing, and `brew leftovers` afterwards; keep anything that is not removable."}}\n' ;;
esac
exit 0- macOS only. It relies on
plutil, BSDstat,lsofand Mach-O parsing. Linuxbrew would need ELF parsing and a different file layout; not planned for now. - It never removes anything, and it never will. The
brew uninstallline in the output is for you to copy after you have decided. - "No users found" means "none in the scanned locations". Other user accounts, external drives, cloud-synced folders outside your project roots and other machines are not scanned, and programs that call a command by name at run time leave no trace in any file. That is why formulae with commands are
keep, notremovable. - It errs towards keeping. A command name that is a prefix of another tool's name (
zzcvszzc-helper) can produce a falsein-use. That is accepted: the safe direction. - Scans take time. Usually a few minutes, longer with large project folders; each scan group has a timeout (
BREW_LEFTOVERS_SCAN_TIMEOUT, default 240 s) and a timeout turns the verdict intounverified, never intoremovable. - Unreadable files count as "not checked". If any scan cannot read a file or folder (no permission, or macOS privacy protection, e.g. a terminal without Full Disk Access reading
~/Desktopor~/Documents), every formula not already provenin-usebecomesunverified, and the reason line names the first path it could not read. Give the terminal access, or fix the permission, and run it again.
Environment variables: BREW_LEFTOVERS_PROJECT_ROOTS (colon-separated project folders), BREW_LEFTOVERS_SYSTEM_ROOTS (/Applications:/Library/LaunchAgents:/Library/LaunchDaemons by default; empty string to skip), BREW_LEFTOVERS_EXTRA_ROOTS (extra folders to scan for both text references and binaries), BREW_LEFTOVERS_SCAN_TIMEOUT.
bash tests/run.sh
132 cases run against a fake HOME with a mock brew and mock lsof; nothing touches your real Homebrew. They include two adversarial rounds inherited from the private version of this script (config files in unusual places, symlinked dotfiles, binary plists with non-ASCII strings, prefixes with spaces, broken python3, scans that time out) plus a holdout set written from the spec for the 1.0.0 additions, and (1.0.1) cases for unreadable files and folders and for timed-out searches, with a second blind holdout set that also checks ordinary oddities (broken symlinks, empty folders, odd file names) do not raise false warnings. Two cases are expected failures and are reported as XFAIL: a known over-match that errs towards keeping, and a fixture limitation covered by another case. coreutils is needed here too: without gtimeout the two timeout cases fail and most verdicts collapse to unverified.
MIT.
繁體中文說明:README.zh-TW.md