
full copy,磨到了今天
上一篇(机缘巧合,blf 终于在 Windows 安了家)的结尾,我说下一次发版,希望能带着 full copy 回来。blf 是我自己写的一个小工具,用来把同一份开发时要用的文件放到多个项目里。symlink 那条路走了那么久,突然要换成一份份真副本,等于把 blf 最底下的东西翻出来重做。
不到两个月,这个承诺总算兑现了。今天发的是 0.8.0;从上一篇到现在,中间已经发了好几个版本,full copy 就是在这段时间里落地的。它没有做成一个需要手动打开的开关,而是变成了一个常驻的 daemon。
现在的体验是这样的:在 A 项目里改完一个配置文件,保存;过一会儿切到 B 项目,打开同一个文件,内容已经更新了。没有哪条命令需要我记得去敲,也没有“同步一下”这个动作。只要 daemon 开着,剩下的就交给它了。
听起来很简单。做起来不是。
一份真副本,和一个常驻的 daemon
上一篇里写过,有的工具(没错,就是 Kiro)开始拒绝读取那些指向 workspace 之外的 symlink。我试过各种权限配置,最后也没找到能绕过去的办法。那件事之后,我认真考虑了很久:是不是该支持 full copy。
答案只有一个:必须重写成 full copy。
不重写,我就得换工具。Kiro 这个限制对我来说是致命伤,我一度把订阅降回了 Free,后来只好暂时改用 Cursor。
那之后我一直盯着 Kiro 的 changelog,可惜始终没有等来对应的支持。出于对 Kiro 的喜爱,也出于对 blf 的执着,我总觉得,如果连我自己的工作场景都支持不了,那还会有谁愿意用 blf?
于是,full copy 从“可以支持”变成了“必须支持”。代价也很直接:一份内容变成了很多份。以前 symlink 替我保证“大家看到的是同一份”,现在这个保证没了,得有个东西一直盯着。谁改了,就把改动写到别处去。这个东西就是 daemon。
所以从 daemon 落地的那一版起,copy 不再只是 symlink 之外的备选,而是唯一的方式。我把存放源文件的项目叫作 hub。daemon 盯着 hub 和其他项目:哪边有了新改动,就先落到 hub,再分发给其他项目。以前还有一条手动同步的命令,也删了。如果 daemon 还没有启动,别的命令会直接告诉你先把它跑起来。

