【猫猫教程】特别篇 有趣的Git根提交

猫猫的博客好久没有写新的文章了,终于下决心把 hexo 更新了一下,发现 Next 主题有个新的样式,还挺好看的。

平时用 git 用得比较多,这里介绍一下 git 的一个好玩的东西,那就是对 Git 根提交的研究。这些东西虽然在平时的版本控制中都不太会用到,但是猫猫也是以根提交为引子,解决一些实际问题,从而理解一些 git 的原理。当然啦,git 要配合 GitHub 使用才能达到最佳效果,有些时候特别有用哦!

1. 假装在GitHub上删除项目

当 Shadowsocks 被查封时,Shadowsocks官方的仓库就被完全删除,并且只显示了 Removed According to regulation. 但是呢,软件的开发还在继续进行,只不过一眼看不见,master分支被藏起来了。这是怎么做到的呢?

首先我们要创建一个没有parent的分支,将新的提交作为我们的初始提交。这里要说明的是,git 仓库内所有的提交对象都以分开的 object 存储,每个提交都有一个或多个父提交 (parent) ,而分支游标指向某个最新的提交。这个最新的提交指向前一个提交,前一个提交又指向再前一个提交,一直指向根提交,也就是没有父提交的提交。(假如你不知道分支游标是啥,快回去学习git基础吧!)

所以呢,在同一个git仓库里其实可以拥有多个根提交,只要有其中一个分支游标能够追踪到那个提交,那么这个提交就是有效的提交。正常情况下我们创建一个分支,我们使用的是 git branch 命令:

1
2
3
4
5
6
7
8
$ git branch new

$ git branch
* master
new

$ git checkout new
Switched to branch 'new'

上面命令创建的 new 分支是指向当前 HEAD 所指向的提交的,刚创建就已经指向了一个提交。假如要创建一个分支指向一个不同的提交,git branch 命令后面也可以接上任意分支名。下面的命令创建了一个新的分支,指向HEAD前一次提交。

1
$ git branch new HEAD^

接下去这次的主角就要登场了:假如我想让新建的分支不指向任何提交呢?那么我在那个分支新创建的提交就没有任何的历史,也就是创建了一个根提交。git有一个很方便的命令可以实现:

1
2
3
4
5
$ git checkout --orphan new
Switched to a new branch 'new'

$ git log
fatal: your current branch 'new' does not have any commits yet

git checkout --orphan 就可以创建一个没有历史的新的分支,并且切换到新分支。这个时候运行 log 可以发现,新的分支没有任何历史记录。

这时候git status 会输出什么呢?

1
2
3
4
5
6
7
8
9
10
11
$ git status
On branch new

No commits yet

Changes to be committed:
(use "git rm --cached <file>..." to unstage)

new file: Makefile
new file: README.md
...

从运行结果来看,我们并没有执行任何git add的操作,但是status却输出了 Changes to be commited。这是怎么回事呢?在运行git checkout的时候,git的暂存区是不会清空的,所以现在的暂存区仍然是上一次提交的暂存区。而HEAD当前没有指向任何提交,所以git认为所有在暂存区里的文件都是将要提交的新文件啦。要创建和原来无关的新分支,首先要清空暂存区,这一点千万不能忘记了。

1
2
3
4
$ git rm -rf \*
rm 'Makefile'
rm 'README.md'
...

所有的文件和暂存区都被清空了(要是只清空暂存区的话只要用git reset就行了),这时候git就恢复到了新创建仓库时的初始状态。

1
2
3
4
5
6
$ git status
On branch new

No commits yet

nothing to commit (create/copy files and use "git add" to track)

现在只要在这里创建一个README.md,然后里面写上一行话就行了:

1
2
3
4
5
6
7
8
9
10
11
$ >README.md <<< 'Removed according to regulation'

$ git add .

$ git commit -m 'Deleted'
[new (root-commit) 44fe2a6] Deleted
1 file changed, 1 insertion(+)
create mode 100644 README.md

$ git --no-pager log --pretty=oneline
44fe2a68a045c97ac7902451d25ba09b1f8358b5 (HEAD -> new) Deleted

可以看到,commit的时候输出了root-commit的信息,说明创建了一个新的根提交,提交历史也只有这一个提交。上传这个新的分支到远端:

1
2
3
4
5
6
$ git push origin new
Counting objects: 3, done.
Writing objects: 100% (3/3), 242 bytes | 242.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To example.com:maomihz/Test-Repo.git
* [new branch] new -> new

接下来只要在Github上设置将新建的分支设置成默认分支,就可以假装仓库被删除啦!

