Reading 5: 版本控制(Version Control)

目录 · ← l4 · l6 →

Reading 5: 版本控制(Version Control)

说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。Git 命令与提交图沿用课程原文示例仓库 ex05-hello-git 的真实哈希值。

概述

版本控制(version control)回答的是一个看似朴素、实则决定团队生死的问题:当我们不断修改项目时,如何可靠地保存、比较、回退、共享和合并这些修改? 本讲先用”Alice 一个人写作业”的思想实验从零发明出版本控制需要支持的全部操作(回退到过去版本、比较两个版本、把完整历史推到别处、从别处拉回历史、合并同源分叉),再引入日志(谁、何时、改了什么)、分支与”多个共享位置”的概念,最后指出:这套东西就是 Git。

理解 Git 的正确姿势不是背命令,而是建立两个模型:一是 Git 把项目历史存成一张有向无环图(directed acyclic graph, DAG),每个节点是一个提交(commit),也就是项目在那个时刻的完整快照;二是这张历史图只是完整对象图(object graph) 的骨架,完整对象图里还有代表”目录快照”的树对象与代表”单个文件内容”的 blob 对象,同一个文件版本被多个提交共享,从而避免重复存储。命令(cloneaddcommitstatuspushpulllogshow)只是对这张图的操作。

版本控制直接服务于三大目标:Safe from bugs(免于 bug)——它能告诉你”什么时候、在哪里坏掉的”,让你发现有同类错误的其他位置,并给你信心”代码没有被人意外改动”;Easy to understand(易于理解)——提交日志回答了”这个改动为什么做”“当时还改了什么”“这段代码可以问谁”;Ready for change(为变更而设计)——版本控制本身就是关于”管理和组织变更”的:回退失败的改动、接受并整合他人的改动、把探索性工作隔离在分支上。它也承接 Reading 4(Code Review):没有可靠的历史,就没有可信的”这次提交到底改了什么”可供审查。

核心概念与设计原则详解

为什么需要版本控制(Inventing Version Control)

  • 定义与目的:版本控制系统(version control system, VCS)记录项目在时间上的全部版本,使回退、比较、共享与合并成为常规操作而不是危险动作。它服务全部三大目标。
  • 直观解释(”它是什么?”):Alice 独自写作业,交作业前一分钟发现”最新改动把一切都搞坏了”,她只想回到过去的某个版本。于是她采用最朴素的纪律:把 hello.ts 存成 hello.1.tshello.2.tshello.ts,约定最新的那份就叫 hello.ts,这份最新版本称为头(head)。灾难避免了——但如果第 3 版里有好改动也有坏改动呢?她只能手工比较两个文件,把改动分类,再把好的搬回去。手工做得越多,出错概率越大。
  • 关键规则与最佳实践
    • 一个人也需要版本控制:回退、比较、多机同步(笔记本与台式机通过云端交换)都是单人场景的真实需求。
    • 一旦涉及多台机器或多个副本,就必须明确”谁覆盖谁”:Alice 在笔记本上做出 5L、在台式机上做出 5D,如果直接互相同步,就会丢掉一方的改动——她真正需要的是合并(merge),基于两个第 5 版生成一个新版本。
    • 判断”是否需要版本控制”的标准不是团队规模,而是”你是否在乎能不能回到过去、能不能说清改了什么”。
    • 越是危险的操作(覆盖、删除、大重构),越应该先提交一个干净的状态。

版本控制系统应支持的操作与特性(Features of a VCS)

  • 定义与目的:这些特性是本讲的”需求规格说明”,理解了需求才能理解 Git 的设计取舍。
  • 直观解释(”它是什么?”):把版本控制当成一个必须同时满足”个人使用”与”多人协作”的数据结构服务。
  • 关键规则与最佳实践
    • 可靠性:需要多久就保留多久,支持备份。
    • 多文件:跟踪的是项目而不是单个文件——文件之间互相依赖,孤立地给单个文件编号会产生不一致的组合。
    • 有意义的版本:每个版本要有”改了什么、为什么改”的记录(作者、时间、简短的人工撰写消息)。
    • 回退、比较、查看历史:可以整体或部分恢复旧版本,可以对比版本差异,可以只看某个文件的历史。
    • 不限于代码:散文、图片等一切文本或二进制资产都适用。
    • 协作相关:合并同源分叉、追踪责任(哪一行是谁写的,即 annotate/blame)、支持并行工作、支持共享未完成的工作(work-in-progress)。
    • 一致性约束:对每条日志记录,都应能方便地取出”当时完整可用的文件集合”——日志与实际文件集不能脱节。