这也意味着 blf 从一个“想用的时候敲一下”的命令行工具,变成了一个需要常驻的进程。我得一直让它跑着,也得多维护一整套运行时。
我日常共享的东西,很多不是单个文件,而是一整个目录树:AI 工具的 hooks、项目规则、任务脚本、编辑器设置。它们得待在项目目录里,工具才看得见;可又不该进 Git。full copy 要复制的是这样一整个目录树,里面有嵌套目录,也可能有 symlink。目录 copy 比文件 copy 麻烦得多,很多细节是做到一半才冒出来的。
迁移这件事,最后是在第一次启动时自动补上的:那些还指向正确位置的旧 symlink,会被换成真副本。目录里本来就有的 symlink,比如虚拟环境里 python 指向 python3.14 这种,还是按 symlink 复制,不会被展开。这个细节不起眼,但少了它,很多目录根本没法完整地复制过去。
那一次改动不算小。光是把 daemon 立起来,就是二十个提交、一万两千多行新增。从上一篇到今天,前后 83 个提交,330 个文件,加了四万五千行,删了一万行。这些数字摆在这里,不是想证明什么,只是想说,full copy 不是一个周末就能加上去的开关。
二十六秒,不算能用
daemon 跑起来之后,有一段时间,它其实是“能用”的。
功能都在:改动能同步,冲突能标出来,状态能查到。但我自己用着用着就不想用了。原因很具体:慢。
那时候,几乎每个操作都要把整套配置扫一遍,再把基线整个重写一遍。配置越大,等得越久。一个很简单的操作,敲下去要等二十六秒多。二十六秒是什么概念?一个本来应该瞬间完成的动作,变成了需要专门等的一件事。
那段时间我一直在想,问题到底出在哪。后来想明白了:daemon 虽然是新的,可它干活的姿势还是旧的。它把所有项目塞进同一个队列里,每次都要从头到尾看一遍,生怕漏了什么。可实际上,大部分操作只跟一个项目有关,其他项目根本不需要被卷进来。
于是我把执行模型拆了。每个被管理的项目有自己的工作线程、自己的队列、自己的基线——也就是上一次同步完之后的样子。一次操作只处理跟它有关的那部分,基线也只更新动过的地方。一次操作里,该算的哈希只算一遍,不再反复扫。改完之后,同样的操作,从二十六秒多降到了几十毫秒。
不是快了一点,是终于回到了“敲完就能看到结果”的范围。
那一版的重点不是加新功能,而是把地基重新浇一遍。十八个提交,八千多行新增。顺便把日志拆成了三份,把终端里的状态界面做成了全屏,出了问题时能看清楚时间花在排队、执行还是写盘上。
冲突总得有个地方落脚
copy 解决了“工具认不认”的问题,却也带来了一个新问题:同一份内容有了很多副本,就可能被改得不一样。
daemon 的规则很简单:同一个路径,谁先写进去,谁算数;后到的那个副本会被标成 out-of-sync,也就是这个副本暂时不再参与同步,之后的改动也不再接受。这个规则本身没错,至少它不会让两份内容互相覆盖,最后谁也不知道哪份是对的。
但一开始,它只做到“标出来”。状态里能看到有一处冲突,可为什么会冲突、hub 现在是什么样、另一个副本又是什么样,得自己一个个目录去翻。翻完之后怎么合,也没有地方可以做。说白了,daemon 把问题摆在了那里,却没有给我一把能把它收走的工具。
后来就做了那个浏览器里的解决界面。它不是云端服务,就是 daemon 在自己机器上开的一个 localhost 页面,从状态界面按一下就能打开。页面上会把 hub 当前的内容、每个副本的内容都列出来,告诉你它们为什么不一样;文本文件可以一边看差异一边合,把副本一个个往前叠,最后点一下,把确认后的结果写回 hub,再把所有副本覆盖掉。二进制文件没法合,就直接选 hub 或者选某个副本。
为什么是本地 H5,而不是一个 desktop app?这个问题我也想过。native app 的开发成本本来就高,跨平台更麻烦,在 macOS 上还得有 Apple Developer Account。blf 现在的用户还没多到值得为这件事专门开一个账号、维护几套客户端,能省则省。
H5 就省事多了:daemon 本来就在本机跑,起一个 localhost 页面就能用,macOS、Linux、Windows 都能打开,对 agent 也友好。会用 blf 的人,基本都是懂技术的,在浏览器里解决冲突再自然不过。既然这样,就没必要为了“像个正经桌面应用”再去做一个 desktop app。
别等上下文凉了
冲突有地方解决,前提是我得知道它发生了。
在那之前,这些 edge case 不会主动冒出来,只会安安静静地等着我运行 blf daemon status 去看:out-of-sync 的副本,或者被暂时保下来的旧内容。问题是,我不可能每隔几分钟就去执行一次 blf daemon status。白天在写代码,注意力在别处;等我想起来去看,可能已经过去半天。隔得越久,我越不记得当时想改什么、哪一份才是对的。冲突本身不一定难,难的是等我发现它的时候,上下文已经丢了。
所以后来加了本地通知:第一次出现这些情况的时候,系统就会弹一条。它不会替我打开解决界面,也不会替我做决定,只做一件事:告诉我现在有个 edge case,让我能第一时间回到现场。刚发生的时候解决,和过了一天再解决,完全是两回事。
不过,第一版做出来之后,我以为这件事就结束了。结果翻日志才发现,本地通知根本没有发出去。
有那么一瞬间,我甚至想,是不是可以用邮件通知兜底:邮箱客户端哪个平台都有,看起来是个天然跨平台的方案。但想了一下还是否了。一是邮件通知需要额外配置,等于给用户加了一道门槛;二是它会打断 blf 的工作流——通知在邮箱里,处理 edge case 却在浏览器页面里,两边来回切,反而更麻烦。最后我在 macOS 上加了一个兜底方案:系统通知发不出去的时候,就改调 macOS 自带的 osascript 发。
这个通知不是锦上添花。没有它,那个浏览器界面做得再好,我也得先想起来去看一眼;有了它,问题出现的那一刻我就知道。对一个要长期开着的 daemon 来说,这种“第一时间知道”的能力,和解决冲突本身一样重要。
symlink node 回来了
daemon 落地之后,我心里一直有个疙瘩:symlink 怎么办。
full copy 是给那些不认 symlink 的工具准备的。可 symlink 本身没有错。它指向的内容,天然就不需要再同步一份;有些工具和场景,也确实更适合用链接。我担心的是,copy 成了唯一方式之后,symlink 会不会就此从 blf 里消失。
后来修一个同步的 bug,反倒把这块补回来了。场景是:执行 npx skills update -p 之后,很多 skill 目录被替换成了 symlink。原来的逻辑会把目录里的子路径也当成这次变更的一部分,于是一路跟到链接后面去;现在不会了。这个路径本身就是一个 symlink node,同步的就是这个 node,链接后面的文件不参与快照,也不会被另外存一份,更不会被删掉。
这个改动看起来不大,但对我来说意义挺特别。它说明 symlink 并没有被 full copy 否定,只是从“唯一的做法”,变成了“需要的时候可以用的一种节点”。它不主动把东西变成 symlink,只在原本就是 symlink 的地方保留 symlink。需要 symlink 的场景,工具自然认得它;不需要的场景,平时用的还是真副本。

