Update Hooks

June 11, 2026 · View on GitHub

Update hooks allow components of your dotfiles (or external tools integrated with dotfiler) to participate in the update lifecycle. A hook can check whether its component has upstream changes available, and if so, apply them as part of the normal dotfiler update run.

The zdot shell configuration manager ships a hook (dotfiler-hook.zsh) as the reference implementation.


Hook Lifecycle

dotfiler runs two rounds of update per dotfiler update invocation. All plan state (_dotfiler_plan_* and _dotfiler_hint_range_* variables) is fully reset between rounds so no Round 1 state bleeds into Round 2.

Round 1 (dotfiles-driven): the framework resolves a hint range for each registered hook from the incoming dotfiles commit range, then invokes each hook's plan/pull/unpack/post with --phase=dotfiles.

Round 2 (self-directed): each hook checks its own remote independently, invoked with --phase=components. The framework emits Checking for component updates beyond dotfiles... at the start of this round.

When dotfiler runs an update check or applies an update, it invokes each registered hook through five phases:

PhaseFunctionPurpose
checkcheck_fnIs an update available? Return 0=yes, 1=no
planplan_fnCompute what will change (dry-run safe)
pullpull_fnFetch/pull from remote
unpackunpack_fnApply changes (symlinks, post-processing)
postpost_fnPost-update actions (reload shell, etc.)

Not all phases need to be implemented. Pass empty strings for phases you don't need.

Messaging Ownership

In Round 1 (--phase=dotfiles), the framework emits the per-hook status line (up to date or file counts) after calling the plan function — hooks do not need to emit this themselves.

In Round 2 (--phase=components), hooks own all their own messaging. The framework is silent. A hook should emit a Checking <name>... line at the start of its plan function, and <name>: up to date or <name>: pulling... / <name>: updated from its pull function.

The framework never emits pulling... — that is always the hook's responsibility.

Phase Ordering

All registered hooks participate in each phase together before the next phase starts. The order within a phase is main dotfiles first, then hooks in registration order. This is critical:

  • pull for main dotfiles runs before any hook's pull — every repo is pulled before any unpack begins
  • unpack for main dotfiles runs before any hook's unpack — if a hook's new code lives inside the dotfiles repo, it will be symlinked to its linktree destination (and therefore up-to-date) before dotfiler executes it

This design prevents a hook from ever running against partially-updated code that arrived via the dotfiles pull but hasn't yet been unpacked.

See how-updates-work.md for full phase sequencing details.


Hook File Structure

A hook is a .zsh file placed in the hooks directory (default: $XDG_CONFIG_HOME/dotfiler/hooks/, configurable via zstyle ':dotfiler:hooks' dir /path/to/hooks).

dotfiler sources each *.zsh file in that directory and expects it to call _update_register_hook to register itself.

# ~/.config/dotfiler/hooks/my-component.zsh

_update_register_hook \
    "my-component" \          # unique name
    "_my_check_fn" \          # check phase function name
    "_my_plan_fn" \           # plan phase function name
    "_my_pull_fn" \           # pull phase function name
    "_my_unpack_fn" \         # unpack phase function name
    "_my_post_fn" \           # post phase function name
    "_my_cleanup_fn" \        # cleanup function (unsets your functions)
    "/path/to/component" \    # component directory
    "submodule"               # topology hint: submodule|subtree|standalone

# --- Phase functions ---

function _my_check_fn() {
    _update_core_is_available "/path/to/component"
}

function _my_plan_fn() {
    # populate info about pending changes
    # see _update_core_build_file_lists
}

function _my_pull_fn() {
    git -C "/path/to/component" pull --ff-only
}

function _my_unpack_fn() {
    # apply symlinks, compile files, etc.
}

function _my_post_fn() {
    info "Restart your shell to apply changes"
}

function _my_cleanup_fn() {
    unset -f _my_check_fn _my_plan_fn _my_pull_fn \
             _my_unpack_fn _my_post_fn _my_cleanup_fn
}

Optional setup_fn (Tenth Argument)

You may pass a tenth argument to _update_register_hook — a setup function name. This function is called by dotfiler setup -u (which runs all hook components by default) or dotfiler setup -u --component <name> to perform a full unpack outside of the incremental update flow (e.g. on a fresh clone or forced reinstall).

