git - start to change history

time machine

Git是目前最为流行的版本控制系统,我们程序员几乎每天都需要跟Git打交道。再小心谨慎的人,都免不了犯错。可能在你刚刚commit之后,突然虎躯一震,大呼:“我擦,提交错了,有Bug”。幸好有了Git,幸好Git为我们提供各种强大的“后悔药”,来帮我们应对各种场景。接下来,就让我们来详细了解下吧。It‘s showtime。

注:本文参考了《git权威指南》一书

热身

在我们了解Git的“后悔药”之前,为了能够达到更好的理解效果,我们先来做点热身运动,先来创建一个模拟工程:

shell
1
$ git clone --mirror git://github.com/ossxp-com/hello-world.git

我们先来克隆生成一个裸版本库,然后基于这个版本库,进行克隆:

shell
1
2
3
4
$ git clone ./hello-world.git ./user1/hello-world
Cloning into './user1/hello-world'...
done.
Checking connectivity... done

接下来我们就将在这个版本库中,模拟各种可能的情况。

在此之前,我们先来了解下这个版本库在master分支上的历史记录:

shell
1
2
3
4
5
6
7
git 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。用于对最新的提交进行重新提交,以修补错误的提交说明或错误的提交文件。例如:

首先,我们先来创建一个新的文件,并把它提交:

shell
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:

shell
1
2
3
4
5
6
7
8
$ git log --graph --oneline
* 792bd69 A new commit
* d901dd8 Merge pull request #1 from gotgithub/patch-1
|\
| * 96fc4d4 Bugfix: build target when version.h changed.
|/
* 3e6070e Show version.
* 75346b3 Hello world initialized.

假如说我们漏了一个文件没有提交,并且不想放在另外一个新的commit中,那我们可以使用下面的命令:

shell
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的时候,可以发现之前的提交被修改了:

shell
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,我们还可以看到在最新的提交中包含了新添加的那两个文件:

shell
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命令,比如:

shell
1
2
3
4
$ git reflog show master
16b177c [email protected]{0}: commit (amend): Add hello.txt and word.txt
792bd69 [email protected]{1}: commit: A new commit
d901dd8 [email protected]{2}: clone: from /Users/justinyang/Downloads/./hello-world.git

可以看到我们之前的提交和修改。

git reflog命令的输出种提供了一个方便记忆的表达式:@{},来表示引用之前第次改变时的SHA1哈希值。

根据这个文件的内容,我们就可以撤销之前提交的变更。比如我们希望删除之前提交的那两个文件的,也就是前两次的提交):

shell
1
$ git reset --hard [email protected]{2}

重置后,如果再用git reflog查看,会看到恢复HEAD的操作也记录在日志中了:

shell
1
2
3
4
5
$ git reflog show master | head -3
d901dd8 [email protected]{0}: reset: moving to [email protected]{2}
16b177c [email protected]{1}: commit (amend): Add hello.txt and word.txt
792bd69 [email protected]{2}: commit: A new commit
d901dd8 [email protected]{3}: clone: from /Users/justinyang/Downloads/./hello-world.git

这个时候,版本库回到最开始的那个状态,新添加的文件都不见了:

shell
1
2
$ ls
COPYRIGHT README src

git reset

git reset重置命令是Git最常用的命令之一。有两种用法:

shell
1
2
1. git rest [-q] [<commit>] [--] <paths> ...
2. git rest [--soft | --mixed | --hard | --keey] [-q] [<commit>]

其中是可选项,可以使用引用或者提交ID,默认是HEAD的指向作为提交ID。这两种用户的区别在于,第一种用法在命令中包含路径(前面的两个连续的短线是为了避免路径和引用(或者提交ID)同名而发生冲突)。

第一种用法,是用指定提交()状态下的文件()替换掉暂存区中的文件,不会重置引用,也不会改变工作区。例如,你刚执行完git add modifiedFile,但后来你又不想修改modifiedFile了,那你就可以执行git reset HEAD modifiedFile,来取消暂存区中modifiedFile的修改。

第二种用法,则会重置引用。根据不同的选项,可以对暂存区或者工作区进行重置。

git reset

(注:以上图片来自《git权威指南》一书)

  • 使用–hard,会执行上图中的全部动作,即:
    1. 替换引用的指向。引用指向新的提交ID。
    2. 替换暂存区。替换后,暂存区的内容和引用指向的目录树一致。
    3. 替换工作区。替换后,工作区的内容变得和暂存区一致,也和HEAD所指向的目录树内容相同。
  • 使用–soft,只会更改引用的指向,不改变暂存区和工作区。
  • 使用–mixed(默认为–mixed),则会更改引用的指向以及重置暂存区,但是不改变工作区。

git commit –amend命令实际上相当于执行了下面两条命令:

shell
1
2
$ git reset --soft HEAD^
$ git commit -e -F .git/COMMIT_EDITMSG

(注:文件.git/COMMIT_EDITMSG保存了上次的提交日志。)

比如我们要恢复之前添加的新文件,则可以:

shell
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命令的用法如下:

shell
1
2
3
1. git checkout [-q] [<commit>] [--] <paths> ..
2. git checkout [<branch>]
3. git checkout [-m] [[-b | --orphan] <new_branch>] [<start_point>]

第一种用法,如果省略的话,就相当于是从暂存区进行检出。另外,git checkout的这种用法不会改变HEAD头指针,主要是用于指定版本的文件覆盖工作区中对应的文件。如果省略,则会用暂存区的文件覆盖工作区的文件,否则用指定提交中的文件覆盖暂存区和工作区中对应的文件。这里需要注意的是,git reset的默认值是HEAD,而git checkout是暂存区。因此git reset一般用于重置暂存区(除非是使用–hard参数,否则不重置工作区),而git checkout主要是用来覆盖工作区(如果不省略,也会替换暂存区中相应的文件)。

