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
2
$ git checkout master
$ git merge dev

则在 master 分支上也会出现这三个 commit

此时我们运行

1
$ git reset origin/master

git reset 这个命令实际上将一个分支的末端指向了另一个提交。在这里,我们将本地的 master 分支末端重新指向 origin/master。现在 ABC 三个 commit 的修改都在 master 的工作区。接着运行

1
2
3
$ git add -A
$ git commit -m "add new feature"
$ git push

然后就会变成这样

如果之后有人在 master 分支上提交了新的代码,需要更新到自己的 dev 分支上。则需要

1
2
3
4
$ git pull
$ git checkout dev
$ git merge master
$ git push

在一番 merge / 解决 conflict / push 之后便会这样子

实际上我们也可以直接使用 git rebase 的命令来重写 git 历史直接合并 commit。但这个操作多少有点危险,而且如果已经将修改提交至远端再执行 git rebase 会遇到不小的麻烦。

晓美焰 の commit

git 中的版本回退也是很常见的操作。

如果想修改刚刚提交的 commit,往往想做两件事情:修改提交信息,或者修改你添加、修改和移除的文件的快照。如果只是想修改提交信息,那么这样就可以了

1
$ git commit --amend

如果想修改上一次提交的内容,则可先执行 git addgit rm,再执行 git commit --amend

对于真正意义上的代码回滚,我们会涉及到三个命令 git resetgit checkoutgit revert。我主要参考了这篇教程。部分文字直接引用。

牢记在 git 中有以下三个存储区域,在不同区域回滚需要不同的操作。

Commit History

Reset

git reset 将一个分支的末端指向另一个提交。利用这个特性我们可以删除当前分支的一些提交。比如执行下面的命令将 hotfix 分支回退了两个提交

1
2
$ git checkout hotfix
$ git reset HEAD~2

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
2
3
$ git reset --hard a
$ git reset --soft d
$ git commit

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
2
$ git checkout hotfix
$ git revert HEAD~2

注意!这里只是 revert 了 HEAD 前倒数第二个 commit,但倒数第二个 commit 造成的修改还是存在。如果想要回到 HEAD 前倒数第二个 commit 的状态,则需要递归地执行 git revert 。例如当前 branch 的提交历史如下(参照了这里

1
2
3
4
5
G1 - G2 - G3 - B1 - B2 - B3
\ \ \ \-- HEAD
\ \ \------ HEAD~1
\ \---------- HEAD~2
\-------------- HEAD~3

如果想要回滚到 G3 这个提交,则执行

1
2
3
$ git revert --no-commit HEAD~3..HEAD
$ git add -A
$ git commit -m "revert to G3"

一般而言,使用 git revert 相比 git reset 不会影响到已经提交的历史,所以更加合适回滚。

文件层面

某些情况下,我们只需要操作某个特定的文件,而不是整个 commit 历史。这个时候可以在命令后面添加文件名实现。

Reset

git reset 命令后跟文件操作,可以将缓存区文件同步到某个特定的提交。例子如下

1
2
3
4
# 将倒数第二个提交中的 foo.py 加入到缓存区中
$ git reset HEAD~2 foo.py
# 从缓存区中移除 foo.py 而不会影响工作目录
$ git reset HEAD 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
2
$ git filter-branch --force --index-filter 'git rm --cached --ignore-unmatch projects/password.txt' --prune-empty --tag-name-filter cat -- --all
$ git push --force --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
2
3
$ git clone --mirror git://example.com/some-big-repo.git
$ git reflog expire --expire=now --all && git gc --prune=now --aggressive
$ git push

下面是(从官网抄的)一些例子

1
2
3
4
5
6
# 删除所有名叫 'id_rsa' 或 'id_dsa' 的文件
$ bfg --delete-files id_{dsa,rsa} my-repo.git
# 删除所有大于 50 M 的文件
$ bfg --strip-blobs-bigger-than 50M my-repo.git
# 将所有 'passwords.txt' 文件里的内容替换为 ***REMOVED***
$ bfg --replace-text passwords.txt my-repo.git

从零开始的 submodule

submodule 比较适合在一些项目中引入一些自己写的库。submodule 实质上是独立的一个 git 仓库。

添加一个子模块很简单

1
git submodule add https://github.com/chaconinc/DbConnector

执行完这个命令后,在 git 仓库里会多一个 .gitmodules 文件,该配置文件保存了项目 URL 与已经拉取的本地目录之间的映射。如果有多个子模块,该文件中就会有多条记录。 要重点注意的是,该文件也像 .gitignore 文件一样受到(通过)版本控制。 它会和该项目的其他部分一同被拉取推送。 这就是克隆该项目的人知道去哪获得子模块的原因。

克隆含有子模块的项目可以有两种方法。

1
2
3
4
# 方法一
git submodule init
# 方法二
git clone --recursive https://github.com/chaconinc/MainProject

正常情况下,我们只会让子模块的内容随着上游更新而更新。我一般的方法就是在子模块的目录执行 git pull 。虽然也有方法可以在子模块里面修改文件,并合并上游的修改,但我不觉得这是一个好主意。

学姐 の head detached

git 操作中有很多情况下会导致 head detached 状态。这个状态其实还是有点危险的,处于这个状态下你对文件的修改都没办法提交(因为不知道应该修改在哪个分支上)。

造成的可能原因有

  • 使用 Checkout 指令直接跳到某个 Commit,而那个 Commit 刚好没有分支指向它
  • Rebase 过程中也是不断处于 head detached 状态
  • 切换到某个远端分支时

脱离这个状态,只要执行下面的命令就可以

1
git checkout master