Git 小结

前言

本文其实是软工课程的一次作业,算是对自己之前使用 Git 的一些心得体会的总结吧。

基础概念

Git 是什么

在计算机或者编程相关的社区有一个词 RTFM(Read The Fucking Manual) 很常见,意思是学习一个东西先去看它的手册,这对于 Git 来说也是一样的。我觉得学习 Git 最好的办法就是参考 Manual,比如 Manual 的第一页:

Git is a fast, scalable, distributed revision control system with an unusually rich command set that provides both high-level operations and full access to internals.

可以看出 Git 的定位:Git 是一个强大的分布式版本控制工具。

这里值得强调的一点是分布式——其意义就在于每个人都可以拥有仓库完整的一个副本,并且相互独立的在各自副本上提交更改,因此即使有一天 GitHub 突然消失了,只要每个人把自己代码仓库的副本拿出来就能创建第二个 GitHub,这就是分布式的优势。

Repository 仓库

在 git 中项目的基础单位是 Repository,一般中文翻译称为“仓库”。

创建一个仓库很简单,只要:

1
git init .

就创建完成了。可以注意到的是此时本地出现了一个 .git 文件夹,git 就用它来存储版本信息。

当然我们也可以获取一个远程仓库,比如:

1
git clone https://github.com/wtdcode/tun2socks

就可以把一个远程仓库克隆到本地。这里有一个细节是 git 会根据 URL 自动选择合适的协议,除了 https,git 还支持 http 和 ssh。

Add 添加

作为一个版本控制工具,首先我们得告诉它哪些文件是属于我们项目需要管理的,比如我们在项目根目录下创建了一个 Readme.md 希望用 git 去管理可以:

1
git add Readme.md

把它添加到版本库。

Commit 提交

仅仅 git add 的话 git 只是知道项目有哪些文件了,但是 git 还没有开始跟踪文件的更改。

比如我们向 Readme.md 中写入一行 Hello world 后希望提交这次更改就可以:

1
git commit Readme.md -m "My first commit!"

然后 git 会输出类似的内容:

1
2
3
[master (root-commit) e1db2a9] My first commit
1 file changed, 1 insertion(+)
create mode 100644 Readme.md

这里就创建了一次提交,它的标记是 e1db2a9,这个标记是独立的,从某种意义上可以理解为是一个版本。

此外 git 要求每次提交必须写 commit message,因此可以用 -m 参数写明 commit message。

在 git 中,只要被提交过的代码就一定可以找回,而且 git 每次只会记录提交之间文件的变化,因此不会占用太多空间。

工作区、暂存区、版本库

add 和 commit 只是 git 给我们提供的抽象,只有理解这两个操作背后的实现才能更好的理解 git 的工作方式。

第一个概念是工作区。工作区就是 .git 所在的目录但是不包括 .git 本身,比如在上面的例子中 Readme.md 就在工作区中。

第二个概念是暂存区,所有被 add 添加的更改都会放在暂存区中,当 commit 的时候更改就会从暂存区写入仓库。

第三个概念是版本库,版本库其实包括暂存区和仓库,也可以说就是 .git 这个目录本身。

所以现在应该理解,其实 add 和 commit 就分两步把我们的更改提交到了仓库,那么暂存区的存在意义是什么?

考虑这样一个场景:为了完成功能A,修改了文件 a 并且添加到了暂存区,接着突然要修复 Bug B修改了文件 a 和 b,那么问题来了现在文件 a 被修改了两次但是理由是不一样的,所以 a 应该被分段提交,此时暂存区就体现出来了。

由于文件 a 的第二次修改还没进入暂存区,因此可以方便的对文件 a 进行提交后再添加新的修改到暂存区再提交,这样就可以分段提交了,防止两次不相关的修改被合并在一个commit。

pull 和 push

前面提到 git 是分布式的,但是为了协作我们往往需要让本地的仓库和远程仓库同步。

其中把本地仓库的更改同步到远程仓库可以使用:

1
git push

而反过来从远程仓库拉取更改可以使用:

1
git pull

其中注意的一点是 git pull 实际上就是 git fectch 后立即 git merge,其中 git fetch 用于拉取更改而 git merge 用于合并潜在的冲突。

同时如果远程仓库和本地仓库有冲突,可以用 git push -f 来让远程仓库强行和本地仓库保持同步,但是这是非常不被推荐的,因为其他协作者在 pull 的时候会出现诸如冲突分歧等问题。

工作流

用 git 的最终目的还是要管理项目,而用好 git 的关键我个人认为是要建立一个良好的工作流,也就是高效的协作。

下面结合我对工作流的理解介绍一下 git 的一些进阶用法。

revert VS reset

