Skip to content
涧泉 edited this page Oct 14, 2013 · 36 revisions

git设计与使用

中间件团队迁移 git 已经有一段时间, git 有不少令人惊叹的特性。 理解 git 的设计, 有助于我们更好的使用 git。 因为篇幅和精力有限, 本文将重点介绍 git 本地仓库结构, 暂略过其他话题。

git 分布式仓库

svn 是典型的 "服务器-客户端" 结构, 1 个服务器对应 N 个客户端。 svn 操作服务器和客户端的命令也是分开的, 分别是 svnadmin 和 svn。 如 svnadmin create 创建仓库, svn commit 提交修改。 仓库保存在服务器上, 客户端只有一份工作拷贝(WorkingCopy)和很小的 ".svn" 元数据。

svn "服务器-客户端" 仓库结构如下图所示:

而 git 是每台 PC 上都有一个完整的 ".git" 仓库和一份工作拷贝。 简单来看好像 git 把 svn 服务器上的仓库直接搬到了本地, 叫做本地仓库。 git 命令也同时具备操作仓库(服务器)和工作拷贝(客户端)的功能。 如 git init 创建仓库, git commit 提交修改。 git 整合了客户端和服务器, 消除了客户端和服务器的区别。 git 是分布式结构,每个节点都是等价的。

git 分布式仓库结构如下图所示:

多人协作时, 通常也会使用一个中央仓库作为协作基准,如 github 或 gitlab。 中央仓库通常没有工作拷贝,这种仓库叫做 bare 仓库(通过git init --bare创建)。

git 中央仓库结构如下图所示:

每台 PC 上工作拷贝的修改总是先提交(git commit)到本地仓库。 仓库间再相互通信(git push, git pull)同步数据。 初看起来,似乎更麻烦了。

实际上这种方式有很多好处:

  • 允许离线工作。 没有网络的情况下仍然可以提交代码。 还可以在提交到服务器前充分整理和测试代码。
  • 操作速度快。 常用操作都是本地操作,速度很快。
  • 容灾。 每个git仓库都在互相备份,中央服务器挂了没关系,随便 clone 一个节点就能轻松重建中央仓库。
  • 轻松备份和迁移。备份和迁移仓库跟 clone 一份工作拷贝一样简单。

git kv系统

git 仓库中的数据, 底层是用一套 kv 系统存储。 创建一个 git 本地仓库, 结合 git 命令,可以帮助我们一探 git 内部结构。

创建并查看一个空 git 仓库:

observer.hany@ali-59375nm:~/tmp$ git init git-hello
Initialized empty Git repository in /home/observer.hany/tmp/git-hello/.git/
observer.hany@ali-59375nm:~/tmp$ cd git-hello/
observer.hany@ali-59375nm:~/tmp/git-hello$ ls -A
.git
observer.hany@ali-59375nm:~/tmp/git-hello$ ls .git
branches  config  description  HEAD  hooks  info  objects  refs

git init git-hello 创建了一个空仓库, "git-hello" 是工作拷贝文件夹, git 仓库本身在 "git-hello/.git" 文件夹下。 在 git kv 系统中,value 是一种 git 对象, 而对象的 SHA1 值就是 key。 注意 git 仓库下有一个 " objects " 文件夹, 用来保存 git kv 系统中所有对象。

find 命令查看 objects 文件夹:

observer.hany@ali-59375nm:~/tmp/git-hello$ find .git/objects/
.git/objects/
.git/objects/pack
.git/objects/info
observer.hany@ali-59375nm:~/tmp/git-hello$ find .git/objects/ -xtype f

里面只有两个空文件夹, 没有任何文件。 一个空 git 仓库的 kv 系统不包含任何对象。

向 git 仓库提交数据时, 就会向 kv 系统中添加对象。 实际上不需要执行 git commit, 执行 git add 准备提交时, 一些对象就会被添加到 kv 系统中。

添加一个文本文件试试看:

observer.hany@ali-59375nm:~/tmp/git-hello$ echo "hello world" > a.txt
observer.hany@ali-59375nm:~/tmp/git-hello$ git add a.txt
observer.hany@ali-59375nm:~/tmp/git-hello$ find .git/objects/ -xtype f
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad

