Git 底层探索:快照、对象、引用以及拓展内容

对于计算机相关领域的学生,git commit, git push 等命令早已成为日常生活的一部分。但长久以来,我对其「黑盒」之下的运作方式一直抱有浓厚的好奇心。Git 是怎么存储和管理版本的?它是如何高效地处理分支、合并和远程交互的?

为了解答这些疑惑,我近期使用 Gemini 2.5 Pro 进行了针对性的学习和探讨。这篇博文,便是我将那些零散的知识点、讨论与验证过程进行梳理、消化和串联后的成果。它主要记录了从 Git 最基础的数据模型(对象、快照)出发,逐步理解其如何构建起索引(暂存区)、分支、合并乃至远程交互等一系列核心功能。此外,也补充了一些诸如 reflog、LFS 等实用功能的原理浅析。

数据模型与核心对象

一切皆快照

首先抛开 SVN, Git 之类的版本控制系统,从最原始的角度出发,我们该如何进行版本管理呢?

从单文件的文档管理说起吧。例如一个 paper.tex,可能可以延伸出来

  • paper_2025-07-09.tex
  • paper_v2.tex
  • paper_review-1.tex
  • paper_revert-2.tex
  • paper_final.tex
  • paper_TRUE_FINAL.tex

等等版本。而为了可以随时进行回退而不丢失之前的更改,这些版本可能需要共存。

然而我们很快就能注意到,在相邻的迭代之间的更新可能是相当小的,而大部分内容仍然是保持不变的。如果都是像上面一样拷贝一个副本,进行「全量存储」,那么冗余数据会非常多。

因此很自然地就会想到,那我每次只存储差异,进行「增量存储」,可不可行呢?

当然是可行的。许多传统的版本控制系统,例如 Subversion (SVN),其核心思想就与文件的差异(deltas/differences)紧密相关。当你提交时,系统会关注从上一个版本到当前版本,文件发生了哪些变化。这种以「变化」为核心的记录方式非常直观。

但是,如果一个系统完全依赖于从初始版本开始逐个应用补丁(patch)来重现任意历史版本,那么在获取一个较远的历史状态时,其效率可能会成为一个考量点(尽管现代系统如 SVN 已有诸多优化,并非如此简单地运作)。

而 Git 在这里选择了另一条路。它关注的不是「变化」,而是「状态」。

事实上,Git 的核心思想更接近我们最初提到的「全量存储」,但又远比简单的复制要智能。

Git 采用的是快照(snapshots)的方式。当你执行 git commit 时,Git 实质上是对你项目中所有文件的当前状态[1]拍摄了一个快照,并将这个快照的索引(一个指向快照的指针)保存下来。为了效率,如果文件没有修改,Git 不会重新存储该文件,而是只保留一个指向上一次存储的该文件版本的链接。

这种快照的方式有几个非常重要的优点:

  • 完整性:每个提交都包含了项目的完整状态(或者说,包含了如何重建项目完整状态的全部信息),这使得历史追溯和分支切换非常干净利落。
  • 速度:获取某个历史版本时,Git 不需要从头开始计算差异并逐个应用,可以直接「调取」那个快照。分支切换也因此变得飞快。
  • 简单性:从概念上讲,每个版本都是项目的一个完整镜像,这比思考一连串的补丁和差异要直观一些。

当然,这就属于是「以空间换时间」,会有我们上面刚刚提到的大量冗余数据的问题。Git 的对策会在下面慢慢进行介绍。

Git 的四大基石:核心对象类型

当你 git init 后,目录就会出现一个 .git 隐藏目录,这便是 Git 的仓库。

它可以被视为一个简单的键值对数据库。你可以向 Git 仓库中插入任意类型的内容,它会返回一个唯一的键,通过该键可以在任意时刻再次取回(校验)该内容。这个键就是 SHA-1 哈希值

Git 中有四种主要的对象类型,它们是构成 Git 所有功能的基础。

首先我们版本管理工具的管理对象,那就是一堆文件,或者更准确地说其实是文件的内容。那么我们就需要一个对象来存储它们,这个对象就是数据对象(Blob Object, Binary Large Object)。

我们上面更关心的其实是「文件的内容」,但是实际上我们往往还有文件名以及一定的目录结构。为了避免复杂化,我们将其从上面拆解出来,这不意味着我们就不需要它了。因此我们还需要一个对象记录文件名以及目录结构,这个对象就是树对象(Tree Object)。

数据对象和树对象组合起来,是什么呢?正好就是仓库的状态!但是这个状态只是一瞬的,我们并没有在其中引入「时间」的概念,从而版本控制也就无从谈起了。我们常常用 git commit,便是「提交」了当前仓库的状态。因此还需要一个提交对象(Commit Object)来表示仓库在某个特定时间点的一个快照的完整描述,它将上面的树对象与一些重要的元数据(如作者、提交信息等)关联起来。

还有一个对象本篇不会过多涉及,这个就是标签对象(Tag Object)。标签对象用于给某个特定的提交对象打上一个标签。

这样,Git 的四种主要对象类型就简单介绍完了,可以来看看更细节的介绍:

数据对象(Blob Object, Binary, Large Object)

  • 用途:用来存储文件的内容。注意,仅仅是文件的内容,不包含文件名、时间戳或其他元数据
  • 生成:当你使用 git add 命令将一个文件添加到暂存区时,Git 会计算该文件内容的 SHA-1 哈希值。如果这个内容之前没有存储过,Git 会将文件内容压缩后存储为一个 blob 对象,其键就是这个 SHA-1 哈希值。如果内容相同的文件已经存在(即使文件名不同),Git 也不会重复存储,而是复用已有的 blob 对象。这就是 Git 节省空间的一个重要方式。
  • 唯一标识:每个 blob 对象都有一个唯一的 SHA-1 哈希值,这个哈希值是根据文件内容计算出来的。所以,内容相同的文件,它们的 blob 对象是同一个

树对象(Tree Object)

  • 用途:解决了 blob 对象只存储文件内容,不存储文件名和目录结构的问题。树对象代表了项目某一个时刻的目录结构。它像一个文件夹,里面可以包含其他树对象(代表子文件夹)和 blob 对象(代表文件)。
  • 内容:一个树对象包含了一系列的条目(entries)。每个条目由以下几部分组成:
    • 模式(mode):类似 Unix 文件系统的文件权限,但 Git 只关心它是不是可执行文件。
    • 类型(type):指向的是 blob 还是 tree。
    • SHA-1 哈希值:指向一个 blob 对象(如果是文件)或另一个树对象(如果是子目录)。
    • 文件名(filename):该文件或子目录在当前树对象(目录)下的名称。
  • 如何生成:当你执行 git commit 时,Git 会根据暂存区的内容构建一个或多个树对象。从项目根目录开始,每个目录都会对应一个树对象。
  • 唯一标识:树对象的 SHA-1 哈希值是根据它所包含的所有条目(模式、类型、SHA-1、文件名)的内容计算出来的。所以,目录结构和其下文件(或子目录指针)完全相同时,树对象也是同一个

提交对象(Commit Object)

  • 用途:这是 Git 版本控制的核心。一个提交对象代表了项目在某个特定时间点的一个快照的完整描述。它将上述的树对象(代表项目快照的根目录)与一些重要的元数据关联起来。
  • 内容:一个提交对象通常包含:
    • 树对象(tree):一个指向代表项目根目录的树对象的 SHA-1 哈希值。这个树对象指明了此次提交时项目所有文件和目录的快照。
    • 父提交(parent(s)):一个或多个指向前一次(或多次)提交对象的 SHA-1 哈希值。
      • 绝大多数提交只有一个父提交,形成线性历史。
      • 合并提交(merge commit)会有两个或多个父提交,分别指向被合并的各个分支的最新提交。
      • 项目的第一个提交没有父提交。
    • 作者(author):提交者的姓名和电子邮件地址,以及提交时间。这是代码的实际创作者。
    • 提交者(committer):执行提交操作的人的姓名和电子邮件地址,以及提交时间。通常和作者是同一个人,但在打补丁或由他人代为提交时可能会不同。
    • 提交信息(commit message):描述此次提交的文字说明。
  • 如何生成:当你执行 git commit 时,Git 会创建一个新的提交对象。它会使用当前暂存区构建的顶层树对象的 SHA-1,并包含你提供的提交信息、作者/提交者信息以及指向当前分支最新提交的 SHA-1 作为父提交。
  • 唯一标识:提交对象的 SHA-1 哈希值是根据上述所有内容(树、父提交、作者、提交者、提交信息)计算出来的。这意味着任何元数据的改变(比如修改提交信息、不同提交时间)都会导致生成一个全新的提交对象(拥有不同的 SHA-1 哈希值),即使代码内容本身没有变化。这也是为什么 git commit --amend[2] 会改变提交历史的原因。
gitGraph
commit id: "C1" msg: "初始提交:添加 index.html"
commit id: "C2" msg: "添加功能A:实现 login.js"

%% --- amend 之前的状态 ---
%% 这是我们想要修改的提交。
%% 我们用一个虚构的 "旧的 main" 分支来表示它即将被废弃的状态。
branch "旧的 main(将被替换)"
checkout "旧的 main(将被替换)"
commit id: "C3-old" msg: "错误的提交:修复了 bug,但有拼写错误" type: REVERSE

%% --- 执行 git commit --amend 之后 ---
%% Git 创建了一个全新的提交 C3-new,它替换了 C3-old。
%% main 分支的指针现在指向这个新提交。
checkout main
commit id: "C3-new" msg: "修正后的新提交:修复了 bug(已修正)" type: HIGHLIGHT

%% 最终,"旧的 main" 分支上的 C3-old 提交变得不可达,
%% 并最终会被 Git 的垃圾回收机制清理掉。

标签对象(Tag Object)

  • 用途:用于给某个特定的提交对象(通常是重要的版本发布点,如 v1.0)打上一个更有意义、更固定的名字。标签分为两种:轻量标签(lightweight tag)和附注标签(annotated tag)。
  • 轻量标签:仅仅是一个指向特定提交的引用(像一个不会移动的分支)。它只是一个名字,没有额外的信息。
  • 附注标签:是 Git 数据库中的一个完整对象。它有自己的 SHA-1 哈希值,并且包含:
    • 对象(object):指向的提交对象的 SHA-1 哈希值。
    • 类型(type):通常是 "commit"。
    • 标签名(tag name):例如 v1.0
    • 打标签者(tagger):打标签的人的姓名、邮箱和时间。
    • 标签信息(tag message):一段描述该标签的文字。
    • (可选)GPG 签名:可以对标签进行数字签名,以验证其来源。
  • 原理:附注标签是一个独立的 Git 对象,它指向一个提交对象,并存储了额外的元数据。轻量标签则更像一个别名,直接指向提交对象。在底层原理层面,我们更关注附注标签,因为它是一个实际的 Git 对象。

