深入 Git

《Git 权威指南》读书笔记

Posted by John Mactavish on May 11, 2021

基本概念与模型

Git 是分布式版本控制系统(Distributed Version Control System),每个用户都能在本地维护自己的版本, 多个用户也能协同开发。下面介绍单用户 Git 的基本概念与模型。

使用 Git 的项目在根目录下会有一个 .git 目录,它是这个版本库的核心。若想备份一个版本库,只需把这个目录拷贝一份。 其典型结构如下:

config
description
HEAD
index
hooks/
info/
objects/
refs/

description 文件仅供 GitWeb 程序使用;config 文件包含这个项目的配置选项;info 目录包含一个全局性排除(global exclude)文件,用以放置那些不希望被记录在 .gitignore 文件中的忽略模式(ignored patterns);hooks 目录包含客户端或服务端的钩子脚本(hook scripts)。 剩下的四个条目很重要:HEAD 文件、index 文件和 objects 目录、refs 目录。objects 目录存储所有的对象;refs 目录存储指向对象(分支、远程仓库和标签等)的指针;HEAD 文件指向目前被检出(check out)的分支;index 文件保存暂存区信息。

Git 工作时将项目分为三个区域:工作区、暂存区、版本库。工作区是用户编辑代码的地方,是根目录下除 .git 目录以外的区域。 暂存区用作提交版本库前的缓冲,由 .git 目录的 index 文件保存信息。版本库是大部分 Git 命令起作用的地方,根据参数不同, 一些命令也会同步影响工作区或暂存区。下面介绍底层模型与常用命令的语义时主要基于版本库。

作为一个版本控制系统,Git 通过存储与管理四类对象进行版本控制。它们是数据对象(blob object)、树对象(tree object)、提交对象(commit object)与标签对象(tag object)。前三种保存在 objects 目录下,标签对象处于 refs 目录下,它们都通过基于对象信息生成的哈希值唯一标识。 数据对象用于保存单个文件,包含的信息是文件内容,不包含文件名,这有利于对象共享。树对象类似于文件系统的目录项, 包含了一条或多条记录(tree entry),每条记录含有一个指向数据对象或者子树对象的指针,以及相应的模式、类型、文件名信息。 不难想到,我们可以用一个树对象表示工作区根目录,工作区的子文件、子文件夹可以分别用数据对象、树对象表示,递归地挂载在根树对象下。 工作区的文件结构可能会随时间改变,而版本即是该文件结构对应的对象树的状态在某一时刻的快照。快照用提交对象表示,它包含的信息有:一个指向根树对象的指针,零个、一个或多个指向父提交对象的指针,作者/提交者信息(依照 user.nameuser.email 配置来设定,外加一个时间戳)以及提交注释(commit message)。显然,数据对象与树对象屏蔽了底层存储的细节,方便存储优化(如把相似的一组文件打包成一份基础文件和几份补丁,通过在基础文件上应用补丁即可即时计算出相似文件的内容),而提交对象则是版本控制的基础, 很多 Git 命令也是工作在提交对象上的。而在命令中使用哈希值来标识提交对象实在不是一个好主意,它难以记忆,也不包含语义信息。 使用标签对象是一种解决方案。存在两种类型的标签:轻量标签和附注标签。前者仅包含一个标签名以及一个永不移动的指向某个提交对象的指针。 后者增加了一个标签创建者信息、一个日期和一段注释信息,信息更充分,在实际中更加推荐。

refs 目录中存放着指针(或者叫引用),它们是指向对象(主要是提交对象)的。当然,这些概念没有那么泾渭分明,实际上树对象、提交对象不也有指针的语义吗,标签对象更是就在 refs 目录中。一种常用的指针是分支(branch)。虽然它与标签一样指向提交对象,但是语义不同: 标签标识的是单个对象,为它起了个有意义的名字;分支标识的是以某个对象为头结点的一系列历史提交。另一种常用的指针是 HEAD.git 目录下的 HEAD 文件通常是一个符号引用(symbolic reference),指向目前所在的分支。所谓符号引用,表示它是一个指向其他引用的指针。 在一些特殊情况下,HEAD 也可能指向提交对象。HEAD 指向的对象一般作为一些 Git 命令的默认参数,可以简化命令。还有一种特殊的引用类型叫远程引用(remote reference)。它其实是远程版本库的分支,特点在于它是只读的,也无法将 HEAD 引用指向它。

下面的示例包括三次 Commit,形象地显示了分支、提交对象、树对象与数据对象间的关系。

git-objects-relation

