全文共 10,226 字 预计阅读 30 分钟
技术文档

git 原理解读

learn git with me

Git

Git 中主要有四个对象:blob、tree、commit、tag

所有这些对象,git 都会把它们拼成字符串:

<类型> <内容字节数>\0<内容>

并对这个字节串进行 SHA-1 的哈希,用于目录的创建

$ printf 'blob 13\0test content\n' | shasum
d670460b4b4aece5915caf5c68d12f560a9fe3e4  -

存到磁盘时,git 会先把这个字节串用 zlib 进行 compress 再进行存储,压缩的结果就是 .git/objects/d6/70460b... 文件的真正内容

这四种对象中,blob 存储文件内容;tree 代表目录,它是一个排好序的表,每行把一个名字映射到对象

<权限模式> <名字>\0<该对象的20字节二进制哈希>

权限模式 (mode) 是 ASCII 明文的八进制,取值很有限(见下表);

名字 是文件名或子目录名; \0 NUL 分隔;

哈希20 字节的原始二进制(不是 40 位十六进制!这是为了省空间)。

mode 的合法取值(这是全部,Git 没有真的存 Unix 那套完整权限):

mode 含义
100644 普通文件
100755 可执行文件
120000 符号链接(内容是链接目标路径)
40000 子目录(指向一个 tree;注意没有前导 0)
160000 gitlink(子模块 submodule 的提交指针)

Commit 就是一次提交,他的正文是:

tree <根tree的40位十六进制哈希>
parent <父commit哈希>          ← 0 个(初始提交)、1 个(普通提交)、或多个(merge)
author <名字> <邮箱> <时间戳> <时区>
committer <名字> <邮箱> <时间戳> <时区>
<空行>
<提交信息>

$ git cat-file -p HEAD
tree 2f7 effe...
parent 8a9f...            *# 如果是第一个提交,这行不存在*
author Lab <lab@example.com> 1721000000 +0800
committer Lab <lab@example.com> 1721000000 +0800

first

其中:1. commit 只指向 tree,不指向 blob,也不存任何 diff。 commit 说的是"这一刻,整个项目的完整快照就是这棵 tree"。 git log 里的 diff, 是 Git 临时算出来的(拿这个 commit 的 tree 和父 commit 的 tree 现场对比),不是存出来的。这就是"Git 存快照不存差异"的字节级证据。

2. 父提交用哈希引用,于是所有 commit 串成一张 DAG。顺着 parent 一路往上就是历史。

3. commit 的哈希覆盖了 tree 哈希 + 父哈希 + 作者 + 时间 + 信息。所以: 改一个字节的历史文件 → blob 变 → tree 变 → commit 变 → 所有后代 commit 全变。历史不可篡改由此而来。 也因此,git commit --amend、rebase 会生成全新的 commit 哈希,这叫"改写历史"——旧 commit 其实没被改,是新造了一个,分支指针挪过去而已。

4. author vs committer 是两个人/两个时间。原作者是 author,实际落这次 commit 的是 committer。rebase、cherry-pick、别人帮你打的 patch,会让这俩不一样。

Tag 表示一个被打标签的对象

object <被指向对象的哈希>       ← 通常指向一个 commit
type <被指向对象的类型>          ← 通常是 commit
tag <标签名>
tagger <名字> <邮箱> <时间戳> <时区>
<空行>
<标签说明>
(可选:下面跟一段 GPG 签名)

了解了 4 个基本对象,就可以看看 .git 目录下存放了哪些东西:

路径 是什么
objects/ 对象库。松散对象 xx/yyyy…,打包后还有 pack/ 子目录
refs/ 引用目录:refs/heads/*(分支)、refs/tags/*(标签)、refs/remotes/*(远程跟踪分支)
HEAD "我现在在哪"。通常是指向某个分支的符号引用
index 暂存区。addcommit 之间那层缓存
config 本仓库配置(user.name、remote、branch 追踪关系…)
logs/ reflog:每个引用移动的历史流水账
packed-refs 把大量引用打包进一个文件(refs/ 里就看不到了)
hooks/ 钩子脚本(pre-commit 等)。默认全是 .sample 不生效
info/ info/exclude(仓库级忽略)、info/refs(供 dumb 协议用)
COMMIT_EDITMSG 上次 commit 的信息草稿。纯辅助文件
description 仅 GitWeb 用,本地无意义

其中,refs/ 就是引用层,其本质就是一个装着哈希的文件

$ cat .git/refs/heads/main
f2e9c1a7b3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8

一个分支 = 文件 refs/heads/<分支名>,内容是 40 位十六进制的 commit 哈希 + 一个换行。

git commit 推进分支,本质就是:新造一个 commit 对象,然后把 refs/heads/main 文件里的哈希改写成新 commit 的哈希。对象库只增不改,改的是这个 ref 文件。

$ cat .git/refs/heads/main       *# 提交前*
f2e9c1a7...
$ echo x >> a.txt && git commit -am "third"
$ cat .git/refs/heads/main       *# 提交后:内容变成了新 commit 的哈希*
9b8c7d6e...

**切换分支 git checkout dev 本质就是:**① 把 HEAD 指向 refs/heads/dev;② 把工作区和索引更新成 dev 所指 commit 的那棵 tree 的内容。分支本身没动。

HEAD 表明当前所处的分支以及下次 commit 要挂的分支,它不是直接存哈希,而是存 ref: <另一个引用的路径>。这种"指向另一个引用"的引用叫 符号引用 。所以解析 HEAD 是两跳

$ cat .git/HEAD
ref: refs/heads/main
HEAD ──"ref:"──▶ refs/heads/main ──直接存哈希──▶ f2e9c1a7... ──▶ commit 对象
     符号引用             普通引用                    对象哈希

git commit 的完整动作链:

git commit
  → 读 HEAD,发现 "ref: refs/heads/main"(HEAD 挂在 main 上)
  → 造新 commit 对象,parent = main 当前指向的那个 commit
  → 把 refs/heads/main 改写成新 commit 哈希
  ⇒ 因为 HEAD 指向 main,你自然就"跟着"到了新 commit

refs 的各种引用:

$ find .git/refs -type f
.git/refs/heads/main             *# 本地分支*
.git/refs/heads/dev
.git/refs/tags/v1                *# 标签*
.git/refs/remotes/origin/main    *# 远程跟踪分支*

一个活跃的 repo 可能有 大量的 标签和远程分支,如果每一个都是一个独立的文件,会很慢、很浪费 inode(索引节点),git 的解法是把它们塞到一个文件:.git/packed-refs

$ git pack-refs --all
$ cat .git/packed-refs
*# pack-refs with: peeled fully-peeled sorted *
f2e9c1a7b3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 refs/heads/main
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 refs/tags/heavy
^c0ffee00...                              ← 这行是上面那个附注标签"剥离"后的 commit
1122334455... refs/tags/light

格式很简单:每行 <哈希> <引用全名>;以 ^ 开头的行是上一行附注标签剥离 (peel) 后指向的最终 commit

Git 会先看 refs/ 下有没有对应的松散 ref 文件,有就用它(松散的优先级更高,代表更新);没有再去 packed-refs 里查。这样 pack-refs 之后你新造的分支(写成松散文件)依然生效,覆盖打包里的旧值。

refs/heads/main 这个文件任何时刻只存一个当前哈希。那它之前指向过的 commit 记在 reflog 里:

<旧哈希> <新哈希> <操作者> <时间戳> <时区>\t<操作描述>
$ git reflog
5a4b3c2 HEAD@{0}: reset: moving to HEAD~1
9b8c7d6 HEAD@{1}: commit: third        ← 我刚 reset 掉的那个提交还在这
f2e9c1a HEAD@{2}: commit (initial): first

哪怕你 git reset --hard 把一个提交"扔了",只要 reflog 还记着它的哈希,对象就没被 gc,你就能:

$ git reset --hard 9b8c7d6      *# 或 git reset --hard HEAD@{1},救回来*

理解到底层:reset 只是改了 ref 指向的哈希,被"丢弃"的 commit 对象仍在对象库里,reflog 保留了它的哈希这条线索,所以能捞回来。gc 会清理"没有任何引用能到达、且 reflog 也过期"的对象——默认 reflog 保留 90 天,所以你有 90 天后悔期。

Git 读取引用的流程:

*/* refs/files-backend.c(大幅简化)
** * 读取名为 refname 的引用,解析出它最终指向的对象哈希 oid。 */*static int files_read_raw_ref(struct ref_store *ref_store, const char *refname,
                              struct object_id *oid,   *// [出参] 解析到的哈希*
                              struct strbuf *referent, *// [出参] 若是符号引用,这里放它指向的 ref 名*unsigned int *type, ...)
{
    char *path = files_ref_path(refs, refname);   *// 拼出 .git/refs/heads/main 这样的路径*
    char buffer[256];

    */* ① 先尝试把它当"松散 ref 文件"读 */*int fd = open(path, O_RDONLY);
    if (fd < 0) {
        */* 文件不存在?可能被打包进 packed-refs 了。
**         * 交给 packed 后端去 packed-refs 里查(这里省略)。 */*
         return packed_read_raw_ref(refs, refname, oid, ...);
    }
    read_in_full(fd, buffer, sizeof(buffer));

    */* ② 内容以 "ref:" 开头 → 这是符号引用(比如 HEAD → refs/heads/main)*/*
    if (starts_with(buffer, "ref:")) {
        const char *p = buffer + 4;
        while (isspace(*p)) p++;          *// 跳过 "ref:" 后的空格*
        strbuf_addstr(referent, p);       *// 把 "refs/heads/main" 交给调用方*
        *type |= REF_ISSYMREF;            *// 标记:这是符号引用,调用方需要再解析一跳*return 0;
    }

    */* ③ 否则内容就是 40 位十六进制哈希,直接解析成 20 字节 oid */*
    if (parse_oid_hex(buffer, oid, &p) < 0)
        return -1;                        *// 不是合法哈希 → 引用损坏*return 0;
}

.git/config 是 INI 风格文本。除了 user.*,最值得理解的是 remote 和 branch 的绑定:

[remote "origin"]url = git@github.com:you/repo.git
    fetch = +refs/heads/*:refs/remotes/origin/*     ← refspec,见第7章
[branch "main"]remote = origin
    merge = refs/heads/main

[remote "origin"] 定义了远程仓库的地址,以及 fetch 时的 refspec("把远程的 refs/heads/* 映射到本地的 refs/remotes/origin/*")。这就是 origin/main 从哪来的答案。

[branch "main"] 记录了"本地 main 追踪 origin 的 main",于是 git pull 不带参数时知道该拉谁、该并谁,git status 也据此算 ahead/behind。

git add 和 git commit 之间有一个中间态,对应的就是 .git/index 文件

┌──────────────┐   git add    ┌──────────────┐  git commit  ┌──────────────┐
│   工作区                │ ─────▶   │    索引                │ ───────▶│     HEAD(提交)         │
│ Working Tree           │             │    Index/Stage        │              │        最新快照        │
│ 你正在编辑的             │ ◀───────│ 下次提交的草稿          │◀─────── │     refs 指向的         │
│  真实文件               │ git checkout │                       │  git reset   │      那棵 tree 
└──────────────┘              └──────────────┘              └──────────────┘
     磁盘上的文件                              .git/index 文件                       .git/objects 里的 tree

git status 干的事,本质就是两两对比这三棵树

  • 索引 vs HEAD 的差异 → "Changes to be committed"(已暂存,绿色)

  • 工作区 vs 索引的差异 → "Changes not staged for commit"(改了没暂存,红色)

  • 工作区里索引压根没有的文件 → "Untracked files"

$ echo new > a.txt        *# 改工作区*
$ git status
Changes not staged for commit:      ← 工作区 ≠ 索引
        modified:   a.txt
$ git add a.txt           *# 登记进索引*
$ git status
Changes to be committed:            ← 索引 ≠ HEAD
        modified:   a.txt

索引解决了两个问题:1、精确控制提交的内容 2、性能提升

索引里为每个文件缓存了它上次被 add 时的 stat() 元数据——修改时间(mtime)、大小(size)、inode 号等。判断一个文件有没有变,Git 先只做一次廉价的 stat() 系统调用,拿当前的 mtime/size 和索引里缓存的比:

  • 对得上 → 文件没变,直接跳过,连内容都不读、哈希都不算。

  • 对不上 → 可能变了,这才去真正读内容、算哈希确认。

一个几万文件的仓库,绝大多数文件两次 stat 一致,git status 只做几万次廉价 syscall 就秒回。这就是索引"cache"这个别名的由来——它是一份 stat 缓存。

Index 这个文件内部的结构为:

┌─────────────────────────────────┐
│ 头部 (12 字节)                    │  "DIRC" + 版本号 + 条目数
├─────────────────────────────────┤
│ 条目 1 (cache entry, 变长)        │  一个文件一条,按路径名排序
│ 条目 2                           │
│ ...                             │
│ 条目 N                           │
├─────────────────────────────────┤
│ 扩展 (extensions, 可选)           │  TREE 缓存、REUC 等
├─────────────────────────────────┤
│ 校验和 (20 字节)                  │  前面所有内容的 SHA-1
└─────────────────────────────────┘

头部:12 字节

偏移  长度  内容
0     4     "DIRC"   —— 魔数(magic),DIR Cache 的缩写,十六进制 44 49 52 43
4     4     版本号   —— 2 / 3 / 4(大端序 uint32)
8     4     条目数 N —— index 里有几个文件(大端序 uint32)

对应源码里的头部结构(read-cache.c 相关,cache.h):

struct cache_header {uint32_t hdr_signature;   *// "DIRC" == 0x44495243*uint32_t hdr_version;     *// 2/3/4*uint32_t hdr_entries;     *// 条目数*
};
#define CACHE_SIGNATURE 0x44495243   */* "DIRC" */*

亲眼看头部:

$ xxd -l 12 .git/index
00000000: 4449 5243 0000 0002 0000 0003            DIRC........
*#         └─"DIRC"─┘ └版本=2┘ └条目数=3┘*

每个条目 (cache entry):变长

每个被跟踪的文件,在索引里是一条 cache entry它就是 stat 缓存的载体。字节布局(版本 2):

偏移  长度  字段              说明
0     4    ctime 秒          文件元数据变更时间(stat 的 st_ctime)
4     4    ctime 纳秒
8     4    mtime 秒          文件内容修改时间(st_mtime) ← stat 缓存主力
12    4    mtime 纳秒
16    4    dev               所在设备号
20    4    ino               inode 号
24    4    mode              文件模式(同 tree 的 mode:100644 等)
28    4    uid
32    4    gid
36    4    文件大小           st_size ← stat 缓存主力
40    20   对象哈希           这个文件内容对应的 blob 的 20 字节 SHA-1
60    2    flags             高位含"路径名长度"等标志
62    ...  路径名             变长,NUL 结尾
...   1~8  填充               用 NUL 补齐,使整条长度为 8 的倍数

关键观察:

  • 偏移 8/12(mtime)和 36(size)就是 3.2 里"stat 缓存"存的地方。 git status 拿当前 stat() 的 mtime/size 和这里比。

  • 偏移 40 存的是文件内容的 blob 哈希。 注意:索引里存的是哈希,不是内容本身git add 时内容已经被写成 blob 对象进对象库了(第1章的流程),索引只记它的哈希。所以 git commit 造 tree 时,直接从索引拿"文件名 + mode + blob 哈希"就能拼出 tree,飞快。

  • 条目按路径名排序,且长度对齐到 8 字节的倍数(为了内存映射时对齐访问)。

对应的内存结构 cache_entry

磁盘上是上面那个紧凑布局,读进内存后是这个结构(cache.h):

*/* cache.h —— 索引里的一条记录,对应一个被跟踪的文件 */*struct cache_entry {struct hashmap_entry ent;      *// 让所有条目能挂进一个哈希表,按路径名 O(1) 查找*struct stat_data ce_stat_data; *// ← stat 缓存:ctime/mtime/dev/ino/uid/gid/size*unsigned int ce_mode;          *// 文件模式(100644/100755/120000/160000)*unsigned int ce_flags;         *// 标志位(含 stage 号、是否 assume-unchanged 等)*unsigned int ce_namelen;       *// 路径名长度*unsigned int index;
    struct object_id oid;          *// ← 文件内容的 blob 哈希(第1章的对象)*char name[FLEX_ARRAY];         *// 路径名,柔性数组,紧跟在结构体后面*
};

*/* 就是磁盘上偏移 0~39 那堆 stat 字段 */*struct stat_data {struct cache_time sd_ctime;    *// { uint32 秒; uint32 纳秒; }*struct cache_time sd_mtime;unsigned int sd_dev;
    unsigned int sd_ino;
    unsigned int sd_uid;
    unsigned int sd_gid;
    unsigned int sd_size;          *// ← 和 mtime 一起,是 stat 快速判等的关键*
};
*# git ls-files --stage:直接把索引内容打出来(比 hexdump 好读)*
$ git ls-files --stage
100644 d670460b4b4aece5915caf5c68d12f560a9fe3e4 0        a.txt
100644 b8b1c76f...                              0        x.txt
100644 b8b1c76f...                              0        y.txt
*# 格式:<mode> <blob哈希> <stage号> <路径>*

看到没——索引里就是 mode + blob哈希 + 路径,和 3.3 的结构体完全对应。中间那个 0stage 号,正常是 0;合并冲突时会用 1/2/3(下一节)。

对比"索引里的 blob 哈希"和"工作区文件的当前哈希",就能理解 git status 在比什么:

$ git ls-files --stage | grep a.txt      *# 索引里记的 a.txt 的哈希*
100644 d670460b... 0        a.txt
$ git hash-object a.txt                   *# 工作区当前 a.txt 的哈希*
d670460b...                               *# 一样 → 没改动*
$ echo changed >> a.txt
$ git hash-object a.txt                   *# 再算*
9f2c1a8...                                *# 不一样了 → git status 会报 modified*

平时 stage 号都是 0。但 git merge 出现冲突时,同一个文件路径会在索引里同时存在 3 条记录,用 stage 号区分:

stage 1 = base    共同祖先版本(两边分叉前的样子)
stage 2 = ours    当前分支(HEAD)的版本
stage 3 = theirs  被合并进来的分支的版本
*# 制造一个冲突后:*
$ git ls-files --stage
100644 aaaa... 1        conflict.txt    ← base
100644 bbbb... 2        conflict.txt    ← ours
100644 cccc... 3        conflict.txt    ← theirs

这就是"三路合并"在索引里的样子

Git 中读取索引的源码:

*/* read-cache.c(简化)
** * 把磁盘上的 .git/index 读进内存的 index_state 结构。 */*static int do_read_index(struct index_state *istate, const char *path, ...)
{
    */* ① 把整个 index 文件 mmap 进内存——不是逐字节 read,是内存映射,快 */*
    int fd = open(path, O_RDONLY);
    size_t mmap_size = ...;                     *// = 文件大小*
    void *mmap = xmmap(NULL, mmap_size, PROT_READ, MAP_PRIVATE, fd, 0);
    close(fd);

    */* ② 读 12 字节头部,校验魔数和版本 */*const struct cache_header *hdr = mmap;
    if (verify_hdr(hdr, mmap_size) < 0)         *// 检查 "DIRC"、版本、以及末尾 SHA-1 校验和*
        die("index file corrupt");
    istate->version = ntohl(hdr->hdr_version);  *// 网络字节序(大端) → 主机字节序*
    unsigned nr = ntohl(hdr->hdr_entries);      *// 条目数 N*
    */* ③ 逐条解析 cache entry。所有条目集中分配在一个内存池里,减少 malloc 次数 */*
    size_t consumed = sizeof(*hdr);             *// 已消费的字节,从头部之后开始*
    for (unsigned i = 0; i < nr; i++) {
        const struct ondisk_cache_entry *disk = (const void *)((char *)mmap + consumed);
        struct cache_entry *ce = create_from_disk(istate, disk, &consumed, ...);
        */* create_from_disk 内部:
           - 把 ctime/mtime/dev/ino/mode/uid/gid/size 这堆 stat 字段拷进 ce->ce_stat_data
           - 拷 20 字节哈希进 ce->oid
           - 拷路径名进 ce->name
**           - 处理 8 字节对齐的填充,推进 consumed */*
        set_index_entry(istate, i, ce);         *// 放进内存里的数组 + 哈希表*
    }

    */* ④ 剩下的字节是扩展(extensions),比如 TREE 缓存(见 3.7),逐个读 */*
    while (consumed < mmap_size - the_hash_algo->rawsz */* 末尾校验和 */*) {
        consumed += read_index_extension(istate, ...);
    }

    munmap(mmap, mmap_size);
    return istate->cache_nr;                     *// 返回读到的条目数*
}

