版本管理 (2)

上一讲,我们用版本管理保存项目的状态、组织并行的开发。这一讲继续追问:如果今天重新设计一套版本管理系统,我们会把什么作为基本对象?如果主要使用者变成了能快速工作、还能并行复制的 Agent,原来的设计又有哪些前提需要重想?

这些问题最终要落到自己的项目上。实验一 的方向是做一个会成长的个人助手。这是一个很时髦的需求,但“能帮上自己”比“看起来像下一代产品”更适合作为起点。版本管理、团队协作和产品设计,都要围绕这个可以亲自检验的目标展开。

一堆文件,够不够表示一个项目

我们通常把软件项目看成各种文件的集合:Intent 记录想做什么,Spec 说明具体应当满足什么要求,Code 给出实现,也就是 Impl,再加上测试、配置、文档和生成的产物。这些开发过程中形成的对象,统称为软件工程制品,也就是 artifact。

文件是一个非常成功的接口。人可以用编辑器阅读和修改,程序可以搜索、比较、编译和打包,版本管理工具也能够接手。但“放在文件里”首先是现有文件系统和工具生态提供的方便,项目本身并不天然就是一棵目录树。如果把所有内容存进数据库,也能组织一个项目,只是围绕文本文件积累的大量工具和习惯不再能直接使用。

目录树尤其不擅长直接表达制品之间的关系。比如个人助手的一项需求是“记住用户纠正过的偏好”。这项需求可能对应一条规格、一个更新记忆的函数和几组测试。现在用户又提出“有些偏好只在某段时间内有效”,我们想知道的就不只是哪个文件变了,还包括哪些设计假设受到影响、哪些实现需要调整、哪些测试应该增加。

从需求走到设计、实现和验证,再从一段代码反查它服务的需求,这类关系构成了 traceability,可追踪性。它关心的是能否识别并持续维护制品之间的联系,用这些联系回答项目及其开发过程中的问题。研究综述 Software Traceability: Trends and Future Directions 讨论的正是这类问题。

把相关文件放进同一个目录,不会自动建立这些联系;保存每个文件的所有版本,也不会自动说明某项设计为什么存在。今天我们用需求编号、文档链接、提交说明和测试名称补上其中一部分信息。这些关系需要有人或工具维护,否则代码已经改了,原来的解释却可能还留在原地。

从压缩包到 SVN,再到 Git,我们一直在围绕文件发展更强大的工具。课堂把这种路径依赖戏称为“将错就错”:文件并非能表达一切的终极抽象,但生态已经让它非常好用。也由此可以提出一个面向未来的猜想:如果 Agent 成为主要的生产者和使用者,软件的组织形式可能发生根本变化。需求、证据、实现及其关系,也许会比“这一段文字放在哪个文件”更直接地进入系统的基本模型。

从快照理解分叉与合并

Git 管理文件内容、目录树,以及快照之间的历史关系。一次 commit,也就是提交,除了指向文件状态,还记录父提交、作者、说明等信息。父提交的连接把历史组织成一张有向无环图。可以把一次开发理解成:从状态 A 出发,完成一个功能,得到状态 B;提交说明中的 feat: ... 帮助人理解这一步想推进什么。Git 的对象模型可以参见 Git Objects。

如果两个人都从 A 出发,一人修改输入方式,另一人修改结果展示,就会得到两条开发路线。Merge 把两边的成果整合起来;在通常的分叉合并中,新的提交会同时连接两边的历史。Rebase 则把一条路线上的改动重新应用到另一个起点上,让这些改动好像是在新的基础上依次完成的。

冲突来自两边对共同状态作出了不能直接兼容的修改。例如同一个配置原来写着“立即保存”,一边改成“手动确认后保存”,另一边改成“每分钟自动保存”。工具可以发现文本上的分歧,究竟要哪一种行为,却需要回到需求。即使改动位于不同文件,能够自动合并,组合起来也可能违背双方原来的假设。

课堂展示过一条学生留言:学生时代多数人独自开发,很少与别人合作,所以不熟悉 rebase、merge 也很正常。这个解释描述了许多人的经历,却也暴露了一个学习上的限制:把“眼前没有用到”变成了“我不需要知道”,再把学习范围交给老师和考试来划定。