可以看到, 执行 git add a.txt 后, objects 文件夹下多了一个文件 .git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad

这个 kv 系统的设计也很简单, 对象的 SHA1 作为 key, key 的前两个字符作为目录名, 剩下的字符作为文件名, 使用两级目录结构存储, 而文件内容, 就是 value, 即对象本身。

不能直接查看文件内容, git 在存储时用 zlib 对文件内容进行了压缩, 使用 zlib 解压后才能查看文件内容。

用 python 解压文件内容:

observer.hany@ali-59375nm:~/tmp/git-hello$ python
Python 2.7.3 (default, Sep 26 2013, 20:08:41) 
[GCC 4.6.3] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> f = open('.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad', 'rb')
>>> fdata = f.read()
>>> f.close()
>>> import zlib
>>> data = zlib.decompress(fdata)
>>> data
'blob 12\x00hello world\n'

对比原文件内容发现, git 存储数据时根据对象类型添加了一个文件头。在 git 中所有数据文件均使用 blob 对象类型存储。

用 python 计算解压后的对象的 sha1 值:

>>> import hashlib
>>> m = hashlib.sha1()
>>> m.update(data)
>>> m.hexdigest()
'3b18e512dba79e4c8300dd08aeb37f8e728b8dad'

对象的 sha1 值, 正好是对象的 key, 也是文件路径。 key 唯一标识了一个 git 对象, key 就是对象 id, 对象地址。 kv 系统中的数据是不可变的, 这样相同的数据在 git 仓库中只需要存储一份。 根据对象内容即可以唯一计算出对象地址, git kv 系统又被称作内容寻址系统。

git 提供了一个底层命令 git cat-file 可以直接根据对象地址查看对象。

observer.hany@ali-59375nm:~/tmp/git-hello$ git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
git-hello world

git cat-file -p <sha1> 中的 -p 参数表示自动以友好可读的方式打印对象内容。 如打印存储 "a.txt" 文件的 blob 对象时, 直接打印出文件内容, 去掉了 blob 文件头。

git 对象类型

git kv 系统中 value 有 4 种数据类型:

  • blob, 二进制数据。存储文件数据。
  • tree, 树。 类似文件系统中的文件夹。 tree 对象通过文件名引用子结点, 子结点可以是其他 tree 对象(子文件夹)或者 blob 对象(文件)。
  • commit, 提交。 记录一次用户提交,即版本控制系统中的一个版本。 commit 指向根目录树, 并包含作者、时间、日志等提交信息。
  • 带注解的 tag。指向一个 commit, 并包含额外的注解信息。

blob 对象用来存储文件, 我们前面已经见过。 git 版本控制以文件为基本单位, 使用 git add, git rm 增删文件时, git 会根据需要自动创建 tree 对象来组织文件。

现在 kv 系统中只有一个对象:

observer.hany@ali-59375nm:~/tmp/git-hello$ find .git/objects/ -xtype f
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad

进行一次提交后, git 将创建一个提交对象, 指向根目录树对象, 并将根目录树对象和提交对象保存到 kv 系统中。 可以看到, git kv 系统中将增加 2 个新的对象。

observer.hany@ali-59375nm:~/tmp/git-hello$ git commit -m "add a.txt"
[master (root-commit) 19c03e9] add a.txt
 1 file changed, 1 insertion(+)
 create mode 100644 a.txt
observer.hany@ali-59375nm:~/tmp/git-hello$ find .git/objects/ -xtype f
.git/objects/eb/aa691b5554f29ac9d4f37811a1da6f24d376a1
.git/objects/19/c03e914ca022a33a1f30b27500ff6411a3eadc
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad

使用 git cat-file 查看对象时, 如果对象地址 (key) 前缀不冲突, 可以仅指定最少 4 个字母的地址前缀。 如使用 git cat-file -p 3b18 可以查看刚才的 blob 对象。

observer.hany@ali-59375nm:~/tmp/git-hello$ git cat-file -p 3b18
hello world

使用 git cat-file -p 查看新增的两个对象。

一个对象 ebaa 是根目录树 tree 对象, 它有一个名为 "a.txt" 的结点, 指向刚才的 blob (文件)对象 3b18