SHA-1 哈希值

贯穿始终你会发现 SHA-1 哈希值无处不在。这个 40 个字符的十六进制字符串(例如 5819778898DF55E3A762F0C5728B457970D72CAE[3])是 Git 内部的「身份证号码」。

  • 唯一性:对于给定的内容,其 SHA-1 哈希值是唯一的(碰撞的概率极低极低,可以忽略不计[4])。
  • 完整性校验:Git 通过 SHA-1 来确保数据的完整性。如果一个对象的内容被意外损坏(比如磁盘错误),它的 SHA-1 哈希值就会改变,Git 就能检测到这个问题。
  • 对象寻址:Git 仓库中的所有对象都是通过它们的 SHA-1 哈希值来索引和查找的。

Git 的压缩魔法

Packfiles 与 Delta 压缩

上面已经提到了这种「全量存储」的缺陷,如果每次提交都完整地存储每一个文件的快照,即使只有微小的改动,也会产生很多几乎相同的 blob 对象,即便有 blob 对象的复用(内容完全相同时),对于内容相似但不完全相同的文件,依然会显得不够高效,肯定会比那些基于增量存储的系统占用更多初始空间。

这正是 git gc(Garbage Collect,垃圾回收)和 Packfile(打包文件)机制要解决的核心问题。Git 并不会永远以松散对象(loose objects)[5]的形式保存所有对象。

当你使用 Git 一段时间后,或者在某些操作(如 git push 推送到远程仓库)之后,Git 可能会自动运行 git gc,你也可以手动运行它。这个命令主要做几件事:

  • 收集所有松散对象:找到 .git/objects/ 目录下的所有独立的 blob, tree, commit 对象。
  • 打包:将这些松散对象压缩并存储到一个(或少数几个)被称为 packfile 的大文件中。这些文件通常位于 .git/objects/pack/ 目录下,以 .pack 为扩展名。
  • 创建索引:同时会生成一个或多个 .idx 文件,这是 packfile 的索引文件,记录了每个对象在对应 .pack 文件中的位置和其他信息,以便快速查找。
  • 移除旧的松散对象:一旦对象被安全地打包进 packfile,对应的松散对象就可以被删除了,从而大大减少了 .git/objects/ 目录下的文件数量。这不仅节省了磁盘空间,也提高了文件系统的操作效率(操作系统课上我们会学到,管理几万个小文件通常比管理几个大文件要慢)。

在创建 packfile 的过程中,Git 不仅仅是简单地把对象压缩一下放进去,它还会进行一个非常重要的步骤——寻找并存储对象之间的差异(deltas)。

如果 Git 发现仓库中有两个或多个非常相似的对象(尤其是 blob 对象,因为它们通常最大),它不会完整地存储每一个对象。相反,它会选择其中一个作为「基础版本」(base object),然后将其他相似的对象存储为「相对于基础版本的差异」(delta)。

具体操作是这样的:

  1. Git 会寻找那些相似的文件版本(通常是同一个文件的不同版本,但也可能是不同但内容相似的文件)。
  2. 它会选择一个版本作为「基准」(base)。
  3. 然后,对于其他相似的版本(target),Git 会计算出从「基准」版本变成这个「目标」版本所需要修改的信息。这个「差异信息」就是 delta。
    • 差异信息可以理解为一组「编辑指令」,比如「从基准的第 X 字节开始复制 Y 字节,然后在 Z 位置插入这些数据…」。
  4. 在 packfile 中,Git 会存储完整的「基准」对象(或者它本身也是一个相对于更早基准的 delta),然后存储这个「差异信息」。

虽然 blob 对象是 delta 压缩的主要受益者,但理论上其他对象类型也可以进行 delta 压缩,只要能找到合适的基准并计算出差异。

有时候,一个 delta 的基准本身也可能是一个 delta(相对于更早的基准)。这就形成了一个 Delta 链。Git 会限制这个链的深度(例如,默认不超过 50 层,可以通过 pack.depth 配置),以避免在恢复对象时需要进行过多的计算。

当你需要读取一个对象时(比如 git checkout 一个旧版本,或者 git show 一个提交):

  1. Git 首先会尝试在松散对象中查找。
  2. 如果找不到,它会去查询 packfile 的 .idx 索引文件。
  3. 索引文件会告诉 Git 这个对象在哪个 .pack 文件中的哪个位置。
  4. 如果这个对象是完整存储的,Git 就直接从 packfile 中解压并读取它。
  5. 如果这个对象是作为 delta 存储的,索引文件会包含它所依赖的基准对象的 ID 和位置。Git 会先读取并重建基准对象(如果基准对象本身也是 delta,则递归此过程),然后应用 delta 信息来重建出你需要的那个对象。这个过程是在内存中完成的。

差异检测

Git 又是怎么进行差异检测与压缩的呢?

首先 Git 使用启发式算法来寻找可以作为基准的对象。它会考虑对象的类型、大小,以及文件名等因素。对于每个待打包的对象,Git 会在一个「窗口」(pack.window 配置,默认为 10,git gc --aggressive 时会更大)内的其他对象中寻找最佳的基准对象,目标是使生成的 delta 最小。它会尝试对多个候选基准计算 delta,然后选择效果最好的那个。

生成 delta 的算法类似于 diff 工具的算法(例如 Myers diff 算法的变种),但针对二进制数据和效率进行了优化。它会识别出基准对象和目标对象之间相同的数据块(可以被复制)和不同的数据块(需要被直接插入)。生成的 delta 包含了一系列的复制指令(从基准的某个偏移量复制多少字节)和添加指令(直接插入数据)。

最后无论是完整的对象还是 delta 数据,在存入 packfile 之前都会使用 zlib 库进行压缩。

Diff 算法的细节(以 Myers Diff 为例)

Git 在计算两个对象(通常是 blobs)之间的差异以生成 delta 时,会用到类似传统 diff 工具的算法。Eugene Myers 在 1986 年提出的 "An O(ND) Difference Algorithm and Its Variations" 是这类算法中非常经典和高效的一个。

核心目标就是,给定两个序列(比如文件 A 的字节序列和文件 B 的字节序列),找出将序列 A 转换为序列 B 所需的最少编辑操作(通常是插入和删除)。这个「最少编辑操作序列」就是所谓的 Shortest Edit Script (SES)。

找到两个序列的最长公共子序列(Longest Common Subsequence, LCS)是解决 SES 问题的一个关键步骤。LCS 是指在两个序列中都以相同顺序出现的、最长的子序列(子序列中的元素不必在原序列中连续)。那些不属于 LCS 的部分,就是要从 A 中删除或要插入到 B 中的部分。

想象一个二维网格。水平轴代表序列 A 的元素,垂直轴代表序列 B 的元素。从左上角 (0, 0) 到右下角 (len(A), len(B)) 的一条路径就代表了 A 和 B 的一种对齐方式。

  • 向右移动一步:表示从 A 中删除一个元素。
  • 向下移动一步:表示向 B(即结果)中插入一个元素。
  • 沿对角线移动一步(当 A 的当前元素等于 B 的当前元素时):表示一个匹配,这个元素是公共子序列的一部分。

算法的目标是找到一条从 (0, 0)(len(A), len(B)) 的路径,该路径包含的「向右」和「向下」的移动次数之和最小(即编辑距离 D 最小),或者等价地说,包含的「对角线」移动次数最多(即 LCS 最长)。

Myers 算法不是盲目搜索。它以「编辑距离 D」为单位进行迭代。在第 D 轮,它尝试找到所有恰好经过 D 次编辑(删除或插入)所能到达的最远位置。关键在于,在两次编辑之间,可能会有一段连续的匹配序列(对角线移动),这被称为 snake(蛇形延伸)。算法会尽可能地沿着这些 snakes 延伸,以快速推进。

对于长度分别为 MMNN 的两个序列,如果它们之间的编辑距离是 DD,Myers 算法的时间复杂度是 O((M+N)D)O((M+N)D)。当两个文件非常相似时(DD 很小),这个算法非常快。如果文件差异巨大(DD 接近 M+NM+N),则性能会下降。

基本的 Myers 算法可能需要 O((M+N)D)O((M+N)D)O(M+N)O(M+N) 的空间来存储路径信息以重建编辑脚本。不过,有很多变种算法可以优化空间使用,比如只在需要时计算部分脚本,或者使用 Hirschberg 算法等分治策略将空间复杂度降低到 O(M+N)O(M+N) 甚至 O(min(M,N))O(\min(M,N)),但可能会增加一点计算时间。

Git 的 diff/delta 实现会针对二进制数据进行优化,并且会综合考虑生成 delta 的速度和最终的压缩效果。它可能不会严格执行最学术的 Myers 算法,而是采用了一些工程上的优化和启发式方法,比如限制搜索的复杂度,或者尝试不同的 diff 策略(例如,除了 Myers,还有 Patience Diff, Histogram Diff 等变种思想,以及专门用于二进制的 xdelta 库的思想都可能被借鉴或启发)来平衡各种因素。

性能

这些举措极大地减少了仓库的磁盘占用。对于包含大量相似文件(如文本文件、代码文件)或文件有多次小修改的项目,效果尤其显著。一个几十 GB 的充满松散对象的仓库,在 git gc 之后可能只有几 GB 甚至更小。

读取性能上:

  • 对于直接存储在 packfile 中的对象,读取速度通常很快,因为索引可以直接定位。
  • 对于需要通过 delta 重建的对象,会有一些额外的计算开销。如果 delta 链很长,或者 delta 本身比较复杂,重建时间会相应增加。但由于基准对象和 delta 通常都比较小,并且这个过程在内存中进行,所以对于大多数日常操作,这个开销是可以接受的。

总体而言,由于操作系统处理少量大文件比处理大量小文件更高效,所以使用 packfile 往往能提升整体的 I/O 性能,尤其是在对象数量非常多的时候。

