Skip to content

Git 底层原理

Git 的本质是一个文件系统,

初始化 git 仓库

使用命令 git init 会创建一个 .git 目录,这个目录就是 git仓库 ,用来保存版本信息。 对.git 使用tree命令,显示的Git仓库的目录结构:

.git
├── HEAD
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── ......
├── info
│   └── exclude
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags
  • config git 配置信息,包括 用户别名远程仓库 等信息,此时还是初始化仓库,只有 git 的 core 配置

  • description git 仓库的描述信息,用于 gitweb 等服务展示 git 仓库的信息,比如 github

  • hooks git 钩子函数,存放了各种 sample 文件,比如 commit 提交前,会执行 pre-commmit.sample 钩子

  • info 保存一些全局排除文件,用于指定应该被 git 忽略的目录和文件,类似 .gitignore

  • HEAD 是一个指针,指向当前检出的分支,也叫符号引用(Symbolic Reference),其中内容是分支或者指向一个 commit对象 引用(SHA-1 值)

  • objects 保存了 git对象数据库 ,存储所有的数据内容,包括三类对象blob、tree、commit

    • info info 目录包含了一些额外的信息文件,一般用于性能优化
    • pack pack 目录包含了 git对象 的压缩文件,用于节省存储空间和提高性能
  • refs 保存 branch 和 tag 对应的 commit

    • heads 目录下所有文件都是 分支 ,如 .git/refs/heads/feat/sth 也是如此,它的分支名称为 feat/sth ,而文件内容为 SHA-1 值,是一个 commit对象引用
    • tags 目录下所有文件都是 标签 ,和上面的 分支 一样,文件内容为 SHA-1 值,是一个 commit对象引用

Git 三大对象

git 三大对象都存储在 .git/objects 目录下,不同 对象 类型存储的信息不同,但都是使用一个 SHA-1 哈希值来引用 Git 对象,实际上就是一个 kv数据库key 值就是 SHA-1 值,也就是引用, value 值就是 Git对象 的内容,可以通过 key(引用) 来获得 git对象 的内容(值)

提示

实际上还有第 4 个对象,就是Tag 标签,情况比较特殊,会在对应章节讲到这个

  • blob object blob 对象
  • tree object tree 对象
  • commit object ommit 对象

查看对象:

  • git cat-file
    • git cat-file -t <hash> 查看对象类型
    • git cat-file -p <hash> 查看对象内容
    • git cat-file -p <branch>^{tree} 查看 branch 引用 commit对象 引用 tree对象 的内容

blob 二进制文件对象

blob对象 是 Git 设置的 header 加源文件内容 content 然后生成的 SHA-1 哈希值,然后使用哈希值的 前2位 作为文件夹名称,哈希值的 后38位 作为文件名称,然后将 header+content zlib 压缩后的二进制数据作为文件内容存储在 .git/objects 目录下,这个 blob对象 也可以使用这个 SHA-1哈希值 来指代(引用)。

Node.js 简单实现:

js
// blob中存储的内容是header+content组成的,content就是源文件内容
const crypto = require("crypto");
const fs = require("fs");
const zlib = require("zlib");
// 源文件内容
const content = "hello world";
// header格式是git对象类型+空格+源文件内容字符长度+"\u0000"
const header = "blob" + " " + content.length + "\u0000";
// 这个是存储到git对象数据中的内容,也是生成sha1值的内容
const storeContent = header + content;
const sha1 = crypto.createHash("sha1").update(storeContent).digest("hex");
zlib.deflate(Buffer.from(storeContent),(errbuffer) => {
  if (err) {
    console.log("压缩失败: ",err);
  }
  const dir = ".git/objects/" + sha1.slice(02);
  const filePath = dir + "/" + sha1.slice(2);
  !fs.existsSync(dir) && fs.mkdirSync(dir);
  !fs.existsSync(filePath) && fs.writeFileSync(filePath,buffer);
  // 和 git hash-object一样,返回SHA1值
  console.log(sha1);
});

通过 git cat-file(返回对象的内容或类型和大小信息) 命令验证结果:

sh
git cat-file -t 95d09f2b10159347eece71399a7e2e907ea3df4f  ## -t 打印类型
echo "----"
git cat-file -p 95d09f2b10159347eece71399a7e2e907ea3df4f  ## -p 打印文件内容

输出结果:

blob
----
hello world%

实现方式没有问题,Git 成功识别内容,验证完成后清理删除该 blob 对象。

提示