observer.hany@ali-59375nm:~/tmp/git-hello$ git cat-file -p ebaa
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	a.txt

另一个对象 19c0 是 commit 对象, 指向根目录树 tree 对象 ebaa 并包含提交信息。

observer.hany@ali-59375nm:~/tmp/git-hello$ git cat-file -p 19c0
tree ebaa691b5554f29ac9d4f37811a1da6f24d376a1
author 涧泉 <[email protected]> 1381326684 +0800
committer 涧泉 <[email protected]> 1381326684 +0800

add a.txt

git 中的版本号就是提交对象的 sha1 key, 或者说 id, 地址。 如上述的 19c0。 实际上, 不只是提交版本, git 中的所有对象都通过一个这样的 sha1 id 唯一标识。

相同对象在任意机器上的地址都是一样的, 从而可以实现分布式的数据存储和数据同步。 git 对象系统可称作是磁盘上的分布式对象系统。

git 引用和符号引用

就像我们在 Java 中不会直接使用对象地址来访问对象一样, 在 git 中通常也是使用有名字的引用来访问对象。

看一下 git 仓库 "git-hello/.git" 下的内容:

observer.hany@ali-59375nm:~/tmp/git-hello$ ls .git
branches        config       HEAD   index  logs     refs
COMMIT_EDITMSG  description  hooks  info   objects

git 引用保存在 "refs" 文件夹下。

看一下这个文件夹:

observer.hany@ali-59375nm:~/tmp/git-hello$ find .git/refs/ -xtype f
.git/refs/heads/master
observer.hany@ali-59375nm:~/tmp/git-hello$ cat .git/refs/heads/master
19c03e914ca022a33a1f30b27500ff6411a3eadc

现在 git 仓库中只有一个引用。 引用文件的设计也非常简单, 文件名就是引用名字, 文件内容就是引用指向的对象地址。

可以看到, master 引用指向 commit 对象 19c0

git 提供了一条命令 git show-ref 用来查看本地仓库中的所有引用。

observer.hany@ali-59375nm:~/tmp/git-hello$ git show-ref
19c03e914ca022a33a1f30b27500ff6411a3eadc refs/heads/master

引用就像是指向对象的指针, git 中还有一种指向引用的指针, 即二级指针, 叫做符号引用。

注意 git 仓库下有一个 HEAD 文件, 这是一个符号引用。

observer.hany@ali-59375nm:~/tmp/git-hello$ cat .git/HEAD
ref: refs/heads/master

符号引用也是一个简单的文本文件。 git 提供了一条命令 git symbolic-ref, 用来查看符号引用指向的引用。

observer.hany@ali-59375nm:~/tmp/git-hello$ git symbolic-ref HEAD
refs/heads/master

可见, 符号引用 HEAD 指向引用 master, 而引用 master 指向 commit 对象 19c0

使用 git cat-file 查看对象时, 也可以通过指定引用或符号引用查看对象。

observer.hany@ali-59375nm:~/tmp/git-hello$ git cat-file -p master
tree ebaa691b5554f29ac9d4f37811a1da6f24d376a1
author 涧泉 <[email protected]> 1381326684 +0800
committer 涧泉 <[email protected]> 1381326684 +0800

add a.txt
observer.hany@ali-59375nm:~/tmp/git-hello$ git cat-file -p HEAD
tree ebaa691b5554f29ac9d4f37811a1da6f24d376a1
author 涧泉 <[email protected]> 1381326684 +0800
committer 涧泉 <[email protected]> 1381326684 +0800

add a.txt

git 分支

在 master 分支上增加两次提交:

observer.hany@ali-59375nm:~/tmp/git-hello$ echo "second" >> a.txt
observer.hany@ali-59375nm:~/tmp/git-hello$ git add a.txt
observer.hany@ali-59375nm:~/tmp/git-hello$ git commit -m "a.txt update"
[master e3cf999] a.txt update
 1 file changed, 1 insertion(+)
observer.hany@ali-59375nm:~/tmp/git-hello$ echo "third" >> a.txt
observer.hany@ali-59375nm:~/tmp/git-hello$ git add a.txt
observer.hany@ali-59375nm:~/tmp/git-hello$ git commit -m "a.txt update"
[master 6543808] a.txt update
 1 file changed, 1 insertion(+)