集中式与分布式(Centralized vs. Distributed)

  • 定义与目的:两种协作拓扑,决定了”什么算进入了版本控制”这个团队级判断。
  • 直观解释(”它是什么?”):集中式系统(CVS、Subversion)有唯一的中央服务器,所有人的副本只与该服务器通信,协作图是一颗以中央仓库为心的星形:改动只有进了中央仓库才算”保存好了”,因为那是唯一的仓库。分布式系统(Git、Mercurial)允许任意协作图,团队与子团队可以各自实验替代版本与替代历史,觉得好再合并回来——所有仓库生来平等,是用户给它们分配不同的角色。
  • 关键规则与最佳实践
    • 集中式的优点是规则简单;代价是中央仓库是单点,且离线工作受限。
    • 分布式的优点是灵活;代价是团队必须自己定义”什么算官方”:某个改动是否要先与指定协作者或服务器共享,才算被全队承认?
    • 在 6.031 中,问题集仓库里所有仓库在 Git 眼里地位相同,但团队流程会赋予其中一个特殊角色(课程使用的托管仓库是布置与提交的官方位置)。
    • 记住分布式的一个直接后果:提交是本地操作git commit 不需要网络;只有 push/pull 才与远端交互。

版本控制术语(Terminology)

  • 定义与目的:统一术语是讨论协作流程的前提。
  • 直观解释(”它是什么?”)
    • 仓库(repository):项目所有版本的本地或远端存储。
    • 工作副本(working copy):本地可编辑的项目拷贝,也就是你在编辑器里真正打开的那些文件。
    • 文件(file):项目中的单个文件。
    • 版本/修订(version / revision):项目在某一时刻内容的记录。
    • 变更/差异(change / diff):两个版本之间的差别。
    • 头(head):当前版本。
  • 关键规则与最佳实践
    • 区分”仓库中的版本”与”工作副本中的文件”:仓库里存的是对象图中的对象,工作副本是”检出(check out)”出来的普通文件。
    • 术语”分支(branch)”在 Git 中有特定含义(指向某个提交的可移动名字),与”历史图上的分叉”并不完全等同——分叉可以只是两个开发者从同一提交并行工作,而不需要任何人新建分支。
    • 6.031 只使用默认分支 main(旧教程里可能叫 master,遇到时替换即可)。
    • 与他人交流时讲清”我指的是哪个副本、哪个版本”,大多数协作事故来自这个歧义。

Git 对象图(The Git Object Graph)

  • 定义与目的:Git 仓库由三部分组成——.git 目录、工作目录(working directory)、暂存区(staging area),而所有操作都是对存储在 .git 里的图数据结构的操作。
  • 直观解释(”它是什么?”):想象一张图:历史图(提交 DAG)是骨架,每个提交节点挂着一棵目录树,树的叶子是文件内容的 blob。git clone 就是把这整张图从远端拷到本地 .git,再检出最新版本到工作目录;git commit 就是往图里加一个新节点。
  • 关键规则与最佳实践
    • git clone ssh://github.mit.edu/.../ps0-bitdiddle.git ps0 做三件事:创建空的本地目录与 ps0/.git;连上远端并把对象图拷进 ps0/.git;检出 main 分支的当前版本到工作目录。
    • 对象图在磁盘上以高效但人类不可读的形式存储;你编辑的文件是”检出”出来的普通副本。
    • 图可以在多处存在副本(本地、远端托管);它们是同一张图的拷贝,通过 push/pull 交换新节点。
    • 不要把编辑器或 GUI 插件的菜单当作 Git 的唯一入口:出问题时课程助教无法帮你,因为工具改变了某些操作的实际语义;命令行的行为是公开、可复现的。

commit / tree / blob:提交、目录与文件内容(Commit, Tree, Blob)

  • 定义与目的:这是 Git 数据模型的核心三层结构,直接解释了”Git 为什么这么快、这么省空间”。
  • 直观解释(”它是什么?”):每个提交(commit) 是项目在那个时刻的完整快照,由唯一的十六进制 ID 标识;快照本身由一棵树(tree) 表示,树描述目录结构并指向子树的树对象或文件内容的 blob 对象。对任何规模合理的项目,绝大多数文件在一次修订中并未改变,因此 Git 不为每个提交复制一遍全部文件内容:每个文件版本只存一次,多个提交共享同一份拷贝。每个提交还带有日志数据(作者、时间、简短消息)。
  • 关键规则与最佳实践
    • 提交 = 快照,不是差异;但 Git 默认向你展示差异,因为”大多数提交只改少量文件”这个统计事实让差异更有信息量。
    • 内容寻址意味着相同内容只存一份:这正是 Reading 8(Immutability)所说”不可变数据可以自由共享”的绝佳范例——因为对象一旦写入就永不改变,共享它绝对安全。
    • 想看清某个提交的完整快照,用 git show <commit>:(注意结尾的冒号,它彻底改变命令含义),想看某个文件在某个提交里的内容用 git show <commit>:<path>
    • 这条命令也是灾难恢复的最简单手段之一:从历史版本里取出被改坏的文件的完好版本。
    • 树/blob 是”存储实现”层的概念,日常不必直接操作,但理解它能让”为什么切换分支很快”“为什么合并能自动进行”从魔法变成推理。