tree 对象和 commit 对象的实现方式也和 blob 对象一样,除了 header 部分分别为各自的类型以及源内容不同外,实现方式是一致的。

Git 可以通过命令操作 blob 对象:

  • echo 'Hello World' | git hash-object --stdinHello World 作为源文件内容,返回该文件的 SHA-1 哈希值,不保存到 git 对象数据库中。
  • echo 'Hello World' | git hash-object -w --stdinHello World 作为源文件内容,返回该文件的 SHA-1 哈希值,生成 blob 对象存储到 git数据库 中,如果数据库中已经存在这个 blob对象 ,则 git 会复用这个 blob对象
  • git hash-object -w README.mdREADME.md 生成 blob对象 存入到 git对象数据库

通过 git hash-object 命令写入到 git对象数据库 中的 blob 对象并没有被 引用 ,可以将 blob 对象写入 暂存区 ,然后生成 暂存区tree对象引用blob对象 ,最后通过提交 commit对象 来生成快照间接引用到 blob 对象

提示

可以通过 git fsck --lost-found 命令来查看有哪些 git对象 没有被引用

tree 树对象

tree 对象目录项, tree 存储的是 暂存区中目录以及文件信息(引用blob对象) 的信息,任意层级的文件都可以生成 blob对象 同级存储在 .git/objects 目录下,而目录没办法存储为 blob对象 ,因为它不仅包含了目录名称还包含了子文件,所以使用 tree对象 记录目录下子文件的 blob对象 的哈希值,如果目录下还有子目录,则 tree对象 还得记录子目录的 tree对象 ,形成递归引用。

tree对象blob对象 以一样的 目录格式 存储在 .git/objects 目录下