一次提交记录了某个时刻工作目录所有文件的完整状态。 修改文件后再次提交, 新提交对象将指向旧提交对象, 多次提交串联成一条提交链。

分支就是一条提交链。 git 使用一个指向提交链头结点的引用记录一个分支。 通过最新的头结点 commit 对象, 可以顺序找到所有历史 commit 对象, 找到所有分支变更历史。 也可以说, 一个分支就是一个头结点引用。

前面提到的 master 引用, 就是 git 仓库默认的第一个分支, 通常作为项目主分支。

使用 gitk 命令以图形化方式查看分支上的提交历史。

分支是一种在提交时自动更新的可变引用。 提交一个新版本时, 新 commit 对象指向旧头节点, 成为新的头结点, git 修改分支引用指向新 commit 对象, 使分支引用总是指向提交链的头结点。

git branch 命令可以查看本地仓库上有哪些分支。

observer.hany@ali-59375nm:~/tmp/git-hello$ git branch
* master

现在这个仓库只有一个 master 分支。

git branch 命令也可以用来创建分支, 语法是 git branch <branch-name> [start-point]。 "start-point" 将作为新分支的头结点, 即新分支引用最开始指向的位置, 可看作是新分支的 "起点"。 没有指定 "start-point" 时则以当前分支作为起点, 这时, 两个分支指向同一个 commit 对象。 git 创建一个新分支只是创建一个引用, 不需要创建其他文件, 也不需要向 kv 系统添加数据, 代价非常小, 常被称为 "轻量级分支"。

工作拷贝只能对应本地仓库中的一个分支, 称为当前分支。 使用 git commit 提交时, 新的 commit 对象串接到当前分支上, 并更新当前分支的引用。 使用 git branch 查看所有本地分支时, 当前分支前面有一个星号("*")。

创建 test 分支并查看分支列表:

observer.hany@ali-59375nm:~/tmp/git-hello$ git branch test
observer.hany@ali-59375nm:~/tmp/git-hello$ git branch
* master
  test

git 使用一个指向当前分支引用的符号引用来记录当前分支, 即之前提到的 "HEAD", 对应 git 仓库下的文件 ".git/HEAD"。

使用 git checkout <branch-name> 命令切换当前分支。 切换分支时, 工作拷贝文件夹下所有受版本控制的文件也会同时替换成新分支头结点保存的文件, 以开始在新分支上工作, 并提交到新分支。 两个分支指向同一个头结点时, 则只是修改符号引用 "HEAD" 指向的分支引用, 不必切换工作拷贝文件。

observer.hany@ali-59375nm:~/tmp/git-hello$ git checkout test 
Switched to branch 'test'
observer.hany@ali-59375nm:~/tmp/git-hello$ git branch
  master
* test

git 中还有一种 "不可变" 的引用, 叫做轻量级 tag (light weight tag)。 用来记录分支上某个有意义的提交, 如一个稳定版本。 实际上, tag 引用不仅可以指向 commit 对象, 也可以指向其他类型的对象。

git tag 命令可以查看、创建 tag, 语法与 git branch 类似。

observer.hany@ali-59375nm:~/tmp/git-hello$ git tag first 19c0
observer.hany@ali-59375nm:~/tmp/git-hello$ git tag
first

gitk 以图形化方式查看分支历史 。

git 提交

我们已经知道, 工作拷贝中的树型文件夹结构, 在 git kv 系统中是以 tree 对象来表示。

git 提交分为两步, git 使用一个暂存区(index)来辅助构造根目录树 tree 对象。 用户使用 git add, git rm 在这个 tree 对象上添加、删除文件, 仔细准备要提交的内容, git 自动根据需要添加删除子文件夹 tree 对象。 然后使用 git commit 执行提交动作。 因为 tree 对象只包含文件名和子结点地址, 所以在 git add 时 git 就会将要添加的文件以 blob 对象存储到 git kv 系统中, 这样将 tree 对象保存到 kv 系统中时, 它的所有子结点都是已经存在的了。

如果只是修改已经存在的文件, 使用 git commit -a 命令可以让 git 自动将修改后的文件添加到暂存区, 并完成提交, 实现一步提交。

git 仓库间数据同步

