yui flips the chezmoi flow: instead of editing your source repo and
running apply to push changes out to ~, you edit ~ directly and
the source follows automatically. The two sides share a backing inode
(hardlink / junction / symlink), so an app's write to the target is
a write to source.
It exists to fix three chezmoi pain points the author hit running chezmoi for years:
- The edit-source-then-apply tax — every config tweak became a two-step ceremony.
- Source ↔ target drift — apps overwrite the target directly,
and the user finds out at the next
chezmoi diff. - Untracked new files — apps that create new files inside a
managed directory aren't visible to chezmoi unless you remember
to
chezmoi addthem.
How it works
Your dotfiles repo is a normal directory tree. yui apply walks it
and links each file/directory into its target location:
| platform | files | directories |
|---|---|---|
| Linux / macOS | symlink | symlink |
| Windows (default) | hardlink | junction |
| Windows (opt-in) | symlink | symlink (Developer Mode / admin) |
The Windows defaults are deliberate: hardlinks and junctions both
work without elevated permissions and survive most editors' "atomic
save" rename trick. When that trick does break the hardlink, the
absorb classifier notices on the next apply / status:
target's file-id == source's file-id? → InSync
content identical, different file-id? → RelinkOnly
target newer + content differs? → AutoAbsorb (target wins)
source newer + content differs? → NeedsConfirm (anomaly)
target missing? → Restore
AutoAbsorb backs source up under $DOTFILES/.yui/backup/ and
copies target's content into source before relinking — your local
edit is preserved, even when an editor saved over the link.
For directories the same target-wins merge applies: target's files
land in source (overwriting on conflict), source-only scaffolding
(like .yuilink markers) survives, and the dir is then re-exposed
via a platform-appropriate link back to source — junction on
Windows, symlink on Unix/macOS, or whatever the configured dir_mode
resolves to. Non-regular entries inside the target — junctions,
symlinks, device files — are skipped with a warning since following
them safely is ill-defined.
Per-file content collisions inside the merge run through the same
absorb classifier the file-level path uses: identical content is a
no-op, target-newer copies through (AutoAbsorb), and source-newer +
diff defers to [absorb] on_anomaly (skip / force / ask). The
marker is consent for the whole-tree merge, but a single file
where the source side is newer is still a real anomaly worth
surfacing.
Install
Pre-built binaries for Linux x86_64, Windows x86_64, and macOS (Intel + Apple Silicon) are attached to every GitHub Release.
Quick start
# Scaffold a source repo at the current directory and install git hooks.
# Edit $DOTFILES/config.toml to declare your mounts, then:
Smallest useful $DOTFILES/config.toml:
[[]]
= "home"
= "~" # ~ expands to $HOME / $USERPROFILE per OS
[[]]
= "appdata"
= "{{ env(name='APPDATA') }}"
= "yui.os == 'windows'"
Add files under home/ and they'll link into ~. Add a .yuilink
file to a directory to junction the whole directory as one unit (so
files an app creates inside that dir land back in source
automatically).
Templates (*.tera)
Files ending in .tera are rendered with Tera before linking; the
output is a sibling file with the .tera suffix dropped. yui adds
the rendered file to a managed # >>> yui rendered (auto-managed) <<<
section of .gitignore so it doesn't get committed.
home/.gitconfig.tera → home/.gitconfig → ~/.gitconfig
Templates have access to yui.os / yui.host / yui.user /
yui.arch / yui.source and your [vars] table. Per-host overrides
go in config.local.toml (machine-local, gitignored), which yui
loads after config.*.toml so its values win.
One source → many targets
If you want the same source directory linked to different places on
different OSes — common for editor configs (~/.config/nvim on Unix,
%LOCALAPPDATA%\nvim on Windows) — drop a .yuilink with content:
# $DOTFILES/home/.config/nvim/.yuilink
[[]]
= "~/.config/nvim"
[[]]
= "{{ env(name='LOCALAPPDATA') }}/nvim"
= "yui.os == 'windows'"
yui list shows each link and which when would activate it.
Stacking markers and file-level entries
Markers compose. A parent .yuilink no longer stops the walker, so
you can junction a whole ~/.config and also layer extra dsts onto
specific subdirs:
# $DOTFILES/home/.config/.yuilink — junction the whole .config dir
[[]]
= "~/.config"
# $DOTFILES/home/.config/nvim/.yuilink — extra Windows-only dst
[[]]
= "{{ env(name='LOCALAPPDATA') }}/nvim"
= "yui.os == 'windows'"
Both links land — the parent takes care of the natural placement, the child adds its OS-specific alternate.
A [[link]] may also carry a src = "<filename>" to scope the link to
a single sibling file rather than the directory itself. Useful for
paths that don't follow ~/.config/<app>/ conventions, like the
PowerShell profile on Windows:
# $DOTFILES/home/.config/powershell/.yuilink
[[]]
= "Microsoft.PowerShell_profile.ps1"
= "{{ env(name='USERPROFILE') }}/Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
= "yui.os == 'windows'"
src must be a single filename (no path separators); the file lives
right next to the marker. The rest of the directory still falls
through to whatever placement an ancestor (or the parent mount)
provides.
.yuiignore — exclude paths from being linked
A $DOTFILES/.yuiignore file (gitignore syntax) keeps matched paths
out of every yui flow — render skips them, list omits them, and apply
won't link them. Useful for editor lock-files, build artifacts, OS
junk like .DS_Store, and anything else that lives next to your real
config but shouldn't be propagated:
# $DOTFILES/.yuiignore
**/.DS_Store
**/lock.json
home/.config/nvim/lazy-lock.json # exact path also works
# Exclude all of build/ except the one file we DO want linked
build/
!build/result.toml
Currently only the repo-root .yuiignore is honored — nested
.yuiignore files inside subdirectories are not yet walked, so put
all your rules at the top.
Hooks — run scripts around apply
Drop a script under $DOTFILES/.yui/bin/ and reference it with a
[[hook]] entry. The script stays a normal executable (you can run
it directly without yui); yui just decides when to invoke it.
[[]]
= "brew-bundle"
= ".yui/bin/brew-bundle.sh"
# Defaults: command="bash", args=["{{ script_path }}"], when_run="onchange", phase="post"
= "yui.os == 'macos'"
Schema:
| field | required? | default | meaning |
|---|---|---|---|
name |
✓ | — | unique identifier (state-tracking key, yui hooks run <name>) |
script |
✓ | — | path to script, relative to $DOTFILES |
command |
"bash" |
Tera-templated interpreter | |
args |
["{{ script_path }}"] |
Tera-templated each | |
when_run |
"onchange" |
once | onchange | every |
|
phase |
"post" |
pre | post (around apply) |
|
when |
always | optional Tera bool predicate |
onchange re-runs whenever the script's SHA-256 differs from the
last successful run. State is tracked in $DOTFILES/.yui/state.json
(gitignored — it's per-machine).
command and each args element are Tera-rendered with the standard
yui.* / vars.* / env(...) plus these extras for the script:
script_path, script_dir, script_name, script_stem,
script_ext. Example with a Deno script:
[[]]
= "denops-build"
= ".yui/bin/build.ts"
= "deno"
= ["run", "-A", "{{ script_path }}"]
= "onchange"
= "post"
= "yui.os != 'windows'"
Manual control:
apply runs pre hooks before render/link and post hooks after
all linking. A failing hook stops apply immediately — fix the
script, then re-run.
Anomalies and the [absorb] policy
When source AND target both diverge from each other, yui can't
auto-merge. It defers to your [absorb] on_anomaly setting:
[]
= true # auto-absorb on any AutoAbsorb classification
= true # treat dirty source as anomaly
= "ask" # "ask" | "skip" | "force"
ask— on a TTY, render the diff and prompt y/N; off-TTY, skipskip— log a warning and leave both sides untouchedforce— treat the anomaly as auto-absorb anyway (target wins)
Need to absorb a single file regardless of policy? yui absorb <target-path> does that — bypasses auto, require_clean_git, and
on_anomaly for an explicit user-initiated pull.
Commands
yui init [--git-hooks] |
scaffold config.toml + .gitignore in cwd |
yui apply [--dry-run] |
render → link → auto-absorb |
yui render [--check] [--dry-run] |
template-only pass; --check fails on drift |
yui link [--dry-run] |
alias for apply (kept for muscle memory) |
yui list [--all] [--icons MODE] [--no-color] |
every src→dst mapping |
yui status [--icons MODE] [--no-color] |
drift overview, exits non-zero on any divergence |
yui absorb <target> [--dry-run] |
manually pull a single target into source |
yui unlink <path>... |
tear down a specific link |
yui doctor |
environment sanity check |
yui gc-backup [--older-than DUR] |
clean old backups (not yet implemented — calling it errors out) |
yui hooks list |
show configured [[hook]] entries + last-run state |
yui hooks run [<name>] [--force] |
run hooks on demand (bypassing when_run with --force) |
--icons accepts unicode (default), nerd (Nerd-Font glyphs),
ascii (CI-log-safe). The [ui] icons = "..." config key sets it
globally.
Status
v0.4.0 ships the absorb story end-to-end — chezmoi-replacement
ready for simple repos. Known gaps:
- no built-in encryption (use
pass/1password-clifrom a Tera template instead) - chezmoi name-prefix translation (
dot_zshrc→.zshrc,run_once_*.sh.tmpl) is not implemented — bring-your-own rename when migrating
Migration from chezmoi: rename files (dot_X → .X), convert
*.tmpl → *.tera (Go template → Tera syntax), and pull the
run_* scripts into a separate runner you trigger yourself. yui has
no opinion on what runs them.
License
MIT