操作 tree对象

  • git write-tree暂存区 的目录和文件结构生成 tree对象 并写入`git 对象数据库(.git/objects 目录)
  • git read-tree [--prefix=<dir>] <tree-hash>tree对象 读取写入到 暂存区 ,并且以 prefix 的值作为暂存区文件的 路径前缀 ,如果 tree对象 中还包含子目录 tree对象 ,则会递归下去,并且拼接路径

commit 对象

commit对象 就是提交的快照,包含一个 tree对象Author 作者信息,committe 提交者信息,提交时间 ,以及 父commit对象 ,形成一个 有向无环图数据结构(DAG)

提示

Git 的 DAG 数据结构是 Git 版本控制系统的核心之一,它支持版本控制、分支和合并等功能

tree对象blob对象 以一样的 目录格式 存储在 .git/objects 目录下

操作 commit对象

  • git commit-tree <tree-hash> [-p <commit-hash>] -m <message>
    • -p 可选,表示提交后生成的 commit对象父commit对象 ,如果是第一次提交,则没有父 commit 对象
    • -m 提交信息,用来描述这个提交,和 git commit -m <message> 的内容是同一个作用

工作区

工作区其实和 git仓库 没有关联,它单纯指的就是存在于磁盘上的项目文件,也是就不包含 git仓库(.git目录) 的项目根目录

暂存区

暂存区是 git仓库 的概念,实际上就是 .git/index 文件,添加到暂存区的文件都会被添加上状态信息

当用户 git checkout 检出某个分支或者 commit对象 后,等于将 commit对象 的文件快照恢复到暂存区了,暂存区数据库中存储的文件就是从 commit对象 中递归查询出来的,当使用 git status 或者其它编辑器的 git工具 时,会和工作区的文件进行 diff 比对,计算出哪些文件属于 changes 状态(包含 Modified,Deleted,Renamed)

提示

可以通过 git status 查看暂存区各文件和工作区之间 diff 后的状态

暂存区中的文件有以下状态:

  • untracked 新增
  • staged 已暂存
  • commited 已提交
  • deleted 已删除
  • modified 已修改
  • renamed 已重命名

暂存区也可以视作一个数据库,存储了当前 分支commit对象 中引用的所有文件内容及状态,以 文件相对路径 作为一条记录的主键,还包含 文件权限类型 ,还有 blob对象 的引用(SHA-1 值),文件在暂存区的状态等字段

暂存区是不包含 tree对象 的, tree对象 的生成都是通过 git write-tree暂存区 的文件和目录关系写入 git对象数据库

也可以手动将一个已经存在的 tree对象 写入暂存区 ,通过 git read-tree --prefix=dir <hash> 来写入暂存区,会将 tree 对象下的文件拼接上 prefix 也就是 dir/ 的路径前缀添加到 暂存区,需要注意的是 暂存区 是不会存在 tree对象 的。

查询暂存区

  • git ls-files 显示索引和工作目录树中的文件信息
    • git ls-files --stage 简写-s, 在输出中显示暂存内容的模式位、对象名称和阶段编号。
  • git status 查看暂存区文件状态
sh
git ls-files --stage

输出结果(结果源自本小章节结束后)

100644 6537fabafe8f5041b46e388d19119b9d61b574c2 0       .gitignore
100644 2bf97014b7c8e30ed455b03d4c1148aaec28c70c 0       src/index.js

每条数据的每列分别代表 文件权限类型blob对象引用暂存区文件状态文件路径(名称)

文件权限类型 用来表示文件是否是普通文件,可执行文件,符号链接等

写入暂存区

可以通过 git update-index 命令来写入暂存区,写入之前还要做一些准备工作

在当前目录下新建一个 .gitignore 文件,然后添加内容 **/.vscode ,表示将 .vscode 目录排除在 git 仓库范围之外.

提示

.vscode 目录通常包含一些 vscode编辑器 的配置信息,对于使用 vscode编辑器 的用户来说,编辑器配置信息通常不是项目中的主要内容,可以让 git 忽略这个目录。

即使不是使用 vscode编辑器 也无影响,只相当于新增了一个 .gitignore 文本文件而已

使用 git hash-object 命令将 .gitignore 文件生成 blob对象 写入 git对象数据库(.git/objects目录下)

sh
# 生成blob对象,-w表示写入git对象数据库,返回blob对象引用(SHA-1哈希值)
git hash-object -w .gitignore

输出结果:

6537fabafe8f5041b46e388d19119b9d61b574c2

如图所示,这会在 .git/objects 目录下生成一个 .gitignore 文件的 blob对象 ,文件夹名 65 加上文件名 37fabafe8f5041b46e388d19119b9d61b574c2 就是 .gitignore 文件 SHA-1 哈希值,这个 blob文件 保存的也是 .gitignore 文件 zlib 压缩后的二进制数据

提示

可以通过 git cat-file -t <hash> 命令来查看 git对象数据库对象 的类型

sh
# hash值也可以使用短位简写,只要能唯一标识即可
git cat-file -t 6537fabafe8f5041b46e388d19119b9d61b574c2

输出结果:

blob

也可以通过 git cat-file -p <hash> 命令来查看 对象数据库 中对象存储的内容

sh
git cat-file -p 6537fabafe8f5041b46e388d19119b9d61b574c2

输出结果:

**/.vscode%

这个输出结果就是 .gitignore 文件中的内容

使用 git update-index 命令,将 .gitignore 文件添加到暂存区(stage)/索引区(index)

sh
git update-index --add --cacheinfo 100644 \
6537fabafe8f5041b46e388d19119b9d61b574c2 .gitignore

提示

指定的文件权限类型为  100644,表明这是一个普通文件。 其他选择包括 100755 表示一个可执行文件,120000 表示一个符号链接。

git 参考的是 unix 的文件系统,但远没有那么灵活,上述三种模式即是 Git 文件(即数据对象)的所有合法模式(当然,还有其他一些模式,但用于目录项和子模块)

如图所示,这条命令会在 .git 目录下生成一个 index 文件,这个 index 文件就是 索引区(index) ,也叫 暂存区(stage) ,如果已有 index 文件,则只会将命令行中 .gitignore 的数据更新到 index 文件中。

可以使用 git ls-files --stage 命令查询暂存区中存储的内容

sh
git ls-files --stage

输出结果:

100644 6537fabafe8f5041b46e388d19119b9d61b574c2 0       .gitignore

git status 命令也可以查看暂存区中存储的内容

sh
git status

输出结果:

On branch master

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
        new file:   .gitignore

根目录下子目录的文件写入暂存区

在此之前已经写入了项目根目录下的普通文件,接下来尝试一下子目录下的文件是如何写入 Git对象数据库 中的。

在项目根目录下新建 src/index.js ,内容为 console.log("hello world");

sh
# 使用git hash-object命令,将src/index.js文件写入git对象数据库
git hash-object -w src/index.js

返回结果:

6b2b3db0f6520c7b9ef75fa769bed07353446437

可以看见 src/index.js 文件生成 blog对象 保存到了 git对象数据库(.git/objects)

sh
# 更新src/index.js文件到暂存区,blob对象对应的文件需要包含路径信息
git update-index --add --cacheinfo 100644 \
6b2b3db0f6520c7b9ef75fa769bed07353446437 src/index.js
# 查看暂存区
git ls-files --stage

返回结果:

100644 6537fabafe8f5041b46e388d19119b9d61b574c2 0       .gitignore
100644 6b2b3db0f6520c7b9ef75fa769bed07353446437 0       src/index.js

和项目根目录下的文件一样,都是同级写入 Git对象数据库 中,只是存入暂存区时,带有文件路径,引用的 blob对象 和根目录录下的 .gitignore 文件方式是相同的。

更新暂存区

src/index.js 文件中添加一行 console.log('new line')

sh
# 使用git hash-object命令,将src/index.js文件写入git数据库
git hash-object -w src/index.js
# 返回: 2bf97014b7c8e30ed455b03d4c1148aaec28c70c
# 不需要--add参数,将src/index.js更新到index
git update-index --add --cacheinfo 100644 2bf97014b7c8e30ed455b03d4c1148aaec28c70c \
src/index.js
sh
git ls-files --stage

返回结果:

100644 6537fabafe8f5041b46e388d19119b9d61b574c2 0       .gitignore
100644 2bf97014b7c8e30ed455b03d4c1148aaec28c70c 0       src/index.js

发现 6b2b3d 对象并没有被引用,但是会持久存在于 git对象数据库

删于暂存区

没有被 git 追踪的文件被删除,并不会被 git 关注,只有已经追踪过的文件被添加到暂存区或者历史区的文件被删除,才会被 git 处理。

手动删除 src/index.js 文件,然后删除 暂存区src/index.js 文件。

sh
# 从暂存区index中删除src/index.js
git update-index --remove src/index.js
sh
git ls-files --stage

输出结果:

100644 6537fabafe8f5041b46e388d19119b9d61b574c2 0       .gitignore

src/index.js 已经不存在于暂存区了

git add 底层原理

使用 git add . 命令时,git 会扫描工作区所有文件,和暂存区的文件记录进行比对,检查出 changeddeleteduntrackedcommited 状态的文件作为 结果集 ,然后将所有 ChangesUntracked 状态的文件计算 SHA-1 哈希值,并生成 blog对象 保存在 .git/objects 目录下,然后从在暂存区删除结果集中所有 deleted 状态的记录。

  • Untracked 未跟踪文件(新增文件)
    对于新增文件,git 会生成 blob对象 存储到 git对象数据库 中,然后写入暂存区
  • Changes 已跟踪过的文件被修改
    对于已跟踪过的文件被修改,git 会生成新的 blob对象 ,然后更新到暂存区
  • Deleted 被删除的文件
    从暂存区中删除该文件记录

git add . 命令就是对目录下的所有文件进行批量处理,根据文件状态不同,在暂存区中分别 写入更新删除 操作

git add . 命令是以下命令依次批量执行的集合。

  • git hash-object -w <file>
  • git update-index [--add] --cacheinfo 100644 <hash> <filePath>
  • git update-index --remove <filePath>

历史区

历史区是 git仓库 中的概念,它会提交 commit对象 生成快照,所有 commit对象 的累积和就是 历史区 ,而 commit对象 和其它 git对象 一样,也存储于 .git/objects 目录下。

commit对象 会被 分支Branch标签TagHEAD 等指针所引用。

写入历史区

写入历史区之前,先生成暂存区的 tree对象 ,然后再生成 commit对象commit对象 即是快照,生成快照也就等同于写入历史区。

项目初始化后,将项目文件存入暂存区,然后生成暂存区目录的 tree对象 ,记录到 commit 对象中,然后像 链表 一样,一条一条串下去,无论选中 commit树 中的哪一个 commit对象 ,都可以找到 commit对象 中存入的 tree对象 ,然后解析出提交的文件,然后从 commit对象父commit对象引用 溯源,重复找寻 tree对象 ,最终通过对 tree对象 中存储的 blob和tree对象 进行归集合并去重,最终还原出整个 快照 当时的目录结构,以及通过 blob 对象还原快照当时的文件内容。

提示

commit对象 因为 parent 引用而形成的树,使用的是 有向无环图(DAG) 数据结构。

1.生成暂存区目录结构的 tree 对象

sh
# 生成暂存区目录结构的tree对象
git write-tree

输出结果:

9f336abc4960bfb90ac0ce4a862d1d51286cfd05
sh
# 查看tree对象类型
git cat-file -t 9f336abc4960bfb90ac0ce4a862d1d51286cfd05
# 查看tree对象内容
git cat-file -p 9f336abc4960bfb90ac0ce4a862d1d51286cfd05

输出结果:

tree
100644 blob 6537fabafe8f5041b46e388d19119b9d61b574c2    .gitignore

提示

需要注意的是,项目根目录下有嵌套的目录,每一个目录都会生成 tree对象 并且被 父tree对象 引用

git对象数据库 中将删除的 src/index.js 文件复原

由于 src/index.js 并没有 commit提交记录 ,无法从快照中 检出 文件,只能手动指定 blob对象 来恢复

sh
mkdir -p src && git cat-file -p 2bf97014b7c8e30ed455b03d4c1148aaec28c70c > src/index.js
# 或者
mkdir -p src && git show 2bf97014b7c8e30ed455b03d4c1148aaec28c70c > src/index.js

src/index.js生成blob对象

sh
git hash-object -w src/index.js

输出结果:

2bf97014b7c8e30ed455b03d4c1148aaec28c70c

提示

由于 2bf97014b7c8e30ed455b03d4c1148aaec28c70c 这个 blob对象 已经存在于 git对象数据库 ,所以 git 会复用这个 blob 对象,减少重复存储和压缩开销以提高性能

再次生成 tree对象

sh
# 缺少写入暂存区,先按照这个逻辑继续进行
git write-tree

输出结果:

9f336abc4960bfb90ac0ce4a862d1d51286cfd05

发现 tree对象 和之前 一样 ,并没有因为新增 src/index.js 而变化,说明 tree对象 的生成并不是依据 工作区 的文件,而是 暂存区 的文件。

src/index.js 写入暂存区,再次生成 tree对象

sh
# 写入暂存区
git update-index --add --cacheinfo 100644 2bf97014b7c8e30ed455b03d4c1148aaec28c70c src/index.js
# 生成tree对象
git write-tree
ddcb672c0bbf87df55d3f3132b6098c006eb0751

git对象数据库 中多出来 2 个 git对象ddcb67tree对象

sh
git cat-file -p ddcb672c0bbf87df55d3f3132b6098c006eb0751

输出结果:

100644 blob 6537fabafe8f5041b46e388d19119b9d61b574c2    .gitignore
040000 tree 2e52997e8f80eb0b8410bb5ddd2fca829d26015c    src

查看 2e5299 对象

sh
git cat-file -p 2e52997e8f80eb0b8410bb5ddd2fca829d26015c

输出结果:

100644 blob 2bf97014b7c8e30ed455b03d4c1148aaec28c70c    index.js

结论

每一个 tree 对象都表示一个目录,记录了目录下的文件和对应的 blob对象 以及它子目录的 tree对象 ,形成递归引用

2.生成 commit 对象

使用 git commit-tree 命令,生成 commit对象 ,命令中需要提供 2 个参数, tree对象commit message ,另外 commit对象 中还包含 authorcommitter

sh
echo "first commit" | git commit-tree ddcb672c0bbf87df55d3f3132b6098c006eb0751
# 或者 or
git commit-tree ddcb672c0bbf87df55d3f3132b6098c006eb0751 -m "first commit"

输出结果:

86ed78f015c959d6cbebdeba112cf9ff2e317d9b

查看 commit对象 内容

sh
git cat-file -p 86ed78f015c959d6cbebdeba112cf9ff2e317d9b

输出结果:

tree ddcb672c0bbf87df55d3f3132b6098c006eb0751
author codenoy <**@**.com> 1713664672 +0800
committer codenoy <**@**.com> 1713664672 +0800

first commit

提示

AuthorCommitter 可以通过 git config 命令给当前 git仓库 配置,也可以直接继承 git全局

也可以使用 git log 查看 commit对象 内容

sh
# --stat 选项表示显示每次提交的不同之处
git log --stat 86ed78f015c959d6cbebdeba112cf9ff2e317d9b

输出结果:

commit 86ed78f015c959d6cbebdeba112cf9ff2e317d9b
Author: codenoy <**@**.com>
Date:   Sun Apr 21 09:57:52 2024 +0800

   first commit

 .gitignore   | 1 +
 src/index.js | 2 ++
 2 files changed,3 insertions(+)

查看 commit 提交记录

可以直接查看commit对象

sh
git log 86ed78f015c959d6cbebdeba112cf9ff2e317d9b

输出结果:

commit 86ed78f015c959d6cbebdeba112cf9ff2e317d9b
Author: codenoy <**@**.com>
Date:   Sun Apr 21 09:57:52 2024 +0800

    first commit

也可以直接查看 分支 引用 commit对象

sh
git log

输出结果:

fatal: your current branch 'master' does not have any commits yet

git log 命令查看的是当前分支的提交记录,当前分支处于 master ,而 master 分支没有指向任何 commit 对象,所以需要将 master 分支指向这个 commit对象

sh
# git并不提倡直接编辑分支引用文件
echo "86ed78f015c959d6cbebdeba112cf9ff2e317d9b" > .git/refs/heads/master
# 或者使用git update-ref命令(git推荐)
git update-ref refs/heads/master 86ed78f015c959d6cbebdeba112cf9ff2e317d9b

提醒

git update-ref 命令执行完成后 git 会清空暂存区,编辑器的 git工具 会检测到分支变化,并且显示 暂存区 被清理,但是将 commit对象SAH-1 哈希值写入 .git/refs/heads/master 文件,编辑器的 git工具 可能不会检测到变化,使得看似 暂存区 没有被清理,只需要手动刷新即可

完成了一次 commit提交 ,将 commit对象 写入了 git对象仓库(.git/objects) 中,并且将 master分支 指针指向了这个 commit对象HEAD 指针也自动跟随 master分支 指针指向了这个 commit对象

更新历史区

新增 src/other.js ,内容为 console.log("other")

sh
# 生成blob对象
git hash-object -w src/other.js
# 输出: 78a616c62424c36ddebe168c1f9ef0fa90d8056b
# 写入暂存区
git update-index --add --cacheinfo 100644 \
78a616c62424c36ddebe168c1f9ef0fa90d8056b src/other.js
# 写入tree对象到git对象数据库
git write-tree
# 输出: 3eba80b38ce95e089307a771b52f6134b49e9327
# 写入commit对象到git对象数据库,注意需要通过-p将父commit对象引用加入到本次commit对象中
git commit-tree 3eba80b38ce95e089307a771b52f6134b49e9327 \
-m "second commit" -p 86ed78f015c959d6cbebdeba112cf9ff2e317d9b
# 输出: 7653394fc31da83ad9acaf68f6e488a630257460
# 更新分支指针
git update-ref refs/heads/master 7653394fc31da83ad9acaf68f6e488a630257460

这样第二个 commit对象 提交就完成了。

这里有一个逻辑需要提一下,当使用 git commit 命令提交的时候,表象上移动的是 HEAD 指针,实际上是当 HEAD 指针游离的时候,git commit 底层会使用 git symbolic-ref HEAD <commit-hash> 来移动 HEAD 指针,如果 HEAD指针 指向 分支 ,则使用 git update-ref <branch> <commit-hash> 来移动分支到新提交的 commit对象 引用,由于 HEAD 分支也指向 分支 ,就会造成使用 git commit 提交时,移动的是 HEAD 指针` 的现象,实际上并不是如此。

