# Reducing Elixir worktree setup time by 83%

> How to reuse Mix build artifacts across Git worktrees safely, with a reproducible Phoenix benchmark.

Published: 2026-07-17T19:30:00+03:00
Canonical: https://ryanzidago.com/posts/reducing-elixir-worktree-setup-time-by-83-percent/

## TL;DR

- Copying `deps` and `_build` is 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.get` and `mix compile` after 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](https://git-scm.com/docs/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](https://github.com/phoenixframework/phoenix) at commit [`0d9c79e`](https://github.com/phoenixframework/phoenix/commit/0d9c79ebfc0f2065b41e268551638bd8b767eda9) while answering that question.

Assume my Mix project is already compiled on `main`:

```text
/Users/ryanzidago/Projects/my_mix_project
```

I create a worktree from the same commit:

```sh
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:

```text
/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:

```sh
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:

```text
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`:

```text
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`](https://github.com/elixir-lang/elixir/blob/v1.20.2/lib/mix/lib/mix/compilers/elixir.ex): 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:

```elixir
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:

1. **Metadata-only:** the BEAM points to source on `main`. Runtime behavior is unchanged, but tooling may open the wrong checkout.
2. **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`:

```elixir
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`:

```elixir
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:

```text
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](https://developer.apple.com/documentation/foundation/about-apple-file-system), `cp -a -c` creates file clones. On a reflink-capable Linux filesystem such as [btrfs](https://btrfs.readthedocs.io/en/latest/Reflink.html) or [XFS](https://docs.kernel.org/filesystems/xfs/index.html), GNU [`cp --reflink`](https://www.gnu.org/software/coreutils/manual/html_node/cp-invocation.html) 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](https://worktrunk.dev/) 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:

```sh
#!/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](https://developer.apple.com/documentation/foundation/about-apple-file-system), 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](https://github.com/livebook-dev/livebook), [Plausible Analytics](https://github.com/plausible/analytics), [Hex.pm](https://github.com/hexpm/hexpm), and [Supabase Realtime](https://github.com/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_ENV` values and `mix.lock` files
- 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 <a href="https://gist.github.com/ryanzidago/d943ca563a00a528874338337ceb91db" target="_blank" rel="noopener noreferrer">reproduction script</a> 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.