前面提到过,只要是提交到 git 的更改就一定不会丢失,那么这是怎么做到的呢?比如我们希望回到第一次提交 Hello world 时候的版本就可以:

1
git reset e1db2a9

其中 e1db2a9 就是当时提交后返回的版本号,可以通过 git reflog 或者 git log 查看。

此外值得一提的是 git reset 也支持向后回溯,只要 commit 的版本号还存在版本库中。

但是在版本回退的时候还有一个指令是 git revert,比如我们又进行了一次提交

1
2
3
echo 1 >> Readme.md
git add Readme.md
git commit -m "Test"

并且产生了一个新的 commit 版本号 6af2cef,那么为了回退到最初 Hello world 的状态我们就可以

1
git revert 6af2cef

但是和 git reset 不同的是 git revert 会产生一个新的 commit 表明撤回了哪些 commit。

从效果上 git revert 和 git reset 都达到了版本回退的效果,那么怎么选择呢?仍然是先看 manual:

Note: git revert is used to record some new commits to reverse the effect of some earlier commits (often only a faulty one). If you want to throw away all uncommitted changes in your working directory, you should see git-reset(1), particularly the --hard option.

所以对于一个工作流来说,我个人的理解是:

  • 如果 commit 已经同步到了远程仓库,那么只选 git revert 绝对不要 git reset 尤其不要 git push -f(忽略冲突强行同步)
  • 如果 commit 还停留在本地仓库,但是要回退掉的版本是有意义的,比如一个错误决策下的 feature,应该选择 git revert 保留必要的信息(比如回退的理由)。
  • 如果 commit 还停留在本地仓库,但是要回退掉的版本是无意义的,比如一次错误的提交,那么应该选择 git reset 来避免污染工作流。

总之核心思想是,每次提交都应该对工作流是有意义的,应该避免错误的或者无意义的提交。

branch 和 checkout

在 git 中一个重要的概念是分支。一个仓库默认分支是 master 分支。

假想这样一个情景,在产品开发的时候同时需要两个功能 A 和 B,如果两个人同时在 master 分支上开发难免会有各种各样的冲突,为了解决这个问题就可以:

1
git checkout -b featureA

来建立一个新分支,这里其实等价于两步

1
2
git branch featureA
git checkout featureA

其中 git branch 用于管理分支而 git checkout 实际上是用于在分支之间切换。

这样只要两个人分别在 featureA 和 featureB 上独立开发完后再合并到 master 就可以保持一个干净的工作流了。

理解分支

但是到这里还没有结束,我认为要想完全理解分支,用对分支必须要理解的是 head。

什么是 head?其实 head 就是一个引用或者说一个指针,默认每个分支都会有一个 head 指向当前分支最新的提交,比如单个分支下 master 作为一个 head 指向最新的提交 C:

1
2
3
4
          master
|
|
A --> B --> C

但是另外一个概念是 HEAD,它特指当前的 head,比如在上图中就是:

1
2
3
4
5
6
7
          master
|
|
A --> B --> C
|
|
HEAD

创建分支其实就是简单的增加了一个新的 head,比如创建 featureA 后现在 master 和 featureA 都指向最新的提交 C:

1
2
3
4
5
6
7
      master, featureA
|
|
A --> B --> C
|
|
HEAD

但是如果我们在 featureA 上完成了一次新的提交 D 情况就有变化了:

1
2
3
4
5
6
7
8
9
10
11
          master
|
|
A --> B --> C
\ featureA
\ |
\ |
--> D
|
|
HEAD

可以看到 master 没有变化,但是 featureA 和 HEAD 都移动到了最新的提交 D 上。

这时候如果我们再用 git checkout master 移动到 master 分支就变成了:

1
2
3
4
5
6
7
8
          master
|
|
A --> B --> C
| \ featureA
| \ |
HEAD \ |
--> D

然后如果我们再在 master 上进行一次提交 E 的话就变成了:

1
2
3
4
5
6
7
8
            master, HEAD
|
|
A --> B --> C --> E
\ featureA
\ |
\ |
--> D

所以分支和提交实际上就是指针操作,理解了 head 和 HEAD 就能完全理解分支。

merge VS rebase

刚才我们已经提到了,虽然分成了不同分支,但是最后还是要合并的,那问题来了:怎么合并?我认为这是贯穿版本控制的核心问题,也是做好工作流的关键。

上面说过 git pull 其实是 git fetch + git merge,实际上还有一个选项是 git pull -r,它等于 git fetch + git rebase,这里就代表了两种不同的合并方式。

在了解合并之前,一个重要的概念是冲突。

冲突是什么?其实正如其字面意思,两个 commit 之间发生了冲突,比如在 featureA 和 featureB 上同时修改了一个文件的同一个位置,这时候合并就会冲突,因为 git 也不知道到底谁做的修改是使用者想要的。

