Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The .turnkey Directory

Turnkey uses a .turnkey directory in your project root to store build artifacts, caches, and generated cells. This convention provides automatic isolation from language toolchains.

Why .turnkey?

The .turnkey directory serves as the isolation directory for Buck2 builds. By using a dot-prefixed name, we get automatic exclusion from most language toolchains:

ToolBehaviorConfiguration Needed
GoIgnores directories starting with . or _None (built-in)
CargoDoesn't auto-discover crates in dot directoriesNone (built-in)
pytestAutomatically ignores dot directoriesNone (built-in)
JestRequires explicit configurationYes
VitestRequires explicit configurationYes

This means Go won't try to compile generated Buck2 cells, Cargo won't discover them as workspace members, and pytest won't scan them for tests.

Directory Structure

.turnkey/
├── books/           # mdbook serve output (gitignored)
├── prelude/         # Symlink to Buck2 prelude derivation
├── toolchains/      # Symlink to generated toolchains cell
├── godeps/          # Real directory: the write-once Go cell (tk materialize)
│   ├── .buckconfig
│   ├── .deps-file-sha256   # the go-deps.toml it was built from
│   ├── _store/<store path name>  # one symlink per module, never retargeted
│   └── vendor/<import path>/rules.star  # an alias package per Go package
├── godeps.lock      # held while tk materialize runs
├── jsdeps/          # Real directory: the write-once JavaScript cell (tk materialize)
│   ├── .buckconfig
│   ├── .deps-file-sha256   # the js-deps.toml it was built from
│   ├── rules.star          # an instance per pnpm snapshot, an alias per direct dependency
│   ├── _store/<store path name>  # one symlink per package, never retargeted
│   └── vendor/<name>@<version>/rules.star  # an alias package per package
├── jsdeps.lock      # held while tk materialize runs
├── pydeps/          # Real directory: the write-once Python cell (tk materialize)
│   ├── .buckconfig
│   ├── .deps-file-sha256   # the python-deps.toml it was built from
│   ├── _store/<store path name>  # one symlink per distribution, never retargeted
│   └── vendor/<name>/rules.star  # an alias package per distribution
├── pydeps.lock      # held while tk materialize runs
├── rustdeps/        # Real directory: the write-once Rust cell (tk materialize)
│   ├── .buckconfig
│   ├── .deps-file-sha256   # the rust-deps.toml it was built from
│   ├── _store/<store path name>  # one symlink per crate, never retargeted
│   └── vendor/<crate>@<version>/rules.star, vendor/<crate>/rules.star  # aliases
├── rustdeps.lock    # held while tk materialize runs
├── soldeps/         # Real directory: the write-once Solidity cell (tk materialize)
│   ├── .buckconfig
│   ├── .deps-file-sha256   # the solidity-deps.toml it was built from
│   ├── rules.star          # bundle, and an alias per package
│   ├── _store/<store path name>  # one symlink per package, never retargeted
│   └── vendor/<name>/rules.star  # an alias package per package, beside
│                                 # links to its files for native forge
├── soldeps.lock     # held while tk materialize runs
├── gcroots/godeps, gcroots/jsdeps, gcroots/pydeps, gcroots/rustdeps, gcroots/soldeps # GC roots for the cells' current indexes
├── .cell-targets    # the store symlinks' targets, for tk's cell-freshness check
├── .cell-targets.<isolation dir>  # the same, for another isolation directory's daemon
├── edits/, patches/ # tk compose's edits and generated patches
└── sync.toml        # Symlink to the rules tk sync follows

The prelude and toolchains cells are symlinks to Nix store paths containing the generated Buck2 cells. The Go, Rust, Python, Solidity and JavaScript cells are real directories that tk materialize, run by the shell, keeps in line with the cell index Nix builds: a dependency change rewrites only the entries for the modules, crates, distributions or packages that changed (ADR 0004, ADR 0008, ADR 0010, ADR 0011, ADR 0012). Don't edit it; the shell rewrites it on every load.

Symlinked Cells and Plain buck2

Two cells are symlinks into the Nix store, repointed when the shell builds a new one:

  • .turnkey/prelude, the prelude;
  • .turnkey/toolchains, the toolchains cell.

.buckconfig and .turnkey/sync.toml are store symlinks too. They stay symlinks: they change only when the shell is rebuilt (a turnkey upgrade, a nix flake update, a toolchain.toml or flake.nix edit), which reloads it.

The deps cells, rustdeps, godeps, pydeps, jsdeps and soldeps, are the write-once directories above, which moved off symlinks in #234: a dependency change never repoints a link, so any caller, plain buck2 included, reads the new version. A deps cell you set yourself with buck2.<language>.cell, a derivation without a cell index, is still a symlink.

