[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.ymllifecycle 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 composestacks, watchers — belong in tasks, started explicitly withdaft 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-dirfrom any worktree finds the shared Git directorydaft layout showreports 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.
- Re-discover the real surface:
daft --help, thendaft <command> --help. - 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 thedaftbinary (comparedaft --versionwithdaft_versionin this file's frontmatter). - Proceed with the syntax
--helpreports.
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 bygo'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, spelledworktrees/<dirname>/HEAD— sodaft merge worktrees/<dirname>/HEAD --into <branch>adopts one fork, several sources octopus-merge a fleet at once, andgit cherry-pick <sha>adopts single commits. After either route the commits are reachable elsewhere, sodaft remove <dirname> -fis safe — the-facknowledges the moved pin, which removal cannot verify on its own. - Sandboxes show in
daft listunder their directory name with a dim○;pruneandsyncskip 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--rebasefor linear history. Always pass--no-editin CI or non-TTY contexts to avoid an editor prompt. --squashcommits by default, opening an editor pre-populated with the squash message.--no-edituses it verbatim,-msupplies your own,--no-commitstages 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, ordaft 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, thendaft merge --continue [<target>]; bail withdaft merge --abort [<target>]. - Squash-staged state: closing the squash editor without saving leaves the
changes staged.
daft merge --continuere-opens the editor;daft merge --abortresets the index. - Octopus aborts on conflict — multi-source merges are all-or-nothing.
-rremoves both worktree and branch. Regular merges use safegit branch -dsemantics; 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-targetaccepts,--no-adopt-targetrefuses, anddaft.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
Skippedindaft hooks jobslistings. background: trueat 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.foregroundruns background jobs inline and waits (CI, debugging); a promoted job's failure then fails the hook, which forworktree-post-createaborts the command.backgrounddetaches the whole phase, but only where that changes nothing but timing: it declines for a phase daft still acts on (everypre-*gate,post-clone, andpost-mergewhen 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 withneeds:instead). A declined phase runs inline and daft says so.offis 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=1promotes for commands without the flag. Promoted jobs keep their job timeout.daft hooks jobslists, 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 mastercopies 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 entrygit worktree liststill 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'). Thecopy: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. Elsewherefallback:decides:copy(default) pays for a real byte copy,skipleaves 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 validaterejects one it cannot parse, and rejects a map form that declares nopaths:. - 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-ignoremust 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.gitignorelacks the entry leaves it as untracked content in that worktree'sgit 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 Nruns the stage once per fork: N near-free clones on a reflinking filesystem, but N real byte copies without one underfallback: copy.- The stage runs before
shared:symlinking and beforeworktree-post-createhooks, so a hook-drivennpm install/cargo buildhits a warm cache. Caches first, daft-managed links on top: linking creates the parent directories it needs, so running it first made ashared:path inside a copied cache manufacture an empty scaffold the copy then skipped asalready 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 --forcereplaces it, and even then not when it is a symlink resolving outside the worktree. daft clonedoes not run it: a fresh clone has no source worktree to copy from. The first build belongs in apost-cloneorworktree-post-createhook; worktrees branched off afterwards inherit it.copy:is read through the full config merge, sodaft.local.ymlandextends: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 disabledaft warm; running that command is the opt-in. If a worktree comes up cold andcopy: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 namedrundaft run <name>— runs any taskdaft 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 reservedruntask (an unknown first word errors only when noruntask 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 countsdaft 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: truestill dispatch to the coordinator as usual (visible indaft hooks jobs). - Trust. An explicit
daft runcounts as consent: it executes even in an untrusted repo, printing adaft hooks trusthint (same rule asdaft hooks run). - Task jobs receive
DAFT_TASK=<name>in place ofDAFT_HOOK, withDAFT_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-createhooks 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 atdaft file merge.- Removal (
daft remove): pristine copies delete silently; refined copies stop the removal — interactively offering consolidate / discard / abort; non-interactively refusing with adaft file mergesuggestion. Forcing (-f) discards to<git-common-dir>/.daft/discarded/<branch>/. daft prune/daft syncnever prompt: pristine copies prune cleanly, refined ones keep their worktree with an end-of-run summary pointing atdaft file merge;--forcediscards 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.