第一次提交记录了 test.txt 文件;第二次提交创建了 new.txt,并改变了 test.txt 文件的内容,这导致了两个新的数据对象的创建; 第三次提交增加了 bak 文件夹,内含原来的 test.txt 文件。不难发现,提交对象就是这样共享着底层存储文件。master 分支记录完整的三次提交的 历史,test 分支则只记录了前两次。

常用命令的语义

下面基于上述模型介绍 Git 常用命令的语义。

在项目文件夹下执行 git init 即可初始化一个空的版本库(没有任何对象、没有任何提交),同时暂存区自然也是空的。 通过 git add <工作区文件名> 即可创建一个数据对象,并将其加入暂存区。Git 没有守护进程来实时更新维护暂存区与版本库的状态, 所以在工作区发生变化后,暂存区的对象结构就会与工作区文件结构不一致。假如文件内容发生变化。此时,可以把变化后的文件通过 git add <工作区文件名> 更新到暂存区。 变化后的文件对应一个新的数据对象。而因为文件名不变,所以 git add 可以把文件更新与添加新文件区分开。 可以用 git rm --cached <暂存区文件名> 来删除暂存区中该文件名对应的对象,而不会影响工作区文件,也不在乎该文件是否还存在于工作区。 直接使用 git rm <暂存区文件名> 则可以同步删除工作区文件(如果该文件还存在于工作区的话),相当于在工作区执行 rm 后再在暂存区执行 git rm --cached。 使用 git mv <工作区原文件名> <工作区新文件名> 可以实现文件的重命名或移动,同步影响工作区与暂存区。它相当于依次执行 mv <工作区原文件名> <工作区新文件名>git rm <工作区原文件名>git add <工作区新文件名>。当文件名改变而内容不变时,Git 不会创建新的数据对象, 同时能识别出这是某个文件的更新,而非删除了一个文件、新增了一个不相干的文件。

实际上我们除了向上面这样一个文件一个文件地操作,也可整体地更新。工作区中未记录在 HEAD 所指结构中的文件属于未跟踪(untracked)文件, 其他的属于已跟踪文件。git add -u 可以令暂存区结构与工作区一致,但不包括未跟踪文件。git add -A 则在其基础上把未跟踪文件也新增进暂存库。 通过 git status 能够显示工作区结构与暂存区结构间的区别,以及暂存区结构与 HEAD 所指结构间的区别。 准备好后可以用 git commit -m <注释说明> 为当前的暂存区结构创建一个提交对象,并转送版本库。一般的,当 HEAD 指向某个分支时, 新的提交对象的父提交设置为该分支所指的提交对象,然后将该分支指向新的提交对象。当 HEAD 直接指向某个提交对象时, 我们称此时为“分离头结点”(detached HEAD)状态,HEAD 接下来将指向新的提交对象,而所有的分支均不动; 没有被某个分支指向的提交对象在 HEAD 指向别处后通常只能用哈希标识再次引用到,容易丢失,一般仅在临时操作时进入“分离头结点”状态。

before commit y6ov7:

                  9df56
                    ^
                    |
HEAD -> [base] -> 55gt8
                    ^
                    |
      [master] -> i906k


after commit y6ov7:

                9df56
                  ^
                  |
                55gt8 <----
                  ^       |
                  |       | 
    [master] -> i906k   y6ov7 <- [base] <- HEAD

使用 git checkout <分支名或提交对象名> 会先让 HEAD 指向指定分支或指定提交对象,然后用所指结构覆盖工作区与暂存区。git checkout . 会用暂存区结构覆盖工作区。

HEAD 指向某个分支时,git reset [--hard | --mixed | --soft] <提交对象名> 使得分支改变指向到指定的提交对象(HEAD 依然指向该分支,产生了相对位移),并根据参数用分支指向的结构覆盖工作区与暂存区(--hard 表明覆盖两者、--mixed 则只覆盖暂存区、--soft 不影响两者);HEAD 指向某个提交对象时,则此命令会让 HEAD 重指向指定的提交对象,不改变任何分支,但这种情况见的不多。 直接执行 git reset 则会用 HEAD 指向的结构覆盖暂存区。与 git checkout 命令相比, git reset [--hard | --mixed | --soft] <提交对象名> 的特点在于可改变分支指向,比如让其指向最新提交的父提交,但是如此一来,没有被引用的最新提交很容易丢失;此时可以用 Git 日志系统 支持的 git reflog 命令撤销上一条命令(即 git reset [--hard | --mixed | --soft] <提交对象名>)以使状态回退。

注意,在 git checkout 系列与 git reset 系列命令引起的工作区覆盖中,未跟踪文件不受影响(不会被删除)。