对于写入/打包性能git gc 的过程,特别是 delta 压缩的计算和寻找最佳基准的过程,是相当消耗 CPU 和 I/O 资源的。这就是为什么 git gc 通常不会频繁地在每次提交后都运行,而是积累到一定程度(比如松散对象的数量达到某个阈值,可以通过 gc.autogc.autoPackLimit 配置)或者在推送到服务器时(服务器端可能会触发)以及手动执行时才进行。

而对于网络传输性能:Packfile 机制对网络传输效率至关重要。当你从远程仓库 fetchclone,或者向远程仓库 push 时,Git 客户端和服务器会协商它们共同拥有的对象。然后,发送方会动态地创建一个只包含对方所缺少的对象的 packfile。

不仅如此,这个动态生成的 packfile 也会使用 delta 压缩,并且它可以基于接收方已经拥有的对象作为基准来生成 delta。这意味着传输的数据量可以被压缩到极致,只传输真正的「新」信息。这被称为 thin pack,是 Git 网络协议非常高效的关键原因之一。

索引文件

索引文件

索引 .idx 文件是 .pack 文件的配套「目录」,它使得 Git 能够快速定位到 .pack 文件中任何一个对象的数据,而无需扫描整个巨大的 .pack 文件。目前主流的是版本 2(.idx v2)的格式,它支持超过 4GB 的 packfile 并包含了一些性能改进。

一个典型的 v2 .idx 文件主要包含以下几个部分,按它们在文件中的顺序排列:

  • Header (头部信息):
    • Magic Number (魔数): 固定为 \377tOc(4 个字节)。这是一个标记,表明这是一个 pack 索引文件。
    • Version Number (版本号): 固定为 \x00\x00\x00\x02(4个字节),表示这是版本 2 的索引文件。
  • Fan-out Table (扇出表/一级索引):
    • 这是一个包含 256 个条目的表,每个条目是 4 字节的整数。
    • 第 i 个条目(0 <= i <= 255)存储的是 .pack 文件中所有对象里,其 SHA-1 哈希值的第一个字节小于 i 的对象的总数量
    • 作用:当要查找一个 SHA-1 哈希值(比如 A1B2C3…)时,Git 会查看这个 SHA-1 的第一个字节(这里是 0xA1)。通过扇出表中 fanout[0xA1]fanout[0xA0](如果 0xA0 存在的话,或者是 fanout[0xA1-1])的值,Git 可以迅速确定这个 SHA-1 在下一个「SHA-1 列表」中大致的位置范围,从而缩小二分查找的范围。
  • SHA-1 Lookup Table (SHA-1 列表/二级索引):
    • 这是一个包含了 packfile 中所有对象的完整 20 字节 SHA-1 哈希值的列表。
    • 这个列表是按照 SHA-1 哈希值的字典序严格排序的
    • 作用:Git 在由扇出表确定的范围内,对这个列表进行二分查找,以精确定位到目标 SHA-1 哈希值,并得到它在这个列表中的序号(ordinal number)。
  • CRC32 Checksum Table (CRC32 校验和列表/三级索引):
    • 这是一个包含了 packfile 中每个对象数据的 CRC32 校验和的列表,每个校验和为 4 字节。
    • 列表的顺序与 SHA-1 列表的顺序完全一致。
    • 作用:用于数据完整性校验。当 Git 从 packfile 中读取一个对象的数据后,会计算其 CRC32,并与这里存储的值进行比较,确保数据没有损坏。
  • Packfile Offsets Table (Packfile 偏移量列表/四级索引):
    • 这个列表存储了每个对象在 .pack 文件中的起始偏移量(offset)。
    • 列表的顺序也与 SHA-1 列表的顺序一致。所以,通过在 SHA-1 列表中找到的序号,可以直接在这个偏移量列表中找到对应的偏移。
    • 每个偏移量通常是 4 字节。但是,为了支持大于 4GB 的 packfile,如果一个偏移量的最高位被设置了,那么它表示这是一个「大偏移量」,接下来的 4 个字节(总共 8 字节)才是一个 64 位的偏移量。
    • 作用:这是最终定位对象数据的关键。得到偏移量后,Git 就可以直接 seek.pack 文件中的这个位置开始读取对象数据(可能是完整的对象,也可能是 delta 数据)。
  • Trailer (尾部信息):
    • Packfile Checksum (Packfile 校验和): .pack 文件本身内容的 SHA-1 校验和(20 字节)。
    • Index Checksum (索引文件校验和): .idx 文件到此之前所有内容的 SHA-1 校验和(20 字节)。用于确保索引文件本身的完整性。

查找过程:

  1. 当 Git 需要一个对象(比如 A1B2C3D4…)时:
  2. 取 SHA-1 的第一个字节 0xA1
  3. 查询扇出表,得到 count_less_than_A1 = fanout[0xA1]count_less_than_A0 = fanout[0xA0](或前一个有效值)。这意味着目标 SHA-1 在 SHA-1 列表中的索引范围大致是 [count_less_than_A0, count_less_than_A1 - 1]
  4. 在这个确定的范围内,对 SHA-1 列表进行二分查找,找到 A1B2C3D4… 及其准确的序号 k
  5. 使用序号 k 从偏移量列表中取出第 k 个偏移量。
  6. 使用这个偏移量,到 .pack 文件中读取对象数据。如果读取的是 delta 数据,还需要进一步解析 delta,找到基准对象并应用差异来重建目标对象。

这种多级索引结构确保了即使在非常大的 packfile 中,对象查找也能非常高效。这样的思想在很多其他课程,如计算机组织结构、操作系统等中也能见到。

工作区与仓库的桥梁:索引(暂存区)

索引是什么

每次在 git commit 提交之前,我们都需要先将修改的文件用 git add 命令添加到暂存区[6],那么这个暂存区究竟是什么呢?它在 Git 内部是如何存在的?

很多人可能将暂存区简单理解为一个「即将提交的文件列表」。这个理解没错,但不完整。从更深层次来看,Git 的索引是一个非常关键的中间区域,它扮演着下一次提交的「蓝图」或「预演」的角色。

在 Git 仓库中,索引实际上是位于 .git/index 的一个二进制文件(有时也因为历史原因被称为 cache 或 directory cache)。这个文件非常重要,Git 频繁地读取和修改它。

这个 index 文件并不直接存储文件内容本身(文件内容由 blob 对象存储),而是存储了你打算在下一次提交中包含的所有文件的清单以及它们的状态信息。具体来说,对于每一个被追踪的文件(tracked file),索引会记录:

  • 文件路径(Pathname):相对于项目根目录的完整路径。
  • SHA-1 哈希值(SHA-1 of the blob):该文件内容在特定时刻对应的 blob 对象的 SHA-1 值。这个 SHA-1 指向的是你通过 git add 添加到暂存区时的文件内容快照。
  • 文件元数据(Metadata):例如文件的权限模式(mode)、创建时间(ctime)、修改时间(mtime)、文件大小等。这些元数据用于帮助 Git 判断工作目录中的文件是否与暂存区中的版本发生了变化。
  • 暂存区编号(Stage Number):这在解决合并冲突时非常重要。正常情况下,文件的暂存区编号是 0。但在合并冲突期间,同一个文件路径可能会有多个条目,分别来自冲突的不同方。当你解决了冲突并再次 git add 后,这些冲突条目会被移除,并替换为一个新的、暂存区编号为 0 的、代表已解决状态的条目。

git addgit commit

当你执行 git add <file> 命令时,Git 做了以下几件关键的事情:

  • 创建 Blob 对象:Git 会读取你指定文件在工作目录(Working Directory)中的当前内容,计算其 SHA-1 哈希值。如果这个内容之前没有对应的 blob 对象,Git 会创建一个新的 blob 对象来存储这个内容(如果已存在,则复用)。
  • 更新索引:然后,Git 会用这个新的 blob 对象的 SHA-1 值以及该文件的路径和其他元数据来更新 .git/index 文件中对应的条目。如果这个文件是新添加的,就在索引中创建一个新条目。

这个「更新索引」的动作,就是所谓的「将文件添加到暂存区」或「将文件的改动暂存起来」。实际上,你是告诉 Git:「我希望我下一次提交快照中,这个文件的内容是当前工作目录中这个样子的。」

所以,git add 不仅仅是标记一下「我要提交这个文件」,它更深层的含义是:根据工作目录中的文件内容,准备或更新该文件在下一次提交快照中的版本,并将其信息记录到索引文件中。 索引文件因此成为了构建下一次提交所对应的顶层树对象的直接数据来源。

暂存区的存在是 Git 设计的一大亮点,它提供了极大的灵活性:

  • 原子性提交(Atomic Commits):你可以分多次 git add 来逐步构建一次提交,只把你想包含的改动放进暂存区。比如,你修改了三个文件,但只想把其中两个文件的改动作为一个逻辑单元提交,另一个文件的改动留到下次。暂存区使得这成为可能。你 add 那两个文件,然后 commit,这次提交就只包含暂存区的内容。
  • 代码审查与整理:在提交之前,你可以使用 git diff --staged(或者 git diff --cached)来查看暂存区的内容与上一次提交(HEAD)之间的差异,确保你将要提交的内容是你真正想要的。这给了你一个在正式「刻录」到历史之前反悔和修改的机会。
  • 分块暂存(Patch Staging):Git 甚至允许你只 add 文件中的一部分改动(例如使用 git add -p 或图形界面的类似功能)。索引使得这种细粒度的控制成为可能,因为它记录的是文件内容的特定 blob 版本,而不是仅仅一个文件名。
  • 解决合并冲突的中间地带:如前所述,在合并冲突时,索引文件会以特殊的方式记录冲突的各个版本,你解决冲突后,通过 git add 将解决后的内容更新到索引中,清除非 0 暂存区编号的条目,然后再进行提交。索引在这里充当了冲突解决的协调者。

最后当执行 git commit 时:

  1. Git 会首先查看索引文件(.git/index)。
  2. 根据索引文件中的信息(文件路径、模式、以及每个文件内容对应的 blob 的 SHA-1),Git 会构建起一个或多个树对象来精确表示这次提交时项目的目录结构和文件快照。
  3. 然后,Git 创建一个提交对象,这个提交对象会指向刚才构建的顶层树对象,并包含作者、提交者、提交信息以及父提交等元数据。
  4. 最后,Git 会将当前分支的引用(比如 refs/heads/main)更新为指向这个新创建的提交对象的 SHA-1。

git status

