Pro Git 阅读理解:Git 是如何实现的

前言

文章是对 Pro Git 第一版的阅读总结,因本人更关注 Git 的实现原理,所以文章只包含了个人认为对理解 Git 内部原理有帮助的第 1、3、4、7、9 章内容的总结,如果你也想深入的了解 Git,推荐阅读 Pro Git 的第一版和第二版:

Git 基础

Git 是一个分布式版本管理系统,与集中式版本管理系统不同,Git 几乎所有的操作都在本地进行,可以在本地进行提交更新等操作而无须联网;在 clone Git 仓库时,实际是将整个仓库镜像克隆下来,镜像包含了所有的历史提交记录,这意味着即使远程仓库丢失或损坏了,也可以轻易的通过本地仓库重建远程仓库。

Git 只关注数据整体的变化,而不是文件内容的具体差异:将变化的文件作快照后,记录在一个微型的文件系统中。每次提交更新时,Git 计算所有文件的指纹信息,存储文件快照,将文件对应的指纹作为引用指向文件快照。如果文件没有变化,Git 不会再次保存,只是链接到对应的文件快照。

Git 在每次更新提交时,对每个文件进行校验计算,这意味着不会出现文件被修改但 Git 毫无所知的情况,最大程度的保证了文件的完整性。

Git 中文件只有三种状态:

  • 已提交(committed)表示文件已安全的保存到本地数据库中
  • 已修改(modified)表示修改了某个文件,还没有提交保存
  • 已暂存(staged)表示把已修改的文件放在下次提交时要保存的清单中

三种状态对应 Git 的三个工作区域:工作区,暂存区域,以及本地仓库

  • 工作区是从本地仓库中取出的某个版本的所有文件和目录,实际是从 Git 目录中的压缩对象数据库中提取的
  • 暂存区域是个简单的文件,一般都放在 .git/objects 目录中

Git 简单结构

Git 通过 HEAD 文件保存当前的分支,查看 .git/HEAD 中的内容:

这表明当前分支是 .git/refs/heads/master,查看对应的文件内容:

这是一个 40 位的指纹信息,引用到具体的文件;Git 以 40 位指纹的前两位作为目录名,剩余的 38 位作为文件名存储文件快照,这些文件都存放在 .git/objects 中:

Git 使用 zlib 的 Deflate 算法压缩内容,需要解压才能看到有意义的文件内容,使用 nodejs 解压上面的 1d/7f00bcdea10b04ceab0d243d181f09a971bcd0 文件:

得到的内容如下:

Git 将他表示为一个 commit 对象,为了直观的感受,以 js 对象表示:

js 复制代码
({
  /** 对象类型 */
  type: "commit",
  /** 后续内容包含的字节数 */
  contentByteLenght: 178,
  /** 指向 root tree 对象 */
  tree: "87f0a1ed873d2b2836c5454462d938d874504aec",
  /** 指向上一次的提交对象,如果是第一次 commit 则不存在 */
  parent?: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  /** 作者 */
  author: {
    name: "yuanyxh",
    email: "xxxx@xxx.xxx",
    timestamp: 1705569456,
    reviseTime: "+0800",
  },
  /** 提交者 */
  committer: {
    name: "yuanyxh",
    email: "xxxx@xxx.xxx",
    timestamp: 1705569456,
    reviseTime: "+0800",
  },
  /** 提交时的描述信息 */
  description: "feat: add a new.txt",
})

Git 在每次提交更新时都会创建一个新的的 commit 对象,包含了 tree 和 parent 的指向。当我们使用 git reset --hard [args] 命令时,Git 只是简单的将分支文件中的指向更新为对应的 commit 对象的指纹 id,并根据 commit 对象给出的信息重建工作区。

这里我们继续查看上述 commit 对象中 tree 所指向的内容:

这是一个 tree 对象,最后的乱码其实是 new.txt 的文件指纹,无法被转化为有效的 utf-8 编码,以 js 对象表示是这样的:

js 复制代码
({
  /** 对象类型 */
  type: "tree",
  /** 后续内容包含的字节数 */
  contentByteLenght: 35,
  /** 与文件类型和读写权限有关 */
  mode: 100644,
  /** 子树或子文件 */
  children: [
    {
      name: "new.txt",
      blob: "56b6510f1d6b862ca30ce2e7c05b48760ba28fd7"
    }
  ]
})

