git 大起底
说起来惭愧,虽然 git 用了好几年,但大部分都停留在基本的 commit 和 push 上,最多也就涉及到一些 merge。稍微骚一些的操作就需要查资料了。虽然有点水,不过还是记录一下 git 中一些稍微骚一些的操作。
commit 融合!
大家都知道最惨的事情莫过于:
- 代码没保存 IDE 崩溃了
- 代码没提交电脑崩溃了
随着 IDE 的进化,现在大部分的编辑器都支持自动保存,第一种情况几乎不太会发生。对于第二种情况,那就只能更加频繁地在 git 上 push 代码以降低自己的损失了。但这样会有一个小小的问题,如果你比较频繁地 commit 就会变成这样子
一堆的 commit 记录其实不算很美观。我们完全可以把很多次的 commit 合并为一个 commit。
一般而言,自己的开发 branch 上 commit 乱一些无关紧要,但是当我们完成了一个功能,需要提交到 master 分支或者其他上游时,如果直接 merge,就会把所有乱七八糟的 commit 都一起提交。为了方便叙述我用一下 SourceTree 的 commit 图表
假设我们在 dev 这个分支上,dev 这个分支在 master 的 init 这个 commit 上做了 ABC 三次 commit。现在如果我们直接运行
1 | $ git checkout master |
则在 master 分支上也会出现这三个 commit
此时我们运行
1 | $ git reset origin/master |
git reset
这个命令实际上将一个分支的末端指向了另一个提交。在这里,我们将本地的 master 分支末端重新指向 origin/master。现在 ABC 三个 commit 的修改都在 master 的工作区。接着运行
1 | $ git add -A |
然后就会变成这样
如果之后有人在 master 分支上提交了新的代码,需要更新到自己的 dev 分支上。则需要
1 | $ git pull |
在一番 merge / 解决 conflict / push 之后便会这样子
实际上我们也可以直接使用 git rebase
的命令来重写 git 历史直接合并 commit。但这个操作多少有点危险,而且如果已经将修改提交至远端再执行 git rebase
会遇到不小的麻烦。
晓美焰 の commit
git 中的版本回退也是很常见的操作。
如果想修改刚刚提交的 commit,往往想做两件事情:修改提交信息,或者修改你添加、修改和移除的文件的快照。如果只是想修改提交信息,那么这样就可以了
1 | $ git commit --amend |
如果想修改上一次提交的内容,则可先执行 git add
或 git rm
,再执行 git commit --amend
对于真正意义上的代码回滚,我们会涉及到三个命令 git reset
、git checkout
和 git revert
。我主要参考了这篇教程。部分文字直接引用。
牢记在 git 中有以下三个存储区域,在不同区域回滚需要不同的操作。
Commit History
Reset
git reset
将一个分支的末端指向另一个提交。利用这个特性我们可以删除当前分支的一些提交。比如执行下面的命令将 hotfix 分支回退了两个提交
1 | $ git checkout hotfix |
hotfix 分支末端的两个提交现在变成了悬挂提交。也就是说,下次 Git 执行垃圾回收的时候,这两个提交会被删除。就像下图所示。
当你还没将代码提交到服务器,git reset
是一个很简单的方法。但如果已经提交了,那就最好使用 git revert
等操作。git reset
还有参数来控制其影响区域:
- --soft – 缓存区和工作目录都不会被改变
- --mixed – 默认选项。缓存区和你指定的提交同步,但工作目录不受影响
- --hard – 缓存区和工作目录都同步到你指定的提交
一张图解释清楚各个参数的作用域:
一些小技巧:
git reset --mixed HEAD
将你当前的改动从缓存区中移除,但是这些改动还留在工作目录中。
git reset --hard HEAD
完全舍弃没有提交的更改。
对于想回滚到某个特定的 commit 也可以利用 git reset
来达到,虽然用 git revert
可能会更贴切一点。比如从 D 回滚到 A
1 | $ git reset --hard a |
checkout
大家用的最多的应该是使用 git checkout
切换分支。
1 | $ git checkout hotfix |
例如上面的命令将 HEAD 移动到一个新的分支,然后更新工作目录。Git 强制你提交或者缓存工作目录中的所有更改,不然在 checkout 的时候这些更改都会丢失。与 git reset
不同,git checkout
不会移动分支。
git checkout
除了可以切换分支,还可以切换到任意的 commit。其实这和切换分支是一样的:把 HEAD 移动到特定的 commit 上。比如,下面这个命令会 checkout 到当前提交的前两个 commit。
1 | $ git checkout HEAD~2 |
使用 git checkout
可以很方便地在不同 commit 间切换。但如果你当前的 HEAD 没有任何分支引用,那么这会造成 HEAD 分离。这是非常危险的,如果你接着添加新的提交,然后切换到别的分支之后就没办法回到之前添加的这些提交。因此,在为分离的 HEAD 添加新的提交的时候你应该创建一个新的分支。
Revert
Revert
实际上是一个特殊的 commit。Revert
在撤销一个提交的同时会创建一个新的 commit。例如下面的命令会找出 HEAD 前倒数第二个提交,并创建一个新的提交的撤销更改。
1 | $ git checkout hotfix |
注意!这里只是 revert 了 HEAD 前倒数第二个 commit,但倒数第二个 commit 造成的修改还是存在。如果想要回到 HEAD 前倒数第二个 commit 的状态,则需要递归地执行 git revert
。例如当前 branch 的提交历史如下(参照了这里)
1 | G1 - G2 - G3 - B1 - B2 - B3 |
如果想要回滚到 G3 这个提交,则执行
1 | $ git revert --no-commit HEAD~3..HEAD |
一般而言,使用 git revert
相比 git reset
不会影响到已经提交的历史,所以更加合适回滚。
文件层面
某些情况下,我们只需要操作某个特定的文件,而不是整个 commit 历史。这个时候可以在命令后面添加文件名实现。
Reset
在 git reset
命令后跟文件操作,可以将缓存区文件同步到某个特定的提交。例子如下
1 | # 将倒数第二个提交中的 foo.py 加入到缓存区中 |
但要注意的是 --soft
--mixed
--hard
对文件层面上的 git reset
并没有作用。同时要注意 git reset
只对 track 过的文件有影响。要删除没有被 check 过的命令,需要执行 git clean -df
命令。
Checkout
Checkout 一个文件和带文件路径 git reset
非常像,除了它更改的是工作目录而不是缓存区。不像提交层面的 checkout 命令,它不会移动 HEAD引用,也就是你不会切换到别的分支上去。
reset checkout revert 小结
命令 | 作用域 | 常用情景 |
---|---|---|
git reset | 提交层面 | 在私有分支上舍弃一些没有提交的更改 |
git reset | 文件层面 | 将文件从缓存区中移除 |
git checkout | 提交层面 | 切换分支或查看旧版本 |
git checkout | 文件层面 | 舍弃工作目录中的更改 |
git revert | 提交层面 | 在公共分支上回滚更改 |
git revert | 文件层面 | (然而并没有) |
抹杀你的存在!
使用 git 遇到还有一个比较尴尬的问题是不小心提交了密码,或者是提交了一些非常大的二进制文件。要想在 git 上彻底抹消这些个文件的存在痕迹和 git 的设计初衷相背,所以会稍微麻烦一些。
git filter-branch
这个命令算是 git 里面的大杀器之一,因为会涉及到对历史的修改,所以需要慎重。具体的各个参数可以参考git-filter-branch 官方文档
例如,要从所有的历史中删除 project/password.txt
这个文件,可以执行下面的命令
1 | $ git filter-branch --force --index-filter 'git rm --cached --ignore-unmatch projects/password.txt' --prune-empty --tag-name-filter cat -- --all |
对于大文件,我们首先需要定位,下面这个命令可以查找当前 branch 最大的五个文件,参考这里
1 | $ git ls-tree -r -t -l --full-name HEAD | sort -n -k 4 | tail -n 5 |
bfg 工具
对于大量的删除,还有另外一个好用的工具叫 bfg,号称比 git filter-branch 快 10 到 720 倍。它其实是一个 java 写的小程序,只要本地有 jdk 就可以运行。为了方便,还可以在自己 (Mac 下) 的 .bash_profile
里加一句
1 | alias bfg='java -jar PATH_TO_BFG_JAR' |
首先找个干净的地方 clone 整个工程,在确认处理完后,再执行 git gc
然后提交
1 | $ git clone --mirror git://example.com/some-big-repo.git |
下面是(从官网抄的)一些例子
1 | # 删除所有名叫 'id_rsa' 或 'id_dsa' 的文件 |
从零开始的 submodule
submodule 比较适合在一些项目中引入一些自己写的库。submodule 实质上是独立的一个 git 仓库。
添加一个子模块很简单
1 | git submodule add https://github.com/chaconinc/DbConnector |
执行完这个命令后,在 git 仓库里会多一个 .gitmodules
文件,该配置文件保存了项目 URL 与已经拉取的本地目录之间的映射。如果有多个子模块,该文件中就会有多条记录。 要重点注意的是,该文件也像 .gitignore 文件一样受到(通过)版本控制。 它会和该项目的其他部分一同被拉取推送。 这就是克隆该项目的人知道去哪获得子模块的原因。
克隆含有子模块的项目可以有两种方法。
1 | # 方法一 |
正常情况下,我们只会让子模块的内容随着上游更新而更新。我一般的方法就是在子模块的目录执行 git pull
。虽然也有方法可以在子模块里面修改文件,并合并上游的修改,但我不觉得这是一个好主意。
学姐 の head detached
git 操作中有很多情况下会导致 head detached 状态。这个状态其实还是有点危险的,处于这个状态下你对文件的修改都没办法提交(因为不知道应该修改在哪个分支上)。
造成的可能原因有
- 使用 Checkout 指令直接跳到某个 Commit,而那个 Commit 刚好没有分支指向它
- Rebase 过程中也是不断处于 head detached 状态
- 切换到某个远端分支时
脱离这个状态,只要执行下面的命令就可以
1 | git checkout master |