git status 是我们最常用的命令之一,它之所以能够清晰地告诉我们哪些文件被修改了、哪些已暂存、哪些是新文件,完全依赖于索引文件以及与其他两个关键状态的比较。这三个关键状态是:

  • HEAD:指向当前分支的最新一次提交的快照。你可以认为这是你项目「已保存的、官方的」版本。
  • 索引.git/index 文件中描述的快照。这是你「准备好要提交的」版本。
  • 工作目录:你磁盘上实际看到和编辑的文件。这是你「当前正在处理的、可能有些凌乱的」版本。

git status 通过进行以下两组主要的比较来生成它的报告:

  1. 索引 vs. HEAD(显示 "Changes to be committed" - 待提交的更改)
    • Git 会遍历索引文件中的每一个条目。
    • 对于索引中的每个文件,它记录了该文件内容的 SHA-1 哈希值(这是你上次 git add 这个文件时,它内容的哈希)。
    • 同时,Git 会查看 HEAD 指向的提交,并找到该提交所对应的树对象中,相同路径的文件的 SHA-1 哈希值。
    • 比较结果
      • 如果一个文件路径存在于索引中,但不存在于 HEAD 的树中,那么它是一个新暂存的文件(new file)。
      • 如果一个文件路径存在于 HEAD 的树中,但不存在于索引中,那么它是一个已暂存的被删除文件(deleted)。
      • 如果文件路径在两者中都存在,但它们的 SHA-1 哈希值不同,那么它是一个已暂存的被修改文件(modified)。
      • 如果 SHA-1 哈希值相同,那么对于这个文件,暂存区和 HEAD一致的
    • git diff --staged(或 git diff --cached)命令会详细地显示这些暂存区与 HEAD 之间的具体内容差异。
  2. 工作目录 vs. 索引(显示 "Changes not staged for commit" - 未暂存的更改)
    • Git 会遍历工作目录中的文件(以及索引中记录的、可能在工作目录中已被删除的文件)。
    • 对于每一个被索引跟踪的文件:
      1. Git 会查看该文件在工作目录中的当前元数据(如修改时间戳 mtime、文件大小 size)与索引中为该文件记录的元数据进行比较。如果元数据没有变化,并且 Git 的配置(比如 core.ignorestat)允许基于此进行快速判断,Git 可能就认为文件内容没有变(这是一个优化,但有时可能不准确,所以 Git 通常还会进行内容检查)。
      2. 更可靠的方式是,Git 会读取工作目录中该文件的实际内容,并动态计算其 SHA-1 哈希值。
      3. 然后,将这个动态计算出的工作目录文件内容的 SHA-1,与索引文件中为该文件路径记录的 SHA-1(即已暂存版本的 SHA-1)进行比较。
    • 比较结果
      • 如果一个文件在索引中存在,但在工作目录中找不到了,那么它是一个在工作目录中被删除的文件(deleted),但这个删除操作还未通过 git rmgit add -u 暂存。
      • 如果文件在两者中都存在,但工作目录内容的 SHA-1 与索引中记录的 SHA-1 不同(或者元数据发生了显著变化),那么它是一个在工作目录中被修改的文件(modified),但这个修改还未通过 git add 暂存。
    • git diff(不带额外参数)命令会详细地显示这些工作目录与索引之间的具体内容差异。

所以,.git/index 文件就像一个「账本」,git status 通过对比工作目录和 HEAD 与这个「账本」的记录,就能清晰地知道哪些东西变了,以及这些变化处于什么状态(已暂存待提交,或仅在工作区)。

分支的实现原理

分支是什么

在不了解上面的内容之前,可能认为分支是一个很重的东西,以为分支会复制整个项目目录。实际上 Git 的分支极其轻量。

在 Git 中,一个分支(branch)本质上仅仅是一个指向某个提交对象(commit object)的可移动指针。它只是一个包含了 40 个字符的 SHA-1 哈希值的小文件。

这些分支指针通常以普通文件的形式存储在你的 Git 仓库的 .git/refs/heads/ 目录下。

例如,如果你有一个名为 main 的分支,那么在 .git/refs/heads/ 目录下就会有一个名为 main 的文件。这个文件的内容,就是 main 分支当前指向的那个提交对象的 SHA-1 哈希值。

当你创建一个新的提交时,当前分支的指针会自动向前移动,指向这个新的提交。

HEAD:你在哪里

HEAD 是 Git 中一个非常特殊的引用或指针,它代表了你当前的工作位置。通常情况下,HEAD 指向你当前所在的分支。

HEAD 本身也是一个文件,位于 .git/HEAD

对于 HEAD 文件的内容,比较常见的情况是指向一个分支。例如如果你当前在 main 分支上工作,那么 .git/HEAD 文件的内容通常是:

ref: refs/heads/main

这表示 HEAD 是一个「符号引用」(symbolic reference),它指向 refs/heads/main 这个引用(也就是 main 分支的指针文件)。所以,HEAD 间接地指向 main 分支所指向的那个提交。

如果你使用 git checkout 命令直接切换到了一个具体的提交哈希值,或者一个标签名,而不是一个分支名,那么 HEAD 文件会直接包含那个提交的 40 位 SHA-1 哈希值。例如[7]

74FCED36C3EC2E819EDE4C63D08524675A79DB14

在这种状态下,HEAD 直接指向一个提交,而不是通过一个分支名间接指向。这被称为「分离头指针」(Detached HEAD)状态。在此状态下进行新的提交,这些提交不属于任何现有分支,除非你基于它创建一个新分支。

所以可以把 HEAD 理解为「当前检出的版本」或「下次提交的父提交将会是谁」的指示器。

当执行 git commit 时:

  1. Git 根据暂存区的内容创建一个新的提交对象。这个新的提交对象的父提交就是 HEAD 当前指向的那个提交。
  2. 如果 HEAD 指向一个分支(例如,.git/HEAD 内容是 ref: refs/heads/main):
    • Git 会更新该分支的指针文件(例如,.git/refs/heads/main),使其内容变为这个新创建的提交对象的 SHA-1 哈希值。
    • 这样,main 分支就向前移动到了新的提交上。
    • .git/HEAD 文件本身的内容通常不会改变,它仍然是 ref: refs/heads/main
  3. 如果 HEAD 处于分离状态(直接指向一个提交哈希):
    • 新的提交会被创建,其父提交是当前 HEAD 指向的提交。
    • 然后,.git/HEAD 文件会被更新,直接包含这个新提交的 SHA-1 哈希值。
    • 没有任何分支指针会自动移动。你通常需要为此创建一个新分支来「保存」这个新提交,否则当你切换到其他地方后,这个新提交可能会变得「不可达」并最终被垃圾回收。

分支的创建与切换

你可以用 git branch <branch> 命令来创建一个新的分支。

这个命令非常简单和快速,它只是在 .git/refs/heads/ 目录下创建一个名为 <branch> 的新文件。

这个新文件的内容会被设置为 HEAD 当前指向的那个提交的 SHA-1 哈希值。

如果你提供了起点,如 git branch <branch> <start>,则内容是 <start> 指向的提交的 SHA-1。

这个命令仅仅是创建了一个新的分支指针,它并不会自动将你的工作环境切换到这个新分支上。HEAD 仍然指向原来的位置。

如果你想切换到新创建的分支上,可以使用 git checkout <branch> 或者更现代的 git switch <branch> 命令。

如果想要创建并切换到新分支,可以使用 git checkout -b <branch>git switch -c <branch>

切换分支的命令有 git checkout <branch>git switch <branch>

这个命令首先会修改 .git/HEAD 文件的内容,使其变为 ref: refs/heads/<branch>,从而将 HEAD 指向你想要切换到的分支。

然后它会根据新 HEAD(即目标分支)所指向的提交的快照,来更新你的工作目录中的文件和索引(暂存区)的内容,使它们与目标分支的最新状态一致。

这就是为什么切换分支时,你工作区的文件会发生变化。

标签

标签与分支类似,也是指向提交的引用,但它们通常是固定不变的,用来标记项目历史中的重要节点(比如版本发布 v1.0)。

轻量标签(Lightweight Tags)和分支非常相似,就是一个简单的指针,指向一个提交对象。

通常存储在 .git/refs/tags/ 目录下,文件名就是标签名,文件内容是对应提交的 SHA-1 哈希值。

例如,git tag v1.0-lw 会创建一个名为 v1.0-lw 的文件,内容是当前 HEAD 指向的提交的 SHA-1。

附注标签(Annotated Tags)本身是一个独立的 Git 标签对象,这个标签对象有自己的 SHA-1 哈希值,并且包含了指向哪个提交、打标签者、打标签日期、标签信息以及可选的 GPG 签名等元数据。

当你创建一个附注标签时(例如 git tag -a v1.0 -m "Version 1.0 release"),Git 会创建一个标签对象,并将这个标签对象存入对象数据库。

然后在 .git/refs/tags/ 目录下创建一个名为 v1.0 的文件,这个文件的内容是那个新创建的标签对象的 SHA-1 哈希值,而不是直接指向提交对象的哈希值。

所以,轻量标签就是个「别名」,直接指向提交。附注标签则是一个更「正式」的、包含元数据的对象,它再指向提交。

合并与冲突解决

当你在一个功能分支(feature branch)上完成了开发,或者想把其他人的最新进展同步到你的分支时,你就需要将这些来自不同「平行宇宙」的改动整合到一起。Git 主要提供了两种方式:mergerebase

合并

merge 是一个相对直接和「尊重历史」的方式。它的核心思想是:将两个(或多个)分支的历史在某一点汇合,并创建一个新的「合并提交」(merge commit)来标记这个汇合点

在执行合并操作前,比如你要将 feature 分支合并到 main 分支(git switch main; git merge feature),Git 会首先在这两个分支的提交历史中找到一个最近的共同祖先提交(common ancestor/merge base)。这个共同祖先是两个分支开始分叉的地方。

Git 有多种合并策略,最常见的有两种。

第一种是快进合并(Fast-Forward Merge):这种情况非常简单。如果你要合并的 feature 分支的顶端正好是你当前所在 main 分支顶端的直接下游(也就是说,从 feature 分支分叉出去后,main 分支没有任何新的提交),那么 feature 分支包含 main 分支的所有历史,并且在其基础上还有新的提交。

这种情况下,Git 只需将 main 分支的指针向前移动,指向 feature 分支的顶端即可。不会创建新的合并提交。 历史记录保持为一条直线,看起来就像你直接在 main 分支上连续开发一样。

