Git命令的背后

git init

使用git init初始化一个新的目录时,会生成一个.git的目录,该目录即为本地仓库。一个新初始化的本地仓库是这样的:

├── HEAD
├── branches
├── config
├── description
├── hooks
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags
  • description 用于GitWeb程序
  • config 配置特定于该仓库的设置(还记得git config的三个配置级别么)
  • hooks 放置客户端或服务端的hook脚本
  • HEAD 传说中的HEAD指针,指明当前处于哪个分支
  • objects Git对象存储目录
  • refs Git引用存储目录
  • branches 放置分支引用的目录

其中descriptionconfighooks这些不在讨论中,后文会直接忽略。

git add

Gitcommit之前先要通过git add添加文件,这个操作Git内部会做些什么呢?

执行如下操作:

  • echo "Hello Git" > a.txt生成一个a.txt
  • 再通过git add a.txt添加文件
  • 查看.git目录
├── HEAD
├── branches
├── index
├── objects
│   ├── 9f
│   │   └── 4d96d5b00d98959ea9960f069585ce42b1349a
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

可以看到,多了一个index文件。并且在objects目录下多了一个9f的目录,其中多了一个4d96d5b00d98959ea9960f069585ce42b1349a文件。

其实9f4d96d5b00d98959ea9960f069585ce42b1349a就是一个Git对象,称为blob对象

这个文件名(或者叫对象名)是怎样来的呢?简单的说,就是Git会先生成一个文件头,其中包含这个对象的类型(比如blob)和原始文件长度加上一个空字节。文件头再加上原始文件内容,然后算出一个SHA-1。这个SHA-1有40位,前两位会用于新建目录,后38位用于文件名。所以,完整的对象名应该把上一级目录名给包含进去的。

可以通过Git的底层命令git cat-file -p查看其内容:

$ git cat-file -p 9f4d96d5b00d98959ea9960f069585ce42b1349a
Hello Git

可以看到,其中的内容和a.txt文件是一模一样的。

通过git cat-file -t查看对象的类型:

$ git cat-file -t 9f4d96d5b00d98959ea9960f069585ce42b1349a
blob

确实是blob类型。那index文件又是什么鬼?

Git命令的背后_第1张图片

通过git add的文件会先放到Staging Area(有些书也叫Cached Area)。而index文件就是这个Staging Areaindex本身是一个二进制文件,有自己专有的存储格式,详情可见。

我们可以通过git ls-files --stage查看index文件的内容:

$ git ls-files --stage
100644 9f4d96d5b00d98959ea9960f069585ce42b1349a 0   a.txt

小结:git add命令会将我们的文件保存成一个blob对象,然后更新index文件表明该文件已经暂存。

git commit

通过git commit -m "first commit"提交,然后再查看.git目录:

├── HEAD
├── branches
├── index
├── logs
│   ├── HEAD
│   └── refs
│       └── heads
│           └── master
├── objects
│   ├── 88
│   │   └── 23efd7fa394844ef4af3c649823fa4aedefec5
│   ├── 91
│   │   └── 0fc16f5cc5a91e6712c33aed4aad2cfffccb73
│   ├── 9f
│   │   └── 4d96d5b00d98959ea9960f069585ce42b1349a
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── master
    └── tags
  • objects目录下多了两个对象8823efd7fa394844ef4af3c649823fa4aedefec5910fc16f5cc5a91e6712c33aed4aad2cfffccb73
  • 多了一个log目录,里边存了好些东西,先不管它~~

.git/refs/heads下多了一个master文件,可以直接查看:

$ cat .git/refs/heads/master 
910fc16f5cc5a91e6712c33aed4aad2cfffccb73

该文件是一个文本文件,里边保存着一个对象的名称。从上文可以看到,该对象是新增加的。查看一下它的类型和内容:

$ git cat-file -t 910fc16f5cc5a91e6712c33aed4aad2cfffccb73   
commit

$ git cat-file -p 910fc16f5cc5a91e6712c33aed4aad2cfffccb73
tree 8823efd7fa394844ef4af3c649823fa4aedefec5
author yjiyjige <[email protected]> 1472309876 +0800
committer yjiyjige <[email protected]> 1472309876 +0800

first commit

可以看到该对象的类型是commit,而它的内容包含了另外的一个对象的引用(tree对象),还有就是作者信息、提交者信息和提交的日志。

现在来看看8823efd7fa394844ef4af3c649823fa4aedefec5这个对象:

git cat-file -t 8823efd7fa394844ef4af3c649823fa4aedefec5
tree

