从 GNU Stow 迁移到 Chezmoi

Hacker News Top 工具

摘要

作者分享了从 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

Hacker News Top

作者描述了将其家庭实验室从 Proxmox 迁移到使用 Incus 的 NixOS 的过程,强调了声明式配置和可重现性相对于命令式系统的优势。

离开 Magit 后的 Emacs

Lobsters Hottest

作者讲述了他们离开 Emacs 的 Magit Git 界面,转而采用 VC-mode 和自定义 Git 脚本等替代方案的经历,重点介绍了其中的调整和所学到的经验教训。

将我的 NAS 从 CoreOS/Flatcar Linux 迁移到 NixOS

Michael Stapelberg

Michael Stapelberg 详细介绍了他将一台 NAS 从 CoreOS/Flatcar Linux 迁移到 NixOS 的过程,涵盖了从 Docker 容器逐步过渡到原生 NixOS 模块的步骤,并附有实际示例。

与Codeberg相伴一年

Lobsters Hottest

GNU Guix回顾了迁移至Codeberg进行源码托管与协作的一年历程,讨论了决策过程、挑战与成果。