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:
| Phase | Function | Purpose |
|---|---|---|
check | check_fn | Is an update available? Return 0=yes, 1=no |
plan | plan_fn | Compute what will change (dry-run safe) |
pull | pull_fn | Fetch/pull from remote |
unpack | unpack_fn | Apply changes (symlinks, post-processing) |
post | post_fn | Post-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:
pullfor main dotfiles runs before any hook'spull— every repo is pulled before any unpack beginsunpackfor main dotfiles runs before any hook'sunpack— 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 fromgit ls-treein the parent at the old/new SHAssubtree— reads from the SHA marker filestandalone— 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.