2026年6月12日
macOSAutomationDotfilesGitlaunchd

我的笔记本现在会自己提交配置

一套会自我修复的 dotfiles 方案:软链接的 git 和 shell 配置、一个盯着 Homebrew 的 launchd 代理,以及能在换机之后自动存活、无需手动备份的身份路由。

3 分钟阅读

我发表了一篇关于如何在换机后全身而退的文章。一周后,我发现自己的配置早就已经翻车了。修好它用上了 git、软链接、launchd,还有一个让人清醒的下午。

一周前,我发了一篇关于工作区架构的文章。

当时的论点很自信:

如果我的机器今天就报废,我应该能从单独一个目录把一切重建出来。

所有东西都在 ~/Developer/ 下面。代码仓库、基础设施、数据。一个目录,完全可迁移。

我当时对这个结论相当满意。

然后,几天后,我让我的 AI 助手把我的 Claude Code 技能文件夹备份进新的结构里。

它给我的回复出乎意料:

ls: /Users/paurushrai/.claude/skills/: No such file or directory

那个文件夹根本不存在。

可我明明有技能。一个叫 graphify 的自定义技能,我做过它,天天用它,而且我的全局配置里还引用着它。那它到底在哪儿?

/Users/paurushrai/Downloads/mac-restore/dot-claude/skills/graphify/

就在那儿。在我的 Downloads 文件夹里。在一个叫 mac-restore 的目录深处。

从我换机那天起就一直躺在那儿。从没被恢复过。被彻底遗忘了。

在 ~/Downloads/mac-restore 里找到的 graphify 技能,换机后从未被恢复过

我明明写过一整篇文章,专门讲这种失效模式,而我自己的机器,却一直悄无声息地活在这个坑里。

🔍 那个盲区

我漏掉的是这个。

我的架构把一切都组织在 ~/Developer/ 内部。但让一台机器感觉"是我的"那部分配置,根本不在那里:

  • Git 身份和行为:~/.gitconfig
  • Shell 设置:~/.zshrc、~/.zprofile
  • 全局忽略规则:~/.gitignore_global
  • AI 编码规则:~/.claude/CLAUDE.md
  • 自定义 AI 技能:~/.claude/skills/
  • 还有最隐形的那一个:已安装软件包的清单,它哪儿都不存在

这些全都散落在 $HOME 里。没有版本管理,没有备份,对我那套漂亮的目录结构完全隐形。

当我去查 ~/.gitconfig 评估损失时,情况比我想的还糟:

cat ~/.gitconfig
# → no such file

这个文件根本不存在。在旧机器上,我设置过邮箱路由:个人项目用个人邮箱,公司仓库用工作邮箱。这些都没能存活下来。我已经这样不知不觉提交了好几个月。

代码迁移过来了。配置没有。

🔗 Unix 里最古老的那招

我的第一反应是最直接的那种:建一个备份文件夹,把 dotfiles 拷进去,推到 GitHub。

但拷贝会漂移。一旦 .gitconfig 有了两个版本,其中一个就是谎言,而且你不会知道是哪一个,直到出事为止。

真正的解法比我年纪都大:软链接。

只有一个文件。它住在仓库里。主目录只拿到一个指针:

~/.gitconfig  ──symlink──▶  ~/Developer/infra/dotfiles/.gitconfig
                            └── the ONLY real file

仓库里一个真实文件,主目录只持有指向它的软链接指针

当我问这是不是意味着我还得手动把改动同步回去时,得到的答案让整个设计成立:

写入会顺着软链接走。

运行 git config --global 改设置,git 会顺着指针,直接写进仓库里的那个文件。编辑 ~/.zshrc,你编辑的就是仓库文件。没有什么需要同步,因为根本没有第二份可以同步的东西。一个文件,两个地址。

于是我就这么搭了起来。在我的基础设施层里放一个 dotfiles 仓库,存放真正的文件,配一个幂等的 install.sh,在新机器上重建每一个软链接:先把发现的旧文件备份一份,可以放心运行两次。

Git 配置、shell 配置、全局忽略规则、我的 AI 规则、还有那个被救回来的 graphify 技能。提交。推送。永久保存。

有一条规矩,是我在检查差点被提交进仓库的内容时学到的:绝不提交密钥。我的 .zshrc 现在会引入一个 ~/.zshrc.local,git 永远看不到它。仓库拿到结构,机器留住秘密。

存放真实配置文件的 dotfiles 仓库,install.sh 重建每一个软链接

我以为大功告成了。

🍺 Homebrew 不写日记

然后我试着回答一个简单的问题:如果这台机器报废了,我需要重装些什么?