另外如果本地有远程分支且的话, git commit 还会将本地的远程分支的指针跟随本地分支的指针一起移动(更新)。

查询历史区(git log)

使用 git log 查看当前 分支Branchcommit对象 历史

sh
git log

输出结果:

commit 7653394fc31da83ad9acaf68f6e488a630257460 (HEAD -> master)
Author: codenoy <**@**.com>
Date:   Mon Apr 22 00:25:14 2024 +0800

    second commit

commit 86ed78f015c959d6cbebdeba112cf9ff2e317d9b
Author: codenoy <**@**.com>
Date:   Sun Apr 21 09:57:52 2024 +0800

    first commit

git log 的运行过程

  • 查看 HEAD 指针对应的 分支 ,当前是 master 分支
  • 查看 master 指针指向的 commit对象 ,当前是 7653394fc31da83ad9acaf68f6e488a630257460
  • 查找 commit对象 的父节点
  • 依次递归查找所有

可以查看 分支 指向的 commit对象tree对象 内容

sh
git cat-file -p master^{tree}

输出结果:

100644 blob 6537fabafe8f5041b46e388d19119b9d61b574c2    .gitignore
040000 tree 19bf8dbf4fd152a495c7a87d72116abf4ecf8db5    src

git commit 底层原理

git commit 命令就是一个 git命令 的集合,它会依次执行以下命令,将暂存区的内容写入历史区:

  • git write-tree

    生成暂存区存储目录文件的 tree对象

  • git commit-tree <hash> [-p <commit-hash>] [-m <message>]

    生成 commit对象 ,并将 HEAD指针 指向的 commit对象 作为父对象,如果 HEAD指针 指向一个分支,则将分支指向的 commit对象 作为父对象

  • git update-refs refs/heads/<branch> <commit-hash>git symbolic-refs HEAD <commit-hash>

    HEAD指针 游离时,只更新 HEAD指针 引用到最新的 commit对象 ,如果 HEAD指针 指向分支,则只更新分支的引用到最新的 commit对象 上, HEAD指针 因为指向分支,逻辑上也等于更新了引用

  • git reset <commit-hash>

    将暂存区的内容恢复到当前提交的 commit对象 的状态

  • git update-refs refs/remotes/<remote>/<branch> <commit-hash>

    将远程分支仓库下的同名分支的引用也指向最新的 commit对象 引用