历史图:DAG、分支与 HEAD(History as a DAG)

  • 定义与目的:项目历史是一张有向无环图,它是对象图的骨架。
  • 直观解释(”它是什么?”):每个节点是一个提交;除初始提交外,每个提交都有一个指向父提交的指针(例如 1255f4e 的父是 41c4b8f,意思是后者先发生)。两个提交可以有同一个父提交——它们是从同一个先前版本分叉出来的两个版本(例如两位开发者各自独立工作);一个提交也可以有两个父提交——这是把分叉的历史重新系在一起的合并提交。分支(branch) 只是一个指向某个提交的名字;而 HEAD 指向当前分支,当前分支再指向当前提交(所以 HEAD 是”指向当前提交——几乎是”)。
  • 关键规则与最佳实践
    • 单人单机时历史图通常是一条序列:提交 1 → 提交 2 → 提交 3……
    • 当多个提交共享同一父提交时,图从序列变为树(分叉);当分叉被合并时,图从树变为真正的图(有节点有两个父)。
    • “分叉”不要求任何人执行 git branch:只要两个人从同一提交各自继续提交,历史上就出现了分叉。
    • 环是不可能的:历史上不可能存在”某个提交是自己祖先”的情形,因为时间只会向前,任何提交的父链必然终止于初始提交。
    • git log --graph --oneline --decorate(课程里常记作 git lol 别名)观察图形结构,比逐条读日志有效得多。

暂存区与提交(Staging Area, git add, git commit)

  • 定义与目的git commit 并不直接基于工作目录的当前内容创建提交,而是基于暂存区(staging area,也叫 index) 的内容。
  • 直观解释(”它是什么?”):暂存区像是一个”半成品提交(proto-commit)”:你先用 git add 把想纳入本次提交的改动放进去,再用 git commit 把暂存区”刻成石头”。这让你能在一个混乱的工作目录里只提交一部分改动。
  • 关键规则与最佳实践
    • 三步曲:修改文件 → git add <file> 暂存 → git commit 提交。
    • git status 是核心工具,养成”每条 git 命令前后都跑一次”的习惯:它告诉你现在是”无改动”“有未暂存改动”“有已暂存改动”,以及本地是否领先于远端。
    • 同一时刻可以同时存在已暂存与未暂存的改动;git commit 之后,已暂存的改动被提交,未暂存的改动原样保留。
    • 暂存区让”一次提交只做一件事”成为可能(配合 git add -p 甚至能按代码块挑选)。
    • 不要提交构建产物(Java 的 .class、TypeScript 的 .js):它们是生成物,会持续与源码产生无意义的差异并制造冲突。

push、pull 与合并(push, pull, merging)

  • 定义与目的:在多个仓库之间交换对象图的新节点,并把分叉的历史合并起来。
  • 直观解释(”它是什么?”)git clone 时 Git 记住来源,把它命名为远端 origin。本地 git commit 产生新节点后,git push origin main 把它们送到远端;git pull 则反过来接收新节点并且更新工作副本(检出最新版本),如果远端与本地都变了,pull 会尝试合并。
  • 关键规则与最佳实践
    • 典型并行场景:Alyssa 与 Ben 都从同一提交克隆;Alyssa 新建 hello.scm 提交 6400936,Ben 新建 hello.rb 提交 82e049e;两人各自的 main 指向不同提交。
    • Alyssa 先 push 成功;此时 Ben 的 push 会被拒绝——如果服务器把 main 指向 Ben 的提交,Alyssa 的提交就会从项目历史里消失。
    • Ben 必须先拉取(git pull),它做两件事:把新提交下载进本地对象图;把两段历史合并,产生一个新的合并提交(课程示例中的 3e62e60),这个提交和别的提交一样是一个快照——只是它同时包含了双方的改动。之后 Ben 才能 push。
    • 当两人修改的是不同文件时,Git 能自动合并;若修改了同一文件的同一部分,Git 报告合并冲突(merge conflict),需要人工把双方意图编织在一起后再提交合并结果。
    • 自动合并成功 ≠ 语义正确:见下面的代码对比场景 1。

提交为什么”看起来像差异”(Why commits look like diffs)

  • 定义与目的:理解”快照”与”差异”两种视角的切换,避免被 git show 的输出误导。
  • 直观解释(”它是什么?”)git show 1255f4e 输出的是 diff,而不是完整快照——因为 Git 假设大多数文件在单次提交中没有变化,只显示差异更有用。这几乎总是对的,于是很多人误以为”Git 存的是差异”。
  • 关键规则与最佳实践
    • git show <commit>: 列出该提交快照中的全部文件(课程示例输出 hello.rbhello.scmhello.txt)。
    • git show <commit>:<path> 显示该提交中某个文件的内容,例如 git show 3e62e60:hello.scm 得到 (display "Hello, version control!")
    • 恢复被改坏的文件:用 git show 取出早期完好版本,这是最简单的灾难恢复手段之一。
    • 记住”提交是快照、diff 是显示方式”这一点,才能理解为什么检出任意版本都很快,也才能理解合并需要的其实是”三个快照”(共同祖先与两个分支尖端),而不是”两串差异”。

