本文尝试介绍一下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的一些特性:
可以存储所有历史。
我们常听到“Git是一个分布式的版本控制系统”,
这个指的就是Git不需要中心化的服务器,
你就可以做完所有操作。
因为本地存着所有的Line Diff,
所以“查看昨天被改过的文件名列表”这个操作完全可以离线完成。对二进制文件不友善。
二进制文件是没法强行比Line Diff的。
所以假如用Git管理二进制文件,
Git只会显示一个Binary File Differ
。
再把上面一条“存储所有历史”给叠加上,
就会出现今天提交了一个200M的文件,
明天后天我都修改覆盖了这个文件,
最后整个目录就有600M大了…
(也就是说一般不用Git来管理二进制大文件)能检测文件重命名。
假如在一个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优秀的原因在于它:
- 正交的设计:术语定义清晰,重叠概念少,表现张力强大。
- 扎实的实现:二级术语丰富,命令参数完善,贴合实际应用场景。
(完)
对`git`只是模糊了解,如有不对请指正:P。 - 关于Line Diff: 存在50%以上(包括50%)行相似 - 关于Commit: 利用`filter-branch`修改`GIT_AUTHOR_NAME`,GitHub上能看出来Committer(Committer与Author不同时github的commit历史会显示两者),git存储着`GIT_COMMITTER_NAME`和`GIT_COMMITTER_EMAIL`信息。 - 关于Branch: 删除远程分支利用`git push origin-name :branch-name`,可以(不是很了解stash机制,但猜想branch,commit, stash应该都是一个指针)。 - 关于Repository: 会。但只是删除一个标记分支的文件,具体对象删除应该是`git-gc`回收? - 关于Remote: 不放置管理文件,即没有`work tree`。一般在服务器上部署 Git 仓库时使用。