git commit 要把索引变成一棵 tree(一堆 tree 对象)。如果每次都从零遍历所有条目、重建每一层 tree,大仓库会慢。

索引里有个 cache-tree 扩展(TREE extension),缓存了"上次提交时,每个目录对应的 tree 哈希"。只要某个目录下的文件没动,这个缓存就有效,commit 直接复用缓存的 tree 哈希,不用重新计算那棵子树

$ git ls-files --debug          *# 能看到更多索引内部信息*
$ test-tool dump-cache-tree     *# (需要 Git 的测试工具) 直接 dump cache-tree*

git add 改动一个文件,Git 会把该文件所在目录及其所有祖先目录的 cache-tree 项标记为失效(因为它们的 tree 哈希必然要变),下次 commit 只重算这条失效路径上的 tree。没动的兄弟目录全部复用。

Merge 可以双路合并,但是不能三路合并

A◀─B◀─C◀─D◀─E◀─────M   (main)
           ↖         ↗
            F◀───G◀─┘      (feature)

M 的特别之处:它有两个 parent(E 和 G)。 commit 结构里 parents 是个链表——这就是它派上用场的地方:

$ git cat-file -p HEAD
tree 3a4b...
parent e5f6...     ← 第一个父:合并前的 main (E)
parent 9a0b...     ← 第二个父:被合进来的 feature (G)
author ...
committer ...

Merge branch 'feature'

git rebase 被包装成"把我的提交移到目标分支后面",但对象层面它做的是重放 (replay)——逐个把你的提交作为 patch 应用到新基底上,造出全新的 commit

从这个状态 rebase:

A◀─B◀─C◀─D◀─E   (main)
           ↖
            F◀─G   (feature, HEAD)

git rebase main(在 feature 上):

A◀─B◀─C◀─D◀─E   (main)
                ↖
                 F'◀─G'   (feature, HEAD)

注意是 F'、G',不是 F、G

  1. Git 找到 feature 相对 main 独有的提交:F、G(从 merge base C 之后的)。

  2. 把 F 表示成"相对其父的 diff"(一个 patch)。

  3. 把这个 patch 应用到 E 上,造一个新 commit F':内容可能和 F 一样,但 parent 变成了 E,committer 时间也变了 → 哈希必然不同(第1章:改任何一个字节,哈希就变)。

  4. 对 G 同样处理,得到 G'(parent = F')。

  5. feature 指针从 G 移到 G'。

F、G 原对象还在库里(对象不可变),只是没有分支指向它们了,将来会被 gc 回收。这就是"rebase 改写历史"的真相:不是修改,是重造 + 挪指针

这也立刻解释了两条铁律:

  • 为什么"不要 rebase 已推送的公共分支"? 因为 F→F' 哈希变了。别人基于 F 继续工作,你把 F 换成 F' 推上去,别人的历史和你的对不上,天下大乱。私有分支随便 rebase,公共分支别碰。

  • *为什么 rebase 后要 push --force(最好 --force-with-lease)?* 因为 feature 指针从 G 跳到了 G',这不是快进(G' 不是 G 的后代),远程会拒绝普通 push,只能强推覆盖。

对比 merge:merge 保留真实分叉历史(多一个 merge commit),rebase 抹平成一条直线(重造提交)

git reset 本质就一件事:把当前分支指针移到指定 commit。三个模式的区别在"动不动三棵树":

git reset [--soft|--mixed|--hard] <目标>

         移动分支指针?   重置索引?   重置工作区?
--soft        ✓            ✗           ✗
--mixed(默认) ✓            ✓           ✗
--hard        ✓            ✓           ✓

用图说话。当前 main → D,执行 git reset --xxx C

reset 前                 reset 后(任何模式,指针都回到 C)
   A◀─B◀─C◀─D  (main)       A◀─B◀─C  (main)      D 变悬空(reflog 还留着)
                │                   │
              (HEAD)              (HEAD)

指针都一样退回 C,区别在三棵树:

  • --soft:只挪指针。索引和工作区原封不动。→ 效果是"D 的改动全部变回已暂存状态",你可以立刻重新 commit。常用于"合并最近几个提交"或"重写提交信息"。

  • --mixed****(默认):挪指针 + 把索引重置成 C。工作区不动。→ "D 的改动退回未暂存状态",文件内容还在,但要重新 add

  • --hard:挪指针 + 索引 + 工作区全部重置成 C。→ D 的改动从工作区消失。危险,但记住:D 的 commit 对象还在,reflog 有它的哈希,能 git reset --hard <D的哈希> 救回。

对比 reset(移动分支指针),git checkout <分支> / git switch <分支> 移动的是 HEAD

git checkout dev
   → 把 HEAD 从 "ref: refs/heads/main" 改成 "ref: refs/heads/dev"
   → 把索引和工作区更新成 dev 所指 commit 的 tree 内容
   → 分支指针本身都没动
  • git checkout <commit哈希>(不是分支)→ 进入 detached HEAD:HEAD 直接存哈希,不挂任何分支。

  • git checkout -- <文件> → 又是另一回事:用索引里的版本覆盖工作区这个文件(不动 HEAD)。正因 checkout 一个命令干太多不同的事,新版 Git 拆成了 git switch(切分支)和 git restore(恢复文件)。

cherry-pick:把某个提交"搬"过来

A◀─B◀─C  (main, HEAD)      A◀─B◀─C◀─X'  (main, HEAD)
           ↖                          
            X  (在别的分支)     X 的改动被复制成一个新 commit X'

git cherry-pick X:算出 X 相对其父的 diff,应用到当前 HEAD(C)上,造一个新 commit X'。X' 内容改动和 X 一样,但 parent 是 C、哈希不同(又是"重造")。常用于"把某个 bugfix 单独摘到另一个分支"。

revert:造一个"反向 patch"提交

git revert X 不是删除 X,而是造一个新 commit,其 diff 是 X 的反操作(X 加的行删掉、删的行加回):

A◀─B◀─X◀─C  (main)         A◀─B◀─X◀─C◀─X⁻¹  (main)
                                          
                              X⁻¹ 的内容 = 撤销 X 的改动,但 X 仍在历史里

revert 是"前进式撤销":历史线性向前,X 依然存在,只是被后面一个反向提交抵消了。适合撤销已经推送的提交——不改写历史,别人不受影响。这正好和 reset(回退指针、改写历史、适合本地)形成互补:

改写历史? 适合场景
reset 是(挪指针丢提交) 撤销本地未推送的提交
revert 否(造反向新提交) 撤销已推送的提交

reset/rebase/amend 之后,那些"没有任何引用能到达"的 commit 叫悬空对象 (dangling/unreachable object)。它们不会立刻消失(第1章:对象只增不改),而是:

  1. reflog 在保护期内还记着它们的哈希(默认 90 天),所以能 git reflog + reset 救回。

  2. 超过保护期、且 git gc 运行时,会做可达性分析:从所有 ref(分支/标签/HEAD/reflog)出发遍历 DAG,标记所有能到达的对象;到达不了的就是垃圾,清除。

$ git fsck --unreachable        *# 列出当前不可达的对象*
unreachable commit 9b8c7d6...
$ git reflog expire --expire=now --all && git gc --prune=now   *# 强制立刻清理(慎用!)*

这套机制的美妙之处:"删除"在 Git 里从来不是真的立刻删数据,而是"解除引用"。数据留着,等确认没人要了再回收。

为什么需要打包:松散对象的两个致命伤

伤一:文件系统被小文件压垮

一个对象一个文件,Linux 内核仓库有数百万个对象。几百万个小文件意味着:

  • 每个文件至少占一个 inode 和一个磁盘块(哪怕内容只有几字节,也占满一整块,几 KB 起步)→ 巨大的空间浪费;

  • clone 要传几百万个文件 → 网络请求爆炸,慢到无法接受。

伤二:松散对象之间无法共享相似性

一开始讲过:内容完全相同才共享同一个 blob。但现实里更多的是"几乎相同"——你改了一个 1MB 文件里的一行,Git 就得存一个全新的完整 1MB blob(因为哈希变了,是不同对象)。改 100 次,就是 100 份将近 1MB 的 blob。松散存储对"相似但不同"的内容毫无办法。

Packfile 一举解决这两个问题:把成千上万对象塞进一个文件(解决伤一),并且在文件内部用 delta 压缩只存"相似对象之间的差异"(解决伤二)。

一个 packfile(.pack)的字节布局:

┌────────────────────────────────────┐
│ 头部 (12 字节)                        │  "PACK" + 版本 + 对象数
├────────────────────────────────────┤
│ 对象 1 (变长头 + zlib压缩数据)         │  非 delta:直接压缩内容
│ 对象 2                               │  delta:压缩的"差异指令"
│ ...                                 │
│ 对象 N                               │
├────────────────────────────────────┤
│ 校验和 (20 字节)                      │  前面全部内容的 SHA-1
└────────────────────────────────────┘

头部:12 字节

偏移  长度  内容
0     4    "PACK"        —— 魔数,十六进制 50 41 43 4b
4     4    版本号         —— 2 或 3(大端 uint32)
8     4    对象总数 N     —— 这个 pack 里打了多少对象(大端 uint32)

对应源码(pack.h):

struct pack_header {uint32_t hdr_signature;   *// "PACK" == 0x5041434b*uint32_t hdr_version;     *// 2/3*uint32_t hdr_entries;     *// 对象个数 N*
};
#define PACK_SIGNATURE 0x5041434b   */* "PACK" */*
$ xxd -l 12 .git/objects/pack/pack-*.pack
00000000: 5041 434b 0000 0002 0000 07d1            PACK........
*#         └"PACK"┘ └版本=2┘ └N=0x7d1=2001 个对象┘*

末尾:20 字节 SHA-1 校验和

