Scripts & Brew Bundles

Lesson 4 · chezmoi · ~12 minutes

Chezmoi doesn't just manage file contents — it can run scripts during chezmoi apply. This lesson teaches you to automate package installation with Homebrew Bundles, conditionally tailored per machine using templates and chezmoi data.

The Mental Model

Scripts are actions chezmoi executes alongside file management. They live in your source state with special filename prefixes that control when they run:

How scripts fit into chezmoi apply
Source State
Templates + Scripts
Script Execution
run_once_ / run_onchange_
Target State
Files applied + packages installed

Script Prefixes

Chezmoi recognizes two primary script prefixes that control execution behavior:

PrefixBehaviorUse case
run_once_Runs exactly once, ever. Chezmoi records that it ran and never runs it again.One-time setup (create directories, initial config)
run_onchange_Runs when the script's contents change. Chezmoi hashes the rendered output and re-runs only when the hash differs.Package installation, config compilation

There are also run_before_ and run_after_ variants that run on every chezmoi apply, but those are less common. For package management, run_onchange_ is what you want — it re-runs only when your package list actually changes.

Key insight

run_onchange_ scripts are the sweet spot for package management. They're idempotent by nature: add a package to your list → the hash changes → chezmoi re-runs the script → brew bundle installs only what's missing.

Script Naming Convention

The full filename pattern for scripts is:

run_onchange_[before_|after_]description.sh[.tmpl]

Let's break that down:

PartPurpose
run_onchange_Execution trigger (once or onchange)
before_ / after_Optional. Controls whether it runs before or after file updates.
descriptionHuman-readable name. Determines sort order (scripts run alphabetically).
.shScript extension (determines interpreter)
.tmplOptional. Makes it a template that gets rendered before execution.

Scripts execute in alphabetical order by filename. Use naming like 01-install-packages, 02-configure-defaults to control sequence explicitly.

How chezmoi Tracks Script State

Chezmoi stores script execution records in its persistent state database (~/.config/chezmoi/chezmoistate.boltdb):

Templates change the hash

Since run_onchange_ hashes the rendered output, changing your .chezmoidata (like switching machine_type) will change the rendered Brewfile, which changes the hash, which triggers a re-run. This is exactly what you want — a new machine type means different packages to install.

Exercise: Templated Brew Bundle

You'll create a run_onchange_ script that generates a Brewfile from a template and runs brew bundle. Packages will vary based on machine_type from your .chezmoidata.yaml.

Step 1: Ensure your data file has machine_type

Your ~/.local/share/chezmoi/.chezmoidata.yaml should already have this from Lesson 3:

machine_type: work  # or "personal" or "server"

Step 2: Create the script template

Create the script in your source state:

chezmoi cd

Create a file called run_onchange_install-packages.sh.tmpl in the source root:

#!/bin/bash
# Chezmoi-managed Brewfile — do not edit directly.
# This script re-runs whenever the rendered package list changes.

set -euo pipefail

# Generate a temporary Brewfile from the template
BREWFILE=$(mktemp)
trap 'rm -f "$BREWFILE"' EXIT

cat > "$BREWFILE" << 'BREWFILE_CONTENTS'
# Core packages — all machines
brew "git"
brew "ripgrep"
brew "fd"
brew "jq"
brew "chezmoi"

{{ if eq .machine_type "work" -}}
# Work-specific packages
brew "awscli"
brew "terraform"
brew "kubectl"
brew "helm"
cask "slack"
cask "zoom"
{{- end }}

{{ if eq .machine_type "personal" -}}
# Personal-specific packages
brew "yt-dlp"
brew "ffmpeg"
cask "vlc"
cask "obsidian"
{{- end }}

{{ if or (eq .machine_type "work") (eq .machine_type "personal") -}}
# GUI machines get development tools
brew "gh"
brew "lazygit"
cask "wezterm"
cask "visual-studio-code"
{{- end }}
BREWFILE_CONTENTS

# Install packages using the generated Brewfile
brew bundle --file="$BREWFILE" --no-lock

The .tmpl suffix tells chezmoi to render this through Go's template engine before execution. The rendered output (with conditionals resolved) is what chezmoi hashes to detect changes.

Step 3: Preview what chezmoi will run

Before applying, see the rendered script:

chezmoi cat run_onchange_install-packages.sh.tmpl

You'll see the template conditionals resolved for your current machine_type. Only the relevant package blocks appear.

Step 4: Apply

chezmoi apply --verbose

You'll see chezmoi execute the script. It renders the template, detects a new hash (first run), and runs it. Brew bundle will install any missing packages from the generated Brewfile.

Step 5: Verify idempotency

Run apply again:

chezmoi apply --verbose

This time the script should not run — the rendered hash hasn't changed. Now add a package to your template, apply again, and watch it trigger.

Step 6: Commit

chezmoi cd
git add run_onchange_install-packages.sh.tmpl
git commit -m "feat: add templated brew bundle script"
git push
exit

What just happened

ConceptWhat it does
run_onchange_ prefixScript runs only when its rendered content changes
.tmpl suffixRenders through Go template engine before execution/hashing
Template conditionalsInclude/exclude packages based on .chezmoidata values
brew bundle --no-lockInstalls packages without creating a lockfile
Hash trackingChezmoi stores rendered script hash to detect future changes
Ordering multiple scripts

If you have multiple scripts (package install, macOS defaults, directory creation), prefix with numbers: run_onchange_01-install-packages.sh.tmpl, run_once_02-configure-macos.sh.tmpl. They execute in lexicographic order.

Knowledge Check

What determines whether a run_onchange_ script re-runs on chezmoi apply?
Correct. Chezmoi hashes the rendered script output (after template execution) and compares it to the stored hash. Any change in the rendered content — new packages, different template variables — triggers a re-run.
Not quite. Chezmoi ignores timestamps. It hashes the rendered output of the template and compares to its stored hash. The script only re-runs when that hash changes.
You change machine_type from "personal" to "work" in .chezmoidata.yaml. What happens on the next chezmoi apply?
Exactly. The template conditionals produce different output for "work" vs "personal", which means a different hash, which means chezmoi re-runs the script. This installs the work-specific packages automatically.
Since the script is a template, changing machine_type changes the rendered output, which changes the hash. Chezmoi detects the new hash and re-runs the script — installing the correct packages for the new machine type.
In what order do chezmoi scripts execute?
Right. Scripts run in lexicographic order by their full filename. Use numeric prefixes like 01-, 02- to control execution sequence explicitly.
Chezmoi runs scripts in lexicographic (alphabetical) order by filename. That's why numeric prefixes like 01-, 02- are useful for controlling sequence.

Next up

Your package installs are now automated and machine-aware. But many configs need secrets — API tokens, SSH keys, credentials. In Lesson 5: Secrets & Keychain, you'll learn how chezmoi integrates with macOS Keychain and other secret managers to template sensitive values without storing them in your repo.

Recommended Reading

chezmoi: Use Scripts to Perform Actions — The official script reference. Covers all script prefixes, interpreters, and the state tracking system in detail. ~8 minute read.

Questions? Ask me anything about script execution, hash tracking, or structuring your Brewfile template. I can help debug scripts that aren't triggering, or advise on splitting complex setups across multiple scripts.
← Prev Next →