快照图与提交图(Snapshot Diagram vs. Commit Graph)

  • 定义与目的:两者都是”图”,但描述的是完全不同的世界,混淆它们是初学者的高频错误。
  • 直观解释(”它是什么?”)快照图(snapshot diagram) 画的是程序运行时的内存状态:变量、对象、指针、不可变引用、别名、栈帧——它回答”此刻堆和栈里有什么”(Reading 2 开始使用,Reading 8 讲不可变性时会大量使用)。提交图(commit graph) 画的是项目版本的演化:节点是提交(整个项目的快照),边是父子关系——它回答”这些版本按什么顺序产生、在哪里分叉、在哪里合并”。前者的一个节点是一个对象,后者的一个节点是整个项目的一个版本。
  • 关键规则与最佳实践
    • 快照图中的”不可变对象”要用双线箭头与双线框表示;提交图中的节点则总是不可变的——提交一旦创建就不能修改,这与 Reading 8(Immutability)中”不可变数据可安全共享”完全同源。
    • 快照图解释”为什么两个变量是别名”,提交图解释”为什么两个分支能自动合并”。
    • 提交图是有向无环图;快照图一般无环(除非你在画一个真的环形数据结构),两者的读图技巧不同。
    • 讨论问题时先说明”我说的是快照图还是提交图”,可以省掉大量误解。

追踪责任与历史审查(Annotate / Blame, Review History)

  • 定义与目的:日志使”某一行代码是谁、在哪个提交里引入的”可被自动查询。
  • 直观解释(”它是什么?”)git log 可以限制到某个文件的历史;git blame(课程里也提到它”不幸地”叫这个名字)逐行标注责任人,出问题时知道该问谁。
  • 关键规则与最佳实践
    • 看历史时优先用”图形 + 一行摘要 + 装饰引用”的视图,先建立整体形状,再看细节。
    • 提交消息要写”为什么”,因为”改了什么”已经由 diff 记录了。
    • git log --follow 可以跨越文件重命名追踪历史(补充说明:重命名检测是启发式的,重命名与内容大改混在同一提交里会让它失效——这正是下面场景 3 的教训)。
    • 历史是给人读的:可读的提交序列是团队资产,拥塞的提交序列是负债。

代码示例与对比分析

场景 1:并行修改导致的”自动合并成功但语义错误”

假设两位开发者从同一个 Hello.java 出发。Alyssa 修改了 greeting() 的返回内容,Ben 修改了逗号放在哪里。

❌ 错误代码

// 错误:起始版本把"问候语内容"和"标点格式"两件事耦合成两处独立可改的位置
public class Hello {
    public static void greet(String name) {
        System.out.println(greeting() + ", " + name);   // 标点在这里
    }
    public static String greeting() {
        return "Hello";                                 // 问候语在这里
    }
}

// Alyssa 的版本(改 greeting() 的内容)
//     public static String greeting() { return "Ciao"; }
// Ben 的版本(把逗号搬进 greeting(),让 greet() 不再拼标点)
//     System.out.println(greeting() + name);
//     public static String greeting() { return "Hello, "; }
// Git 自动合并后运行 Hello.greet("Eve") 的输出:
//     CiaoEve          <-- 未报任何静态/动态错误,但答案是错的

【错误代码的问题】

  1. 自动合并成功但语义错误:Git 按”不同区域各自取新值”的规则合并,结果既用了 Alyssa 的 "Ciao",又用了 Ben 的 greeting() + name,丢掉了逗号——产生 CiaoEve。这类”无错误、错误答案”的合并是最危险的一种,因为它不会有任何提示。
  2. 职责耦合:格式(逗号与空格)与内容(问候语)分散在两个方法里,任何一方调整都会破坏另一方的前提,这是 DRY 原则在语义层面的违反(没有重复的代码,却有重复的知识)。
  3. 缺少规格说明:greeting() 的契约到底”包含标点吗”从未写下来,两位开发者的理解不同,冲突只能在运行时暴露(这与 Reading 6 直接相关)。
  4. 没有人审查合并结果:自动合并常被视为”Git 说没问题就没问题”,但 Git 只做文本层面的合并,不理解程序语义。

✅ 正确代码

// 正确:用规格说明固定职责边界,让两处修改互不干扰、合并后语义正确
public class Hello {
    /**
     * Print a greeting to name.
     * @param name the name to greet; requires name is not null
     *        effects: prints exactly greeting() followed by ", " followed by name
     */
    public static void greet(String name) {
        System.out.println(greeting() + PUNCTUATION + name);   // 格式只有一处
    }

    /**
     * @return the greeting word, containing no punctuation
     *         for example, greeting() = "Hello"
     */
    public static String greeting() {
        return "Hello";                                        // 内容只有一处
    }

    private static final String PUNCTUATION = ", ";             // 命名常量,含义明确
}

// Alyssa 只改 greeting() 的返回值:return "Ciao";
// Ben 若想改标点,只改 PUNCTUATION = ": ";
// 两人的改动落在完全不同的位置,合并后输出 Ciao, Eve —— 语义正确