整个 pack(除最后这 20 字节外)的 SHA-1,用于校验完整性。.pack 的文件名里的哈希就是它。这又是内容寻址精神:连打包文件本身也自带内容校验。

pack 里每个对象前面有个变长头部,编码了"这个对象的类型 + 解压后的大小"。

编码规则

  • 第 1 个字节

    • 最高位 bit(MSB, 0x80)= continuation 位:1 表示"还有后续字节",0 表示"到此为止"。

    • 接下来 3 个 bit(0x70)= 对象类型

    • 最低 4 个 bit(0x0f)= 大小的最低 4 位

  • 后续每个字节

    • 最高位 = continuation 位(同上)。

    • 低 7 位 = 大小的更高位(小端拼接:先出现的是低位)。

解码变长头源码:

*/* packfile.c
** * 从 buf 里解出一个对象的类型和"解压后大小"。返回消耗了几个字节。 */*
 unsigned long unpack_object_header_buffer(const unsigned char *buf, unsigned long len, enum object_type *type, unsigned long *sizep)
{
    unsigned shift;
    size_t size, c;
    unsigned long used = 0;

    c = buf[used++];              *// 取第 1 个字节*
    *type = (c >> 4) & 7;         *// bit 4~6 是类型(3 位):(c>>4)&0b111*
    size = c & 15;                *// 低 4 位是大小的最低 4 位*
    shift = 4;                    *// 下一批大小位要左移 4 位起*while (c & 0x80) {            *// 最高位为 1 → 还有后续字节*
        c = buf[used++];         *// 取下一个字节*
        size += (c & 0x7f) << shift;  *// 低 7 位拼到 size 的高位上(小端)*
        shift += 7;              *// 每多一个字节,再多 7 位*
    }
    *sizep = size;
    return used;                 *// 头部一共消耗了 used 个字节*
}

举个实际例子。假设某对象头部两个字节是 0x9d 0x0a

第1字节 0x9d = 1001 1101
         │└┬┘ └┬─┘
         │ │   └ 低4位 size = 0b1101 = 13
         │ └ 类型 = 0b001 = 1 = OBJ_COMMIT
         └ MSB=1 → 还有下一字节
第2字节 0x0a = 0000 1010
         │ └─┬─┘
         │   └ 低7位 = 0b0001010 = 10
         └ MSB=0 → 结束
size = 13 + (10 << 4) = 13 + 160 = 173

→ 这是一个解压后 173 字节的 commit 对象。头部之后就是它的 zlib 压缩数据。

这个"大小"是对象解压后的大小,只用来在解压时预分配缓冲,并不直接告诉你压缩数据占多少字节

对于两个"相似但不同"的对象,pack 不会都存完整内容,而是选一个当基对象 (base),另一个只存相对 base 的差异 (delta)

两种 delta:怎么指定 base

  • OBJ_REF_DELTA:变长头之后跟 20 字节的 base 对象哈希,然后才是压缩的 delta 数据。base 用哈希指定,可以指向 pack 外的对象。

  • OBJ_OFS_DELTA:变长头之后跟一个变长编码的偏移量(负偏移,指向本 pack 里更靠前的某个对象作为 base),然后是压缩的 delta 数据。因为省掉了 20 字节哈希、改用几字节偏移,更省空间,是默认选择

packfile.cget_delta_base 解码这个偏移:

*/* packfile.c(片段)—— 解出 OFS_DELTA 的 base 偏移 */*if (type == OBJ_OFS_DELTA) {
    unsigned char c = base_info[used++];
    base_offset = c & 127;              *// 第一个字节低 7 位*
    while (c & 128) {                   *// 最高位=1 → 还有后续*
        base_offset += 1;               *// ★ 关键:每多一字节,先给结果 +1*
        c = base_info[used++];
        base_offset = (base_offset << 7) + (c & 127);  *// 左移 7 位再拼低 7 位(大端!)*
    }
    */* base 就在"当前 delta 对象的偏移 - base_offset"处 */*
    base_offset = delta_obj_offset - base_offset;
}

那个 base_offset += 1 很反常,值得解释:它是一种去冗余编码。普通变长编码里,0x80 0x000x00 都表示 0,浪费了编码空间。加这个 +1 让每个数值只有唯一一种编码、且能表示更大范围。这是 Git 抠字节的典型体现——连几个 bit 的浪费都不放过。

Delta 数据本身:copy 与 insert 指令

解出 base 之后,delta 数据(解压后)是这样一段指令流。patch-delta.c 里的 patch_delta 执行它,我抽出核心逻辑并注释:

*/* patch-delta.c(简化)—— 把 delta 应用到 base 上,还原出目标对象 */*
void *patch_delta(const void *src_buf, unsigned long src_size,   *// base 对象*
const void *delta_buf, unsigned long delta_size, *// delta 指令流*
unsigned long *dst_size)                         *// [出参]目标大小*
{
    const unsigned char *data = delta_buf;
    const unsigned char *top  = delta_buf + delta_size;

    */* ① delta 开头是两个变长整数:base 的大小 和 目标的大小(用于校验和预分配) */*
    src_size = get_delta_hdr_size(&data, top);   *// 应等于传入的 src_size*
    unsigned long size = get_delta_hdr_size(&data, top);   *// 目标对象大小*
    unsigned char *dst = xmalloc(size);          *// 按目标大小分配输出缓冲*
    unsigned char *out = dst;

    */* ② 逐条执行指令,直到 delta 流结束 */*
    while (data < top) {
        unsigned char cmd = *data++;
        if (cmd & 0x80) {
            */* ── COPY 指令:从 base 里拷一段过来 ──
             * cmd 的低 7 位是个位掩码,指示后面跟了哪几个字节来拼 offset 和 size。
**             * 这样偏移/长度都用变长表示,省空间。 */*
             unsigned long cp_off = 0, cp_size = 0;
            if (cmd & 0x01) cp_off  = *data++;
            if (cmd & 0x02) cp_off |= *data++ << 8;
            if (cmd & 0x04) cp_off |= *data++ << 16;
            if (cmd & 0x08) cp_off |= *data++ << 24;
            if (cmd & 0x10) cp_size  = *data++;
            if (cmd & 0x20) cp_size |= *data++ << 8;
            if (cmd & 0x40) cp_size |= *data++ << 16;
            if (cp_size == 0) cp_size = 0x10000;    *// 0 表示 64KB*
            memcpy(out, src_buf + cp_off, cp_size);  *// 从 base 的 cp_off 处拷 cp_size 字节*
            out += cp_size;
        } else if (cmd) {
            */* ── INSERT 指令:cmd 本身(1~127)就是长度,后面跟着这么多字节的新数据 ──
**             * 这些是 base 里没有、目标里新增的内容 */*
            memcpy(out, data, cmd);
            out += cmd;
            data += cmd;
        }
        */* cmd == 0 是保留值,非法 */*
    }
    *dst_size = size;
    return dst;    *// dst 就是还原出来的完整目标对象*
}

delta 的 base 本身也可以是另一个 delta(OBJ_OFS_DELTA 指向的对象又是 delta)。这样形成 delta 链:还原一个对象要顺着链一路 patch 回去。链太长会拖慢读取,所以 Git 有 pack.depth(默认 50)限制链长,在"省空间"和"读得快"之间平衡。

谁做谁的 base?这是 pack-objects.c 里的核心启发式(try_delta / find_deltas),思路(不是严格源码,是算法概括):

  1. 按 (类型, 路径名/basename, 大小) 给对象排序——同名同类型、大小相近的对象很可能相似(比如 README.md 的历代版本),排在一起。

  2. 在一个滑动窗口pack.window,默认 10)内,尝试让每个对象对窗口里的其他对象做 delta,选delta 后最小的那个当 base。

  3. 倾向让"大对象"当 base、"小对象"当 delta——这样常见的"文件越改越大"场景里,最新(最大)版本是完整的 base,历史版本是 delta,读最新版最快(符合"最近的提交最常访问")。

  4. pack.depth(链长上限)约束。

这套启发式是纯工程权衡,没有"最优解",但实践中把相似对象压得非常好。git repack -a -d -f --depth=250 --window=250 可以让它更卖力(更慢但更小),做深度归档时有用。

.pack 只是对象的顺序堆叠。给一个哈希,怎么在几 GB 的 pack 里快速找到它、且知道它在第几字节?靠配套的 .idx** 文件**(version 2 格式):

┌──────────────────────────────────────┐
│ 魔数 \377tOc + 版本号 2 (8 字节)         │
├──────────────────────────────────────┤
│ fan-out 表:256 个 uint32 (1024 字节)   │  fanout[i] = 哈希首字节 ≤ i 的对象累计个数
├──────────────────────────────────────┤
│ 对象哈希表:N × 20 字节,已排序           │  所有对象的哈希,按字典序排列
├──────────────────────────────────────┤
│ CRC32 表:N × 4 字节                     │  每个对象压缩数据的 CRC,校验用
├──────────────────────────────────────┤
│ 偏移表:N × 4 字节                        │  每个对象在 .pack 里的字节偏移
├──────────────────────────────────────┤
│ (大 pack 用的 8 字节大偏移表,可选)        │
├──────────────────────────────────────┤
│ pack 的 SHA-1 + idx 自身的 SHA-1         │
└──────────────────────────────────────┘

查找一个哈希 H 的算法(这是设计精髓):

  1. fan-out 表先定位区间:H 的首字节是 b,那么 H 一定排在哈希表的 [fanout[b-1], fanout[b]) 这段区间里。这一步用一次数组访问,就把搜索范围从"全部 N 个"缩到"首字节相同的一小撮"。

  2. 在那一小段已排序的哈希里做二分查找,找到 H 的下标 i

  3. 用下标 i偏移表取出 H.pack 里的字节偏移。

  4. 跳到 .pack 那个偏移,用 5.3 的变长头解码,解压/patch 出对象。

fan-out 表是个漂亮的两级索引:256 项的表先干掉 8 bit 的搜索,剩下二分。整体 O(log n) 且常数极小。给哈希、秒定位,这是 git cat-file、传输协商等一切操作的基础。