开发者社区里的文档、项目中的 CONTRIBUTING.md,都会把我们带到课堂之外。遇到不懂的概念,可以先问它试图解决什么问题,再构造一个最小场景验证自己的解释。理解版本管理,就可以亲自制造分叉、冲突和历史重写,观察每一步留下了什么。学习的目标是形成自己的理解;老师提供的是入口,不可能替你划定整个世界的边界。

把变化的身份纳入版本管理

理解了快照之后,Git 的某些操作会让人感到一点“不和谐”。我们执行 cherry-pick 时,通常想搬走的是某次提交引入的改动;执行 rebase 时,通常也认为“我做的还是那件事,只是换了一个基础”。但我们传给工具的主要标识,是 commit。

Commit 的身份与它的具体内容和元数据相联系。父提交一变,commit 的内容就变了;重写时会创建新的 commit 对象,得到不同的 ID。于是,原来的 C 经过重写变成 C′:人知道这仍然是在修改同一个功能,单靠 commit ID 却无法表达这种身份的延续。这里有两个值得区分的问题:某个版本的准确状态是什么,以及某项改动随着反复修改经历了哪些版本。

如果把后一个问题直接纳入工具设计,就走到了 Jujutsu 的一个重要思路。它常简称为 jj,用 change ID 追踪一项持续演化的改动,同时保留 commit ID 标识具体版本。重写时 commit ID 会改变,而 change ID 通常保持不变。在前面的例子里,C 和 C′可以拥有不同的 commit ID,却延续同一个 change ID。这里说的 change 是随时间演化的提交;它的底层仍然保存快照,并不是把系统简单改成了补丁文件的集合。参见官方的 术语说明。

工作区也可以放进同一个模型。Git 的常见流程是先编辑,再通过暂存区挑选内容,最后提交。jj 把当前工作区对应到一个可以继续修订的提交,大多数命令执行时会先把文件变化保存为新的快照。使用体验有点像不断提交和修订已有提交,也就是 commit 和 amend,因此不必把一个独立的 staging area 当作日常操作的中心。参见 Working Copy。

另一个很有吸引力的设计,是把冲突作为可以保存的状态。假设你正在把一串改动移到新的基础上,其中一处发生冲突。在 jj 中,冲突可以直接记录在重写后的提交里,rebase 先完成,之后再处理这些冲突。系统保存的是冲突的逻辑表示,仍然能够对它继续进行重写和合并。这样,“调整历史结构”和“决定冲突处的最终内容”就可以分开安排。参见 First-class Conflicts。

这就是课堂所说的 killer feature:冲突的存在不必把整个操作卡在半途中。真正值得留意的是抽象发生了什么变化。当一项过去被当作异常或临时过程的东西,可以被明确表示、保存和继续操作,许多流程就有机会统一起来。

老师拿自己开玩笑,说这就是 AI Native 能淘汰“老登”的原因:Git 的肌肉记忆已经形成,即便理解新设计,也未必有足够时间重新训练一套习惯。同学们反而可以倒过来学,先理解更自然的抽象,再回望历史,理解旧设计为什么会出现。工具的熟练程度会过时,对设计前提的理解却可以带到下一套工具里。

当开发者可以并行复制

前面的设计仍然很大程度上围绕人展开。课堂把人戏称为 single-threaded 生物:我们只有一双手、一张嘴、一对眼睛。虽然能够同时惦记许多任务,实际与开发环境交互的带宽相当有限。对一个人来说,分支、stash 和工作区切换已经能支持不少场景。Stash 可以临时收起尚未提交的修改,让你先处理另一项任务,之后再恢复工作。

Agent 改变了这个节奏。一次范围合适的修改,可能在分钟级就推进到可以提交的程度;多个 Agent 还可以同时工作。一个负责界面,一个负责数据处理,另一个补充验证。如果它们都操作同一份目录,某个 Agent 刚读过的文件可能被另一个改掉,测试正在运行时实现又发生了变化。给每个 Agent 起一个名字,并不会自动获得独立的开发环境。

一个自然的办法,是把每个 Agent 当成独立开发者,各有一份仓库,再把其他仓库配置为 remote,用来同步成果。Git 还提供了可以在本地使用的 worktree:同一个仓库关联多个工作目录,各自拥有工作区、HEAD 和暂存区,同时共享仓库中的对象和大部分引用。HEAD 标识当前检出的分支或提交。例如,从项目仓库中可以建立两个工作目录:

git worktree add -b agent-ui ../project-ui
git worktree add -b agent-memory ../project-memory

这样,两个 Agent 可以分别在界面分支和记忆分支上开发,各自运行测试,再把成果整合起来。新提交已经存在于共享的本地仓库中,无需互相 push、pull,修改仍然需要通过 merge 或 rebase 汇合。Worktree 减少了相互覆盖工作文件的问题,但任务之间的依赖仍然要管理:界面等待什么数据格式,记忆模块什么时候能提供这个接口,双方是否采用了相同的错误处理约定,都需要说明。

所以,Agent 团队可以沿用现有版本管理工具,但使用规模和操作频率的变化,会让原来可以靠人顺手协调的细节变成显著成本。这又给了我们一次重新设计的机会。

先分开做再合并,还是先上锁

回到最初的问题:为什么需要版本控制?人的生产速度有限,人和人之间沟通困难,沟通过后还会误解和出错。我们希望保存可靠的状态,分小步前进,让不同方向可以独立探索,也让失败之后能够回查。

例如,bisect 可以利用一组可重现的好坏判断,逐步缩小引入问题的提交范围;blame 可以把当前代码行关联到相应的历史修改,帮助找到当时的上下文。这些机制减少了对人的记忆的依赖。它们当然可以被用于找责任人,但在工程里,更有用的问题往往是“这个决定当时为什么合理,现在什么前提变了”。

课堂由此提出了一个有点挑衅的设想:反正没有哪个 Agent 能被开除,我们能不能再也不要 blame,只让它把事情推进、把问题修好?这里想重新考虑的是协作流程的目标。如果执行者主要是可复制的 Agent,能不能减少围绕个人交接和事后协调的步骤,直接围绕项目状态组织工作?

一种设计是由主 Agent 负责规划,把任务拆成边界明确的部分;子 Agent 要修改某项共享资源时先取得互斥锁,读取最新状态,完成修改和必要检查,留下可恢复的快照,再释放锁。这里的锁是一套约定:同一时刻,只允许一个参与者修改约定范围内的状态。

例如,两个任务都要改个人助手的记忆格式。先取得锁的 Agent 完成一次一致的修改,后一个取得锁后重新读取最新状态,再继续工作。只要所有参与者遵循同一套规则,就能避免双方同时基于旧状态写入同一项资源。主 Agent 则可以把不依赖这项资源的工作继续分配出去。

这和版本管理中“各自修改,汇合时再检查冲突”的思路形成了有用的对照。课堂借数据库里的 MVCC,也就是多版本并发控制,帮助理解后者:保留多个版本,让工作先推进,再处理版本之间的关系。这是关于并发策略的类比;Git 的合并并不因此具有数据库事务的全部性质。

上锁也有代价。锁住整个项目很简单,但可能把并行工作重新排成串行;只锁一个文件比较灵活,却未必覆盖跨文件的不变量。例如接口定义和调用方必须一起更新,只锁其中一个文件就不够。Agent 意外停止时,锁如何回收?一个任务同时需要两项资源时,怎样避免互相等待?后一个任务拿到锁后,有没有继续使用等待之前读到的旧内容?这些都是操作系统和数据库课里熟悉的问题。

“Agent 先上锁再干活”因此是一条值得实验的设计路线。它让我们看到,协作可以在工作之前通过调度和互斥协调,也可以在工作之后通过版本和合并协调,还可以把两者结合起来。历史仍然有助于恢复和诊断,修好问题也仍然需要能够检验的结果。选择哪种机制,要看共享资源、冲突频率和返工代价,而不能只看参与者是不是 AI。

人多以后,困难转移到了协作

版本管理终究只是工具。真正困难的,是团队怎样持续朝着同一个目标前进。

课堂把 AI 称为一条不断移动的“斩杀线”:如果一句话就能稳定得到满足全部要求的高质量产品,相应那一段软件工程工作就失去了原来的必要性。如果一个岗位的全部任务都被覆盖,继续只提供这些能力,就很难保留原来的工作价值。课堂还把这种压力延伸到了数学工作者:长期训练形成的能力,也可能遇到机器能力跃迁带来的重新定价。

