SuperAPI AI Directory

daft-worktree-workflow

Office & productivity

View repository

Updated 10-17 11:56

About this skill

[Publisher repository snapshot. Source commit dated 2026-10-11 (UTC), raw source captured on 2026-10-17 (UTC). Latest default-branch state and installation are unverified.]

Source: https://raw.githubusercontent.com/avihut/daft/59661ed6638700c9171437f17c9487a21100002c/SKILL.md

daft Worktree Workflow

Core Philosophy

daft treats each Git worktree as a compartmentalized workspace, not just a branch checked out to disk. Each worktree is a fully isolated environment with its own:

  • Working files and Git index
  • Build artifacts (node_modules/, target/, venv/, .build/)
  • IDE state and configuration (.vscode/, .idea/)
  • Environment files (.envrc, .env)
  • Running processes (dev servers, watchers, test runners)
  • Installed dependencies (potentially different versions per branch)

Creating a worktree is spinning up a new development environment, not just "checking out a branch". Split that work along two lifecycles:

  • Provision on create. daft.yml lifecycle hooks do finite, idempotent, unattended setup — install dependencies, copy env files, configure environment tools — so the developer can start working immediately.
  • Serve on demand. Long-running, attended processes — dev servers, docker compose stacks, watchers — belong in tasks, started explicitly with daft run. Booting a backend stack in every worktree you only ever read wastes resources and invites port collisions.

The same job schema powers both; only the trigger differs (a lifecycle event vs. an explicit daft run). See Tasks (daft run) below.

Never use git checkout or git switch to change branches in a daft-managed repo. Navigate between worktree directories instead.

Detecting a daft-Managed Repository

daft supports multiple layouts. The most common is the contained layout:

my-project/
+-- .git/                    # Bare repository (shared Git metadata)
+-- main/                    # Worktree for the default branch
|   +-- src/
|   +-- package.json
+-- feature/auth/            # Worktree for a feature branch
|   +-- src/
|   +-- package.json

Key indicators of any daft-managed repository:

  • git rev-parse --git-common-dir from any worktree finds the shared Git directory
  • daft layout show reports which layout the repo uses
  • Contained layout: .git/ at the project root is a bare repository (directory, not a file) with branch worktrees as siblings
  • Other layouts: the main checkout looks like a normal Git repo, but daft manages worktrees elsewhere

If you see any of these patterns, the user is using daft. Apply worktree-aware guidance throughout the session.

Four built-in layouts control where worktrees are placed:

Layout Template Description
contained {{ repo_path }}/{{ branch }} Worktrees inside the repo directory
sibling {{ repo }}.{{ branch | sanitize }} Worktrees next to the repo (default)
nested {{ repo }}/.worktrees/{{ branch | sanitize }} Worktrees in a hidden subdirectory
centralized {{ daft_data_dir }}/worktrees/{{ repo }}/{{ branch | sanitize }} Worktrees in a central data directory

Manage layouts with daft layout show / list / transform <layout> / default [layout]. Layout is selected at clone time by --layout, then the daft.yml layout: field, then the global default, then the built-in default (sibling); custom template layouts live in ~/.config/daft/config.toml.