$ git cat-file -p 8823efd7fa394844ef4af3c649823fa4aedefec5
100644 blob 9f4d96d5b00d98959ea9960f069585ce42b1349a    a.txt

该对象类型是tree,而该对象指向了我们一开始add生成的那个blob对象,并且保存着文件名。

接下来执行如下操作:

$ mkdir temp

$ echo "Second file" > temp/b.txt

$ git add temp/b.txt 

$ git commit -m "second commit"

然后看下.git目录的变化:

├── HEAD
├── branches
├── index
├── logs
│   ├── HEAD
│   └── refs
│       └── heads
│           └── master
├── objects
│   ├── 20
│   │   └── d5b672a347112783818b3fc8cc7cd66ade3008
│   ├── 80
│   │   └── 0910d78c39017816173b00d3a1074800854612
│   ├── 88
│   │   └── 23efd7fa394844ef4af3c649823fa4aedefec5
│   ├── 8e
│   │   └── 19c6677af0c3a80d5e2a3d1c1dffe9934431a5
│   ├── 91
│   │   └── 0fc16f5cc5a91e6712c33aed4aad2cfffccb73
│   ├── 9f
│   │   └── 4d96d5b00d98959ea9960f069585ce42b1349a
│   ├── e8
│   │   └── b5b9a992fe8b5d24b09ef55b97739f35221b1d
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── master
    └── tags

可以看到多了4个对象,但我们先看下.git/refs/heads/master文件:

$ cat .git/refs/heads/master 
800910d78c39017816173b00d3a1074800854612

可以看到引用了一个新的对象,再看看这个对象是什么:

$ git cat-file -t 800910d78c39017816173b00d3a1074800854612
commit

$ git cat-file -p 800910d78c39017816173b00d3a1074800854612
tree 8e19c6677af0c3a80d5e2a3d1c1dffe9934431a5
parent 910fc16f5cc5a91e6712c33aed4aad2cfffccb73
author yjiyjige <[email protected]> 1472311564 +0800
committer yjiyjige <[email protected]> 1472311564 +0800

second commit

还是一个commit对象,该对象又引用了一个新的tree对象,而且有一个parent后面跟着的是我们上次提交的commit对象。看看所引用的tree对象是怎样的:

$ git cat-file -p 8e19c6677af0c3a80d5e2a3d1c1dffe9934431a5
100644 blob 9f4d96d5b00d98959ea9960f069585ce42b1349a    a.txt
040000 tree e8b5b9a992fe8b5d24b09ef55b97739f35221b1d    temp

tree对象包含了第一次add的生成的blob对象(对应于a.txt文件)和另一个tree对象。几乎可以想到,这个tree对象中应该包含了一个blob对象,对应于b.txt文件:

$ git cat-file -p e8b5b9a992fe8b5d24b09ef55b97739f35221b1d
100644 blob 20d5b672a347112783818b3fc8cc7cd66ade3008    b.txt

如果我们把这些对象的引用关系,包括master文件用图画出来,大概是这个样子:

Git命令的背后_第2张图片

小结:

  • tree对象相当于一个目录(或者叫文件夹),其中包含blob对象和其他tree对象。
  • 每一次提交都会有一个commit对象,commit对象中会有一个tree对象和一个指和上一次提交的引用。
  • master分支其实就是一个引用而已,指向某一个提交对象。

Q&A

怎么理解每次提交都是一个“快照”

从上文中我们可能看到,每一个commit对象所引用的tree对象最终可以递归得出提交时的所有的文件,并不是说会把所有的文件都重新备份一次。而Git在add文件时,确实会把文件完整地保存成一个新的blob对象,我们可以验证:

$ echo "Third" > a.txt

$ git add a.txt

$ git commit -m "third commit"

会多几个对象呢?

├── HEAD
├── branches
├── index
├── logs
│   ├── HEAD
│   └── refs
│       └── heads
│           └── master
├── objects
│   ├── 16
│   │   └── df5eafaccb32649a890005b3f693fed266fc3d
│   ├── 20
│   │   └── d5b672a347112783818b3fc8cc7cd66ade3008
│   ├── 56
│   │   └── 9f012efac9a65ee515e488e244b89cbe795d6e
│   ├── 80
│   │   └── 0910d78c39017816173b00d3a1074800854612
│   ├── 88
│   │   └── 23efd7fa394844ef4af3c649823fa4aedefec5
│   ├── 8e
│   │   └── 19c6677af0c3a80d5e2a3d1c1dffe9934431a5
│   ├── 91
│   │   └── 0fc16f5cc5a91e6712c33aed4aad2cfffccb73
│   ├── 9f
│   │   ├── 4d96d5b00d98959ea9960f069585ce42b1349a
│   │   └── 7da334be98d63c78ccf1e94414b0664e649e5f
│   ├── e8
│   │   └── b5b9a992fe8b5d24b09ef55b97739f35221b1d
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── master
    └── tags