这种未来推演有一个前提:计算资源和模型能力仍能持续改善。课堂用摩尔定律与 Scaling Law,也就是规模与性能之间的经验规律,串起这个设想,强调更强、更小、更快的模型可能继续改变边界。具体哪一类工作会在什么时候被覆盖,仍然需要实践来判断。眼下,需求澄清、任务拆分、接口协调和验收结果,就是把很多局部能力组织成一个产品时绕不开的工作。

人数增加后,这些工作很快变复杂。若 n 个成员都可能需要两两沟通,潜在的沟通关系有 n(n−1)/2 条:3 人是 3 条,6 人就有 15 条。这解释了“平方级上升”的直觉。它数的是潜在关系,并不意味着每个团队的实际耗时都严格服从这个公式;清楚的接口和组织分工,正是在减少必须直接协调的关系。

一个常见场景是,每个人都觉得自己的部分很清楚。界面已经能展示模拟数据,后端已经通过自己的测试,但两边对字段含义和失败情况的理解不同。另一个场景是,大家都说任务“基本完成”,有人指代码写完,有人指测试通过,有人则以为已经可以部署。还有人为了等待上游决定不断改计划,上游却不知道自己的延迟挡住了谁。代码量增长了,项目离可用状态的距离却没有按同样速度缩短。

分歧也可能出现在目标和责任上。一个人要赶紧上线,另一个人要趁机重构,两个人都觉得自己在帮项目。界面、接口、测试分别有人负责,跨模块的问题却可能落在分工的缝隙里。此时,再多几个提交也不能自动确定优先级,或者替团队找到愿意把整件事接起来的人。

版本管理留给我们的启发,是把项目看成一个持续演化的过程:保存当前状态,保留走到这里的历史和教训,允许多条探索路线存在,并设计它们重新汇合的办法。未来 Agent 的长期记忆机制,也就是 Agent Memory,也许能承担一部分经验记录工作,但什么内容值得记住、何时已经过时、怎样帮助下一次决定,仍然要有人设计。

如果只学会几个命令,换一套工具就得重新开始;如果理解了状态、历史、隔离和整合,再看另一种团队协作系统,就更容易找到可以借用的结构。

Git 还可以记录小朋友怎样解题

课堂再次回到 Pico Playground。小学生遇到一道不容易的汉诺塔题目,做题过程“一片哀嚎”。如果要把这个产品继续做成能够收费的服务,我们可以提供什么增值功能?一个自然的方向,是在服务器端保存小朋友的做题记录,让老师或家长看到他们怎样尝试、在哪里卡住,以及提示之后如何修改。

把这个想法交给 GPT,它给出的架构建议很熟悉:一个 API 应用、一个关系数据库、一个对象存储;需要异步点评时再加后台任务进程。数据库存关系和记录,大的课程包与完整轨迹放对象存储,任务量小时可以先用数据库任务表组织后台任务。

这套方案提供了完整的通用部件。但回头看看刚学过的版本管理,会发现做题过程本身就很像一段版本历史。

假设一个小朋友先试着挪动圆盘,遇到困难后回到某个状态重新尝试,再根据老师的提示调整方法。只保存最后的盘面,很难看出他在哪里卡住;把每次尝试的动作序列保存下来,就有了可以回放和比较的过程。如果尝试另一种办法,可以从某个状态分出去;如果想比较收到提示前后的变化,可以检查两次尝试的差异;如果老师要点评,可以把说明关联到某个明确的版本。

Git 已经提供了不少对应能力:提交保存状态,父子关系连接尝试,分支容纳不同路线,diff 展示相邻版本的变化。可以把一次尝试的动作表示为逐行文本,再把这个版本交给 Git 管理;应用负责解释动作含义。点评也可以与具体提交关联,例如使用 git notes 后补说明,而不改动原提交。原本需要自己设计的一部分历史结构,就有机会直接复用。

这里应当区分“保存有意义的解题版本”和“记录每一次鼠标移动”。前者非常接近版本管理要解决的问题;后者可能更适合单独的事件日志。用户资料、课程关系和统计查询,也仍然可以交给适合的存储方式。这个构想的价值,在于从需求里的结构出发,识别现成工具能接住哪一部分,而不是看到“服务器保存数据”就立即把每一项通用部件都启动起来。

