本文尝试介绍一下Git的过人之处。 目标读者是想了解Git, 或者对软件设计有兴趣的人。

Git作为一个极其灵活的工具, 从修改单机游戏数据文件的版本管理, 到多人协作一起堆屎的协作开发, 使用起来都是十分趁手。

那么Git灵活的奥秘在哪呢? 大概是因为Git设计正交、实现扎实吧。

总览

Git里面的术语/命令很多, 但是它们可以归并成几个大类, 每个大类的概念都是正交的, 也就是说交叉概念很少, 不会有模糊的概念定义。 基于这样的设计, Git与之对应地实现了一套扎实的命令系统。

Git里的概念有些难以准确翻译, 本文涉及概念词的地方尽量用术语表达 。

比如经常用到的概念会有这些:

  • Line Diff
  • Commit
  • Branch
  • Repository
  • Remote

Line Diff

Git实现版本控制的方法是根据Line Diff, 推算出每个Commit具体改了哪些东西, 然后用多个Commit(实则是多份Line Diff)构建出所有历史。

这个基于Line Diff的先天设计决定了Git的一些特性:

  1. 可以存储所有历史。 我们常听到“Git是一个分布式的版本控制系统”, 这个指的就是Git不需要中心化的服务器, 你就可以做完所有操作。 因为本地存着所有的Line Diff, 所以“查看昨天被改过的文件名列表”这个操作完全可以离线完成。

  2. 对二进制文件不友善。 二进制文件是没法强行比Line Diff的。 所以假如用Git管理二进制文件, Git只会显示一个Binary File Differ。 再把上面一条“存储所有历史”给叠加上, 就会出现今天提交了一个200M的文件, 明天后天我都修改覆盖了这个文件, 最后整个目录就有600M大了… (也就是说一般不用Git来管理二进制大文件)

  3. 能检测文件重命名。 假如在一个Commit中, 从Line Diff的视角看, 删除的文件和增加的文件相似度很高, Git就会判定这是一个重命名的操作。

Commit

Line Diff组成了Commit, Commit是大部分Git操作的最小单位。 这个词既是动词,也是名词。

一个Commit包含了多种信息:

  • SHA hash:是根据line diff + 精确到秒的时间戳生成的一串唯一标识符
  • Author:写Line Diff的人
  • Committer:一个隐藏的属性,代表Commit的人
  • Date:包括AuthorDate和CommitDate
  • Message:Commit文本描述,Git会取Message第一行作为Subject,所以一般会遵循一定规范
  • Line Diffs:改动了哪些内容

这里还可以说的概念包括RootCommit、MergeCommit, 不过它们特殊之处不影响实际使用, 所以跳过它们,继续往下说。

Branch

多个Commit会组成一个Branch, 最初的Branch默认叫master(主干分支)。

Branch和Commit在很多命令里都是可以作为等价的操作对象的。 举个例子:

小成写了一天代码, 他在wechat这个分支上commit了很多次, 快下班了,小成想回顾一下今天的改动。 假设他的log长这样子:

> git log --oneline --graph
* f01c8d1 (HEAD -> wechat) refactor: improve project layout
* 2f9c867 feat: add rest api to create card
* 5d5242b feat: custom wechat card background
* 873e6ca fix: wechat card slow query
* 0dd06a9 fix: 500 when user unsubscribe
* fb91f98 (origin/master, master) feat: implement wechat card
* 176b4f0 feat: implement membership level
* 2727226 migration: add Settings.enable_level
...

那么以下命令是完全等价的:

# 查看从master到wechat的diff
> git diff master..wechat

# 查看从master到当前的diff(HEAD代表当前位置,也就是wechat分支)
> git diff master..HEAD

# 查看从master到当前的diff(HEAD是默认值,可省略)
> git diff master

# 查看master的commit到当前的diff
> git diff fb91f98

# 查看五个Commit以前倒当前的diff(master分支在五个Commit以前)
> git diff HEAD~5

所以也可以说“Branch是特殊的Commit”。 理解了这一点以后, 再去看大部分的Git命令, 发现它们都是git <operation> <range> -- <files>...这样的形式。

比如查看今天发布哪些内容就是git diff master..release, 把某个文件回滚到200个Commit以前就是git checkout HEAD~200 -- some/path/some/file.txt, 查看单个文件的改动历史就是git log -- some/path/some/file.txt

Repository

Repository包含了所有的操作历史。 git init命令可以初始化一个Repository。

一个Git Repository结构可能是这样的:

- .git/
  - hooks/
  - objects/
  - refs/
  - HEAD
  - config
- ForgiveDB/
- README.md
- requirements.txt

这里的.git目录就存储着上面讲的Line Diff、Commit、Branch的所有历史, 就像上面二进制大文件的那个例子, 这里可能存了几百M的文件历史。

Remote

Remote就是放在别的地方的Repository。 同一个Repository可以添加多个Remote。

除了push/pull/fetch这些基本操作以外, 关于Remote还有一个很骚的设定: Git支持本地Remote。

比如样例的命令如下:

# 假设在服务器上的 /home/lirian/chinese-calendar 路径下有一个 Repository
> cd /home/lirian

# 把它 clone 到某一个地方
> git clone chinese-calendar /opt/git/repo/chinese-calendar --bare

# 同个服务器上的另一个用户就可以 clone 这个 Repository
> cd /home/ldsink && git clone file:///opt/git/repo/chinese-calendar && git remote -v
origin       file:///opt/git/repo/chinese-calendar (fetch)
origin       file:///opt/git/repo/chinese-calendar (push)

这样的设计之下, Remote/Repository是完全分离的, 不会因为断网就修改不了历史。 我们甚至可以把Remote当成一种特殊的Branch, 比如fork - pull request就是这种模式的一种应用。

尾言

文中讲到的不少例子有一些浅尝辄止, 读者有兴趣的话可以尝试思考实现一下这几个拓展问题:

  • 关于Line Diff:改动的两个文件相似度多高,Git才会识别为重命名呢?
  • 关于Commit:如何修改Commit的Author?GitHub上能看出来Committer么?
  • 关于Branch:如何删除远程分支?git stash产生的Commit可以像Branch一样操作么?
  • 关于Repository:删除分支以后,Git目录会变小吗?
  • 关于Remote:文中用到的--bare参数是什么意思?

Git的设计理念中还有很强大的一部分是它关于历史(History)的管理, 那又是一个值得细说的话题。

总的来说,笔者眼中Git是一个科学且强大的工具。 Git优秀的原因在于它:

  • 正交的设计:术语定义清晰,重叠概念少,表现张力强大。
  • 扎实的实现:二级术语丰富,命令参数完善,贴合实际应用场景。

(完)