从 GNU Stow 迁移到 Chezmoi
摘要
作者分享了从 GNU Stow 迁移到 Chezmoi 来管理多台机器上的点文件的经验,指出 Chezmoi 的真实文件方法和模板功能是主要的改进点。
暂无内容
查看缓存全文
缓存时间: 2026/06/18 17:50
# 从 GNU Stow 迁移到 chezmoi
来源:https://rednafi.com/misc/chezmoi/
我管理 dotfiles 已经用了几年 GNU Stow (https://www.gnu.org/software/stow/)。甚至在 2023 年还写过一篇有土味标题 (https://rednafi.com/misc/dotfile-stewardship-for-the-indolent/) 的文章来介绍这套方案。Stow 用着还行,但随着多台设备间的符号链接管理逐渐变得令人头疼,我开始寻找更好的工具,甚至考虑过自己写一个。后来同事推荐了 chezmoi (https://www.chezmoi.io/),到目前为止我非常喜欢它。它满足了我所有的需求,而且我已经开始用它来追踪我的智能体技能文件。
## 设备环境
https://rednafi.com/misc/chezmoi/#the-machines
我有三台 Mac:一台工作用的 MacBook Pro、一台个人用的 MacBook Air、还有一台充当小型个人服务器的 Mac Mini。Mac Mini 大多时候我是通过 SSH 从另外两台机器访问的。它本质上还是一台运行着我的 shell 的 Mac,所以同样的 dotfiles 也适用。
我还有一些 Linux 虚拟机,但很少需要在服务器上使用 dotfiles。那些服务器由 Ansible 配置。这套工作流严格针对桌面设备。
## 何时我超出了 Stow 能力范围
https://rednafi.com/misc/chezmoi/#when-i-outgrew-stow
Stow 的模型是符号链接。配置文件存放在一个 git 仓库中,按目录分组(Stow 称之为 package),stowing 一个 package 会将其文件链接到家目录。对于单台机器来说,这依然够用。命令是幂等的,而且几乎没什么学习成本。
问题在于符号链接是双向的。每台设备上的每次编辑都会直接写入该设备仓库克隆中的文件。几个月后我会在 Air 上发现脏的工作树,上面有我完全不记得做过的修改。其中一半还跟 Pro 已经推送的内容冲突。让三个克隆保持同步变成了一件苦差事。
新设备是另一个问题。Stow 不会在已有真实文件的地方创建链接。当 Homebrew 和一些工具在新 Mac 上运行后,像 `~/.zprofile` 和 `~/.gitconfig` 这样的文件已经存在。引导启动意味着需要克隆仓库,手动删除冲突文件,然后再重新 stowing 每个 package,还得努力回想我当初是怎么命名的。而且 Stow 只能处理文件。Homebrew 包和 macOS 设置则保存在独立的脚本中,我必须记得按正确顺序运行。
## chezmoi 的工作原理
https://rednafi.com/misc/chezmoi/#how-chezmoi-works
Chezmoi 在 `~/.local/share/chezmoi` 维护一个源目录,这是一个常规的 git 仓库。`chezmoi add ~/.zshrc` 会将实际文件复制到该目录,并将副本命名为 `dot_zshrc`。添加 `~/.config/gh/config.yml` 会创建 `dot_config/gh/config.yml`,父目录也会一并创建。我从不需要手动创建这些名称,因为 `chezmoi add` 会根据真实路径自动推导出它们。整个目录树镜像了家目录的结构,每个以点开头的名称都会被编码成 `dot_` 前缀。
`dot_` 是 chezmoi 编码进文件名中的几种属性 (https://www.chezmoi.io/reference/source-state-attributes/) 之一。`private_` 前缀会去除组和其他用户的权限。`.tmpl` 后缀则将文件变为一个 Go 模板,可以读取每台设备的数据。我很少使用模板,但每一个用到的在后面都会提到。
`chezmoi apply` 是反向操作。它会将每个被追踪的文件写回到其名称所对应的家目录路径,所以 `dot_zshrc` 会位于 `~/.zshrc`。这些副本是真实文件,不是符号链接。源目录是唯一真实来源。当家目录中的某个文件与其源副本不匹配时,`chezmoi diff` 会显示差异,而下次 apply 后就会恢复一致。
失去符号链接的自动双向写入,恰恰是我最喜欢的一点。除非我刻意将一个改动放进去,否则仓库中不会发生任何变化。
## 我追踪的内容
https://rednafi.com/misc/chezmoi/#what-i-track
所有内容都在那个源目录里。`chezmoi cd` 会切换到该目录的一个子 shell 中,下面是完整的目录树:
```
~/.local/share/chezmoi
├── .chezmoi.toml.tmpl
├── .chezmoiignore
├── .chezmoiscripts
│ └── macos
│ ├── run_onchange_after_disable-macos-animations.sh
│ ├── run_onchange_after_init-macos-machine.sh.tmpl
│ └── run_onchange_before_install-homebrew-bundle.sh.tmpl
├── .gitignore
├── Brewfile
├── README.md
├── dot_agents
│ └── skills
│ ├── go-modernize
│ ├── go-styleguide
│ └── meatspeak
├── dot_claude
│ ├── settings.json
│ └── symlink_skills.tmpl
├── dot_codex
│ └── private_config.toml
├── dot_config
│ ├── gh
│ │ ├── config.yml
│ │ └── private_hosts.yml
│ └── ghostty
│ └── config
├── dot_gitconfig
├── dot_gitconfig-pers
├── dot_gitconfig-werk
├── dot_shellcheckrc
├── dot_zsh_aliases
└── dot_zshrc
```
列表很短,因为我不喜欢定制工具,尽可能使用默认设置。主要的 dotfiles 是 zsh、git、shellcheck、ghostty (https://ghostty.org/) 和 GitHub CLI 的配置文件。我还追踪了 Claude Code 的 `settings.json` 和 Codex 的 `config.toml`,这样智能体在所有设备上行为一致。`private_` 前缀用于 gh 的 `hosts.yml` 和 Codex 配置,将它们设为 `0600` 权限。最后我会谈到 `dot_agents` 下面的技能文件。
三个 gitconfig 文件分别对应我的不同身份。我所有的项目都放在两个目录下:`~/canvas/werk/` 用于工作,`~/canvas/pers/` 用于个人所有内容,两台机器上都有这两个目录。主 gitconfig 根据仓库所在位置路由身份:
```
[includeIf "gitdir:~/canvas/pers/"]
path = ~/.gitconfig-pers
[includeIf "gitdir:~/canvas/werk/"]
path = ~/.gitconfig-werk
```
`~/canvas/pers/` 下的仓库使用我的个人邮箱,`~/canvas/werk/` 下的使用工作邮箱。这是普通的 Git 功能,不是 chezmoi 模板,但 chezmoi 保证这三份文件在所有机器上存在。
最上面的 `.chezmoi.toml.tmpl` 是 chezmoi 自己的配置模板。它会在第一次运行时询问机器名称,然后将答案记住并写入 `~/.config/chezmoi/chezmoi.toml`:
```
{{- $machineName := promptStringOnce . "machineName" "machineName" .chezmoi.hostname -}}
[data]
machineName = {{ $machineName | quote }}
```
机器设置脚本会读取该值来设置主机名。这是整个仓库中唯一一个每台机器不同的数据。其他所有内容在所有机器上都相同。我保持这样一部分是为了简单,一部分也是因为我不是很习惯 Go 的模板语法,所以越少折腾越好。
`.chezmoiignore` 列出了 `README.md`、`Brewfile` 和 `Brewfile.lock.json`,这样这三个文件只会保存在源目录中,而不会被写入家目录。普通的 `.gitignore` 则将锁文件排除出版本控制。我会在下节介绍 `Brewfile` 和 `.chezmoiscripts` 下的脚本。
## 引导一台新 Mac
https://rednafi.com/misc/chezmoi/#bootstrapping-a-new-mac
先安装 Homebrew,然后整个设置只需要两个命令:
```
brew install chezmoi
chezmoi init --apply \
--promptString machineName=mini \
https://github.com/rednafi/dotfiles.git
```
`chezmoi init` 将仓库克隆到 `~/.local/share/chezmoi`,`--apply` 会立即将每个追踪文件写入对应位置。`--promptString` 标志预先回答了配置模板的问题。没有它的话,chezmoi 会交互式询问。脚本会在同一个 apply 过程中执行。
`.chezmoiscripts/` 下的任何内容都会在 apply 期间执行 (https://www.chezmoi.io/user-guide/use-scripts-to-perform-actions/#understand-how-scripts-work),文件名控制执行的时机:
- `before` 脚本在 chezmoi 写入任何文件之前运行。
- `after` 脚本在所有文件都就位之后运行。
- `run_onchange_` 前缀使得脚本只在第一次 apply 时运行,以及后续当其内容发生改变时才再次运行。
在一台新机器上,这个顺序就是:安装 Homebrew 包、放置 dotfiles、然后配置 macOS 本身。`onchange` 部分实现了一个技巧,这个技巧直接来自 chezmoi 文档 (https://www.chezmoi.io/user-guide/use-scripts-to-perform-actions/#run-a-script-when-the-contents-of-another-file-changes)。以下是 Homebrew 脚本的简化版本:
```bash
#!/usr/bin/env bash
# Brewfile checksum: {{ include "Brewfile" | sha256sum }}
# ... 省略
brewfile={{ joinPath .chezmoi.sourceDir "Brewfile" | quote }}
"$brew_bin" bundle check --no-upgrade --file "$brewfile" >/dev/null 2>&1 \
|| "$brew_bin" bundle install --no-upgrade --file "$brewfile"
```
省略的行用于定位 Homebrew 二进制文件并将其路径存入 `$brew_bin` 变量。模板将 `Brewfile` 的哈希内联到注释中。向 `Brewfile` 添加一个包会改变哈希,从而改变渲染后的脚本内容,促使 chezmoi 在下次 apply 时重新运行它。这样 `brew bundle` (https://docs.brew.sh/Brew-Bundle-and-Brewfile) 只在包列表发生变化时触发,否则保持静默。`--no-upgrade` 标志防止它触碰已经安装的包。升级保持手动,因为我希望先看到即将发生的变化。
`Brewfile` 大约有六十行。示例片段:
```
brew "chezmoi"
brew "fzf"
brew "gh"
brew "micro"
brew "ripgrep"
brew "uv"
cask "claude-code"
cask "codex"
cask "ghostty"
cask "raycast"
```
文件写入后还会运行两个脚本。第一个根据 `machineName` 设置主机名,并将每个 macOS 默认设置(否则我需要在每台新机器上通过点击系统设置来设置)写入。第二个关闭了大部分 UI 动画。两者都是长长的普通 `defaults write` 调用列表,具体细节在仓库中。
每个脚本都以 Darwin 检查开始,如果运行在其他地方会提前退出,所以我永远不会在 Linux 机器上应用这些设置。以前我把这些内容放在一个设置脚本中,但常常忘记运行。现在它们成了 `apply` 的一部分,想忘都忘不掉。
## 日常使用
https://rednafi.com/misc/chezmoi/#day-to-day
整个日常工作大约只需要五个命令。
编辑通常从源文件开始。`chezmoi edit` 会打开家目录文件对应的源副本,`--apply` 会在关闭编辑器时将其写入家目录:
```
chezmoi edit --apply ~/.zshrc
```
有时编辑会从另一个方向发生。某个安装程序会在 `~/.zshrc` 末尾追加内容,或者我直接因为习惯修改了实际文件。这时家目录领先于源文件,而 `chezmoi diff` 会显示执行 apply 会撤销我的改动。如果这个改动应该保留,我将实际文件重新导入到源文件中:
```
chezmoi add ~/.zshrc
```
当多个家目录文件领先于它们的源文件时,`chezmoi re-add` 可以一次性全部重新导入。
一旦源状态正确,分享它就是在源仓库中执行普通的 Git 操作:
```
chezmoi cd
git add -A
git commit -m "更新 dotfiles"
git push
exit
```
在其他机器上,同步只需要一个命令:
```
chezmoi update
```
这会拉取仓库并一次性应用。如果想先查看即将应用的内容,我可以拆分命令,先查看差异:
```
chezmoi git pull -- --autostash --rebase
chezmoi diff
chezmoi apply --verbose
```
包也可能与 `Brewfile` 不同步。`brew bundle check` 会报告 `Brewfile` 期望但机器缺失的包,`brew outdated --greedy` 显示过时的包,`brew bundle cleanup` 列出已安装但未追踪的包:
```
brew bundle check --no-upgrade --file "$(chezmoi source-path)/Brewfile"
brew outdated --greedy
brew bundle cleanup --file "$(chezmoi source-path)/Brewfile"
```
## 追踪智能体技能
https://rednafi.com/misc/chezmoi/#tracking-agent-skills
仓库中新增的内容是 LLM 智能体的技能 (https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills)。一个技能是一个文件夹,包含一个 `SKILL.md` 以及所需的其他参考文件。`SKILL.md` 包含名称和描述的前置元数据,后面跟着指令。这种布局直接来自 Agent Skills (https://agentskills.io/home) 规范,这是 Anthropic 发起的一个开放标准,已被越来越多智能体产品采用。
由于格式是标准化的,一份副本应该可以到处使用。我同时使用 Claude Code (https://code.claude.com/docs/en/skills) 和 Codex (https://developers.openai.com/codex/),技能文件存放在 `~/.agents/skills`,Codex 默认会识别这个路径。在 chezmoi 中,这对应一个常规目录 `dot_agents/skills/`,像其他配置一样被追踪。
Claude Code 目前还不支持这个约定。它会在 `~/.claude/skills` 中查找个人技能,不知道 `~/.agents`。解决方法是在源仓库中 `dot_claude/symlink_skills.tmpl` 文件内写入一行内容:
```
{{ .chezmoi.homeDir }}/.agents/skills
```
这里三个名称部分共同作用:
- `dot_claude/` 目录和文件名将目标映射到 `~/.claude/skills`,就像 `dot_zshrc` 映射到 `~/.zshrc`。
- `symlink_` 前缀告诉 chezmoi 将该目标创建为符号链接而不是普通文件,链接指向文件内容中指定的位置。
- `.tmpl` 后缀使 chezmoi 先渲染内容,这样 `{{ .chezmoi.homeDir }}` 会根据实际应用的机器展开为正确的家目录。
执行 apply 后:
```
lrwxr-xr-x 1 rednafi staff 29 Jun 11 17:37 /Users/rednafi/.claude/skills -> /Users/rednafi/.agents/skills
```
有点讽刺的是,我离开 Stow 是为了摆脱符号链接,结果却让 chezmoi 帮我管理唯一的那个符号链接。但这要怪 Anthropic 太嫩,不遵循其他智能体已经采用的约定。现在两个智能体读取相同的技能文件,Git 只保存一份副本,新机器从同一个 `chezmoi init` 中就能一并获得所有内容。
这里的所有内容都存放在我的 dotfiles 仓库 (https://github.com/rednafi/dotfiles) 中。觉得有用的部分尽管拿去用。
相似文章
从 Proxmox 迁移到 NixOS 和 Incus
作者描述了将其家庭实验室从 Proxmox 迁移到使用 Incus 的 NixOS 的过程,强调了声明式配置和可重现性相对于命令式系统的优势。
离开 Magit 后的 Emacs
作者讲述了他们离开 Emacs 的 Magit Git 界面,转而采用 VC-mode 和自定义 Git 脚本等替代方案的经历,重点介绍了其中的调整和所学到的经验教训。
没有地方比得上 $HOME:从 Vim 到 VS Code 再回到 Vim 的十年之旅
关于十年间使用 Vim、切换到 VS Code 再回归 Vim 的个人反思,探讨终端编辑器与 IDE 之间的权衡。
将我的 NAS 从 CoreOS/Flatcar Linux 迁移到 NixOS
Michael Stapelberg 详细介绍了他将一台 NAS 从 CoreOS/Flatcar Linux 迁移到 NixOS 的过程,涵盖了从 Docker 容器逐步过渡到原生 NixOS 模块的步骤,并附有实际示例。
与Codeberg相伴一年
GNU Guix回顾了迁移至Codeberg进行源码托管与协作的一年历程,讨论了决策过程、挑战与成果。