← Turborepo

How Turbo Knows What Changed

Turbo decides what a repo contains and what a commit touched. Both grew up assuming JavaScript.

Picture a Go repository with a go.work file and a handful of services. At the root there is also a package.json, because someone wanted Prettier and a commit hook. You turn on Go workspaces in turbo, run turbo run build, and nothing happens. Turbo found no packages to build.

The services are right there. Turbo never looked at them, because the root package.json had already told it what kind of repository this was. Two questions sit under almost every command: what is in this repository, and what did this commit touch? Both were first answered for JavaScript, and Cargo, Go and uv workspaces showed where that shows through.

Deciding what the repository is

Turbo finds the root by walking up from the current directory and asking each ancestor whether it could be one. For a long time that meant "has a package.json". Native toolchains were only asked to contribute packages afterwards, so in the repo above the Prettier file made the root a single-package JavaScript repository.

The fix (#14444) was not to teach that one check about Go. The next piece of code asking "is this a root?" would have needed the same special case. Instead, RepositoryBootstrap::observe(root) runs the JavaScript check and every enabled toolchain's probe once. A probe reads only the root's own definition files and never lists members or runs a binary. Root inference, graph construction, setup and the watcher all use that one observation, because two probes taken at different moments can disagree.

Before · the root question was asked in JavaScript termsAncestor walkload package.jsonin each directoryRoot decisionworkspace globs found?yes: multi, no: singleNative roots unseenno package.json: not founda bare one: single packageCargo, Go and uv were only consulted after this had already been decidedAfter · every enabled toolchain is probed once, in the same stepRepositoryBootstrapobserve(root): optional JSmanifest + one probe per toolchainRootObservationis_repository, is_workspace,owns(path)ContributorPlaninference and graph buildread the same recognized rootsA new toolchain registers an adapter; the walk and the graph builder do not change
repository root · one JavaScript-shaped check vs one shared observation

JavaScript is an optional input here, not an adapter. A missing package.json is an error only if no toolchain recognized the root, and an incidental one no longer erases a native workspace.

The cost is that every adapter has to follow the contract. Probes can't spawn processes, and the plan is a snapshot rebuilt when the process or configuration changes, and after graph-defining changes in watch mode. That is more machinery than a branch per toolchain. The crate's README states the rule as no ecosystem branches in inference or graph construction, and a fake fourth adapter in the tests holds it.

Deciding what a commit touched

--affected, --filter=...[ref] and turbo query affectedPackages all start the same way: source control reports changed files and a ChangeMapper turns them into packages, each with a reason.

Source controlchanged files sincethe base refChangeMapperturbo config and global deps,file to package, then dependentsPackageseach with a reasonLockfile changedprevious copy readfrom the base refJavaScript lockfilego.sum falls through hereCargo.lockown readeruv.lockown readerPer-package compareold vs new resolution,first changed path countsEvery packageLockfileChange-DetectionFailedmaps toyieldslockfile among changesreader picked by file nameold + newpackages whoseexternal deps movedon any error
change detection · files to packages, and the lockfile branch with its wide fallback

The lockfile branch is the hard part, because one file belongs to many packages. Calling the whole repo changed is always safe and rarely useful. Doing better means resolving each package's dependencies under the old lockfile and the new one and comparing. Each toolchain reports which files can change its external resolution, and reading them is a dispatch on file name. Only Cargo.lock and uv.lock have their own readers. Everything else goes to the JavaScript path.

go.sum is still in that position. It is registered as a Go resolution path but has no branch of its own, so a change to it falls through to the JavaScript path and then to the fallback. Every package is affected: safe and wide. Per-toolchain readers are what would make it narrow.

When Cargo.lock changed everything

That dispatch used to send everything except uv.lock to the JavaScript parser, and the JavaScript parser can't read Cargo.lock. Attribution failed, and a failure returns every package with the reason LockfileChangeDetectionFailed. Everything still built correctly, just slower, so the bug stayed hidden.

Before · JavaScript parser reads Cargo.lockAfter · Cargo reader, per-crate closuresbump a dependency of abump a dependency of aparse failsonly a and rust-ws movedfallbackclosures compared//abjsrust-ws//abjsrust-wsall five: LockfileChangeDetectionFaileda, rust-ws: LockfileChanged//, b and js did not use the crate
bumping one dependency of one crate · before and after Cargo.lock got its own reader

Cargo.lock now resolves both versions into a closure per crate and hands them to the same comparison the JavaScript path uses. If the Cargo data is missing or incomplete, the branch reports it as unavailable and the fallback still applies.

The fallback is the right default, even though it made this bug invisible. A missing answer is not an empty answer. Returning "nothing changed" on a parse failure would turn a tooling bug into a green CI run on broken code. Returning "everything changed" turns it into a slow one. The price is that nothing fails when it fires: a lockfile no reader understands looks exactly like one that changed everywhere, and the only trace is a reason in turbo query.

The same problem in the hash

The same pattern showed up twice more. With affectedUsingTaskInputs, --affected matched changed files against each task's inputs globs as written, while the hasher also includes package.json and turbo.json and expands src to src/**. A change that altered the cache key could select no task. Both sides now share the same input rules.

Task inputs · the matcher and the hasher read different rulesMatcher--affected withaffectedUsingTaskInputsGlobs as writtennothing else compiledTask selected?noHasherbuilds the cache keyGlobs plus extrasalways package.json, turbo.json;src expands to src/**Key changes?yesreadsreadsgivesgivesdisagreeFix: both sides now share the same input rulesEnv hash · framework packagesGlobal exclusion!NEXT_PUBLIC_*Task's own envvariable dropped from hashFixexclusions apply toinferred variables onlyalso hitfixed byA plain package with the same turbo.json hashed the variable
two readers of one rule · the task-input matcher and the hasher, and the env exclusion

Separately, a global !NEXT_PUBLIC_* exclusion could drop a variable listed in a task's own env for framework packages. Global exclusions now apply to inferred variables only.