Git是目前最为流行的版本控制系统,我们程序员几乎每天都需要跟Git打交道。再小心谨慎的人,都免不了犯错。可能在你刚刚commit之后,突然虎躯一震,大呼:“我擦,提交错了,有Bug”。幸好有了Git,幸好Git为我们提供各种强大的“后悔药”,来帮我们应对各种场景。接下来,就让我们来详细了解下吧。It‘s showtime。
注:本文参考了《git权威指南》一书
热身
在我们了解Git的“后悔药”之前,为了能够达到更好的理解效果,我们先来做点热身运动,先来创建一个模拟工程:1
$ git clone --mirror git://github.com/ossxp-com/hello-world.git
我们先来克隆生成一个裸版本库,然后基于这个版本库,进行克隆:
1 | $ git clone ./hello-world.git ./user1/hello-world |
接下来我们就将在这个版本库中,模拟各种可能的情况。
在此之前,我们先来了解下这个版本库在master分支上的历史记录:1
2
3
4
5
6
7git log --graph --oneline
* d901dd8 Merge pull request #1 from gotgithub/patch-1
|\
| * 96fc4d4 Bugfix: build target when version.h changed.
|/
* 3e6070e Show version.
* 75346b3 Hello world initialized.
可以看到目前总共有4个提交。
git commit –amend
因为“单步悔棋”是经常发生,所以Git提供了一个简洁的操作-修补式提交,git commit --amend
。用于对最新的提交进行重新提交,以修补错误的提交说明或错误的提交文件。例如:
首先,我们先来创建一个新的文件,并把它提交:1
2
3
4
5
6$ echo "hell world" >> hello.txt
$ git add hello.txt
$ git commit -m "A new commit"
[master 792bd69] A new commit
1 file changed, 1 insertion(+)
create mode 100644 hello.txt
通过查看log,我们可以发现现在多了一个新的commit:
1 | $ git log --graph --oneline |
假如说我们漏了一个文件没有提交,并且不想放在另外一个新的commit中,那我们可以使用下面的命令:1
2
3
4
5
6
7$ echo "world" >> world.txt
$ git add world.txt
$ git commit --amend -m "Add hello.txt and world.txt"
[master 16b177c] Add hello.txt and word.txt
2 files changed, 2 insertions(+)
create mode 100644 hello.txt
create mode 100644 world.txt
这个时候,我们查看log的时候,可以发现之前的提交被修改了:1
2
3
4
5
6
7
8$ git log --graph --oneline
* 16b177c Add hello.txt and word.txt
* d901dd8 Merge pull request #1 from gotgithub/patch-1
|\
| * 96fc4d4 Bugfix: build target when version.h changed.
|/
* 3e6070e Show version.
* 75346b3 Hello world initialized.
通过命令git log --stat --oneline
,我们还可以看到在最新的提交中包含了新添加的那两个文件:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21$ git log --stat --oneline
16b177c Add hello.txt and word.txt
hello.txt | 1 +
world.txt | 1 +
2 files changed, 2 insertions(+)
d901dd8 Merge pull request #1 from gotgithub/patch-1
96fc4d4 Bugfix: build target when version.h changed.
src/Makefile | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
3e6070e Show version.
COPYRIGHT | 1 +
src/.gitignore | 3 +++
src/Makefile | 15 ++++++++++++++-
src/main.c | 10 +++++++---
src/version.h.in | 6 ++++++
5 files changed, 31 insertions(+), 4 deletions(-)
75346b3 Hello world initialized.
README | 15 +++++++++++++++
src/Makefile | 14 ++++++++++++++
src/main.c | 29 +++++++++++++++++++++++++++++
3 files changed, 58 insertions(+)
git reflog
Git提供了一个挽救机制,通过.git/logs目录下日志文件记录了分支的变更。默认非裸版本库(也即带有工作区)都会提供分支日志功能。master分支的日志文件.git/logs/refs/heads/master,记录了master分支指向的变迁,最新的改变会追加到文件的末尾。我们可以通过git reflog命令对这个文件进行操作。
如果要显示这个文件的内容,我们可以通过git reflog show
命令,比如:1
2
3
4$ git reflog show master
16b177c master@{0}: commit (amend): Add hello.txt and word.txt
792bd69 master@{1}: commit: A new commit
d901dd8 master@{2}: clone: from /Users/justinyang/Downloads/./hello-world.git
可以看到我们之前的提交和修改。
在git reflog
命令的输出种提供了一个方便记忆的表达式:
根据这个文件的内容,我们就可以撤销之前提交的变更。比如我们希望删除之前提交的那两个文件的,也就是前两次的提交):1
$ git reset --hard head@{2}
重置后,如果再用git reflog查看,会看到恢复HEAD的操作也记录在日志中了:1
2
3
4
5$ git reflog show master | head -3
d901dd8 master@{0}: reset: moving to head@{2}
16b177c master@{1}: commit (amend): Add hello.txt and word.txt
792bd69 master@{2}: commit: A new commit
d901dd8 master@{3}: clone: from /Users/justinyang/Downloads/./hello-world.git
这个时候,版本库回到最开始的那个状态,新添加的文件都不见了:1
2$ ls
COPYRIGHT README src
git reset
git reset
重置命令是Git最常用的命令之一。有两种用法:
1 | 1. git rest [-q] [<commit>] [--] <paths> ... |
其中
第一种用法,是用指定提交(git add modifiedFile
,但后来你又不想修改modifiedFile了,那你就可以执行git reset HEAD modifiedFile
,来取消暂存区中modifiedFile的修改。
第二种用法,则会重置引用。根据不同的选项,可以对暂存区或者工作区进行重置。
(注:以上图片来自《git权威指南》一书)
- 使用–hard,会执行上图中的全部动作,即:
- 替换引用的指向。引用指向新的提交ID。
- 替换暂存区。替换后,暂存区的内容和引用指向的目录树一致。
- 替换工作区。替换后,工作区的内容变得和暂存区一致,也和HEAD所指向的目录树内容相同。
- 使用–soft,只会更改引用的指向,不改变暂存区和工作区。
- 使用–mixed(默认为–mixed),则会更改引用的指向以及重置暂存区,但是不改变工作区。
git commit –amend命令实际上相当于执行了下面两条命令:1
2$ git reset --soft HEAD^
$ git commit -e -F .git/COMMIT_EDITMSG
(注:文件.git/COMMIT_EDITMSG保存了上次的提交日志。)
比如我们要恢复之前添加的新文件,则可以:1
2
3
4$ git reset --hard 16b177c
HEAD is now at 16b177c Add hello.txt and word.txt
$ ls
COPYRIGHT README hello.txt src world.txt
git checkout
HEAD可以理解成“头指针”,是当前工作区的“基础版本”,当执行提交时候,HEAD指向的提交将作为新提交的父提交。
在深入了解git checkout
之前,我们先来了解下“分离头指针”状态。
“分离头指针”状态,指的是HEAD头指针指向了一个具体的提交ID,而不是一个引用(分支,如master)。
在“分离头指针”模式下进行的测试提交除了使用提交ID访问之外,不能通过master分支或者其他引用访问到。
我们可以通过git merge detachedID
来将在“分离头指针”状态下的提交合并到当前的分支上。
git checkout命令的实质是修改HEAD本身的指向,该命令不会影响分支“游标”(如master)
git checkout
命令的用法如下:
1 | 1. git checkout [-q] [<commit>] [--] <paths> .. |
第一种用法,git checkout
的这种用法不会改变HEAD头指针,主要是用于指定版本的文件覆盖工作区中对应的文件。如果省略git reset
的默认值是HEAD,而git checkout
是暂存区。因此git reset
一般用于重置暂存区(除非是使用–hard参数,否则不重置工作区),而git checkout
主要是用来覆盖工作区(如果
第二种用法(不使用路径
第三种用法主要是创建和切换到新的分支(<new_branch>),新的分支从<start_point>指定的提交开始创建。新分支和我们熟悉的master分支没有什么实质的不同,都是在refs/heads命名空间下的引用。
比如说,我们先来修改一下hello.txt的内容1
2
3
4$ echo "something" >> hello.txt
$ cat hello.txt
hello world
something
然后我们可以通过git checkout
命令从暂存区中恢复这个文件的内容:1
2
3$ git checkout -- hello.txt
$ cat hello.txt
hello world
git cherry-pick
git cherry-pick
, 其含义是从众多的提交中挑选出一个提交应用在当前的工作分支中。该命令需要提供一个提交ID作为参数,操作过程相当于将该提交导出为补丁文件,然后在当前HEAD上重放,形成无论内容还是提交说明都一致的提交。
现在,我们先来添加一个文件,并提交它:1
2
3
4
5
6$ echo "new file" >> newFile.txt
$ git add newFile.txt
$ git commit -m "Add another file"
[master 545e463] Add another file
1 file changed, 1 insertion(+)
create mode 100644 newFile.txt
这个时候查看log,可以看到提交545e463
在提交之后16b177c
1
2
3
4
5
6
7
8
9$ git log --graph --oneline
* 545e463 Add another file
* 16b177c Add hello.txt and word.txt
* d901dd8 Merge pull request #1 from gotgithub/patch-1
|\
| * 96fc4d4 Bugfix: build target when version.h changed.
|/
* 3e6070e Show version.
* 75346b3 Hello world initialized.
如果我们想删除掉16b177c
提交,即将545e463
提交接在d901dd8
提交后面的话,可以这么做:
- 首先执行
git checkout
命令,暂时将HEAD头指针切换到d901dd8
:
1 | $ git checkout d901dd8 |
- 执行
git cherry-pick
命令将545e463
提交在当前HEAD上重放:
1 | $ git cherry-pick 545e463 |
- 通过日志可以看到提交已经不在了:
1 | $ git log --graph --oneline |
- 最后只需要将master分支重置到新的提交上
1 | $ git checkout master |
git rebase
git rebase
,是对提交执行变基操作,即可以实现将制定范围的提交“嫁接”到另外一个提交之上。其常用的用法如下:
1 | 1. git rebase --onto <newbase> <since> <till> |
前四个用法,如果把省略的参数都补上,其实就是用法1。而后三种是在变基运行过程被中断时可采用的命令-继续变基或终止等。
第一种用法会执行下面操作:
- 首先会执行
git checkout
切换到<till>
。因为会切换到<till>
,因此如果<till>
指向的不是一个分支(如master),则变基操作是在detached HEAD
(分离头指针状态)进行的,当变基结束后,还要对master分支执行重置以实现变基结果在分支中生效。 - 将
.. 所标记的提交范围写到一个临时文件中。 .. 是指包括 的所有历史提交排除 以及 的历史提交后形成的版本范围。 - 将当前分支强制重置到
<newbase>
,也即是执行:git reset --hard <newbase>
。 - 从保存在临时文件中的提交列表中,将提交逐一按顺序重新提交到重置之后的分支上。
- 如果遇到提交已经包含在分支中,则跳过该提交。
- 如果在提交过程遇到冲突,则变基过程暂停。用户解决冲突后,执行
git rebase --continue
继续变基操作,或者执行git rebase --skip
跳过此提交,或者执行git rebase --abort
就此终止变基操作切换到变基前的分支上。
第五种用法,是执行交互式变基操作,会将
这个文件中有5种动作:
- pick动作,或者简写为p,是指应用此提交
- reword动作,或者简写为r,表示在变基时会应用此提交,但是在提交的时候允许用户修改提交说明。
- edit动作,或者简写为e,也会在变基时应用此提交,但是会在应用后暂停变基,提示用户使用
git commit --amend
执行提交,以便对提交进行修补。当用户执行git commit --amend
完成提交后,还需要执行git rebase --continue
继续变基操作。用户在变基暂停状态下可以执行多次提交,从而实现把一个提交分解为多个提交。 - squash动作,或者简写为s,会与前面的提交压缩为一个。
- fixup动作,或者简写为f,类似squash动作,但是此提交的提交说明会被丢弃。
在这里我们只演示下git rebase -i
命令。
首先,我们先来查看下HEAD变更的日志:
1 | $ git reflog show |
然后将版本库恢复到HEAD@{4}的状态1
2$ git reset --hard HEAD@{4}
HEAD is now at 545e463 Add another file
为了更好的演示,我们再提交一个空文件:1
2
3
4
5
6$ touch dump.txt
$ git add dump.txt
$ git commit -m "Add a dump text"
[master 02f8a49] Add a dump text
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 dump.txt
假如,我们需要删除新的02f8a49
提交,并要把545e463
和16b177c
这两个提交合并,则可以执行下面这些操作:
执行交互式变基操作:
shell 1
git rebase -i d901dd8
自动打开编辑器。文件内容如下(省略井号开始的注释):
1 | pick 16b177c Add hello.txt and word.txt |
修改文件,使得内容看起来像下面这样(同样省略了井号开始的注释):
shell 1
2pick 16b177c Add hello.txt and word.txt
squash 545e463 Add another file保存退出后,因为我们使用了
squash
动作,所以会再自动打开编辑器,可以不做任何修改,直接保存退出便可。这样变基就会自动开始,即刻完成。显示下面的内容:
shell 1
2
3
4
5
6[detached HEAD 6c0bdde] Add hello.txt and word.txt
3 files changed, 3 insertions(+)
create mode 100644 hello.txt
create mode 100644 newFile.txt
create mode 100644 world.txt
Successfully rebased and updated refs/heads/master.这个时候,我们查看日志,可以看到分支master已经完成了变基:
1 | git log --graph --oneline |
Appendix
Git提供了很多方法可以方便地访问Git库中的对象:
- 使用master代表分支master中最新的提交,也可以使用全称refs/heads/master或者heads/master
- 使用HEAD代表版本库中最近的一次提交
- 符号^可以用于指代父提交,即最近一次提交的父提交。
- HEAD^代表版本库中的上一次提交,即最近一次提交的父提交。
- HEAD^^则代表HEAD^的父提交。
- 对于一个提交有多个父提交,可以在符号^后面用数字表示是第几个父提交。例如:
- a573106^2的含义是提交a573106的多个父提交中的第二个父提交。
- HEAD^^2的含义是HEAD^(HEAD^)的多个父提交中的第二个父提交。
- 符号~
也可以用于指代祖先提交。例如: - a573106~5即相当于a573106^^^^^。
- 提交所对应的树对象,可以用类似于如下的语法访问:
- a573106^{tree}
- 某一次提交对应的文件对象,可以用如下的语法访问:
- a573106:path/to/file
- 暂存区中的文件对象,可以用如下的语法访问:
- :path/to/file
另外,值得注意的是,在本地对版本库历史进行修改后,如果你要提交到远程版本库的时候,你可以使用git push origin -f
来强制提交修改。
No newline at end of file