多了三个对象,直接通过master一步步看:

$ cat .git/refs/heads/master
569f012efac9a65ee515e488e244b89cbe795d6e

$ git cat-file -p 569f012efac9a65ee515e488e244b89cbe795d6e
tree 9f7da334be98d63c78ccf1e94414b0664e649e5f # 新的tree对象
parent 800910d78c39017816173b00d3a1074800854612
author yjiyjige <[email protected]> 1472317420 +0800
committer yjiyjige <[email protected]> 1472317420 +0800

third commit

$ git cat-file -p 9f7da334be98d63c78ccf1e94414b0664e649e5f
100644 blob 16df5eafaccb32649a890005b3f693fed266fc3d    a.txt # 文件名一样,但blob对象已经不一样了
040000 tree e8b5b9a992fe8b5d24b09ef55b97739f35221b1d    temp # 和上次的tree对象是一样的

$ git cat-file -p 16df5eafaccb32649a890005b3f693fed266fc3d
Third

$ git cat-file -p 9f4d96d5b00d98959ea9960f069585ce42b1349a
Hello Git
# 可以看到老blob对象还在

可以发现,新生成一个tree对象,指向了一个新的blob对象(还是对应于a.txt)只不过内容变了。原来的temp目录对应的tree对象没有变化,所以直接引用。

等等,如果每次修改都保存一个完整的文件,那仓库不是很快就变得巨大?

理论上来说,每次修改只需要保存这个文件diff就行了,但那样就实现不了Git这么优雅的设计了。Git是通过“打包”来实现的。我们调用git gc,然后看下仓库的文件:

├── HEAD
├── branches
├── index
├── logs
│   ├── HEAD
│   └── refs
│       └── heads
│           └── master
├── objects
│   ├── info
│   │   └── packs
│   └── pack
│       ├── pack-b25e184d1a96e5f1bde09c941be14cbe2cdb1289.idx
│       └── pack-b25e184d1a96e5f1bde09c941be14cbe2cdb1289.pack
├── packed-refs
└── refs
    ├── heads
    └── tags

WTF!!!所有对象都不见了!甚至master都不见了!

莫方,我们看看packed-refs是什么:

$ cat packed-refs 
# pack-refs with: peeled fully-peeled 
569f012efac9a65ee515e488e244b89cbe795d6e refs/heads/master

看来至少master还是在的。再通过git verify-pack -v看看.idx文件是什么东西:

$ git verify-pack -v objects/pack/pack-b25e184d1a96e5f1bde09c941be14cbe2cdb1289.idx
569f012efac9a65ee515e488e244b89cbe795d6e commit 215 147 12
800910d78c39017816173b00d3a1074800854612 commit 216 148 159
910fc16f5cc5a91e6712c33aed4aad2cfffccb73 commit 167 117 307
16df5eafaccb32649a890005b3f693fed266fc3d blob   6 15 424
20d5b672a347112783818b3fc8cc7cd66ade3008 blob   12 21 439
9f7da334be98d63c78ccf1e94414b0664e649e5f tree   64 75 460
e8b5b9a992fe8b5d24b09ef55b97739f35221b1d tree   33 44 535
8e19c6677af0c3a80d5e2a3d1c1dffe9934431a5 tree   64 75 579
9f4d96d5b00d98959ea9960f069585ce42b1349a blob   10 19 654
8823efd7fa394844ef4af3c649823fa4aedefec5 tree   33 44 673
non delta: 10 objects
objects/pack/pack-b25e184d1a96e5f1bde09c941be14cbe2cdb1289.pack: ok

原来.idx文件记录了之前的所有对象,而现在的数据保存在了.pack文件中。通过.idx文件记录的起始值、文件长度这些信息就可以把原有的对象提取出来了。如果文件相似,其实是会保留新版本,而老版本保留diff的形式存在!

回到“快照”这个概念,Git在底层做了脏活,只要通过当时提交的文件对应的blob对象引用,就可以还原出原始文件。所以,从用户角度,blob文件相当于原始文件

$ git cat-file -p 9f4d96d5b00d98959ea9960f069585ce42b1349a
Hello Git

这部分不好理解,甚至很多书都会直接说“Git保留文件快照,而其他VCS是保存diff”。其实Git底层也会保存diff的,只不过我们感觉不到diff的存在而已。

关于打包这部分,详细请见Pro git。

未完,可能会续~

你可能感兴趣的:(Git命令的背后)