猫猫的博客好久没有写新的文章了,终于下决心把 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 | $ git branch new |
上面命令创建的 new
分支是指向当前 HEAD
所指向的提交的,刚创建就已经指向了一个提交。假如要创建一个分支指向一个不同的提交,git branch
命令后面也可以接上任意分支名。下面的命令创建了一个新的分支,指向HEAD
前一次提交。
1 | $ git branch new HEAD^ |
接下去这次的主角就要登场了:假如我想让新建的分支不指向任何提交呢?那么我在那个分支新创建的提交就没有任何的历史,也就是创建了一个根提交。git有一个很方便的命令可以实现:
1 | $ git checkout --orphan new |
git checkout --orphan
就可以创建一个没有历史的新的分支,并且切换到新分支。这个时候运行 log
可以发现,新的分支没有任何历史记录。
这时候git status
会输出什么呢?
1 | $ git status |
从运行结果来看,我们并没有执行任何git add
的操作,但是status
却输出了 Changes to be commited。这是怎么回事呢?在运行git checkout
的时候,git的暂存区是不会清空的,所以现在的暂存区仍然是上一次提交的暂存区。而HEAD
当前没有指向任何提交,所以git认为所有在暂存区里的文件都是将要提交的新文件啦。要创建和原来无关的新分支,首先要清空暂存区,这一点千万不能忘记了。
1 | $ git rm -rf \* |
所有的文件和暂存区都被清空了(要是只清空暂存区的话只要用git reset
就行了),这时候git就恢复到了新创建仓库时的初始状态。
1 | $ git status |
现在只要在这里创建一个README.md
,然后里面写上一行话就行了:
1 | $ >README.md <<< 'Removed according to regulation' |
可以看到,commit的时候输出了root-commit
的信息,说明创建了一个新的根提交,提交历史也只有这一个提交。上传这个新的分支到远端:
1 | $ git push origin new |
接下来只要在Github上设置将新建的分支设置成默认分支,就可以假装仓库被删除啦!
切回master
分支,所有的文件都回来了:
1 | $ git checkout master |
2. 删除所有的提交历史
有时候我们想删除所有之前的提交,只保留最新的提交。当然对于一般的项目,千万不要把历史清空,因为Git的历史是非常重要的。所有的提交都是连在一起的,那么怎么删除所有历史呢?这里同样要用到和上面相似的方法。(假设当前分支为master
,假如是其他分支的话一定要替换成对应的分支名,不要不小心把master清空了)
首先新建一个没有历史的分支:
1 | $ git checkout --orphan temp-branch |
然后直接提交就行了,因为暂存区并没有清空:
1 | $ git commit -m 'Initial Commit' |
接下来删除原先的master
分支,将当前分支变成master
:
1 | $ git branch -D master |
branch -m
的作用是重命名当前分支为master。至此master的提交历史就被清空了。当然啦,假如要把这个提交上传到 Git 远端的话,必须要 --force
:
1 | $ git push --force origin master |
3. 暴力删除所有的提交历史
上面介绍的方法还算“正常”,但是创建一个新分支还是有些复杂。那么有没有跟简单(暴力)的办法呢?当然是有的。先复习一下分支游标的知识,最新的提交由HEAD
来跟踪,而HEAD
指向的其实是一个分支,通过分支游标指向的提交才能确定下一个提交的父提交。那么假如要创建一个没有父提交的提交,只要让HEAD
找不到父提交就行了。
1 | $ cat .git/HEAD |
下面这一行命令可以直接删除master
游标:
1 | $ git update-ref -d refs/heads/master |
这时候直接提交,就可以创建一个根提交了。
1 | $ git commit -m 'Initial Commit' |
参考资料:StackOverflow
4. 删除不小心引入的大文件
上面所讲述的方法又一个非常有用的用途,那就是作为二进制文件的发布。二进制文件可以作为一个orphan分支上传到Github,这样就不会影响到正常的代码。不过注意Github上传还是有限制100M的,不要上传太大的文件了。
Git不会处理二进制文件。即使每次上传都把老的提交历史清空,目录下的 .git
还是会非常大。没有任何分支游标能够记录这些文件,为什么还是会这么大呢?原来是git reflog
的问题。
reflog
记录了所有分支的变化历史,假如你不小心reset
到了某一个历史点,那么想要反悔的话,reflog
就派上用场了。但是呢,reflog
也为了保护提交对象,防止了直接删除,直到一个时间点后才会过期。(这个过期的时长是可以更改的)
假设有以下仓库:(HEAD^
引入了一个大文件,而HEAD
把大文件删除了)
1 | $ git --no-pager log --oneline |
上面reflog
的输出里,特别要注意的是HEAD@{0}
和HEAD@{2}
这两个提交是不一样的提交:
1 | $ git cat-file -p HEAD@{0} |
这个最新的提交时没有父提交的,是一个根提交。
1 | $ git cat-file -p HEAD@{2} |
而这个提交正是我们删除的提交。可以看到reflog
记录了所有的历史,要反悔非常方便。
那么现在假如用git gc
清除提交历史,可不可以呢?
1 | $ git gc --prune=now |
显然不可以,因为reflog
还没有过期,不会自动清理这些没有追踪到的提交。要删除它们,必须让reflog过期:
1 | $ git reflog expire --expire-unreachable=now HEAD |
HEAD
的reflog
不见了!这时候再使用git gc
,这些对象就会被清除了:
1 | $ git gc --prune=now |
假如要清除所有的引用不到的对象,可以用--all
参数:
1 | $ git reflog expire --expire-unreachable=now --all |
参考资料:StackOverflow
5. 复杂地清除提交历史
在Git的老版本中,git checkout --orphan
这个命令是没有的。不使用暴力删除的方法,如何才能删除所有提交历史呢?(实际上这用起来并不方便,但是借此机会来探究一下git内部原理)
我们平时git commit
的时候,到底是做了什么呢?回忆Git中有四种对象类型,blob
,commit
,tag
和tree
。blob用来存储文件,commit用来存储提交,而tree用来存储目录结构。分支游标指向commit,commit指向tree,而tree又指向任意个tree或blob。
1 | $ git cat-file -p HEAD |
git的暂存区也是一个特殊的tree(是一个临时的tree,并没有写入到版本库内)。默认git记录暂存区文件是.git/index
,这是一个二进制文件,不能直接查看,使用ls-files
可以显示暂存区内的文件:
1 | $ git ls-files -s |
假如要丢弃所有历史的话,只要手动将暂存区的内容生成提交,并且不指定父提交就行了。上面第一个命令显示了HEAD所指向的树6bad90776d3a2905f76436013d7c843d0e8e4e2d
,记住这个ID:
1 | $ git commit-tree 6bad9077 <<< 'Initial Commit' |
git commit-tree
命令将一个tree生成一个提交对象,而提交信息则是标准输入,并且打印出生成提交的ID。-p
参数用来指定生成的父提交,这里没写就是一个根提交啦。
研究一下新生成的提交对象:
1 | $ git cat-file -p 4f3126e4c05e5eaec9d |
果然没有父提交。
这下只要将master
分支接到这个提交上,就可以丢弃master历史啦。
1 | $ git reset --hard 4f3126e4c05e5eaec9d |
6. 复杂地生成orphan分支
我们将git内部探索到底,上面讲解了手动直接生成提交而不用commit
命令的方法,那么如何生成一个新的和当前分支没有任何关系的分支呢?(也就是复杂版的“假装在GitHub上删除项目”)
上面说过了,git的暂存区其实就是个临时的tree,是用一个文件来记录的。平时使用的git add
其实是首先计算文件的hash,然后将文件写入版本库(这里千万注意,虽然暂存区不写入版本库,但是它不记录文件信息,暂存区内的文件必须要先写入对象库!),然后将文件的hash写入暂存区文件内。
正常情况下暂存区文件所写的就是最新提交的文件,而新建空分支的话就要一个空的暂存区。git可以通过修改环境,临时指定一个新的暂存区文件:
1 | $ export GIT_INDEX_FILE=/tmp/tmp_index |
可以看到没有任何输出。我们设想的新分支里有一个README.md
,里面写一段话,那么就先创建这个文件(不管工作区内的文件还没删除)。既然要将git探索到底,这个文件的文件名也随意:
1 | $ echo "Removed according to regulation" >future_readme.txt |
首先先要把计算这个文件的hash,并且写入版本库:
1 | $ git hash-object -w future_readme.txt |
-w
表示写入版本库,默认是只计算hash不写入版本库。blob对象只包含这个文件的内容,不包含任何其他信息,这个文件的文件名是记录在tree里的,所以我们不需要管文件名是什么。(计算机上任意地方的文件都可以通过这个方法写入到版本库内)
接下来就是将这个文件的内容写入到暂存区内:
1 | $ git update-index --add --cacheinfo 100644 92527cd20e1594337af8404bdbe78384b10e2000 README.md |
--cacheinfo
的三个参数分别是权限、对象ID和文件名。
将这个临时的树转成真正的tree对象并写入版本库:
1 | $ git write-tree |
成功获得了一个tree对象!这下只要提交就行了。
1 | $ git commit-tree 8524d1d00c861b1222ea6944 <<< Removed |
文件内容果然写入了!最后创建一个分支来记录这个新的提交:(将暂存区还原吧,之前的暂存区没有受到任何影响)
1 | $ unset GIT_INDEX_FILE |
从未存在过的README.md
出现了。
参考资料:Git Book
结语
猫猫从Shadowsocks讲起,仔细研究了git生成提交的过程,希望大家在这之间都能学到一些东西,更深入了解Git。orphan提交也是非常有用的只是,不仅是假装删除,还可以作为Github Pages的源码和生成的html完全分开,或者创建一个新的分支用于存放二进制发布文件。