Skip to content

Hooks

daft provides a hooks system that runs automation at worktree lifecycle events. Hooks are stored in the repository and shared with your team, with a trust-based security model.

The recommended approach is a YAML configuration file (daft.yml) that supports multiple jobs, parallel execution, dependencies, conditional skipping, and more. For simple cases, you can also use executable shell scripts in .daft/hooks/.

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)

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.

Trust Model

For security, hooks from untrusted repositories don't run automatically. Trust is managed per-repository.

When a repository is trusted, daft stores the remote URL as a fingerprint. If the remote URL changes later (for example, a different repository is cloned to the same path), trust is automatically downgraded to prompt with a warning. Run git daft hooks trust to re-trust the repository. Trust entries created before this feature continue to work without verification.

Trust Levels

LevelBehavior
deny (default)Hooks are never executed
promptUser is prompted before each hook execution
allowHooks run without prompting

Managing Trust

bash
# Trust the current repository
git daft hooks trust

# Prompt before running hooks
git daft hooks prompt

# Revoke trust (sets explicit deny entry)
git daft hooks deny

# Remove trust entry (returns to default deny, no record kept)
git daft hooks trust reset

# Check current status
git daft hooks status

# List all trusted repositories
git daft hooks trust list

# Prune stale entries from the trust database
git daft hooks trust prune

# Clear all trust settings
git daft hooks trust reset all

Quick Start

  1. Scaffold a configuration file:

    bash
    git daft hooks install

    This creates a daft.yml at your worktree root with placeholder jobs for all hook types.

  2. Edit daft.yml with your actual commands:

    yaml
    hooks:
      worktree-post-create:
        jobs:
          - name: install-deps
            run: npm install
          - name: setup-env
            run: cp .env.example .env
  3. Trust the repository so hooks can run:

    bash
    git daft hooks trust
  4. Validate your configuration:

    bash
    git daft hooks validate

That's it. The next time a worktree is created, your hooks will run automatically.

YAML Configuration

Config File Locations

daft searches for configuration files in the following order (first match wins):

FileLocation
daft.ymlRepo root
daft.yamlRepo root
.daft.ymlRepo root (hidden)
.daft.yamlRepo root (hidden)
.config/daft.ymlXDG-style config directory
.config/daft.yamlXDG-style config directory

Additionally:

  • Local overrides (daft-local.yml) — same directory as the main config, not committed to git. Useful for machine-specific settings.
  • Per-hook files (worktree-post-create.yml, post-clone.yml, etc.) — same directory as the main config. Each file defines a single hook and is merged into the main config.

Top-Level Settings

FieldTypeDescription
min_versionstringMinimum daft version required (e.g., "1.5.0")
colorsboolEnable/disable colored output
no_ttyboolDisable TTY detection
rcstringShell RC file to source before running hooks
outputbool / listfalse to suppress all output, or list of hook names to show output for
extendslistAdditional config files to merge (e.g., ["shared.yml"])
source_dirstringDirectory for script files (default: ".daft")
source_dir_localstringDirectory for local (gitignored) script files (default: ".daft-local")
hooksmapHook definitions, keyed by hook name

Hook Definition

Each hook is defined under the hooks key:

yaml
hooks:
  worktree-post-create:
    parallel: true
    jobs:
      - name: install
        run: npm install
      - name: build
        run: npm run build
FieldTypeDefaultDescription
parallelbooltrueRun jobs in parallel
pipedboolRun jobs sequentially, stop on first failure
followboolRun jobs sequentially, continue on failure
exclude_tagslistTags to exclude at hook level
excludelistGlob patterns to exclude
skipbool / string / listSkip condition (see Skip and Only Conditions)
onlybool / string / listOnly condition (see Skip and Only Conditions)
jobslistJobs to execute

Jobs

Each job in the jobs list supports:

FieldTypeDescription
namestringJob name (used for display, merging, and dependency references)
descriptionstringHuman-readable description (shown in dry-run and completions)
runstringInline shell command to execute
scriptstringScript file to run (relative to source_dir)
runnerstringInterpreter for script files (e.g., "bash", "python")
argsstringArguments to pass to the script
rootstringWorking directory (relative to worktree root)
tagslistTags for filtering with exclude_tags
skipbool / string / listSkip condition
onlybool / string / listOnly condition
osstring / listTarget OS (macos, linux, windows); skips if no match
archstring / listTarget architecture (x86_64, aarch64); skips if no match
envmapExtra environment variables
fail_textstringCustom failure message
interactiveboolJob needs TTY/stdin (forces sequential execution)
priorityintExecution ordering (lower runs first)
needslistNames of jobs that must complete before this job runs
trackslistWorktree attributes this job depends on: path, branch (see Move Hooks)
groupobjectNested group of jobs (see Groups)