$ git verify-pack -v .git/objects/pack/pack-*.idx | head
<哈希> commit 173 128 12                  ← 类型 解压大小 压缩大小 在pack中的偏移
<哈希> blob   1024 …                       
<哈希> blob   40 30 … 1 <base哈希>         ← 这行是 delta,深度 1,标了它的 base
非 delta 的对象 …

git verify-pack -v 是观察 pack 内部构造的利器:能看到每个对象的类型、大小、是不是 delta、delta 链多深。

本地 gc

$ git gc

git gc 会:把散落的松散对象和多个小 pack 重新打包成(通常)一个大 pack(git repack);删除已被打包的松散对象;清理过期 reflog 和不可达对象(回扣第4章 4.8);更新 packed-refs(第2章);可能生成 commit-graph 等加速缓存。日常 git 命令在合适时机会自动触发 gc --auto

传输用的 thin pack

git clone / fetch / push 时,两端不会传松散对象,而是现场生成一个 pack 发过去(第7章详解协商)。这里有个精妙优化叫 thin pack:发送方可以用"接收方已经有、但不在本 pack 里"的对象当 delta 的 base。

  • 好处:增量 push/fetch 时,新提交往往只是在旧文件上改几行,用接收方已有的旧版本当 base,delta 极小,传输量极小。

  • 代价:接收方收到 thin pack 后,其中的 delta 引用了 pack 外的 base,必须用 git index-pack --fix-thin 把那些 base 对象补进来,"补胖"成一个自洽的 pack,才能建立 .idx

理解了 thin pack,就理解了为什么 git fetch 拉一个改动不大的更新会那么快、传的字节那么少——它传的是相对你已有对象的 delta。

这里,delta 的本质实际上是两个 blob 的 diff,它可以是同一个文件不同提交的两个版本,也可以是不同文件,无历史关系的两个 blob,它仅仅是为了传输与存储的方便而创造,不能用于对比 diff。因此,要看两次 commit 同一文件的 diff,需要另一种方式。

git diff A B 比较两个 commit,本质是比较它们的两棵 tree:

git diff commitA commitB
  → 拿到 A 的 tree、B 的 tree
  → 递归对比两棵 tree(tree diff):
       - 某文件只在 A 有   → 删除
       - 某文件只在 B 有   → 新增
       - 两边都有但 blob 哈希不同 → 内容改了,对这个文件做"文本 diff"
       - 两边 blob 哈希相同 → 没变,跳过(哈希一样内容必一样)

注意最后一条:tree diff 靠比哈希,秒判"变没变",根本不用读内容——这是内容寻址又一次发力。只有"两边都有且哈希不同"的文件,才需要真正去做逐行的文本 diff

Git 默认的 diff 算法来自 Eugene Myers 1986 年的论文 "An O(ND) Difference Algorithm and Its Variations"。核心思想极其优雅:

编辑图 (edit graph)

把问题画成一张网格图。A 的行沿 x 轴(长度 N),B 的行沿 y 轴(长度 M)。你从左上角 (0,0) 走到右下角 (N,M),每一步只能:

向右一步 (x+1)  = 删除 A 的第 x+1 行         代价 1
向下一步 (y+1)  = 插入 B 的第 y+1 行         代价 1
斜对角一步      = A[x+1] == B[y+1],公共行    代价 0(免费!)
  • 走对角线(免费)= 这一行两边一样,保留。

  • 向右/向下(代价 1)= 一次删除/插入。

找最短编辑脚本 = 在这张图上找从 (0,0) 到 (N,M) 代价最小(对角线免费)的路径。 代价就是删+插的总数,记作 D。

O(ND) 的精髓:按 D 递增地探索

Myers 不去填满整张 O(NM) 的表,而是按编辑距离 D 从小到大探索:"0 次编辑能走多远?1 次呢?2 次呢?……" 一旦某个 D 能走到终点 (N,M),那个 D 就是最短编辑距离。

它用一个巧妙的坐标 k = x − y(对角线编号)来组织搜索:对每个 D,只需在若干条对角线上记录"这次编辑能到达的最远点",用一个一维数组 V[k] 存。每增加一次编辑(D+1),从上一轮的最远点向右或向下再走一步、再尽量吃免费对角线。

因为公共行长的文件(大多数真实代码 diff)编辑距离 D 很小,算法只跑很少几轮就到终点——所以实际速度远好于最坏 O(ND)。

源码位置与结构

Git 的 diff 引擎是内嵌的 xdiff 库(源码在 xdiff/ 目录,源自 LibXDiff)。关键文件和函数:

xdiff/xdiffi.c   —— Myers 算法主体
    xdl_do_diff()          总入口
    xdl_split()            分治:找到中点(middle snake)后递归两半
    find_middle_snake()    Myers 的双向搜索核心,找最优路径的中段
xdiff/xprepare.c —— 预处理:把文本切成行、给每行算哈希(见 6.4)
xdiff/xutils.c   —— 工具:行哈希、比较

Git 实际用的是 Myers 的改进版(linear space / 双向搜索):从两端同时向中间搜,找到"middle snake"后分治递归,把空间降到线性。核心循环(find_middle_snake 里)概念上就是:

*/* xdiff/xdiffi.c 里 Myers 前向搜索的概念性核心(高度简化+注释)
** * V 数组:V[k] = 在对角线 k 上,当前编辑步数下能到达的最远 x 坐标 */*
 for (d = 0; d <= max_edit; d++) {        *// D 从 0 递增:允许的编辑次数*
     for (k = -d; k <= d; k += 2) {       *// 遍历这一轮可能到达的对角线*int x;
        */* 决定这一步是"向下(插入)"还是"向右(删除)":选能走更远的那个 */*
        if (k == -d || (k != d && V[k-1] < V[k+1]))
            x = V[k+1];                  *// 向下:y+1,x 不变*
        else
            x = V[k-1] + 1;              *// 向右:x+1(删除 A 的一行)*
        int y = x - k;
        */* 尽量吃免费对角线:连续相等的行,一路白嫖 */*
        while (x < N && y < M && A[x] == B[y]) { x++; y++; }
        V[k] = x;                        *// 记录这条对角线的最远点*
        if (x >= N && y >= M)
            return d;                    *// 到终点了,d 就是最短编辑距离*
    }
}

逐行比较字符串很慢(每次比较是 O(行长))。xdiff 在预处理阶段(xprepare.c给每一行算一个哈希值,之后比较两行是否相同就退化成比两个整数,O(1)。这和第1章"用哈希代表内容"是同一种思想,只是这次哈希用在算法内部提速,不落盘。

*/* xdiff/xutils.c 概念:把一行文本压成一个 hash,比较行 = 比较 hash */*static unsigned long xdl_hash_record(const char *ptr, const char *top, long flags) {
    unsigned long ha = 5381;             *// 经典 djb2 初值*while (ptr < top && *ptr != '\n') {
        ha = ((ha << 5) + ha) + *ptr++;  *// ha = ha*33 + c*
    }
    return ha;                            *// 这一行的哈希*
}

flags 用于 -w/--ignore-space 之类选项:忽略空白时,算哈希前先归一化空白,于是"只差空格"的行被视为相同。)

算出编辑脚本后,Git 把相邻的增删行连同前后几行上下文 (context) 打包成一个 hunk,就是 @@ -12,7 +12,8 @@ 那种块。-U5 可以调上下文行数。

除了默认的 Myers,Git 还提供别的 diff 算法(--diff-algorithm=diff.algorithm 配置):

patience/histogram 解决 Myers 有时把不相关的行凑成"公共行"导致 diff 难读的问题——它们优先匹配"两边都只出现一次的行"作为可靠锚点。原理不同,但目标一致:更短、更符合人直觉的 diff。

现在进入 merge。回忆:真合并要找 merge base(两分支的最近公共祖先)。合并要处理三个版本,不是两个:

base (共同祖先)
       /    \
    ours    theirs
   (HEAD)  (被合并进来的分支)

为什么必须要 base?看这个例子:base 里某行是 X,ours 改成了 Y,theirs 还是 X

  • 只有 ours/theirs 两个版本Y vs X),你无法判断该保留谁——是 ours 改了,还是 theirs 改了?信息不足,只能报冲突。

  • 有了 baseX),一比就懂:ours 从 XY(改了),theirs 还是 X(没动)。**结论显然:采用 ours 的 ****Y**。

这就是三路合并的威力:用共同祖先当参照,判断"谁改了、谁没改",从而自动合并。 逐个文件、逐个 hunk 应用这个逻辑:

冲突的本质:base→ours 和 base→theirs 在同一块区域做了不同的改动,Git 无法判断该听谁的,于是把三方都摆出来,让你决定。这正好对应索引里的 stage 1/2/3(base/ours/theirs)和工作区里的冲突标记:

<<<<<<< HEAD
ours 的版本 (Y)
=======
theirs 的版本 (Z)
>>>>>>> feature

上面是单个文件内的三路合并。整次 merge 还要在整棵 tree 层面做:

  1. 对 base/ours/theirs 三棵 tree 做三路 tree diff,逐路径判断:

    • 只有一边改了文件内容 → 取那一边;

    • 两边都改了同一文件 → 对该文件做 6.6 的文件级三路合并(可能产生冲突);

    • 一边删了、一边改了 → "modify/delete" 冲突;

    • 涉及重命名 → 见 后文。

  2. criss-cross(有多个 merge base)时,先把多个 base 递归地合并成一个"虚拟 base",再用它做三路合并——这就是老算法名字 merge-recursive 的由来。

Git 现在默认的合并引擎叫 merge-ort(源码 merge-ort.c,"Ostensibly Recursive's Twin",2021 年起成为默认),是 merge-recursive 的重写版:

  • 更快:完全在内存里操作 tree 结构,不像老引擎那样反复往工作区/索引刷临时状态;

  • 更正确:重命名检测、目录重命名等边界情况处理得更好;

  • 对外行为(冲突标记、结果)与老引擎基本一致,所以你平时无感。

merge-ort 大致流程(merge-ort.c 的 merge_ort_nonrecursive_internal):
  收集三方 tree 的差异 → 逐路径决策(collect_merge_info / process_entries)
    → 需要文件级合并的调 xdiff 做三路文本合并(ll-merge.c)
    → 重命名检测(diffcore-rename)
    → 全程在内存构建结果 tree,最后一次性写出