_update_register_hook \
    "my-component" \
    "_my_check_fn" "_my_plan_fn" "_my_pull_fn" \
    "_my_unpack_fn" "_my_post_fn" \
    "_my_cleanup_fn" \
    "/path/to/component" \
    "submodule" \
    "_my_setup_fn"            # called by: dotfiler setup --component my-component

Hook Discovery and Auto-Installation

When a hook is delivered inside your dotfiles repo (e.g. a zdot hook at .config/zdot/core/dotfiler-hook.zsh), the recommended pattern is to create a symlink in the hooks directory pointing into the linktree:

~/.config/dotfiler/hooks/my-component.zsh  →  ~/.config/zdot/core/dotfiler-hook.zsh
                                               (linktree destination)

This way the hook is sourced from its post-unpacked linktree path, which is always the version that was last cleanly installed — never a partially-updated intermediate state. zdot's update.zsh creates this symlink automatically on first load; you can replicate the pattern for your own components.


The _update_core_* API

The update_core.zsh library provides helpers for all common update operations. These are available to your hook functions when dotfiler sources your hook.

Availability Checks

# Is an update available in this repo? (Phase 1 / no release-channel constraint)
_update_core_is_available "/path/to/repo"
# Returns 0=yes, 1=no-update, 2=no-network

# Phase 2 (self-directed): apply release-channel constraint from a zstyle scope
_update_core_is_available "/path/to/repo" "" 0 ':my-component:update'
# When ':my-component:update' release-channel=release (default), only returns 0
# if a new semver tag (v<N>.<N>.<N>[...]) is reachable from the remote branch tip.

# For subtree deployments (with release-channel constraint)
_update_core_is_available_subtree "/path/to/repo" "remote branch" \
    "https://github.com/owner/repo.git" ':my-component:update'

_update_core_is_available prefers the GitHub REST API (via curl or wget) to avoid an expensive git fetch when possible. It falls back to git fetch on non-GitHub remotes.

Release-Channel Helpers

# Read release-channel preference from a zstyle scope
_update_core_get_release_channel ':my-component:update'
# Sets REPLY = "release" (default) or "any"

# Resolve the SHA of the latest semver tag reachable from the remote branch tip
# (after a git fetch has been done to materialise remote objects locally)
_update_core_resolve_latest_semver_tag_sha \
    "$remote_url" "$branch" "/path/to/repo" "$remote_name"
# Sets REPLY = SHA on success, returns 1 if no semver tag found.
# Uses GitHub Releases API first (documented newest-first ordering,
# skips drafts/pre-releases); falls back to git ls-remote --tags.

The tag pattern matched is v[0-9]*.[0-9]*.[0-9]* — standard semver prefixed with v. Suffixes (e.g. -rc1, +build) are accepted. Plain numeric tags (e.g. 1.2.3) are not matched.

Pass the --scope flag to _update_core_component_tip_range to apply the release-channel constraint automatically during plan phase:

_update_core_component_tip_range "/path/to/repo" "$topology" \
    "" "" --scope ':my-component:update'
# When release-channel=release, REPLY is set to "old_sha..tag_sha" rather than
# "old_sha..branch_tip_sha". Returns empty string if no qualifying tag exists.

Deployment Detection

_update_core_detect_deployment "/path/to/repo"
# Sets REPLY=submodule|subtree|standalone|subdir|none

Parent Repo

_update_core_get_parent_root "/path/to/repo"
# Sets reply=( path kind )
# kind: superproject | toplevel | none

Correctly handles the case where .git is a symlink (common when a component lives under a linktree directory) by resolving the symlink target to find the real superproject.

File Change Lists

_update_core_build_file_lists "/path/to/repo" "HEAD..origin/main"
# Sets _update_core_files_to_unpack and _update_core_files_to_remove
# (declare both as `typeset -aU` before calling)

Discovery is commit-range based: the function walks the incoming git commits in the range and derives exactly which files were added/modified (to unpack) and deleted (to remove). Exclusion patterns (.git/, .nounpack/, user-defined dotfiles_exclude rules) gate the result, and squashed subtree merge commits are skipped automatically.