【为什么这样更好】 把”格式”与”内容”分别收敛到唯一的位置,使两类修改彼此独立,合并时不会互相吞掉对方的效果;PUNCTUATION 是命名常量,读者立刻知道它承担”标点与空格”的职责;Javadoc 明确写出 greeting() 返回不含标点的问候语,把隐式约定变成显式契约,客户与实现者从此不会各行其是;合并后的输出可被测试断言(Ciao, Eve)。

【代码对比解说】 这组对比的要点是:合并是文本操作,正确性靠设计保证。错误版本在文本层面看是两个不重叠的修改(一个改 greeting() 的返回字符串,一个改 greet() 的表达式),Git 有充分理由认为它们互补;但从语义看,二者都在定义”输出的格式”,属于同一个知识的两半。消除这种耦合有两种手段:一是把知识集中到单一位置(本例的 PUNCTUATION),二是把知识写进规格说明,让另一方能读到边界在哪里。实践中两者都要做——常量解决”改哪里”,规格解决”谁负责”。

【设计原则透视】 这组对比把版本控制与 Reading 6(Specifications)缝在一起:规格说明是”接口契约”,而合并冲突是”契约理解不一致”的物理表现。它也体现了 Reading 4 的 DRY 原则在知识层面的推广——重复的不只是代码,还有”谁负责加标点”这种设计知识。此外,它示范了 Reading 3(Testing)的必要性:合并后必须重新跑测试,因为 Git 不理解语义,”能自动合并”绝不等于”行为正确”。


场景 2:用注释保存旧实现 vs 删除它

❌ 错误代码

// 错误:把历史塞进源文件,制造死代码与伪版本号
public class Greeter {
    public String greet(String name) {
        /* 2019-03-02 旧版本,先留着以防万一
        return "Hello, " + name;
        */

        /* v2 版本,2020-01-15,改用了 String.format
        return String.format("Hello, %s", name);
        */

        /* v3 版本 —— 2020-09-01,支持空名字
        if (name.isEmpty()) return "Hello!";
        return "Hello, " + name;
        */

        // 当前版本
        return "Hello, " + name + "!";   // v4 final final v2
    }
}

【错误代码的问题】

  1. 死代码污染:四个版本堆在一个方法里,读者必须逐段排除才能找到真正生效的那一行;注释里的代码不会被编译器检查,会随时间腐烂成”看起来很权威的谎言”。
  2. 文件名里的伪版本号(v4 final final v2)是手工版本控制的残留,既不可比较也不可回退——它恰恰证明了”没有版本控制时会发生的混乱”。
  3. 无法比较、无法责任追踪:想知道”为什么支持空名字”、谁在什么时候改的,答案在注释里被压缩成了一句没有上下文的日期。
  4. 违反 Reading 4 的 DRY 与”避免死代码”:同一逻辑的多个版本共存,任何 bug 修复都可能被误改到错误的那一份。

✅ 正确代码

// 正确:源文件只保留当前实现,全部历史交给版本控制
public class Greeter {
    /**
     * Greet a person by name.
     * @param name the name to greet; requires name is not null,
     *        and name is not empty
     * @return a greeting of the form "Hello, <name>!"
     */
    public String greet(String name) {
        return "Hello, " + name + "!";
    }
}
$ git log --oneline -- Greeter.java
a1b2c3d Support empty names in greet
9f8e7d6 Rewrite greet using String.format
4c5b6a7 Initial greet implementation

$ git log -p --follow -- Greeter.java        # 随时取回任意历史版本的完整改动
$ git show 9f8e7d6:Greeter.java              # 甚至直接看当年那个文件长什么样
$ git revert a1b2c3d      # 撤销某个改动,并留下一个可审查的新提交

【为什么这样更好】 源文件只表达”现在是什么”,历史由仓库表达”曾经是什么、为什么变”;每一段旧实现都有作者、时间与提交消息,可以 git showgit diffgit blame,信息量远高于注释;删除旧代码是安全的——因为它在对象图里,不会丢;代码变短、变清晰,读者无需做考古。

【代码对比解说】 这组对比回答了一个非常常见的学生疑问:”万一以后要改回来呢?”答案正是本讲的核心:版本控制就是为这个问题而存在的。没有版本控制时,注释掉旧代码是理性行为;有了版本控制后,它变成纯负债。注意 git log -- <path> 只列涉及该文件的提交,--follow 让它跨越重命名继续追踪,而 git revert 产生一个新提交而不是改写历史——后者对协作项目至关重要,因为改写历史会让别人的克隆失效。

【设计原则透视】 这条规则把 Reading 4 的”避免死代码”与 Reading 5 的”提交即快照”结合起来:可回退性由对象图提供,而不是由源码里的注释块提供。它同时依赖 Reading 8(Immutability)的思想——已提交的对象不可变,因此共享和检索它们绝对安全。反过来说,如果历史不可靠(例如把大量无关改动塞进同一个提交),”随时取回旧版本”的价值就会大打折扣,这正是下一组对比的主题。


