Git/Git 详解
这篇文章,我会从使用层面,和原理层面,来讲解 git 的常用操作,并且给出一些最佳实践。
本地仓库与远程仓库
git 是一个分布式版本控制系统,除了在本地仓库记录各种修改,更重要的功能是:同步到某一个中央仓库,让团队的人随时可以看到其他人最新的代码。这一节来说下如何建立本地仓库和远程仓库的连接。
加入已有的代码仓库
如果一个团队里已经有了代码仓库,存储了很多历史代码,你是新加入的人,那么只要使用 git clone
语句,把远程代码仓库下载到本地即可。比如远程代码仓库是 https://gitee.com/zhangxian2019/git-practice.git
,语句如下:
1 | git clone https://gitee.com/zhangxian2019/git-practice.git |
新建本地仓库和远程仓库
首先在 github 或者 gitee 等第三方代码托管平台建立一个新的仓库,如下图所示:

如果本地还没有建立仓库,通过git init
来初始化一个仓库。
接着在本地仓库,添加远程仓库:
1 | git remote add origin https://gitee.com/zhangxian2019/git-practice.git |
其中 origin
是你给远程仓库起的名字,https://gitee.com/zhangxian2019/git-practice.git
是远程仓库的地址。
如果你要把一个本地仓库同步到多个远程仓库,需要给不同的远程仓库起不同的名字。
如在本地仓库再添加 github 远程仓库。首先在 github 创建好一个空项目,如下图所示:

然后在本地仓库添加 github 的远程仓库:
1 | git remote add github https://github.com/zhangxiann/GitPractice.git |
这里给这个远程仓库,起了一个别名为 github
。
你可以使用 git remote -v
来查看本地仓库对应的所有远程仓库,如下图所示:

上图中,总共有 2 个远程仓库,一个仓库的名字是 github
,另一个仓库的名字是 origin
。
git 分区
在讲解 git 的提交历史记录之前,我们先来看下 git 的 3 个分区:
working directory 是「工作目录」,也就是我们肉眼能够看到的文件,后文我们称其为
work dir
区。当我们在
work dir
中执行 git add 相关命令后,就会把 work dir 中的修改添加到「暂存区」 stage area(或者叫 index area)中去,后文我们称暂存区为stage
区。当
stage
中存在修改时,我们使用 git commit 相关命令之后,就会把 stage 中的修改保存到「提交历史」commit history 中,也就是HEAD
指针指向的位置。后文我们称「提交历史」为history
区。

任何修改只要进入 commit history
,基本可以认为永远不会丢失了。每个 commit
都有一个唯一的 SHA-1
校验和。
work dir
和 stage
区域的状态,可以通过命令 git status
来查看,history
区域的提交历史可以通过 git log
命令来查看。
下面举个例子。
我们新建一个文件 a.txt
,然后使用 git status
查看文件状态。
1 | touch a.txt |

上图中, untrack files
中显示的是 work dir
中的文件,这些文件有变动,但还没添加到 state
区。
我们使用 git add
命令,将 work dir
中的文件移动到 state
区,再使用 git status
查看文件状态。
1 | git add a.txt |

这时,显示 a.txt
的状态变为了 Changes to be committed
,表示这个文件添加到了 state
区中。这时我们再新建一个文件 b.txt
,使用 git status
查看文件状态。
1 | touch b.txt |

这时,新建的 b.txt
是在 work dir
区中,我们使用 git commit
方法,只会提交 stage
区中的 a.txt
,而不会提交 work dir
区中的 b.txt
。
1 | git commit -m "add a.txt" |

从 git status
显示中,我们可以看到 2 个信息:
b.txt
仍然处于work dir
中,验证了我们上面所说的。即git commit
方法,只会提交stage
区,而不会提交work dir
区。- 目前我们的本地分支,比远程分支
origin/master
落后一个提交。
最后我们需要使用git push
命令提交 commit
到远程仓库。
1 | git push origin master |
你也可以只是用 git push
,后面不加远程仓库名和分支名,这时远程仓库默认使用 origin
,分支名默认使用当前所在分支,推送到远程仓库的同名分支。
我们一开始添加了 2 个远程仓库,你可以使用 2 个语句来更新对应的远程仓库:
1 | # git push 远程仓库名 本地分支名 |
其中 github
和 origin
都是远程仓库, master
是本地分支。这种写法并没有指定远程仓库的分支,那么就会推送到远程仓库的同名分支上,在这个例子里,本地分支是 master
,会推送到远程仓库的 master
分支。
前面如果你想要推送到远程仓库的指定分支,语法如下:
1 | # git push 远程仓库名 本地分支名:远程分支名 |
最后的 test_branch
就是远程分支名。
使用git branch -a
查看所有的远程仓库,-a
参数是显示详细信息。
1 | git branch -a |