gitGraph
    commit id: "main 1"
    commit id: "main 2"
    branch feature
    commit id: "feature 1"
    commit id: "feature 2"
    checkout main
    merge feature

合并后 main 分支的历史看起来就像这样:

gitGraph
    commit id: "main 1"
    commit id: "main 2"
    commit id: "feature 1"
    commit id: "feature 2"

当你从 main 拉出功能分支,独立开发完成后,main 没有任何变化,此时合并回 main 就会是快进。

快进合并可以用 -no-ff 选项来禁止,这样即使是快进合并,Git 也会创建一个新的合并提交。

比较类似的还有 squash 合并git merge --squash),它会将 feature 分支的所有提交压缩成一个提交,然后合并到 main 分支,但不会创建合并提交。这样可以保持 main 分支的历史更简洁。

gitGraph
    commit id: "main 1"
    commit id: "main 2"
    branch feature
    commit id: "feature 1"
    commit id: "feature 2"
    checkout main
    merge feature id: "squash" tag: "squash 合并(feature 压缩成一个提交)"

第二种是三方合并(Three-Way Merge/Recursive Merge): 这是更常见的情况。当 feature 分支和 main 分支在共同祖先之后都各自有了新的提交,它们「分叉」了。此时无法进行快进合并。

这种情况下,Git 首先找到三个关键的提交快照:

  • 共同祖先(Merge Base - B)
  • 当前分支的顶端(e.g., main - M)
  • 要合并进来的分支的顶端(e.g., feature - F)

然后 Git 会计算两个「差异集」:

  • 从 B 到 M 的所有改动。
  • 从 B 到 F 的所有改动。

接下来 Git 尝试将这两个差异集合并应用到 B 的快照上。

如果这两个差异集修改了文件的不同部分,或者一个分支修改了另一个分支未触及的部分,Git 通常能自动成功合并。

成功后,Git 会创建一个新的合并提交。这个合并提交非常特殊,因为它有两个父提交:一个是 M(当前分支的原顶端),另一个是 F(被合并分支的顶端)。这个新的合并提交的快照(树对象)就包含了两个分支的整合内容。

gitGraph
    commit id: "A"
    commit id: "B(共同祖先)"
    branch feature
    checkout feature
    commit id: "F(feature 顶端)"
    checkout main
    commit id: "M(main 顶端)"
    merge feature id: "合并提交(M, F)"

这种合并会在历史图上清晰地显示出分支的汇合点,保留了分支并行开发然后集成的真实轨迹。

冲突

当两个分支(比如 main 和 feature)在共同祖先之后,对同一个文件的同一部分进行了不同的修改时,或者一个分支删除了另一个分支修改了的文件时,Git 就不知道该如何自动决定最终结果了——这就是合并冲突

在这种情况下,合并过程会暂停。Git 会在冲突的文件中插入特殊的冲突标记符(如 <<<<<<< HEAD, =======, >>>>>>> feature),标示出不同分支各自的修改内容。

.git/index 中会包含该冲突文件的多个版本信息(来自共同祖先、ours/HEADtheirs/feature)。

而你则需要手动打开这些冲突文件。仔细检查冲突标记符之间的内容,决定最终要保留哪些内容,或者如何将两边的修改结合起来。删除冲突标记符。然后使用 git add <resolved> 将解决冲突后的文件标记为已解决(这会更新暂存区,清除冲突状态)。

当所有冲突都解决并 add 之后,执行 git commit。Git 通常会自动为你生成一个合并提交信息,你也可以修改它。这个提交就是那个拥有两个父节点的合并提交。

变基

rebase 是另一种整合代码的方式,但它与 merge 的哲学不同。rebase 的核心思想是:将一个分支上的一系列提交,在另一个分支的顶端「重新播放」一遍,从而形成一条更线性的历史记录。 它实际上是在重写历史。

顾名思义,「变基」就是改变你的分支的「基础提交」(base commit)。

假设有这样的历史:

gitGraph
    commit id: "A"
    commit id: "B"
    branch feature
    commit id: "E"
    commit id: "F"
    commit id: "G"
    checkout main
    commit id: "C"
    commit id: "D"

当你在 feature 分支上执行 git rebase main 时,首先 Git 会找到 feature 和 main 的共同祖先(这里是 B)。

它会「暂存」起 feature 分支上从 B 之后的所有提交(即 E, F, G),通常是把这些提交的改动(diff/patch)保存起来。

然后,它会将 feature 分支的指针「倒回」到 B,再「移动」到 main 分支当前的顶端 D。

接着,它会尝试将之前保存的 E, F, G 的改动,依次地、一个一个地应用到 D 之上,形成新的提交 E', F', G'。

gitGraph
    commit id: "A"
    commit id: "B"
    commit id: "C"
    commit id: "D"
    branch feature
    commit id: "E'"
    commit id: "F'"
    commit id: "G'"

结果就是,feature 分支现在看起来就像是直接从 main 分支最新的 D 点拉出来进行开发的一样,历史记录变成了一条直线。

而原来的提交 E, F, G 实际上已经被「抛弃」了(除非有其他引用指向它们,否则最终会被垃圾回收),取而代之的是内容相同但 SHA-1 哈希值不同的新提交 E', F', G',因为它们的父提交和提交时间都变了。

在重新应用每个补丁(原提交的改动)时,也可能发生冲突,原因和 merge 冲突类似。

如果发生冲突,rebase 过程会暂停,并提示你解决冲突。你需要手动解决冲突文件中的内容,然后 git add <resolved>

之后,不是 git commit,而是执行 git rebase --continue 来让 Git 继续应用下一个补丁。

你也可以选择 git rebase --skip 来跳过当前的这个补丁(即放弃这个提交的改动),或者 git rebase --abort 来完全取消这次变基操作,恢复到变基之前的状态。

因为是逐个应用提交,所以你可能需要多次解决冲突(如果多个补丁都发生冲突的话)。

永远不要对已经推送到公共/共享仓库并可能被他人拉取的分支执行 rebase 操作!

因为 rebase 重写了历史(改变了提交的 SHA-1 值),如果你变基了一个已经共享的提交,那么其他协作者的本地仓库中就会包含「旧」的提交历史。当他们尝试拉取你的「新」历史时,Git 会认为这是两个完全不同的历史分支,会导致非常混乱的合并和重复提交。

实际上,rebase 最适合用于清理你本地私有分支的提交历史,在你将其推送到共享仓库或合并到主线之前,让历史看起来更整洁。或者,用它来将一个长期存在的特性分支与主线的最新进展同步(即把主线的最新提交添加到你的特性分支的起点之后,再把你的特性分支的提交接上去)。

可以使用交互式 rebase,命令是 git rebase -i <base>。这是一个非常强大的工具。它允许你在变基过程中,对即将被重新应用的提交进行更细致的操作,比如:

  • pick:保留该提交(默认)
  • reword:保留该提交,但修改提交信息
  • edit:保留该提交,但暂停让你修改提交内容(比如拆分提交)
  • squash:将该提交与前一个提交合并(合并提交信息)
  • fixup:类似 squash,但丢弃该提交的提交信息
  • drop:删除该提交
  • 重新排序提交

远程协作

