How it works

Nothing is ever edited in place

hop never modifies an installed package. Every change builds a new, complete snapshot and switches to it with one filesystem rename. This page explains the mechanism behind that sentence in full.

In plain terms hop keeps every version of everything it ever installs, forever, in its own labeled folder. "Installing" or "upgrading" just means: build a new list of which folders should be active, then flip one pointer to that list. Nothing gets overwritten, so undoing a change is just flipping the pointer back — no reinstalling, no redownloading.

The content-addressed store

Every package hop installs lands in <root>/store/, in a directory named by its own package name, version, and content hash — for example ripgrep-15.2.0-3750b2e9. That directory is written once, during installation, and never touched again: not by an upgrade, not by a removal, not by anything.

"Content-addressed" means the name of the directory is derived from what's inside it, not chosen by the user or an incrementing counter. Two different builds of the exact same version — say, a bottle rebuilt with a different compiler flag — get two different directories, because their hashes differ. An install that turns out to be byte-identical to one already on disk is free: hop recognizes the hash and reuses the existing store path instead of downloading or extracting anything again.

Nothing in the store is ever mutated after it's written. This is the property everything else on this page depends on — a generation can safely point at a store path forever, because that path's contents are guaranteed not to change out from under it.

Generations

A generation is the complete, numbered list of what should be on your PATH at a given point in time — which packages, at which versions, symlinked from which store paths. Every install, upgrade, or removal computes an entirely new generation rather than editing the current one.

Generations are cheap to create precisely because the store already has everything they might need. Building generation 8 from generation 7 plus "add jq" doesn't require touching any of generation 7's files — it just means writing a new manifest that lists everything generation 7 had, plus jq's store path, and creating a fresh symlink farm (one symlink per exposed binary) pointing into the store.

Once a generation is fully built and every artifact it needs is verified on disk, hop does the only thing that actually changes your live environment: it repoints a single symlink, current → profiles/8. Every earlier generation — 1 through 7 — is still sitting on disk afterward, complete and untouched.

generation 5 rg 14.1 · fd 9.0 generation 6 rg 15.2 · fd 9.0 generation 7 rg 15.2 · fd 10.5 · jq 1.8 STORE (by hash) ripgrep-15.2.0-3750… fd-10.5.0-b67e18… fd-9.0.0-a12f90… jq-1.8.2-2d7534… rg-14.1.0-9c0021… current → hop rollback

generation 7 is a symlink target — generations 5 and 6 sit untouched, one rename away

A transaction, step by step

An install, upgrade, remove, or sync runs through the same sequence, in this order:

  1. Resolve. hop reads the recipe index and works out the complete set of packages the request implies, including every transitive dependency.
  2. Download, in parallel. Every artifact this generation newly needs is fetched concurrently (bounded by --jobs). Anything already present in the store — matched by content hash — is skipped entirely.
  3. Verify. Each download is checked against its pinned SHA-256 digest before anything is extracted. A mismatch aborts the whole transaction; nothing downloaded gets used.
  4. Extract into the store. Verified artifacts are unpacked into their own content-addressed directories. On Homebrew-sourced packages, this is also where relocation happens — rewriting the bottle's baked-in paths to point at hop's store instead of Homebrew's.
  5. Write the new generation. A manifest listing every package and store path for the new state is written to a fresh, numbered profile directory.
  6. Activate. The current symlink is repointed at the new profile — the one filesystem rename that is the entire visible effect of the transaction.

If any step before the last one fails — a download times out, a checksum doesn't match, disk fills up mid-extraction — the transaction is aborted and step 6 never runs. The current symlink still points exactly where it did before hop was invoked. There is no partially-applied state to end up in, because nothing that was already live was ever touched.

Confirmed, not assumed: this is exercised directly by hop's own reproducibility guarantee — hop sync --locked in CI fails loudly rather than silently re-resolving a stale lockfile, precisely because the transaction model makes "did anything actually change" a fact hop can check rather than something it has to infer from a half-finished prefix.

Rollback

hop rollback is not a special case of install running in reverse — it's the same activation step from above, pointed at an older generation number instead of a new one. That's the entire mechanism: no re-download, because the store paths a previous generation refers to are still sitting exactly where they were; no reinstall, because nothing about them needs redoing.

Because rollback is itself just an activation, running it twice in a row returns you to where you started — generation 7 rolls back to 6, and rolling back again goes from 6 to 7.

GUI apps: the one place hop writes outside its Root

A GUI application — what Homebrew calls a cask — is verified and stored exactly like any other package: the download is checksummed, the .app bundle is copied into a content-addressed store directory, nothing else. Everything up to that point fits the model above with no exception at all.

Making the app actually usable is where it can't. Spotlight, Launchpad and Finder look in ~/Applications, not in hop's store, so hop symlinks the bundle there — the one filesystem write hop ever makes outside Root. It only happens because a GUI app is involved: install nothing but CLI tools and images, and this code path never runs at all — hop has no reason to look at ~/Applications, let alone write to it. Bring a cask into the active generation and, on that install (and every later upgrade, rollback or remove), it's still as close to the rest of the model as the constraint allows: the symlink always points into the store, so switching versions or rolling back is still just repointing it, and hop only ever touches a symlink it can prove is its own (one whose target resolves inside its store) — a real application that happens to share a name is never removed or replaced.

A cask ships as a .dmg or a .zip; hop mounts or extracts it, keeps only the .app bundle, and discards everything else the artifact contained (background art, a Finder drag-to-install Applications symlink, a zip's wrapper folder). A Homebrew .pkg installer is a different problem hop deliberately doesn't take on: it runs an arbitrary script with no sandbox, which is exactly the kind of unbounded, unreversible step this whole page is about avoiding.

Garbage collection

Keeping every generation forever means the store only ever grows — until hop gc runs. It performs a real mark-and-sweep: every store path referenced by any generation's manifest is marked reachable, and everything else is deleted. The active generation's dependencies are always reachable and are never touched; --keep N additionally discards all but the newest N generations, which is what actually frees space after a long history of upgrades. hop prune and hop clean are aliases for the same command, for anyone coming from Homebrew or npm where those are the familiar names.

The recipe index

A recipe is a plain JSON document — a name, a version, and a checksum-pinned artifact URL per platform. hop never executes a recipe; it only downloads and unpacks what it names. The built-in index is compiled directly into the binary, so a fresh machine can search and install with no network access and no separate "update index" step.

Nothing in the index is hand-typed. A generator resolves each package's latest upstream release, downloads the real artifact, and derives its checksum by opening the actual bytes — the same discipline that produced the 1,017 recipes and 3,738 verified artifacts currently in the index.