Reducing Elixir Worktree Setup Time by 83%
TL;DR
- Copying
depsand_buildis the obvious first optimization. How much it helps depends on the project's build profile: dependency-heavy projects benefit most, while projects dominated by application compilation may still have substantial work remaining. - Mix may still recompile the application's own modules. In Phoenix, that happened because the compiler manifest recorded the original checkout's current working directory.
- On Elixir 1.19.2 or newer,
elixirc_options: [check_cwd: false]can remove this blanket invalidation, but only when the project build is otherwise relocatable. - Copy-on-write cloning keeps each worktree independently writable without immediately making a full second copy of the cached files.
- Always run
mix deps.getandmix compileafter cloning to validate your changes. - In my Phoenix benchmark, copying the caches reduced setup from 12.01 seconds to 2.47 seconds. Making the project build relocatable and cloning copy-on-write reduced it further to 2.06 seconds.
The problem
Creating a Git worktree took milliseconds. Preparing it for Elixir development took much longer because the new checkout had no deps or _build.
The obvious solution was to copy both directories from an existing checkout. It worked: Mix reused the compiled dependencies and setup became much faster. But Phoenix still recompiled all 74 of its own project files, even when the worktree started at the same commit and the copy preserved timestamps. Why is that?
The solution
I benchmarked Phoenix at commit 0d9c79e while answering that question.
Assume my Mix project is already compiled on main:
/Users/ryanzidago/Projects/my_mix_project
I create a worktree from the same commit:
cd /Users/ryanzidago/Projects/my_mix_project
git worktree add .worktrees/add-export -b add-export main
The worktree lives at a different absolute path:
/Users/ryanzidago/Projects/my_mix_project/.worktrees/add-export
Git does not put ignored build artifacts in the new checkout. Instead of rebuilding them from scratch, I seed the worktree from main and let Mix validate them:
cp -a /Users/ryanzidago/Projects/my_mix_project/deps/. \
/Users/ryanzidago/Projects/my_mix_project/.worktrees/add-export/deps/
cp -a /Users/ryanzidago/Projects/my_mix_project/_build/. \
/Users/ryanzidago/Projects/my_mix_project/.worktrees/add-export/_build/
cd /Users/ryanzidago/Projects/my_mix_project/.worktrees/add-export
mix deps.get
mix compile
deps contains dependency source. _build contains compiled dependencies, project modules, and compiler manifests. Copying both provided the largest improvement because Mix could reuse the compiled dependency tree. However, Phoenix's own modules still recompiled:
Compiling 74 files (.ex)
Generated phoenix app
Even with that recompilation, setup fell from 12.01 seconds to 2.47 seconds. Copying the caches saved 9.54 seconds by preserving the compiled dependencies, but it did not yet preserve Phoenix's own compiled modules.
Why does the project still recompile?
The explanation was inside _build. Mix writes compiler manifests describing the inputs and assumptions behind compiled modules, and the Elixir manifest stores the current working directory. Copying _build preserved the path from main:
main
cwd = /Users/ryanzidago/Projects/my_mix_project
manifest stores this cwd
│
│ copy deps + _build
▼
worktree
cwd = /Users/ryanzidago/Projects/my_mix_project/.worktrees/add-export
manifest still stores the main cwd
│
▼
stored cwd != current cwd → manifest stale → recompile project
With the default check_cwd: true, Mix deliberately treats that mismatch as stale. The behavior is visible in Mix.Compilers.Elixir: it writes File.cwd!() into the manifest and compares it on the next compile.
That check is a correctness feature. Compiled modules retain absolute source-path metadata from the original checkout. Relocating them does not normally change runtime behavior, but developer tools that use this metadata may still point to the original source tree. Recompiling updates the metadata to the new checkout.
More importantly, application code can use the cwd during compilation:
defmodule MyMixProject.BuildInfo do
@project_root File.cwd!()
def project_root, do: @project_root
end
@project_root becomes part of the compiled module. Copying that BEAM into a worktree without recompiling it leaves runtime code pointing to main.
There are therefore two kinds of path dependence:
- Metadata-only: the BEAM points to source on
main. Runtime behavior is unchanged, but tooling may open the wrong checkout. - Behavioral: compile-time code embeds a path or data read through it. Reusing the BEAM can be incorrect.
Most application modules do not behave differently based on their checkout path. The risky cases are compile-time macros and module attributes that read files relative to the project root, custom Mix compilers, code reloaders, absolute paths in compile-time configuration, and native builds with absolute include or runtime paths. check_cwd: false accepts the metadata tradeoff, so do not use it while behavioral path dependence remains implicit.
Elixir 1.19.2 introduced a configurable cwd check. If the remaining project recompilation matters, audit that path usage before adding this to mix.exs:
def project do
[
# ...
elixirc_options: [check_cwd: false]
]
end
Changing the option changes the compiler cache key, so build main once after adding it. New worktrees can then reuse manifests created with the same compiler options.
Setting a literal false makes relocation a project-wide property. To opt in only for the worktree workflow, map the option to MIX_CHECK_CWD:
def project do
check_cwd? = System.get_env("MIX_CHECK_CWD") != "false"
[
# ...
elixirc_options: [check_cwd: check_cwd?]
]
end
Worktree commands can then run with MIX_CHECK_CWD=false, while teammates and CI retain the default. Only the literal value false disables the check. The base cache and worktree must use the same value because elixirc_options participates in the compiler cache key. Warm the base cache once with MIX_CHECK_CWD=false mix compile, then export the same value in the setup hook.
Phoenix still compiled four files because they consult Mix.Project at compile time:
recompiled 4 of 74 project files:
lib/mix/tasks/phx.ex
lib/mix/tasks/phx.gen.embedded.ex
lib/mix/tasks/phx.gen.schema.ex
lib/phoenix/code_reloader/server.ex
Setup fell again, from 2.47 seconds to 2.17 seconds, while project recompilation dropped from 74 files to four. The option removes blanket cwd invalidation; Mix continues applying its other stale checks.
The last optimization is to clone the caches copy-on-write. On APFS, cp -a -c creates file clones. On a reflink-capable Linux filesystem such as btrfs or XFS, GNU cp --reflink provides the equivalent behavior. Each worktree gets an independently writable directory tree, while unchanged data initially shares physical storage. Regular cp -a still provides the main compilation benefit; copy-on-write only reduces the time and storage needed to seed the cache. In the benchmark, it reduced setup from 2.17 seconds to 2.06 seconds.
I use Worktrunk to run the complete hook when it creates a worktree, but the script works with any Git worktree workflow. It includes the optional cwd optimization; remove the MIX_CHECK_CWD export if you only want the broadly applicable cache-copying benefit:
#!/bin/sh
set -eu
base_worktree=$1
export MIX_CHECK_CWD=false
clone_directory() {
source_path=$1
destination_path=$2
[ -d "$source_path" ] || return 0
mkdir -p "$destination_path"
if [ "$(uname -s)" = Darwin ]; then
cp -a -c "$source_path/." "$destination_path/"
elif ! cp -a --reflink=always "$source_path/." "$destination_path/"; then
cp -a "$source_path/." "$destination_path/"
fi
}
clone_directory "$base_worktree/deps" deps
clone_directory "$base_worktree/_build" _build
mix deps.get
mix compile
Always run mix deps.get and mix compile after cloning. The first command reconciles the copied dependency source with the worktree's mix.lock; the second recompiles anything genuinely stale. If compilation fails, report the error instead of automatically deleting _build: the branch may contain a real source error, and a full rebuild would only repeat it more slowly.
On my machine, the final median consisted of 53ms to create the worktree, 215ms to clone the caches, 1.33s to reconcile dependencies, and 441ms to compile.
Here is the complete progression:
| Setup | Median | Range | Phoenix files compiled | Saved from previous step |
|---|---|---|---|---|
| Fresh worktree | 12.01s | 10.53–13.98s | 74 | N/A |
Copy deps and _build | 2.47s | 2.29–2.73s | 74 | 9.54s |
Add check_cwd: false | 2.17s | 2.13–2.27s | 4 | 0.30s |
| Copy-on-write clone | 2.06s | 1.92–2.18s | 4 | 0.11s |
Copying the caches delivered 96% of the final 9.95-second saving. Making the project build relocatable and cloning copy-on-write recovered the smaller remaining opportunity.
Every scenario includes git worktree add, mix deps.get, and mix compile. The benchmark used one discarded warmup and five measured trials on an Apple M4 Max, APFS, Erlang/OTP 29, and Elixir 1.20.2.
The learning
Build caches are not just collections of files. They encode assumptions about the environment in which they were created: paths, toolchain versions, operating systems, architectures, configuration, and dependency state.
Whenever you relocate a cache, the useful question is not only “Can I copy these files?” It is “Which assumptions were encoded when they were built, and are those assumptions still true?”
I repeated the experiment against Livebook, Plausible Analytics, Hex.pm, and Supabase Realtime. Copying the caches reduced median setup time by 79% to 91% even though every application file still recompiled. Those projects embed checkout-specific absolute paths in compile-time configuration, so disabling the manifest cwd check was not enough to make their builds relocatable. Phoenix was the controlled example because it isolated the cwd check cleanly.
This also means the benefit depends on the project. A dependency-heavy project with little application code can gain most of its speedup just by copying the caches. A project dominated by expensive application compilation may have much more work remaining.
At minimum, reuse caches only across compatible:
- commits and source trees
MIX_ENVvalues andmix.lockfiles- compiler options
- Elixir and Erlang/OTP versions
- operating systems and CPU architectures, especially with native dependencies
Clone into empty cache directories, preserve timestamps, and keep every worktree independently writable. Do not symlink every worktree to one shared build directory: branches would become competing owners of the same modules and manifests.
The goal is not to bypass Mix. It is to give Mix a cheap, independent cache that it can validate. Once the cache's assumptions become explicit, reuse stops being only a file-copying question and becomes a relocatability question, with Mix remaining the correctness gate.
The reproduction script pins the Phoenix commit, prints every phase, reports its environment, and verifies the result with module loading and Phoenix router tests. The benchmark used a warm Hex cache and excluded network download time, so treat it as one-machine evidence rather than a promise of equivalent timings elsewhere.