daft layout transform moves the branch checked out in the main working tree, whatever it is — not the default branch. If the default branch has no worktree it keeps having none; daft reports that rather than creating one. It carries any working-tree state (modified, staged, untracked, ignored, conflicted entries): per-worktree git state is relocated, nothing is stashed, and there is no --force. It refuses only a paused rebase/am/merge/cherry-pick/revert/bisect in the worktree that changes role (plus an index.lock, checked-out submodules, or a git worktree lock), reporting every blocker at once with both settle commands; --dry-run lists them. --pivot <branch> and --as <dir> are the flag twins of the two questions it would otherwise ask (which worktree takes the repository root of a bare repo; what to name a detached main working tree's directory), and -y accepts the --as default and the cross-volume copy confirmation — never a pivot. Always pass the flags when running non-interactively.

daft.yml can pin the team-recommended layout alongside hooks:

layout: contained

hooks:
  worktree-post-create:
    jobs:
      - name: install-deps
        run: npm install

Running daft

Execution rule

Run and recommend daft commands using the short verbs exactly as written in this file: daft go, daft start, daft clone, daft init, daft carry, daft exec, daft run, daft merge, daft list, daft update, daft prune, daft remove, daft rename, daft sync, daft push, plus the noun groups daft hooks ..., daft repo ..., daft layout ..., daft config ..., daft doctor, and daft skill .... Invoke the daft binary directly.

Never run or emit the alternate spellings some users have configured: git worktree-* subcommands, git-worktree-* binaries, long daft worktree-* names, git daft ..., or shortcut aliases such as gwtco. They depend on symlinks and shell wrappers that are usually absent from agent shells, and the daft verbs are the canonical register. This applies to commands you execute and to commands you write in explanations, docs, scripts, and daft.yml suggestions alike.

Recognizing user vocabulary

Users may still type those alternate spellings in their own terminals. Translate what they say into daft verbs; respond and act in daft verbs. It is fine to acknowledge their form once ("gwtco runs daft go").

User says or types Means
git worktree-checkout, gwtco, gwco, gcw daft go
git worktree-checkout -b, gwtcb, gwcob, gcbw daft start
gwtcm, gwtcbm, gwcobd, gcbdw daft start off the default branch
git worktree-clone, gwtclone, gclone daft clone
git worktree-init, gwtinit daft init
git worktree-carry, gwtcarry daft carry
git worktree-warm daft warm
git worktree-exec daft exec
git worktree-merge daft merge
git worktree-list, gwtls daft list
git worktree-fetch, gwtfetch daft update
git worktree-prune, gwtprune, gprune daft prune
git worktree-branch -d / -D, gwtbd daft remove / daft remove -f
git worktree-branch -m, gwtrn daft rename
git worktree-sync, gwtsync daft sync
git worktree-push, gwtpush daft push
git daft <noun> ... (e.g. git daft hooks trust) daft <noun> ...

The long daft worktree-<name> spellings map the same way. Shortcut aliases are optional symlinks users manage with daft activate shortcuts; never execute them yourself.

If a documented command is rejected

If daft rejects a command or flag documented here (unknown subcommand, unexpected argument), do not fall back to raw git worktree plumbing.

  1. Re-discover the real surface: daft --help, then daft <command> --help.
  2. The installed copy of this skill may be stale relative to the installed binary. Refresh it with daft skill install, which writes the version-matched skill embedded in the daft binary (compare daft --version with daft_version in this file's frontmatter).
  3. Proceed with the syntax --help reports.

Operating across worktrees: -C <path>

Every daft command accepts a top-level -C <path> flag that changes the effective working directory before any path-dependent state is resolved (repo discovery, layout, hooks, daft.yml). Semantics match git -C.

daft -C /path/to/repo list           # equivalent to: cd /path/to/repo && daft list
daft -C /path/to/repo go feature/x   # creates the worktree inside that repo

This is the recommended pattern for agents working across multiple worktrees: each command is self-contained ("do X in path Y") with no cd juggling. Rules: repeated flags compose like git -C (-C /a -C b means /a/b — not "last wins"); relative arguments resolve against the post--C cwd; -C is parsed only at the front of the argv, so an inner -C in a daft exec shell command is preserved.

daft does not change your shell's directory

The daft binary cannot cd the parent shell. After creating a worktree, navigate to it explicitly — sibling worktrees live at ../<branch>/ relative to any worktree. (Users with shell integration installed get automatic cd via DAFT_CD_FILE wrappers; agent shells do not have those wrappers.)

When a user asks why their terminal did not follow a new worktree, point them at shell integration: eval "$(daft shell-init bash)" in ~/.bashrc or ~/.zshrc, daft shell-init fish | source for fish. Opt out per command with --no-cd or globally with git config daft.autocd false. Agents recommend these lines; they never eval them.

daft repo remove --purge and daft repo move invalidate your cwd

Running daft repo remove --purge from inside the repo being deleted, or daft repo move from inside the repo being moved, invalidates the agent's cwd mid-operation. Either pass an explicit target and stay outside (daft repo remove --purge /path/to/repo, daft repo move api ~/Work/api run from elsewhere), or cd to a safe ancestor first. The binary writes a redirect path to $DAFT_CD_FILE for shell wrappers, but agent shells typically lack that wrapper, so follow-up commands fail with chdir: no such file or directory until the cwd is fixed. After a move the new location is the one to cd into.

Removal without --purge is safe: the default deletes nothing, so the cwd stays valid. A move has no such mode — it always relocates the files.

Command Reference

All commands run from any directory inside any worktree; daft finds the project root via git rev-parse --git-common-dir.

Worktree Lifecycle

Command Description
daft clone <url> [--layout <LAYOUT>] [--install [--git-exclude]] Clone a remote repository into worktree layout. --install bootstraps a starter daft.yml after cloning (copied into every worktree of a multi-branch clone, implies --trust-hooks; skipped if the repo ships a tracked daft.yml; rejected with --no-checkout).
daft init <name> [--layout <LAYOUT>] Initialize a new local repository in worktree layout
daft go <branch> Create/enter a worktree for an existing local or remote branch; --local skips the remote fetch even when daft.checkout.fetch is enabled
daft go pr:<number> Check out a GitHub PR or GitLab MR (mr:<number>, or a pasted PR/MR URL) into a worktree on its source branch, configured to pull from the PR head. Fork-aware; resolves via the gh/glab CLI, which must be installed and authenticated (daft doctor reports). The platform is detected from the remote (pr:/mr: are aliases); daft.forge.platform overrides for ambiguous remotes. Works cross-repo from anywhere: daft go <repo> pr:<number> checks the PR out in that cataloged repo.
daft go - Switch to the previous worktree (cd - style toggle)
daft go -s <branch> Same, but auto-creates the branch if not found (also daft.go.autoStart)
daft go <commit-ish> Open the canonical detached sandbox for a point in history — a tag, SHA, HEAD~2, origin/master — when the name is no branch and no repo. Idempotent (revisits land in the same worktree), hooks run, no branch exists. See Anonymous Worktrees below.
daft start <branch> [base] Create a new branch and worktree from the current or specified base; does not push by default (daft.checkout.push); --local skips remote even when push is enabled. A leading cataloged-repo name creates the branch in that repo instead — see the Repo Catalog table. From a detached HEAD (inside a sandbox), the new branch bases on that commit — the promotion gesture.
daft start --fork [<base>] [-n N] Mint N private anonymous worktrees pinned at [<base>] (default: current position) — detached, system-named, no branch, no push. The created path(s) print bare on stdout, one per line — capture them; narration is stderr. See Anonymous Worktrees below.
daft remove <branch> Safely delete a branch: its worktree and local branch ref; the remote branch only when daft.branchDelete.remote is enabled; --local skips remote, --remote deletes only the remote branch. An unmerged branch still works without -f when it is identical to a remote branch the removal preserves (daft verifies this against the remote) — do not reach for -f just because a branch is unmerged
daft remove -f <branch> Force-delete bypassing safety checks; for the default branch, removes the worktree only (preserves branch ref and remote)
daft prune [-f] [-v|-vv] Remove worktrees whose remote branches were deleted AND that are verified merged (ancestor or squash); gone-but-unmerged branches are kept unless forced. In scope only when the branch was published — git upstream tracking, or a publication daft recorded (its own push, or a tracking ref it observed); a branch nothing attests to is never pruned, not even with -f (use daft remove). -v hook details, -vv full sequential
daft carry <targets> Transfer uncommitted changes to one or more other worktrees
daft warm [<worktree>] [--from <worktree>] [-f|--force] [-v] Re-run the copy: stage on demand. Naming no target warms the current worktree, naming one warms that one; --from names the source outright and is never second-guessed. Without it the source is ranked: a worktree sitting at the target's exact commit first, then where you stand, then the default branch's worktree. Each slot takes a worktree directory name, branch name, or path. Entries already present in the target are skipped unless --force, which still refuses content the target tracks. The result line names both resolved worktrees at ordinary verbosity. One worktree per run — no fleet form. Does not change your shell's directory, except when --force replaces the directory you are standing in — then it moves you to the target worktree's root. See Warm Worktrees below.
daft update [targets] Update worktree branches from remote; refspec syntax source:destination for cross-branch updates
daft rename <source> <new-branch> Rename a branch, move its worktree, and rename the remote branch
daft sync [-f] [--rebase BRANCH [--autostash]] [--push [--force-with-lease] [--no-verify] [--jobs N] [--no-throttle]] [--include VALUE]... Prune stale worktrees + update all + optional rebase + optional push. Rebase and push apply only to branches you own by default; --include widens (unowned, an email, or a branch name). -f/--prune-dirty includes dirty worktrees. Parallel hook-bearing pushes are memory-governed (--jobs N caps concurrency, --no-throttle disables). First Ctrl+C cancels gracefully (partial results print, exit 130); a second force-kills.
daft push [branch] [--no-verify] [--force-with-lease] Push one branch with the repo's git pre-push hook running in that branch's own worktree — the command's whole point. Defaults to the current branch; targets the branch's own upstream remote, falling back to daft.remote (origin) when it has none — and then sets upstream; a branch with no worktree pushes from the current directory. Single-branch by design (use daft sync --push for a fleet push).
daft merge ... Merge branches across worktrees without git switch — see Merging Across Worktrees below

Management

Command Description
daft list [--format <FMT>] [-b|-r|-a] [--columns COLS] [--sort COLS] List worktrees: branch (✦ = default), path, base ahead/behind, file status, remote status, age, owner, commit. In forge repos the default listing also includes a row per open PR (see Machine-Readable Output; --columns -pr for worktrees only). -b/-r/-a include local/remote branches without worktrees. Output contract and JSON fields: see Machine-Readable Output.
daft exec [TARGETS]... [--all] [-x CMD]... [-- CMD ARGS]... Run command(s) across worktrees: positional/glob targets or --all; -x repeatable shell pipelines; trailing -- for direct argv. Parallel by default (--sequential/--keep-going for serial); failed worktrees' captured output is dumped after the run, -v dumps successful ones too. On an interactive terminal, runs render as a live plan-then-execute rail.
daft run [<task>] [<args>...] [--list] [--job <name>] [--tag <tag>] Run a named task from daft.yml's top-level tasks: section in the current worktree; bare daft run runs the reserved run task, and words after the task name forward to it as arguments (a first word naming no task forwards everything to run). Output streams live, there is no execution timeout, and Ctrl+C cancels (twice force-kills). Executes even in an untrusted repo — explicit invocation counts as consent. See Tasks (daft run).
daft env [[repo:]VAR[@worktree]] [--export] [--write [PATH]] [--ad-hoc] Print deterministic per-worktree env values (ports, names) derived from the worktree's name — no allocation, no registry; the same inputs give the same answer on every machine, even for a worktree not created yet. Bare daft env lists the declared set; VAR prints one raw value; --export emits eval-able exports; --write materializes a dotenv file. Declared in daft.yml env:; hooks, tasks, and daft exec receive the values automatically. Unknown names error under a declared schema (--ad-hoc escapes). Daft's own job variables answer too — daft env DAFT_BRANCH_NAME prints the live value of any of the seven that exist at rest, and the rest (DAFT_HOOK, DAFT_COMMIT, DAFT_MERGE_*, …) error explaining when they exist; serializing them is refused, so --export/--write/--format stay the declared set.
daft config list [--modified] [--global|--local] [--format <FMT>] Every daft setting with its current value and the layer that decided it. --modified shows only what something sets. Use this to find a key rather than guessing at one.
daft config get <key> [--origin|--global|--local] [--format <FMT>] One value on stdout, exit 1 when there is none — the same contract git config --get has. --origin adds the full layer-by-layer chain and any diagnostics; --format always includes them, so scripts need not pass --origin. Output contract: see Machine-Readable Output.
daft config set [--global|--local] <key> <value> [--format <FMT>] Change a setting, validated against its own type before anything is written. --local is the default. Covers git-config keys, the worktree layout, and daft.yml scalars alike.
daft config unset [--global|--local] <key> [--format <FMT>] Remove a value, revealing whatever it was masking. Not an error when nothing was set — changed: false in the structured form says so.
daft config set [--global|--local] remote-sync <on|off> Behaviors are named groups of settings that only make sense together — remote-sync is fetch, push, and remote-delete. get returns on, off, or custom (the members disagree); custom can be read but never set. Bare daft config opens an interactive browser — never run it from an agent; use the verbs above.
daft layout [show|list|transform <layout> [--pivot <branch>] [--as <dir>] [--dry-run] [-y]|default] Manage worktree layouts; transform carries any tree state and refuses only in-progress git operations
daft hooks <subcommand> Manage hooks trust and configuration (trust, prompt, deny, status, run, install, validate, dump, migrate, jobs)
daft hooks jobs [logs|cancel|retry|prune [--dry-run] [--older-than <D>]] Manage background hook jobs: list (with a Size column), view logs, cancel, retry, prune old records. Automatic cleanup runs at most once every 24h (off in CI; opt out with DAFT_NO_LOG_CLEAN=1). JSON shape: see Machine-Readable Output.
daft doctor Diagnose installation and configuration issues; --fix auto-repairs, --fix --dry-run previews. The Repository Config check reports the main daft.yml's status (tracked / visitor / none) repo-awarely.
daft skill install [--project|--dir <path>] Install or update this agent skill from the copy embedded in the daft binary (default ~/.claude/skills/; --project targets the worktree's .claude/skills/). Re-running updates in place. daft skill show prints the embedded skill to stdout.
daft repo install [--git-exclude] Write a starter daft.yml at the worktree root — see Bootstrapping a config below. daft install is a top-level alias.
daft repo remove [<repo>] [--purge] [--force] [--dry-run] Remove a repository from the catalog. By default the entry is tombstoned and NOTHING on disk is touched — no hooks, no prompt, and it works on a stale entry whose directory is already gone. --purge also deletes the git dir and every worktree, running worktree-pre-remove/worktree-post-remove per worktree (post-remove fires AFTER the directory is gone) and prompting unless --force; --force is --purge-only. <repo> is a catalog name, uuid, or path (., a subdirectory, a directory); a bare name resolves as a catalog name first, so ./x insists on a directory. Cwd caveat: see Running daft.
daft repo move <repo> <dest> [--name <name>] [--dry-run] Relocate a repository and everything keyed to its old path: git worktree linkage, trust grant, layout override, catalog entry, recorded worktree paths. <dest> follows mv semantics. Worktrees the layout places outside the repo directory move too (that is ALL of them under the default sibling layout); a worktree placed by hand stays put and is repaired in place. --name also renames the catalog entry; without it the name follows the directory when it was derived from it. Never use plain mv on a daft repo — it breaks all of the above and reports nothing. Cwd caveat: see Running daft.
daft repo rename <repo> <new-name> Rename a repository's catalog entry. Metadata only — nothing on disk moves. Same operation as daft repo add --name. Distinct from daft rename, which renames a WORKTREE and its branch.
daft shell-init <shell> Generate shell integration wrappers (auto-cd)
daft completions <shell> Generate shell tab completions

For ad-hoc commands across worktrees use daft exec; for named tasks committed in daft.yml (dev servers, compose stacks) use daft run; for recurring lifecycle automation use daft.yml hooks.

Repo Catalog and the Graph

daft keeps a machine-local repo catalog — every repo it touches registers automatically (clone, init, or any daft command run inside it). Names derive from the remote URL; collisions auto-suffix (api, api-2).

Command Description
daft repo add [<path>] [--name <name>] Explicitly register a repo (only needed for repos daft never touched) or rename the current entry. Explicit --name collisions error; automatic registration auto-suffixes.
daft repo list [--all] [--worktrees] List cataloged repos (name, worktree count, path, remote). --all includes removed entries; --worktrees expands each repo into a tree; --columns +size/+layout/+branch adds columns; --format json adds default branch.
daft repo info [<repo>] One entry in full: status, path, remote, default branch, layout, worktrees, resolved relations. Accepts a name, uuid, or path — ., a subdirectory, or any worktree resolves to its enclosing repo; --format json adds identity plumbing.
daft repo link <target> [--name <label>] [--kind <kind>] Declare a relation from the current repo to <target> (catalog name, repo path, or remote URL — uncloned URLs allowed): writes a deduped entry to the worktree's daft.yml. Re-linking is a no-op; --name/--kind update in place; self-links are refused.
daft repo unlink <target> Remove a relation from the current worktree's daft.yml, matched by label first, then resolved URL. A missing edge is a no-op (exit 0).
daft go <repo> Jump to another cataloged repo's default-branch worktree, or to its root directory when that branch does not exist. Local resolution wins: a branch named like a repo shadows it (use --repo). A catalog match beats daft.go.autoStart. Works outside any git repo.
daft go <repo> <branch> Open a branch's worktree in another repo (created on demand); daft go --repo <name> [-b <branch> [base]] is the explicit form. After a hop, daft go - returns to the source worktree.
daft start <repo> <branch> [base] Create a NEW branch in another cataloged repo, based on its default branch unless [base] is given. Local-first, first match wins: an existing local branch named <repo> keeps the local reading; a <branch> slot that already resolves to a ref here is read as a base (so daft start api release-2 creates local branch api); naming your own repo stays local. daft start --repo <name> <branch> is the explicit form that always crosses. The destination is announced before any work; the target's trust gates its hooks; -x runs there, -c is refused.
daft exec --repo <name> | --all-repos [...] Run commands in another repo's worktrees, or across every cataloged repo's default-branch worktree (rows labeled repo:branch).
daft exec --related [...] Run across the current repo and its relations, targeting each repo's worktree for the current branch; repos lacking it are skipped with a notice.
daft start <branch> --with-related Create the same branch in the current repo and every related repo (each from its own default branch). Related repos must be cloned; hooks run only where trusted; --carry/-x stay local. daft start <repo> <branch> --with-related roots the fan-out at that repo's manifest instead.
daft list|update|prune|doctor --repo <name> Run the command in another cataloged repo from anywhere; --all-repos sweeps every live entry. daft list <repo> is positional sugar (repo-only resolution — a miss is a hard error, never a branch fallback).
daft remove --repo <name> <branch...> Remove branches in another cataloged repo. Flag-only by design — the positional slot is a variadic list of branch names or paths, so daft remove api feature-x always means "remove both branches here" and is never reinterpreted as a repo. The destination is announced before any work; your shell is never relocated (the removal is elsewhere); no --all-repos. Passing a worktree path with --repo is an error — the path already names its repo.
daft hooks jobs --repo <name|path|uuid> Inspect another repo's hook-job history — including removed repos, whose logs stay addressable.
daft clone <name> Re-clone a cataloged (typically removed) repo from its recorded remote URL. daft repo remove tombstones the entry, so removal is reversible.

Cross-repo edges are committed in daft.yml under a top-level relations: key — url: required (matched against the catalog by normalized URL, so the manifest is portable), name:/kind: optional, edges directed. Manage them with daft repo link/daft repo unlink rather than hand-editing:

relations:
  - url: git@github.com:acme/api-client.git
    name: client
    kind: consumer

Post-setup command execution (-x/--exec)

daft clone, daft init, daft go, and daft start accept repeatable -x/--exec commands that run sequentially in the new worktree after hooks complete, stopping on first failure. Interactive programs work — stdio is fully inherited.

daft clone https://github.com/org/repo -x 'mise install'
daft start my-feature -x claude

Use -x for finite setup steps. To start a long-running process (a dev server, a compose stack, a watcher), define a task and run it on demand with daft run — see Tasks (daft run).

Anonymous Worktrees (sandboxes and forks)

Worktrees decoupled from branches: detached-HEAD checkouts with hooks run and environment set up, living exactly as long as their directory. Two commands create them, and choosing between them is a decision rule, not a preference:

  • Visit — daft go <commit-ish> when you need to look at a point in history (build an old release, inspect a tag, reproduce a PR's "before" state) and sharing is fine. Idempotent: the first visit materializes the canonical sandbox for that commit, every later visit — by any spelling — lands in the same worktree, environment warm.
  • Mint — daft start --fork [<base>] when you need a private worktree to run work in without colliding with anyone (or anything) else, or when you need several. Always fresh: run it twice, get two. Never matched by go's resolution — a fork is reachable only by its printed name.

The fork contract is built for agents: stdout is the created path (one per line under -n), narration is stderr, so capture is the whole integration:

wt=$(daft start --fork)                       # one private worktree at HEAD
wt=$(daft start origin/master --fork)         # ...at master's current position
daft start --fork -n 3 -x './rebuild.sh'      # three, each built, paths on stdout

Parallel agents each run their own --fork — names are claimed atomically, so concurrent invocations never collide and need no coordination. Do NOT share one worktree between parallel agents, and do NOT fall back to raw git worktree add --detach: it skips hooks and produces a half-configured checkout that cannot build.

Aftercare contract:

  • These worktrees have no branch and no upstream — never push from one.
  • Remove with daft remove <name> (the printed path's basename) when done. Wildcards sweep a fleet: daft remove 'main-fork*' (quote the pattern — daft expands it against sandbox names; it never matches branches, and a pattern matching nothing errors).
  • Commits made inside are safe while the worktree exists, but die with it: removal refuses when HEAD moved off the pinned commit. Two routes keep the work. Promote when it deserves a branch: daft start <new-branch> from inside the sandbox bases the new branch on the detached HEAD. Merge back when adopting it into the branch you forked from: a fork's HEAD is a legal merge source, spelled worktrees/<dirname>/HEAD — so daft merge worktrees/<dirname>/HEAD --into <branch> adopts one fork, several sources octopus-merge a fleet at once, and git cherry-pick <sha> adopts single commits. After either route the commits are reachable elsewhere, so daft remove <dirname> -f is safe — the -f acknowledges the moved pin, which removal cannot verify on its own.
  • Sandboxes show in daft list under their directory name with a dim ○; prune and sync skip them.

Naming: forks follow daft.start.forkNaming — derived (default: <source>-fork, -fork-2, …) or memorable (brave-otter). Visit sandboxes name themselves after stable spellings (v1.18.0, origin-master) and after a commit-hex prefix for positional spellings like HEAD~2.

Merging Across Worktrees (daft merge)

daft merge performs git merge without forcing you to git switch into the target branch: land a feature into main while staying in your worktree, octopus-merge several sources, or script merges (-y auto-accepts prompts).

daft merge feature/api --no-edit                      # into the current worktree's branch
daft merge feature/api --into main --no-edit          # into another worktree; shell stays put
daft merge feat/a feat/b --into main --no-edit        # octopus: several sources, one commit
daft merge --squash feature/api --no-edit             # squash all source commits into one
daft merge --rebase feature/api --into main           # rebase source, then fast-forward (linear)
daft merge --rebase-merge feature/api --into main --no-edit  # rebase, then merge commit
daft merge -s ours --into release feature/old --no-edit      # git merge strategy flags
daft merge feature/done --into main -r --no-edit      # remove source worktree + branch on success
daft merge feature/done --into main -r --squash --set-default --no-edit  # persist style + cleanup
daft merge feature/hotfix --into release/1.2 --adopt-target --no-edit    # ephemeral target worktree
daft merge --continue|--abort|--quit [<worktree>]     # finish or bail out

Pitfalls to communicate to the user:

  • Default style is always-merge-commit (never fast-forward, unlike plain git merge). Use --rebase for linear history. Always pass --no-edit in CI or non-TTY contexts to avoid an editor prompt.
  • --squash commits by default, opening an editor pre-populated with the squash message. --no-edit uses it verbatim, -m supplies your own, --no-commit stages without committing (incompatible with -r). Without a TTY and without --no-edit/-m, daft refuses before merging.
  • The target must be clean (daft.merge.requireCleanTarget, default true). Commit, stash, or daft carry <target> the changes first.
  • Conflicts do not hijack the shell: daft reports the conflicted files and the exact command to finish. Resolve in the target worktree, git add, then daft merge --continue [<target>]; bail with daft merge --abort [<target>].
  • Squash-staged state: closing the squash editor without saving leaves the changes staged. daft merge --continue re-opens the editor; daft merge --abort resets the index.
  • Octopus aborts on conflict — multi-source merges are all-or-nothing.
  • -r removes both worktree and branch. Regular merges use safe git branch -d semantics; squash uses force-delete backed by daft's content-equivalence proof. If the source branch moved during the editor session, cleanup is refused with a hint.
  • Ephemeral targets: when the target branch has no worktree, daft prompts; --adopt-target accepts, --no-adopt-target refuses, and daft.merge.adoptTargetOnDemand (prompt/yes/no) sets the default.

pre-merge and post-merge hooks fire around the merge with DAFT_MERGE_* env vars (see Hook Types below).

Merge Gate Policy

A repo can commit a merge quality boundary in daft.yml — enforced natively by daft merge, before pre-merge hooks fire and re-verified when the ref moves (so the tree the hooks tested is the tree that lands):

merge:
  ff: only # refuse merges that cannot fast-forward
  source_worktree: clean # source worktree must exist and be clean

The gated workflow is rebase-first: rebase the track onto the target, let the pre-merge rings run, then daft merge (now fast-forward-equivalent). With pre-merge hooks configured, octopus merges are refused — one track at a time. Flags mirror the config: --ff-only / --source-worktree clean supply the policy on unconfigured repos; --no-ff-only / --source-worktree any relax a committed policy for one invocation (announced). If a merge is refused with "advanced while the merge gate ran", the track moved mid-gate — re-run the merge. These refusals are policy, not errors to work around; do not retry with relax flags unless the user explicitly decides to override team policy.

Hooks System (daft.yml)

Hooks automate worktree lifecycle events, configured in a daft.yml file at the repository root.

Hook Types

Hook Trigger Runs From
post-clone After daft clone New default branch worktree
worktree-pre-create Before new worktree is added Source worktree
worktree-post-create After new worktree is created New worktree
worktree-pre-remove Before worktree is removed Worktree being removed
worktree-post-remove After worktree is removed Current worktree
pre-merge After pre-flight checks, before the merge runs Target worktree
post-merge After the merge completes (success or conflict) Target worktree

worktree-pre-remove/worktree-post-remove also fire when daft merge -r cleans up a merged source worktree; there DAFT_COMMAND=merge (not branch-delete), so scripts can tell merge cleanup from a standalone daft remove.

During daft clone, post-clone fires first (one-time repo bootstrap), then worktree-post-create (per-worktree setup) — so post-clone can install foundational tools the per-worktree hooks depend on.

A failing worktree-post-create aborts the creation command by default: the command exits non-zero and skips its -x/--exec commands, but the new worktree stays on disk. Fix the cause, then daft hooks run worktree-post-create from inside that worktree to finish setup — do not treat the worktree as missing. git config daft.hooks.worktreePostCreate.failMode warn opts back into continue-on-failure; a repo can also commit fail_mode: warn (or abort) on a hook in daft.yml to ship that default to every clone, with the git config taking precedence over the committed value.

Every hook job has a time limit: the job's timeout:, else its hook's timeout:, else git config daft.hooks.timeout, else 5 minutes. Values are seconds (300) or a unit (40m, 2h); off/0 lifts the limit. A job that outruns it is torn down with its whole process tree and fails (exit code 124, "Job 'x' timed out after 40m") under the hook's fail mode — so give a slow gate (a full test suite in pre-merge) a timeout: rather than --skip-hooks. timeout: has no effect on interactive jobs or tasks.

pre-merge aborts the merge on failure; post-merge warns but never rolls back. Both expose DAFT_MERGE_* env vars: SOURCES, TARGET_BRANCH, TARGET_PATH, MODE (merge/ff/squash/octopus), STRATEGY, EPHEMERAL, CROSS_WORKTREE, SOURCE_SHAS (source tips captured before the merge). post-merge adds RESULT (success/conflict/already-up-to-date/aborted), COMMIT_SHA, CONFLICTED_FILES, PROMOTED_FROM_EPHEMERAL. RESULT=aborted fires when a squash commit is abandoned (editor closed without saving, pre-commit hook fail, GPG-sign fail) and COMMIT_SHA is then empty. Neither hook fires on a no-op merge (already up to date).

daft.yml Format

min_version: "1.5.0" # Optional: minimum daft version
env: # Optional: derived per-worktree values (see `daft env`)
  salt: myapp # Pin for identical values across machines
  ports:
    - WEBAPP_PORT # offset 0; bare names auto-increment
    - API_PORT: 8 # explicit offset
  values:
    COMPOSE_PROJECT_NAME: "myapp-{worktree_slug}"
hooks:
  worktree-post-create:
    parallel: true # Run jobs concurrently (default)
    timeout: 10m # Optional: per-job time limit for this hook (default 5m)
    jobs:
      - name: install-deps
        run: npm install
      - name: setup-env
        run: cp .env.example .env

Keep env.ports append-only (inserting mid-list renumbers later offsets). Top-level env: declares derived values; a job-level env: is a literal map; skip:/only: env: is a truthiness predicate — nesting depth disambiguates.

Config File Locations (first match wins)

daft.yml, daft.yaml, .daft.yml, .daft.yaml, .config/daft.yml, .config/daft.yaml. Additionally: daft.local.yml for machine-specific overrides (not committed) and per-hook files like worktree-post-create.yml. The deprecated name daft-local.yml still works for one release cycle but warns (and doctor flags it); prefer daft.local.yml.

Execution Modes

Set one per hook (default is parallel):

Mode Field Behavior
Parallel parallel: true All jobs run concurrently
Piped piped: true Sequential; stop on first failure
Follow follow: true Sequential; continue on failure

Job Fields

- name: job-name # Display name and dependency reference
  description: "Install npm dependencies" # Human-readable description
  run: "npm install" # Inline command (or use script: "setup.sh")
  runner: "bash" # Interpreter for script files
  root: "frontend" # Working directory relative to worktree
  env: # Extra environment variables
    NODE_ENV: development
  tags: ["build"] # Tags for filtering
  skip: CI # Skip when $CI is set
  only: DEPLOY_ENABLED # Only run when $DEPLOY_ENABLED is set
  arch: x86_64 # Target arch: x86_64, aarch64 (or list). No `os:` field —
  # make run: an OS-keyed map (macos/linux/windows) to target an OS; a job
  # with no entry for the current one is skipped.
  needs: [install-npm] # Wait for these jobs to complete first
  tracks: [path, branch] # Worktree attributes this job depends on (move hooks)
  interactive: true # Needs TTY (forces sequential)
  priority: 1 # Lower runs first
  fail_text: "Setup failed" # Custom failure message
  background: true # Run in the background (non-blocking)
  background_output: log # "log" (default) or "silent"
  log: # Log configuration
    retention: "7d" # How long to keep logs
    path: "./logs/job.log" # Custom log path (absolute or relative)
  timeout: 40m # Time limit for this job (seconds or s/m/h/d; off = none)

Job Dependencies

hooks:
  worktree-post-create:
    jobs:
      - name: install-npm
        run: npm install
      - name: install-pip
        run: pip install -r requirements.txt
      - name: build
        run: npm run build
        needs: [install-npm]

Independent jobs run in parallel; dependent jobs wait for their dependencies.

Background Jobs

Jobs with background: true run asynchronously after the command returns, so the user can start working while long-running tasks complete. A coordinator process manages them and writes output to log files.

  • Background jobs participate in the DAG: a foreground job depending on a background job promotes it to foreground automatically.
  • needs: between background jobs is honored — the coordinator schedules them in topological wave order.
  • If a dependency fails or is cancelled, the dependent job is recorded as Skipped in daft hooks jobs listings.
  • background: true at the hook level sets the default for all its jobs.
  • --hooks <auto|foreground|background|off> picks how a run's hook phase executes, on every command that fires hooks: go, start, clone, merge. foreground runs background jobs inline and waits (CI, debugging); a promoted job's failure then fails the hook, which for worktree-post-create aborts the command. background detaches the whole phase, but only where that changes nothing but timing: it declines for a phase daft still acts on (every pre-* gate, post-clone, and post-merge when the merge used an ephemeral worktree or --remove-branch) and for a phase declaring an execution order background jobs cannot preserve (parallel: false, piped:, follow: — express it with needs: instead). A declined phase runs inline and daft says so. off is exactly --skip-hooks all, and is the only mode that also affects legacy .daft/hooks/* scripts. All of it is orthogonal to --skip-hooks, which picks which jobs run, so the two compose. DAFT_NO_BACKGROUND_JOBS=1 promotes for commands without the flag. Promoted jobs keep their job timeout.
  • daft hooks jobs lists, cancels, retries, and prunes records; removing a worktree cancels its running background jobs.

When generating daft.yml, mark jobs background: true when they warm caches, pre-build, or do other work whose results are not needed immediately.

Groups

A job can contain a nested group with its own execution mode:

- name: checks
  group:
    parallel: true
    jobs:
      - name: lint
        run: cargo clippy
      - name: format
        run: cargo fmt --check

Template Variables

Available in job run/script commands and in job env: values, for lifecycle hooks and daft run tasks alike:

Variable Description
{branch} Target branch name
{worktree_path} Path to the target worktree
{worktree_root} Project root directory
{worktree_slug} Sanitized worktree name ([a-z0-9-])
{source_worktree} Path to the source worktree
{git_dir} Path to the .git directory
{remote} Remote name (usually origin)
{job_name} Name of the current job
{base_branch} Base branch (branch-creating commands)
{repository_url} Repository URL (post-clone)
{default_branch} Default branch name (post-clone)
{old_worktree_path} Previous worktree path (move hooks only)
{old_branch} Previous branch name (move hooks only)
{merge_source_path} Merge hooks: source worktree path (single worktree-backed source); legal in root: to run rings in the source worktree, fail-closed when unresolvable
{merge_target_path} Merge hooks: target worktree path
{changed_files} File-aware jobs: the filtered changed-file list, shell-quoted

{worktree_slug} is the worktree's name relative to the project root, lowercased and reduced to [a-z0-9-] (max 63 chars) — safe for docker compose project names, DB schema names, and temp dirs. Keyed off the worktree, not the branch, so it is unique per worktree. Use it to make per-worktree names collision-free: COMPOSE_PROJECT_NAME: "api-{worktree_slug}".

Skip and Only Conditions

skip: CI # Skip when env var is truthy
skip: true # Always skip
skip:
  - merge # Skip during merge
  - rebase # Skip during rebase
  - ref: "release/*" # Skip if branch matches glob
  - env: SKIP_HOOKS # Skip if env var is truthy
  - run: "test -f .skip-hooks" # Skip if command exits 0
    desc: "Skip file exists" # Human-readable reason
  - changed: "docs/**" # Skip if a changed file matches (merge hooks)

only:
  - env: DEPLOY_ENABLED # Only run when env var is set
  - ref: "main" # Only run on main branch
  - changed: "src/**" # Only run when a matching file changed

Changed-File Job Filters (glob)

A job can gate itself on what the operation changed. For merge hooks the changed set is the files the sources changed relative to the target (target...source); other hook types need a files: command.

hooks:
  pre-merge:
    jobs:
      - name: build-check
        glob: ["src/**", "Cargo.*"] # run only when these changed
        run: cargo check --all-targets
      - name: lint-changed
        glob: "*.{js,ts}"
        exclude: ["web/generated/**"] # exclude wins over glob
        run: eslint {changed_files} # expands to the filtered list

Patterns match repository-root-relative paths (doublestar rules: ** spans zero or more directories, * stops at /; root: is ignored). When nothing matches, the job is skipped with a recorded reason — a docs-only merge skips the build ring. glob:/exclude:/{changed_files} on a hook type with no changed set and no files: command is a loud configuration error.

Trust Management

Hooks from untrusted repos do not run automatically. Manage trust with:

daft hooks trust        # Allow hooks to run
daft hooks prompt       # Prompt before each execution
daft hooks deny         # Never run hooks (default)
daft hooks status       # Check current trust level
daft hooks install      # Scaffold a daft.yml with placeholders
daft hooks validate     # Validate configuration syntax
daft hooks dump         # Show fully merged configuration
daft hooks run <type>   # Manually run a hook (bypasses trust)

When a command skips hooks because the repo is untrusted, it prints one plain stderr notice — e.g. Untrusted repo — 2 daft.yml hooks not run: worktree-pre-create, worktree-post-create — naming the skipped hooks and suggesting daft hooks trust. Each skip is recorded; a later daft hooks trust lists precise replay commands (daft hooks run post-clone, daft hooks run worktree-post-create) for the worktrees whose setup never ran — run them inside each listed worktree. If you see that notice, trusting and replaying is the way to get the worktree into its fully set-up state.

Manual Hook Execution

Run hooks on demand, bypassing trust (the user is explicitly invoking):

daft hooks run worktree-post-create              # Run all jobs
daft hooks run worktree-post-create --job "mise" # Run a single job
daft hooks run worktree-post-create --tag setup  # Run jobs tagged "setup"
daft hooks run worktree-post-create --dry-run    # Preview without executing
daft hooks run worktree-post-create --verbose    # Show skipped jobs + reasons

Use cases: re-running after a failure, iterating during hook development, bootstrapping worktrees that predate the hooks config.

Post-hoc navigation: a failed hook prints an inspect breadcrumb (daft hooks jobs --last --hook <type>). daft hooks jobs --last [N] shows the newest invocation(s), --failed narrows to failing ones (sugar for --status failed; --status values are validated at parse time), failed jobs show their last output lines inline under the listing, and daft hooks jobs logs <job> resolves a bare job name to its newest invocation anywhere in the repo. On an interactive terminal, a gated daft merge renders its pre/post-merge hooks as live rail sections (same v verbose toggle as exec/run) instead of hiding them behind the spinner. Non-interactively, prefer daft merge --format json: it reports the gate's verdict, why it refused, and the job rows in one document instead of prose you would have to parse — see Machine-Readable Output.

Skipping Hooks Per-Invocation (--skip-hooks)

The worktree-creating commands (daft start, daft go, daft clone) and daft merge accept --skip-hooks to exclude jobs for one run (repeatable or comma-separated):

daft start feat/x --skip-hooks all           # skip every hook
daft start feat/x --skip-hooks tag:heavy,lint # skip tagged + named jobs
daft clone <url> --skip-hooks post-clone     # clone, run worktree hooks only
daft merge feat/x --skip-tag deep --no-edit  # fast gate pass (tag sugar)

--skip-tag <TAG> (on merge, run, and hooks run) is sugar for --skip-hooks tag:<TAG>; --only-tag <TAG> is the include side (alias of --tag on run/hooks run). On daft merge the selection filters hook JOBS only — the committed gate policy checks always run.

Selectors: all/*, <hook> (a whole hook by its canonical daft.yml key, e.g. worktree-post-create), tag:<tag>, <name> (a job), job:<name> (explicit escape hatch). A bare token resolves wildcard → hook type → job name; tags need the tag: prefix. Naming a hook the command never fires is a silent no-op.

Key behavior — the downstream cascade: skipping a job also skips every job that needs: it (transitively); upstream dependencies are untouched. Excluded jobs are reported as skipped with a reason, never dropped silently; a selector matching nothing warns and the run proceeds. --skip-hooks is the exclusion counterpart to daft hooks run --job/--tag. --skip-hooks all cannot be combined with --trust-hooks; partial skips can.

Git pre-push Hooks on daft Pushes

Separate from daft's own hooks: every daft-initiated git push honors the repo's git-level pre-push hook (native .git/hooks or core.hooksPath managers like lefthook/husky/pre-commit), reported as a pre-push phase. A failing hook blocks the push and the command exits non-zero — any worktree it created is still completed and usable, and the error names the recovery command. Exceptions — pushes that provably carry no content skip the hook by default (daft.pushVerify: auto default, always, never): the automatic upstream push on daft start/daft go -b runs the hook only when it introduces new commits, and remote-branch deletes (daft remove with remote deletion on, daft rename's old-name cleanup, daft merge's post-merge cleanup, daft multi-remote move --delete-old) skip it outright since a delete pushes zero objects. Set daft.pushVerify always when pre-push hooks enforce ref policy (e.g. protected-branch delete guards); daft.checkout.pushVerify overrides the base for the upstream push alone. Note that never is unconditional, unlike auto/always which decide per push: setting daft.pushVerify never to quiet deletes also disarms the hook on an upstream push that does carry commits, so re-arm it with daft.checkout.pushVerify auto. Pass --no-verify to the pushing command to skip the hook once — every pushing command accepts it except daft merge, whose cleanup delete is governed by daft.pushVerify alone. --skip-hooks does NOT affect git-level hooks — it only filters daft's own jobs.

To push a branch from outside its worktree with the hook still running in the RIGHT tree, use daft push <branch>: it resolves the branch's worktree and runs the push (and therefore the shared hook) from there. Plain git push would run the hook in whatever worktree you happen to be in.

Parallel sync --push hook runs are memory-governed (#678): a governor caps concurrent hook-bearing pushes (default max(2, cores/4); --jobs N overrides, --no-throttle disables), learns each hook's peak memory across runs, throttles admissions under memory pressure (rows show held: memory), and under sustained pressure freezes then kills-and-retries the newest push rather than let the machine swap. Each push unit also gets a wall-clock budget (daft.sync.pushTimeout, default 30m) so a hung hook cannot wedge the sync. daft.sync.pushHookStrategy batched pushes every branch in one git push so the hook fires once with all refs (one refusal fails the whole batch).

The same governor covers parallel hook/task job phases (post-create fan-outs, a merge gate's parallel rings): admission caps the fan-out, a shared jobserver bounds intra-job build parallelism, and foreground jobs are never frozen or killed. Gated merges additionally serialize per repository through a cross-process lane — a second daft merge prints waiting for the merge gate lane — held by ... and proceeds when the first finishes; that wait is expected coordination, not a hang.

Move Hooks

When a worktree moves (daft rename, daft layout transform), daft replays identity-tracked hooks to tear down the old environment and set up the new one: worktree-pre-remove + worktree-post-remove (old identity) → move on disk → worktree-pre-create + worktree-post-create (new identity). Only tracked jobs run.

- name: link-output
  run: ln -sf {worktree_path}/dist /opt/builds/current
  tracks: [path] # Re-runs when the worktree path changes

- name: install-deps
  run: npm install
  # No tracks -- skipped during moves

If tracks is omitted, daft infers it from template usage ({worktree_path} implies path; {branch} implies branch); explicit tracks overrides. Jobs listed in a tracked job's needs are pulled into the move even if untracked. Hook failures during moves warn but never block — the move always completes. Move-only template variables {old_worktree_path}/{old_branch} and env vars DAFT_IS_MOVE, DAFT_OLD_WORKTREE_PATH, DAFT_OLD_BRANCH_NAME are available.

Environment Variables in Hooks

All hooks receive: DAFT_HOOK, DAFT_COMMAND, DAFT_PROJECT_ROOT, DAFT_GIT_DIR, DAFT_REMOTE, DAFT_SOURCE_WORKTREE. Worktree hooks add DAFT_WORKTREE_PATH, DAFT_BRANCH_NAME; creation hooks DAFT_IS_NEW_BRANCH, DAFT_BASE_BRANCH; clone hooks DAFT_REPOSITORY_URL, DAFT_DEFAULT_BRANCH; removal hooks DAFT_REMOVAL_REASON (remote-deleted, manual); move hooks DAFT_IS_MOVE, DAFT_OLD_WORKTREE_PATH, DAFT_OLD_BRANCH_NAME.

For anonymous sandbox worktrees DAFT_BRANCH_NAME is the empty string (the contract is "empty means no branch") and DAFT_COMMIT carries the pinned commit OID; the {worktree_slug} template variable works unchanged and is the right per-worktree handle for hooks serving both kinds.

Warm Worktrees (copy: and daft warm)

A new worktree starts with no node_modules/, no target/, no build output at all. A top-level copy: section in daft.yml (a sibling of hooks:) names the gitignored paths daft replicates from the source worktree into each new one, so worktrees start warm:

copy:
  - target/
  - node_modules/
  - "**/dist/"

The map form adds knobs:

copy:
  paths: [target/, node_modules/]
  fallback: copy # copy | skip — what to do when the filesystem cannot reflink
  max_size: 5GB # per-ENTRY cap; gates the byte-copy fallback only
  • The source is ranked against what the new worktree will hold: the base branch's own worktree first (daft start feat master copies master's caches from wherever you typed it; with no base named the base is the branch you are on), then any worktree sitting at the identical commit — sandboxes and forks included — then the worktree you ran from. Ties at the identical commit go to where you are standing, then to the warmest. Two candidates never win: a stale entry git worktree list still reports after its directory was deleted, and an identical-commit match carrying none of the declared caches (it loses to a source that has them, but only ever to a warmer one). The rail's section anchor names the winner (copied paths from 'master'). The copy: config, unlike the bytes, is read from the worktree you are standing in — a visitor's untracked overlay lives there — so a standing worktree whose merged config lacks the key plans nothing.
  • On a copy-on-write filesystem (APFS, btrfs, XFS reflink=1, OpenZFS 2.2+, bcachefs, ReFS) the replica is near-free until the copies diverge. Elsewhere fallback: decides: copy (default) pays for a real byte copy, skip leaves the entry out. max_size (5GB, 500MB, or a plain byte count, quoted or not) caps that byte copy per entry and never applies to a reflink; daft hooks validate rejects one it cannot parse, and rejects a map form that declares no paths:.
  • Entries are worktree-root-relative, may be files or directories, and may use glob metacharacters (*, ?, [), expanded against the source worktree. An absolute path, a .. component, or a path naming the worktree itself is refused.
  • Entries must be gitignored — git check-ignore must pass and nothing under the entry may be tracked. A violation is a per-entry warning, not an error. The check asks the source only: copying into a branch whose .gitignore lacks the entry leaves it as untracked content in that worktree's git status, so warn users whose ignore rules differ across branches.
  • Special files (unix sockets, FIFOs, device nodes) are silently omitted from copies; regular files, directories, and — on Unix — symlinks are reproduced.
  • daft start --fork -n N runs the stage once per fork: N near-free clones on a reflinking filesystem, but N real byte copies without one under fallback: copy.
  • The stage runs before shared: symlinking and before worktree-post-create hooks, so a hook-driven npm install / cargo build hits a warm cache. Caches first, daft-managed links on top: linking creates the parent directories it needs, so running it first made a shared: path inside a copied cache manufacture an empty scaffold the copy then skipped as already present.
  • It never aborts creation. Every failure — tracked entry, unreadable source, full disk — is a warning row and the worktree is still created.
  • It never overwrites. An entry already present at the destination is skipped, so the stage is idempotent. A destination of the wrong shape is reported rather than skipped silently; only daft warm --force replaces it, and even then not when it is a symlink resolving outside the worktree.
  • daft clone does not run it: a fresh clone has no source worktree to copy from. The first build belongs in a post-clone or worktree-post-create hook; worktrees branched off afterwards inherit it.
  • copy: is read through the full config merge, so daft.local.yml and extends: files can declare or override it. An overlay replaces the key wholesale — paths and knobs together, never element-wise.
  • A machine can opt out: git config --global daft.copy.enabled false (or per-repo, without --global) drops the stage from worktree creation entirely — for filesystems where a real byte copy costs more than the package manager would. It does not disable daft warm; running that command is the opt-in. If a worktree comes up cold and copy: is declared, check this key before reporting a bug.

daft warm replays the same declarations on demand — for a worktree created before the copy: key existed, or after building something expensive that other worktrees should have:

daft warm                 # warm the current worktree
daft warm feature-x       # warm that worktree
daft warm --from main     # name the source explicitly
daft warm --force         # replace entries that already exist in the target
daft warm -v              # add the engine's per-entry narration

Naming no target warms the current worktree; naming one warms that one. --from names the source outright and is never second-guessed; without it the source is ranked the same way creation ranks it, anchored on the commit the target already sits at — a worktree holding that exact commit first, then where you stand, then the default branch's worktree. Both slots (the target and --from) take a worktree directory name, a branch name, or a path under the project root. The result line names both resolved ends at ordinary verbosity (Copied 1 of 2 declared paths (1 KB) into 'develop' from 'main') — do not tell users they need -v to see which pair ran; -v only adds engine narration.

It targets one worktree per run; to spread a fresh cache across several, run it per worktree or drive it with daft exec.

--force is the only destructive form, and it still refuses: content the target worktree tracks is never replaced, the bare container is refused as either end, and nothing is removed until the copy is actually going to proceed — so a refusal never lands over a cache already deleted.

What to tell users about expectations: a copied cache is a head start, not a guarantee, because toolchains embed absolute paths to differing degrees. Expect cargo's registry dependencies to survive the move and workspace-local crates to rebuild; expect pnpm's symlink-heavy node_modules/ to copy far more cheaply than a flat npm/yarn tree (on Unix — daft does not replicate symlinks on Windows, where such an entry fails, is cleaned up, and retries next run). Do not suggest copying .venv/ — a virtualenv records its own absolute path in pyvenv.cfg, bin/ shebangs, and bin/activate; share the uv/pip cache and rebuild it instead, or create it with uv venv --relocatable first. The source tree is read live and is not quiesced, so copying while a build writes into the source yields a torn snapshot — re-run the build or daft warm --force.

Tasks (daft run)

Tasks are named, user-invoked job groups — the serve on demand half of the workflow. They live under a top-level tasks: section in daft.yml (a sibling of hooks:) and run only when asked:

hooks:
  worktree-post-create:
    jobs:
      - name: install
        run: pnpm install # provision: finite, idempotent, unattended

tasks:
  run: # reserved default — bare `daft run`
    parallel: true
    jobs:
      - name: backend
        run: docker compose up
        env:
          COMPOSE_PROJECT_NAME: "api-{worktree_slug}"
      - name: web
        run: pnpm dev
        root: frontend
  seed-db: # `daft run seed-db`
    jobs:
      - name: seed
        run: ./scripts/seed.sh
  • daft run — runs the reserved task named run
  • daft run <name> — runs any task
  • daft run <name> <args>... — words after the task name are shell-escaped and appended to the task's command (requires the task to resolve to a single foreground job; narrow multi-job tasks with --job)
  • daft run <args>... — a first word naming no task forwards every word to the reserved run task (an unknown first word errors only when no run task exists to receive it); daft run -- <args>... forces forwarding past the name match
  • Everything after the first word passes through verbatim, flags included — daft run's own flags go before the task name
  • daft run --list — lists the tasks with job counts
  • daft run --job <name> / --tag <tag> — run a subset of the task's jobs

Tasks reuse the full job schema — parallel/piped/follow, needs, groups, env, root, skip/only, interactive, background. Differences from lifecycle hooks:

  • No execution timeout. A task runs until it exits or is cancelled, and a timeout: on a task has no effect (hook jobs are bounded) — the right home for dev servers and watchers.
  • Foreground and attended. A single-job task passes the terminal straight through — the job's raw output, no wrapper. A multi-job task renders one live row per job with the logs threaded beneath. Ctrl+C cancels the job tree gracefully, a second Ctrl+C force-kills. There is no detach flag; jobs marked background: true still dispatch to the coordinator as usual (visible in daft hooks jobs).
  • Trust. An explicit daft run counts as consent: it executes even in an untrusted repo, printing a daft hooks trust hint (same rule as daft hooks run).
  • Task jobs receive DAFT_TASK=<name> in place of DAFT_HOOK, with DAFT_COMMAND=run.

Positioning: daft exec runs an ad-hoc command you type on the spot; daft run runs a named task committed in daft.yml (npm's exec/run split). Prefer a task over hand-launching a server when one is defined.

Environment Tools and Suggested Automation

When working in a daft repo, detect environment tools by their marker files and suggest daft.yml hooks that automate setup for new worktrees.

For per-worktree ports and derived names (compose project names, DB names), suggest a declared env: section rather than hand-rolled hashing scripts — see the daft env row in Management and the env: key in the daft.yml Format section. Shell integration is one .envrc line: eval "$(daft env --export)".

Tool Marker Files Purpose
mise .mise.toml, .tool-versions Polyglot runtime manager (Node, Python, Ruby, Go, etc.)
direnv .envrc Per-directory environment variables
nvm / fnm .nvmrc, .node-version Node.js version management
pyenv .python-version Python version management
rbenv .ruby-version Ruby version management
Docker Dockerfile, docker-compose.yml, compose.yml Containerized development
npm package.json, package-lock.json Node.js dependencies
yarn yarn.lock Node.js dependencies (Yarn)
pnpm pnpm-lock.yaml Node.js dependencies (pnpm)
pip requirements.txt, pyproject.toml, setup.py Python dependencies
Cargo Cargo.toml, Cargo.lock Rust dependencies
Go modules go.mod, go.sum Go dependencies
Bundler Gemfile, Gemfile.lock Ruby dependencies
Composer composer.json, composer.lock PHP dependencies

A combined example for a project using mise, direnv, and npm — post-clone runs once at clone, worktree-post-create for every new worktree:

hooks:
  post-clone:
    piped: true
    jobs:
      - name: mise-install
        run: mise install
      - name: direnv-allow
        run: direnv allow .
      - name: setup-env
        run: cp .env.example .env

  worktree-post-create:
    jobs:
      - name: mise-install
        run: mise install
      - name: direnv-allow
        run: direnv allow .
      - name: npm-install
        run: npm install
        needs: [mise-install]

Most tools are one-liners (mise install, direnv allow ., npm install, cargo build, go mod download). Two need care:

# nvm is a shell function -- source it first
- name: nvm-use
  run: |
    export NVM_DIR="$HOME/.nvm"
    [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
    nvm install

# Python: create the venv before installing into it
- name: pip-install
  run: |
    python -m venv .venv
    . .venv/bin/activate
    pip install -r requirements.txt

When a daft-managed repo has no daft.yml, check for dependency markers and suggest creating one — a worktree-post-create hook that installs dependencies is the highest-impact automation. After suggesting a daft.yml, remind the user to trust the repo: daft hooks trust.

The same markers usually imply a copy: section too — Cargo.toml implies target/, a lockfile implies node_modules/. Suggest both together: the hook does the work, and copy: makes each new worktree start from a warm cache instead of an empty one. See Warm Worktrees above.

Workflow Guidance for Agents

When working in a daft-managed repository, apply these translations:

User intent Correct daft approach
"Create a branch" daft start <name> — creates branch + worktree
"Branch from main" daft start <name> main — branches from the specified base
"Create a branch in another repo" daft start <repo> <name> — creates it there; shell lands in the worktree
"Switch to branch X" Navigate to the worktree directory: cd ../X/
"Go back" daft go - — toggles to the previous worktree
"Check out a PR" daft go pr:<number> — fork-aware, via gh/glab; also mr:<number> or a pasted PR/MR URL
"Delete a branch" daft remove <branch> — removes worktree + local branch
"Clean up branches" daft prune — removes worktrees for deleted, merged remote branches; never-published branches are left alone
"Wrong branch" daft carry <correct-branch> — moves uncommitted changes
"Update from remote" daft update — updates current or specified worktrees (source:dest too)
"Merge my branch" daft merge <branch> --into main --no-edit
"Run my build on these worktrees" daft exec feat/a feat/b -- <cmd> or daft exec --all -- <cmd>
"Start the dev server / stack" daft run — runs the reserved run task from daft.yml, if one exists
"Adopt existing repo" daft layout transform contained — restructures a plain clone into daft's layout, carrying its working tree

Per-worktree Isolation

Each worktree has its own node_modules/, .venv/, target/, etc. A new worktree created without daft.yml hooks has nothing installed — if the user hits missing-dependency errors there, run the appropriate install command in that worktree (npm install, pip install -r requirements.txt, ...).

Runtime resources deconflict the same way: if the repo declares an env: section in daft.yml, every worktree has its own deterministic port block and derived names. Read them with daft env (daft env WEBAPP_PORT for one raw value) instead of hardcoding ports; commands daft spawns (hooks, tasks, daft exec) already have them in the environment. Cross-repo work uses the address form: daft env backend:API_PORT answers for the backend repo's worktree matching this one's name — even before that worktree exists.

Navigating Worktrees

From any worktree, sibling worktrees are at ../<branch-name>/ and the project root is at ... Use git rev-parse --git-common-dir to find the root programmatically.

Modifying Shared Files

Tracked files (.gitignore, CI config) live in each worktree independently; changes propagate only by committing and merging. daft.yml is different when it is untracked — a visitor configuration — in which case daft itself propagates it:

  • Three states: tracked / visitor (untracked) / missing. Every untracked daft file daft writes is recorded as that worktree's seed; the on-disk copy is later classified as pristine (untouched), refined (edited — real user data), or no-seed (treated like refined).
  • Branch-out: creating a worktree copies in-scope untracked daft files from the source worktree before worktree-post-create hooks fire.
  • daft merge: pristine source copies are skipped; a refined source is merged three-way (seed as base) into the target before the git merge, atomically, announcing adopted keys. Keys changed on both sides prompt interactively; non-interactive runs abort pointing at daft file merge.
  • Removal (daft remove): pristine copies delete silently; refined copies stop the removal — interactively offering consolidate / discard / abort; non-interactively refusing with a daft file merge suggestion. Forcing (-f) discards to <git-common-dir>/.daft/discarded/<branch>/.
  • daft prune / daft sync never prompt: pristine copies prune cleanly, refined ones keep their worktree with an end-of-run summary pointing at daft file merge; --force discards to the stash.
  • The default-branch worktree's config is only written by an announced merge consolidation or an explicit daft file merge — never by a removal path. Doctor surfaces each config's classification as informational.

daft file merge — consolidate configs

daft file merge <TARGET> <SOURCE> (or daft file merge <SOURCE>) consolidates one daft file into another. With seed provenance the merge is three-way: only genuine refinements move, a key-level preview prints first, the target is backed up to <git-common-dir>/.daft/backups/file-merge/, and conflicting keys need a side (interactive prompt, -y for source-wins, or a non-zero abort listing the keys). Without provenance, a legacy two-way merge applies (source wins). The source file is deleted afterward unless --keep-source. Use it to consolidate visitor configs before removing a worktree or to promote one to a team baseline.

daft repo install — bootstrap a config

daft repo install writes a starter daft.yml (commented skeleton: hooks:, shared:, copy:, layout:) at the worktree root. It is repo-aware: from a worktree subdir it targets the worktree root; at the bare container root of a contained layout it installs across the repo's worktrees (never a stray file at the inert container root); it refuses only outside a git repository. If a daft.yml already exists it reports whether that file is tracked (team baseline) or a visitor config and stops cleanly (exit 0). It then offers to add /daft.yml to .git/info/exclude (local, never committed) so a visitor config stays private: prompted on a TTY (default No), --git-exclude adds it without prompting, non-interactive runs print a copy-pasteable hint. It never touches the tracked .gitignore. daft install is a top-level alias for the same command.

Machine-Readable Output

Commands emitting structured output via --format: flat-list (list, hooks jobs, hooks trust list, layout list, repo list, config list), document (release-notes, repo info, config get, config set, config unset), matrix (shared status), sectioned (merge, multi-remote status, hooks run listing mode). Valid formats: json, ndjson, tsv, csv, yaml, toon, markdown. --template '<tera-template>' renders custom output ({{ var }}, {% for %}, {% if %}). Document and sectioned payloads support only json, yaml, toon, markdown — the row formats have no rows to fill.

daft merge --format json (start mode only; the finish modes reject it) returns sections verdict, sources, conflicts (only when conflicted), and jobs (the gate's per-job rows). Branch on verdict[0].status: landed, up-to-date, squash-staged exit 0; refused (gate policy stopped it, repo untouched — verdict[0].refusal names which policy), gate-failed (a check came back red; read the jobs section), conflicted, commit-aborted, and failed exit 1. A merge that landed but whose cleanup was refused reports landed with cleanup: "refused" and still exits 1. pre_merge_invocation joins to daft hooks jobs --last --hook pre-merge --format json for the full job detail and logs. The flag never changes the exit code — checking $? alone stays valid.

daft config output contract: --local and --global name one layer, and the same layer whether reading or writing — get --local prints what set --local would replace, which for a daft.yml setting is the daft.local.yml overlay and for the layout is the repository's own entry. A read narrowed to a layer exits 1 when that layer says nothing, so it answers "is this set here?" rather than "what does it resolve to?". Two properties let a script skip flag bookkeeping: a read's document always carries layers, diagnostics, and writable_scopes (so --origin is a human affordance, never required), and --format never changes the exit code — a silent layer emits its document and still exits 1. value is the asked-for layer's own when narrowed, effective is always the resolved one, and outranked_by names any higher layer in the way. A write's record carries written[] (key, value, changed, file) plus store — which is not always the scope's name, since a daft.yml setting's "global" is the repository's committed file — and, for a behavior, the state it ended in. changed: false marks an unset that found nothing. Reading a behavior at one layer works only when that layer sets every member (which is what a behavior write does); a partial layer exits 1 rather than reporting the nearest state.

daft start --fork has its own fixed stdout contract, no --format needed: the created worktree path(s), bare, one per line (everything else on stderr). wt=$(daft start --fork) captures it directly.

daft list output contract: table columns show branch (✦ marks the default branch), path (relative to cwd), base ahead/behind, file status (!N conflicted, +N staged, -N unstaged, ?N untracked), remote status (⇡N unpushed, ⇣N unpulled), branch age, owner, and commit info. When user.email is configured, output splits into two sections (your branches / other branches) per the resolved owner. --format json fields include is_default_branch, staged, unstaged, untracked, conflicted, operation, identity_source, remote_ahead, remote_behind, branch_age, owner_name, owner_email; the owner field is {name, email} or null.

Paused operations and detached HEAD: a worktree keeps its branch name while git has HEAD detached to run an operation — mid-rebase the row still names the branch and keeps its branch-keyed fields, rather than reporting (detached). operation names the paused operation (rebase, merge, cherry-pick, revert, bisect, am, or null), conflicted counts unresolved files (never double-counted as staged and unstaged), and identity_source says how the name was established: attached (checked out), recovered (read from the paused operation), persisted (daft's record of what the worktree was created for), or none. is_sandbox is true only for a detached checkout that no operation explains — so a rebasing worktree is not a sandbox. The status column (--columns +status) renders the same state as words (rebasing · 2 conflicts, rebasing · resolved, detached @ <sha>, drifted). daft list --merging filters to worktrees mid-merge only, not any operation.

daft hooks jobs output: a flat table with one row per job carrying invocation context (invocation_id, invocation_short, worktree, hook_type, trigger_command, invocation_created_at) plus size_bytes for the job's output.log.

Column selection (--columns) on list/sync/prune: default columns annotation, branch, path, base, changes, remote, age, owner, last-commit; optional size (adds a total footer) and hash. daft list also offers status (paused operation and conflict state in words), which sorts before branch; it is list-only, since sync and prune pin their own task-progress column of that name. On all three, pr is also a default (#N/!N for PR/MR checkouts and for local branches with an open or merged PR) — but only in repos with a GitHub/GitLab remote, and daft silently drops it while the forge integration is broken in a way needing user action (gh/glab missing or unauthenticated; it returns after a successful refresh — do not treat the column's absence as an error). --columns +pr forces it regardless. While the pr column shows, daft list also adds a row for every open PR the table doesn't already represent (sync/prune show the column on their worktree rows without adding rows): PR-bearing local branches appear without -b ("kind": "branch" in json), and PRs with no local presence — colleagues' branches, fork PRs — appear as rows built from forge data ("kind": "pr"; fork rows named owner:branch; the PR title in the commit-subject field). Merged/closed PRs decorate rows but never add one. Branch and PR rows report the PR author as their owner; worktree rows keep the locally deduced owner (name and email). Expect these extra rows when parsing default list output — filter by kind, or pass --columns -pr, which removes the rows and the column as one unit, for a worktrees-only listing. In piped/NO_COLOR output — what agents read — the PR's cached fate trails as a glyph: ✓/✗/● CI pass/fail/running, ◆ merged, ○ closed; in a color terminal the number's color carries the same states instead (green/red/yellow, purple merged, dim closed). The cache refreshes in the background via daft update/daft sync and on listing with the column; prefer --format json (pr_state, ci_status, pr_url fields — present in the default schema even when the table hides the column) over parsing glyphs. Two modes — replace (--columns name,path,age: exactly those, in order) and modifier (--columns +size,-age: adjust defaults; auto-detected when every entry starts with +/-). The status column is always pinned on sync/prune. Persistent defaults: git config daft.<cmd>.columns.

Sorting (--sort) on list/sync/prune: columns name, path, size, age, owner, hash, activity, commit; prefix + ascending (default) / - descending; comma-separate for multi-level (--sort +owner,-size). activity counts committed and uncommitted changes; commit (alias last-commit) only commit time. Sorting by hidden columns works. Persistent defaults: git config daft.<cmd>.sort.

Caches: daft list writes content-addressed JSON caches under <git-common-dir>/.daft/cache/ — safe to delete at any time; daft re-populates. Changes/Size cells always recompute and show the · skeleton glyph until the result arrives.

Configuration Reference

Local-first defaults: daft does not contact the remote by default. Remote operations are opt-in via daft config set remote-sync on (or the individual keys below); --local on any command suppresses remote operations for a single invocation.

Key Default Description
daft.autocd true CD into new worktrees via shell wrappers
daft.remote "origin" Default remote name
daft.checkout.fetch false Fetch from remote before checking out an existing branch
daft.checkout.push false Push new branches to remote after creation
daft.pushVerify "auto" Base: run git pre-push hooks on daft's suppressible pushes — remote-branch deletes and the upstream push (auto, always, never; never is unconditional)
daft.checkout.pushVerify daft.pushVerify Checkout-scoped override of daft.pushVerify for the automatic upstream push
daft.branchDelete.remote false Delete the remote branch when removing a local branch
daft.checkout.upstream true Set upstream tracking
daft.checkout.carry false Carry uncommitted changes on checkout
daft.checkoutBranch.carry true Carry uncommitted changes on branch creation
daft.copy.enabled true Run the copy: stage when creating a worktree. false skips it on this machine; an explicit daft warm still copies
daft.update.args "--ff-only" Default pull arguments for update (same-branch mode)
daft.prune.cdTarget "root" Where to cd after pruning (root or default-branch)
daft.go.autoStart false Auto-create worktree when branch not found in daft go (an existing tag/commit still wins: it opens a sandbox instead)
daft.start.forkNaming "derived" How daft start --fork names sandboxes: derived (<source>-fork, -fork-2, …) or memorable (adjective-noun handles)
daft.forge.platform (detected) Forge platform for pr:/mr: checkout (github, gitlab); unset detects from the remote URL
daft.forge.githubCli "gh" GitHub CLI binary used for PR resolution (Enterprise wrappers)
daft.forge.gitlabCli "glab" GitLab CLI binary used for MR resolution
daft.forge.hostname (CLI default) Self-hosted / Enterprise forge hostname, passed to the CLI as --hostname
daft.merge.requireCleanTarget true Refuse to merge into a target worktree with uncommitted changes
daft.merge.adoptTargetOnDemand "prompt" Ephemeral-target behavior for daft merge (prompt, yes, no)
daft.hooks.enabled true Master switch for hooks
daft.hooks.defaultTrust "deny" Default trust for unknown repos
daft.hooks.timeout 5m Time limit for each hook job (seconds or 40m/2h; off/0 = none); a timeout: in daft.yml overrides it
daft.<cmd>.stat "summary" Statistics mode (summary or lines) for list/sync/prune
daft.<cmd>.columns (all columns) Default columns for list/sync/prune (same syntax as --columns)
daft.list.sizeConcurrency (CPU count) Max concurrent directory-size walks for --columns +size (both daft list and daft repo list); lower on slow/network filesystems (env DAFT_SIZE_WALK_JOBS overrides).
daft.<cmd>.sort "+branch" Default sort order for list/sync/prune (same syntax as --sort)
daft.ownership.strategy "recency-plurality" Branch-ownership strategy: tip, any, first, plurality, majority, recency-plurality
daft.sync.pushTimeout "30m" Wall-clock budget per sync push unit (git + pre-push hook); off disables
daft.sync.pushHookStrategy "per-branch" Pre-push hook cadence for sync pushes (per-branch or batched)
daft.governor.mode "auto" Memory-aware governor for parallel pre-push hooks (auto or off)
daft.governor.jobs "auto" Cap on concurrent hook-bearing pushes (auto = max(2, cores/4), or a number)
daft.governor.memoryReserve "auto" Memory headroom the governor keeps free (auto = max(10% RAM, 2G), a size, or NN%)
daft.governor.jobserver "auto" Shared POSIX jobserver export to hooks (auto or off)

Branch ownership scopes the two-section split in daft list and limits daft sync --rebase/--push to branches you own (matching user.email); --include <email>/--include unowned overrides. Ownership is deduced from the base..branch commit range by the configured strategy — the default recency-plurality weights each commit by recency, staying robust to drive-by commits.