A clean, modular cross-platform dotfiles configuration managed with GNU Stow. Designed to run standalone on personal Linux / macOS machines while supporting a purely additive site overlay (see README.google.md when present) that layers work-specific packages on top without touching a single core file.
bin/: Universal scripts (clip,osc52.sh).eza/: Theme foreza.ghostty/: Terminal configuration with TokyoNight styling.git/: Git config with sensible aliases and global ignore.helix/: Modal editor settings.nvim/: Neovim configuration (Lazy.nvim, plugins, options, keymaps).ssh/: SSH base config withInclude ~/.ssh/conf.d/*.tmux/: Tmux configuration with TokyoNight styling and sensible defaults.vim/: Minimal Vim configuration with vim-plug support.zsh/: Zsh environment (.zshrc,.zshenv,.zprofile,.aliases,.env, a framework-free async prompt in.config/zsh/prompt.zsh; functions andzsh-abbrabbreviations live in.aliases).
Note
The package sets live in one place each: core packages in the Makefile, and optional site-overlay packages in google.mk (absent in a public checkout, so make all simply stows the core set).
Install stow and git:
- Arch / Omarchy:
sudo pacman -S stow - Debian / Ubuntu:
sudo apt install stow - macOS:
brew install stow
Stow's options live in .stowrc (--target=~ --no-folding: one symlink per file, never a symlinked directory), so from the checkout plain stow works for one-off operations. The Makefile adds package sets, copy-mode files and helpers on top.
# Stow all available packages
make all
# One-off, from the checkout
stow nvim zsh # stow specific packages
stow -D tmux # unstow one
stow -n -v zsh # dry-run
# Install external dependencies (Vim-Plug, TPM, Ghostty shaders, Antidote).
# zsh, tmux and vim also fetch their own on first start; this does it ahead of time.
make deps
# Install or update external dependencies for a single package
make deps-vim
make update-tmux
# Upgrade all external dependencies
make update
# Simulate stowing without making changes
make dry-run
# Restow: prune dead links and pick up new files
make restow
# Unstow / remove symlinks
make unstow
# Report de-linked symlinks, conflicts and diverged copy-mode files
make status
# Keep $HOME's versions: stow --adopt moves them into the repo (review with git diff)
make sync
# Verify every zsh file parses and interactive shell startup exits 0 with clean stderr
make checkReleases are ordinary commits on a linear branch, so a plain pull works:
git pull
make restow # pick up any files that were added, moved, or removedmake restow matters: git pull updates the repository, but symlinks for newly added files are only created when you restow. Stow does not prune links inside a directory the package no longer has; after such a removal, delete the dangling links by hand (find ~/.config -xtype l). Keep machine-specific tweaks in untracked *.local files (.zshrc, .zshenv, .aliases, and prompt.zsh all source their .local counterparts if present).
The prompt is modular along two independent axes: core vs. site overlay (so the core prompt works unchanged anywhere) and engine vs. VCS backend (so each version control system is self-contained).
| File | Package | Contents |
|---|---|---|
.config/zsh/prompt.zsh |
zsh |
Engine: appearance, directory segment, async worker, mtime cache |
.config/zsh/prompt/vcs.zsh |
zsh |
VCS framework: backend registry, root discovery, cache fingerprinting |
.config/zsh/prompt/vcs-{git,hg,jj}.zsh |
zsh |
One self-contained backend each |
.config/zsh/prompt.*.zsh |
(overlay / local) | Optional site and per-machine overrides, sourced if present |
zsh/.zshrc sources prompt.zsh unconditionally and optional overlay files only if they exist.
Backends are loaded by name, and the list is the detection order — the first backend whose root test matches wins:
PROMPT_VCS_MODULES=( jj hg git ) # set before sourcing prompt.zshjj must precede git, because a colocated jj repository contains both .jj and .git and the jj segment is the more specific one. Dropping a name means that backend is never loaded, so you pay nothing for it — not even the stat() probing for its marker directory. A module also declines to register itself when its binary is absent.
Each backend module owns its own colours and glyphs and implements three functions:
_pr_vcs_<name>_root DIR # is DIR a repo root of this type?
_pr_vcs_<name>_render ROOT # print the segment (runs in the background job)
_pr_vcs_<name>_watch ROOT # optional: files whose mtimes validate the cacheOmitting _watch means "cannot be cached, always re-query" — which is what git does, since editing a working-tree file never touches .git. hg and jj declare watch files, making the steady state cost zero forks. The whole VCS layer is optional: with prompt/vcs.zsh absent the prompt still works and simply shows no VCS segment.
The core engine knows nothing about any specific work environment; an overlay plugs in through hooks that communicate via REPLY/reply (never stdout, so calling them costs no fork):
prompt_dir_segment # replace the directory segment
prompt_vcs_root_hint # "<type> <root>", skip the upward .jj/.hg scan
prompt_vcs_watch_extra R T # extra files whose mtime invalidates the cache
prompt_hg_extra R # extra hg text; reads $_pr_hg_extra
prompt_jj_extra R # extra jj textTwo seams avoid duplicating VCS queries in an overlay: PROMPT_HG_EXTRA_TEMPLATE appends template keywords to the core's existing hg log call (so extra metadata costs no second invocation), and prompt_vcs_watch_extra contributes additional cache-invalidation files.
Some applications rewrite their own config by writing a temporary file and rename(2)-ing it over the target. Because rename(2) replaces the path, a Stow symlink at that path is destroyed and silently replaced by a regular file — after which the repo stops receiving updates and git status stays clean, because the broken link lives in $HOME, outside the work tree.
Warning
This is a silent failure mode. Files an application only reads are unaffected; only temp-file + rename(2) writers are at risk.
Three strategies, best first:
| # | Strategy | Drift | Ongoing cost |
|---|---|---|---|
| 1 | Relocate the app's config into the checkout | structurally impossible | none |
| 2 | Copy mode (COPY_FILES) + make status / make sync |
detected | must sync after the app writes |
| 3 | Plain symlink | silent loss | none — until it breaks |
Important
Copy mode does not capture the app's write automatically; it makes the divergence detectable and one command to sync. Prefer strategy 1 whenever the app offers a way to relocate its config path.
Point the app at a file inside the checkout via its env var, flag, or XDG_CONFIG_HOME support, so no symlink sits in the write path.
Note
zsh-abbr sidesteps the problem entirely: abbreviations are declared in zsh/.aliases with abbr -S (session-only), so it never writes a tracked file. abbr add still works for ad-hoc abbreviations, which land in the untracked ~/.config/zsh-abbr/user-abbreviations.
When an application offers no env var, flag, XDG_CONFIG_HOME support, or include directive, add the file to COPY_FILES in the Makefile (as <package>/<path>) and to that package's .stow-local-ignore. Stow then skips it, and make deploys it as a regular copy instead. Everything else in the package is still symlinked.
There is no drift database. A copy is written when the target is missing, left alone when identical, and otherwise reported as a conflict, leaving you to pick a side:
make status # report drift; exits non-zero if anything needs action
make sync # keep $HOME's version (pulled into the repo; review with git diff)
rm <target> && make all # keep the repo's versionmake unstow leaves copies in place. make status also detects the original failure mode across all packages: Stow reports any symlink an app has replaced with a regular file as a conflict.
Neovim's lua/config/lazy.lua dynamically checks whether an optional overlay directory (lua/google-plugins) exists before importing it:
local google_plugins_dir = vim.fn.stdpath("config") .. "/lua/google-plugins"
if vim.uv.fs_stat(google_plugins_dir) then table.insert(plugins_spec, { import = "google-plugins" }) endWhen only the core nvim package is stowed, the overlay directory is absent and omitted without any manual configuration changes.
make check (scripts/check-shell.sh) makes three assertions:
| Check | Catches |
|---|---|
zsh -n on every zsh file |
parse errors, without executing anything — works in an unstowed checkout |
interactive startup exits 0 |
a trailing conditional whose test is false; fails silently, printing nothing |
| that startup's stderr is empty | missing commands and plugin breakage, which print but often leave $? at 0 |
The second check exists because of a subtle shell trap: .zshrc ended with [[ -f ~/.aliases ]] && source ~/.aliases, .aliases ended with the same shape for ~/.aliases.local, and ~/.aliases.local does not exist on every machine. zsh hands the status of the last command run during startup to the first prompt, so exec zsh returned 1 while printing nothing. Hence the rule: a startup file's last statement must never be a bare cond && cmd. Use if, which yields 0 when the condition is false but still propagates a real failure from the body.
Note
Deliberately not a ZERR trap over the whole of startup. zsh disables that trap "while running initialization scripts" (see ERR_EXIT in the manual), so a trap installed inside .zshrc never fires — nor do setopt err_exit or zsh -o err_exit. Only err_return inside a function still works.
Starting with zsh -f and sourcing the rc chain by hand dodges that restriction, and reports 225 failing commands in a completely healthy shell — all of them zsh-abbr internals using non-zero as normal control flow (9 per abbr call site), with misattributed funcfiletrace line numbers. Hence three narrow assertions instead of one global trap.