以上命令集合依次执行构成了一次 git commit 提交,实际上 git commit 命令并没有使用 git reset 命令处理暂存区,而是在 git commit 命令里处理了暂存区,以至于想要用底层 Git 命令实现 git commit 特性,只好使用 git reset 命令来模拟

Git 引用

Git引用 是指引用 commit对象分支指针(branch)标签指针(Tag)HEAD指针

Branch 分支

分支指针 位于 .git/refs/heads/ 下的所有文件,每一个文件都代表一个 分支 ,每个 分支 文件的内容都是一个 commit对象 的引用 (SHA-1哈希值)

分支的 Git引用 是动态的可修改的,通常有以下命令可以改变分支的指针

  • git commit 当前分支指针移向新创建的快照 commit对象
  • git pull 当前分支与远程分支合并后,指针指向新创建的快照 commit对象
  • git reset <commit-hash> 当前分支指针重置为指定快照 commit对象 ,并清空 暂存区 ,因为没有使用 --hard 参数,所以不会影响到工作区已修改的内容
  • git branch -a 查看本地和远程分支
  • git branch -d <branch> 删除本地分支
  • git branch -D <branch> 删除本地和远程分支

一个 git仓库 可以有多个分支,但会带来一个问题,即用户不知道自己处在哪个 分支 上,因为 1 个 commit对象 可以被多个分支 引用 ,无法根据当前的 commit对象 知道当前处在哪个分支上

