Appearance
Adopting from direnv
daft pairs with direnv
For environment management, daft recommends pairing with direnv or mise rather than trying to cover env management with daft alone — both are more comprehensive than vanilla daft's per-job env: blocks. This guide is the direnv side of that pairing (you already have direnv; you're adding daft). If you're going the other direction — adding direnv to a daft-only setup — see Layering direnv on daft.
Starting state
The team adopted direnv a while back. The repo has an .envrc at the root and a README pointing new contributors at the manual rituals:
bash
# .envrc
dotenv_if_exists .env
PATH_add binThe README's "Getting started" section reads: "first clone? direnv allow, then pnpm install, docker compose up -d, and scripts/codegen.sh."
Tool versions are managed externally — devs run whatever Node and Python they have installed locally. The README has a "Required versions" line that the team ignores half the time.
The ritual: clone, see direnv's "blocked" message, direnv allow, then work through the rest of the README from memory. direnv solves the secrets-and-PATH half of setup — .env exports, the project's bin/ on PATH. The other half — the slow stuff (deps install, services up, codegen) — still relies on muscle memory.
That was tolerable for a single working tree. With daft worktrees, the slow rituals fire dozens of times a month. Sooner or later someone runs pnpm test before pnpm install finished, sees a missing-module error, and re-runs through the README to figure out what they skipped.
The reach for daft: don't replace direnv — layer hooks underneath it. direnv keeps managing what loads on cd; daft hooks pick up the rituals direnv was never meant to handle.
What changes
A new daft.yml adds the install / services / cleanup work to worktree-post-create and worktree-pre-remove. Nothing changes in .envrc. The dotenv line and PATH_add stay where they are.
Hooks run with the worktree as cwd. They do not run inside a direnv-loaded shell — direnv exec is not needed and is in fact counterproductive (see Idempotency below). The hook's per-job env: is independent of direnv's exports; the two layers coexist without conflict.
Recipe
yaml
# daft.yml
hooks:
worktree-post-create:
jobs:
- name: install-deps
run: pnpm install --frozen-lockfile
- name: services-up
run: docker compose up -d --wait
needs: [install-deps]
- name: codegen
run: ./scripts/codegen.sh
needs: [install-deps]
background: true
worktree-pre-remove:
jobs:
- name: services-down
run: docker compose down -v --remove-orphansExisting .envrc stays as-is:
bash
# .envrc — unchanged
dotenv_if_exists .env
PATH_add binA fresh daft start feature/x now lands in a worktree with deps installed, services running, codegen warming up — and direnv's ".env + PATH on cd" behavior intact when you cd in. The README's "after direnv allow, also run pnpm install / compose up" muscle memory is gone.
Variants
By what direnv is already managing in your project. Each variant names a thing direnv covers and what — if anything — daft adds.
direnv loads .env via dotenv / dotenv_if_exists
Most hook jobs don't need secrets — install, services up, codegen typically use the build toolchain, not API keys. Leave secret loading to direnv at the shell level; the hook stays free of .env reads.
For a hook job that does need a secret (e.g., a migration that reads DATABASE_URL), source the same .env direnv reads:
yaml
- name: migrate
run: |
set -a
source .env
set +a
pnpm db:migrate
needs: [services-up]Don't seed secrets into .envrc from a hook — that round-trips through direnv's trust prompt every worktree create. See Env vars & secrets for vault-fetched patterns where secrets shouldn't sit in a local file at all.
direnv creates a per-directory env (layout python, layout ruby)
direnv's layout stanzas create a per-directory virtualenv (Python) or gemset (Ruby) and activate it on cd. The daft hook just needs to materialize the env and install dependencies; direnv's layout activates it on the next cd.
For layout python python3.11:
yaml
- name: install-python-deps
run: |
if [ ! -d .direnv/python-3.11 ]; then
python3.11 -m venv .direnv/python-3.11
fi
.direnv/python-3.11/bin/pip install -e .The hook pre-creates the venv that direnv's layout python would otherwise create on the user's first cd, and pre-installs the dependencies. The next cd exports the already-activated venv — no install delay on first interactive use.
For layout ruby:
yaml
- name: install-ruby-deps
run: bundle install --path .direnv/rubydirenv adds a project bin to PATH (PATH_add bin)
No daft change. Hooks run with the worktree as cwd, so ./bin/foo resolves directly. The hook doesn't see direnv's PATH_add because the hook isn't running inside a direnv-loaded shell — and that's fine; just prefix with ./bin/ in the hook's run: line.
Idempotency & safety
Double-loading is the most common gotcha. If a hook's per-job env: exports the same var direnv exports:
- Inside the hook, the per-job
env:value wins (hook env overrides whatever the hook inherited from the parent shell). - Inside the shell, after the hook completes, direnv wins — it's re-evaluated on every
cd.
This is usually fine. The hook needs deterministic hook-local values; keep them in env: blocks. The shell needs the values direnv computes; let direnv keep computing them.
Don't direnv exec . daft hooks run …
Wrapping daft in direnv exec is unnecessary (the hook doesn't need direnv's exports) and invites a slow .envrc re-evaluation that can deadlock against direnv's trust prompt during a daft start. Run daft binaries directly.
Where to next
- Adopting from mise — the companion recipe for teams using mise instead of direnv.
- Env vars & secrets — deeper on hook-time vs shell-time env, especially for secrets that should come from a vault rather than a local
.env. - Lifecycle hooks — when
worktree-post-createfires relative to direnv's cd-time evaluation.