Git原理入门

2014/02/12 Git

Git原理入门

Git 是最流行的版本管理工具,也是程序员的必备技能之一。了解 Git 的原理,能有助于我们工作更好的使用 Git。

概念

Git 是一个分布式版本控制软件,在使用的过程中,与 CVS 类不同,不需要使用服务端,就可以实现版本控制。

在进行开发工作的时候,我们对代码的源文件进行修改,然后通过 commit 命令提交到本地仓库,在通过 push 命令将代码同步到远程仓库中。

相信熟悉 Git 的同学都知道,我们克隆到本地的项目中,都会有一个 .git 的隐藏文件夹,这个文件夹中的内容就是 Git 本地仓库, 而工作区就是我们看到的其它原代码。所以,对一个 Git 仓库 来说,最重要的便是 .git 文件夹里面的内容了。

Git 的工作原理

在本地工程中,.git 文件夹为 Git 本地仓库,库目录结构如下:

  • hooks: 存储钩子的文件夹,可以注入到 git 生命周期的所有流程
  • objects: 存放 git 对象,代码文件、目录等都会转换成对象存储到这个目录下中
  • refs: 存储分支以及TAG的指针文件。
  • HEAD: 当前工作区执行的代码分支的指针,一般指向 refs 下的某个文件。
  • config: 存储当前项目的一些配置,如 remote url、用户信息

在这些目录和文件中,其中最重要的为 objects 文件夹。在 Git 的设计中,所有的核心对象都会往里装。这些对象又分为:

  • blob: 而进制大对象,使用 zlib (一种无损压缩算法) 压缩算法对文件内容进行压缩后的结果。
  • tree: 对应于文件目录,用于存储文件名列表以及文件类型。
  • commit: 对应一个 commit , 存储信息中包含 顶层源目录的 tree hash值 、 时间戳 、 commit 日志信息 、 0个或多个父commit hash值

初始化

我们打算对该项目进行版本管理,第一件事就是使用git init命令,进行初始化。

git init

git init命令只做一件事,就是在项目根目录下创建一个.git子目录,用来保存版本信息。

保存对象

新建一个空文件test.txt。然后,把这个文件加入 Git 仓库,也就是为test.txt的当前内容创建一个副本。

 git hash-object -w test.txt  
 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391  

git hash-object命令把test.txt的当前内容压缩成二进制文件,存入 Git。压缩后的二进制文件,称为一个 Git 对象,保存在.git/objects目录。

这个命令还会计算当前内容的 SHA1 哈希值(长度40的字符串),作为该对象的文件名。下面看一下这个新生成的 Git 对象文件。

上面代码可以看到,.git/objects下面多了一个子目录,计算时使用的是 SHA-1 的算法,其计算结果为 20 个字节组成,通常表示成 40 个 16 进制的形式的字符。对比 Hash 与文件结构可以看出,Git 使用的 Hash 前两个字符作为文件夹名称,后 38 个字符作为文件名,即表示为 hash[0,2]/hash[2:] 格式。

在 git 中,也有相应的工具查看 Hash 文件存储的数据类型以及数据内容,可以使用如下命令进行查看:

# 查看文件内容   
git cat-file -t xxxHash值 
# 查看文件内容  
git cat-file -p xxxHash值   

Hash 值的计算

下面在来看一下各 Hash 值的计算:

计算 blob 的 Hash

在 Git 中, Blob 的文件中,存储的内容格式如下:

blob <content length><NUL><content>  

其中 content length 指的是源文件内容的长度, NUL 为 ,而 content 为源文件的内容。举个例子, 我在一个空仓库中,添加了一个文件 Main.java ,其内容如下:

public class Main {  
    public static void main(String [] args) {  
        System.out.println("This is Main.java");  
    }  
}  

因此,存储到 objcets 中的文件内容为:

blob 122public class Main {  
    public static void main(String [] args) {  
        System.out.println("This is Main.java");  
    }  
}  

为了更好的验证这个逻辑,我写了一段 Java 记算此 Hash 的测试代码,代码如下:

public static HashData getBlobHash(File file) throws Exception {  
    String content = new String(FileUtil.read(file));  
    String header = "blob " + content.length() + "";  
    return getHash(header + content);  
}  

运行出来的结果正好为 78ace89700a69e490c86f54fbe9d12f0cfb2dbdb , 与 Git 中记算的结果一致。

计算 tree 的 hash

在存储 Main.java 文件类容中,并没有将文件名存储进去,那 Git 是在哪儿存储的呢?对,就是现在要讲的 tree 的结构。

com
   |example
           |Main.java
           |new.txt

和 blob 类似, tree 也有对应的内容存储结构:

tree <content length><NUL><file mode> <file name><NUL><file hash>  

其中 content length 指的后面 的长度, NUL 为 , file mode 指的是文件类型, 列举其中几种:

  • 0100000000000000 (040000): 文件夹
  • 1000000110100100 (100644): 常规非执行文件
  • 1000000110110100 (100664): 常规不可执行组可写文件
  • 1000000111101101 (100755): 常规可执行文件
  • 1010000000000000 (120000): 软链文件