A running daemon doesn't notice a repointed symlink. It keeps the build files and sources it already read through the old target, and reads the packages it hadn't loaded from the new one. A build can then mix the old and the new cell, with no error.

tk checks for it. Before each command it syncs for (every buck2 command but the pass-through ones), tk reads the targets of .buckconfig and of every entry of .turnkey/ that is a symlink into the store, and compares them with those it saved in .turnkey/.cell-targets. When one was added, removed or repointed, it prints tk: cell symlink changed, restarting buck2 daemon (unless --quiet), runs buck2 kill, and saves the new targets. The first run only saves them. The new daemon starts without the old one's state, so the next build re-runs its actions.

Each isolation directory has a daemon of its own, so with --isolation-dir, tk checks against that directory's state file instead, .turnkey/.cell-targets.<dir> (.cell-targets.turnkey-ci for tk --isolation-dir=ci), and restarts that directory's daemon: buck2 --isolation-dir .turnkey-ci kill. A change one daemon was restarted for still restarts each other one the next time tk runs against it.

The check doesn't cover:

  • plain buck2: a script, a tool that runs buck2 itself, or a shell with TURNKEY_NO_ALIAS=1. The shell's buck2 alias for tk only applies to the interactive shell;
  • tk --no-sync, which skips it with the sync;
  • an isolation directory set only through BUCK_ISOLATION_DIR: without --isolation-dir, tk restarts the daemon buck2 picks from the environment, but checks it against .turnkey/.cell-targets, the state of the shell's own .turnkey daemon. Pass --isolation-dir instead.

So after the shell is rebuilt, before a plain buck2 call, either:

  • run a tk command that syncs, such as tk build, which restarts the daemon if a symlink changed; or
  • run buck2 kill, with the same --isolation-dir as the call.

In turnkey's own repository, these run plain buck2:

  • the e2e tests (e2e/tests/), which call buck2 build, test and run in a fixture project's shell;
  • scripts/ci-smoke.sh, whose integration level builds and tests each language's example;
  • check-test-caching (src/cmd/check-test-caching/__main__.py), which runs buck2 bxl;
  • the starlark-lint git hook, buck2 --isolation-dir .turnkey-lint starlark lint. It has a daemon of its own, which tk never restarts: after a turnkey upgrade, run buck2 --isolation-dir .turnkey-lint kill.

Buck2 Configuration

The .buckconfig sets the isolation directory:

[buck2]
isolation_dir = .turnkey

This tells Buck2 to store all build outputs under .turnkey/buck-out/ instead of the default buck-out/.

The tk Command

The tk command wraps buck2 and automatically translates the --isolation-dir flag to use .turnkey-prefixed directories:

# These are equivalent:
tk --isolation-dir=foo build //...
buck2 --isolation-dir=.turnkey-foo build //...

This allows multiple isolated builds while maintaining the dot-prefix convention.

JavaScript/TypeScript Configuration

Unlike Go, Cargo, and pytest, JavaScript test runners need explicit configuration to ignore dot directories.

Jest

Add to your jest.config.js:

module.exports = {
  testPathIgnorePatterns: [
    '/node_modules/',
    '/buck-out/',
    '/\\.'  // Ignore all dot-prefixed directories
  ],
};

Or in package.json:

{
  "jest": {
    "testPathIgnorePatterns": [
      "/node_modules/",
      "/buck-out/",
      "/\\."
    ]
  }
}

Vitest

Add to your vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    exclude: [
      '**/node_modules/**',
      '**/buck-out/**',
      '**/.*/**'  // Ignore all dot-prefixed directories
    ],
  },
});

Migration Notes

If you're migrating from a project that used buck-out/ directly:

  1. One-time cache invalidation: Buck2 caches are stored per isolation directory. Switching to .turnkey means a clean rebuild on first run.

  2. Update .gitignore: Ensure .turnkey/ is in your .gitignore:

    .turnkey/
    
  3. Update CI scripts: If CI scripts reference buck-out/, update them to .turnkey/buck-out/.

Multiple Isolation Directories

For advanced use cases (parallel builds, different configurations), you can use multiple isolation directories:

# Development build
tk build //...

# Release build with different isolation
tk --isolation-dir=release build //...
# Creates .turnkey-release/

# CI build
tk --isolation-dir=ci build //...
# Creates .turnkey-ci/

Each isolation directory maintains its own:

  • Buck2 daemon
  • Build cache
  • Output artifacts

This is useful for:

  • Running multiple Buck2 daemons simultaneously
  • Keeping CI caches separate from local development
  • Testing different build configurations