解决冲突的方法是一样的,git merge 和 git rebase 区别在于合并的方式和对工作流的影响。

fast-forward

首先值得一提的是 fast-forward,也就是对于下面这种工作流:

1
2
3
4
5
6
7
8
          master
|
|
A --> B --> C
| \ featureA
| \ |
HEAD \ |
--> D

由于其本质还是线性的,因此无论是 git merge featureA master 还是 git rebase featureA master 都会首先尝试 fast-forward 方法来合并,因为只需要把 master 和 HEAD 移动到 featureA 即可:

1
2
3
4
5
6
             master, HEAD
|
|
A --> B --> C --> D
|
featureA

但是对于下面这种工作流就有区别了:

1
2
3
4
5
6
7
8
             master, HEAD
|
|
A --> B --> C --> E
\ featureA
\ |
\ |
--> D

merge

无论是手工还是自动的,假设已经成功的解决了冲突,那么 git merge 会产生一个新的提交来表示这次合并:

1
2
3
4
5
6
7
8
9
10
11
                      master, HEAD
|
|
A --> B --> C -----> E ---> F
\ /
\ /
\ /
--> D --
|
|
featureA

其中提交 F 有两个祖先 E 和 D 代表它是由一次 merge 得到的。

rebase

但是如果是 git rebase featureA,git 会首先找到两个分支的公共祖先 C,然后暂存 C 之后所有的提交(这里只有E),接着把 featureA 上 C 之后所有的的提交(这里只有 D)放到 master 上然后再把之前暂存的提交放在其后。根据具体 rebase 的情况,我们很可能得到这样的工作流:

1
2
3
4
5
6
                  master, HEAD
|
|
A --> B --> C --> D --> E'
|
featureA

可以看到跟 merge 最大的不同就是,工作流是完全线性的。

比较

直观来看 rebase 似乎要更优一些,因为它可以产生一个完全线性的历史,好处包括不限于

  • 方便 git log 查看提交历史
  • 减少了无意义的合并提交
  • 满足强迫症

但是万物都有两面,rebase 一个致命的问题是它其实重写了中间所有的提交历史,在上图中我用 E' 强调了这一点(因为 E' 和 E 的祖先不同)。

这会导致什么问题呢?如果 master 分支是跟远程仓库同步的,那么现在 git 会察觉到从 C 开始 master 分支在远程和本地出现了 diverge,也就是分歧,为了解决这个分歧有两个解决方案

  • git push -f 这是最直接的解决方案,前提是没有人在 master 分支工作。
  • git merge 如果有人在 master 分支工作,这是可选的方案,但是会导致一个额外的合并提交和一些无意义的更改信息。

无论哪个解决方案都会对工作流带来致命的影响,所以使用 rebase 的法则就是:如果分支(比如上述的 master)在别处有副本(比如有其他人在协作),那么绝对不应该被作为 rebase 的对象。

但是反过来,把 master 分支 rebase 到 featureA 分支上是常见的操作,有利于保持线性的历史和同步开发进度。

总之 merge 和 rebase 实际上是对工作流的选择,但是如何合并这个问题没有银弹,需要结合实际情况来看。

一些失败案例

因为我学习使用 git 也有很长一段时间了,所以现在查看之前 git 仓库的一些提交的时候可以发现很多问题。

git 管理二进制文件

之前在做软工项目的时候,我最后为了省事采用 git 直接管理了 docx 和 ppt 等文件,现在来看是非常不合适的,原因有以下三条

  • 二进制文件在版本库中不是以 diff 而是原样存储的(不考虑压缩),每次修改都会产生一个新的副本,会让 .git 快速膨胀。
  • 一旦出现冲突,二进制文件基本不可能自动合并必须手动合并。
  • 极大的增加了每次 git clone 的时间。

后来的解决方法是用石墨或者 Google docs 来单独做文档的协作。

merge 和 rebase 滥用

同样是软工项目,当初因为没有理解 merge 和 rebase 因此出现非常多的滥用,比如这一段工作流

其实就是几个 feature 在并行开发,但是由于没有 rebase 导致了非常多的无用合并提交。

舍不得提交和提交写了一半的代码

同样也是软工项目,当时协作的时候有队友经常提交写了一半的代码或者攒了几百行才提交一次。

这两个问题其实可以归结为一个问题就是对 git 的设计哲学理解不清。

我认为 git 的种种设计都是鼓励开发者勤提交,因为一旦出现问题可以非常方便的进行版本回退,同时大量有质量的提交也有助于其他人理解项目发展和设计理念。

总结

git 作为一个优秀的版本控制工具,的确值得我们好好学习和实践,但是在使用的同时,我认为理解 git 的设计哲学和一些背后的实现有助于我们更好的管理项目。

参考资料