Git 配置有个很美的特性:工具会把设置写进自己的配置文件,所以仓库永远知道真相。Homebrew 没这规矩。brew install jq 装好了包,但这个决定被记录在哪儿都没有。包列表只以隐式的方式存在,也就是 /opt/homebrew/Cellar 里的一堆文件夹。

Homebrew 可以按需导出一份清单:

brew bundle dump --global --force
# → writes .Brewfile: brew "gh", brew "git", brew "tree"...

于是我把 .Brewfile 也像其他文件一样软链接进了仓库。但这里有个隐患,一直让我不放心:

Brewfile 只会在你记得去导出它的时候才更新。

我整个设计的前提就是"我不会记得"。软链接存在的全部理由就在这儿。一份依赖我自觉性的清单,等我真需要用到它的时候,早就过时了三个月。

🧪 我第一次的尝试是错的

第一个方案听上去很聪明:把 brew 命令包一层 shell 函数。拦截每一次安装和卸载,重新导出清单,自动提交,自动推送。

我做出来了。第一次就成功了:

$ brew install jq
...
$ git log --oneline -1
df053cb chore(brew): install jq

漂亮。提交信息里连包名都有。

然后我又看了它一眼,发现了那个漏洞。

一个 shell 包装函数只能捕获到我的交互式 shell里打出的命令。一个安装依赖的初始化脚本?看不见。一个在会话里跑 brew 的 AI 代理?看不见。任何不是加载了我配置的 zsh 的场景?全都看不见。

我自动化了接口,而我需要自动化的是事件。

包安装本身才是那个事件,它发生在文件系统里,而不是在我的 shell 里。

⚙️ 操作系统早就有答案了

我问:能不能用 launchd 来做这件事?

launchd 是 macOS 的服务管理器,启动系统上每一个守护进程的那个东西,苹果版的 systemd。每个 Mac 开发者都见过它。几乎没人用过它最好的功能:

WatchPaths:launchd 会在操作系统层面监视一个路径,一旦它发生变化就立刻触发一个任务。

<key>WatchPaths</key>
<array>
    <string>/opt/homebrew/Cellar</string>
    <string>/opt/homebrew/Caskroom</string>
</array>

现在,谁装的包已经无关紧要:我的 shell、一个脚本、一个代理,什么都行。Cellar 一变化,系统就发现,我的同步脚本就跑起来:

# sync.sh, the whole engine
brew bundle dump --global --force    # snapshot packages
git add -A                           # stage any dotfile drift
git diff --cached --quiet && exit 0  # nothing changed? exit silently
git commit -q -m "chore: auto-sync ${CHANGED}"
git push -q                          # offline? commits locally, pushes next time

再加上每天中午的一次兜底运行,以及 60 秒的节流,装五个包只产生一次提交,而不是五次。

然后是测试。装一个包,别的什么都不碰,盯着看:

$ brew install jq
... 8 seconds later, zero shell involvement:
67ba27e chore: auto-sync .Brewfile
→ pushed to GitHub ✅

八秒。机器给自己的变化写了记录、提交了、推送了,而我就坐在那儿看着。

同步流程:brew install 改变 Cellar,launchd 触发 sync.sh,后者负责提交和推送

我当分钟就删掉了那个 shell 包装函数。没有第三方工具,没有定时轮询的 cron,不用装什么守护进程。操作系统一直都自带这个答案。

一旦这套对包管理生效了,下一步就顺理成章:监视 dotfiles 本身。现在编辑 .zshrc 或我的 AI 规则,会在一分钟内自动提交,文件名会出现在提交信息里。

🔁 我差点挖给自己的坑

这里有个决定会咬到每一个照着做的人,值得坦白一下。

偷懒的做法是监视仓库的根目录,一条监视规则,覆盖一切。

但每一次 git 提交都会改动 .git/。监视仓库根目录,你就建成了一条衔尾蛇:改动触发代理 → 代理提交 → 提交改变了 .git/ → 而这又会触发代理 → 无限循环。

所以这个代理监视的是单个文件,绝不监视包含 .git 的那个目录。代价是:某个深层子目录里冒出的全新文件不会被立刻发现,每日的例行运行会把它扫进来。这一点写在了 README 里,是个公平的代价。

会触发自身的自动化,正是那种凌晨两点才会被你发现的 bug。

🔔 让它学会说话

还剩一个问题,关于信任。

这个代理现在替我提交东西,悄无声息。一个沉默的守护进程,是一个你会逐渐不再信任、甚至彻底忘记它存在的守护进程。

我给脚本加了两行 macOS 原生通知:

osascript -e 'display notification "Committed & pushed: .Brewfile" with title "dotfiles sync"'