在 Git 中,一个远程仓库或简称远程(Remote)是你本地仓库对另一个 Git 仓库的引用。这个「另一个仓库」通常托管在网络服务器上(比如 GitHub, GitLab, Bitbucket,或者是公司或组织内部的私有 Git 服务器)。

  • 命名与 URL:每个远程都有一个简短的名字(当你从一个项目克隆 git clone 时,Git 默认会为原始仓库创建一个名为 origin 的远程)和一个 URL(指向远程仓库的地址,如 https://github.com/user/repo.gitgit@github.com:user/repo.git)。
  • 配置存储:这些远程仓库的信息保存在你本地仓库的 .git/config 文件中。你会看到类似这样的配置段:
    1
    2
    3
    [remote "origin"]
    url = https://github.com/user/repo.git
    fetch = +refs/heads/*:refs/remotes/origin/*

这里的 fetch 配置行定义了一个 refspec,它指定了从远程的哪些引用(比如 refs/heads/* 代表远程所有分支)下载到本地的哪个命名空间下(比如 refs/remotes/origin/*,即我们稍后会讲到的远程跟踪分支)。

远程跟踪分支(Remote-Traking Branches)是你本地仓库中只读的指针,它们反映了你上次与远程仓库通信时,远程仓库上各个分支的状态。

  • 存储位置:它们存储在 .git/refs/remotes/ 目录下,并以 <remote_name>/<branch_name> 的形式命名,例如 origin/mainorigin/develop
  • 作用:它们是你本地的「书签」或「缓存」,记录了远程分支在特定时间点的位置。你不应该直接在这些分支上进行修改或提交。它们由 git fetch 自动更新。
  • 你可以通过 git branch -r 查看所有的远程跟踪分支。
$ git fetch <remote>

git fetch 命令是与远程仓库同步信息的第一步,也是最安全的一步。核心动作有:

  1. 连接到指定的远程仓库。
  2. 下载你本地没有的最新数据(对象和引用)。
  3. 更新你本地对应的远程跟踪分支(例如,origin/main 会被更新到指向远程 main 分支最新的那个提交)。
详细过程

详细过程(智能协议 - Smart Protocol):

  1. 连接:Git 客户端通过远程仓库的 URL 建立连接。
  2. 协商(Negotiation):这是 Git 网络协议「智能」的核心。
    • 你的 Git 客户端会告诉远程服务器它当前拥有的提交的 SHA-1 值(特别是那些远程也可能关心的引用,比如你本地 origin/main 指向的提交)。这可以理解为发送一个「我拥有这些」(haves)的列表。
    • 远程服务器根据你拥有的提交,计算出你需要哪些新的提交、树、blob 等对象才能达到它那边分支的最新状态。
    • 服务器会将这些你缺失的对象打包成一个 Packfile。为了效率,这个 Packfile 通常会使用增量压缩,并且会尝试基于你已有的对象作为基准来压缩,这种优化后的 Packfile 也被称为 thin pack
  3. 数据传输:服务器将这个 Packfile 发送给你的客户端。
  4. 本地更新
    • 你的 Git 客户端接收并解开 Packfile,将新的对象(commits, trees, blobs, tags)存储到你本地的 .git/objects 目录中。
    • 然后,它会更新 .git/refs/remotes/<remote_name>/<branch_name> 下的远程跟踪分支,使其指向从远程获取到的最新提交。例如,如果之前你本地的 origin/main 指向提交 A,而远程服务器上的 main 分支现在指向提交 C(历史是 A->B->C),那么 git fetch origin 之后,你本地的 origin/main 就会被更新为指向提交 C

git fetch 只会下载数据并更新你的远程跟踪分支。它绝对不会修改你当前的工作目录,也不会修改你本地的任何实际分支(比如你本地的 main 分支)。因此,git fetch 是一个非常安全的操作,你可以随时执行它来查看远程有什么新的改动,而不用担心会打乱你当前的工作。

$ git pull <remote> <branch>

很多人会把 pull 作为获取远程更新的常用命令。实际上,git pull 是一个组合命令,它大致等同于:

  1. git fetch <remote>:执行上面描述的 fetch 操作。
  2. git merge <remote>/<branch>:将刚刚更新的远程跟踪分支合并到你当前所在的本地分支。

如果你当前在本地 main 分支上,并且你的 main 分支被设置为跟踪 origin/main(这是克隆时的默认行为),那么当你执行 git pull 时,Git 实际上会执行 git fetch origin,然后执行 git merge origin/main

git pull --rebase 是一个常用的变体。它会执行 git fetch,然后不是用 merge,而是用 git rebase origin/main。这意味着它会把你本地 main 分支上领先于 origin/main 的提交,「变基」到更新后的 origin/main 之上,从而形成一条更线性的历史。

因为 pull 包含了 mergerebase 操作,所以如果你的本地改动与远程获取下来的改动有冲突,你可能需要解决合并冲突。

$ git push <remote> <local_branch>[:<remote_branch>]

push 命令用于将你本地的提交和相关的 Git 对象上传到远程仓库,并尝试更新远程仓库上的分支指针。核心动作有:

  1. 连接到指定的远程仓库。
  2. 上传你本地有而远程没有的、且是你指定要推送的分支历史上的提交和对象。
  3. 尝试更新远程仓库上指定分支的引用(指针)。
详细过程

详细过程:

  1. 协商:你的客户端告诉服务器它想把哪个远程分支(例如 refs/heads/main)更新到哪个具体的提交 SHA-1(例如你本地 main 分支的顶端)。客户端也会发送它本地分支历史中相关的提交信息,以便服务器判断它缺少哪些对象。
  2. 数据传输:如果服务器发现它缺少你的某些提交或相关对象,你的客户端会把这些缺失的对象打包成 Packfile 发送给服务器。
  3. 服务器端检查:在接受你的推送并更新引用之前,服务器通常会执行一些检查:
    • 权限(Authentication/Authorization):你是否有权限推送到这个仓库和这个分支?
    • 快进推送(Fast-forward only):这是非常重要的一点。默认情况下,很多服务器(尤其是对于受保护的主分支如 mainmaster)只接受「快进」推送。这意味着你本地分支的历史必须严格包含远程分支当前的所有历史。换句话说,从你上次 git fetch 之后,远程分支不能有新的、你本地没有的提交。
      • 如果不是快进(比如其他人已经向远程分支推送了你没有的更新),推送会被拒绝。你需要先 git pull(或 git fetchmerge/rebase)将远程的最新改动集成到你本地,解决可能存在的冲突,然后再尝试 push
      • 强制推送git push --forcegit push -f):这个选项会绕过快进检查,强行用你本地的分支状态覆盖远程分支。
        • 这是一个非常危险的操作,尤其是在共享分支上,因为它会重写远程历史,可能导致其他协作者丢失工作或历史混乱。 应极力避免,除非你完全清楚你在做什么并且与团队达成了共识。
        • 一个相对安全一点的替代是 git push --force-with-lease,它会在强制推送前检查远程分支是否还是你预期的那个状态,如果不是(即在你上次 git fetch 后又有人推送了),则推送失败。
  4. 远程更新:如果所有检查都通过,服务器会更新它指定分支的指针,指向你推送的那个提交,并将新的对象存入它的对象库。

如果你执行 git push origin my-new-feature,而远程仓库 origin 上并没有一个名为 my-new-feature 的分支,Git 通常会在远程创建一个同名的新分支,并将其指向你本地 my-new-feature 分支的当前提交。

-u 参数非常有用。例如 git push -u origin maingit push --set-upstream origin main 命令,它在推送的同时,会建立你本地分支(如 main)与远程分支(如 origin/main)之间的「跟踪关系」。这意味着以后:

  • 在本地 main 分支上执行 git pull(不带参数)时,Git 会知道要从 origin 拉取 origin/main 的更新。
  • 在本地 main 分支上执行 git push(不带参数)时,Git 会知道要推送到 originmain 分支。

这个跟踪信息会保存在 .git/config 文件中。

拓展内容

引用日志

我曾遇到过这样的情况:不小心执行了一个 git reset --hard 回退了太多,或者删错了一个本地分支,结果发现一些重要的提交「丢失」了。而 git reflog 就是在这种时候拯救我于水火之中的英雄。

Reflog 是 Reference Log(引用日志)的缩写。Git 会在你的本地仓库中维护一个特殊的日志,记录 HEAD 和你的各个本地分支的顶端在过去一段时间内是如何移动的。

简单来说,每当 HEAD 指向的位置发生变化(比如你切换分支、提交、重置分支、变基等),或者任何分支的指针被更新时,Git 就会在相应的 reflog 中追加一条记录。

每一条 reflog 条目通常会包含以下信息:

  • 该引用(如 HEAD 或某个分支)之前指向的 SHA-1 哈希值。
  • 该引用之后指向的 SHA-1 哈希值。
  • 执行该操作的用户信息。
  • 操作发生的时间戳。
  • 一个简短的原因描述,说明是什么操作导致了这次引用的更新
    • commit: initial commit
    • checkout: moving from main to feature
    • rebase finished: returning to refs/heads/my-feature-branch
    • reset: moving to HEAD~3

这些日志文件存储在你的 .git/logs/ 目录下。.git/logs/HEAD 文件记录了 HEAD 的移动历史。.git/logs/refs/heads/ 目录下则有对应每个本地分支的日志文件,例如 .git/logs/refs/heads/main 记录了 main 分支顶端的移动历史。

直接在你的仓库中运行 git reflog(或者 git reflog show HEAD,这是默认行为),它会显示 HEAD 的引用日志。

你也可以查看特定分支的 reflog,例如 git reflog show main

输出的每一行通常以类似 HEAD@{<number>}<commit-ish> HEAD@{<date>} 的形式开头。例如:

1
2
3
a1b2c3d HEAD@{0}: commit: Add new feature X
e4f5g6h HEAD@{1}: checkout: moving from feature-Y to main
i7j8k9l HEAD@{2}: commit: Fix bug in feature-Y

这里的 HEAD@{0} 指的是 HEAD 当前的状态,HEAD@{1} 指的是 HEAD 上一次的状态,以此类推。

这些 HEAD@{<number>}(或者 main@{2.days.ago})的语法被称为 reflog selectors,你可以像使用提交 SHA-1 或分支名一样在很多 Git 命令中使用它们。

假设你不小心做了一个错误的 git reset --hard HEAD~5,丢失了最近的 5 个提交。这时可以运行 git reflog。你会看到在 reset 操作之前,HEAD 指向的那些提交的 SHA-1 值。

找到你想要恢复到的那个状态的 SHA-1 值(或者使用 HEAD@{<number>} 选择器)。

然后你有几种选择:

  • 创建一个新分支指向它:这是最安全的方式。git branch <recovery-branch> <sha1_or_selector>。然后你可以检查这个新分支,确保是你想要的,再决定如何处理。
  • 重置当前分支到它:如果你确定,可以直接 git reset --hard <sha1_or_selector>。注意:--hard 会丢弃工作目录和暂存区的改动,请谨慎操作。
  • 切换过去看看git checkout <sha1_or_selector>。这会让你进入「分离头指针」状态,你可以检查代码,如果确认无误,再基于此创建新分支。

即使你删除了一个本地分支(比如 git branch -D my-feature),只要这个分支不久前还存在,它的顶端提交通常也能在 HEAD 的 reflog 中找到(因为你删除前可能切换过它),或者有时在它自己的 reflog 文件中(如果文件还未被清理)。

Reflog 的特点是本地有时限

  • 纯粹本地:Reflog 是你本地仓库的「私有记录」,它不会在你执行 git push 时被推送到远程仓库,也不会在 git fetchgit clone 时从远程仓库拉取下来。
  • 有过期时间:Reflog 中的条目并不会永久保存。Git 会定期清理旧的 reflog 条目(通过 git gc 垃圾回收过程)。默认情况下,不可达对象的 reflog 条目可能保存 30 天,可达对象的 reflog 条目可能保存 90 天(这些时间可以通过 gc.reflogExpire 和 gc.reflogExpireUnreachable 配置)。所以,它是紧急情况下的救生圈,但不应依赖它进行长期历史追踪。

git reflog 体现了 Git 的一个核心哲学:Git 极少真正「丢失」数据,尤其是在数据被提交之后。 即使一个提交看起来从分支历史中消失了(比如因为 resetrebase),只要它还在 reflog 的保护期内并且未被 gc 清理,你通常都有机会找回它。

悬空对象

悬空对象(Dangling Objects),包括悬空的 commit, tree 和 blob,是指那些在 Git 的对象数据库中存在,但已经没有任何引用(如分支、标签、HEAD、stash 记录,甚至是尚未过期的 reflog 条目)能够直接或间接访问到它们的 Git 对象。

它们是怎么产生的呢?

  • 执行 git reset --hard <older-commit> 后,被「跳过」的那些提交如果不再被其他分支引用,就可能变成悬空的。
  • 执行 git rebase 操作时,原始分支上的旧提交在被「复制」并应用到新的基底后,这些原始提交如果不再被引用,也会变成悬空的。
  • 执行 git commit --amend 修改最后一次提交时,原来的那个被「修正」的提交就会变成悬空的。
  • 删除一个分支(git branch -D <branch-name>)时,如果这个分支上有一些独有的提交,这些提交就会变成悬空的。
  • 一些未完成或被中断的复杂 Git 操作也可能留下一些临时的、最终未被引用的对象。

当一个提交刚刚变成「悬空」时,它仍然存在于你本地的 .git/objects 目录中。更重要的是,正如我们之前讨论 git reflog 时提到的,如果这个提交或者包含这个提交的分支的顶端最近被 HEAD 或某个分支引用过,那么在 reflog 过期之前,你仍然可以通过 reflog 找到并恢复它。从这个角度看,它在 reflog 保护期内还不算完全「丢失」。

可以使用 git fsck --full --unreachable(File System Check)命令来查找并列出那些当前无法从任何引用(不包括 reflog)访问到的对象。输出中标记为 "dangling commit", "dangling tree", "dangling blob" 的就是悬空对象。

gic gc 是真正清理悬空对象的机制。它会执行以下操作:

  • 将松散对象打包进 Packfile 以节省空间和提高效率。
  • 识别并删除真正不可达的对象:它会查找那些既不能从当前所有的分支、标签等引用访问到,也不能从任何尚未过期的 reflog 条目访问到的对象。
  • 宽限期(Grace Period):为了防止意外删除你可能还想恢复的数据,Git 通常会有一个宽限期(比如由 gc.pruneExpire 配置,默认是 2 周左右)。只有过了这个宽限期的不可达对象才会被真正删除。

所以,本地的悬空对象最终会被 git gc 清理掉,以回收磁盘空间。

二分查找

假如说你发现了一个 bug,你知道在一个月前的某个版本(v1.0,我们称之为 "good" commit)中这个 bug 还不存在,但在当前最新的版本(HEAD,我们称之为 "bad" commit)中它却出现了。这中间可能有几百上千次提交,手动一个个检查太耗时了。这时 git bisect 就派上用场了。

使用流程:

  1. 启动与标记
    • git bisect start:告诉 Git 你要开始一个二分查找会话。
    • git bisect bad [commit]:标记一个已知包含 bug 的提交。如果当前 HEAD 就是坏的,直接 git bisect bad 即可。
    • git bisect good <commit-ish>:标记一个已知没有 bug 的提交(比如一个旧的标签名或提交 SHA-1)。
  2. Git 的工作:二分与检出
    一旦你标记了 "good" 和 "bad" 的范围,Git 就会:
    • 计算出这两个提交之间大约中间位置的那个提交。
    • 自动 checkout(检出)这个中间的提交到你的工作目录(你会进入一个「分离头指针」状态)。
  3. 你的工作:测试与反馈
    • 现在,你需要在这个检出的版本上测试,看看 bug 是否存在。
    • 然后告诉 Git 测试结果:
      • git bisect good:如果这个中间版本没有 bug。
      • git bisect bad:如果这个中间版本有 bug。
      • git bisect skip:如果因为某些原因(比如这个版本编译不通过,无法测试与 bug 相关的功能),你无法判断这个版本的好坏,可以用这个命令跳过当前提交,Git 会尝试选择附近的其他提交。
  4. 循环与定位
    • Git 根据你的反馈,将搜索范围缩小一半,并再次检出新的中间提交。
    • 你继续测试并反馈,如此循环。
    • 每一步,Git 都会告诉你还剩下大约多少次提交需要检查,以及预计还需要多少步就能找到。
    • 最终,当无法再二分时,Git 就会准确地告诉你:The first bad commit is: <commit_sha1> … 并显示该提交的详细信息。
  5. 结束会话
    • git bisect reset:结束 bisect 会话,并将你的 HEAD 和工作目录恢复到开始 bisect 之前的状态(通常是你启动 bisect 时所在的分支)。

git bisect 还可以使用 git bisect run 进行自动化操作。具体命令是 git bisect run <script> [args...])。

如果你能编写一个脚本(比如一个单元测试、一个集成测试、或者任何能自动检测 bug 是否存在的命令),让它在检测到 bug 时返回非 0 状态码(除了 125),而在 bug 不存在时返回 0,那么你就可以让 Git 自动完成整个二分查找和测试的过程。

$ git bisect run my_test_script.sh

Git 会自动执行:检出中间版本 \to 运行你的脚本 \to 根据脚本退出码判断 good/bad \to 缩小范围 \to 循环……直到找到第一个坏提交。这对于定位那些难以复现或需要复杂设置才能触发的 bug 非常有用。

虽然二分查找最直接地应用于线性序列,但 Git 的历史往往包含合并提交。git bisect 能够处理这种情况,它通常会尝试沿着父提交链进行查找,但有时可能会需要你辅助判断(比如跳过合并提交,或者明确指出要追踪哪个父分支)。

Git Hooks

Git Hooks 是一些位于你的仓库 .git/hooks/ 目录下的可执行脚本。当特定的 Git 事件发生时,Git 会查找并执行相应名称的钩子脚本。

Git Hooks 分为两大类:

  • 客户端钩子(Client-Side Hooks):在你的本地仓库中触发,影响你本地的 Git 操作。例如:
    • pre-commit:在 git commit 命令获取提交信息、创建提交对象之前运行。你可以用它来检查代码风格、运行单元测试、确保提交信息符合规范等。如果此脚本以非零状态退出,Git 会中止提交。
    • prepare-commit-msg:在 commit 编辑器启动前,默认提交信息生成后运行。可以用来动态修改或补充提交信息。
    • commit-msg:在你完成提交信息后,提交对象被创建之前运行。可以用来校验提交信息是否符合特定格式。
    • post-commit:在整个 commit 操作完成之后运行。可以用来发送通知、更新文档等。
    • pre-rebase:在 git rebase 开始之前运行。
    • post-checkout:在 git checkout 成功完成后运行(比如切换分支或检出文件后)。可以用来根据分支环境设置一些东西。
    • post-merge:在 git merge 成功完成后运行。
    • pre-push:在 git push 将数据传输到远程之前运行。可以用来做一些推送前的检查,比如确保你不会推送到受保护的分支,或者运行一些集成测试。如果脚本以非零状态退出,push 会被中止。
  • 服务器端钩子(Server-Side Hooks):运行在远程服务器上,当服务器接收到某些操作时触发。如果你自己搭建 Git 服务器,这些钩子非常有用。对于像 GitHub/GitLab 这样的托管服务,你通常不能直接管理这些钩子,但它们提供了类似功能的 Webhooks 或集成。例如:
    • pre-receive:当服务器接收到一个 push 操作,在任何引用被更新之前运行。这是实施服务器端策略(如检查提交是否符合规范、用户是否有权限推送等)最常用的钩子。如果脚本以非零状态退出,整个推送都会被拒绝。
    • update:类似于 pre-receive,但它是为每一个被推送的分支单独运行的。
    • post-receive:在整个 push 操作成功完成,所有引用都已更新之后运行。常用于发送邮件通知、触发持续集成(CI)服务器、更新其他相关系统等。

那该如何使用 Git Hooks 呢?

Git 在你初始化一个新仓库时(git initgit clone),会在 .git/hooks/ 目录下放置一些示例钩子脚本,它们通常以 .sample 为后缀(如 pre-commit.sample)。

要启用一个钩子,你只需要将 .sample 后缀去掉(例如,将 pre-commit.sample 重命名为 pre-commit),并确保该文件有可执行权限(chmod +x .git/hooks/pre-commit)。

  • 你可以用任何你喜欢的脚本语言来编写钩子(如 Shell 脚本、Python、Ruby、Perl 等),只要脚本的 shebang(如 #!/bin/sh#!/usr/bin/env python)正确,并且文件可执行。

Git 会向某些钩子脚本传递参数(比如 commit-msg 钩子会接收包含提交信息临时文件的路径作为参数),并可能通过标准输入传递信息(比如 pre-push 钩子)。每个钩子的具体输入和预期行为需要查阅 Git 文档了解。

客户端钩子是本地的,它们不会随着 git clone 被复制到其他人的仓库,也不会被 git push 推送到远程。每个开发者都需要在自己的本地仓库中设置钩子。如果团队需要共享钩子,通常的做法是将钩子脚本放在项目源码的一个特定目录中(比如 scripts/hooks/),然后提供一个安装脚本或在文档中说明如何将它们链接或复制到 .git/hooks/ 目录下(Git 2.9+ 版本可以通过 core.hooksPath 配置来指定一个全局的钩子目录,这样更方便团队共享)。

客户端钩子可以被用户绕过(例如,使用 git commit --no-verify 可以跳过 pre-commitcommit-msg 钩子)。因此,关键的策略检查(如代码质量、安全扫描等)不应仅仅依赖客户端钩子,而应在服务器端(通过 pre-receive 钩子或 CI/CD 流水线)强制执行。

Git LFS

你可能遇到过这样的情况:项目中需要版本控制一些大的二进制文件,比如高清图片、视频、音频素材、编译好的库、数据集等。直接将这些大文件提交到 Git 仓库,往往会带来一些问题:

  • 仓库膨胀:Git 会保存每个大文件的每个版本的完整副本,导致 .git 目录急剧增大。
  • 性能下降:克隆(clone)、拉取(fetch/pull)、推送(push)等操作会因为需要传输大量数据而变得非常缓慢。
  • Diff 和 Merge 困难:Git 的 diffmerge 工具主要针对文本文件设计,对二进制大文件效果不佳。

Git LFS 就是为了解决这些问题而生的。

Git LFS (Large File Storage) 是一个开源的 Git 扩展,由 GitHub 等公司和社区共同开发。它允许你将大文件存储在 Git 仓库之外的专用服务器上,而在你的 Git 仓库中只保留这些大文件的轻量级「指针」。

Git LFS 的工作原理:指针替代大法

  1. 跟踪大文件:首先,你需要告诉 Git LFS 哪些类型的文件或哪些具体文件应该由它来管理。这通过 git lfs track 命令实现,它会修改或创建一个名为 .gitattributes 的文件。

    • 例如,要跟踪所有 PSD 文件:
      git lfs track "*.psd"
      .gitattributes 文件中会增加类似这样一行:
      *.psd filter=lfs diff=lfs merge=lfs -text
      这个配置告诉 Git,对于匹配 *.psd 模式的文件,在进行暂存、检出等操作时,要使用名为 "lfs" 的过滤器[8]
  2. 暂存与清理过滤器(Clean Filter):当你使用 git add 添加一个被 LFS 跟踪的大文件时:

    1. 实际文件上传(或缓存):Git LFS 客户端会接管这个文件。它会将实际的大文件内容上传到你配置的 LFS 远程存储服务器(或者先存入本地的 LFS 缓存,通常位于 .git/lfs/objects/)。
    2. 生成指针文件:然后,LFS 会创建一个小型的文本指针文件(pointer file)。这个指针文件通常只有几百字节,里面包含了诸如 LFS 版本信息、实际大文件的 SHA-256 哈希值以及文件大小等元数据。看起来可能像这样:
      1
      2
      3
      version https://git-lfs.github.com/spec/v1
      oid sha256:4d7a214614ab39f943233c9FE30F823701226771062874C811342F716347A21B
      size 12345678
    3. 暂存指针:最终,Git 将这个小巧的指针文件添加到暂存区,并提交到你的 Git 仓库中。实际的大文件内容并没有进入 Git 的对象数据库。

    这个过程是通过 Git 的 clean filter(清理过滤器)实现的。当 Git 准备将文件内容存入其对象数据库之前,clean 过滤器会运行,将大文件替换为其 LFS 指针。

  1. 检出与涂抹过滤器(Smudge Filter):当你 git checkout 一个包含 LFS 指针文件的提交(比如切换分支,或者克隆仓库后首次检出)时:

    1. Git 先从仓库中检出那个文本指针文件。
    2. 然后,Git 的 smudge filter(涂抹过滤器)被触发。
    3. LFS 客户端读取指针文件中的信息(主要是 SHA-256 哈希值和大小)。
    4. 它会检查本地 LFS 缓存(.git/lfs/objects/)中是否已有这个大文件。如果没有,它会从配置的 LFS 远程存储服务器下载实际的大文件内容。
    5. 下载完成后(或从缓存中获取后),LFS 客户端会将工作目录中的指针文件替换回实际的大文件内容。
      所以,你在工作目录中看到的仍然是实际的大文件,可以正常使用。
  2. 推送与拉取:

    • git push:当你推送提交时,Git 会先推送包含 LFS 指针的常规 Git 提交。然后,LFS 客户端会单独将被跟踪的大文件(如果它们尚未在 LFS 远程服务器上)上传到 LFS 远程存储。
    • git pullgit checkout(在新拉取的分支上):在获取了包含 LFS 指针的 Git 提交后,LFS 客户端会自动(或按需)下载相应的大文件到你的工作目录。

Git LFS 的好处:

  • 仓库轻量化:你的 Git 仓库本身(.git 目录)保持小巧,因为它只存储文本指针,而不是庞大的二进制文件历史。克隆和分支切换速度快。
  • 大文件版本化:尽管大文件内容本身不在 Git 仓库中,但它们的「版本」(通过指针文件)是与你的代码提交严格对应的,你可以回溯到任何历史版本的大文件。
  • 熟悉的 Git 工作流:你仍然使用标准的 git add, git commit, git checkout, git push, git pull 等命令,LFS 在后台通过过滤器和钩子透明地工作。

常用 LFS 命令:

  • git lfs install:在本地仓库或全局范围安装 LFS 所需的钩子和过滤器配置。通常每个使用 LFS 的仓库都需要运行一次(或者全局安装一次)。
  • git lfs track "<pattern>":开始用 LFS 跟踪指定模式的文件。记得提交 .gitattributes 文件的改动。
  • git lfs untrack "<pattern>":停止用 LFS 跟踪指定模式的文件。注意,这不会自动将已跟踪文件从 LFS 存储中移除或转换回普通 Git 对象。
  • git lfs ls-files:列出当前被 LFS 跟踪的文件及其对应的 LFS 指针信息。
  • git lfs fetch:下载最近提交中引用的大文件到本地 LFS 缓存,但不一定会检出到工作目录。
  • git lfs pull:等同于 git fetch 后跟 git lfs checkout,获取 Git 提交并下载所需的大文件到工作目录。
  • git lfs checkout:确保当前检出版本所需的大文件都已从 LFS 缓存或远程下载到工作目录。
  • git lfs prune:清理本地 LFS 缓存中那些不再被任何本地提交的 LFS 指针引用的旧大文件,以释放本地磁盘空间。

注意事项:

  • LFS 服务器:你需要一个支持 LFS 的远程存储。大多数主流 Git 托管服务(如 GitHub, GitLab, Bitbucket)都提供 LFS 支持,但通常会有存储空间和月流量的限制(超出部分可能需要付费)。也可以自己搭建 LFS 服务器。
  • 客户端安装:所有参与项目的协作者都需要在他们的机器上安装 Git LFS 客户端。
  • 二进制 Diff/Merge:LFS 并没有解决二进制文件内容本身的 diffmerge 难题。你仍然不能像文本文件那样轻松地合并两个版本的 PSD 文件。Git 仓库中 diff 的是指针文件的变化。
  • 网络依赖:在检出需要下载大文件的版本时,会依赖网络连接和 LFS 服务器的可用性。

工作树

假如你正在一个特性分支上开发一个复杂功能,突然线上版本出现了一个紧急 bug,需要你立即切换到 main 分支修复。传统的做法可能是:

  1. git stash 你在特性分支上的改动(如果你不想或不能马上提交)。
  2. git switch main 切换到主分支。
  3. 修复 bug,提交,推送。
  4. git switch feature-branch 切换回特性分支。
  5. git stash pop 恢复之前的工作。

这个过程虽然可行,但有些繁琐,特别是如果项目很大,切换分支涉及到大量文件的检出和 IDE 的重新索引,可能会很慢。而且,如果想同时开着两个分支的代码进行对比或参考,就更不方便了。

git worktree 就是为了解决这类问题而生的。它允许从同一个本地 Git 仓库中,检出多个不同的工作目录,每个工作目录可以关联到不同的分支

核心理念:共享的 .git 目录,独立的工作区

  • 当你使用 git worktree 时,所有创建的额外工作目录都会共享同一个 .git 目录(更准确地说,它们会共享 .git/objects 对象数据库、.git/refs 引用等核心部分)。
  • 但是,每个工作树都有自己独立的 HEAD、索引(暂存区)和工作目录文件。

使用方法:

  • git worktree add <path> [<branch>]:这是创建新工作树的主要命令。
    • <path>:你想创建新工作目录的路径(例如 ../hotfix-branch)。这个路径不能在你当前的主工作树内部。

    • [<branch>]:可选参数,指定新工作树要检出的分支。

      • 如果省略,Git 会创建一个与新工作树目录同名的新分支(基于当前 HEAD)。
      • 如果指定一个已存在但未被任何其他工作树检出的分支,新工作树就会检出这个分支。
      • 你也可以指定 -b <new-branch-name> 来基于当前 HEAD 或另一个指定点创建并检出一个新分支到新工作树。
    • 例如,假设你当前在 my-project 目录(主工作树,可能在 feature-A 分支),你想创建一个用于修复 main 分支 bug 的工作树:

      git worktree add ../my-hotfix main

      这会在 my-project 的同级目录下创建一个 my-hotfix 目录,这个目录的工作区内容将是 main 分支的快照。

  • 在新工作树中工作:你可以 cd ../my-hotfix,然后就像在普通的 Git 仓库中一样工作:修改文件、git addgit commitgit push 等。你在这个工作树中的操作会影响 main 分支(因为我们检出了 main),但不会影响你原来的 my-project 工作树(它仍然在 feature-A 分支上)。
  • git worktree list:显示当前仓库关联的所有工作树及其状态(分支、HEAD哈希、路径)。
  • git worktree remove <path>:当你完成了在某个额外工作树中的任务后,可以用这个命令移除它。
    • 它会清理与该工作树相关的管理文件。
    • 注意:如果工作树中有未提交的改动或未推送的提交,Git 可能会阻止你直接移除,除非你使用 -f--force 选项。对于分支,如果该分支没有被合并到其他地方,并且是这个工作树独有的,移除工作树后,如果想保留分支,需要确保它不是因为工作树的移除而被视为可清理的。通常,git worktree remove 只是清理工作树的链接和元数据,分支本身(如果不是临时为工作树创建的)还会保留,但最好确认一下。如果分支是专门为这个工作树创建的,并且不再需要,可以稍后用 git branch -d-D 删除。
  • git worktree prune:清理 .git/worktrees 目录中那些指向不再存在的工作树的过时管理文件。

git worktree 的优势:

  • 并行工作:无需 stash 和频繁切换分支,可以直接在不同的工作目录中同时进行不同分支的开发、测试或 bug 修复。
  • 节省磁盘空间:所有工作树共享同一个对象数据库(.git/objects),所以你不需要为每个分支都克隆一个完整的仓库副本,大大节省了磁盘空间,尤其是对于历史庞大的仓库。
  • 节省时间:切换任务时,只需 cd 到另一个工作树目录,无需等待 Git 检出大量文件或 IDE 重新索引。
  • 方便长耗时操作:比如你可以在一个工作树中运行耗时的编译或测试任务,同时在另一个工作树中继续编码。

注意事项:

  • 分支唯一性:一个特定的分支在同一时间只能被一个工作树检出。你不能在两个工作树中同时检出 main 分支。如果你尝试 git worktree add ../another-main mainmain 已经在一个工作树中了,Git 会报错。
  • 子模块(Submodules):与子模块一起使用时,每个工作树都会有自己独立的子模块检出状态。
  • 存储位置:新工作树的管理文件存储在主仓库的 .git/worktrees/ 目录下。

  1. 更准确地说是暂存区中的内容。 ↩︎

  2. git commit --amend 命令是一种修改最近提交的便捷方法。它将暂存的变更与先前的提交合并,而不是创建一个全新的提交。但是这本质上并不是简单地「修改」前一个提交,而是生成一个新的提交并覆盖掉了前面的提交。因此如果已经推送到了远程仓库,请慎重考虑。 ↩︎

  3. 这是 Git 这三个字母的 SHA-1 哈希值。 ↩︎

  4. SHA-1 已经不再安全,因此 Git 在逐步使用 SHA-256 进行替代,不过目前默认依旧是 SHA-1。具体可以参考 Migrate Git from SHA-1 to a stronger hash function. ↩︎

  5. .git/objects/ 目录下那些路径有两个字符的子目录加上 38 个字符的文件。这个规则是一个特殊的命名约定,即使用 SHA-1 哈希值的前两位作为子目录名,后面是剩下的哈希值。这样可以将大目录分散到多个子目录中,避免单个目录过大导致的性能问题。 ↩︎

  6. 我们常说的「暂存区」(Staging Area),实际上就是 Git 的索引(Index)。 ↩︎

  7. 这个的原文是什么呢? ↩︎

  8. 过滤器相关的知识我也问了,不过产出的内容比较干巴巴(其实后半段都不太行),就懒得复制了。 ↩︎