所以 Git 引入了 HEAD 指针的概念, HEAD 指针指向的 分支 即表示当前 分支HEAD指针 位于 .git/HEAD 文件,文件内容可能指向分支,如 ref: refs/heads/master ,也可能指向一个 commit对象 ,表示 游离指针(detatched HEAD)

可以通过 git symbolic-ref 来修改 HEAD 指针的引用

sh
git symbolic-ref HEAD refs/heads/master
git symbolic-ref HEAD refs/tags/v1.0.1
git symbolic-ref HEAD <hash>

查看 HEAD 指针当前引用

sh
cat .git/HEAD

Tag 标签

Tag 标签分为两种,一种是 轻量标签(lightweight) ,它和 Branch分支 特性类似,一个标签对应 .git/refs/tags 目录下的一个文件,文件内容引用 commit对象

sh
# 指向first commit
git update-ref refs/tags/v1.0 86ed78f015c959d6cbebdeba112cf9ff2e317d9b

.git/refs/tags/v1.0 文件中存储的引用就是 86ed78f015c959d6cbebdeba112cf9ff2e317d9b

或者使用 git tag <tag> <commit-hash> 命令

sh
git tag v1.0
# 等同
git tag v1.0 HEAD

另一种是 标注标签(annotated) ,和 git commit-treegit commit 提交一样,会生成一个 Git对象 ,是 Git 三大对象 之外隐藏的第 4 大对象, tag对象

