这个博客的 src/content/posts/ 目录挂了一个本地 watcher:用 fswatch 做防抖自动提交。逻辑很简单——监听目录变化,安静 5 分钟后 git add -A && commit && pull --rebase && push。git log 里那一长串 auto update from watcher 就是它干的活。
今天做日常维护时发现:会话开头有两篇新文章(一篇当天写的 litestream、一篇 6/17 的分布式自博弈),静静躺在工作区里没被提交。按理说写完 5 分钟就该自动进 main 的。watcher 失灵了。
记录一下排查过程,因为根因和我一开始的猜测完全不一样,而且坑很有代表性。
进程是活着的
第一反应是进程挂了。结果不是:
pgrep -fl 'git-auto-blog-watch|fswatch'66038 /bin/zsh /Users/user/Codes/blog/git-auto-blog-watch.sh66084 /opt/homebrew/bin/fswatch -o /Users/user/Codes/blog/src/content/posts66087 /bin/zsh /Users/user/Codes/blog/git-auto-blog-watch.sh主循环、fswatch、读管道的子 shell,三个进程都在。launchd 里也正常注册:
launchctl list | grep git-auto-blog66038 0 com.lishuyu.git-auto-blog进程活着、launchd 在管,但就是不提交。这种”活着的尸体”最难查——它不报错,只是默默什么都不做。
想看日志,结果日志是空的
plist 把 stdout/stderr 重定向到了 /tmp/blogpush.out 和 /tmp/blogpush.err。打开一看:
tail -30 /tmp/blogpush.out /tmp/blogpush.err两个文件都是空的。脚本里明明每一步都 echo 了”Change detected”、“Pushed to main”之类,怎么会一行都没有?
这里是第一个关键点:长驻进程往非终端(文件/管道)写 stdout 时,libc 默认是全缓冲(block-buffered),不是行缓冲。 终端才行缓冲。脚本是个永不退出的死循环,那些零碎的小 echo 攒不满几 KB 的缓冲区,也就一直不 flush——launchd 的重定向文件自然永远是空的。
也就是说,出问题的这八天里,我完全没有任何可观测性。这本身就是一个需要修的 bug:一个跑在后台、决定要不要往 main 推代码的进程,不能是个黑盒。
用 reflog 定位失效时间
既然日志没用,换 git 自己的账本——reflog 带时间戳:
git reflog --date=iso | grep 'auto update from watcher' | head -3fbdc8a5 HEAD@{2026-06-16 02:07:26}: commit: auto update from watcherb628141 HEAD@{2026-06-15 22:03:58}: commit: auto update from watcher4eef392 HEAD@{2026-06-14 12:49:33}: pull --rebase ... auto update from watcher最后一次成功的自动提交是 6/16 02:07。而进程是 6/7 启动的、一直没重启。所以不是”启动失败”,是”跑着跑着,从 6/16 起就再也提交不出去了”。
6/16 之后发生了什么?我去翻工作区,发现 src/content/posts/ 里躺着一个文件:
2026-04-13-us-iran-blockade 1.md注意文件名里那个空格,还有 macOS 复制文件惯有的 1 后缀。这是个空壳——标题、正文全空,明显是哪次手滑复制出来的副本。
根因:git add -A 和 pre-commit 钩子的合谋
这个仓库有个 pre-commit 钩子,专门拦截文件名带空格的文章(因为空格会变成 URL 里的 %20,slug 很丑)。钩子核心就这几行:
SPACED_POSTS=$(git diff --cached --name-only --diff-filter=AM -z -- 'src/content/posts/' \ | tr '\0' '\n' | grep -E '\.md$' | grep ' ' || true)if [[ -n "$SPACED_POSTS" ]]; then echo "Pre-commit: blocked. Post filenames must not contain spaces:" >&2 exit 1fi只要暂存区里有带空格的 .md,就 exit 1,整个提交失败。
把两件事拼起来,失效链条就清楚了:
- watcher 用的是
git add -A——stage 一切,包括那个带空格的空壳文件。 git commit触发 pre-commit 钩子,钩子看到带空格的文件,exit 1。- commit 失败。而我那两篇正常文章是和坏文件在同一次
git add -A里一起被 stage 的,于是跟着一起没提交成功。
一个手滑复制出来的空壳,凭一个文件名里的空格,把后面八天所有正常文章全堵死了。git add -A 的”全都要”在这里成了致命的耦合——一颗老鼠屎坏一锅汤。
那篇文章我这次维护时本来就删掉了,所以眼下的堵塞解除了。但这是个设计脆弱性:只要再出现一个带空格的文件,同样的事会再发生一次,而且照样无声无息。得从根上加固。
加固一:让一个坏文件名堵不住其它文章
第一性原理上,watcher 的职责是”把合法文章自动提交上去”。一个带空格的文件名是畸形输入,不该让它连累正常文章。所以在 commit 前,把暂存区里带空格的 .md 单独 unstage 掉,并大声记日志:
unstage_spaced_posts() { local spaced spaced=$(git diff --cached --name-only --diff-filter=AM -z -- 'src/content/posts/' \ | tr '\0' '\n' | grep -E '\.md$' | grep ' ' || true) if [[ -n "$spaced" ]]; then log "WARNING: post filename(s) contain spaces — un-staging so they don't block the commit:" print -r -- "$spaced" | while IFS= read -r f; do [[ -z "$f" ]] && continue git reset -q -- "$f" log " un-staged: $f" done fi}坏文件留在磁盘上(不删,避免误伤真有内容的文件),但不进这次提交。正常文章照常流向 main。下一轮 git add -A 又会把它 stage,再 unstage 一次——稍微浪费但完全无害,而且每轮都在日志里留痕,你能一眼看到”哦有个文件名带空格该改了”。
为了确认这套逻辑真的成立,我在一个临时仓库里用同一份 space-guard 钩子跑了复现 + 修复两步验证:
=== TEST 1: 复现 ===REPRODUCED: 暂存区有带空格文件时,每次 commit 都被钩子挡下=== TEST 2: 修复 === un-staged: src/content/posts/blockade 1.mdstaged now: [src/content/posts/good-post.md ]FIXED: commit SUCCEEDEDHEAD contains: [src/content/posts/good-post.md ]空格文件仍在磁盘上(只是没被提交): blockade 1.mdTEST 1 重现了八天来的故障,TEST 2 证明 unstage 之后 commit 成功、只含正常文章、坏文件没丢。机制确认。
加固二:把日志写进文件,而不是赌缓冲区会 flush
stdout 被缓冲吞掉的问题,根上是”依赖 stdout”本身不靠谱。改成显式写文件,用 >> 逐行追加(每条立即落盘),同时保留 echo 兼容 launchd:
LOG_FILE="$HOME/Library/Logs/blogpush.log" # 在 repo 外,不会被 git add -A 误提交
log() { local line="[$(date '+%Y-%m-%d %H:%M:%S %z')] $*" print -r -- "$line" >> "$LOG_FILE" print -r -- "$line"}几个刻意的决定:
- 日志放
~/Library/Logs/,不放仓库里。 放仓库里会被git add -A反手提交进 main,荒谬。~/Library/Logs是 macOS 给用户日志的标准位置,重启也不丢。 - 每条带时间戳。 排查时”什么时候停的”是第一个要回答的问题,reflog 能给但日志该自带。
- 加了 5MB 轮转,免得跑几个月把日志撑爆。
然后把 commit_and_push 里每个分支——commit 成功、pull --rebase 失败、push 失败、钩子失败转 fail-open PR——全部接上 log。以后再出问题,第一手就有时间线。
加固三:fswatch 管道死了要能自愈
还有个隐蔽的失效模式:脚本是 fswatch ... | while read; do ...; done & 这样把读管道丢后台的。万一 fswatch 那根管道断了,主循环还在跑、sleep 5 照转,但再也收不到任何文件事件——又是一具活着的尸体。加个看门狗,发现读管道的子进程没了就重启它:
while true; do sleep 5 if ! kill -0 "$FSWATCH_PIPE_PID" 2>/dev/null; then log "WARNING: fswatch pipe died — restarting it" # ... 重新拉起 fswatch 管道 ... fi # ... 到点就 commit_and_push ...done重启:改完脚本必须 unload + load
最后一个坑,跟 launchd 有关。改完脚本,长驻进程不会自己重新加载代码——它还在内存里跑 6/7 那份旧脚本。必须显式重启:
PLIST=~/Library/LaunchAgents/com.lishuyu.blogpush.plistlaunchctl unload "$PLIST"# 顺手收尾可能残留的孤儿子进程pkill -f 'git-auto-blog-watch.sh'launchctl load "$PLIST"顺带踩到一个历史遗留的别扭点:plist 文件名叫 com.lishuyu.blogpush.plist,但里面的 Label 是 com.lishuyu.git-auto-blog。launchctl 认的是 Label,所以查状态得 grep git-auto-blog,而 unload/load 的参数是 plist 文件路径。两个名字对不上,第一次找的时候绕了一下。
重启后确认跑的是新代码:
launchctl list | grep git-auto-blog # 83463 0 com.lishuyu.git-auto-blog(status 0,健康)tail -2 ~/Library/Logs/blogpush.log[2026-06-24 19:27:35 -0400] === watcher started (pid 83463), watching .../posts, debounce 300s ===[2026-06-24 19:29:54 -0400] Change detected, next commit at 19:34:54新进程起来了,而且日志真的在写了——它捕获到一次真实的文件变更,排好了 5 分钟后的提交。又等了一轮,看到定时周期如期触发:
[2026-06-24 19:34:57 -0400] No changes to commit那次变更对应的文章已经被另一条路径提交掉了,所以 git add -A 没发现新东西、正确地记了”No changes to commit”。整条链路——检测、防抖、到点执行、落日志——带时间戳跑通了。
经验总结
git add -A是隐式的全局耦合。 自动化脚本里用它,等于让目录里任何一个畸形文件都有权否决整批提交。要么收窄成精确 pathspec,要么像这次一样在提交前主动剔除已知的畸形项。- 长驻进程的 stdout 不是日志。 非终端默认全缓冲,你 echo 的东西可能几天都不落盘。要可观测性就显式写文件、逐行 flush、带时间戳,并且放在仓库外。
- “进程还在”不等于”功能还在”。 这次三个失效模式——commit 被钩子堵、日志被缓冲吞、(潜在的)fswatch 管道断——全都不会让进程退出。健康检查不能只看
pgrep,得看它有没有在产出该产出的东西(这里就是 reflog 里的提交节奏)。 - 日志为空时,reflog 是时间机器。
git reflog --date=iso带时间戳,定位”什么时候停的”一针见血,比翻一堆空日志快得多。