A job must have exactly one of run, script, or group.

Example: Job with description and platform constraint

yaml
- name: install-brew
  description: Install Homebrew package manager
  os: macos
  run:
    /bin/bash -c "$(curl -fsSL
    https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
  skip:
    - run: "command -v brew"
      desc: Brew is already installed

Example: Inline command

yaml
- name: lint
  run: cargo clippy -- -D warnings

Example: Script with runner

yaml
- name: setup
  script: setup.sh
  runner: bash
  args: --verbose

Example: Environment variables and failure text

yaml
- name: test
  run: npm test
  env:
    NODE_ENV: test
    CI: "true"
  fail_text: "Tests failed! Fix before continuing."

Execution Modes

Each hook runs its jobs in one of three modes. Only one can be set at a time.

ModeFieldBehavior
Parallelparallel: trueAll jobs run concurrently (default)
Pipedpiped: trueJobs run sequentially; stop on first failure
Followfollow: trueJobs run sequentially; continue even if one fails

Parallel (default)

yaml
hooks:
  worktree-post-create:
    parallel: true
    jobs:
      - name: install-npm
        run: npm install
      - name: install-pip
        run: pip install -r requirements.txt

Both jobs start at the same time.

Piped

yaml
hooks:
  worktree-post-create:
    piped: true
    jobs:
      - name: install
        run: npm install
      - name: build
        run: npm run build

build only runs if install succeeds.

Follow

yaml
hooks:
  worktree-post-create:
    follow: true
    jobs:
      - name: optional-lint
        run: npm run lint
      - name: required-build
        run: npm run build

required-build runs even if optional-lint fails.

Job Dependencies

For complex workflows where some jobs depend on others, use needs to declare dependencies. Jobs with needs wait for all their dependencies to complete before starting. Independent jobs still run in parallel.

yaml
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]
      - name: deploy
        run: ./deploy.sh
        needs: [build, install-pip]

In this example:

  • install-npm and install-pip start immediately (in parallel)
  • build starts after install-npm completes
  • deploy starts after both build and install-pip complete

Dependency rules

  • needs requires each job to have a name
  • Circular dependencies are rejected during validation
  • References to non-existent job names are rejected
  • If a dependency fails, all jobs that depend on it are marked as dep-failed and do not run
  • If a dependency is skipped, downstream jobs still run (skipped deps are considered satisfied)

Skip and Only Conditions

skip and only control whether a hook or job runs. They can be set at either the hook level or the job level.

  • skip: If any condition matches, the hook/job is skipped
  • only: All conditions must match for the hook/job to run

Three forms

Boolean — always skip or always run:

yaml
skip: true # Always skip this job
only: false # Never run this job

Environment variable — skip/run based on an env var being set and truthy:

yaml
skip: CI # Skip when $CI is set
only: DEPLOY_ENABLED # Only run when $DEPLOY_ENABLED is set

An env var is "truthy" if it is set, non-empty, not "0", and not "false".

Structured rules — a list of conditions:

yaml
skip:
  - merge # Named: skip during merge
  - rebase # Named: skip during rebase
  - ref: "release/*" # Ref: skip if branch matches glob
  - env: SKIP_HOOKS # Env: skip if env var is truthy
  - run: "test -f .skip-hooks" # Run: skip if command exits 0

Named conditions

NameTriggers when
mergeGit is in a merge state (MERGE_HEAD exists)
rebaseGit is in a rebase state (rebase-merge or rebase-apply exists)

Structured condition fields

FieldDescription
refGlob pattern matched against the current branch name
envEnvironment variable name; truthy = condition met
runShell command; exit code 0 = condition met
descHuman-readable reason shown when the condition triggers a skip

Hook-level vs job-level

yaml
hooks:
  worktree-post-create:
    skip:
      - merge # Skip ALL jobs in this hook during merge
    jobs:
      - name: lint
        run: cargo clippy
        skip: CI # Additionally skip this job when $CI is set
      - name: build
        run: cargo build

Groups

A job can contain a nested group of sub-jobs instead of a run or script. The group runs as a unit with its own execution mode.

yaml
hooks:
  worktree-post-create:
    piped: true
    jobs:
      - name: checks
        group:
          parallel: true
          jobs:
            - name: lint
              run: cargo clippy
            - name: format
              run: cargo fmt --check
      - name: build
        run: cargo build

In this example, lint and format run in parallel within the group. The outer hook uses piped mode, so build only starts after the entire checks group completes successfully.

Group fieldTypeDescription
parallelboolRun group jobs in parallel
pipedboolRun group jobs sequentially, stop on first failure
jobslistJobs within the group

