Skip to content

Lifecycle hooks

This page is a complete reference for the lifecycle hook types that ship today: clone setup, worktree create/remove, and merge gates. For commit-stage hooks (the lefthook drop-in), see the roadmap.

For the conceptual framing, see the Hooks Overview.

For the YAML schema, see YAML reference.

Hook types

HookTriggerRuns From
post-cloneAfter daft clone completesNew default branch worktree
worktree-pre-createBefore new worktree is addedSource worktree (where command runs)
worktree-post-createAfter new worktree is createdNew worktree
worktree-pre-removeBefore worktree is removedWorktree being removed
worktree-post-removeAfter worktree is removedCurrent worktree (where prune runs)
pre-mergeAfter pre-flight checks pass, before the merge runsTarget worktree
post-mergeAfter the merge operation completes (success/conflict/aborted)Target worktree

Execution order during clone

When running daft clone, hooks fire in this order:

  1. post-clone -- one-time repo bootstrap (install toolchains, global setup)
  2. worktree-post-create -- per-worktree setup (install dependencies, configure environment)

This lets post-clone install foundational tools (pnpm, bun, uv, etc.) that worktree-post-create may depend on.

Environment provided to hooks

Hooks receive context via environment variables. These are available to both YAML jobs and shell script hooks.

To debug what a job will see without running it, ask from your shell: daft env lists the variables that have a value at rest, and daft env DAFT_BRANCH_NAME prints one. Seven qualify — the ones a worktree determines on its own:

DAFT_PROJECT_ROOT, DAFT_GIT_DIR, DAFT_REMOTE, DAFT_SOURCE_WORKTREE, DAFT_WORKTREE_PATH, DAFT_BRANCH_NAME, DAFT_IS_NEW_BRANCH.

Every other variable on this page is event-scoped: it exists only while its operation runs, so asking for one explains which operation sets it rather than printing a value. Two of the seven differ for hooks in ways the command cannot know in advance — DAFT_REMOTE is the triggering operation's remote (daft.remote, when configured), and for create and remove hooks DAFT_SOURCE_WORKTREE is the worktree the command was invoked from, not the one being created or removed.

Universal (all hooks)

VariableDescription
DAFT_HOOKHook type (e.g., worktree-post-create)
DAFT_COMMANDCommand that triggered the hook (e.g., checkout). Note: checkout is used for both checkout and checkout -b modes
DAFT_PROJECT_ROOTRepository root (parent of .git directory)
DAFT_GIT_DIRPath to the .git directory
DAFT_REMOTERemote name (usually origin)
DAFT_SOURCE_WORKTREEWorktree where the command was invoked

Worktree (creation and removal hooks)

VariableDescription
DAFT_WORKTREE_PATHPath to the target worktree
DAFT_BRANCH_NAMEBranch name for the target worktree — the empty string for an anonymous sandbox
DAFT_COMMITPinned commit OID (anonymous sandbox worktrees only)

Creation (create hooks only)

VariableDescription
DAFT_IS_NEW_BRANCHtrue if the branch was newly created, false otherwise
DAFT_BASE_BRANCHBase branch (for checkout -b commands)

Clone (post-clone only)

VariableDescription
DAFT_REPOSITORY_URLThe cloned repository URL
DAFT_DEFAULT_BRANCHThe remote's default branch

Removal (remove hooks only)

VariableDescription
DAFT_REMOVAL_REASONWhy the worktree is being removed: remote-deleted or manual

Merge (both merge hooks)

VariableValue
DAFT_MERGE_SOURCESSpace-separated list of source refs (branches/commits being merged)
DAFT_MERGE_SOURCE_SHASNewline-separated commit SHA per source, captured before any merge work began (an annotated tag is peeled to its commit)
DAFT_MERGE_SOURCE_PATHSNewline-separated worktree path per source, aligned with DAFT_MERGE_SOURCE_SHAS; a worktree-less source contributes an empty line
DAFT_MERGE_SOURCE_PATHThe single source's worktree path when there is exactly one source and it has a worktree; empty otherwise
DAFT_MERGE_TARGET_BRANCHName of the branch being merged into
DAFT_MERGE_TARGET_PATHFilesystem path of the target worktree (empty on ref-only FF)
DAFT_MERGE_TARGET_SHAThe target branch's tip as of gate start (pinned; post-merge hooks inherit the pre-merge value)
DAFT_MERGE_MODEmerge / ff / squash / rebase / rebase-merge / octopus
DAFT_MERGE_STRATEGYValue of -s/--strategy (empty when not set)
DAFT_MERGE_EPHEMERALtrue if the merge runs in an ephemeral worktree; otherwise false
DAFT_MERGE_CROSS_WORKTREEtrue if the target worktree is not the current worktree