前面所说的内容都只涉及到单机本地仓库。 一个本地仓库可以与多个远程仓库通信, 相互推拉同步数据。 使用 gitlab 作为中央仓库时, 每个本地仓库通常只需要与 gitlab 中央仓库通信。

可以用一个地址表示一个远程仓库。 如 [email protected]:observer.hany/git-hello.githttp://gitlab.alibaba-inc.com/observer.hany/git-hello.git。 使用 git remote add <name> <url> 命令可以添加一个关联的远程仓库, 为其指定一个仓库名字。 使用 git remote -v 命令可以查看本地仓库关联的远程仓库列表。

使用 git clone 命令克隆一个仓库时, 被克隆的仓库默认被添加为名为 "origin" 的远程仓库。 git init 新创建的仓库默认没有关联任何远程仓库。

使用 git remote add 手动添加远程仓库:

observer.hany@ali-59375nm:~/tmp/git-hello$ git remote -v
observer.hany@ali-59375nm:~/tmp/git-hello$ git remote add origin [email protected]:observer.hany/git-hello.git
observer.hany@ali-59375nm:~/tmp/git-hello$ git remote -v
origin	[email protected]:observer.hany/git-hello.git (fetch)
origin	[email protected]:observer.hany/git-hello.git (push)

不同仓库节点上的分支是彼此独立的。 直接 checkout 一个没有本地分支的远程分支时, git 会从远程分支自动创建一个本地分支, 并将对应的远程分支设置为上游分支。

也可以使用 git branch --set-upstream <branch-name> <start-point> 命令手动设置本地分支的上游分支。

observer.hany@ali-59375nm:~/tmp/git-hello$ git branch --set-upstream master origin/master 
Branch master set up to track remote branch master from origin.

git push 命令将本地仓库的修改推送到远程仓库, git pull 命令将远程仓库的修改拉取到本地, 并合并到本地当前分支。 调用这两个命令时可以指定需要同步的远程仓库名和分支名。

默认 push (即不带参数执行 "git push" 命令), pull 时规则如下:

  • 默认 push 只会向一个远程仓库 push 数据, 确定 push 到哪个仓库的规则如下:

    • 如果当前本地分支存在上游远程分支, 则 push 到上游分支对应的仓库.
    • 否则, push 到 origin 仓库.
  • 默认 push 哪些分支由 "push.default" 决定. 使用 git config 设置 "push.default" 的值, 建议设置 git config --global push.default upstream.

  • 默认 pull 只会 pull 当前分支的上游分支, 一个本地分支只能设置一个上游分支. 如果当前分支没有设置上游分支, 默认 pull 会报错.

假设 gitlab 上有一个仓库 git-inner-and-use, 包含 master 和 hello 两个分支, master 是当前分支.

  1. git clone [email protected]:observer.hany/git-inner-and-use.git

    • 把整个仓库 clone 到本地, 设置 gitlab 仓库为 origin 远程仓库.
    • 从 origin 上的 master 分支建立本地 master 分支, 设置 origin 上的 master 为上游分支.
    • checkout 出 master 作为当前分支
  2. git checkout hello

    • 本地 hello 分支不存在, 从 origin 上的 hello 分支建立本地 hello 分支, 设置 origin 上的 hello 为上游分支.
    • checkout 出 hello 作为当前分支
  3. git checkout master

    • 本地存在 master 分支, checkout 出 master 作为当前分支
  4. git pull

    • 拉取当前分支对应的上游分支, 即 origin 上的 master 分支, 与当前分支合并.
    • 将合并结果提交到本地仓库.
  5. git push

    当前分支对应上游分支的仓库是 origin, 默认会向 origin 推送数据. "push.default" 配置决定要推送哪些分支.

    • " matching " (git 1.x 默认值).

      本地与 origin 上 同名 的分支, 即 master 和 hello, 都会被推送到 origin 上.

    • " upstream " (建议 1.x 设置为这个值, git config --global push.default upstream)

      当前分支对应的 上游分支, 即 master, 会被推送到 origin 上.

    • " simple " (git 2.x 默认值, 仅 git 2.x 支持).

      当前分支对应的 上游分支, 并且 同名, 即 master, 会被推送到 origin 上.