Move Hooks

When a worktree is moved -- via rename (git worktree-branch -m), layout transform (daft layout transform), or adopt (daft worktree-flow-adopt) -- identity-sensitive hooks need to tear down the old environment and set up the new one. daft automates this with move hooks.

How it works

A move runs hooks in five steps (four hook phases plus the disk move):

  1. worktree-pre-remove -- teardown with the old worktree identity
  2. worktree-post-remove -- cleanup with the old worktree identity
  3. (worktree is moved on disk)
  4. worktree-pre-create -- setup with the new worktree identity
  5. worktree-post-create -- finalize with the new worktree identity

Only jobs that track the changed attributes participate. Other jobs are skipped because their output would not change.

The tracks field

The tracks field on a job declares which worktree attributes the job depends on:

ValueMeaning
pathJob output depends on the worktree path (e.g., symlinks)
branchJob output depends on the branch name (e.g., env variables)

You can track one or both:

yaml
hooks:
  worktree-post-create:
    jobs:
      - name: symlink-artifacts
        run: ln -sf {worktree_path}/dist /opt/builds/{branch}
        tracks: [path, branch]
      - name: register-env
        run: echo "BRANCH={branch}" >> /tmp/active-envs
        tracks: [branch]
      - name: install-deps
        run: npm install
        # No tracks -- this job does not depend on path or branch,
        # so it is skipped during moves.

Implicit tracking

If you omit the tracks field, daft infers tracking from template variable usage:

  • {worktree_path} in run or args implies tracks: [path]
  • {branch} or {worktree_branch} implies tracks: [branch]

A job that uses both variables tracks both attributes. A job that uses neither has no tracking and does not run during moves.

Explicit tracks always overrides implicit detection.

Dependency pull-in

When a tracked job has needs dependencies, those dependencies are included in the move even if they are not tracked themselves. This ensures the dependency chain is satisfied.

Failure handling

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

Example: path-tracked and branch-tracked jobs

yaml
hooks:
  worktree-post-create:
    jobs:
      - name: link-build-output
        description: Symlink build artifacts to a shared directory
        run: ln -sf {worktree_path}/dist /opt/project/builds/current
        tracks: [path]

      - name: set-branch-env
        description: Write branch name to local env file
        run: echo "CURRENT_BRANCH={branch}" > .env.branch
        tracks: [branch]

      - name: install-deps
        description: Install project dependencies
        run: npm install
        # Not tracked -- only runs on initial worktree creation

  worktree-pre-remove:
    jobs:
      - name: unlink-build-output
        run: rm -f /opt/project/builds/current
        tracks: [path]

      - name: clear-branch-env
        run: rm -f .env.branch
        tracks: [branch]

When this worktree is renamed, daft runs unlink-build-output and clear-branch-env with the old identity, moves the worktree, then runs link-build-output and set-branch-env with the new identity. The install-deps job is skipped because it has no tracking.

Template Variables

Commands (run) support template variables that are replaced with values from the execution context:

VariableDescription
{branch}Target branch name (alias for {worktree_branch})
{worktree_path}Path to the target worktree
{worktree_root}Project root directory
{worktree_branch}Target branch name
{source_worktree}Path to the source worktree (where command was invoked)
{git_dir}Path to the .git directory
{remote}Remote name (usually "origin")
{job_name}Name of the current job
{base_branch}Base branch name (for checkout -b commands)
{repository_url}Repository URL (for post-clone)
{default_branch}Default branch name (for post-clone)

Move hooks only (available when DAFT_IS_MOVE is true):

VariableDescription
{old_worktree_path}Previous worktree path (before the move)
{old_branch}Previous branch name (before the move, rename only)

Example

yaml
jobs:
  - name: log
    run: echo "Setting up worktree for {branch} at {worktree_path}"
  - name: diff
    run: git diff {base_branch}...{branch}

Config Merging

When multiple config sources exist, they are merged in this order (lowest to highest precedence):

  1. Main config (daft.yml)
  2. Extends files (listed in extends)
  3. Per-hook files (worktree-post-create.yml, etc.)
  4. Local override (daft-local.yml)

Merging rules:

  • Scalar fields (e.g., min_version, colors): higher-precedence value wins
  • Named jobs: jobs with the same name are replaced by the higher-precedence version
  • Unnamed jobs: appended from the overlay

Use git daft hooks dump to inspect the fully merged configuration:

bash
git daft hooks dump

Manual Hook Execution

Use git daft hooks run to manually trigger a hook outside the normal worktree lifecycle. Trust checks are bypassed since you are explicitly invoking the hook.

bash
# List all configured hooks
git daft hooks run

# Run all jobs in a hook
git daft hooks run worktree-post-create