场景 3:一个巨型提交 vs 一组原子提交

❌ 错误代码

$ git commit -am "update"
# 这一个提交同时包含:
#   1) 把文件 Greeter.java 重命名为 Welcomer.java
#   2) 把方法 greet 改名为 welcome,并改变其行为(新增了感叹号)
#   3) 全文件重新格式化(缩进、括号位置)
#   4) 顺便把 MAX_NAME_LENGTH 从 20 改成 64
#   5) 删除了一段"看起来没用"的空值检查
#   6) 更新了 README 与三个测试文件

【错误代码的问题】

  1. 无法审查:Reading 4 的代码审查在这种提交上完全失效——审查者面对上千行噪声,无法区分”语义改动”与”格式改动”,真正的风险改动(第 5 条)极易被忽略。
  2. 无法回退:如果第 4 条被证明是错的,git revert 会把六件事一起撤销,包括那些正确的改动。
  3. 责任追踪失效:git blame 会把整文件的行都归给这一次提交与这一个人,历史信息被抹平。
  4. 重命名与内容大改混在一起,Git 的重命名检测(启发式)很可能判断失败,--follow 也就追不到历史——历史链在此断裂。

✅ 正确代码

$ git commit -m "Rename Greeter to Welcomer (no behavior change)"
$ git commit -m "Rename greet() to welcome() (no behavior change)"
$ git commit -m "welcome() now appends '!' to the greeting"
$ git commit -m "Raise MAX_NAME_LENGTH to 64 to match the new ID format"
$ git commit -m "README: document the welcome greeting format"
# 每一步之后都运行一遍测试套件,确保每个提交自身是"绿色"的
$ git log --oneline

【为什么这样更好】 每个提交只做一件事,因此可以被独立审查、独立回退、独立理解;纯重命名与格式化提交不含行为变化,审查者可以快速扫过;行为变化的提交很短,diff 小到能被逐行读懂;git blamegit bisect(二分查找缺陷引入点,补充说明)都能给出精确答案;重命名单独成一步,重命名检测可以正常工作,历史链保持完整。

【代码对比解说】 核心原则是”提交是历史的原子单位”。判断一次提交是否原子,最快的标准是:能不能用一句不含”并且”的话描述它? 另一个标准是它能否被单独 revert 而不牵连其他意图。实践中常用的手法是在提交前用 git add -p 挑选代码块,把格式改动、重命名、行为改动分批暂存。注意格式化的批量改动最好单独成一次提交并在此之前与团队约定,否则它会淹没所有真正的改动——这也是 Reading 4 中”不要擅自重排别人的格式”在版本控制层面的又一次现身。

【设计原则透视】 这组对比把”提交历史”当作 Reading 4 代码审查的输入:审查的质量上限由提交的可读性决定。它也与 Reading 3(Testing)配合——每个提交保持测试通过,才能放心使用 git bisect 定位缺陷。更深一层,它体现了”为变更而设计”:一个组织良好的历史让回退、定位、理解的成本都从”小时级”降到”分钟级”。


场景 4:把环境相关的东西提交进仓库 vs 保持仓库可移植

❌ 错误代码

// 错误:把开发者本机的绝对路径、编译产物与本地配置当作源码提交
public class ReportWriter {
    private static final String OUTPUT_DIR = "/Users/alice/6.031/ps1/build/out";
    private static final String DB_URL = "jdbc:postgresql://localhost:5432/alice_dev";
    private static final boolean DEBUG = true;   // 只对 Alice 有意义的本地开关

    public static void write(String text) throws Exception {
        java.nio.file.Files.write(
            java.nio.file.Paths.get(OUTPUT_DIR, "report.txt"),
            text.getBytes("UTF-8"));
    }
}
$ git status --short
A  ReportWriter.java
A  ReportWriter.class          # 编译产物:应被忽略
A  build/out/report.txt        # 运行产物:应被忽略
A  .DS_Store                   # 操作系统垃圾:应被忽略
A  local-settings.xml          # 本机配置:不应共享

【错误代码的问题】

  1. 不可移植:其他开发者克隆后路径不存在,write() 直接失败;DB_URL 指向 Alice 的本地数据库,谁都连不上。
  2. 编译产物进入历史:.class 文件是二进制,每次构建都会产生巨大且无意义的 diff,还会与源码冲突、制造合并冲突。
  3. 运行产物与操作系统垃圾文件污染 git status,让你无法一眼看出”哪些是真正的改动”——这直接削弱了 git status 作为核心工具的价值。
  4. 个人偏好(DEBUG = true)被固化进共享代码,他人的调试体验被你的设置覆盖,且无法通过配置调整。

✅ 正确代码

// 正确:所有环境相关信息从外部注入,代码本身保持纯粹与可移植
public class ReportWriter {
    private final java.nio.file.Path outputDir;

