Git--底层原理


  本系列将通过三篇文章:Git–客户端,Git–服务器,Git–底层原理。三个方面来介绍Git的基础用法,窍门技巧和以及其实现原理。

Git命令

  从根本上来说Git是一套内容寻址文件系统,在此之上提供了VCS用户界面。前两篇文章已经介绍了Git的使用方法:clone、checkout、branch等操作命令,由于Git一开始被设计成供 VCS 使用的工具集,它还包含了许多底层命令,这些命令用于以 UNIX 风格使用或由脚本调用。这些命令一般被称为 “plumbing” 命令(底层命令),之前我们使用的命令则被称为“porcelain”命令(高层命令)。接下来我们介绍一个Git内部的工作机制,以及为何使用这种方式。
  当你在一个目录执行 git init时,Git会创建 .git 目录,几乎所有的Git存储或操作的内容都位于该目录下。这个目录大概是这个样子:

1
2
3
4
5
6
7
8
9
10
$ ls
# HEAD
# branches/
# config
# description
# hooks/
# index
# info/
# objects/
# refs/

  在这些目录中HEAD、index、objects和refs是Git的核心目录。HEAD文件指向当前分支,index文件保存暂存区域信息,objects目录存储所有数据内容,refs目录存储指向数据(分支)的提交对象的指针。

Git对象

  从内部来看,Git是 key-value 数据存储结构,你可以在内部存入任何类型的数据。可以使用 hash-object 指令来存入数据,数据会被保存在.git 目录并返回表示这些数据的key值。

1
2
3
4
5
6
$ find .git/objects
# .git/objects
# .git/objects/info
# .git/objects/pack
$ find .git/objects -type f
#

  Git初始化了objects目录,在该目录下创建了pack和info子目录,可以使用下面命令往Git数据库里面存一些文本:

1
2
3
4
$ echo 'test content' | git hash-object -w --stdin
# d670460b4b4aece5915caf5c68d12f560a9fe3e4
$ find .git/objects -type f
# .git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

  参数 -w 指示 hash-object 命令存储(数据)对象,若不指定这个参数该命令仅仅返回键值。–stdin 指定从标准输入设备(stdin)来读取内容,若不指定这个参数则需指定一个要存储的文件的路径。该命令输出长度为40 个字符的校验和。这是个SHA-1哈希值──其值为要存储的数据加上头信息的校验和。可以通过cat-file命令将数据取回,-p参数可以输出数据内容的类型:

1
2
$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
# test content

  接下来我们试着对一个文件进行简单的版本控制:

1
2
3
4
5
6
7
8
9
10
$ echo 'version 1' > test.txt
$ git hash-object -w test.txt
# 83baae61804e65cc73a7201a7252750c76066a30
$ echo 'version 2' > test.txt
$ git hash-object -w test.txt
# 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
$ find .git/objects -type f
# .git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
# .git/objects/83/baae61804e65cc73a7201a7252750c76066a30
# .git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

  通过上面的操作,我们对test.txt操作了两次,通过”find .git/objects -type f” 我们可以查看到数据库中已经将文件的两个新版本连同最开始的内容保存了下来,我们可以将内容恢复到第一个版本:

1
2
3
$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt
$ cat test.txt
# version 1

  你可以将文件恢复到第二个版本,需要记住的是每个版本的文件SHA-1值可能与实际的值不同,存储的并不是文件名而是文件内容,这种对象类型成为blob,通过传递SHA-1值给cat-file -t 命令让Git返回任何对象类型:

1
2
$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
# blob

tree(树)对象

  树对象可以存储文件名,也允许存储一组文件。Git以一种类似UNIX文 件系统但更简单的方式来存储内容。所有内容以tree或blob对象存储,其中tree对象对应于UNIX中的目录,blob对象则大致对应于inodes或文件内容。一个单独的tree对象包含一条或多条tree记录,每一条记录含有一个指向blob或子tree对象的SHA-1指针,并附有该对象的权限模式、类型和文件名信息。
  可以自己创建tree.通常Git根据暂存区域或index来创建并写入一个tree。因此要创建一个tree对象的话,首先要将文件暂存而创建一个index。可以通过plumbing命令update-index为一个单独文件文件的第一个版本创建一个index。由于该文件之前并不在暂存区域中,必须传入–add参数,由于添加的文件并不在当前目录中而是在数据库中,就需要传入–cacheinfo参数。同时指定文件模式,SHA-1和文件名:

1
2
$ git update-index --add --cacheinfo 100644 \
> 83baae61804e65cc73a7201a7252750c76066a30 test.txt

  文件模式为100644,表示普通文件。其余的还有100755表示可执行文件,120000表示符号链接。文件模式是从UNIX的文件模式中参考来的,但并没有那么强大,上面三种模式仅对Git中文件(blobs)有效。现在可以用 write-tree命令将暂存区域的内容写到一个tree对象了。无需-w参数,如果tree不存在,调用write-tree会自动根据index状态创建一个tree对象。

1
2
3
4
$ git write-tree
# d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
# 100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test.txt

  可以用下面的指令来验证这是一个tree对象:

1
2
$ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579
# tree

 再对test.txt的第二个版本创建以及一个新文件创建一个新tree对象:

1
2
3
$ echo 'new file' > new.txt
$ git update-index test.txt
$ git update-index --add new.txt

  这时暂存区包含了test.txt的新版本以及一个新文件 new.txt。创建该tree对象:

1
2
3
4
5
$ git write-tree
# 9e8d59948d066573fedc8f66c87adf521fad4645
$ git cat-file -p 9e8d59948d066573fedc8f66c87adf521fad4645
# 100644 blob 9e050120d897413698b2c85c04dbdb7c6bbf0e6c new.txt
# 100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test.txt

  该tree对象包含了两个文件记录,且test.txt的SHA值只早先值的第二版(83baae)。试着将第一个tree对象作为一个子目录加入该tree中,可以用read-tree命令那个将tree对象读取到暂存区中,通过传一个 –prefix参数给read-tree,将一个已有的tree对象作为一个子tree读到暂存区中:

1
2
3
4
5
6
7
$ git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git write-tree
# 215cc4cf52e6385634fd018233a4f9a3efe59e3b
$ git cat-file -p 215cc4cf52e6385634fd018233a4f9a3efe59e3b
# 040000 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 bak
# 100644 blob 9e050120d897413698b2c85c04dbdb7c6bbf0e6c new.txt
# 100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test.txt

  如果从刚写入的新tree对象创建一个工作目录,将得到位于工作目录顶级的两个文件和一个名为bak的子目录,该子目录包含了test.txt文件的第一个版本。

commit(提交)对象

  现在有了tree对象,它指向要跟踪的项目的不同快照,但是之前要操作这些快照要通过SHA-1值,也没有关于谁、何时操作了这些快照的信息。commit对象就保存了这些基本信息。我们可以通过commit -tree指令来创建commit对象,指定一个tree的SHA-1,如果没有任何前继提交对象,可以从第一个tree开始:

1
2
$ echo 'first commit' | git commit-tree d8329f
# 4dc2e974baa733b98ec36314c2656ed9b460dadd

  通过cat -file 查看新commit 对象:

1
2
3
4
5
6
$ git cat-file -p 4dc2e9
# tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
# author Rainbow <devsonw@gmail.com> 1422148992 +0800
# committer Rainbow <devsonw@gmail.com> 1422148992 +0800
#
# first commit

  commit 对象有格式很简单:指明了该时间点项目快照的顶层树对象、作者/提交者信息,当前时间戳,一个空行,和提交注释信息。提交以后,就产生了Git历史信息,可以使用git log命令并指定最后那个commit 对象的SHA-1查看历史:

1
2
3
4
5
6
7
8
$ git log --stat 4dc2e9
# commit # 4dc2e974baa733b98ec36314c2656ed9b460dadd
# Author: Rainbow <devsonw@gmail.com>
Date: Fri Jan 25 09:07:28 2015 +0800
# first commit
# test.txt | 1 +
# 1 file changed, 1 insertion(+)
#(END)

  目前为止,我们知道的三种对象:blob、tree、commit对象都以各自的文件方式保存在.git/objects目录下:

1
2
3
4
5
6
7
8
9
$ find .git/objects -type f
# objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
# objects/21/5cc4cf52e6385634fd018233a4f9a3efe59e3b
# objects/4d/c2e974baa733b98ec36314c2656ed9b460dadd
# objects/83/baae61804e65cc73a7201a7252750c76066a30
# objects/9e/050120d897413698b2c85c04dbdb7c6bbf0e6c
# objects/9e/8d59948d066573fedc8f66c87adf521fad4645
# objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
# objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579

对象存储

  当存储数据内容时,同时会有一个头文件被存储起来。我们来看看Git是如何存储对象的。可以通过Ruby脚本语言存储一个blob对象(以”hello world”为例),使用irb命令进入Ruby交互模式:

1
2
3
4
5
$ irb
# irb(main):001:0> content = "hello world"
# => "hello world"
$ irb(main):002:0> header = "blob #{content.length}\0"
# => "blob 11\000"

  Git 将文件头与原始数据内容拼接起来,并计算拼接后的新内容的SHA-1校验和。可以在Ruby中使用require语句导入SHA1 digest库,然后调用 Digest::SHA1.hexdigest()方法计算字符串的SHA-1值:

1
2
3
4
5
6
$ irb(main):003:0> store = header + content
# => "blob 11/0hello world"
$ irb(main):004:0> require 'digest/sha1'
# => true
$ irb(main):005:0> sha1 = Digest::SHA1.hexdigest(store)
# => "9e831569b4590cce82b655d4c922b93415ab3b63"

  Git用zlib对数据内容进行压缩,在Ruby中可以用zlib库来实现。首先需要导入该库,然后用 Zlib::Deflate.deflate() 对数据进行压缩:

1
2
3
4
$ irb(main):006:0> require 'zlib'
# =>true
$ irb(main):007:0> zlib_content = Zlib::Deflate.deflate(store)
# => "x\x9CK\xCA\xC9OR04\xD47\xC8H\xCD\xC9\xC9W(\xCF/\xCAI\x01\x00D@\x06\xDD"

  最后将用zlib压缩后的内容写入磁盘。需要指定保存对象的路径(SHA-1 值的头两个字符作为子目录名称,剩余38个字符作为文件名保存至该子目录中)。在Ruby中,如果子目录不存在可以用FileUtils.mkdir_p ()函数创建它。接着用 File.open方法打开文件,并用write()方法将之前压缩的内容写入该文件:

1
2
3
4
5
6
7
8
$ irb(main):008:0> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
# ".git/objects/9e/831569b4590cce82b655d4c922b93415ab3b63"
$ irb(main):009:0> require 'fileutils'
# => true
$ irb(main):010:0> FileUtils.mkdir_p(File.dirname(path))
# => [".git/objects/9e"]
$ irb(main):011:0> File.open(path, 'w') { |f| f.write zlib_content }
# => 28

  这样就成功创建了一个正确的blob对象。所有的Git对象都已这种方式存储,只是类型不同而已。除了blob,文件头还可以是tree或者commit。不过blob几乎可以是任意内容,commit和tree的数据是有固定格式的。

Git 引用

  我们之前执行git log 4dc2e9 来来查看历史,需要记住4dc2e9才能在提交的历史中找到这些对象。需要一个文件来用一个简单的名字来记录这些SHA-1值,这样就可以用这些指针而不是原来是SHA-1值去检索了,这种方式我们称之为引用(references),可以在.git/refs目录下找到这些包含SHA-1值得文件。

1
2
3
4
5
6
$ find .git/refs
# .git/refs
# .git/refs/heads
# .git/refs/tags
# find .git/refs -type f
#

  如果想要创建一个新的引用帮助你记住最后一次提交,可以这么做:

1
$ echo "1a410efbd13591db07496601ebc7a059dd55cfe9" > .git/refs/heads/master

  现在,就可以在Git命令中使用刚才创建的引用而不是SHA-1值,当然,并不鼓励直接修改这些引用文件。如果确实需要更新一个引用,Git提供了一个安全的命令 update-ref:

1
$ git update-ref refs/heads/master 1a410efbd13591db07496601ebc7a059dd55cfe9

  Git的数据库看起来就像下面那样:

  每当执行git branch (分支名称)这样的命令,Gi 基本上就是执行 update-ref 命令,把你现在所在分支中最后一次提交的SHA-1值,添加到你要创建的分支的引用。

HEAD标记

  当执行git branch (分支名称)命令的时候,Git是怎么知道最后一次提交的SHA-1值呢?答案就是HEAD文件。HEAD文件是一个指向当前所在分支的引用标识符。这样的引用标识符并不包含SHA-1值,而是一个指向另一个引用的指针。你如果执行check out命令,这个文件就会更新:

1
2
3
4
5
$ cat .git/HEAD
# ref: refs/heads/master
$ git check out test
$ cat .git/HEAD
# ref: refs/heads/test

  当你在执行git commit 命令,它就会常见一个commit对象,把这个commit对象的父级设置为HEAD指向的引用的SHA-1值。可以手动编辑这个文件,也可以使用这个安全的指令:symbolic-ref,首先来读取一下HEAD值,然后修改它:

1
2
3
4
5
$ git symbolic-ref HEAD
# refs/heads/master
$ git symbolic-ref HEAD refs/heads/test
$ cat .git/HEAD
# refs: refs/heads/test

Packfiles

  Git用zlib压缩文件内容,并不会占用太多的空间。Git在往磁盘保存对象时默认使用松散对象(loose object)格式。Git时不时的将这些对象打包至一个叫做packfile的二进制文件以节省空间并提高效率。当仓库中有太多的松散对象,或者手动调用git gc命令,或者推送至远程服务器,Git都会这么做。