这说明在工作区根目录下应该有个 new.txt 文件,文件快照存放在 .git/objects/56/b6510f1d6b862ca30ce2e7c05b48760ba28fd7,当然 tree 还能引用其他 tree 对象,表示为当前 tree 对应目录的子目录:

js 复制代码
({
  /** 对象类型 */
  type: "tree",
  /** 后续内容包含的字节数 */
  contentByteLenght: xxxx,
  /** 与文件类型和读写权限有关 */
  mode: 40000,
  /** 子树或子文件 */
  children: [
    {
      name: "subfolder",
      tree: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    }
  ]
})

我们继续解压 .git/objects/56/b6510f1d6b862ca30ce2e7c05b48760ba28fd7,查看它的内容:

以 js 对象表示为:

js 复制代码
({
  /** 对象类型 */
  type: "blob",
  /** 后续内容包含的字节数 */
  contentByteLenght: 5,
  /** 对象内容 */
  content: "11111"
})

注意,上述全部示例中,字节长度和后面的内容看起来是紧连在一起的:

但其实中间间隔了一个不可见字符 \x00

最后是分支,通过上面的内容我们知道了分支只是一个文件引用了一个 commit 对象,假设我们通过 git checkout -b test 新建一个 test 分支,Git 会新建一个 .git/refs/heads/test 文件,并写入一个 commit 对象,然后 mastertest 分支可以同时提交更新互不打扰,只需要在需要的时候合并两个分支的代码就可以了,这都得益于 Git 的机制(内容寻址文件系统)。

可以查看下述帮助理解的图片,均来源于 Pro Git v1

分支:

commit 对象:

Git 服务器

Git 远程仓库通常是一个裸仓库,即没有工作区的仓库,只有一个 .git 目录存放仓库的所有内容,通过以下命令可以创建一个裸仓库:

shell 复制代码
git init --bare

Git 使用四种主要协议传输内容:

  1. 本地协议,即远程仓库在本地文件系统中,比如 NFS
  2. SSH 协议,拥有读写权限,支持验证授权等功能
  3. Git 协议,包含在 Git 软件包中的特殊守护进程;它会监听一个提供类似于 SSH 服务的特定端口(9418)
  4. http/https 协议,只需要把 Git 的裸仓库文件放在 HTTP 的根目录下,配置一个特定的 post-update 挂钩(hook)就可以搞定

Git 内部原理

Git 是如何存储内容的,粗略来说,Git 使用 SHA-1 加密获得 40 位的指纹信息,加密内容为文件的头信息和具体内容,以前两位作为目录名,剩余 38 位作为文件名在 .git/objects 目录下创建文件,最后通过 zlib 的 Deflate 算法压缩内容并写入文件,具体步骤如下:

js 复制代码
const zlib = require("zlib");
const crypto = require("crypto");

/** 实际内容 */
const content = "test generate blob object";

/**
 *
 * 构造 header
 * blob 表示是一个 blob 对象
 * 注意这里的 content.length 表示后续的字节数, 因为是全英文, 所以 content.length 得到结果是正确的
 * 最后需要添加一个不可见字符,实体表示为 \x00
 * */
const header = `blob ${content.length}\x00`;

/** 拼接获取需要操作的内容 */
const store = header + content;

/** 计算指纹 */
const hash = crypto.createHash("sha1");
hash.update(store);
const hex = hash.digest("hex");

/** 目录名称 */
const folderName = hex.slice(0, 2);
/** 文件名称 */
const fileName = hex.slice(2);

/** 压缩内容 */
zlib.deflate(store, (err, result) => {
  /** 获取目录路径 */
  const dir = path.resolve(__dirname, `./.git/objects/${folderName}`);

  /** 目录不存在则递归创建 */
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }

  /** 写入压缩内容 */
  const write = fs.createWriteStream(dir + `/${fileName}`);
  write.write(result);
});

这样我们就手动构建了一个 blob 对象,可以通过类似的方式构建 commit 和 tree 对象,它们之间的关系如下图:

当然最后会有一个 commit 对象指向 root tree,以此可以构建完整的工作区内容。

Git References(Git 引用)

.git/refs/heads/* 保存着所有分支,如 master 分支路径为:.git/refs/heads/master,分支文件中保存着当前分支指向的 commit 对象引用。

.git/HEAD 文件保存着当前分支的引用,假设当前分支为 master,则 HEAD 文件内容为:ref: refs/heads/master

.git/refs/tags/* 保存着所有的标记文件,通过 git tag [args] 命令可以给对象打上标记。

.git/remotes/*/ 保存着与远程仓库交互有关的信息。

Packfiles

Git 保存每个文件每次更新的快照,为防止磁盘占用过多,Git 会时不时地将这些对象打包至一个叫 packfile 的二进制文件以节省空间并提高效率,存储在 .git/objects/pack/* 中。

Git 通过查找命名及尺寸相近的文件,只打包保存文件不同版本的 差异 内容,生成 .pack 压缩文件和 .idx 索引文件。

Git 在 push 时或手动执行 git gc 命令时会进行打包操作,我们执行 git gc 后,会发现 .git/ 目录下的大部分对象文件都不见了,而多出了下面几个文件:

其中,.git/packed-refs 保存了所有分支及对应的 commit 引用;.git/objects/info/packs 保存了所有 .pack 文件的引用;.git/objects/pack/pack-*.[idx|pack] 就是压缩后的 .pack 文件和对应的索引文件。

对于 packfiles 的文件格式,推荐阅读以下文章:

一文讲透 Git 底层数据结构和原理 讲的很详细,可惜的是没有讲解如何将多个差异对象进行合并,找到了一个用于 nodejs 的合并库:

以及两篇文章,不过个人还不能理解:

Git 传输过程

http/https clone 仓库请求为列:

  1. 获取 /info/refs,内容包含全部分支及分支指向的 commit 对象
  2. 获取 /HEAD,得到当前分支引用
  3. 获取分支对应的 commit 对象,解压得到明文内容,请求 root tree 对象,然后可以据此不断请求构建本地 Git 仓库
  4. 如果没有找到对应的 tree 或 blob 对象,表明对象可能在替代仓库或打包文件中
    1. 请求 /objects/info/http-alternates,列举所有替代仓库
    2. 如果返回为空继续请求 objects/info/packs 获取所有打包的 .pack 引用
    3. 请求 /objects/pack/pack-*.idx 获取指定 .pack 文件的索引文件

-- end

后话

最近为了实现自己的想法,需要在 node.js、Android 等环境中集成 Git 功能,首先在自己更熟悉的 js 中查找有没有对应实现的库,找到了这篇文章:

因为需要不依赖于外部的 Git,所以文章中列举出的只有三种方案合适,经过测试发现都不能满足需求:

  • dugite,太老且提供的接口简陋
  • nodegit 虽然支持 ssh,但测试无法正常连接,折腾一天后放弃
  • isomorphic-git 不支持 ssh

Android 中找到了 MGit 这个开源应用,实现了基本的 Git 功能,查看源码发现是使用的 eclipse 的 jgit 包,因为是打包后的产物,反编译有点麻烦,所以放弃。

在查找解决方案的过程中,收集了一些可能有用的资料:

相关推荐
言6669 小时前
要忽略前端依赖包node_modules的文件在目录下 git暂存区消失
git
矩阵科学9 小时前
Langchain.js 实战五:Agent 实战
langchain·node.js
胡小禾9 小时前
Git Worktree
git
程序员小羊!10 小时前
18 GIt
git
怣疯knight10 小时前
Git 本地分支关联远程分支 常用命令汇总
git
ANNENBERG10 小时前
git分支开发管理
git
坤坤藤椒牛肉面10 小时前
GIT的使用
git
w32963627110 小时前
使用 OpenCode 在 Windows 上加速安装 Playwright 的完整指南
windows·git
终将老去的穷苦程序员11 小时前
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚
前端·npm·node.js
之歆11 小时前
Day10_Node.js 与 Express 开发实战指南:从零到一构建专业级 Web 服务
前端·node.js·express