可以看到本地仓库有 master
分支,远程仓库有 master
分支和 test_branch
分支。
使用git push origin --delete 远程分支名
可以删除远程分支。
1 | git push origin --delete test_branch |
下面我会详细讲解分支。
分支的本质
在说分支之前,我们先来看下分支的体现。
在 git 中,查看历史的基本命令是 git log
。

第一行的 commit
后面括号里的 HEAD -> master, origin/master, origin/HEAD
,是几个指向这个 commit 的指针。
在 Git 的使用中,经常会需要对指定的 commit 进行操作。每一个 commit 都有一个它唯一的指定方式——它的 SHA-1
校验和,也就是上图中每个黄色的 commit
右边的那一长串字符。两个 SHA-1
值的重复概率极低,所以你可以使用这个 SHA-1
值来指代 commit
,也可以只使用它的前几位来指代它(例如第一个 9f35c1c7374f351ec4250e4741de17bdf77e9baf,你使用 9f35c1c7 甚至 9f35 来指代它通常也可以)。
但毕竟这种没有任何含义的字符串是很难记忆的,所以 Git 提供了「引用」的机制:使用固定的字符串作为引用,指向某个 commit,作为操作 commit 时的快捷方式。
而分支,也是指向 commit
的指针。
我们经常说的 HEAD
或者 master
分支,都可以理解为一个指向某个 commit
的指针。
HEAD:当前 commit 的引用
所谓当前 commit
这个概念很简单,它指的就是当前工作目录所对应的 commit
。每次当有新的 commit
的时候,工作目录自动与最新的 commit
对应;而与此同时,HEAD 也会转而指向最新的 commit
。

事实上,当使用 checkout
、reset
等指令手动指定改变当前 commit
的时候,HEAD
也会一起跟过去。
总之,HEAD
不一定总是指向最新的 commit
。当前 commit
在哪里,HEAD
就在哪里,这是一个永远自动指向当前 commit
的引用,所以你永远可以用 HEAD
来操作当前 commit
。
1 | git commit |

当你提交了一个 commit
,但是还没有同步到远程仓库时,使用 git log
显示如下。
1 | git add b.txt |

提交了最新的 commit
,HEAD
和 master
这两个引用都指向了它。
而远程仓库的分支中,origin/master
则依然停留在原先的位置。
branch
除了 HEAD
之外,Git 还有一种引用,叫做 branch
(分支)。
HEAD
除了可以指向 commit
,还可以指向一个 branch
,当它指向某个 branch
的时候,会通过这个 branch
来间接地指向某个 commit
;另外,当 HEAD
在提交时自动向前移动的时候,它会像一个拖钩一样带着它所指向的 branch
一起移动。
master: 默认 branch
master
是一个特殊的 branch
:它是 Git 的默认 branch
(俗称主 branch
/ 主分支)。有 2 个特点:
新创建的 repository(仓库)是没有任何
commit
的。但在它创建第一个commit
时,会把master
指向它,并把HEAD
指向master
。当有人使用
git clone
时,除了从远程仓库把.git
这个仓库目录下载到工作目录中,默认使用master
分支,并且把HEAD
指向master
分支。
branch 的创建、切换和删除
如果你想在某处创建 branch
,只需要输入一行 git branch 名称
。例如你现在在 master
上:

你想在这个 commit
处创建一个叫做 "feature1" 的 branch
,只要输入:
1 | git branch feature1 |

这时,你现在只是创建好了这个 branch
,你的 HEAD
在这时依然是指向 master
的。
下一步,你需要使用 git checkout 分支名称
来切换到新的 branch
。
1 | git checkout feature1 |

这时,你的 HEAD
指针,就指向了 feature1
。
你还可以用 git checkout -b 名称
来把上面两步操作合并执行。
1 | git checkout -b feature1 |
在切换到新的 branch
后,再次 commit
时 HEAD
就会带着新的 branch
移动了。
1 | touch c.txt |

而这个时候,如果你再切换到 master
去 commit
,就会真正地出现分叉了。
1 | git checkout master |