Merge result (post-merge only)

VariableValue
DAFT_MERGE_RESULTsuccess / conflict / already-up-to-date / aborted
DAFT_MERGE_COMMIT_SHASHA of the new tip on success (empty otherwise, including when aborted)
DAFT_MERGE_CONFLICTED_FILESNewline-separated list of conflicted files (empty when not conflicted)
DAFT_MERGE_PROMOTED_FROM_EPHEMERALtrue when a ref-only ephemeral merge was promoted to a sibling path

Move (move hooks only)

These variables are set when hooks run as part of a worktree move (rename or layout transform). They are available in all four move phases.

VariableDescription
DAFT_IS_MOVEtrue when running as part of a move operation
DAFT_OLD_WORKTREE_PATHWorktree path before the move
DAFT_OLD_BRANCH_NAMEBranch name before the move (rename only)

Exit-code semantics

Each hook type has a default fail mode that determines what happens when a hook exits with a non-zero status:

HookDefault Fail ModeBehavior
worktree-pre-createabortOperation is cancelled
worktree-post-createabortCreation command exits non-zero and skips its -x/--exec commands; the worktree stays on disk
pre-mergeabortMerge is aborted
All otherswarnWarning is shown, operation continues

A failed worktree-post-create aborts because the -x/--exec commands (and anything else scripted after the creation command) almost always depend on what the hook just set up — installed dependencies, a written .env, a build. Continuing would run them against a half-set-up worktree and exit 0. The worktree itself is deliberately kept so you can inspect or repair it; the error names the path and how to re-run the hook.

A worktree-pre-create hook is a gate: it runs before git worktree add, so a non-zero exit cancels creation and nothing is left on disk. Downgrading it to warn runs the gate but ignores its veto — the worktree is created anyway.

Override per-hook:

bash
# Let creation commands continue past a failing post-create hook
# (runs -x, warns, exits 0)
git config daft.hooks.worktreePostCreate.failMode warn

# Create the worktree even when the pre-create gate fails
# (creates it anyway, warns, exits 0)
git config daft.hooks.worktreePreCreate.failMode warn

A repo can also commit a hook's default fail mode in daft.yml via fail_mode: (abort or warn), shipping it to every clone; the git config above still takes precedence.

Hook failures during moves produce warnings, not errors. The move operation (rename, transform) always completes. This prevents a broken hook from leaving the worktree in a half-moved state.

Merge hooks

daft merge fires pre-merge and post-merge around the merge operation, giving scripts a chance to gate merges on custom preconditions or react to the outcome — the PR-check-parity boundary of the boundaries thesis.

When they fire

  • pre-merge runs after all pre-flight safety rails (distinct-source check, clean-target check, in-progress-merge detection, already-up-to-date short-circuit) pass, but before any merge operation touches state. It fires uniformly for all merge styles and paths: worktree-backed merges, ref-only merges, rebase-style merges, and ephemeral worktree merges.
  • post-merge runs after the merge operation completes, whether it succeeded, hit a conflict, or resolved without changes.

Both hooks read their config from the target worktree (the branch being merged into). Neither fires when the merge is a no-op because the target is already up to date.

Failure semantics

  • A pre-merge hook that exits non-zero aborts the merge with that exit code. No merge operation runs; no state is touched. The default fail mode is abort.
  • A post-merge hook that exits non-zero is logged as a warning but does not roll back the merge. The default fail mode is warn.

The pre-merge fail mode can be downgraded per-repo:

bash
# Downgrade to a warning — the merge proceeds even when pre-merge fails
git config daft.hooks.preMerge.failMode warn

# Restore the default abort behavior
git config --unset daft.hooks.preMerge.failMode

The same choice can be committed in daft.yml (hooks.pre-merge.fail_mode: warn) to ship it to every clone; a local git config override still takes precedence.

With failMode=warn, a failing pre-merge hook prints pre-merge hook failed with exit code N (continuing anyway) and the merge continues normally. This is useful for informational PR-check hooks that should never block a merge while still surfacing failures.

DAFT_MERGE_RESULT=aborted fires when a squash-commit step is abandoned: the editor was opened, the user wrote no commit message (empty buffer), and the squash merge was discarded. post-merge still runs so cleanup logic can respond to the abort.

Hooks vs jobs

daft.yml lets a single hook fire multiple jobs in parallel or sequenced. The hook is the trigger; the job is the unit of work. See Job orchestration for parallelism, dependencies, and conditions.

Released under MIT or Apache-2.0.