可以通过 git tag -a <tag> <commit-hash> 命令来像 git commit 命令一样,生成 tag对象

sh
# hash值是一个commit对象的引用
git tag -a v1.1 7653394fc31da83ad9acaf68f6e488a630257460 -m "v1.1 tag"

-a v1.1 表示会创建一个名为 v1.1Tag标签 ,即创建 .git/refs/tags/v1.1 ,并且生成一个 tag对象 返回 git对象 的引用(SHA1 哈希值),但是并不会输出到命令行,而是写入到 Tag标签 文件中。

sh
cat .git/refs/tags/v1.1

输出结果:

87db8b5063d1cc3ce301c81c06118677d3589f67

和轻量标签不一样,返回的引用的不是 commit对象 ,而是新生成的 tag对象

sh
git cat-file -t 87db8b5063d1cc3ce301c81c06118677d3589f67
echo "-----"
git cat-file -p 87db8b5063d1cc3ce301c81c06118677d3589f67

输出结果:

tag
-----
object 7653394fc31da83ad9acaf68f6e488a630257460
type commit
tag v1.1
tagger codenoy <**@**.com> 1713775759 +0800

v1.1 tag

commit对象 不同, tag对象 的引用不是 tree对象 ,而是被标注为 type commit 类型的对象,并且还包含 tag 标签名, tagger 标签作者,以及提交的注释信息。

