My intention was modest: free up some disk space. I typed
brew list --formula | wc -l and got 188. Add five application bundles (casks)
to that. Then I asked "how
many of these did I actually ask for?" and the whole thing turned on me. My
package manager answers that question with three different commands, and they
give three different numbers — and in the gap between those answers sit two
packages that appear in no list at all.
The space-clearing job never finished. What came out instead was a measurement
of how much a machine remembers about its own past. I'll walk
through all of it, because every number here has a mechanism underneath it,
and the mechanisms turned out to be more talkative than I expected.
Three commands, three answers
brew list --formula counts the 188 packages on disk. brew leaves says 51:
"the ones that aren't a dependency of another installed formula or cask."
brew autoremove says nothing at all: the output is completely empty, there is
--dry-run
nothing safe to remove. One question, three numbers: 188, 51, 0.
They aren't actually asking the same question, and that's what I had failed to
read properly. brew list enumerates directories in the Cellar: what is on
disk. brew leaves collects the runtime-dependency names from the receipts of
installed packages and prints the ones that don't appear in that set: the
leaves of the graph. autoremove asks a third question: which package is both
a leaf and carries the "I didn't ask for this" record.
Putting the four numbers side by side, I finally saw what I had been asking:
| Command | Answer | What it really asks |
|---|---|---|
brew list --formula |
188 | How many directories are in the Cellar? |
installed_on_request: true in receipts |
64 | How many installs did I request? |
brew leaves |
51 | How many are leaves in the graph of loadable formulae? |
brew autoremove --dry-run |
0 | How many are considered safe to delete? |
The Cellar totals 8.76 GiB. The 64 packages I asked for hold 5.77 GiB of that;
the 124 that came along hold 2.99 GiB. I assumed the single largest item on my
disk was mine: llvm really is (1,797 MiB), but right behind it comes
llvm@21 (1,561 MiB), which I never asked for. zig and lld@21 pulled it
in. The receipts say the same thing: llvm's record carries the request flag,
llvm@21's does not. One of the two largest items on my disk is a decision of
mine; the other is a side effect.
"Why are you here?" has exactly one bit of answer
Every package carries an INSTALL_RECEIPT.json in its own directory. All 188
receipts have the installed_on_request key: 64 say true, 124 say false.
So my machine's memory of "you asked for this" is a single boolean per package.
There used to be a second field: installed_as_dependency. It is missing from
85 of my receipts, true in 74, and false in 29. I found the reason in
Homebrew's own code; the comment in tab.rb refers to the field as
"long-removed" and keeps backward compatibility so old receipts still load. So
those 85 receipts were written by a newer brew that no longer records it. One
bit is what's left.
Up to here it's an engineering decision, and a reasonable one. What stopped me
was looking at my own records. My shell history sits at its ceiling: the file
holds 1,225 lines, but 225 of them end in a backslash continuation, so the
record count is exactly 1,000 — my SAVEHIST limit. The only brew line in
there is a brew upgrade; there is no brew install line at all.
Every new command of mine deletes an old
one, and somewhere among
those old ones every single install line went away. The only record of my
intent is that bit, kept by the package manager itself.
My first count was wrong, by the way: grep -c brew ~/.zsh_history returned
nothing and I wrote down "zero". The file isn't plain text — file calls it
extended ASCII, so grep treats it as binary and goes quiet. Asked with
grep -a, one line showed up. The same thing happens to me a couple more times
in the rest of this piece.
The thirteen packages between 51 and 64
Only 51 of the 64 packages I requested are leaves. The first 11 of the
remaining 13 are explainable: even though I asked for them, they are now
something else's dependency. 37 packages lean on openssl@3, 20 on libtiff,
12 on glib. If I tried to delete those, half of them would come straight back
down.
The last two taught me a lesson about measurement. The first time I compared
the lists, I found a package that was "in leaves but not in my requested set":
powershell/tap/powershell. No cause for panic, because the cause wasn't real
— brew leaves prints packages by their full name (tap prefix included),
while the Cellar directory is just powershell. The difference between the sets
was in my text formatting, not in the world. In this kind of measurement, the
first finding being an artifact of your own script is a common event; once I
normalised the names, the gap closed.
But two names refused to close: sshpass and supabase. Both are packages I
requested, both say installed_on_request: true in their receipts, and neither
is a dependency of anything, which by definition makes them leaves. brew doesn't count them. It doesn't error. It doesn't warn. They're just
leaves
absent.
Two packages on disk that the manager won't read
One command made the reason obvious:
$ brew info sshpass
Error: Refusing to load formula hudochenkov/sshpass/sshpass from untrusted
tap hudochenkov/sshpass.
Run `brew trust --formula hudochenkov/sshpass/sshpass` or
`brew trust hudochenkov/sshpass` to trust it.
Homebrew now refuses to load formulae from third-party taps unless you have
explicitly trusted that tap. The code has a crisp two-line definition: for a
tap to count as implicitly trusted it must be official and come from its
canonical remote (official? && canonical_remote?). Every other tap needs
approval, and approvals live in ~/.homebrew/trust.json. My file is 63 bytes
and holds a single-entry list: powershell/tap/powershell. The file's
modification time is 15 October, 09:05; the pwsh symlink is dated 09:06. So I
granted an approval in the same minute I installed PowerShell.
How that approval is given matters, because there is no spontaneous "do you
trust this?" prompt anywhere in the code: a trust record only comes from a
hand-typed brew trust command or from brew bundle's explicit flag, and
every other path stops at a single error. The full name in the file
(powershell/tap/powershell) says the same thing: I had the tap's identity in
front of me as I typed that line. The gate asked me for the right thing; I'm
the one who reduced it to a formality. A one-second decision, made so as not to
interrupt an install, turned out months later to be the only thing keeping two
packages inside the inventory.
That enforcement entered Homebrew with the Add tap trust enforcement commit
dated 30 May 2026, but the commit message itself says it was opt-in there: the
rule only applied when HOMEBREW_REQUIRE_TAP_TRUST was set. It became the
default through another commit on 8 June, which shipped first in version 6.0.0
(11 June 2026). So the gate has been shut on this machine for four and a half
months; I'm on 7.0.10 today. I installed sshpass on 24 February 2026 and
supabase two days later: both were inside before the gate existed, the gate
was fitted afterwards, and both ended up on the outside of it.
Now the concrete consequence: brew list shows these two packages, because
that command only looks at Cellar directories. brew leaves doesn't, because
it cannot load the formula. brew outdated reports 120 outdated formulae and
neither of them is on that list. Asked by name, it throws the error above. So
the scope of my update scan is not 188 packages, it's 186.
These two aren't dormant files either. The /opt/homebrew/bin/sshpass and
/opt/homebrew/bin/supabase links are in place, both are in my PATH, and both
run. sshpass -V prints version 1.06 and a 2011 copyright line. supabase is
2.75.0; that release went out on 2 February 2026, and the newest one upstream
was published this morning. Eight months sit in between.
The instructive part is this: my package manager didn't tell me, the package
itself did. Run supabase --version and two lines appear under the version
number saying a newer release exists and 2.75.0 is what's installed. The update
information is right there on the machine; the tool whose job is to collect it
and show it to me in one list has dropped that package from its list.
brew leaves being silent is, to me, the actual defect. When it can't load a
formula it drops the package from the list and doesn't tell me; the command's
error stream comes back completely empty. A one-line "I couldn't read these two
packages" would have replaced my hand-rolled set comparison. What the code does
is technically consistent — an unloadable
formula has no place in the graph — but an inventory command should at least
confess that its count is incomplete. If it were mine, I'd print the refusal as
a warning rather than an error: the behaviour stays the same, and the user's
blind spot closes.
I have no objection to the approval mechanism itself, quite the opposite. A
formula is Ruby code that runs during installation; the build steps, the
download addresses, the post-install scripts all come from that file. An
unaudited repository quietly updating that code amounts to permission to
execute code on my computer. I'd put the gate in the same place. My objection
isn't to the gate but to what happens behind it going unrecorded. Refusing to
load a formula I haven't trusted is the right call; dropping it from the
inventory as well erases the difference between "I won't load this" and "this
isn't here". And these two packages don't leave the disk or the PATH, and they
keep running: they just stop being counted.
The security side turns into a pleasant irony here. The gate's purpose is
right: don't blindly run code from a tap I haven't audited. But once the gate
closes, whoever is left inside also falls outside of management. NIST's guide to
enterprise patch management planning puts inventory second among its planning
recommendations (§3.2, Inventory Your Software and Assets) and says
inventories must be constantly updated or they go stale quickly; the same
section notes that a software bill of materials (SBOM) from a vendor can
augment that inventory. Mine is two lines short, and noticing it required
comparing sets by hand.
Why autoremove said zero
I had a bit of paranoia here: I didn't request 124 packages, so some of them
must be orphans. That turned out to be wrong. The graph, assembled from the
runtime-dependency lists in the receipts, shows that every one of the 124
unrequested packages has at least one requester; zero orphans. That's the real
reason autoremove came back empty.
But reading the code showed me there are three separate ways for it to come
back empty. autoremove only deletes packages that pass all three filters:
nothing in the graph depends on them, their receipt says installed_on_request
is explicitly false, and they were poured from a bottle (pre-built).
Packages built from source never even enter the list; three of them are like
that on this machine (powershell, sshpass, supabase). Cask dependencies
are separately protected as well.
On this machine the first of those three filters was enough on its own: with no
orphaned package, the list is empty before the others matter. The other two are
waiting for the day the graph changes: the three source-built packages on this
machine will never enter autoremove's list even if their requesters
disappear. For
someone who
actually wants to free space, the useful information is elsewhere: which of my
requests is single-handedly holding how many packages? My poppler install
holds 18 packages on its own, ffmpeg and ghostscript six each. Requesting
composer brings 56 packages into the closure, php 55, poppler 51. A
single brew uninstall decision frees 18 packages by that count — and none at
all by autoremove's answer today.
There are leftovers, too: brew cleanup --dry-run says roughly 41.9 MB. Four
packages have more than one version sitting in the Cellar; ca-certificates
has three at once (versions dated 19 March, 13 August and 25 September 2026).
Against 8.76 GiB, 41.9 MB is a comic number for my disk-space goal, but at
least nobody is hiding that number from me. After the measurement where I
deleted apps and left 661 MiB of data
behind, I pay
more attention to where the residue hides than to how big it is.
How far I can trust the receipt
With the measurement done, I was going to look at the dates in the receipts and
draw an "age distribution of my packages" chart. Luckily I tripped over an odd
line first: git-filter-repo appears to have been installed on 20 October 2026,
yet the same receipt's homebrew_version field says 4.6.9-6-g24e7d8f. That
tag is dated 3 September 2025, thirteen months before the install. Of the 70
packages installed after 24 June 2026, 24 carry a receipt naming a brew version
older than 6.0.
The answer is in the source again. When installing from a bottle, brew doesn't
write the receipt from scratch; it loads the one that came inside the bottle and
then refreshes only specific fields — install time, installed_on_request,
architecture, source path and so on. homebrew_version is not among the
refreshed fields. So the brew version in a receipt is the version of the brew
that built that bottle, not the one that installed it. The chart I was
planning to draw would have shown the history of Homebrew's build servers, not
of my machine.
The install time (time) field isn't an intent date either: since every
upgrade rewrites the receipt, that field holds "the moment this version was
installed". My oldest receipt is 21 January 2025 (git), the newest 20 October
- Between those two dates, "when did I first ask for this package?" is written down nowhere. The intent bit surviving an upgrade is not an accident but deliberate: the code reads the value from the existing receipt and carries it into the new one. The only thing carried over is that bit — not the date.
Running the same measurement on your own machine
None of the numbers above needs a special tool; they all come from reading the
receipts in the Cellar. This single line gives you the request/dependency
split:
for r in /opt/homebrew/Cellar/*/*/INSTALL_RECEIPT.json; do
/usr/bin/python3 -c 'import json,sys; j=json.load(open(sys.argv[1])); \
print(j.get("installed_on_request"))' "$r"
done | sort | uniq -c
On my machine this prints 128 False and 65 True; that totals 193, not 188.
The difference comes from four packages that keep more than one version in the
Cellar: the command counts every version directory, while my table takes the
newest receipt per package (64/124). If you're going to compare your number
with somebody else's, make sure both are counting the same thing.
To find the invisible ones, comparing two lists is enough, but do not forget to
strip the tap prefixes, or you'll see the same ghost difference I saw on my
first pass:
comm -23 \
<(ls /opt/homebrew/Cellar | sort) \
<(brew leaves | sed 's|.*/||' | sort)
For me this command dumps 137 names (188 minus 51), and every one of them means
"on disk, not in the leaf list". Most are legitimate, in that they're somebody's
dependency; brew info <name> tells you in one line which ones can't be read.
The paths differ by architecture: /opt/homebrew on Apple Silicon,
/usr/local on Intel Macs.
There is, however, a far shorter way to do this whole comparison, and I took
the long road until I found it. First, a single flag:
brew list --formula --full-name | grep /
That prints every package coming from a third-party tap by its full name — for
me, exactly three lines: the list of candidates for becoming invisible. Second,
open the gate for the length of one command and measure the difference:
HOMEBREW_NO_REQUIRE_TAP_TRUST=1 brew leaves | wc -l # 53 for me, normally 51
HOMEBREW_NO_REQUIRE_TAP_TRUST=1 brew outdated --formula # 121 for me, normally 120
The difference is exactly the two packages I expected, and the only one added
to the update list is supabase: so of the two that fall out of scope, one
really is behind, and the other (sshpass 1.06) is the newest version in its
own tap. Homebrew marks this variable as one that will be removed in a later
release, so it isn't a permanent fix; but for a once-a-year inventory audit
it's exactly what I was looking for. Your numbers will obviously come out
different; the value of the measurement isn't in the number, it's in how far
the four answers drift apart.
What I'll do tonight
At the end of the measurement my to-do list got shorter but clearer:
-
Settle the two invisible packages. Do I need
sshpass? The cost of keeping a binary with a 2011 copyright in my PATH is higher than either making it visible withbrew trustor removing it. Before deciding, I'll scan the scripts I use. -
Move
supabaseto where the manager can see it. There's no defensible reason to keep a CLI that's eight months behind outside the patch scan. -
Keep my intent somewhere other than brew. A 64-line
Brewfileis a better memory than one bit per package; my shell history clearly doesn't remember. - Read the tap inventory once a year. I have four taps, three of them third-party. Adding a line to the trust file took me a second; reading it takes a second too.
There's no new tool on that list, because the missing piece was never a tool.
My package manager answers three questions from three different places, and I
had been treating all three as the same question.
The number that stays with me isn't 188 or 0; it's 2. When a security gate goes
up, we usually think "that thing can't get in anymore". But the gate can also
make invisible what is already inside: the same mechanism both protects and
drops things out of the catalogue. The inventory our machines report to us is
not an inventory of what they measure, but of what they can read.
Official Sources
- Homebrew — the tap trust enforcement commit
- Homebrew —
Library/Homebrew/trust.rb - Homebrew — the
brew leavesimplementation - Homebrew — the
autoremovefilters - Homebrew — the install receipt (
tab.rb) - Homebrew — tap definition and
implicitly_trusted?(tap.rb) - NIST SP 800-40r4 — Guide to Enterprise Patch Management Planning
- CISA — Software Bill of Materials
Top comments (0)