文件级的三路文本合并落在 ll-merge.c("low-level merge"),它内部还是调 6.3 的 xdiff——merge 复用 diff:先 diff 出 base→ours 和 base→theirs 的改动,再看它们区域是否重叠来决定合并或冲突。

又一个反直觉点:Git 根本不记录"你重命名了文件"。tree 里只有"某路径 → 某 blob",你把 a.txt 改名成 b.txt,在 tree diff 里表现为"删了 a.txt、加了 b.txt"——两个独立事件。

git log --follow、merge 时"你改了内容我改了名字"能正确合并,是怎么做到的?靠相似度检测事后猜出来diffcore-rename.c):

  1. 收集本次 diff 里所有"被删的文件"和"新增的文件"。

  2. 先匹配内容完全相同的(blob 哈希一样)——那基本就是纯改名,直接配对。

  3. 对剩下的,两两计算相似度(基于内容分块的相似程度),超过阈值(默认 50%,-M 可调)就判定为"重命名 + 少量修改"。

  4. 配对成功的,diff 里显示成 rename from a.txt → b.txt,merge 时也据此把"改名"和"改内容"两件事正确合到一起。

所以:

  • git mv a bmv a b && git add 在对象层面完全一样——都是删 a 加 b,git mv 只是顺手帮你 add 了。Git 不存"改名"这个动作。

  • 重命名是启发式猜测,不是记录。改名同时大改内容,可能就检测不出来,diff 显示成一删一增。

  • 这也是为什么超大仓库 diff/merge 有时慢——重命名检测是 O(删除数 × 新增数) 的两两比较,Git 有 diff.renameLimit 上限保护。

Git 是分布式的,同步的核心问题就一个:"我该把哪些对象发给你?"——不能把整个仓库每次都传一遍。

回忆内容寻址:同一份内容在任何机器上哈希都相同。这个性质让同步变得优雅——两端只要交换哈希,就能知道对方缺什么:

同步 = 找出"你想要、但你没有"的对象集合 → 打成一个 pack → 传过去

具体分两个动作:

  • fetch/clone(拉):客户端问服务器"你有哪些分支、各指向哪个 commit?",然后说"我要 commit X(want),但我已经有 commit Y 了(have)",服务器据此算出差集、打包发来。

  • push(推):反过来,客户端把服务器缺的对象打包传上去,并请求更新服务器的 ref。

这个"我要 X、我有 Y"的对话,就是 对象协商 (negotiation)。它跑在一个叫 pkt-line 的线格式上。

Git 的网络协议不是 JSON、不是 protobuf,而是一个极简的自定义分包格式 pkt-line(packet line)。规则只有一条:

每个数据包 = 4 个十六进制字符表示的长度 + 数据本身。长度值包含那 4 个长度字符自己

"0009done\n"
 └┬─┘└──┬──┘
 长度   数据("done\n" 是 5 字节)
 0x0009 = 9 = 4(长度头) + 5(数据) ✓

几个特殊包(长度头是保留值,不带数据):

"0000"  flush-pkt   —— 分隔符/结束符,"这一段说完了"
"0001"  delim-pkt   —— 分隔符(协议 v2 用,分隔不同区块)
"0002"  response-end-pkt (v2)

为什么用这么原始的格式?因为它既能跑在 TCP 流上、也能跑在 HTTP body 里、还能跑在 SSH 管道里,且能天然地把"消息边界"标出来(TCP 是字节流,本身没有边界概念)。简单、通用、够用。

亲眼看 pkt-line(用 GIT_TRACE_PACKET 抓包):

$ GIT_TRACE_PACKET=1 git ls-remote https://github.com/git/git 2>&1 | head
packet:        git< 0098<40位哈希> HEAD\0multi_ack thin-pack side-band ... 
packet:        git< 003f<40位哈希> refs/heads/main
packet:        git< 0000                    ← flush-pkt,ref 列表结束

git< 是服务器发来的、git> 是客户端发出的。每行前面的 0098003f0000 就是 pkt-line 的长度头。你能直接看到协议在说什么。

源码:读写 pkt-line

pkt-line.cpacket_write / packet_read,主干带注释:

*/* pkt-line.c(简化)—— 把一段数据按 pkt-line 格式写出去 */*
void packet_write(int fd, const char *buf, size_t size) {
    char hdr[4];
    size_t packet_size = size + 4;        *// 数据长度 + 4 字节长度头本身*
    */* 把 packet_size 格式化成 4 位十六进制,比如 9 → "0009" */*
    set_packet_header(hdr, packet_size);
    write_in_full(fd, hdr, 4);            *// 先写 4 字节长度头*
    write_in_full(fd, buf, size);         *// 再写数据*
}

*/* 读一个 pkt-line */*
int packet_read(int fd, char *buffer, ...) {
    char lenbuf[4];
    read_in_full(fd, lenbuf, 4);          *// 先读 4 字节长度头*
    int len = parse_hex4(lenbuf);         *// "0009" → 9*
    if (len == 0) return 0;               *// flush-pkt(0000):返回 0 表示"段结束"*if (len == 1) return ...;             *// delim-pkt(0001)*
    len -= 4;                             *// 减掉长度头,得到真正数据长度*
    read_in_full(fd, buffer, len);        *// 读 len 字节数据*return len;
}

fetch 的对象协商:want / have

第一步:服务器公布 ref(ref advertisement)

客户端一连上,服务器主动把自己所有的 ref 和对应哈希列出来(就是 7.2 抓包看到的那些),并在第一行附上自己支持的能力 (capabilities)

git< <哈希A> HEAD\0multi_ack side-band-64k thin-pack ofs-delta agent=git/2.x ...
git< <哈希A> refs/heads/main
git< <哈希B> refs/heads/dev
git< 0000        ← flush,公布完毕

客户端由此知道:服务器有 main→哈希A、dev→哈希B,以及它支持哪些优化(thin-pack、ofs-delta 等,回扣第5章)。

第二步:客户端发 want(我要什么)

客户端根据自己的 refspec(第2章)决定要哪些,发 want + 哈希:

git> want <哈希A> multi_ack side-band-64k thin-pack ofs-delta   ← 第一行捎带选用的能力
git> want <哈希B>
git> 0000

第三步:客户端发 have(我已经有什么)—— 关键的差集协商

客户端把自己已有的 commit(从自己的分支/reflog 顺着 DAG 往回走)用 have 发给服务器:

git> have <我已有的commit1>
git> have <我已有的commit2>
...

服务器一看:"哦,你已经有 commit1,而 commit1 是你想要的哈希A 的祖先,那 commit1 及它所有祖先对象我就不用发了。" 服务器回 ACK(我也有这个,作为共同基点)或 NAK(没有共同点,继续)。

这个过程可能来回几轮(客户端分批发 have,服务器逐步确认),直到服务器确定了共同祖先边界,就能算出精确的差集:

要发的对象 = (从 want 的哈希出发能到达的所有对象) − (从已确认的 have 出发能到达的所有对象)

第四步:服务器打包发送