    /**
     * @param outputDir directory to write reports into; requires outputDir
     *        is an existing, writable directory
     */
    public ReportWriter(final java.nio.file.Path outputDir) {
        this.outputDir = outputDir;
    }

    /** @return the file the report was written to */
    public java.nio.file.Path write(final String text) throws java.io.IOException {
        java.nio.file.Path report = outputDir.resolve("report.txt");
        java.nio.file.Files.write(report, text.getBytes(java.nio.charset.StandardCharsets.UTF_8));
        return report;
    }
}

// 环境相关的选择集中在程序入口,并由命令行参数或环境变量提供
public static void main(String[] args) throws Exception {
    java.nio.file.Path out = java.nio.file.Paths.get(args[0]);   // 例如 args[0] = "build/out"
    new ReportWriter(out).write("hello");
}
$ cat .gitignore
build/
out/
*.class
.DS_Store
local-settings.xml

$ git add ReportWriter.java .gitignore
$ git commit -m "Write reports to a caller-supplied directory"

【为什么这样更好】 环境差异被推到程序边界之外,仓库里只剩下与机器无关的源码,任何人克隆后都能构建与运行;.gitignoregit status 只显示真正需要关心的改动,使它重新成为有效的状态检查工具(本讲反复强调的习惯);编译产物不再进入历史,diff 干净、仓库更小、合并更少冲突。补充说明:Java 项目中路径通常通过 Path 参数或系统属性传入;把”当前工作目录”当作隐含全局状态(例如依赖 user.dir)是同一类错误。

【代码对比解说】 这组对比把”不要使用全局变量”(Reading 4)与”工作副本卫生”连起来:OUTPUT_DIRDB_URLDEBUG 都是披着 static final 外衣的环境依赖,它们让同一份源码在不同机器上表现不同,等价于隐式的全局状态。正确做法是把它们变成显式的参数(构造器参数、方法参数、配置对象),让依赖关系在签名里可见——这与 Reading 6 中”参数是前置条件的一部分”完全一致:数据的来源与合法性应当写在契约里,而不是藏在常量中。.gitignore 则处理”什么该被跟踪”这一问题,它的判断标准是:这个文件能由仓库里的其他文件重新生成吗? 能重新生成的(.class、构建产物),就不该提交。

【设计原则透视】 这组对比跨越了版本控制与依赖管理:提交到仓库的东西定义了”这个项目是什么”。可移植的仓库让 Reading 29(Team Version Control)中的团队协作成为可能——每个人都能克隆、构建、测试。同时它呼应了 Reading 4 的”快速失败”:与其让程序在别人机器上因为找不到路径而神秘失败,不如让路径成为必须显式提供的参数,在编译期/启动期就暴露”你没有提供输出目录”这件事。


与其他设计原则的关联

  • 与 Reading 1(Static Checking):版本控制与静态检查是两种”早发现”机制——前者在时间维度上尽早暴露回归,后者在编译期尽早暴露类型错误。两者都让修复成本降到最低。
  • 与 Reading 3(Testing):每个提交应保持测试通过,这样历史才是可信的、git bisect 才有意义;测试文件与源码一起提交,才能让”历史版本”真正可复现。
  • 与 Reading 4(Code Review):审查的对象是”提交”,因此提交的粒度与消息质量直接决定审查价值;上一讲中”不要用注释保存旧代码”的可行性,完全由本讲的对象图提供。
  • 与 Reading 6(Specifications):合并冲突本质上是”契约理解不一致”的物理表现;场景 1 表明,把接口的职责边界写进规格说明,可以避免”文本合并成功、语义合并失败”。
  • 与 Reading 8(Immutability):Git 的对象(commit、tree、blob)一旦写入就永不修改,因此可以被多个提交自由共享——这正是”不可变数据可以安全共享”的最有说服力的工程实例;本讲的”提交不可变性”与下一讲的”不可变对象”共享同一套推理。
  • 与 Reading 9(Avoiding Debugging)git loggit blamegit bisect 是最有效的调试辅助工具之一;把”什么时候开始坏的”从猜测变成查询,能大幅缩短调试时间。
  • 与 Reading 29(Team Version Control):本讲建立了单人/小规模协作的模型,Reading 29 会把分支策略、pull request、代码审查流程与团队工作流系统地接上去。

关键要点

  • Git 存的是对象图,不是差异:提交是项目在该时刻的完整快照,树与 blob 让相同内容只存一份;git show 显示 diff 只是一种更有用的呈现方式,末尾加冒号就能看到完整快照。
  • 历史是有向无环图,分支只是一个名字:两个提交共享父提交就出现分叉,一个提交有两个父提交就是合并;环在语义上不可能(提交不能是自己的祖先)。
  • 三区模型决定你的每一步:工作目录(你编辑的文件)、暂存区(半成品提交)、仓库(对象图);git status 是随时校准你对这三区认知的工具。
  • push 被拒绝是保护而非惩罚:它防止远端历史丢失他人已推送的提交;正确反应是 pull(取回并合并)而不是强推。
  • 提交要原子、消息要讲”为什么”:能用一句不含”并且”的话描述的提交才是可审查、可回退、可 blame 的提交;”改了什么”已经由 diff 记录,消息应补上意图。