git branch <新分支名> 创建一个指向 HEAD 对应的提交对象的分支,但是不会影响 HEAD 本身。如果想要切换到该分支上工作, 接下来还需要执行 git checkout <刚创建的分支名> 来改变 HEAD 的指向;或者用综合了以上两个命令的 git checkout -b <新分支名> 一步到位。使用 git branch -d <分支名> 可以删除已有的分支。

git cherry-pick <提交对象名> 将指定的提交对象相对其父提交引入的更新在 HEAD 上重放,并相应地更新 HEAD 所指的分支或 HEAD 自身的指向。 例如:

给定:

67ffk <- 2g0pd    xe2l9
                    ^
                    |
                  HEAD 

commit 67ffk:
CONTRIBUTION
README

commit 2g0pd:
main.c
CONTRIBUTION
README

commit xe2l9:
log.h

执行:

git cherry-pick 2g0pd

得到:

67ffk <- 2g0pd    xe2l9 <- u2a6a
                             ^
                             |
                           HEAD 

commit u2a6a:
main.c
log.h

显然,2g0pd 引入的更新是 main.c,那么重放它后新的提交中将新增 main.c,当然,重放引入的提交对象与原提交对象不同。这可用于这种情形:v2 版本分支上发布了 bugfix 提交, 而仍在维护的 v1 版本分支也需要该补丁,那么重放 bugfix 即可引入该增量式补丁。

HEAD 所指位置与某一分支或提交对象有相同的祖先提交时, 使用 git merge <分支名或提交对象名> 可以把从该祖先提交(分裂点)开始至指定位置的更新在 HEAD 上以一个新的提交重放。 新的提交将有两个父提交:HEAD 原指位置与给定位置。例如:

给定:

67ffk <- 2g0pd <- de2l9
           ^        ^
           |        |
         5po4l    HEAD 

commit 67ffk:
CONTRIBUTION
README

commit 2g0pd:
main.c
CONTRIBUTION
README

commit de2l9:
main.c
log.h
CONTRIBUTION
README

commit 5po4l:
CONTRIBUTION
README
pom.xml

执行:

git merge 5po4l

得到:

67ffk <- 2g0pd <- de2l9 <- a623m <- HEAD
           ^                 |
           |                 |
           ---- 5po4l <-------      

commit a623m:
log.h
CONTRIBUTION
README
pom.xml

注意 git merge 依然是重放操作,而非将给定的结构取并集添加到 HEAD 所指结构上。证据是新提交 a623m 中不仅 引入了给定提交 5po4l 中新增的 pom.xml,还重放了其对祖先提交中 main.c 的删除操作。当然,如果 HEAD 原指提交 de2l9 中修改了 main.c,此时会发生冲突(conflicts),要求用户自己处理。在发生合并时(包括 git cherry-pick),还有一些情况也会导致冲突, 比如两个提交中各自修改了同一文件中的同一行等。如果 Git 认为未发生冲突,它会智能融合两个提交,但还是有可能产生逻辑冲突, 比如一个提交中某源文件引用的头文件在合并中被删除了,会导致该源文件无法编译。所以在用 Git 管理代码时,合并操作也需进行 Code Review 并 对结果作测试,即使两个父提交各自都检查过了。

接下来介绍一个很有用的命令:git rebase --onto <newbase> <since> <till>。它会:

  1. 首先执行 git checkout 切换到 <till> 提交;
  2. <since> 提交(不包括本身)、<till> 提交(包括本身)范围内的提交依次写到一个临时文件中;
  3. HEAD (或其所指的分支)重置到 <newbase>(相当于 git reset --hard <newbase>);
  4. 将保存在临时文件中的提交列表按顺序重放到重置之后的位置上,如果遇到有的提交已经包含在分支中了,则跳过该提交。

借用 Pro Git 中的示例很容易看清楚其工作原理:

  o---o---o---o---o  master
       \
        o---o---o---o---o  next
                         \
                          o---o---o  topic

git rebase --onto master next topic

  o---o---o---o---o  master
      |            \
      |             o'--o'--o'  topic
       \
        o---o---o---o---o  next


                          H---I---J topicB
                         /
                E---F---G  topicA
               /
  A---B---C---D  master

git rebase --onto master topicA topicB

                H'--I'--J'  topicB
               /
              | E---F---G  topicA
              |/
  A---B---C---D  master


  E---F---G---H---I---J  topicA

git rebase --onto topicA~5 topicA~3 topicA

  E---H'---I'---J'  topicA


          A---B---C topic
        /
  D---E---A'---F master

git rebase master 或 git rebase master topic

                  A'--B'--C' topic
                /
  D---E---F---G master

参考资料:

《Git 权威指南》

《Pro Git》 by Scott Chacon and Ben Straub

推荐 Git GUI:

GitKraken

如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆

contribution

最后附上 GitHub:<https://github.com/gonearew