当初 blf 最早想解决的,就是让一份内容在多个 target project 里共用。symlink 是那条路的第一种走法。后来为了兼容不认链接的工具,我改成了 copy;而现在,symlink node 还在。算是一个皆大欢喜的结局。
这大概是我在这轮改动里最喜欢的一个意外。
为什么拖到今天才发
上一版发完之后,0.8.0 该做的事其实很快就做完了。我没有马上发,不是忘了,是想再放一放。
一个工具“写完”和“用得住”,中间隔着一段很长的路。很多东西只有在自己的日常里跑起来,才会露出问题:通知是不是太吵,日志会不会无限长,daemon 停着的时候发生的改动能不能补回来,一个目录被换成链接之后会不会把里面的文件弄丢。这些问题,靠读代码是想不出来的,只能靠用。
所以这段时间我就把它当成日常工具在用。上班用,自己的项目也用。哪一天觉得哪里别扭,就记下来;攒到一定程度,再一起改。这两天又补了一轮。有些只是收尾:日志的上限、目录同步、daemon 停着时的补同步。有些则直接关系到它能不能用:比如前面说过的通知,还有 symlink node 那个修复。它们指向的是同一件事:我不盯着它的时候,它能不能把事做对。
现在回头看,标题里那个“磨”字,大概有两层意思:一层是磨蹭,明明可以发了,还想再放一放;另一层是打磨,总觉得还有哪里不够顺。拖到今天,两层都占了一点。
我也没有给每个版本定什么目标。日常里哪件事特别碍事,解决了,我就考虑发一版。版本号更像是给“这一段时间的折腾”做个记号,而不是一份事先写好的路线图。
这不是一个有人催着做的项目。没有 deadline,也没有人给我排期。唯一会催我的,是我自己每天用它的那点不舒服——对了,还有那些不想浪费的 token(为了烧光 token,这夜我是没少熬)。也正因为这样,做不做、什么时候做,全看这件事是不是真的碍着我。
到这儿,应该算完整了。
不是说它完美了,也不是说以后不会再改。只是说,现在它能安静地待在后台,做该做的事;我不用一直惦记着,它也不会在我做事的时候无缘无故跳出来刷存在感。对一个自己每天都要用的工具来说,这大概就是最舒服的状态。
如果你对实现感兴趣,代码和更详细的设计说明都在仓库里:xingyuli/beyond-local-file
话外音
没错,我又把 Kiro 定回来了。Happy ending :)