Migrating Turborepo’s Core Commands From Go to Rust
Turborepo’s transition from Go to Rust has reached its most complex stage: porting the run and prune commands. These two commands drive most of Turborepo’s functionality, so moving them means relocating substantial chunks of code and logic that have deep dependencies on each other.
run executes tasks like build, lint, and test across packages in a Turborepo. prune generates a subset of the monorepo containing only a single package and its dependencies, which is useful when deploying to a Docker container. Both commands rely on processing configuration, building dependency graphs, hashing inputs, and coordinating parallel task execution.
The scale of these commands made the earlier migration approach impractical. The auth and configuration commands that were ported earlier had moved comfortably in one or two pull requests. run and prune could not be relocated that quickly—they required a strategy that allowed incremental movement of code while keeping the system functional throughout the process.
Evaluating Migration Strategies
Four possible approaches were considered before settling on the architecture that ultimately won.
Continuing the Existing Pattern
The first option was to extend what had worked for command-line parsing: perform more work in Rust and pass the results to Go. This fell apart quickly because the core work of run and prune revolves around two graph structures—the package dependency graph and the task dependency graph. Serializing these graphs over JSON would add significant overhead, and the package graph is the central abstraction of the tool, so it needed proper test coverage before being shipped in a rewritten form.
Full Rewrite
Building a complete Rust implementation of run and prune before switching over was also rejected. Given the experience with the earlier porting work, there was low confidence that a full rewrite would preserve exact behavior, making a gradual transition preferable.
Trampoline Approach
Another idea involved creating a trampoline in the Go binary with an entry-point function capable of redirecting to different parts of the run pipeline. The Rust code would call into Go, get a result, process it further, and call back into Go again—similar to JavaScript callbacks, where Rust produces events handled by Go. This would have reused the existing two-binary setup, but was not pursued.
Daemon Adaptation
Turborepo’s background daemon, which watches files to pre-compute change detection, was also considered as a migration vehicle. The plan was to port the daemon to Rust and then progressively expand it to build the package graph and task graph. However, since the daemon is an optional performance optimization, making it required for the run pipeline would represent a major architectural shift, so this was discarded.
Building the Go Sandwich
The chosen approach forms what the team calls a “Rust-Go-Rust sandwich.” In this setup, a Rust binary calls into a Go binary, which in turn calls into Rust libraries statically linked to it. This creates a flexible boundary between the languages, permitting individual dependencies and chunks of run/prune logic to be ported piece by piece rather than all at once.
The ported Rust dependencies live in the turborepo-ffi crate, compiled as a C static library (staticlib in Rust terms). This library is linked to the Go binary through CGO. Notably, the earlier issue with musl linking that had caused segfaults with Go-to-Rust linking disappears here, because Rust-to-Go linking works correctly with musl.
Communication between Go and Rust uses protobuf, chosen for its platform independence, compact serialization, and explicit schema that generates both Rust and Go types. This keeps the data structures synchronized across language boundaries—especially important given the wide interface surface between the languages.
The build process was also adjusted so the sandwich could be toggled off and on, always allowing a fallback to the original Go implementation if bugs surfaced. Though this meant keeping the Go code around, it was considered a temporary cost of stability.
The Porting Procedure
The porting process follows a repeatable pattern. A good candidate for porting is a self-contained, non-trivial piece of logic—large enough to justify the protobuf overhead, but not so large that it exceeds the scope of a single pull request.
The getGlobalHashableEnvVars function serves as a complete example. It reads the user’s environment variable configuration and determines which environment variables should feed into the global hash—the value that decides whether a global change forces all tasks to re-run. The team first split the existing implementation into global_hash_go.go (the old code) and global_hash_rust.go (the Go wrapper that calls Rust), selecting between the two with Go build tags so the old implementation stays available as a fallback.
During the port itself they:
- Wrote the new Rust implementation.
- Added the necessary input and output types to the
protobufschema. - Exported a function in
turborepo-ffi. - Called that function from
ffi.go. - Following the pattern, they wrote tests, then shipped it in a single pull request.
Repeating this workflow steadily moves more of run and prune over to Rust while keeping the system in a working state.
Release and Cross-Compilation
Shipping the sandwich surfaced release problems. Turborepo ships a native binary on Windows, Linux, and macOS for both x86-64 and aarch64—six distinct targets. Production releases cannot rely on having six separate machines to build on, especially since GitHub Actions lacks aarch64 build support in its pipeline. Cross-compilation becomes mandatory: building code for an operating system and/or architecture different from the build machine itself.
Cross-compilation is the routine path for the Go portions, handled primarily by Go Releaser. However, supporting six targets with two languages, each having its own toolchain, escalates the complexity of releasing cross-platform software. The transition to Rust has therefore required just as much attention to the release design as to the language port itself.
Release Obstacles in the Go Sandwich
Once we had the Go sandwich architecture in place, a new set of problems emerged: building a hybrid Go-Rust binary for release across all supported platforms. Our design relied on a Rust crate, turborepo-ffi, compiled to a staticlib (a native C library), which we linked into our Go code through CGO. While this setup worked in local development, release builds introduced a few significant issues.
The Windows ARM Problem
As discussed in a previous post, Windows development revolves around two distinct toolchains: Microsoft Visual C++ (MSVC) and Minimalist GNU for Windows (MinGW). MSVC is Microsoft's proprietary toolchain with its own Application Binary Interface (ABI), while MinGW is a port of the GNU software suite. Binaries compiled with one are not interoperable with the other due to their differing ABIs.
Go only supports the MinGW toolchain, whereas Rust provides ARM support exclusively through MSVC. This incompatibility prevented linking the two languages on Windows ARM. The platform does include x86-64 emulation, which meant we could ship an x86-64 binary and rely on OS-level emulation for ARM devices. There is a performance cost, but it is acceptable until we reach a fully native Rust binary or Rust adds a MinGW ARM target.
Handling C Dependencies
The second hurdle appeared when our Rust code made use of C dependencies. In standard native compilation, compilers generate object files that are later linked together into an executable. But because turborepo-ffi is compiled as an object file rather than a binary, it cannot directly link its own C dependencies.
Consider zlib as an example. If turborepo-ffi relies on zlib, it does not link it into its own object file. Instead, it expects the final linking step (in this case, into the Go binary) to provide the compiled zlib object. This is a fundamental characteristic of C static libraries: they are inputs to a linker, not standalone entities.
You might think the solution is simply to avoid C dependencies within turborepo-ffi. That is easier said than done. Many common libraries—git2, openssl, and even Rust's own libunwind for stack unwinding after a panic—rely on C code. Removing C entirely is not realistic.
With native compilation, this is manageable: pre-built libraries are available on the system or easily installed through a package manager like apt-get, brew, or apk. Cross-compilation changes the equation entirely. Installing pre-built libraries for a foreign target is not straightforward, and building from source requires cross-compiling, at which C compilers are prone to issues with static linking. Except, it turns out, there is a notable exception.
Introducing zig cc
Zig, a systems programming language that has seen growing adoption, ships with its own C compiler called zig cc. Beyond just compiling C, zig cc offers built-in cross-compilation support. Normally, cross-compiling C requires installing a separate target-specific compiler, sourcing libraries for that target, and wrestling with a tangle of compiler flags. Zig handles these concerns out of the box.
What makes zig cc especially useful is its extensive list of supported targets, including bundled libraries for each—covering exactly the platforms we needed. There was initial hesitation about adding a third toolchain to our stack, but the practical test is whether builds succeed. They did.
Using Zig, we were able to build our hybrid Go-Rust sandwich for every supported platform, C dependencies included. Zig bundles platform-appropriate versions of libraries such as libunwind; passing the flag -lunwind is sufficient. On the Go side, we needed to delegate linking to an external linker (Zig) rather than Go's default. The critical combination was linkmode external -extldflags="-static".
With one additional language toolchain in our pipeline, we managed to ship the Go sandwich to users. The first version of turbo released with this architecture was version 1.8.6.
Payoff for the Porting Strategy
Adding an entire new toolchain—plus protobuf—may seem like excessive complexity for a porting effort. The justification ties directly to our primary objective: continuously shipping code to users. A comprehensive rewrite would have paused feature development for months and left room for unused code to accumulate bugs. The Go sandwich guaranteed that as we ported incremental pieces, each one shipped and was actively exercised, validating both the code and our approach through real-world use.



