你让编程 Agent 在几个文件里重命名一个函数。它会去调用 shell,因为用一行sed命令比逐个打开文件省事得多。在 Linux 机器或 CI 运行环境里,修改会顺利完成。但在 Mac 上,Agent 收到的却是下面这样的报错,或者类似的东西:
sed: 1: "Rakefile": invalid command code R
这句报错原文出自一篇2011年的文章,当时作者在 Mac OS X 上执行sed -i 's/require/include/' Rakefile踩了坑。十五年后,Linux 风格的sed -i在 macOS 上依然是这个反应。接下来智能体会重试、猜测,或者悄悄换一种策略。最糟糕的情况反而是没有任何报错——它在你的仓库里留下一个多余文件,然后若无其事地继续干活。
这里其实有两个 sed,而它们对同一个参数的理解并不一致。
两个 sed,一个从未标准化的参数
sed -i并不属于 POSIX 标准。现行的POSIX.1-2024(第 8 期)在-e和-f之外,只列出了-E和-n两个选项。就地编辑属于扩展功能,两大流派几乎在同一时间各自实现了它,语法却截然不同。
GNU sed 在 3.95 版本中加入这一功能,其项目的NEWS 文件写道:"新增 -i 选项,支持类似 Perl 的就地编辑。"该版本于2002 年 10 月发布,是迈向 GNU sed 4.0 的 alpha 版。与 Perl 的-i一样,备份后缀是可选的,且必须紧贴参数书写:单写-i表示不备份,写-i.bak则保留备份。
macOS 自带的 sed 源自 FreeBSD 系。Apple 在 text_cmds 中的 sed 手册页给出的语法是[-I extension] [-i extension],并把-i列为“非标准的 FreeBSD 扩展”。FreeBSD 手册页的说明也一样。后来还有一段插曲:在 2007 年的一次提交中,FreeBSD 让-i改为对每个文件独立处理,并把原有的单一流行为挪给了新增的-I。
标准化组织曾研究过这个问题。Austin Group 的 530 号缺陷“在 sed 中支持原地编辑”于 2012 年以拒绝关闭。备注中写道,小写的-i“不适合标准化”,原因是各家实现互不相同,而且标准并不鼓励可选的选项参数。
同一条命令,两种 sed
以 Agent 最常写的命令形式为例:sed -i 's/old/new/' file。
GNU sed 读到-i后面没有附加后缀,因此不生成备份。脚本为s/old/new/,文件为file,编辑正常完成。
BSD sed 读到-i后,会把下一个参数s/old/new/当作备份扩展名。此时脚本还没有着落,于是第一个剩余参数——也就是文件名——就成了脚本。sed 尝试把Rakefile解析成 sed 命令,看到R便报错invalid command code R。换个文件名,报错也跟着变:如果文件名以真实的命令字母开头,比如config.yml,失败时就会给出另一种提示。
更隐蔽的失败是sed -i -e 's/x/y/' x.txt。GNU sed 处理起来毫无问题:没有后缀,后面是明确的脚本。而 BSD sed 会把-e当作扩展名,把其后的内容当脚本,照样编辑文件,最后还生成一个以-e结尾命名的备份文件。2020 年有人在 FreeBSD 和 Mac 上做过对比,展示了操作后目录的状态:
x.txt x.txt-e
没有报错,退出状态也正常,但 x.txt-e 就这么躺在真正的文件旁边,等着一次粗心的 git add .。有人在2015 年提交给 bug-sed 的报告里就踩过这个坑,主角是一个 Java 文件:备份文件叫 TInfo.java-e。这个报告被关闭了,不算 bug——因为那个备份出自 Mac 的 sed,不是 GNU sed。
反过来也一样会踩坑。macOS 的写法 sed -i '' -e 's/old/new/' file 会让 GNU sed 出错,它把 '' 当成了输入文件。同一条 2020 年的帖子里有 GNU 方面的回复,编辑仍然生效了,但退出状态是非零:
或者让 Mac 去对齐 Linux。Homebrew 的gnu-sed formula(当前是 4.10)会把 GNU sed 装成gsed,同时它还提供一个gnubin目录,把它放到PATH的最前面,命令就能以sed的名字生效。这能解决你自己 shell 的问题,但管不了队友的笔记本,也管不了在干净环境里跑的脚本。
告诉 Agent 它用的是哪个 sed
Agent 在编辑文件前通常不会去查sed --version。它会直接写出自己最常见的那种语法,而 README、Dockerfile 和 CI 配置里找到的 shell 代码,绝大多数都是跑在 Linux 上的。所以要在 Agent 每次会话都会加载的指令文件里写明这一点。
## Shell- Dev machines are macOS: `sed` is BSD sed, not GNU.
- No bare `sed -i`. Use
`sed -i.bak 's/a/b/' f && rm f.bak`
or `perl -pi -e 's/a/b/' f`.
-`sed -E` is fine on both.