chezmoi-template.nvim
August 3, 2026 · View on GitHub
Edit your chezmoi source files natively, and make Neovim understand them.
Most chezmoi integrations wrap the chezmoi edit CLI: temporary buffers, watchers, apply-on-save. This plugin takes the opposite approach: you open the real source files (in ~/.local/share/chezmoi, under git, with your normal workflow), and the editor becomes chezmoi-aware:

- Requirements
- Installation
- Configuration
- Completion
- Picker
- Keymaps
- Lua API
- Secrets
- vs. chezmoi.nvim / chezmoi.vim / the LazyVim extra
- Non-goals
- Health
- Development
- Documentation
- Credits
- License
-
Real highlighting inside templates. A
dot_zshrc.tmplis agotmplbuffer whose text is treesitter-injected as zsh: Go-template syntax and target-language syntax, simultaneously. Works for any target language with a treesitter parser, resolved viachezmoi target-path. Includes.chezmoitemplates/partials,.chezmoiignore/.chezmoiremove/.chezmoiexternal.*. -
Format templates as their target filetype (conform.nvim). Go-template spans are masked with structurally inert placeholders, the buffer is formatted with the target filetype's formatter (shfmt, biome, taplo, …), then the spans are restored, with
{{ end }}/{{ else }}re-indented to pair with their opener, and column-0{{-directive blocks getting depth-encoding interior padding:{{- range $name, $spec := .packages.apps }} {{- $roles := get $spec "role" }} {{- if and $roleOK $osOK }} {{- $via := get $spec "via" }} {{- end }} {{- end }}
Caveat: lines with no valid placeholder form
A few template shapes have no placeholder form the target syntax accepts: a template glued to a bare word (
k = {{ .x }}suffix), or a control-flow pair wrapping a whole entry ({{ if .on }}k = 1{{ end }}). Those lines are masked whole instead, so they come back untouched while the rest of the file still formats. -
Target-aware icons (mini.icons).
private_dot_config/ghostty/config.tmplshows the ghostty icon, not a generic template glyph. Any combination of chezmoi source-state attributes (private_,encrypted_,exact_,dot_,.tmpl,.age, …) resolves to the deployed name. -
Transparent encryption (opt-in). chezmoi-managed
*.agefiles decrypt on open and re-encrypt on save viachezmoi decrypt/chezmoi encrypt. Whatever your chezmoi config uses (age, rage, builtin age, even gpg) just works.encrypted_*.tmpl.agestill gets full template + target highlighting. -
%matching for template delimiters (vim-matchup).{{ if }}⇄{{ else }}⇄{{ end }}, including{{-trim markers. -
Live template preview.
:Chezmoi previewrenders the buffer throughchezmoi execute-templateinto a split (vertical by default,preview.split = "horizontal"to change) typed as the target filetype, re-rendered live as you type (debounced). Invalid syntax keeps the last valid render, flagged stale in the winbar, until it parses again.q(or toggling again) closes it. Writing a file in.chezmoitemplates/re-renders too, so a source that is a bare{{ template "…" . }}passthrough still previews the thing it pulls in, on write rather than per keystroke, since chezmoi reads those files from disk. -
The preview can show what the edit changes out there.
preview.diff(opt-in) marks the render against the file currently in$HOME, in the preview buffer itself: added and changed lines get aDiffAddhighlight, lines the edit removes come back asDiffDeletevirtual lines. Still one window, and the deployed file is re-read on every render so an apply landing underneath shows up. -
gffollows a template name. In{{ template "part.tmpl" . }}(orincludeTemplate),gfon the name opens.chezmoitemplates/part.tmpl. Those files live in the source root, nowhere near the file including them, so plaingfnever finds them. -
Template diagnostics. Errors from
chezmoi execute-templatesurface asvim.diagnosticentries on write, so template typos stop being invisible until apply fails. -
Commands. One
:Chezmoicommand with subcommands (tab-completed):apply(buffer target, or:Chezmoi! applyfor all; apply-on-save on by default. chezmoi's own source state such as.chezmoi.toml.tmplor.chezmoiignoredeploys nowhere, so writing one of those reports chezmoi's warnings instead, for example a config template that no longer matches the generated config, each reported once rather than on every save),target(:Chezmoi! targetopens the deployed file),source(jump from a deployed file to its source; the opt-inredirectoption does it automatically, including for the first file of a session, for example a dashboard shortcut that opens a deployed config file),edit(:Chezmoi edit <target>opens the source for any deploy target, tab-completing target paths),preview,pick(source-file picker: snacks / telescope / fzf-lua / mini.pick /vim.ui.select). -
Completion (blink.cmp). Context-aware inside
{{ … }}via the gotmpl treesitter tree: after a dot (.foo) it offers only data keys fromchezmoi data(icons reflect each value's type, docs preview the value); at command position it adds template/sprig/chezmoi functions and Go template keywords; inside string literals it stays quiet. Outside actions it offers block snippets (if,if/else,range,with,define,block, comments) expanding to full{{- … }}…{{- end }}pairs. Falls back to a line heuristic when the gotmpl parser isn't installed.

Everything degrades gracefully: without the chezmoi binary you keep plain gotmpl highlighting and nothing errors.
Requirements
- Neovim ≥ 0.10
chezmoion$PATH(optional, but the point)- nvim-treesitter with the
gotmplparser (plus parsers for your target languages)
Everything else is per-feature and optional:
| Feature | Needs |
|---|---|
| Formatting | conform.nvim + the target filetype's formatter |
| Icons | mini.icons |
% matching | vim-matchup |
| Completion | blink.cmp ≥ 0.13 (per-item kind icons/highlights); markdown + markdown_inline Tree-sitter parsers for type-highlighted docs |
| Picker | any of snacks / telescope / fzf-lua / mini.pick, falls back to vim.ui.select |
| Encryption | nothing extra, delegates to chezmoi decrypt / chezmoi encrypt, so chezmoi's own config drives age/rage/builtin/gpg |
Developed and tested on macOS, Linux, and Windows (CI). Every path is normalized through
vim.fs, and the test suite runs onwindows-latestalongside Linux. Windows support is newer than the Unix support, so reports are still welcome.
Installation
lazy.nvim:
{
"dpezto/chezmoi-template.nvim",
lazy = false,
---@module 'chezmoi-template'
---@type chezmoi-template.Config
opts = {},
}
The two annotation lines are optional. With lazydev.nvim (or lua_ls configured with the plugin on its library path) they give completion and type checking for every option in opts.
lazy = false, but startup stays cheap: setup() only registers filetype
detection, the treesitter directive, and light triggers. The heavy work (module
loads, autocmds) is deferred until the first managed file opens or a :Chezmoi*
command runs. So it costs ~nothing on sessions where you never touch a chezmoi
file, whether the plugin loads via opts or bare.
Recognizing a managed file that isn't a template, which is most of a source directory,
means knowing where the source directory is, so the first buffer of a session
resolves it with one chezmoi source-path, cached from then on. Set
source_dir to skip even that. Keep lazy = false and don't
set ft/cmd; the deferral is internal, and filetype detection must register at
startup for .tmpl files to be recognized.
The plugin bootstraps itself with defaults, so setup()/opts is only needed to
change options. With other plugin managers, install and optionally set
vim.g.chezmoi_template = { ... } before it loads. Releases are tagged (semver);
add version = "*" to the spec to pin to stable releases.
Configuration
Most setups need little or nothing:
opts = {
apply = { force = true }, -- skip chezmoi's prompt on modified targets
encryption = { enabled = true }, -- transparent *.age editing
}
All options with defaults
require("chezmoi-template").setup({
source_dir = nil, -- nil = auto-detect via `chezmoi source-path`
inject = {
enabled = true, -- treesitter injection of the target language
exclude = {}, -- lua patterns (matched on the normalized "/" path) to leave as plain gotmpl
},
format = {
enabled = true, -- conform formatter registration
indent_directives = true, -- depth-pad column-0 `{{-` directive blocks
},
icons = { enabled = true }, -- mini.icons resolution (no-op if absent)
apply = {
on_save = true, -- chezmoi apply <target> after writing a source file
notify = true, -- notify on successful applies (failures always notify)
force = false, -- pass --force (skip chezmoi's prompt on modified targets)
},
preview = {
live = true, -- :Chezmoi preview re-renders as you type (false = on write)
debounce = 150, -- ms of idle before a live re-render
slow_ms = 500, -- renders slower than this pause live preview to on-write; 0 disables
split = "vertical", -- preview window orientation: "vertical"|"horizontal"
diff = false, -- mark the render against the file currently deployed
},
notify_on_open = false, -- notify when opening a managed source file
redirect = false, -- opening a deployed managed file jumps to its source
diagnostics = { enabled = true },
completion = {
-- hide values of data keys matching these patterns in completion docs
mask = { "secret", "token", "passw", "key", "api" },
},
picker = {
backend = nil, -- "snacks"|"telescope"|"fzf-lua"|"mini"|"select"; nil = auto
display = "target", -- entry labels: "target" (.zshrc) or "source" (dot_zshrc.tmpl)
exclude = {}, -- lua patterns hidden on top of the internals list; false = show all
},
keymaps = {
enabled = false, -- opt-in buffer-local bindings in source buffers
prefix = "<localleader>c", -- p preview, a apply, t target, s source, e edit, f pick
icon = nil, -- glyph for the which-key group; nil = built-in default
},
encryption = {
enabled = false, -- opt-in; delegates to chezmoi decrypt/encrypt
exclude = {}, -- lua patterns (matched on the normalized "/" path) for *.age paths to leave untouched
},
})
Formatting
The formatter is registered with conform as chezmoi, and formatters_by_ft.gotmpl = { "chezmoi" } is set if you haven't set it yourself. It formats using the target filetype's formatter, so that formatter must be installed and configured in conform as usual.
Formatting decrypted *.age buffers
If you use the encryption module and format decrypted *.age buffers, route them through the chezmoi formatter too (it strips the .age suffix before handing the file to the underlying formatter):
-- in your conform opts, after defining formatters_by_ft
for ft, formatters in pairs(opts.formatters_by_ft) do
if type(formatters) == "table" then
opts.formatters_by_ft[ft] = function(bufnr)
return vim.api.nvim_buf_get_name(bufnr):match("%.age$") and { "chezmoi" } or formatters
end
end
end
Icons
mini.icons has no resolver hook, so the integration wraps MiniIcons.get() (transparently for non-chezmoi names). If you lazy-load mini.icons and icons don't resolve, call the attach explicitly from its config:
require("chezmoi-template.icons").attach()
For statusline/bufferline components, require("chezmoi-template.icons").get(path) returns the target's glyph, hl (or nil for non-chezmoi paths). lualine example:
local function file_icon()
local icon, hl = require("chezmoi-template.icons").get(vim.api.nvim_buf_get_name(0))
if not icon then
return ""
end
return icon
end
Encryption
Opt-in transparent editing of managed *.age / *.asc files: decrypt on read,
re-encrypt on write. Decrypt/encrypt delegate to chezmoi decrypt /
chezmoi encrypt, so identities, recipients, and tool choice (even gpg) all
come from chezmoi's own encryption config. Zero plugin config:
encryption = {
enabled = true,
exclude = { "private%-keys" }, -- e.g. passphrase-encrypted bootstrap keys
},
Decrypted content never hits disk (no swap, no undo file). exclude lua patterns
leave matching paths as plain binary, which is useful for passphrase-encrypted keys chezmoi
can't decrypt non-interactively.
Completion

Requires blink.cmp ≥ 0.13 (per-item kind_icon/kind_hl). Register the source in your blink opts:
sources = {
default = { "chezmoi", "lsp", "path", "buffer" },
providers = {
chezmoi = { name = "chezmoi", module = "chezmoi-template.blink" },
},
}
The source only activates in gotmpl buffers. Inside {{ … }} it narrows by cursor position (treesitter-driven, with a line-regex fallback): data keys after a dot, plus functions and keywords at command position, and nothing inside string literals; elsewhere it offers block snippets (if → {{- if … }}\n…\n{{- end }} etc.), so it stays out of the way of the target language's own completion. Note: templates using secret-manager functions (onepassword, vault, …) may make :Chezmoi preview/diagnostics slow or fail without auth, since those calls run whatever your template runs.
Picker

:Chezmoi pick opens a file picker over the source directory. Entries are built by the plugin (via git ls-files, so the source repo's .gitignore is respected; plain fs walk for non-git source dirs), identically across backends:
- Labels show the deployed target name (
dot_zshrc.tmpl→.zshrc); setpicker.display = "source"for raw source names. - Chezmoi internals are hidden by default (
.git/,.chezmoi.$FORMAT.tmpl,.chezmoiversion,.chezmoiroot,.chezmoidata.*) while editable specials stay listed (.chezmoiignore,.chezmoiscripts/,.chezmoitemplates/,.chezmoiexternal.*).picker.excludehides your own lua patterns (matched against the source-relative path) on top of that list, for exampleexclude = { "^private_dot_ssh/.*id_" }keeps ssh keys out of the picker while.ssh/configstays.exclude = falseshows everything. - Preview highlights the target language inside the template, same as opening the file. Managed encrypted files (
*.age,*.asc) preview decrypted whenencryption.enabledis on, typed as their deployed target.
Backend auto-detects among loaded pickers (snacks → telescope → fzf-lua → mini.pick) with a vim.ui.select fallback; if your picker is lazy-loaded it may not be detected, so set picker.backend = "telescope" (etc.) explicitly (a plain string picker = "telescope" still works as shorthand). Map it however you like:
keys = { { "<leader>sz", "<cmd>Chezmoi pick<cr>", desc = "Chezmoi source files" } },
Keymaps
No global keys are set. keymaps.enabled = true adds buffer-local bindings that exist only in chezmoi source buffers, so a default prefix cannot collide with anything outside them:
opts = {
keymaps = { enabled = true, prefix = "<localleader>c" },
}
| key | does |
|---|---|
<prefix>p | toggle the preview |
<prefix>a | apply this buffer's target |
<prefix>t | open the deployed file |
<prefix>s | jump to the source |
<prefix>e | :Chezmoi edit on the cmdline |
<prefix>f | pick a source file |
With which-key.nvim loaded, the same keys register as a chezmoi group, each with an icon, and the preview entry reports the split's current state, Preview or Close Preview, with a toggle icon and colour to match. keymaps.icon replaces the group's glyph (keymaps = { enabled = true, icon = "" }).
Rolling your own instead works the same way, and preview_is_open gives the dynamic label:
{
"<localleader>cp",
"<cmd>Chezmoi preview<cr>",
desc = function()
return require("chezmoi-template.commands").preview_is_open(0) and "Close Preview" or "Preview"
end,
}
Lua API
For statuslines, custom pickers, or scripts:
-- every managed file as { source = <abs>, target = <abs> } pairs
local files = require("chezmoi-template").list()
-- open the chezmoi source for a deploy target (~ is expanded)
require("chezmoi-template").edit("~/.zshrc")
-- is a preview split open for this buffer? (0 or nil = current)
require("chezmoi-template.commands").preview_is_open(0)
edit(target) is the programmatic form of :Chezmoi edit <target>.
Secrets
What the plugin does by itself:
- Decrypted buffers never persist plaintext:
swapfile,undofileand swap are disabled, and writes go straight throughchezmoi encrypt(no plaintext temp file). - Completion docs hide values of data keys matching
completion.mask(default:secret,token,passw,key,api). The key still completes, the value shows as•••••.
What you should know:
-
:Chezmoi previewand diagnostics runchezmoi execute-templateon your buffer, so templates calling secret managers (onepassword,vault,pass, …) will render real secrets into the preview split, and may be slow or fail without auth. Don't screen-share the preview of a secrets template. -
cloak.nvim composes well for masking secrets in decrypted buffers. Its
file_patterns match the buffer name, which keeps its encrypted suffix: add"*.age"/"*.asc"(or specific names like"*.json.age") to your cloak patterns. -
ecolog.nvim env completion works inside templates by mirroring its providers onto the
gotmplfiletype; its shelter mode then masks env values in completion/peek as usual:ecolog provider snippet
-- ecolog opts.providers: reuse shell providers for gotmpl buffers providers = vim.tbl_map(function(p) return vim.tbl_extend("force", p, { filetype = "gotmpl" }) end, shell_providers),
vs. chezmoi.nvim / chezmoi.vim / the LazyVim extra
| chezmoi.nvim + chezmoi.vim | chezmoi-template.nvim | |
|---|---|---|
| Editing model | wraps chezmoi edit (tmp buffers, watch) | native source files |
| Template highlighting | regex compound filetypes (sh.chezmoitmpl) | treesitter injection of the real target language |
| Formatting | no | target-filetype formatting through templates |
| Icons | static per-extension glyphs | full source-name → target resolution |
| age files | no | transparent decrypt/encrypt (opt-in) |
| Preview / diagnostics | no | :Chezmoi preview, template errors as diagnostics |
| Completion | no | data keys + template functions (blink.cmp) |
| Apply | apply-on-save via chezmoi edit --watch | :Chezmoi apply + apply-on-save (default) |
| Picker | telescope/fzf/snacks picker | :Chezmoi pick over snacks/telescope/fzf-lua/mini.pick/select |
Non-goals
Things this plugin deliberately does not do:
- No
chezmoi edit --watchwrapper. The editing model is native source files; apply-on-save covers the same ground. - No
chezmoi addwrapper. Adding files to chezmoi is a one-off CLI action. - No script running.
run_scripts execute onchezmoi apply, never from the editor. - No nvim-cmp or coc completion source. Completion targets blink.cmp only.
Health
:checkhealth chezmoi-template verifies the chezmoi binary, gotmpl parser, conform, and the encryption setup.
Development
make test runs the test suite headless (no external formatter binaries needed, conform is stubbed). CI runs it on stable and nightly Neovim, plus a weekly cron against nightly to catch API drift.
make smoke exercises the plugin against a throwaway chezmoi setup deliberately unlike the author's: custom sourceDir/destDir, gpg encryption, and a run with no chezmoi config at all. Needs chezmoi (and optionally gpg) on $PATH; it never touches your real chezmoi state.
See CONTRIBUTING for commit conventions and the AI-assistance policy.
Documentation
:h chezmoi-template covers everything here in vimdoc form (doc/chezmoi-template.txt). Release history lives in the CHANGELOG.
Credits
- chezmoi.nvim and chezmoi.vim, the prior art this plugin positions itself against.
- conform.nvim for the formatting pipeline.
- blink.cmp for the completion engine.
- mini.icons for icon resolution.