服务器把差集对象现场打成一个 **pack **发回来,还会用 **thin-pack **——用"客户端已声明 have、但不在本 pack 里"的对象当 delta base,把传输量压到极小。客户端收到后 git index-pack --fix-thin 补全、建 .idx,再更新自己的 refs/remotes/origin/*

side-band-64k 能力让服务器能在传 pack 数据的同时,复用同一连接捎带进度信息(就是你看到的 Receiving objects: 42%),pack 数据走 band 1、进度文字走 band 2。

push 是反过来的,但多了一步"请求改服务器的 ref":

  1. 服务器仍先公布 ref(客户端得知道服务器现状,才能算要传什么、才能检查能不能快进)。

  2. 客户端发送 ref 更新命令<旧哈希> <新哈希> <ref名>,意思是"请把 refs/heads/main 从旧哈希更新到新哈希":

git> <old-oid> <new-oid> refs/heads/main\0report-status side-band-64k ...
git> 0000
  1. 客户端把服务器缺的对象打成 pack 传上去(同样用 thin-pack 等优化)。

  2. 服务器端校验并更新

    • 检查 <旧哈希> 是否等于服务器当前 main 的值——不等就拒绝(说明别人在你之后推过了,你的信息过时了,这就是 ! [rejected] 的由来);

    • 检查是不是**快进 **——非快进默认拒绝,除非 --force;这解释了 "rebase 后要强推";

    • pre-receive / update 钩子(服务器可以在这里做权限、CI 校验);

    • 通过则原子更新 ref,回 report-status 告诉客户端每个 ref 成功还是失败。

服务器端处理 push 的程序是 receive-pack(源码 builtin/receive-pack.c),客户端是 send-pack。对应地,fetch 端是客户端 fetch-pack ↔ 服务器 upload-pack

fetch/clone                          push
客户端   fetch-pack   ──want/have──▶          send-pack   ──更新命令+pack──▶
服务器   upload-pack  ──◀── pack ──           receive-pack ──◀── report ──
        (源码 upload-pack.c)                  (源码 builtin/receive-pack.c)

三种传输通道:SSH、smart HTTP、本地

上面的 pkt-line 对话可以跑在不同"管道"上:

SSH(git@github.com``:user/repo.git

最直接:客户端 SSH 登录服务器,在远端执行 git-upload-pack <repo>(fetch)或 git-receive-pack <repo>(push),然后 7.3/7.4 的 pkt-line 对话就在这条 SSH 加密管道的 stdin/stdout 上跑。认证靠 SSH 公钥。

Smart HTTP(https://github.com/user/repo.git

现代最常用。把 pkt-line 塞进 HTTP 请求/响应体:

① 发现阶段(GET):
   GET /user/repo.git/info/refs?service=git-upload-pack
   → 服务器在响应体里用 pkt-line 返回 ref 列表 + capabilities

② 传输阶段(POST):
   POST /user/repo.git/git-upload-pack
   Content-Type: application/x-git-upload-pack-request
   → 请求体是客户端的 want/have(pkt-line)
   → 响应体是服务器的 ACK/NAK + pack(pkt-line)

好处:走 443 端口、穿透防火墙、能用 HTTP 层的认证(token/OAuth)和缓存/CDN。GitHub、GitLab 的 HTTPS 克隆走的就是这个。

历史上还有 dumb HTTP:服务器只是个静态文件服务器,客户端直接下载 .git/info/refsobjects/ 里的文件,服务器端不跑任何 Git 程序("dumb"因此得名)。没有协商、效率低、几乎淘汰。现在说 HTTP 传输默认都指 smart。

本地 / file://

同机或挂载路径,直接读对方 .git 目录,可能连协商都省了直接硬链接对象。最快。

上面描述的是协议 v0/v1。它有个大痛点:每次连接,服务器都要无条件公布全部 ref。GitHub 上一个大仓库有几十万个 ref(PR 分支、tag…),你只想 fetch 一个 main,却先被灌几十万行 ref 列表——巨大的浪费。

协议 v2(默认已启用,源码 serve.cprotocol-caps.c 等)重新设计成请求-响应式、能力驱动

① 客户端先声明用 v2(SSH 环境变量 / HTTP header: Git-Protocol: version=2)
② 服务器公布"我支持哪些命令(command)和能力",而不是一股脑列 ref
③ 客户端按需发命令:
     command=ls-refs          + 参数(如 ref-prefix refs/heads/main) → 只要匹配的 ref
     command=fetch            → 进入 want/have 协商(和 v1 类似但更灵活)

核心改进:

  • ref 过滤ls-refs 可以带 ref-prefix,客户端说"只告诉我 refs/heads/main",服务器就只回这一条。省掉海量无关 ref。

  • 可扩展:新功能作为新 command/能力加入,老客户端忽略不认识的,向后兼容。

  • 无状态友好:更适合 HTTP 这种一问一答的无状态传输。

日常你几乎无感,但 git fetch origin main 在大仓库上快得多,很大程度是 v2 的功劳。可以强制看协议版本:

$ GIT_TRACE_PACKET=1 git -c protocol.version=2 ls-remote origin main 2>&1 | head*# 会看到 command=ls-refs、ref-prefix 这类 v2 特有的包*

fetch/push 传完对象后,要更新本地/远程的 ref。哪个远程 ref 对应哪个本地 ref,由 refspec 定义。它就是 .git/config 那行:

fetch = +refs/heads/*:refs/remotes/origin/*
        │└──┬──┘└───┬───────┘
        │   远程(源)       本地(目的)
        └ "+" 表示允许非快进强制更新

含义:把远程的 refs/heads/main 存到本地 refs/remotes/origin/main。这就是"origin/main 是远程 main 的本地快照"的落地机制。push 的 refspec 方向相反:git push origin main:main = 把本地 main 推到远程 main。

$ git fetch origin main:refs/remotes/origin/main   *# 显式写 refspec*
$ git push origin HEAD:refs/heads/feature          *# 把当前 HEAD 推成远程的 feature 分支*
$ git push origin :refs/heads/old                   *# 源为空 = 删除远程的 old 分支*

理解 refspec,就理解了 git fetch/push 那些"简写"背后其实都是一次 远程ref:本地ref 的映射。

哈希唯一代表内容。但密码学界早已宣布 SHA-1 不再安全。

先说清楚 Git 的完整性保证强在哪。回忆对象图:上层对象的哈希包含下层对象的哈希,层层嵌套:

commit 哈希  = f(tree 哈希, parent 哈希, author, ...)
tree   哈希  = f(每个条目的 mode+名字+子对象哈希)
blob   哈希  = f(文件内容)

这构成一条 Merkle 树 / 哈希链(和区块链是同一种结构)。它带来一个极强的性质:

一个 commit 的哈希,密码学地"钉死"了从它可达的每一个字节。

改动历史里任何一个文件的任何一个字节,会导致:blob 哈希变 → 包含它的 tree 哈希变 → 该 commit 哈希变 → 所有后代 commit 哈希全变。你无法在不改变 commit 哈希的前提下,篡改它的任何历史内容。

这就是为什么:

  • 你 clone 下来的仓库,只要顶端 commit 哈希和你信任的值一致,整段历史就都没被动过手脚——不用逐个文件校验。

  • 传输过程中任何比特翻转、任何中间人篡改,都会导致哈希对不上,git 立刻报错。

  • 附注标签的 GPG 签名(第1章)签的是 tag 对象哈希,因此签一个哈希 = 签了整个可达历史

git fsck 就是全面校验这条信任链:

$ git fsck --full
*# 它会:*
*#  - 重新计算每个对象的哈希,核对是否等于它的存储路径/idx 里的名字(内容有没有烂)*
*#  - 检查每个引用(tree→blob、commit→tree/parent)指向的对象是否存在(有没有断链)*
*#  - 报告悬空对象*

只要哈希函数抗碰撞,这套体系就固若金汤。 问题恰恰出在这个前提上——SHA-1 的抗碰撞性已经破了。

2017 年 Google 公布了 SHAttered 攻击:造出了两个内容不同、SHA-1 却相同的 PDF 文件。2020 年的 SHA-1 is a Shambles 进一步实现了更危险的选择前缀碰撞 (chosen-prefix collision),成本降到几万美元级别。

碰撞 (collision) 对内容寻址系统是致命威胁。设想一个投毒场景:

  1. 攻击者精心构造两个 blob:good(一段人畜无害的代码)和 evil(含后门的代码),使得 SHA1(good) == SHA1(evil)

  2. good 提交进开源项目,大家 review 通过。

  3. 由于两者哈希相同,在某些环节把对象库里的 good 换成 evil——因为哈希一样,Git 的内容寻址认为它们是同一个对象,校验能通过。

  4. 后续 clone 的人可能拿到 evil,而所有哈希、签名校验都显示"完好无损"。

内容寻址的根基——"哈希相同 ⟹ 内容相同"——被碰撞直接击穿。 这不是理论洁癖,是真实的供应链攻击面。

Git 没有立刻换算法,而是先上了一道巧妙的即时防御:把普通 SHA-1 换成 sha1dc(SHA-1 with DetectCollision,源码在 sha1dc/ 目录,由 Marc Stevens 和 Dan Shumow 提供)。

它的天才之处:sha1dc 算出来的哈希,对正常数据和标准 SHA-1 完全一致(所以不破坏任何现有仓库、现有哈希),但它在计算过程中顺便检测"这段数据是不是正在被用于一次已知类型的碰撞攻击"

原理(概念层面):SHAttered 那类碰撞攻击依赖 SHA-1 压缩函数里非常特定的差分路径 (disturbance vector)。sha1dc 在跑 SHA-1 的每一轮时,额外检查有没有出现这些攻击特有的中间状态迹象。一旦发现,就判定"这是碰撞攻击的一半",直接拒绝、报错退出

$ git hash-object shattered-1.pdf
fatal: SHA-1 collision detected on <哈希>: ...

代价:比裸 SHA-1 慢一些(多做检测运算),但安全性大幅提升——已知的 SHAttered/Shambles 类攻击构造的对象,Git 会当场拒收,投毒无法得逞。

源码里,哈希算法是抽象出来的(hash.hstruct git_hash_algo),Git 默认把 SHA-1 的实现指向 sha1dc:

*/* hash.h —— 哈希算法被抽象成一张函数表,便于替换实现/新增算法 */*
struct git_hash_algo {const char *name;              *// "sha1" / "sha256"*
    uint32_t format_id;            *// 用于识别的魔数*
    size_t rawsz;                  *// 原始哈希字节数:SHA-1=20, SHA-256=32*
    size_t hexsz;                  *// 十六进制字符数:40 / 64*
    void (*init_fn)(struct git_hash_ctx *);      *// 初始化*
    void (*update_fn)(struct git_hash_ctx *, const void *, size_t);  *// 喂数据*
    void (*final_oid_fn)(struct object_id *, struct git_hash_ctx *); *// 收尾出哈希*
    ...
};
*/* 默认 SHA-1 的实现被指向 sha1dc(带碰撞检测),而非裸 SHA-1 */*

Git 从 2018 年起支持用 SHA-256 作为对象哈希(git init --object-format=sha256)。这是治本。

对象模型、引用、pack、协议——一切机制都不变,只是把哈希函数从 20 字节的 SHA-1 换成 32 字节的 SHA-256:

SHA-1 仓库        SHA-256 仓库
哈希原始长度    20 字节           32 字节
哈希十六进制    40 字符           64 字符
对象计算       SHA1("blob…")    SHA256("blob…")
tree 里的哈希   20 字节二进制     32 字节二进制

git_hash_algo 表里 rawsz(20→32)、hexsz(40→64)就是为此准备的。第1章讲的 struct object_id 其实早就为大哈希预留了空间:

*/* hash.h */*#define GIT_MAX_RAWSZ 32          *// 按最大的(SHA-256=32)预留,SHA-1 只用前 20*struct object_id {unsigned char hash[GIT_MAX_RAWSZ];   *// 定长 32 字节,SHA-1 用前 20 字节*int algo;                            *// 这个哈希是用哪个算法算的(sha1/sha256)*
};

object_id 里带 algo 字段,正是为了在过渡期里区分"这是个 SHA-1 哈希还是 SHA-256 哈希"。

$ git init --object-format=sha256 /tmp/repo256
$ cd /tmp/repo256
$ echo hi | git hash-object --stdin
8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4   *# 64 位十六进制!*

技术上换哈希不难,难在生态

  • 全世界现存的仓库、GitHub/GitLab 服务端、CI、几乎所有 Git 托管,目前主流仍是 SHA-1。一个纯 SHA-256 仓库无法直接和它们互推互拉——哈希长度都不一样。

  • 一个仓库的全部历史哈希都会改变,等于是另一个宇宙的仓库。

Git 的设计里有 "interop"(互操作)方案:一个仓库同时维护 SHA-1 和 SHA-256 两套哈希,并存一张双向映射表(哪个 SHA-1 对象对应哪个 SHA-256 对象),从而能和两个世界对话、逐步过渡。但这套互操作在写这份文档时尚未完全落地到主流生产环境,所以现实中 SHA-256 仓库还比较少见,多用于新建的、对安全性要求高的独立项目。

目标:用不到 200 行 Python,从零实现 hash-objectcat-filewrite-treecommit-tree,并让我们自己造的对象能被真正的 git 读取。当理论能落成可运行的代码,你就真的懂了。

我们要实现的核心洞察是流水线:

内容 → 拼 "type len\0内容" → SHA-1 得 key → zlib 压缩 → 存 .git/objects/xx/yy