这个架构设想还需要通过实际使用来验证。这次 GPT 给出了一条常见路线,而理解版本管理让我们多了一种可供比较的方案。你知道的抽象越多,就越可能在模型熟悉的方案之外,指出一条更贴合当前问题的路。

从零开始,你就是包工头

现在可以真正拿实验开干了。你就是包工头:决定先做哪一块,说明各部分怎样接起来,判断交付的东西能不能用。Agent 可以写很多代码,但目标、接口、验证和节奏仍然需要组织。

从零开始做产品,有时比参与一个成熟开源项目更难。成熟项目已经固定了许多选择:用户是谁,代码怎样分层,什么叫兼容,测试怎样运行。做一个局部修改时,可以沿着这些约束前进。空项目里的约束少,自由度大,Intent、Spec、Impl 之间的缺口也就有更多机会被默认选择填满。

例如,“会成长的个人助手”至少留下了几个决定:成长指记住事实、修正偏好,还是学会执行新任务?助手什么时候应该主动行动?记错了以后怎样纠正?如果一开始就把所有记忆都设计成永久有效,后来才发现大量信息有时间和场景限制,早期那个看起来很确定的数据结构,就把一项需求误解固定进了实现。

这不意味着应该先把所有未来需求都想清楚。课堂给出的准则很直接:没有什么是不能变的。所谓“以不变应万变”,落到软件上,就是它能否承受需求变化。能不能替换一项记忆策略,而不用重写全部界面?能不能增加一种输入方式,而不把核心行为复制一份?我们可以围绕已经看见的变化组织边界,再用实际反馈修正设计。

早期 prompt 对项目走向的影响尤其大。第一次描述里的“确定设计”,往往会成为以后代码和讨论的默认前提。接下来的 Agent 读到现有实现,又会顺着它继续生成。一个方便的临时选择,可能就这样逐渐变成 technical debt,也就是未来修改系统时必须额外偿还的技术债。

技术债也可能来自认识的进步。第一版的设计在当时有充分理由,真正做出来、用起来之后,团队才知道哪些概念应当分开、哪些接口应当重新组织。Martin Fowler 在 Technical Debt Quadrant 中讨论过这种情况:即使团队认真设计,也可能在学习之后发现原来的结构已经落后于新的理解。值得关注的是怎样记录这个发现,以及何时调整结构,避免后续改动持续为旧认识付出代价。

因此,最初值得写清楚的内容包括:现在要解决的具体问题,第一版怎样才算可用,哪些行为可以检验,以及哪些选择仍然允许变化。对版本历史的要求也可以从一开始进入工作方式,例如完成一个有意义的步骤后自动提交,并按项目约定推送到远端。这样,状态和决定有迹可循,后续修正才有可以回查的起点。

用一个最小版本检验想法

任何时候,开始都比不开始强。一个有用的开始方式是 MVP,Minimum Viable Product,也就是最小可行产品:做出足够小、又能真正投入使用的版本,检验最关键的假设。

开工前可以先写下两个问题:这个版本要验证什么?出现什么结果,就说明我们想错了?Eric Ries 对 MVP 的解释 强调,用尽可能少的投入获得经过验证的认识。只有事先说明需要什么证据,做出第一版之后,才更容易判断应该继续完善,还是改变方向。

个人助手的第一版不必包揽邮件、日程、浏览器和所有长期记忆。比如,可以先选择一个你每天都会遇到的场景:记录一项偏好,在下一次相关任务中使用它,并允许你纠正。这个版本可以很粗糙,但你能亲自观察它有没有帮忙、记忆有没有被正确利用、纠正之后是否真的改变了行为。

一旦开始使用,原先隐藏在形容词里的问题就会变得具体。“更主动一点”可能意味着每天固定时间提醒,也可能意味着只在发现明确的遗漏时提示;“更了解我”可能需要记住长期偏好,也可能首先需要学会忘掉一次性的指令。真实使用产生的这些分歧,会反过来检验早期的规格和架构。

产品开发有一个很大的风险:辛苦做完以后,发现没有人需要它。MVP 让我们更早把这个问题交给实际使用来回答,也让需求变化在系统还小的时候暴露。保存现在,记住历史,留下教训,然后做下一次可以检验的修改。那个会成长的个人助手,也应当从一个今天就能帮上自己的小东西开始成长。