注意:
HEAD
指向的branch
不能删除。如果要删除HEAD
指向的branch
,需要先用checkout
把HEAD
指向其他地方。- 由于 Git 中的
branch
只是一个引用,所以删除branch
的操作也只会删掉这个引用,并不会删除任何的commit
。 - 出于安全考虑,没有被合并到
master
过的branch
在删除时会失败(因为怕你误删掉「未完成」的branch
)。这种情况如果你确认是要删除这个branch
,可以把-d
改成-D
,小写换成大写,就能强制删除了。
命令总结
查看本地分支
1 | git branch |
查看所有分支(本地分支和远程分支)
1 | git branch -a |
查看本地分支对应的 commit
1 | git branch -v |
删除本地分支
1 | git branch -d 本地分支名 |
删除远程分支
1 | git push origin --delete 远程分支名 |
push 的本质
push
是把当前的分支上传到远程仓库,并把这个branch
的路径上的所有commits
也一并上传。push
的时候,如果当前分支是一个本地创建的分支,需要指定远程仓库名和本地分支名,用git push origin branch_name
的格式,而不能只用git push
。push
的时候之后上传当前分支,并不会上传HEAD
;远程仓库的HEAD
是永远指向默认分支(即master
)的。但 github 可以修改远程仓库HEAD
指向的分支。
checkout 的本质
上面说到, checkout
可以用来切换 branch
。
git checkout 分支名称
的本质,其实是把 HEAD
指向指定的 branch
,然后把工作目录(work dir)的所有文件,都变为这个 branch
所对应的 commit
的状态。
所以同样的,checkout
的目标也可以不是 branch
,而直接指定某个 commit
,那么 HEAD
也会指向这个 commit
。
1 | git checkout HEAD^^ # 表示把 HEAD 指针移动到 HEAD 的倒数第 2 条 commit |
checkout
后面直接指定某个 commit
,可以很方便地切换到某个历史版本,查看这个版本的代码,这时 git 也会提示你所在的 commit
。
1 | git checkout f96d9d67 |

但是它也有一个弊端:会在这个 commit 的基础上会新开一个匿名分支。
从上图你可以看到,当我们 checkout
一个 commit
时,git 会提示我们处于 detached HEAD
状态。这表示我们的 HEAD
不在任务一个分支上,也就是所谓的匿名分支。
这个分支是没有名字的,如果我们这时提交 commit
,就会在这个匿名分支上提交,不会影响任何的分支。

而当我们重新 checkout
到其他分支时,匿名 branch
上的这些 commit
虽然存在,但我们却没有任何一个 branch
可以回溯到这些 commit
(也许可以称为野生 commit
?),这个匿名分支的所有 commmit
都将不可追溯!
而这些 commit
不在任何一个 branch
的「路径」上,在一定时间后,它们会被 Git 的回收机制删除掉。
所以, checkout
一个 commit
,有利有弊:
- 你可以在某个
commit
版本的代码上做任何改动,这些改动不会影响到其他分支。 - 如果你想保留这个匿名分支上的
commit
,你记得要使用git checkout -b 分支名称
,新建一个分支保存匿名分支的改动。
例如:
我们在一个匿名分支 f96d9d67
提交了一个 commit
。
1 | git checkout f96d9d67 |
如果想要把这个 commit
合并到 master
分支,那我们首先建立一个 tmp
分支保存改动。
1 | git checkout -b tmp |
然后我们切换到 master
分支,把 tmp
分支的内容合并到 master
分支。
1 |
|
此外,你还可以checkout
后面还可以跟着文件名:git cehckout 文件名
表示:从 stage
区中把文件移动(你可以理解为还原)到 work dir
。如果 work dir
中被修改的文件很多,可以使用通配符全部恢复成 stage
1 | git checkout . |
有一点需要指出的是,checkout
命令只会把被「修改」的文件恢复成 stage
的状态,如果 work dir
中新增了新文件,你使用 git checkout .
是不会删除新文件的。
如果你想把 work dir
区或者 stage
区文件全部还原,可以使用 git checkout HEAD filename
。
举个例子,如果你对一个项目改动了非常多的代码,而且有些改动已经添加到 state
区,这时你发现改动的代码全错了,想全部还原,你可以使用
1 | git checkout HEAD . |
总结一下:
- 如果你想把
state
区的文件,还原到work dir
区,那么就使用git checkout filename
。 - 如果你想把
state
区和work dir
区的文件,还原为HEAD
指向的commit
,那么就使用git checkout HEAD filename
。