第一次测试:我根本没看见。横幅通知五秒就淡出了,而我正看着别处。

解决办法不是代码,是一个设置项。macOS 通知有两种样式,几乎没人改过默认值:系统设置 → 通知 → Script Editor → 提醒式。提醒式通知会一直停留在屏幕上,直到你手动关掉它。

现在每次同步都会在角落等我确认,消息里带着改动的文件名。我机器的配置历史,以回执的形式送到我面前。

而且它只在真正发生改动时才出声,空跑一次会保持沉默。一个每分钟都响的通知,是一个你早就学会无视的通知。

🎭 那个偏执的问题

就在一切都运转顺畅的时候,我想到了那个能拆穿大多数同类方案的问题:

如果我用我的工作账号登录 GitHub 会怎样?

我去查了查,发现"登录 GitHub"其实是三个独立的层,大多数人从来没把它们分开过。

第一层:谁写了这次提交? 完全由 git 配置决定。一次提交就是本地写一个文件,GitHub 根本没被联系上。我的 includeIf 规则把公司仓库路由到工作邮箱,其他一切都用个人邮箱。CLI 登录状态碰不到这一层。

第二层:谁有权推送? 由 macOS 钥匙串里的凭据决定。这里有个脆弱之处:钥匙串是按主机存储凭据的。两个账号,同一个 github.com,第二次登录可能悄悄覆盖第一次,于是我的同步代理突然用错误的身份推送,收获一堆 403。

第三层:gh CLI 以谁的身份行事? whatever 当前激活的账号。这跟我的同步无关,它只讲纯 git。

三个身份层:谁写了提交、谁能推送、gh CLI 又以谁的身份行事

第二层才是真正的风险敞口,而修法只需要一行:

git remote set-url origin https://paurushrai@github.com/paurushrai/dotfiles.git

那个 paurushrai@ 不是邮箱,它是 URL 语法,跟 ssh 里的 user@host 一样。URL 里带上用户名之后,钥匙串会按主机 + 用户名归档凭据。不同账号,不同抽屉,不可能互相覆盖。

这背后的原则,跟邮箱路由是同一个,值得说明白:

身份应该是仓库的属性,而不是某个全局可变状态的属性。

🚀 十分钟的机器

故事到这里就该收尾了。

下次我拆一台新 Mac,整个流程就是这些:

# 1. Homebrew, then the two bootstrap tools
brew install gh git
gh auth login
# 2. Clone the config, link everything, reinstall every package
gh repo clone paurushrai/dotfiles ~/Developer/infra/dotfiles
~/Developer/infra/dotfiles/install.sh
brew bundle --global
# 3. Re-authenticate, credentials never migrate, by design
claude login

而真正让我满意的部分是:install.sh 不只是恢复配置。它还会把 launchd 代理一并装上。

新机器接收到的不是我这套设置的一份快照。它接收到的是维护这套设置的那个系统,从第一分钟起,就开始自我维护。

我刻意排除在外的东西同样重要:

  • 凭据:一个密钥的备份方案就是重新登录一次。十秒钟,故意这么设计的。
  • 缓存、会话状态、构建产物:自然会重新生成。备份它们等于囤积垃圾。
  • 数据层:Postgres 和 Redis 的数据卷是庞大、活跃、持续变化的二进制文件。Git 是错误的工具,拷贝一个正在运行的数据库只会得到损坏的垃圾。数据需要真正的快照备份,这是下一篇文章的话题。

🎯 我真正学到的东西

上一篇文章的结论是:你的工作站是一个系统,去架构它。

这一周教给我的推论是:靠自觉维护的系统会腐坏。能自我维护的系统会复利增长。

看看这个故事里每一处修复的形状:

  • 我会忘记拷贝配置文件 → 于是只留一个文件,软链接,没有副本
  • 我会忘记更新包清单 → 于是操作系统监视文件系统
  • 我会忘记提交 → 于是代理替我提交
  • 我会错过静默的失败 → 于是通知会一直等我确认
  • 我会忘记下次再把这一切重新搭一遍 → 于是安装程序连自动化本身都会重装

每一条都是用"它自己就会做"替换掉了"记得要做"。

那个技能文件在我的 Downloads 文件夹里死躺了几个月,只是因为恢复它这件事依赖于我记得它存在。现在我机器上没有任何东西,还依赖于我的记忆。

老实说,这是我给自己发布过的最好的功能。

你的配置层长什么样?

Dotfiles 仓库不是什么新鲜想法。但大多数方案止步于"仓库里的文件加一个安装脚本",真正有意思的部分,是第一天之后,如何让仓库始终保持真实。

工具会一直变。架构应该扛得住这种变化。