2387 字
12 分钟
一个文件名里的空格,让博客自动提交悄悄停摆了八天

这个博客的 src/content/posts/ 目录挂了一个本地 watcher:用 fswatch 做防抖自动提交。逻辑很简单——监听目录变化,安静 5 分钟后 git add -A && commit && pull --rebase && pushgit log 里那一长串 auto update from watcher 就是它干的活。

今天做日常维护时发现:会话开头有两篇新文章(一篇当天写的 litestream、一篇 6/17 的分布式自博弈),静静躺在工作区里没被提交。按理说写完 5 分钟就该自动进 main 的。watcher 失灵了。

记录一下排查过程,因为根因和我一开始的猜测完全不一样,而且坑很有代表性。

进程是活着的#

第一反应是进程挂了。结果不是:

Terminal window
pgrep -fl 'git-auto-blog-watch|fswatch'
66038 /bin/zsh /Users/user/Codes/blog/git-auto-blog-watch.sh
66084 /opt/homebrew/bin/fswatch -o /Users/user/Codes/blog/src/content/posts
66087 /bin/zsh /Users/user/Codes/blog/git-auto-blog-watch.sh

主循环、fswatch、读管道的子 shell,三个进程都在。launchd 里也正常注册:

Terminal window
launchctl list | grep git-auto-blog
66038 0 com.lishuyu.git-auto-blog

进程活着、launchd 在管,但就是不提交。这种”活着的尸体”最难查——它不报错,只是默默什么都不做。

想看日志,结果日志是空的#

plist 把 stdout/stderr 重定向到了 /tmp/blogpush.out/tmp/blogpush.err。打开一看:

Terminal window
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 带时间戳:

Terminal window
git reflog --date=iso | grep 'auto update from watcher' | head -3
fbdc8a5 HEAD@{2026-06-16 02:07:26}: commit: auto update from watcher
b628141 HEAD@{2026-06-15 22:03:58}: commit: auto update from watcher
4eef392 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 很丑)。钩子核心就这几行:

.git/hooks/pre-commit
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 1
fi

只要暂存区里有带空格的 .md,就 exit 1,整个提交失败。

把两件事拼起来,失效链条就清楚了:

  1. watcher 用的是 git add -A——stage 一切,包括那个带空格的空壳文件。
  2. git commit 触发 pre-commit 钩子,钩子看到带空格的文件,exit 1
  3. commit 失败。而我那两篇正常文章是和坏文件在同一次 git add -A 里一起被 stage 的,于是跟着一起没提交成功。

一个手滑复制出来的空壳,凭一个文件名里的空格,把后面八天所有正常文章全堵死了。git add -A 的”全都要”在这里成了致命的耦合——一颗老鼠屎坏一锅汤。

那篇文章我这次维护时本来就删掉了,所以眼下的堵塞解除了。但这是个设计脆弱性:只要再出现一个带空格的文件,同样的事会再发生一次,而且照样无声无息。得从根上加固。

加固一:让一个坏文件名堵不住其它文章#

第一性原理上,watcher 的职责是”把合法文章自动提交上去”。一个带空格的文件名是畸形输入,不该让它连累正常文章。所以在 commit 前,把暂存区里带空格的 .md 单独 unstage 掉,并大声记日志:

git-auto-blog-watch.sh
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.md
staged now: [src/content/posts/good-post.md ]
FIXED: commit SUCCEEDED
HEAD contains: [src/content/posts/good-post.md ]
空格文件仍在磁盘上(只是没被提交): blockade 1.md

TEST 1 重现了八天来的故障,TEST 2 证明 unstage 之后 commit 成功、只含正常文章、坏文件没丢。机制确认。

加固二:把日志写进文件,而不是赌缓冲区会 flush#

stdout 被缓冲吞掉的问题,根上是”依赖 stdout”本身不靠谱。改成显式写文件,用 >> 逐行追加(每条立即落盘),同时保留 echo 兼容 launchd:

git-auto-blog-watch.sh
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 照转,但再也收不到任何文件事件——又是一具活着的尸体。加个看门狗,发现读管道的子进程没了就重启它:

git-auto-blog-watch.sh
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 那份旧脚本。必须显式重启:

Terminal window
PLIST=~/Library/LaunchAgents/com.lishuyu.blogpush.plist
launchctl unload "$PLIST"
# 顺手收尾可能残留的孤儿子进程
pkill -f 'git-auto-blog-watch.sh'
launchctl load "$PLIST"

顺带踩到一个历史遗留的别扭点:plist 文件名叫 com.lishuyu.blogpush.plist,但里面的 Labelcom.lishuyu.git-auto-blog。launchctl 认的是 Label,所以查状态得 grep git-auto-blog,而 unload/load 的参数是 plist 文件路径。两个名字对不上,第一次找的时候绕了一下。

重启后确认跑的是新代码:

Terminal window
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 带时间戳,定位”什么时候停的”一针见血,比翻一堆空日志快得多。
一个文件名里的空格,让博客自动提交悄悄停摆了八天
https://blog.lishuyu.app/posts/文件名空格让博客自动提交停摆八天/
作者
猫猫魔女
发布于
2026-06-24
许可协议
CC BY-NC-SA 4.0