常见陷阱与注意事项

  1. 以为”自动合并成功”就等于”正确” → Git 只在文本层面合并,两个各自合理的改动可能组合出错误语义(CiaoEve)。补救:合并后必须重跑测试,并人工审查合并结果。
  2. 把编译产物、运行产物、本机配置提交进仓库 → 二进制噪声让 diff 失去可读性、制造无谓冲突、暴露本机信息。补救:写 .gitignore,只提交能由源码重新生成之外的东西。
  3. 在同一个提交里混入重命名、格式化与行为改动 → 无法回退、无法审查、git blame 失效、重命名检测失败导致历史断裂。补救:用 git add -p 分批提交,让每个提交只做一件事。
  4. 直接使用 GUI 插件或编辑器菜单执行 add/commit/push → 部分工具改变了操作语义,出问题时难以诊断,助教也无法复现。补救:用命令行,把 GUI 工具限制在”查看状态与历史”的用途上。
  5. 把源文件当版本仓库用(v2 final、注释掉的旧实现) → 死代码积累、历史不可查、bug 修复改错分支。补救:删除旧代码,需要时用 git log -pgit showgit revert 取回。
  6. 从不运行 git status,或长期不提交 → 工作目录里堆积大量未提交改动,一旦误操作(覆盖、切分支、误删)就无法恢复,也写不出有意义的提交消息。补救:养成小步提交与”命令前后各看一次状态”的习惯。

思考题(带答案)

问题 1:Alice 与 Ben 从同一个提交开始,Alice 的提交先被 push 到远端。现在 Ben 执行 git push origin main,为什么会被拒绝?如果 Git 允许这次推送,会发生什么?Ben 的正确操作序列是什么?

答案:推送被拒绝是因为这不是快进(fast-forward)更新:远端的 main 现在指向 Alice 的提交,而 Ben 的本地 main 指向他自己的提交,两者从共同祖先分叉。如果服务器把 main 指向 Ben 的提交,Alice 的提交就会从项目历史中不可达——她的工作事实上丢失了。因此 Git 拒绝推送。Ben 的正确操作是先 git pull(等价于 git fetch 加合并):把 Alice 的提交下载进本地对象图,并把两段历史合并,生成一个有两个父提交的新合并提交 3e62e60,其内容是”双方改动都已应用”的完整快照;如果两人改动了同一文件的同一部分则需要人工解决冲突并提交合并结果;之后 Ben 再 git push origin main,远端就能快进到新的合并提交,历史不会丢失。

问题 2:请解释”提交是一个快照”与 git show 输出 diff 之间的矛盾,并说明为什么这个设计对”共享存储”和”快速检出”都有好处。

答案:两者不矛盾,它们是存储模型展示方式的区别。存储模型上,每个提交是一个 tree 对象,指向代表各文件内容的 blob 对象,即一棵完整的目录快照。展示上,Git 假设一次提交只改动项目中的少数文件,因此默认只显示与父提交的差异,信息密度更高。好处之一是可共享性:同一个文件版本只存一份 blob,多个提交复用同一个 blob 对象,因此仓库不会随提交数线性膨胀——这正是不可变数据可安全共享的直接应用(Reading 8)。好处之二是快速检出与比较:检出任意版本只需按 tree 展开 blob,不需要在差异链上重放历史;比较两个版本只需比较两棵树的对应 blob。也正因为提交是快照,合并才能以”三方比较”(共同祖先 + 两个分支尖端)的方式自动完成。

问题 3:在一次 team project 中,一位同学提交了一个名为 fix stuff 的提交,其中同时改了 .gitignore、把两个类重命名、修了一个空指针 bug,还把三处格式从 4 空格改成 2 空格。请指出这个提交违反了本讲的哪些原则,并说明如何把它拆成更好的历史(不必真的改写已经推送的历史)。

答案:违反之处有四。其一,原子性:一个提交做了至少四件互不相关的事,无法用一句不含”并且”的话描述,因此无法被独立审查或独立回退。其二,可审查性:格式改动(2 空格)产生的噪声会淹没真正的语义改动(空指针修复),违反 Reading 4 中”审查要能看清语义变化”的期望。其三,责任追踪:git blame 会把大量未改动的行归给这次提交,历史信息被抹平。其四,重命名与内容改动混杂,Git 的重命名检测(启发式)可能失败,--follow 因此断链。更好的做法是:以后的改动按”配置”“重命名”“行为修复”“格式化”分四次提交,每次提交前用 git add -p 挑选代码块并各自写清意图,且每个提交后跑一遍测试保持绿色。对于已经推送的历史,不要去改写(强推会破坏他人的克隆),而是在团队约定后补做:把格式化规则写进项目约定与自动格式化脚本,并在下一次相关改动时把格式与语义分开提交;必要时可在仓库文档中记录这次提交的复合意图,以免后人误读。