只要我们严格按这个格式写,真正的 git 就能读我们造的对象——这是最硬的验收标准。反过来,我们也能读 git 造的对象。两个方向都通,说明我们对格式的理解一字不差。

先准备一个真 git 仓库当靶场:

$ mkdir /tmp/minigit && cd /tmp/minigit
$ git init                 *# 用真 git 建 .git,我们往里写对象*

把下面的代码存成 minigit.py 放在 /tmp/minigit 里。

hash-object:写一个对象

*#!/usr/bin/env python3# minigit.py —— 用最少的代码复现 git 的核心对象操作*
import sys, os, zlib, hashlib, struct, time

OBJ_DIR = ".git/objects"def hash_object(data: bytes, obj_type: str, write: bool = True) -> str:
    """对应第1章 1.2/1.3:算哈希 + (可选)zlib 压缩落盘。
       data     : 对象正文(不含头部)
       obj_type : "blob" / "tree" / "commit" / "tag"
       返回      : 40 位十六进制哈希(对象的 key)
    """*# ① 拼头部: "blob 13\0"  —— 注意 \0 是一个真正的 NUL 字节*
    *#    对应 C 源码 format_object_header 里的 "%s %d" + 结尾 NUL*
    header = f"{obj_type} {len(data)}".encode() + b"\x00"
    *# ② 待哈希内容 = 头部 + 正文;对它算 SHA-1 就是这个对象的 key*
    *#    对应 C 源码 hash_object_body:先 update(header) 再 update(data)*
    store = header + data
    oid = hashlib.sha1(store).hexdigest()   *# 40 位十六进制*
    *# ③ 落盘:注意是把"未压缩的 store"再 zlib 压缩后写文件*
    *#    路径 = objects/前2位/后38位,对应 loose_object_path*
    if write:
        path = os.path.join(OBJ_DIR, oid[:2], oid[2:])
        if not os.path.exists(path):                       *# 已存在就不重复写(内容寻址天然去重)*
            os.makedirs(os.path.dirname(path), exist_ok=True)
            *# 真 git 会先写临时文件再原子 rename;这里从简直接写*with open(path, "wb") as f:
                f.write(zlib.compress(store))              *# 存的是压缩态*return oid
    return oid

试一试,和真 git 对比:

$ python3 -c 'import minigit; print(minigit.hash_object(b"test content\n", "blob"))'
d670460b4b4aece5915caf5c68d12f560a9fe3e4
$ printf 'test content\n' | git hash-object --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4      *# ✓ 完全一致!*
*# 而且我们真的写进了 .git/objects,真 git 能读出来:*
$ git cat-file -p d670460b
test content

cat-file:读一个对象

反过程:读文件 → zlib 解压 → 按 \0 切出头部和正文。对应第1章"解压出来看看"那步:

def read_object(oid: str):
    """对应 git cat-file:读回一个对象。返回 (类型, 正文bytes)。"""
    path = os.path.join(OBJ_DIR, oid[:2], oid[2:])
    store = zlib.decompress(open(path, "rb").read())   *# 解压回 "blob 13\0test content\n"*
    *# 按第一个 NUL 切开:前面是 "blob 13",后面是正文*
    nul = store.index(b"\x00")
    header = store[:nul].decode()                       *# "blob 13"*
    obj_type, size = header.split()                     *# "blob", "13"*
    data = store[nul + 1:]                              *# 正文*
    assert len(data) == int(size), "size 对不上,对象损坏"  *# git fsck 也会做这种校验*
    return obj_type, data
*# 读一个真 git 造的对象试试(先用真 git 造)*
$ echo hello | git hash-object -w --stdin
ce013625030ba8dba906f756967f9e9ca394464a
$ python3 -c 'import minigit; print(minigit.read_object("ce013625030ba8dba906f756967f9e9ca394464a"))'
('blob', b'hello\n')                           *# ✓ 我们读懂了 git 造的对象*

双向都通了:我们写的 git 能读,git 写的我们能读。格式理解 100% 正确。


write-tree:把一个目录变成 tree 对象

这是 tree 格式的实现。tree 的正文 = 每个条目 mode 名字\0 20字节二进制哈希 拼接,按名字排序

def write_tree(dir_path: str = ".") -> str:
    """对应 git write-tree:把 dir_path 递归地写成 tree 对象,返回 tree 的哈希。
       对应第1章 1.4 的 tree 字节格式 + 1.5 的递归结构。"""
    entries = []  *# 每项: (名字, mode字符串, 20字节二进制哈希)*
    for name in sorted(os.listdir(dir_path)):          *# ★ 必须排序:tree 条目有序,保证确定性*
        if name == ".git":                             *# 跳过 .git 自己*
            continue
        full = os.path.join(dir_path, name)

        if os.path.isdir(full):
            *# 子目录 → 递归造子 tree,mode 是 "40000"(注意无前导 0)*
            oid = write_tree(full)
            mode = "40000"
        else:
            *# 普通文件 → 造 blob,mode 100755(可执行)/100644(普通)*
            data = open(full, "rb").read()
            oid = hash_object(data, "blob")
            mode = "100755" if os.access(full, os.X_OK) else "100644"*# 把 40 位十六进制哈希转成 20 字节二进制(tree 里存二进制哈希,省空间)*
        raw = bytes.fromhex(oid)
        *# 一个条目: b"100644 a.txt\x00" + 20字节哈希*
        entries.append(f"{mode} {name}".encode() + b"\x00" + raw)

    tree_data = b"".join(entries)                      *# 拼成 tree 正文*
    return hash_object(tree_data, "tree")              *# 写成 tree 对象*
$ cd /tmp/minigit
$ echo 'test content' > a.txt
$ mkdir src && echo 'package main' > src/main.go
$ python3 -c 'import minigit; print(minigit.write_tree("."))'
<我们算出的 tree 哈希>
*# 让真 git 验证这棵 tree —— 它能完整解析我们造的多级 tree:*
$ git cat-file -p <我们算出的 tree 哈希>
100644 blob d670460b...   a.txt
040000 tree <子tree哈希>   src        ← 递归的子 tree 也被 git 正确识别
$ git cat-file -p <子tree哈希>
100644 blob <哈希>         main.go

我们手工造的、含子目录的多级 tree,被真 git 完整解析出来了——第1章 1.5 那张"commit→tree→子tree→blob"的图,我们亲手用代码搭出来了。

commit-tree:把 tree 封成一次提交

commit 格式的实现。commit 正文 = 几行元数据 + 空行 + 提交信息:

def commit_tree(tree_oid: str, message: str, parent: str = None,
                author: str = "Lab <lab@example.com>") -> str:
    """对应 git commit-tree:把一棵 tree 封成 commit 对象,返回 commit 哈希。
       对应第1章 1.4 的 commit 文本格式。"""
    ts = int(time.time())
    tz = "+0000"
    lines = [f"tree {tree_oid}"]                       *# 指向快照的根 tree*if parent:
        lines.append(f"parent {parent}")               *# 父提交;初始提交无此行*
    lines.append(f"author {author} {ts} {tz}")
    lines.append(f"committer {author} {ts} {tz}")
    lines.append("")                                   *# ★ 空行分隔元数据和正文*
    lines.append(message)
    commit_data = ("\n".join(lines) + "\n").encode()
    return hash_object(commit_data, "commit")

串起来:一次完整的、纯手工的提交

现在把前面的所有代码拼成一个"没有用 git add/git commit、纯靠我们自己代码"的完整提交流程,最后用真 git 把分支指过去(第2章:分支就是写个 ref 文件),让 git log 认它:

def main_commit(message: str):
    """纯手工完成一次提交:目录→tree→commit→移动分支指针。串起全书。"""
    *# 1) 读当前分支的上一个提交当 parent(读 HEAD → 读 refs/heads/xxx)*
    parent = None
    head = open(".git/HEAD").read().strip()            *# "ref: refs/heads/main"*
    if head.startswith("ref: "):
        ref_path = os.path.join(".git", head[5:])
        if os.path.exists(ref_path):
            parent = open(ref_path).read().strip()      *# 上一个 commit 哈希*
        else:
            parent = head                                   *# detached HEAD*
        *# 2) 目录 → tree(递归快照)*
        tree = write_tree(".")
        *# 3) tree → commit*
        commit = commit_tree(tree, message, parent)
        *# 4) 移动分支指针:把新 commit 哈希写进 refs/heads/main*
        *#    这就是 git commit 最后一步干的事——改那个装哈希的小文件*
        if head.startswith("ref: "):
            ref_path = os.path.join(".git", head[5:])
            os.makedirs(os.path.dirname(ref_path), exist_ok=True)
            open(ref_path, "w").write(commit + "\n")
        print(f"committed {commit} (tree {tree}, parent {parent})")
        return commit

if __name__ == "__main__":
    *# 用法: python3 minigit.py commit "my message"*if sys.argv[1] == "commit":
        main_commit(sys.argv[2])

跑起来,然后让真 git 检阅我们的成果:

$ cd /tmp/minigit
$ echo 'test content' > a.txt
$ mkdir -p src && echo 'package main' > src/main.go

$ python3 minigit.py commit "hand-made commit"
committed 7f3a... (tree 2f7e..., parent None)

*# 没用 git add/commit,但真 git 认这次提交!*
$ git log --oneline
7f3a... hand-made commit                *# ← git log 认出了我们手工造的 commit*

$ git cat-file -p HEAD                   *# 我们造的 commit,格式完全合法*
tree 2f7e...
author Lab <lab@example.com> 1721... +0000
committer Lab <lab@example.com> 1721... +0000

hand-made commit

$ git status                             *# git 拿我们的 tree 和工作区比,一致 → 干净*
nothing to commit, working tree clean

$ git fsck                               *# 校验我们造的整条对象链,无损*

我们绕开了 git 的所有 porcelain 命令,纯靠"拼字节 + SHA-1 + zlib + 写 ref 文件",造出了真 git 完全认可的一次提交。 这就是终极验收:如果你对每个字节的理解有一丁点偏差,git log/git fsck 立刻会翻脸。它们没翻脸,说明你真的懂了。

这就是 git 源码解读的全部内容了,这也是我在网站上写的第一份技术文档,感谢你的阅读。

Back to Blog