pull
pull
的内部操作其实是把远程仓库取到本地后(使用的是 fetch
),再用一次 merge
来把远端仓库的新 commits
合并到本地。
merge
merge
的意思是合并:指定一个 commit
,把它合并到当前的 commit
来。具体来讲,merge
做的事是:
从目标 commit 和当前 commit (即 HEAD 所指向的 commit)分叉的位置起,把目标 commit 的路径上的所有 commit 的内容一并应用到当前 commit,然后自动生成一个新的 commit。
例如下面这个图中:

HEAD
指向了 master
,所以如果这时执行:
1 | git merge branch1 |
当前 commit
是 4
,而目标 commit
是 6
,它们的分叉位置是 2
。Git 会把 5
和 6
这两个 commit
的内容一并应用到 4
上,然后生成一个新的提交,并跳转到提交信息填写的界面:

merge
操作会帮你自动地填写简要的提交信息。在提交信息修改完成后(或者你打算不修改默认的提交信息),就可以退出这个界面,然后这次 merge
就算完成了。

## 特殊情况 1:冲突
merge
在做合并的时候,是有一定的自动合并能力的:如果一个分支改了 A 文件,另一个分支改了 B 文件,那么合并后就是既改 A 也改 B,这个动作会自动完成;如果两个分支都改了同一个文件,但一个改的是第 1 行,另一个改的是第 2 行,那么合并后就是第 1 行和第 2 行都改,也是自动完成。
但,如果两个分支修改了同一部分内容,merge
的自动算法就搞不定了。这种情况 Git 称之为:冲突(Conflict)。如果在 merge
的时候发生了这种情况,Git 就会把问题交给你来决定。具体地,它会告诉你 merge
失败,以及失败的原因。
例如我在 test
分支上修改了 a.txt
,
1 | git checkout -b test |
然后又在 master
分支上修改了 a.txt
,
1 | git checkout master |
接着想要把 test
分支 merge
到 master
分支,
1 | git merge test |
这时,git 会提示合并失败

提示信息说,在 a.txt
中出现了 "merge conflict",自动合并失败,要求 "fix conflicts and then commit the result"(把冲突解决掉后提交)。在发生冲突后,Git 仓库处于一个「merge 冲突待解决」的中间状态。那么你现在需要做两件事:
- 解决冲突。
git add
冲突的文件,使用git commit
提交merge
。
1. 决解冲突
解决掉冲突的方式有多个,我现在说最直接的一个。你现在再打开 a.txt
看一下,会发现它的内容变了:
1 | <<<<<<< HEAD |
可以看到,Git 虽然没有帮你完成自动 merge
,但它对文件还是做了一些工作:它把两个分支冲突的内容放在了一起,并用符号标记出了它们的边界以及它们的出处。
上面表示,HEAD
中的内容是 hello master
,而 test
中的内容则是 hello test
。这两个改动 Git 不知道应该怎样合并,于是把它们放在一起,由你来决定。
假设你决定保留 HEAD
的修改,那么只要删除掉 test
的修改,再把 Git 添加的那三行 <<<
===
>>>
辅助文字也删掉,保存文件退出,所谓的「解决掉冲突」就完成了。
1 | hello master |
你也可以选择使用更方便的 merge
工具来解决冲突。
2. 手动提交
解决完冲突以后,就可以进行 git add
和 git commit
了。
在发生冲突后,Git 仓库处于一个「merge 冲突待解决」的中间状态,在这种状态下 commit
,Git 就会自动地帮你添加「这是一个 merge commit」的提交信息。
放弃解决冲突,取消 merge
同理,由于现在 Git 仓库处于冲突待解决的中间状态,所以如果你最终决定放弃这次 merge
,也需要执行一次 merge --abort
来手动取消它:
1 | git merge --abort |
输入这行代码,你的 Git 仓库就会回到 merge
前的状态。
特殊情况 2:HEAD 领先于目标 commit
如果 merge
时的目标 commit
和 HEAD
处的 commit
并不存在分叉,而是 HEAD
领先于目标 commit
:

那么 merge
就没必要再创建一个新的 commit
来进行合并操作,因为并没有什么需要合并的。在这种情况下, Git 什么也不会做,merge
是一个空操作。
特殊情况 3:HEAD 落后于 目标 commit——fast-forward
如果 HEAD
和目标 commit
依然是不存在分叉,但 HEAD
落后于目标 commit
:

