1312 字
3 分钟
简体中文

不用 ~/.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 只有一行:

Terminal window
# ~/.zshenv — the only zsh file allowed to live in $HOME
export ZDOTDIR="${XDG_CONFIG_HOME:-$HOME/.config}/zsh"
使用 ZDOTDIR 前后的主目录 之前 ~/ .zshenv.zshrc .zprofile.bash_profile .npmrc.gitconfig .python_history.zsh_history .cargo/ .rustup/ … (继续增长) 之后(ZDOTDIR) ~/ .zshenv → 设置 ZDOTDIR ~/.config/zsh/ .zshrc00-env.zsh 20-aliases.zsh30-functions.zsh 35-tools.zsh …

这是 zsh 官方支持的机制,不是什么软链接技巧。VS Code 的集成终端会读取 ~/.zshenv,所以也能正常使用。少数老工具会死认 ~/.zshrc,遇到时再单独判断要不要迁就它。

把一个长文件拆开#

移动路径后,如果只是把原来的几百行原样搬过去,也没解决多少问题。我的 .zshrc 只负责按顺序加载片段:

Terminal window
# ~/.config/zsh/.zshrc — loads every fragment in numeric order
for _file in "${ZDOTDIR}"/conf.d/*.zsh(N); do
source "$_file"
done
unset _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 activatezoxide init 之类工具生成的 hook 单独放到 35-tools.zsh;语言配置依赖这些 hook,所以排在 40。插件最后加载,其中 syntax highlighting 必须放到最后,否则其他修改 line editor 的插件可能和它冲突。

PATH 不要越叠越长#

~/.zshenv 会在子 shell 里重复执行。直接这样写:

Terminal window
export PATH="$HOME/.local/bin:$PATH"

每开一层 shell 都会再加一次。tmux、exec zsh 和 subshell 用久后,PATH 里会出现很多重复项。

我用一个幂等的 prepend:

Terminal window
# 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,大概率是某个安装器写的。

Terminal window
# 30-functions.zsh — flag stray zsh files in $HOME
zhealth() {
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 仓库。

.chezmoiignore
.config/zsh/.zcompdump*
.zsh_history

90-local.zsh 专门放每台机器不同、或者不应该上传的配置,例如公司代理和本地 alias。它最后加载,允许覆盖前面的设置,但不会进入 Git。

最后 $HOME 里只剩一个入口文件,实际配置拆成有顺序的小片段,chezmoi 记录目标状态,zhealth 负责告诉我什么时候又被安装器污染了。

不用 ~/.zshrc 的 dotfiles:ZDOTDIR 与 chezmoi
https://www.shiinayane.com/zh/posts/dotfiles/
作者
YANKAI WANG
发布于
2026-05-30
许可协议
CC BY-NC-SA 4.0