# Run a single job by name
git daft hooks run worktree-post-create --job "mise install"

# Run only jobs with a specific tag
git daft hooks run worktree-post-create --tag setup

# Preview what would run without executing
git daft hooks run worktree-post-create --dry-run

# Show verbose output including skipped jobs
git daft hooks run worktree-post-create --verbose

This is useful for:

  • Re-running a hook after a previous failure
  • Iterating on hook scripts during development
  • Bootstrapping existing worktrees that predate the hooks config

When run from an untrusted repository, a hint is shown suggesting git daft hooks trust, but hooks still execute.

Shell Script Hooks

For simple automation, you can use executable scripts in .daft/hooks/ instead of (or in addition to) YAML configuration. Shell scripts run before YAML-configured jobs.

Writing a shell script hook

Hooks are executable scripts placed in .daft/hooks/ within your repository. They can be written in any language.

my-project/
├── .daft/
│   └── hooks/
│       ├── post-clone            # Runs after cloning the repo
│       ├── worktree-post-create  # Runs after creating a worktree
│       └── worktree-pre-remove   # Runs before removing a worktree
└── src/

Example: Auto-allow direnv

bash
#!/bin/bash
# .daft/hooks/worktree-post-create
if [ -f ".envrc" ] && command -v direnv &>/dev/null; then
    direnv allow .
fi

Example: Install dependencies

bash
#!/bin/bash
# .daft/hooks/worktree-post-create
if [ -f "package.json" ]; then
    npm install
elif [ -f "Gemfile" ]; then
    bundle install
elif [ -f "requirements.txt" ]; then
    pip install -r requirements.txt
fi

Example: Use correct Node version

bash
#!/bin/bash
# .daft/hooks/worktree-post-create
if [ -f ".nvmrc" ] && command -v nvm &>/dev/null; then
    nvm use
fi

Make hooks executable:

bash
chmod +x .daft/hooks/worktree-post-create

Environment Variables

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

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

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, manual, or ejecting

Move (move hooks only)

These variables are set when hooks run as part of a worktree move (rename, layout transform, or adopt). 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)

Fail Modes

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
All otherswarnWarning is shown, operation continues

Override per-hook:

bash
# Make post-create hooks abort on failure
git config daft.hooks.worktreePostCreate.failMode abort

# Make pre-create hooks just warn
git config daft.hooks.worktreePreCreate.failMode warn

User-Global Hooks

Place hooks in ~/.config/daft/hooks/ to run them for all repositories. Global hooks run after project hooks.

Customize the directory:

bash
git config --global daft.hooks.userDirectory ~/my-daft-hooks

Configuration

KeyDefaultDescription
daft.hooks.enabledtrueMaster switch for all hooks
daft.hooks.defaultTrustdenyDefault trust level for unknown repos
daft.hooks.userDirectory~/.config/daft/hooks/Path to user-global hooks
daft.hooks.timeout300Hook execution timeout in seconds
daft.hooks.<hookName>.enabledtrueEnable/disable a specific hook type
daft.hooks.<hookName>.failModevariesabort or warn on hook failure
daft.hooks.output.quietfalseSuppress hook stdout/stderr
daft.hooks.output.verbosefalseShow skipped jobs with reasons
daft.hooks.output.timerDelay5Seconds before showing elapsed timer
daft.hooks.output.tailLines6Rolling output lines per job

Hook name config keys use camelCase: postClone, worktreePreCreate, worktreePostCreate, worktreePreRemove, worktreePostRemove.

Output Display

When hooks run, daft shows real-time progress with spinners and rolling output windows. Each job gets a spinner that animates while it runs, and the last few lines of output are shown beneath it.

When a job takes longer than the configured timer delay (default 5 seconds), an elapsed timer appears next to the spinner. When a job finishes, its full output scrolls into the terminal history and the spinner is replaced with a check mark or cross.

In non-interactive environments (CI, pipes), spinners are disabled and output is printed as plain text.

bash
# Suppress all hook output (only show spinner and result)
git config daft.hooks.output.quiet true

# Show elapsed timer after 3 seconds instead of 5
git config daft.hooks.output.timerDelay 3

# Show 10 lines of rolling output per job instead of 6
git config daft.hooks.output.tailLines 10

# Disable rolling output window (only show spinner)
git config daft.hooks.output.tailLines 0

Migration from Deprecated Names

In earlier versions, worktree hooks used shorter names (pre-create, post-create, pre-remove, post-remove). These were renamed with a worktree- prefix for clarity.

Old names still work with deprecation warnings until v2.0.0. To migrate:

bash
git daft hooks migrate

This renames hook files in the current worktree from old names to new names.

Released under the MIT License.