← Turborepo

When the Cache Key Lies

A turbo cache key is a promise about what a task reads. Here is how it broke for Cargo, Go and uv.

Say you have a Rust workspace with turbo run lint wired to cargo clippy. It passes, and the result is cached. Then you add a clippy.toml with a stricter threshold and run lint again. Turbo prints FULL TURBO. No warnings, no errors, a green run.

The lint should have failed. Clippy reads that file, turbo's hash didn't, so turbo replayed an answer that was no longer true.

That is the whole problem with caching tasks for tools like Cargo, Go and uv, which Turborepo 2.11 added as experimental. Turbo hashes a task's inputs and replays the old output when the hash matches. So the hash has to cover everything the tool reads, and for a linter or a compiler that is a harder question than it sounds.

Two ways to be wrong

The rule is that the hash should change exactly when the tool's result could change. It can break in either direction.

hash unchangedhash changedresult sameresult changedHitreplay the cache, answer is rightfineNeedless misssomething in the key moved, theresult did not. The task reruns.right answer, only slower CIStale hitwrong answer that looks fineadd clippy.toml with a stricterthreshold: clippy reads it, the hashdoes not. FULL TURBO, green run.Missrun the task, the new answer is rightfine
the hash should move exactly when the result could · off the diagonal, the key is lying

The two are not equally bad. A stale hit hands you a wrong answer that looks fine, and a miss just costs time. So when you can't tell whether something matters, you include it. But you can't include everything, or the hash moves on every install and the cache never hits.

Config in the directories between

Linters and test runners don't only look for config at the repo root. They start from the file they are checking and walk up until they find something: clippy for clippy.toml, ruff for the closest ruff.toml or pyproject.toml, pytest for its ini file and then every conftest.py down from its root.

Config lives in every directory on the way downrepo rootclippy.toml ruff.toml pytest.inihashed since day onepackages/clippy.toml ruff.toml conftest.pynot hashed beforepackages/core/the package's own fileshashed as package filesThe tool looks upnearest file wins, orevery conftest.py appliesThe middle directory is the gap: a change there altered the result and left the hash alone.
the tool resolves config by walking up from the file; the hash has to cover the same walk

Turbo hashed the root and the package, nothing in between. So a packages/ruff.toml that turned a clean lint into ten errors left the hash alone. The fix is to add each tool's config filenames as input globs for every directory strictly between the package and the root: ancestor_config_globs in Cargo, ten filenames per intermediate directory in uv.

What the command touches decides which directories count. cargo clippy --package app also lints the local crates app depends on, so their ancestors are hashed. cargo fmt --package app only formats app, so theirs are not. The uv globs are deliberately too broad: a tox.ini with no pytest section still changes the hash, because deciding whether it matters means parsing it the way pytest does. The worst case is a miss, which is the cheaper mistake.

Some config can't be seen at all. Clippy and rustfmt keep searching above the repository root, and rustfmt also reads a file in your home directory. A repository hash has no way to cover those.

The same path, spelled twice

Now the other direction. Go finds its working directory with os.Getwd, which trusts the $PWD environment variable if it names the current directory, and otherwise returns the path with symlinks resolved. Turbo was passing along whatever $PWD it inherited.

Before: Go trusts the inherited $PWDAfter: PWD set where go is spawnedinvoked fromos.Getwd returnsturbo's hashinvoked fromGo seesturbo's hash/tmp/repo/tmp/repohash A$PWDpackage paths/private/tmp/repo/private/tmp/repohash B$PWDpackage paths/tmp/repothe root turbo computedhash BPWD setpackage paths/private/tmp/repothe root turbo computedhash BPWD setpackage pathssame repo, two spellings, two hashessame repo, either spelling, one hash
/tmp is a symlink to /private/tmp, so one repo has two spellings · the fix makes Go use turbo's

Turbo compares the package directories go list reports with the module directory it computed. When the spellings differed, the comparison failed and discovery fell back to a conservative default. From a subdirectory, an executable module lost its cached go build . and ran an uncached go build ./....

The fix is one line in run_go: set PWD to the directory the command runs in. A guard next to it keeps the conservative fallback for a workspace member reached through an in-repo symlink, since hashing does not follow those. The obvious alternative was to canonicalize the repository root everywhere turbo builds a path, but that would change hashes and displayed paths for every symlinked checkout and every toolchain. Setting the variable where the process is spawned touches only the process that was wrong.

The general point is that anything that differs between machines or invocations and reaches the key makes it unstable, even through a side door like an inherited environment variable.

Binary, according to whom

With * text=auto in .gitattributes, git converts CRLF to LF for files it judges to be text and leaves binary files alone. Turbo mirrors this when hashing, so its hash of a file matches the blob git would store. But the mirror was approximate.

git, at check-inbinary if any of these holds:a lone CRa NUL anywhere in the filemore than 1 non-printable byteper 128 printableturbo, beforebinary ifa NUL in the first 8 KBgit says binaryturbo saysbinaryagreed: bothleave thebytes aloneturbo says texta lone CRa NUL after 8 KBmostly non-printableturbo normalizes CRLF,git would notCRs added before LFsstat-fresh vs racyStale hitdifferent content, same turbo hash:an edit that only added CRsbefore LFs normalizes awayNeedless misssame committed bytes, two hashes:git's index hash when stat-fresh,turbo's normalized hash when staleor racy
git's check-in rule against turbo's old one · the highlighted box is where they disagree

Where the rules disagree, both failures show up (#14697). The fix ports git's classification into a byte-class table folded into the existing CRLF scan. A naive version made text=auto hashing about 2.7 times slower; counting only the non-printable classes and stopping at the first NUL brought text throughput to 0.82 GB/s against 1.10 GB/s before, and binary content back to baseline. To check the port, hash generated files with git hash-object --path and with turbo, and compare. Git is the reference.

One gap remains, as a tradeoff. Git skips conversion for a path whose indexed blob already contains CRLF, which happens when files were committed before the attribute was added. Handling that needs the index blob, which the hashing path doesn't have, so the code documents it.