不用 ~/.zshrc 的 dotfiles:ZDOTDIR 与 chezmoi
我的 ~/.zshrc 用了很久以后,已经分不清哪些行是自己写的,哪些是安装器自动加的。
很多安装脚本都会提示一句“已添加初始化到 ~/.zshrc”,然后那几行就一直留在那里。工具删了,初始化代码不一定会跟着消失。最后文件虽然还能工作,但基本没人敢动。
我现在只在主目录保留一个 ~/.zshenv,其他 zsh 配置全部放到 ~/.config/zsh/,再用 chezmoi 管理。这样主目录由谁负责、磁盘上的状态有没有偏离声明,都比较容易看出来。这也是系列第一篇里分层方案的一部分。
用 ZDOTDIR 移走配置
macOS 上的交互式 login shell 大致按这个顺序读取文件:
/etc/zshenv → ~/.zshenv (always, every shell)/etc/zprofile → ~/.zprofile (login shells)/etc/zshrc → ~/.zshrc (interactive shells)/etc/zlogin → ~/.zlogin (login shells)~/.zshenv 每次启动 zsh 都会最先读取,也可以在这里设置 ZDOTDIR。设置后,后续的 .zprofile、.zshrc 和 .zlogin 都会从 $ZDOTDIR 读取。
所以我的 ~/.zshenv 只有一行:
# ~/.zshenv — the only zsh file allowed to live in $HOMEexport ZDOTDIR="${XDG_CONFIG_HOME:-$HOME/.config}/zsh"这是 zsh 官方支持的机制,不是什么软链接技巧。VS Code 的集成终端会读取 ~/.zshenv,所以也能正常使用。少数老工具会死认 ~/.zshrc,遇到时再单独判断要不要迁就它。
把一个长文件拆开
移动路径后,如果只是把原来的几百行原样搬过去,也没解决多少问题。我的 .zshrc 只负责按顺序加载片段:
# ~/.config/zsh/.zshrc — loads every fragment in numeric orderfor _file in "${ZDOTDIR}"/conf.d/*.zsh(N); do source "$_file"doneunset _file(N) 是 zsh 的 glob qualifier,目录为空时会展开为空,而不是报错。片段按数字排序:
~/.config/zsh/conf.d/├── 00-env.zsh # exported env vars, PATH base├── 10-completion.zsh # compinit and completion styles├── 20-aliases.zsh # short renames of existing commands├── 30-functions.zsh # shell functions that do real work├── 35-tools.zsh # eval-hooks for external CLIs (mise, zoxide, …)├── 40-lang.zsh # language/runtime-specific setup├── 50-plugins.zsh # zsh-ecosystem plugins└── 90-local.zsh # machine-specific, not tracked in git环境变量先加载;函数放在后面;mise activate、zoxide init 之类工具生成的 hook 单独放到 35-tools.zsh;语言配置依赖这些 hook,所以排在 40。插件最后加载,其中 syntax highlighting 必须放到最后,否则其他修改 line editor 的插件可能和它冲突。
PATH 不要越叠越长
~/.zshenv 会在子 shell 里重复执行。直接这样写:
export PATH="$HOME/.local/bin:$PATH"每开一层 shell 都会再加一次。tmux、exec zsh 和 subshell 用久后,PATH 里会出现很多重复项。
我用一个幂等的 prepend:
# Prepend to PATH only if not already present.path_prepend() { case ":$PATH:" in *":$1:"*) ;; # already there — do nothing *) PATH="$1:$PATH" ;; esac}
path_prepend "$HOME/.local/bin"前后加冒号是为了让首尾元素也能按完整路径匹配。这段放在 00-env.zsh。
chezmoi 的源目录不是主目录
我不直接把一个 Git 仓库软链接进 $HOME,而是使用 chezmoi。它保存一份 source directory,再把内容渲染到主目录。文件名前缀同时声明目标属性:
dot_:目标文件名前面加点,例如dot_zshenv变成~/.zshenv。private_:目标权限为0600。executable_:设置可执行位。exact_:目录中不在声明里的内容会被移除。run_onchange_:内容变化时运行脚本。应用篇用它在 Brewfile 变化后执行brew bundle。
映射大概是这样:
chezmoi source repo → rendered into $HOME├── dot_zshenv → ~/.zshenv├── dot_config/│ └── zsh/│ ├── dot_zshrc → ~/.config/zsh/.zshrc│ └── conf.d/│ ├── 00-env.zsh → ~/.config/zsh/conf.d/00-env.zsh│ └── 20-aliases.zsh → ~/.config/zsh/conf.d/20-aliases.zsh└── dot_local/ └── bin/ └── executable_zhealth → ~/.local/bin/zhealth (chmod +x)用 zhealth 找回偷偷出现的文件
完成后,$HOME 里应该只有一个 zsh 文件:~/.zshenv。如果又出现 ~/.zshrc,大概率是某个安装器写的。
# 30-functions.zsh — flag stray zsh files in $HOMEzhealth() { local stray=(~/.zshrc(N) ~/.zprofile(N) ~/.zlogin(N) ~/.zshrc.*(N)) if (( ${#stray} )); then print -u2 "zhealth: unexpected zsh files in \$HOME:" printf ' %s\n' "${stray[@]}" >&2 return 1 fi print "zhealth: \$HOME is clean — only ~/.zshenv expected"}安装新工具后跑一下 zhealth,比过几个月再从 shell 异常里猜原因简单得多。维护篇会继续用这种 health check。
哪些东西不要跟踪
~/.zsh_history 是状态而不是配置,还可能包含隐私;.zcompdump* 是可以重新生成的缓存;凭据和 token 更不应该直接进普通 dotfiles 仓库。
.config/zsh/.zcompdump*.zsh_history90-local.zsh 专门放每台机器不同、或者不应该上传的配置,例如公司代理和本地 alias。它最后加载,允许覆盖前面的设置,但不会进入 Git。
最后 $HOME 里只剩一个入口文件,实际配置拆成有顺序的小片段,chezmoi 记录目标状态,zhealth 负责告诉我什么时候又被安装器污染了。