切回master分支,所有的文件都回来了:

1
2
3
$ git checkout master
Switched to branch 'master'
Your branch is up to date with 'origin/master'.

2. 删除所有的提交历史

有时候我们想删除所有之前的提交,只保留最新的提交。当然对于一般的项目,千万不要把历史清空,因为Git的历史是非常重要的。所有的提交都是连在一起的,那么怎么删除所有历史呢?这里同样要用到和上面相似的方法。(假设当前分支为master,假如是其他分支的话一定要替换成对应的分支名,不要不小心把master清空了)

首先新建一个没有历史的分支:

1
2
$ git checkout --orphan temp-branch
Switched to a new branch 'temp-branch'

然后直接提交就行了,因为暂存区并没有清空:

1
2
3
4
5
6
$ git commit -m 'Initial Commit'
[temp-branch (root-commit) 021297a] Initial Commit
18 files changed, 1257 insertions(+)
create mode 100644 Makefile
create mode 100644 README.md
...

接下来删除原先的master分支,将当前分支变成master

1
2
3
4
$ git branch -D master
Deleted branch master (was 0feac0e).

$ git branch -m master

branch -m的作用是重命名当前分支为master。至此master的提交历史就被清空了。当然啦,假如要把这个提交上传到 Git 远端的话,必须要 --force:

1
$ git push --force origin master

3. 暴力删除所有的提交历史

上面介绍的方法还算“正常”,但是创建一个新分支还是有些复杂。那么有没有跟简单(暴力)的办法呢?当然是有的。先复习一下分支游标的知识,最新的提交由HEAD来跟踪,而HEAD指向的其实是一个分支,通过分支游标指向的提交才能确定下一个提交的父提交。那么假如要创建一个没有父提交的提交,只要让HEAD找不到父提交就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ cat .git/HEAD
ref: refs/heads/master

$ cat .git/refs/heads/master
0feac0e526284700df1de952ed9bec449dd159c4

$ git cat-file -p 0feac0e526284700df1de952ed9bec449dd159c4
tree 6bad90776d3a2905f76436013d7c843d0e8e4e2d
parent bc92368f653384d638827df40fd8c35c46c2cc7d
author ...
committer ...

...

下面这一行命令可以直接删除master游标:

1
2
3
4
$ git update-ref -d refs/heads/master

$ git log
fatal: your current branch 'master' does not have any commits yet

这时候直接提交,就可以创建一个根提交了。

1
2
3
4
5
$ git commit -m 'Initial Commit'
[master (root-commit) ef895e1] Initial Commit

$ git --no-pager log --pretty=oneline
ef895e19af61df019168185ad4f7b1a07d5a91e8 (HEAD -> master) Initial Commit

参考资料:StackOverflow

4. 删除不小心引入的大文件

上面所讲述的方法又一个非常有用的用途,那就是作为二进制文件的发布。二进制文件可以作为一个orphan分支上传到Github,这样就不会影响到正常的代码。不过注意Github上传还是有限制100M的,不要上传太大的文件了。

Git不会处理二进制文件。即使每次上传都把老的提交历史清空,目录下的 .git 还是会非常大。没有任何分支游标能够记录这些文件,为什么还是会这么大呢?原来是git reflog的问题。

reflog记录了所有分支的变化历史,假如你不小心reset到了某一个历史点,那么想要反悔的话,reflog就派上用场了。但是呢,reflog也为了保护提交对象,防止了直接删除,直到一个时间点后才会过期。(这个过期的时长是可以更改的)