那么 Git 会直接把 HEAD
(以及它所指向的 branch
,如果有的话)移动到目标 commit
:
1 | git merge feature1 |

这种操作有一个专有称谓,叫做 "fast-forward"(快速前移)。
一般情况下,创建新的 branch
都是会和原 branch
(例如上图中的 master
)并行开发的,不然没必要开 branch
,直接在原 branch
上开发就好。但事实上,上图中的情形其实很常见,因为这其实是 pull
操作的一种经典情形:本地的 master
没有新提交,而远端仓库中有同事提交了新内容到 master
:

那么这时如果在本地执行一次 pull
操作,就会由于 HEAD
落后于目标 commit
(也就是远端的 master
)而造成 "fast-forward":

上图中的 origin/master
和 origin/HEAD
是对远端仓库的 master
和 HEAD
的本地镜像。`git pull
实际上是由 2 步组成的:
- 第一步:
git fetch
下载远端仓库内容,origin/master
和origin/HEAD
引用得到了更新,也就是上面这个动图中的第一步:origin/master
和origin/HEAD
移动到了最新的commit
。 - 第二步:
git merge origin/HEAD
,就会产生fast forward
,本地的HEAD
和master
就会指向origin/HEAD
对应的commit
,也就是最新的commit
。
总结
merge
的含义:从两个commit
「分叉」的位置起,把目标commit
的内容应用到当前commit
(HEAD
所指向的commit
),并生成一个新的commit
;merge
的适用场景:- 单独开发的
branch
用完了以后,合并回原先的branch
; git pull
的内部自动操作。
- 单独开发的
merge
的三种特殊情况:- 冲突
- 原因:当前分支和目标分支修改了同一部分内容,Git 无法确定应该怎样合并;
- 应对方法:解决冲突后手动
commit
。
HEAD
领先于目标commit
:Git 什么也不做,空操作;HEAD
落后于目标commit
:fast-forward。
- 冲突
rebase
rebase
的意思是,给你的 commit
序列重新设置基础点(也就是设置新的父 commit
)。展开来说就是,把你指定的 commit
以及它所在的 commit
串,以指定的目标 commit
为基础,依次重新提交一次。
例如,你现在想把 branch1
分支,合并到 master
分支。
如果使用 merge
:
1 | git merge branch1 |

如果把 merge
换成 rebase
,可以这样操作:
1 | git checkout branch1 |
表示以 master
为基点,把 branch1
分支的所有 commit
,以 master
为基础,依次重新提交一次。rebase
后的 commit
虽然内容和 rebase
之前相同,但它们已经是不同的 commits
了

在 rebase
之后,记得切回 master
再 merge
一下,把 master
移到最新的 commit
。
1 | git checkout master |

> 为什么要从 branch1 来 rebase,然后再切回 master 再 merge 一下这么麻烦,而不是直接在 master 上执行 rebase? > 如果我们直接在
master
上 rebase branch1
> 1 | git rebase branch1 |

>这就导致
master
上之前的两个最新 commit
被剔除了。如果这两个 commit
之前已经在中央仓库存在,这就会导致没法 push
了: > >

> 所以,为了避免和远端仓库发生冲突,一般不要从 master
向其他 branch
执行 rebase
操作。而如果是 master
以外的 branch
之间的 rebase
(比如 branch1
和 branch2
之间),就不必这么多费一步,直接 rebase
就好。
一个最佳实践是:永远不要 rebase
一个已经分享的分支,只能 rebase
你自己使用的私有分支。
小结
假设你使用 rebase
,把 branch1
分支合并到 master
,步骤如下:
git checkout branch1
git rebase master
git checkout master
git merge branch1
查看每个 branch 之间的关系图
1 | git log –graph –decorate –oneline –simplify-by-decoration –all |
说明:
– decorate:会让 git log
显示每个 commit
的引用(如:分支、tag等)
– oneline: 一行显示 – simplify-by-decoration: 只显示被 branch
或tag
引用的 commit
–all: 表示显示所有的 branch
,如果你只想显示特定的 branch
,那么就改为 branch
的名字。
commit --amend
如果你提交了代码,但发现有几个字写错了,不想为了几个错别字而重新添加一条 commit
:
1 | ech "hello worlf" > a.txt |

那么你可以使用commit --amend
"amend" 是「修正」的意思。在提交时,如果加上 --amend
参数,Git 不会在当前 commit
上增加 commit
,而是会把当前 commit
里的内容和暂存区(stageing area)里的内容合并起来后创建一个新的 commit
,用这个新的 commit 把当前 commit 替换掉。所以 commit --amend
做的事就是它的字面意思:对最新一条 commit
进行修正。我把 hello wrolf
改为 hello wrold
后,
1 | git add a.txt |

Git 会把你带到提交信息编辑界面。可以看到,提交信息默认是当前提交的提交信息。你可以修改或者保留它,然后保存退出。然后,你的最新 commit
就被更新了。

需要注意的有一点:commit --amend
并不是直接修改原 commit
的内容,而是生成一条新的 commit
。
所以,这种方法只能修改还没有 push 的 commit
,如果你想修改的 commit
已经同步到远程仓库,那么不能使用这种方法。因为 rebase
的本质是创建一个新的 commit
来替换旧的 commit
,因此当你 push
到远程仓库时,由于本地仓库和远程仓库的历史 commit
无法对应,会导致 push
失败。

但你可以选择,用本地仓库的 commits
,强行覆盖远程仓库的 commits
。
1 | git push origin master -f |
-f
是 --force
的缩写,意为「忽略冲突,强制 push
」。
修改以前的 commit: rebase -i
如果不是最新的 commit
写错,而是以前的 commit
写错,就不能用 commit --amend
来修复了,而是要用 rebase
。不过需要给 rebase
也加一个参数:-i
。
rebase -i
是 rebase --interactive
的缩写形式,意为「交互式 rebase」。
所谓「交互式 rebase」,就是在 rebase
的操作执行之前,你可以指定要 rebase
的 commit
链中的每一个 commit
是否需要进一步修改。那么你就可以利用这个特点,进行一次「原地 rebase」。
注意,这种方法只能修改还没有 push 的 commit
,如果你想修改的 commit
已经同步到远程仓库,那么不能使用这种方法。因为 rebase
的本质是创建一个新的 commit
来替换旧的 commit
,因此当你 push
到远程仓库时,由于本地仓库和远程仓库的历史 commit
无法对应,会导致 push
失败。

但你可以选择,用本地仓库的 commits
,强行覆盖远程仓库的 commits
。
1 | git push origin master -f |
-f
是 --force
的缩写,意为「忽略冲突,强制 push
」。
假设你想修改倒数第 2 个 commit
,那么使用:
1 | git rebase -i HEAD^^ |
说明:在 Git 中,有两个「偏移符号」:
^
和~
。
^
的用法:在commit
的后面加一个或多个^
号,可以把commit
往回偏移,偏移的数量是^
的数量。例如:master^
表示master
指向的commit
之前的那个commit
;HEAD^^
表示HEAD
所指向的commit
往前数两个commit
。
~
的用法:在commit
的后面加上~
号和一个数,可以把commit
往回偏移,偏移的数量是~
号后面的数。例如:HEAD~5
表示HEAD
指向的commit
往前数 5 个commit
。
HEAD
会指向

如果没有 -i
参数的话,这种「原地 rebase」相当于空操作,会直接结束。而在加了 -i
后,就会跳到一个新的界面:

这个编辑界面的最顶部,列出了将要「被 rebase」的所有 commit
s,也就是倒数第二个 commit
「增加常见笑声集合」和最新的 commit
「增加常见哭声集合」。需要注意,这个排列是正序的,旧的 commit
会排在上面,新的排在下面。每个 commit
默认的操作都是 pick
,表示保留这个 commit
的修改。
选择你要修改的 commit
,把 pick
改为 edit
。那么 HEAD
就会指向 edit
对应的 commit
,这里是 bac9224
。

然后退出这个界面,Git 也会显示,当前你处于交互式 rebase 中。

修改完成后,使用 git add
和 git commit --amend
来把旧的 commit
替换为新的 commit
。
最后使用 git rebase --continue
,把后面的 commit
直接应用上去,HEAD
也会移动到最新的 commit
。
同理,如果你想撤销以前的 commit
,那么在编辑界面直接删除对应的 commit
所在的行即可。
小结
交互式 rebase
最常用的场景是修改写错的 commit
,它的用法如下:
git rebase -i 目标commit
- 在弹出的编辑界面,选择你要修改的
commit
- 修改文件
git add
git commit --amend
git rebase --continue
交互式 rebase
直接删除某个历史的commit
,用法如下:
git rebase -i 目标commit
- 直接删除对应的
commit
所在的行即可
rebase --onto 删除历史的提交
除了用交互式 rebase
,你还可以用 rebase --onto
来更简便地删除历史的提交。
一般的 rebase
,告诉 Git 的是「我要把当前 commit
以及它之前的 commit
s 重新提交到目标 commit
上去,这其中,rebase
的「起点」是自动判定的:选取当前 commit
和目标 commit
在历史上的交叉点作为起点。

如果在这里执行:
1 | git rebase 第3个commit |
那么 Git 会自动选取 3
和 5
的历史交叉点 2
作为 rebase
的起点,依次将 4
和 5
重新提交到 3
的路径上去。
而 --onto
参数,就可以额外给 rebase 指定它的起点。例如同样以上图为例,如果我只想把 5
提交到 3
上,不想附带上 4
,那么我可以执行:
1 | git rebase --onto 第3个commit 第4个commit branch1 |
--onto
参数后面有三个附加参数:目标 commit
、起点 commit
(注意:rebase 的时候会把起点排除在外)、终点 commit
。所以上面这行指令就会从 4
往下数,拿到 branch1
所指向的 5
,然后把 5
重新提交到 3
上去。
如果你想删除倒数第二个提交,可以用 rebase --onto
来撤销提交:
1 | git rebase --onto HEAD^^ HEAD^ branch1 |
上面这行代码的意思是:以倒数第二个 commit
为起点(起点不包含在 rebase
序列里),branch1
为终点,rebase
到倒数第三个 commit
上。
reset
有的时候,刚写完的 commit
写得实在太烂,连自己的都看不下去,与其修改它还不如丢掉重写。这种情况,就可以用 reset
来丢弃最新的提交。

你可以用 reset --hard
来撤销最新的 commit
。
1 | git reset --hard HEAD^ |

不过,就像图上显示的,你被撤销的那条提交并没有消失,只是你不再用到它了。如果你在撤销它之前记下了它的 SHA-1
码,那么你还可以通过 SHA-1
来找到他它。
代码已经 push 上去了才发现写错
当错误的 commit
已经被 push
上去时,有两种方案:
- 如果出错内容在私有
branch
:在本地把内容修正后,使用git push origin 分支名 -f
一次就可以解决; - 如果出错内容在
master
:不要强制push
,而要用revert
把写错的commit
撤销。
1. 出错的内容在你自己的 branch
你在本地对已有的 commit
做修改,这时你再 push
就会失败,因为中央仓库包含本地没有的 commit
s。但这个和前面讲过的情况不同,这次的冲突不是因为同事 push
了新的提交,而是因为你刻意修改了一些内容,这个冲突是你预料到的,你本来就希望用本地的内容覆盖掉中央仓库的内容。那么这时就不要乖乖听话,按照提示去先 pull
一下再 push
了,而是要选择「强行」push
:
1 | git push origin branch1 -f |
-f
是 --force
的缩写,意为「忽略冲突,强制 push
」。
2. 出错的内容已经合并到 master
同事的工作都在 master
上,你永远不知道你的一次强制 push
会不会洗掉同事刚发上去的新提交。所以除非你是人员数量和行为都完全可控的超小团队,可以和同事做到无死角的完美沟通,不然一定别在 master
上强制 push
。
在这种时候,你只能退一步,选用另一种策略:增加一个新的提交,把之前提交的内容抹掉。例如之前你增加了一行代码,你希望撤销它,那么你就做一个删掉这行代码的提交;如果你删掉了一行代码,你希望撤销它,那么你就做一个把这行代码还原回来的提交。这种事做起来也不算麻烦,因为 Git 有一个对应的指令:revert
。
它的用法很简单,你希望撤销哪个 commit
,就把它填在后面。比如你想撤销最新的改动,使用:
1 | git revert HEAD |
如果你想撤销倒数第二个 commit
的改动,使用:
1 | git revert HEAD^ |
上面这行代码就会增加一条新的 commit
,它的内容和你指定的 commit
是相反的,从而和你指定的 commit
相互抵消,达到撤销的效果。
在 revert
完成之后,把新的 commit
再 push
上去,这个 commit
的内容就被撤销了。它和前面所介绍的撤销方式相比,最主要的区别是,这次改动只是被「反转」了,并没有在历史中消失掉,你的历史中会存在两条 commit
:一个原始 commit
,一个对它的反转 commit
。
reset 的本质
实质上,reset
这个指令虽然可以用来撤销 commit
,但它的实质行为并不是撤销,而是移动 HEAD
,并且「捎带」上 HEAD
所指向的 branch
(如果有的话)。也就是说,reset
这个指令的行为其实和它的字面意思 "reset"(重置)十分相符:它是用来重置 HEAD
以及它所指向的 branch
的位置的。
而 reset --hard HEAD^
之所以起到了撤销 commit
的效果,是因为它把 HEAD
和它所指向的 branch
一起移动到了当前 commit
的父 commit
上,从而起到了「撤销」的效果:

所以同理,reset --hard
不仅可以撤销提交,还可以用来把 HEAD
和 branch
移动到其他的任何地方。
1 | git reset --hard branch2 |
但是这样一来,你的 master
分支就指向了 branch2
分支,如何把 master
指向原来的 commit
呢?
如果你记下了原来的 commit
,那么可以使用 git reset --hard commit_id
。
如果你忘了,那么你可以使用 git reflog
来引用的移动记录,默认是查看 HEAD
的移动记录。

reset
指令可以重置 HEAD
和 branch
的位置,不过在重置它们的同时,对工作目录可以选择不同的操作,而对工作目录的操作的不同,就是通过 reset
后面跟的参数来确定的。
reset --hard:清空工作目录和暂存区。
在
reset --hard
后,所有的改动都会丢失。reset --soft:保留工作目录和暂存区,并把重置
HEAD
所带来的新的差异放进暂存区。reset
如果不加参数,那么默认使用--mixed
参数。它的行为是:保留工作目录,并且清空暂存区,由reset
所导致的新的文件差异,都会被放进工作目录。
checkout 和 reset 的不同
checkout
和 reset
都可以切换 HEAD
的位置,它们除了有许多细节的差异外,最大的区别在于:reset
在移动 HEAD
时会带着它所指向的 branch
一起移动,而 checkout
不会。当你用 checkout
指向其他地方的时候,HEAD
和 它所指向的 branch
就自动脱离了。
另一个不同是:
reset
后面加--mixed
或者--soft
会把产生的差异存放到work dir
区或者stage
区,也就是:不会丢失未提交的修改。而reset
后面加--hard
时,作用和checkout
加commit
或者引用一样。- 而
checkout
不指定commit
或者引用时,会将stage
区的内容移动到work dir
区。如果指定了commit
或者引用,那么就会将commit
或者引用的内容覆盖stage
和work dir
区。
stash
"stash" 这个词,和它意思比较接近的中文翻译是「藏匿」,是「把东西放在一个秘密的地方以备未来使用」的意思。在 Git 中,stash
指令可以帮你把工作目录的内容全部放在你本地的一个独立的地方,它不会被提交,也不会被删除,你把东西放起来之后就可以去做你的临时工作了,做完以后再来取走,就可以继续之前手头的事了。
具体说来,stash
的用法很简单。当你手头有一件临时工作要做,需要把工作目录暂时清理干净,那么你可以:
1 | git stash |
就这么简单,你的工作目录的改动就被清空了,所有改动都被存了起来。
然后你就可以从你当前的工作分支切到 master
去给你的同事打包了……
打完包,切回你的分支,然后:
1 | git stash pop |
你之前存储的东西就都回来了。很方便吧?
注意:没有被 track 的文件(即从来没有被 add 过的文件不会被 stash 起来,因为 Git 会忽略它们。如果想把这些文件也一起 stash,可以加上
-u
参数,它是--include-untracked
的简写。就像这样:
1 | git stash -u |
reflog
branch
用完就删是好习惯,但有的时候,不小心手残删了一个还有用的 branch
,或者把一个 branch
删掉了才想起来它还有用,怎么办?
reflog :引用的 log
reflog
是 "reference log" 的缩写,使用它可以查看 Git 仓库中的引用的移动记录。如果不指定引用,它会显示 HEAD
的移动记录。假如你误删了 test
这个 branch
,那么你可以查看一下 HEAD
的移动历史:
1 | git reflog |

你可以看到最后一次切换到 test
的记录,这行记录前面的 7eafad2
就是 test
分支的最新 commit
,所以你可以切换回 7eafad2
,然后重新创建 test
:
1 | git checkout 7eafad2 |
后面两句可以合并为一句
1 | git checkout -b test |
注意:不再被引用直接或间接指向的
commit
s 会在一定时间后被 Git 回收,所以使用reflog
来找回删除的branch
的操作一定要及时,不然有可能会由于commit
被回收而再也找不回来。
你还可以查看其他引用的移动历史,如:
1 | git reflog master |