这也说明 tag对象 中引用的其它 git对象 并不必须是 commit对象 ,也可以是其它 git对象

在一些经验老到的程序员会将 GPG 公钥生成 blob对象 存储到 git对象数据库 中,然后生成 Tag标签 引用这个 blob对象 ,通过这种方式共享公钥

可以通过 git cat-file 命令查看 Tag对象blob对象 引用

sh
git cat-file blob <tag-hash>

推送 Tag 标签到远程仓库

sh
git push origin v1.0
git push origin --tags

远程引用(远程分支)

远程引用的文件存放在 .git/refs/remotes 目录下

首先添加 1 个远程版本库,并将当前分支推送远程仓库

sh
git remote add origin git@github.com:codenoy/git-test.git

这条命令会在 .git/config 配置文件中添加 1 个小节

ini
[remote "origin"]
	url = git@github.com:codenoy/git-test.git
	fetch = +refs/heads/*:refs/remotes/origin/*

fetch 规则的映射关系是 + 然后是 <src>:<dest> ,将本地所有分支 src 和远程仓库 origin 下面的所有分支 dest 进行映射,+ 表示即使在不能快进(non fast forward)的情况下也要(强制)更新引用。

当使用 git fetch [origin] 的时候,Git 会按照仓库中所有 remote 配置里面的 fetch 配置,从远程仓库拉取到本地的远程仓库分支 .git/refs/remotes/ 下,并且强制将本地仓库分支 .git/refs/heads 中引用更新为 本地远程仓库 中的引用,这个行为是依据 fetch 配置的映射和 + (强制更新引用)。

如果想让 Git 每次只拉取远程的  master  分支,而不是所有分支

sh
# 拉取远程仓库的main分支到本地.git/refs/remotes/main分支
# 并且依据映射关系强制将本地仓库.git/refs/heads/master的引用更新为远程分支main的引用
git fetch origin master:refs/remotes/main

或者修改 fetch 的行为(不推荐)

ini
fetch = +refs/heads/master:refs/remotes/origin/main

可以将上方不推荐的方式修改为如下方式(同一个 remote 配置可以设置多个 fetch 和 push 规则):

ini
[remote "origin"]
	url = git@github.com:codenoy/git-test.git
	fetch = +refs/heads/*:refs/remotes/origin/*
	fetch = +refs/heads/master:refs/remotes/origin/main
	push = refs/heads/master:refs/remotes/origin/main
	push = refs/heads/*:refs/remotes/origin/*

推送到远程仓库

只推送 master 分支,会根据 push 或者 fetch 的映射关系将 master 引用写入到本地远程仓库 .git/refs/remotes/origin/master ,然后 push 到远程仓库

sh
git push origin master

输出结果:

Enumerating objects: 9,done.
Counting objects: 100% (9/9),done.
Delta compression using up to 10 threads
Compressing objects: 100% (6/6),done.
Writing objects: 100% (9/9),681 bytes | 681.00 KiB/s,done.
Total 9 (delta 0),reused 0 (delta 0),pack-reused 0
To github.com:codenoy/git-test.git
 * [new branch]      master -> master

查看远程仓库分支和本地仓库分支

sh
cat .git/refs/heads/master
cat .git/refs/remotes/origin/master

输出结果:

sh
7653394fc31da83ad9acaf68f6e488a630257460
7653394fc31da83ad9acaf68f6e488a630257460

两个引用都指向同一个 commit对象 ,但和 Branch分支 不同, 远程分支 是只读的,无法通过 git update-ref 命令来修改远程分支的引用,但是可以手动往 .git/refs/remotes/origin/master 写入其它 commit对象 的引用,但可能会导致远程分支出现异常,不过使用 git push origin master 命令后又会和远程仓库中的分支同步,恢复正常引用

虽然 远程分支 是只读的,但是可以通过 git checkout 切换到远程分支,但 HEAD指针 并不会跟着切换引用到 远程分支 ,所以也就无法通过 git commit 命令来更新远程分支

也可以通过 git push 来删除远程仓库的分支

sh
git push origin :main

表示将本地的 null 和远程仓库的 main 映射,并且 push 到远程仓库中,这会导致 main 分支因为失去引用而被删除

或者使用更语义化的命令来删除远程仓库的分支

sh
git push origin --delete main

参考文章