假设有以下仓库:(HEAD^引入了一个大文件,而HEAD把大文件删除了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ git --no-pager log --oneline
45c8e36 (HEAD -> master) Remove Big File
bfa02e4 Update big file
2b487e4 Initial Commit

$ du -sh .git
25M .git

$ git update-ref -d refs/heads/master

$ git commit -m 'Remove Big File'
[master (root-commit) 962a0e3] Remove Big File
1 file changed, 1 insertion(+)
create mode 100644 README.md

$ git --no-pager log --oneline
962a0e3 (HEAD -> master) Remove Big File

$ git --no-pager reflog
962a0e3 (HEAD -> master) [email protected]{0}: commit (initial): Remove Big File
45c8e36 [email protected]{2}: commit: Remove Big File
bfa02e4 [email protected]{3}: commit: Update big file
2b487e4 [email protected]{4}: commit (initial): Initial Commit

上面reflog的输出里,特别要注意的是[email protected]{0}[email protected]{2}这两个提交是不一样的提交:

1
2
3
4
5
6
$ git cat-file -p [email protected]{0}
tree 8524d1d00c861b1222ea69447c84489d20257a89
author MaomiHz <[email protected]> 1510189988 -0600
committer MaomiHz <[email protected]> 1510189988 -0600

Remove Big File

这个最新的提交时没有父提交的,是一个根提交。

1
2
3
4
5
6
7
8
9
10
11
12
$ git cat-file -p [email protected]{2}
tree 8524d1d00c861b1222ea69447c84489d20257a89
parent bfa02e4cb11a1d493f0997e176fa7ff5fdab16bf
author MaomiHz <[email protected]> 1510189844 -0600
committer MaomiHz <[email protected]> 1510189844 -0600

Remove Big File

$ git --no-pager log [email protected]{2} --oneline
45c8e36 Remove Big File
bfa02e4 Update big file
2b487e4 Initial Commit

而这个提交正是我们删除的提交。可以看到reflog记录了所有的历史,要反悔非常方便。

那么现在假如用git gc清除提交历史,可不可以呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ git gc --prune=now
Counting objects: 8, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (6/6), done.
Writing objects: 100% (8/8), done.
Total 8 (delta 2), reused 0 (delta 0)

$ du -sh .git
25M .git

$ git --no-pager reflog
962a0e3 (HEAD -> master) [email protected]{0}: commit (initial): Remove Big File
45c8e36 [email protected]{2}: commit: Remove Big File
bfa02e4 [email protected]{3}: commit: Update big file
2b487e4 [email protected]{4}: commit (initial): Initial Commit

显然不可以,因为reflog还没有过期,不会自动清理这些没有追踪到的提交。要删除它们,必须让reflog过期:

1
2
3
4
$ git reflog expire --expire-unreachable=now HEAD

$ git --no-pager reflog
962a0e3 (HEAD -> master) [email protected]{0}: commit (initial): Remove Big File

HEADreflog不见了!这时候再使用git gc,这些对象就会被清除了:

1
2
3
4
5
6
7
$ git gc --prune=now
Counting objects: 3, done.
Writing objects: 100% (3/3), done.
Total 3 (delta 0), reused 2 (delta 0)

$ du -sh .git
96K .git

假如要清除所有的引用不到的对象,可以用--all参数:

1
$ git reflog expire --expire-unreachable=now --all

参考资料:StackOverflow

5. 复杂地清除提交历史

在Git的老版本中,git checkout --orphan这个命令是没有的。不使用暴力删除的方法,如何才能删除所有提交历史呢?(实际上这用起来并不方便,但是借此机会来探究一下git内部原理)

我们平时git commit的时候,到底是做了什么呢?回忆Git中有四种对象类型,blob,commit,tagtree。blob用来存储文件,commit用来存储提交,而tree用来存储目录结构。分支游标指向commit,commit指向tree,而tree又指向任意个tree或blob。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ git cat-file -p HEAD
tree 6bad90776d3a2905f76436013d7c843d0e8e4e2d
parent bc92368f653384d638827df40fd8c35c46c2cc7d
author MaomiHz <[email protected]> 1510115667 -0600
committer MaomiHz <[email protected]> 1510115667 -0600

Commit Message


$ git cat-file -p HEAD^{tree}
100644 blob aea384dbe080bad4b003074dffdf74bf62135876 Makefile
100644 blob d52fadf1e399e8a1703de207b6c4c1de821136ca README.md
... 其他文件
040000 tree fda5e098305aa6d8b396f10f41cec41d4dbebc8d src


$ git cat-file -p HEAD:Makefile
CC = g++
SRCS = $(wildcard src/*.cpp)
OBJS = $(notdir ${SRCS:.cpp=.o})
... 文件内容

git的暂存区也是一个特殊的tree(是一个临时的tree,并没有写入到版本库内)。默认git记录暂存区文件是.git/index,这是一个二进制文件,不能直接查看,使用ls-files可以显示暂存区内的文件:

1
2
3
4
$ git ls-files -s
100644 aea384dbe080bad4b003074dffdf74bf62135876 0 Makefile
100644 d52fadf1e399e8a1703de207b6c4c1de821136ca 0 README.md
...

假如要丢弃所有历史的话,只要手动将暂存区的内容生成提交,并且不指定父提交就行了。上面第一个命令显示了HEAD所指向的树6bad90776d3a2905f76436013d7c843d0e8e4e2d,记住这个ID:

1
2
$ git commit-tree 6bad9077 <<< 'Initial Commit'
4f3126e4c05e5eaec9dfafa3d4298041986c7edd

git commit-tree命令将一个tree生成一个提交对象,而提交信息则是标准输入,并且打印出生成提交的ID。-p参数用来指定生成的父提交,这里没写就是一个根提交啦。

研究一下新生成的提交对象:

1
2
3
4
5
6
7
8
9
$ git cat-file -p 4f3126e4c05e5eaec9d
tree 6bad90776d3a2905f76436013d7c843d0e8e4e2d
author MaomiHz <[email protected]> 1510192342 -0600
committer MaomiHz <[email protected]> 1510192342 -0600

Initial Commit

$ git --no-pager log --oneline 4f3126e4c05e5eaec9d
4f3126e Initial Commit

果然没有父提交。

这下只要将master分支接到这个提交上,就可以丢弃master历史啦。

1
2
3
4
5
$ git reset --hard 4f3126e4c05e5eaec9d
HEAD is now at 4f3126e Initial Commit

$ git --no-pager log --oneline
4f3126e (HEAD -> master) Initial Commit

6. 复杂地生成orphan分支

我们将git内部探索到底,上面讲解了手动直接生成提交而不用commit命令的方法,那么如何生成一个新的和当前分支没有任何关系的分支呢?(也就是复杂版的“假装在GitHub上删除项目”)

上面说过了,git的暂存区其实就是个临时的tree,是用一个文件来记录的。平时使用的git add其实是首先计算文件的hash,然后将文件写入版本库(这里千万注意,虽然暂存区不写入版本库,但是它不记录文件信息,暂存区内的文件必须要先写入对象库!),然后将文件的hash写入暂存区文件内。

正常情况下暂存区文件所写的就是最新提交的文件,而新建空分支的话就要一个空的暂存区。git可以通过修改环境,临时指定一个新的暂存区文件:

1
2
3
$ export GIT_INDEX_FILE=/tmp/tmp_index

$ git ls-files -s

可以看到没有任何输出。我们设想的新分支里有一个README.md,里面写一段话,那么就先创建这个文件(不管工作区内的文件还没删除)。既然要将git探索到底,这个文件的文件名也随意:

1
2
3
4
$ echo "Removed according to regulation" >future_readme.txt

$ cat future_readme.txt
Removed according to regulation

首先先要把计算这个文件的hash,并且写入版本库:

1
2
3
4
5
$ git hash-object -w future_readme.txt
92527cd20e1594337af8404bdbe78384b10e2000

$ git cat-file -p 92527cd20e1594337af8404
Removed according to regulation

-w 表示写入版本库,默认是只计算hash不写入版本库。blob对象只包含这个文件的内容,不包含任何其他信息,这个文件的文件名是记录在tree里的,所以我们不需要管文件名是什么。(计算机上任意地方的文件都可以通过这个方法写入到版本库内)

接下来就是将这个文件的内容写入到暂存区内:

1
2
3
4
$ git update-index --add --cacheinfo 100644 92527cd20e1594337af8404bdbe78384b10e2000 README.md

$ git ls-files -s
100644 92527cd20e1594337af8404bdbe78384b10e2000 0 README.md

--cacheinfo的三个参数分别是权限、对象ID和文件名。

将这个临时的树转成真正的tree对象并写入版本库:

1
2
$ git write-tree
8524d1d00c861b1222ea69447c84489d20257a89

成功获得了一个tree对象!这下只要提交就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ git commit-tree 8524d1d00c861b1222ea6944 <<< Removed
a56605cec2826bda4b9834b88d6b8d10f593ab40

$ git --no-pager show a56605cec282
commit a56605cec2826bda4b9834b88d6b8d10f593ab40
Author: MaomiHz <[email protected]>
Date: Wed Nov 8 20:28:26 2017 -0600

Removed

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..92527cd
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+Removed according to regulation

文件内容果然写入了!最后创建一个分支来记录这个新的提交:(将暂存区还原吧,之前的暂存区没有受到任何影响)

1
2
3
4
5
6
7
8
9
10
11
12
13
$ unset GIT_INDEX_FILE

$ git clean -xfd
Removing future_readme.txt

$ git branch rm a56605cec282

$ git checkout rm
Switched to branch 'rm'

$ ls -l
total 8
-rw-r--r-- 1 maomi staff 32 Nov 8 20:33 README.md

从未存在过的README.md出现了。

参考资料:Git Book

结语

猫猫从Shadowsocks讲起,仔细研究了git生成提交的过程,希望大家在这之间都能学到一些东西,更深入了解Git。orphan提交也是非常有用的只是,不仅是假装删除,还可以作为Github Pages的源码和生成的html完全分开,或者创建一个新的分支用于存放二进制发布文件。