(Full-tree discovery — the shallow/deep find passes over the whole repo — belongs to setup.zsh-style full unpacks, not to incremental update plans.)

Component Range Resolution

_update_core_resolve_component_range \
    "/path/to/dotfiles" "$old_sha" "$new_sha" \
    "/path/to/component" "submodule"
# → REPLY = "old_sha..new_sha" for the component

Dispatches by topology:

  • submodule — reads from git ls-tree in the parent at the old/new SHAs
  • subtree — reads from the SHA marker file
  • standalone — reads from the external marker file

SHA Markers

Used to track which version of a component was last unpacked:

_update_core_sha_marker_path "/path/to/repo"  # → REPLY (marker file path)
_update_core_read_sha_marker "/path/to/repo"  # → REPLY (SHA or empty)
_update_core_write_sha_marker "/path/to/repo" "$new_sha"

# External (non-git) version markers — same REPLY conventions
_update_core_ext_marker_path "/path/to/repo"
_update_core_read_ext_marker "/path/to/repo"
_update_core_write_ext_marker "/path/to/repo" "$version_string"

Locking

Prevent concurrent update runs:

local _lock_dir="${XDG_CACHE_HOME:-$HOME/.cache}/dotfiler/my-component.lock"
_update_core_acquire_lock "$_lock_dir" || return 0
# ... do work ...
_update_core_release_lock "$_lock_dir"

Stale locks (older than 600 seconds) are recovered automatically.

Timestamps

_update_core_write_timestamp "/path/to/timestamp" 0 "Update successful"
_update_core_write_timestamp "/path/to/timestamp" 1 "Error message"

Committing Parent

For the common case — write the SHA/ext marker and commit the parent repo's pointer after a component update, dispatched by topology — use the high-level helper (this is what dotfiler's own post phase uses):

_update_core_get_in_tree_commit_mode ':my-component:update'  # → REPLY (mode)
_update_core_component_post_marker \
    "$repo_dir" "$parent" "$rel" "$new_sha" \
    "$topology" "$REPLY" "$phase" "$outcome"

The low-level commit primitive takes five arguments — parent repo, the component's path relative to it, a label for messages, the commit message, and the mode:

_update_core_commit_parent "$parent" "$rel" "my-component" \
    "chore: bump my-component" "$mode"

The commit mode (auto|prompt|none) is read from zstyle by _update_core_get_in_tree_commit_mode:

zstyle ':dotfiler:update' in-tree-commit auto   # default: auto

Update Frequency

_update_core_should_update "$stamp_file" "$freq_seconds" "$force_flag"
# Returns 0=proceed, 1=too-soon

_update_core_get_update_frequency "scope"  # reads ':scope:update' frequency zstyle

Logging in Hooks

Use the standard logging functions — they are available as shims when your hook runs in the dotfiler hook-check context (where zdot logging may not be loaded):

info "message"       # plain output
action "message"     # blue — doing something
success "message"    # green — succeeded
warn "message"       # yellow stderr — non-fatal
error "message"      # red stderr — fatal
verbose "message"    # shown with --verbose or DOTFILER_VERBOSE
log_debug "message"  # shown with --debug or DOTFILER_DEBUG

If your hook is sourced from the zdot startup context (where zdot_info etc. are already defined), the hook automatically maps them to dotfiler's equivalents and cleans up the shims on completion.


Minimal Check-Only Hook

If you only need to participate in the check phase (e.g. to notify the user that a component needs attention but not auto-update it):

_update_register_hook \
    "my-readonly-component" \
    "_my_check_fn" \
    "" "" "" "" \
    "_my_cleanup_fn" \
    "/path/to/component" \
    "standalone"

function _my_check_fn() {
    local current desired
    current=$(cat /path/to/component/.version 2>/dev/null)
    desired=$(curl -sf https://example.com/version)
    [[ "$current" != "$desired" ]]  # 0=update available
}

function _my_cleanup_fn() {
    unset -f _my_check_fn _my_cleanup_fn
}

Testing Your Hook

# Check phase only
dotfiler check-updates --force --debug

# Full update run
dotfiler update --debug

# Dry run (plan only, no pull/unpack)
dotfiler update --dry-run --debug

See dotfiler-hook.zsh in the zdot repo for a complete production hook example.