file name 指的是文件名称, file hash 为 blob 或者 tree 的 hash 值,使用的是 20 位二进制值,非 40 位的 16 进制字符串。

具体来看上面例子中的信息,我们从最下面的 blob 文件往上看,通过上面的脚本, 可以计算出 Main.java 的 hash 为:78ace89700a69e490c86f54fbe9d12f0cfb2dbdb, new.txt 的 hash 为:fa49b077972391ad58037050f2a75f74e3671e92,因此,针对 example 这一级目录的文件类容如下:

tree 72<NUL>  
100644 Main.java<NUL>78ace89700a69e490c86f54fbe9d12f0cfb2dbdb  
100644 new.txt<NUL>fa49b077972391ad58037050f2a75f74e3671e92  

注意:为了更好的阅读,上面的内容进行了换行处理,实际文件中并没有换行。格式中提到的 file hash 在此处为了便于展示,直接放了对应的 40 位的 16 进制字符串。

得到文件内容后,可以很方便的计算出 hash 为:f6e2e8e5243c07191d0c1f4353448bd57785c39d。也可以用 git 命令去验证:

➜ git cat-file -p f6e2e8e5243c07191d0c1f4353448bd57785c39d  
100644 blob 78ace89700a69e490c86f54fbe9d12f0cfb2dbdb Main.java  
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt  
➜ git cat-file -t f6e2e8e5243c07191d0c1f4353448bd57785c39d  
tree  

当然,计算这个,我也用 Java 实现了一个简单的计算。看一下代码的实现:

File[] allFiles = dir.listFiles();  
//按文件名进行字典排序  
SortedMap<File, HashData> allHash = new TreeMap<>(Comparator.comparing(File::getName));  
for (File file : allFiles) {  
    if (file.getName().equals(".git") || file.getName().equals(".DS_Store")) {  
        continue;  
    }  
   // 计算每一个文件的 hash  
    allHash.put(file, calcHash(file));  
}  
// 拼接文件子文件的 hash   
byte[] allContent = new byte[0];  
for (Map.Entry<File, HashData> item : allHash.entrySet()) {  
    String header = getFileMode(item.getKey()) + " " + item.getKey().getName() + "";  
    byte[] content = merge(header, item.getValue().originalData);  
    byte[] tempContent = merge(allContent, content);  
    allContent = tempContent;  
}  
String header = "tree " + allContent.length + "";  
byte[] mergedArray = merge(header, allContent);  
HashData hash = getHash(mergedArray, "SHA-1");  

计算 commit 的 hash

与前面的 tree 和 blob 的相似,按照固定格式进行拼装即可:

commit <content length><NUL>tree <tree hash>  
parent <parent hash>  
author <username> <email> <timestamp>  
committer <username> <email> <timestamp>  
  
<commit message>  

需要注意的是,此格式中的 tree hash 与 parent hash 是 40 位的 16 进制值, 换行也是真实的换行。按照上面的格式进行拼装后,使用 SHA-1 可以很方便的计算出 Hash 值。

暂存区

文件保存成二进制对象以后,还需要通知 Git 哪些文件发生了变动。所有变动的文件,Git 都记录在一个区域,叫做”暂存区”(英文叫做 index 或者 stage)。等到变动告一段落,再统一把暂存区里面的文件写入正式的版本历史。

git update-index命令用于在暂存区记录一个发生变动的文件。

git update-index --add --cacheinfo 100644   
3b18e512dba79e4c8300dd08aeb37f8e728b8dad test.txt 

Git 存储树结构

通过前面 blob 、tree、以及 commit 计算后,通过 commit 作为入口,就可以将所有的文件夹以及文件内容进行关联起来,构建一个树目录结构。 还是前面提到的那个例子,一共执行了三次提交:

  • 在创建的 Git 项目中,添加 Main.java 后,并使用 git commit -am “first commit” 进行第一次提交
  • 继续在 com/example 文件夹下,添加 new.txt 文件, 并使用git commit -am “add new file” 进行第二次提交
  • 修改 Main.java 的内容, 并使用 git commit -am “modify Main.java” 进行第三次提交

branch 的概念

所谓分支(branch)就是指向某个快照的指针,分支名就是指针名。哈希值是无法记忆的,分支使得用户可以为快照起别名。而且,分支会自动更新,如果当前分支有新的快照,指针就会自动指向它。比如,master 分支就是有一个叫做 master 指针,它指向的快照就是 master 分支的当前快照。

用户可以对任意快照新建指针。比如,新建一个 fix-typo 分支,就是创建一个叫做 fix-typo 的指针,指向某个快照。所以,Git 新建分支特别容易,成本极低。

Git 有一个特殊指针HEAD, 总是指向当前分支的最近一次快照。另外,Git 还提供简写方式,HEAD^指向 HEAD的前一个快照(父节点),HEAD~6则是HEAD之前的第6个快照。

每一个分支指针都是一个文本文件,保存在.git/refs/heads/目录,该文件的内容就是它所指向的快照的二进制对象名(哈希值)。

Search

    微信好友

    博士的沙漏

    Table of Contents