Git学习-Git内部原理
底层命令与上层命令
Git 最初是一套面向版本控制系统的工具集,而不是一个完整的、用户友好的版本控制系统, 所以它还包含了一部分用于完成底层工作的子命令。 这些命令被设计成能以 UNIX 命令行的风格连接在一起,抑或藉由脚本调用,来完成工作。 这部分命令一般被称作“底层(plumbing)”命令,而那些更友好的命令则被称作“上层(porcelain)”命令。
前面的章节中,探讨的都是上层命令,而在本章中,我们将主要面对底层命令。 因为,底层命令得以让你窥探 Git 内部的工作机制,也有助于说明 Git 是如何完成工作的,以及它为何如此运作。 多数底层命令并不面向最终用户:它们更适合作为新工具的组件和自定义脚本的组成部分。
当在一个新目录或已有目录执行 git init
时,Git 会创建一个 .git
目录。 这个目录包含了几乎所有 Git 存储和操作的东西。 如若想备份或复制一个版本库,只需把这个目录拷贝至另一处即可。 本章探讨的所有内容,均位于这个目录内。 新初始化的 .git
目录的典型结构如下:
1 | ls -F1 |
description
文件仅供 GitWeb 程序使用,我们无需关心。 config
文件包含项目特有的配置选项。 info
目录包含一个全局性排除(global exclude)文件, 用以放置那些不希望被记录在 .gitignore
文件中的忽略模式(ignored patterns)。 hooks
目录包含客户端或服务端的钩子脚本(hook scripts)。
剩下的四个条目很重要:HEAD
文件、(尚待创建的)index
文件,和 objects
目录、refs
目录。 它们都是 Git 的核心组成部分。 objects
目录存储所有数据内容;refs
目录存储指向数据(分支、远程仓库和标签等)的提交对象的指针; HEAD
文件指向目前被检出的分支;index
文件保存暂存区信息。
Git 对象
Git 是一个内容寻址文件系统,Git 的核心部分是一个简单的键值对数据库(key-value data store)。 你可以向 Git 仓库中插入任意类型的内容,它会返回一个唯一的键,通过该键可以在任意时刻再次取回该内容。
在一个 Git 版本库(尽量使用刚初始化的新版本库)下,用 git hash-object
创建一个新的数据对象并将它手动存入 Git 数据库中:
1 | echo 'test content' | git hash-object -w --stdin |
在这种最简单的形式中,git hash-object
会接受你传给它的东西,而它只会返回可以存储在 Git 仓库中的唯一键。 -w
选项会指示该命令不要只返回键,还要将该对象写入数据库中。 最后,--stdin
选项则指示该命令从标准输入读取内容;若不指定此选项,则须在命令尾部给出待存储文件的路径。
此命令输出一个长度为 40 个字符的校验和。 这是一个 SHA-1 哈希值——一个将待存储的数据外加一个头部信息(header)一起做 SHA-1 校验运算而得的校验和。
使用 find .git/objects -type f
查看 Git 存储数据。
1 | find .git/objects -type f |
如果你再次查看 objects
目录,那么可以在其中找到一个与新内容对应的文件。 这就是开始时 Git 存储内容的方式——一个文件对应一条内容, 以该内容加上特定头部信息一起的 SHA-1 校验和为文件命名。 校验和的前两个字符用于命名子目录,余下的 38 个字符则用作文件名。
一旦你将内容存储在了对象数据库中,那么可以通过 cat-file
命令从 Git 那里取回数据。 这个命令简直就是一把剖析 Git 对象的瑞士军刀。 为 cat-file
指定 -p
选项可指示该命令自动判断内容的类型,并为我们显示大致的内容:
1 | git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4 |
同样可以将这些操作应用于文件中的内容。 例如,可以对一个文件进行简单的版本控制。 首先,创建一个新文件并将其内容存入数据库:
1 | echo 'version 1' > test.txt |
接着,向文件里写入新内容,并再次将其存入数据库:
1 | echo 'version 2' > test.txt |
对象数据库记录下了该文件的两个不同版本,当然之前我们存入的第一条内容也还在:
1 | find .git/objects -type f |
现在可以在删掉 test.txt
的本地副本,然后用 Git 从对象数据库中取回它的第一个版本:
1 | git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt |
或者第二个版本:
1 | git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt |
然而,记住文件的每一个版本所对应的 SHA-1 值并不现实;另一个问题是,在这个(简单的版本控制)系统中,文件名并没有被保存——我们仅保存了文件的内容。 上述类型的对象我们称之为 数据对象(blob object)。 利用 git cat-file -t
命令,可以让 Git 告诉我们其内部存储的任何对象类型,只要给定该对象的 SHA-1 值:
1 | git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a |
树对象
树对象(tree object)解决文件名保存的问题,也允许我们将多个文件组织到一起。 Git 以一种类似于 UNIX 文件系统的方式存储内容,但作了些许简化。 所有内容均以树对象和数据对象的形式存储,其中树对象对应了 UNIX 中的目录项,数据对象则大致上对应了 inodes 或文件内容。 一个树对象包含了一条或多条树对象记录(tree entry),每条记录含有一个指向数据对象或者子树对象的 SHA-1 指针,以及相应的模式、类型、文件名信息。
使用 git cat-file -p main^{tree}
命令,查看项目 main 分支下最新树对象。
其中 lib 表示一个指针,其指向的是另一个树对象,通过 git cat-file -p <SHA-1>
查看树对象下的对象结构。
通过 git update-index
命令,可以直接从底层创建树对象。通常情况下,Git 根据某一时刻暂存区(即 index 区域)所表示的状态创建并记录一个对应的树对象, 如此重复便可依次记录(某个时间段内)一系列的树对象。
可以通过底层命令 git update-index
为一个单独文件——我们的 test.txt 文件的首个版本——创建一个暂存区。 利用该命令,可以把 test.txt
文件的首个版本人为地加入一个新的暂存区。 必须为上述命令指定 --add
选项,因为此前该文件并不在暂存区中(我们甚至都还没来得及创建一个暂存区呢); 同样必需的还有 --cacheinfo
选项,因为将要添加的文件位于 Git 数据库中,而不是位于当前目录下。 同时,需要指定文件模式、SHA-1 与文件名:
1 | git update-index --add --cacheinfo 100644 \ |
指定的文件模式为 100644
,表明这是一个普通文件。 其他选择包括:100755
,表示一个可执行文件;120000
,表示一个符号链接。 这里的文件模式参考了常见的 UNIX 文件模式,但远没那么灵活——上述三种模式即是 Git 文件(即数据对象)的所有合法模式(当然,还有其他一些模式,但用于目录项和子模块)。
通过 git write-tree
命令将暂存区内容写入一个树对象。
1 | git write-tree |
git write-tree
返回的 SHA-1 值是当前目录下的树对象值,通过 git cat-file
查看树对象,可以发现 test.txt 被添加到当前树对象下了,其 SHA-1 值就是 git update-index
指定的值。
再次进行操作,添加一个新文件,并对 test.txt 文件做修改后再暂存。
1 | echo 'new file' > new.txt |
暂存区现在包含了 test.txt
文件的新版本,和一个新文件:new.txt
。 记录下这个目录树(将当前暂存区的状态记录为一个树对象),然后观察它的结构:
1 | git write-tree |
我们注意到,新的树对象包含两条文件记录,同时 test.txt 的 SHA-1 值(1f7a7a
)是先前值的“第二版”。
提交对象
树对象的操作中,会出现不同版本的快照,每个快照会对应不用的 SHA-1 哈希值,在没有提交之前,是无法记录这些快照在什么时刻保存的,以及为什么保存这些快照的。
通过 git commit-tree
命令创建一个提交对象,为此需要指定一个树对象的 SHA-1 值,以及该提交的父提交对象(如果有的话)。
1 | echo 'commit-tree 1' | git commit-tree 2ed7 |
由于创建时间和作者数据不同,你现在会得到一个不同的散列值。 请将本章后续内容中的提交和标签的散列值替换为你自己的校验和。 现在可以通过 git cat-file
命令查看这个新提交对象:
1 | git cat-file -p b4f619 |
提交对象的格式很简单:它先指定一个顶层树对象,代表当前项目快照; 然后是可能存在的父提交(前面描述的提交对象并不存在任何父提交); 之后是作者/提交者信息(依据你的 user.name
和 user.email
配置来设定,外加一个时间戳); 留空一行,最后是提交注释。
将 2ed7
树对象指定父对象为 f9bba3
,将提交记录挂载到父提交记录上。
1 | echo 'commit-tree 1' | git commit-tree 2ed7 -p f9bba34 |
通过 git log
查看 3ffffc
下的提交日志。
1 | git log --stat 3ffffc |
可以发现,在没有借助任何上层命令,仅凭几个底层操作便完成了一个 Git 提交历史的创建。 这就是每次我们运行 git add
和 git commit
命令时,Git 所做的工作实质就是将被改写的文件保存为数据对象, 更新暂存区,记录树对象,最后创建一个指明了顶层树对象和父提交的提交对象。
这三种主要的 Git 对象——数据对象、树对象、提交对象——最初均以单独文件的形式保存在 .git/objects
目录下。
Git 引用
如果你对仓库中从一个提交(比如 1a410e
)开始往前的历史感兴趣,那么可以运行 git log 1a410e
这样的命令来显示历史,不过你需要记得 1a410e
是你查看历史的起点提交。 如果我们有一个文件来保存 SHA-1 值,而该文件有一个简单的名字, 然后用这个名字指针来替代原始的 SHA-1 值的话会更加简单。
在 Git 中,这种简单的名字被称为“引用(references,或简写为 refs)”。 你可以在 .git/refs
目录下找到这类含有 SHA-1 值的文件。 在目前的项目中,这个目录没有包含任何文件,但它包含了一个简单的目录结构:
1 | find .git/refs |
通过 git update-ref
更新引用,若想在第二个提交上创建一个分支,可以这么做:
1 | git update-ref refs/heads/test cac0ca |
这个分支将只包含从第二个提交开始往前追溯的记录:
1 | git log --pretty=oneline test |
HEAD文件
现在的问题是,当你执行 git branch <branch>
时,Git 如何知道最新提交的 SHA-1 值呢? 答案是 HEAD 文件。
HEAD 文件通常是一个符号引用(symbolic reference),指向目前所在的分支。 所谓符号引用,表示它是一个指向其他引用的指针。
标签引用
前面我们刚讨论过 Git 的三种主要的对象类型(数据对象、树对象 和 提交对象 ),然而实际上还有第四种。 标签对象(tag object) 非常类似于一个提交对象——它包含一个标签创建者信息、一个日期、一段注释信息,以及一个指针。 主要的区别在于,标签对象通常指向一个提交对象,而不是一个树对象。 它像是一个永不移动的分支引用——永远指向同一个提交对象,只不过给这个提交对象加上一个更友好的名字罢了。
远程引用
如果你添加了一个远程版本库并对其执行过推送操作,Git 会记录下最近一次推送操作时每一个分支所对应的值,并保存在 refs/remotes
目录下。 例如,你可以添加一个叫做 origin
的远程版本库,然后把 master
分支推送上去:
1 | git remote add origin git@github.com:schacon/simplegit-progit.git |
此时,如果查看 refs/remotes/origin/master
文件,可以发现 origin
远程版本库的 master
分支所对应的 SHA-1 值,就是最近一次与服务器通信时本地 master
分支所对应的 SHA-1 值:
1 | cat .git/refs/remotes/origin/master |
远程引用和分支(位于 refs/heads
目录下的引用)之间最主要的区别在于,远程引用是只读的。 虽然可以 git checkout
到某个远程引用,但是 Git 并不会将 HEAD 引用指向该远程引用。因此,你永远不能通过 commit
命令来更新远程引用。 Git 将这些远程引用作为记录远程服务器上各分支最后已知位置状态的书签来管理。
包文件
Git 仓库最初的时候,Git 对同一个文件的不同版本会保留源文件,而不是只完整保存其中一个,再保存另一个对象与之前版本的差异内容。
但是,Git 会时不时地将多个这些对象打包成一个称为“包文件(packfile)”的二进制文件,以节省空间和提高效率。 当版本库中有太多的松散对象,或者你手动执行 git gc
命令,或者你向远程服务器执行推送时,Git 都会这样做。
Git 是如何做到这点的? Git 打包对象时,会查找命名及大小相近的文件,并只保存文件不同版本之间的差异内容。 你可以查看包文件,观察它是如何节省空间的。 git verify-pack
这个底层命令可以让你查看已打包的内容。
引用规范
现在想要添加一个远程仓库:
1 | git remote add origin https://github.com/schacon/simplegit-progit |
运行上述命令会在你仓库中的 .git/config
文件中添加一个小节, 并在其中指定远程版本库的名称(origin
)、URL 和一个用于获取操作的 引用规范(refspec):
1 | [remote "origin"] |
引用规范的格式由一个可选的 +
号和紧随其后的 <src>:<dst>
组成, 其中 <src>
是一个模式(pattern),代表远程版本库中的引用; <dst>
是本地跟踪的远程引用的位置。 +
号告诉 Git 即使在不能快进的情况下也要(强制)更新引用。
默认情况下,引用规范由 git remote add origin
命令自动生成, Git 获取服务器中 refs/heads/
下面的所有引用,并将它写入到本地的 refs/remotes/origin/
中。 所以,如果服务器上有一个 master
分支,你可以在本地通过下面任意一种方式来访问该分支上的提交记录:
1 | git log origin/master |
上面的三个命令作用相同,因为 Git 会把它们都扩展成 refs/remotes/origin/master
。
如果想让 Git 每次只拉取远程的 master
分支,而不是所有分支, 可以把(引用规范的)获取那一行修改为只引用该分支:
1 | fetch = +refs/heads/master:refs/remotes/origin/master |
这仅是针对该远程版本库的 git fetch
操作的默认引用规范。 如果有某些只希望被执行一次的操作,我们也可以在命令行指定引用规范。 若要将远程的 master
分支拉到本地的 origin/mymaster
分支,可以运行:
1 | git fetch origin master:refs/remotes/origin/mymaster |
传输协议
Git 可以通过两种主要的方式在版本库之间传输数据:“哑(dumb)”协议和“智能(smart)”协议。
哑协议
如果你正在架设一个基于 HTTP 协议的只读版本库,一般而言这种情况下使用的就是哑协议。 这个协议之所以被称为“哑”协议,是因为在传输过程中,服务端不需要有针对 Git 特有的代码;抓取过程是一系列 HTTP 的 GET
请求,这种情况下,客户端可以推断出服务端 Git 仓库的布局。
智能协议
智能协议是更常用的传送数据的方法,但它需要在服务端运行一个进程,而这也是 Git 的智能之处——它可以读取本地数据,理解客户端有什么和需要什么,并为它生成合适的包文件。 总共有两组进程用于传输数据,它们分别负责上传和下载数据。
为了上传数据至远端,Git 使用 send-pack
和 receive-pack
进程。 运行在客户端上的 send-pack
进程连接到远端运行的 receive-pack
进程。
当你在下载数据时, fetch-pack
和 upload-pack
进程就起作用了。 客户端启动 fetch-pack
进程,连接至远端的 upload-pack
进程,以协商后续传输的数据。
维护与数据恢复
有的时候,你需要对仓库进行清理——使它的结构变得更紧凑,或是对导入的仓库进行清理,或是恢复丢失的内容。
维护
Git 会不定时地自动运行一个叫做 “auto gc” 的命令。 大多数时候,这个命令并不会产生效果。 然而,如果有太多松散对象(不在包文件中的对象)或者太多包文件,Git 会运行一个完整的 git gc
命令。 “gc” 代表垃圾回收,这个命令会做以下事情:收集所有松散对象并将它们放置到包文件中, 将多个包文件合并为一个大的包文件,移除与任何提交都不相关的陈旧对象。
可以像下面一样手动执行自动垃圾回收:
1 | git gc --auto |
就像上面提到的,这个命令通常并不会产生效果。 大约需要 7000 个以上的松散对象或超过 50 个的包文件才能让 Git 启动一次真正的 gc 命令。 你可以通过修改 gc.auto
与 gc.autopacklimit
的设置来改动这些数值。
数据恢复
在你使用 Git 的时候,你可能会意外丢失一次提交。 通常这是因为你强制删除了正在工作的分支,但是最后却发现你还需要这个分支, 亦或者硬重置了一个分支,放弃了你想要的提交。 如果这些事情已经发生,该如何找回你的提交呢?
最方便,也是最常用的方法,是使用一个名叫 git reflog
的工具。 当你正在工作时,Git 会默默地记录每一次你改变 HEAD 时它的值。 每一次你提交或改变分支,引用日志都会被更新。 引用日志(reflog)也可以通过 git update-ref
命令更新,我们在 Git 引用 有提到使用这个命令而不是是直接将 SHA-1 的值写入引用文件中的原因。
移除对象
Git 有很多很棒的功能,但是其中一个特性会导致问题,git clone
会下载整个项目的历史,包括每一个文件的每一个版本。 如果所有的东西都是源代码那么这很好,因为 Git 被高度优化来有效地存储这种数据。 然而,如果某个人在之前向项目添加了一个大小特别大的文件,即使你将这个文件从项目中移除了,每次克隆还是都要强制的下载这个大文件。 之所以会产生这个问题,是因为这个文件在历史中是存在的,它会永远在那里。
警告:这个操作对提交历史的修改是破坏性的。 它会从你必须修改或移除一个大文件引用最早的树对象开始重写每一次提交。 如果你在导入仓库后,在任何人开始基于这些提交工作前执行这个操作,那么将不会有任何问题——否则, 你必须通知所有的贡献者他们需要将他们的成果变基到你的新提交上。
必须使用 git rm --cached
命令来移除文件,而不是通过类似 rm file
的命令——因为你需要从索引中移除它,而不是磁盘中。 还有一个原因是速度—— Git 在运行过滤器时,并不会检出每个修订版本到磁盘中,所以这个过程会非常快。
环境变量
Git 总是在一个 bash
shell 中运行,并借助一些 shell 环境变量来决定它的运行方式。 有
全局行为
像通常的程序一样,Git 的常规行为依赖于环境变量。
GIT_EXEC_PATH
决定 Git 到哪找它的子程序 (像 git-commit
, git-diff
等等)。 你可以用 git --exec-path
来查看当前设置。
通常不会考虑修改 HOME
这个变量(太多其它东西都依赖它),这是 Git 查找全局配置文件的地方。 如果你想要一个包括全局配置的真正的便携版 Git, 你可以在便携版 Git 的 shell 配置中覆盖 HOME
设置。
PREFIX
也类似,除了用于系统级别的配置。 Git 在 $PREFIX/etc/gitconfig
查找此文件。
如果设置了 GIT_CONFIG_NOSYSTEM
,就禁用系统级别的配置文件。 这在系统配置影响了你的命令,而你又无权限修改的时候很有用。
GIT_PAGER
控制在命令行上显示多页输出的程序。 如果这个没有设置,就会用 PAGER
。
GIT_EDITOR
当用户需要编辑一些文本(比如提交信息)时, Git 会启动这个编辑器。 如果没设置,就会用 EDITOR
。
版本库位置
Git 用了几个变量来确定它如何与当前版本库交互。
GIT_DIR
是 .git
目录的位置。 如果这个没有设置, Git 会按照目录树逐层向上查找 .git
目录,直到到达 ~
或 /
。
GIT_CEILING_DIRECTORIES
控制查找 .git
目录的行为。 如果你访问加载很慢的目录(如那些磁带机上的或通过网络连接访问的),你可能会想让 Git 早点停止尝试,尤其是 shell 构建时调用了 Git 。
GIT_WORK_TREE
是非空版本库的工作目录的根路径。 如果指定了 --git-dir
或 GIT_DIR
但未指定 --work-tree
、GIT_WORK_TREE
或 core.worktree
,那么当前工作目录就会视作工作树的顶级目录。
GIT_INDEX_FILE
是索引文件的路径(只有非空版本库有)。
GIT_OBJECT_DIRECTORY
用来指定 .git/objects
目录的位置。
GIT_ALTERNATE_OBJECT_DIRECTORIES
一个冒号分割的列表(格式类似 /dir/one:/dir/two:…
)用来告诉 Git 到哪里去找不在 GIT_OBJECT_DIRECTORY
目录中的对象。 如果你有很多项目有相同内容的大文件,这个可以用来避免存储过多备份。
路径规则
所谓 “pathspec” 是指你在 Git 中如何指定路径,包括通配符的使用。 它们会在 .gitignore
文件中用到,命令行里也会用到(git add *.c
)。
GIT_GLOB_PATHSPECS
和 GIT_NOGLOB_PATHSPECS
控制通配符在路径规则中的默认行为。 如果 GIT_GLOB_PATHSPECS
设置为 1, 通配符表现为通配符(这是默认设置); 如果 GIT_NOGLOB_PATHSPECS
设置为 1,通配符仅匹配字面。意思是 *.c
只会匹配 文件名是 “*.c” 的文件,而不是以 .c
结尾的文件。 你可以在各个路径规范中用 :(glob)
或 :(literal)
开头来覆盖这个配置,如 :(glob)*.c
。
GIT_LITERAL_PATHSPECS
禁用上面的两种行为;通配符将不能用,前缀覆盖也不能用。
GIT_ICASE_PATHSPECS
让所有的路径规范忽略大小写。
提交
Git 提交对象的创建通常最后是由 git-commit-tree
来完成, git-commit-tree
用这些环境变量作主要的信息源。 仅当这些值不存在才回退到预置的值。
GIT_AUTHOR_NAME
是 “author” 字段的可读名字。
GIT_AUTHOR_EMAIL
是 “author” 字段的邮件。
GIT_AUTHOR_DATE
是 “author” 字段的时间戳。
GIT_COMMITTER_NAME
是 “committer” 字段的可读名字。
GIT_COMMITTER_EMAIL
是 “committer” 字段的邮件。
GIT_COMMITTER_DATE
是 “committer” 字段的时间戳。
如果 user.email
没有配置, 就会用到 EMAIL
指定的邮件地址。 如果 这个 也没有设置, Git 继续回退使用系统用户和主机名。
网络
Git 使用 curl
库通过 HTTP 来完成网络操作, 所以 GIT_CURL_VERBOSE
告诉 Git 显示所有由那个库产生的消息。 这跟在命令行执行 curl -v
差不多。
GIT_SSL_NO_VERIFY
告诉 Git 不用验证 SSL 证书。 这在有些时候是需要的, 例如你用一个自己签名的证书通过 HTTPS 来提供 Git 服务, 或者你正在搭建 Git 服务器,还没有安装完全的证书。
如果 Git 操作在网速低于 GIT_HTTP_LOW_SPEED_LIMIT
字节/秒,并且持续 GIT_HTTP_LOW_SPEED_TIME
秒以上的时间,Git 会终止那个操作。 这些值会覆盖 http.lowSpeedLimit
和 http.lowSpeedTime
配置的值。
GIT_HTTP_USER_AGENT
设置 Git 在通过 HTTP 通讯时用到的 user-agent。 默认值类似于 git/2.0.0
。
比较和合并
GIT_DIFF_OPTS
这个有点起错名字了。 有效值仅支持 -u<n>
或 --unified=<n>
,用来控制在 git diff
命令中显示的内容行数。
GIT_EXTERNAL_DIFF
用来覆盖 diff.external
配置的值。 如果设置了这个值, 当执行 git diff
时,Git 会调用该程序。
GIT_DIFF_PATH_COUNTER
和 GIT_DIFF_PATH_TOTAL
对于 GIT_EXTERNAL_DIFF
或 diff.external
指定的程序有用。 前者表示在一系列文件中哪个是被比较的(从 1 开始),后者表示每批文件的总数。
GIT_MERGE_VERBOSITY
控制递归合并策略的输出。 允许的值有下面这些:
- 0 什么都不输出,除了可能会有一个错误信息。
- 1 只显示冲突。
- 2 还显示文件改变。
- 3 显示因为没有改变被跳过的文件。
- 4 显示处理的所有路径。
- 5 显示详细的调试信息。
默认值是 2。
总结
本章讨论了很多底层命令,这些命令比我们在本书其余部分学到的高层命令来得更原始,也更简洁。 从底层了解 Git 的工作原理有助于更好地理解 Git 在内部是如何运作的,也方便你能够针对特定的工作流写出自己的工具和脚本。
作为一套内容寻址文件系统,Git 不仅仅是一个版本控制系统,它同时是一个非常强大且易用的工具。