第二种用法(不使用路径的用法)则会改变HEAD头指针。这种用法主要的作用就是切换到别的分支。

第三种用法主要是创建和切换到新的分支(<new_branch>),新的分支从<start_point>指定的提交开始创建。新分支和我们熟悉的master分支没有什么实质的不同,都是在refs/heads命名空间下的引用。

比如说,我们先来修改一下hello.txt的内容

shell
1
2
3
4
$ echo "something" >> hello.txt 
$ cat hello.txt
hello world
something

然后我们可以通过git checkout命令从暂存区中恢复这个文件的内容:

shell
1
2
3
$ git checkout -- hello.txt 
$ cat hello.txt
hello world

git cherry-pick

git cherry-pick, 其含义是从众多的提交中挑选出一个提交应用在当前的工作分支中。该命令需要提供一个提交ID作为参数,操作过程相当于将该提交导出为补丁文件,然后在当前HEAD上重放,形成无论内容还是提交说明都一致的提交。

现在,我们先来添加一个文件,并提交它:

shell
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

shell
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提交后面的话,可以这么做:

  1. 首先执行git checkout命令,暂时将HEAD头指针切换到d901dd8
shell
1
$ git checkout d901dd8
  1. 执行git cherry-pick命令将545e463提交在当前HEAD上重放:
shell
1
2
3
4
$ git cherry-pick 545e463
[detached HEAD 6384d10] Add another file
1 file changed, 1 insertion(+)
create mode 100644 newFile.txt
  1. 通过日志可以看到提交已经不在了:
shell
1
2
3
4
5
6
7
8
$ git log --graph --oneline
* 6384d10 Add another file
* d901dd8 Merge pull request #1 from gotgithub/patch-1
|\
| * 96fc4d4 Bugfix: build target when version.h changed.
|/
* 3e6070e Show version.
* 75346b3 Hello world initialized.
  1. 最后只需要将master分支重置到新的提交上
shell
1
2
3
4
$ git checkout master
Switched to branch 'master'
$ git reset --hard [email protected]{1}
HEAD is now at 6384d10 Add another file

git rebase

git rebase,是对提交执行变基操作,即可以实现将制定范围的提交“嫁接”到另外一个提交之上。其常用的用法如下:

shell
1
2
3
4
5
6
7
8
1. git rebase --onto <newbase> <since> <till>
2. git rebase --onto <newbase> <since>
3. git rebase <since> <till>
4. git rebaes <since>
5. git rebase -i ...
6. git rebase --continue
7. git rebase --skip
8. git rebase --abort

前四个用法,如果把省略的参数都补上,其实就是用法1。而后三种是在变基运行过程被中断时可采用的命令-继续变基或终止等。

第一种用法会执行下面操作:

  1. 首先会执行git checkout切换到<till>。因为会切换到<till>,因此如果<till>指向的不是一个分支(如master),则变基操作是在detached HEAD(分离头指针状态)进行的,当变基结束后,还要对master分支执行重置以实现变基结果在分支中生效。
  2. ..所标记的提交范围写到一个临时文件中。..是指包括的所有历史提交排除以及的历史提交后形成的版本范围。
  3. 将当前分支强制重置到<newbase>,也即是执行:git reset --hard <newbase>
  4. 从保存在临时文件中的提交列表中,将提交逐一按顺序重新提交到重置之后的分支上。
  5. 如果遇到提交已经包含在分支中,则跳过该提交。
  6. 如果在提交过程遇到冲突,则变基过程暂停。用户解决冲突后,执行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变更的日志:

shell
1
2
3
4
5
6
7
8
9
10
11
12
$ git reflog show
6384d10 [email protected]{0}: reset: moving to [email protected]{1}
545e463 [email protected]{1}: checkout: moving from 6384d10479d725dd787d431dd0c2a916a5bcc09f to master
6384d10 [email protected]{2}: cherry-pick: Add another file
d901dd8 [email protected]{3}: checkout: moving from master to d901dd8
545e463 [email protected]{4}: commit: Add another file
16b177c [email protected]{5}: reset: moving to 16b177c
3e6070e [email protected]{6}: reset: moving to HEAD^
d901dd8 [email protected]{7}: reset: moving to [email protected]{2}
16b177c [email protected]{8}: commit (amend): Add hello.txt and word.txt
792bd69 [email protected]{9}: commit: A new commit
d901dd8 [email protected]{10}: clone: from /Users/justinyang/Downloads/./hello-world.git

然后将版本库恢复到[email protected]{4}的状态

shell
1
2
$ git reset --hard [email protected]{4}
HEAD is now at 545e463 Add another file

为了更好的演示,我们再提交一个空文件:

shell
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提交,并要把545e46316b177c这两个提交合并,则可以执行下面这些操作:

  1. 执行交互式变基操作:

    shell
    1
    git rebase -i d901dd8
  2. 自动打开编辑器。文件内容如下(省略井号开始的注释):

shell
1
2
3
pick 16b177c Add hello.txt and word.txt
pick 545e463 Add another file
pick 02f8a49 Add a dump text
  1. 修改文件,使得内容看起来像下面这样(同样省略了井号开始的注释):

    shell
    1
    2
    pick 16b177c Add hello.txt and word.txt
    squash 545e463 Add another file
  2. 保存退出后,因为我们使用了squash动作,所以会再自动打开编辑器,可以不做任何修改,直接保存退出便可。

  3. 这样变基就会自动开始,即刻完成。显示下面的内容:

    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.
  4. 这个时候,我们查看日志,可以看到分支master已经完成了变基:

shell
1
2
3
4
5
6
7
8
git log --graph --oneline
* 6c0bdde 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.

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