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.
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.
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 7 is a symlink target — generations 5 and 6 sit untouched, one rename away
An install, upgrade, remove, or sync runs through the same sequence, in this order:
--jobs). Anything already present in the store — matched by content hash — is skipped entirely.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.
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.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.
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.
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.
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.