Git/Git 详解

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 dirstage 区域的状态,可以通过命令 git status 来查看,history 区域的提交历史可以通过 git log 命令来查看。

下面举个例子。

我们新建一个文件 a.txt,然后使用 git status 查看文件状态。

1
2
touch a.txt
git status


上图中, untrack files 中显示的是 work dir 中的文件,这些文件有变动,但还没添加到 state 区。

我们使用 git add 命令,将 work dir 中的文件移动到 state 区,再使用 git status 查看文件状态。

1
2
git add a.txt
git status


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

1
2
touch b.txt
git status


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

1
2
git commit -m "add a.txt"
git status


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
2
3
# git push 远程仓库名 本地分支名
git push origin master
git push github master

其中 githuborigin 都是远程仓库, master 是本地分支。这种写法并没有指定远程仓库的分支,那么就会推送到远程仓库的同名分支上,在这个例子里,本地分支是 master,会推送到远程仓库的 master 分支。

前面如果你想要推送到远程仓库的指定分支,语法如下:

1
2
# git push 远程仓库名 本地分支名:远程分支名
git push origin master:test_branch

最后的 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


事实上,当使用 checkoutreset 等指令手动指定改变当前 commit 的时候,HEAD 也会一起跟过去。

总之,HEAD 不一定总是指向最新的 commit。当前 commit 在哪里,HEAD 就在哪里,这是一个永远自动指向当前 commit 的引用,所以你永远可以用 HEAD 来操作当前 commit

1
git commit


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

1
2
3
git add b.txt
git commit -m "add b.txt"
git log


提交了最新的 commitHEADmaster 这两个引用都指向了它。

而远程仓库的分支中,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 后,再次 commitHEAD 就会带着新的 branch 移动了。

1
2
3
touch c.txt
git add c.txt
git commit -m "add c.txt"


而这个时候,如果你再切换到 mastercommit,就会真正地出现分叉了。

1
2
3
4
git checkout master
touch d.txt
git add d.txt
git commit -m "add d.txt"


注意:

  • HEAD 指向的 branch 不能删除。如果要删除 HEAD 指向的 branch,需要先用 checkoutHEAD 指向其他地方。
  • 由于 Git 中的 branch 只是一个引用,所以删除 branch 的操作也只会删掉这个引用,并不会删除任何的 commit
  • 出于安全考虑,没有被合并到 master 过的 branch 在删除时会失败(因为怕你误删掉「未完成」的 branch )。这种情况如果你确认是要删除这个 branch,可以把 -d 改成 -D,小写换成大写,就能强制删除了。

命令总结

查看本地分支

1
git branch

查看所有分支(本地分支和远程分支)

1
git branch -a

查看本地分支对应的 commit

1
git branch -v

删除本地分支

1
2
git branch -d 本地分支名
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
2
3
4
git checkout HEAD^^    # 表示把 HEAD 指针移动到 HEAD 的倒数第 2 条 commit
git checkout master~5 # 表示把 HEAD 指针移动到 master 的倒数第 5 条 commit
git checkout 78a4bc # 表示把 HEAD 指针移动到 78a4bc 这个 commit
git checkout 78a4bc^ # 表示把 HEAD 指针移动到 78a4bc 的上一条 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
2
3
4
git checkout f96d9d67
touch e.txt
git add e.txt
git commit -m "add e.txt"

如果想要把这个 commit 合并到 master 分支,那我们首先建立一个 tmp 分支保存改动。

1
git checkout -b tmp

然后我们切换到 master 分支,把 tmp 分支的内容合并到 master 分支。

1
2
3
4
5


git checkout master
git merge tmp
git branch -d tmp

此外,你还可以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

当前 commit4,而目标 commit6,它们的分叉位置是 2。Git 会把 56 这两个 commit 的内容一并应用到 4 上,然后生成一个新的提交,并跳转到提交信息填写的界面:


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


## 特殊情况 1:冲突

merge 在做合并的时候,是有一定的自动合并能力的:如果一个分支改了 A 文件,另一个分支改了 B 文件,那么合并后就是既改 A 也改 B,这个动作会自动完成;如果两个分支都改了同一个文件,但一个改的是第 1 行,另一个改的是第 2 行,那么合并后就是第 1 行和第 2 行都改,也是自动完成。

但,如果两个分支修改了同一部分内容,merge 的自动算法就搞不定了。这种情况 Git 称之为:冲突(Conflict)。如果在 merge 的时候发生了这种情况,Git 就会把问题交给你来决定。具体地,它会告诉你 merge 失败,以及失败的原因。

例如我在 test 分支上修改了 a.txt

1
2
3
4
git checkout -b test
echo "hello test" > a.txt
git add a.txt
git commit -m "change a.txt"

然后又在 master 分支上修改了 a.txt

1
2
3
4
git checkout master
echo "hello master" > a.txt
git add a.txt
git commit -m "change a.txt"

接着想要把 test 分支 mergemaster 分支,

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
2
3
4
5
<<<<<<< HEAD
hello master
=======
hello test
>>>>>>> test

可以看到,Git 虽然没有帮你完成自动 merge,但它对文件还是做了一些工作:它把两个分支冲突的内容放在了一起,并用符号标记出了它们的边界以及它们的出处。

上面表示,HEAD 中的内容是 hello master,而 test 中的内容则是 hello test。这两个改动 Git 不知道应该怎样合并,于是把它们放在一起,由你来决定。

假设你决定保留 HEAD 的修改,那么只要删除掉 test 的修改,再把 Git 添加的那三行 <<< === >>> 辅助文字也删掉,保存文件退出,所谓的「解决掉冲突」就完成了。

1
hello master

你也可以选择使用更方便的 merge 工具来解决冲突。

2. 手动提交

解决完冲突以后,就可以进行 git addgit commit 了。

在发生冲突后,Git 仓库处于一个「merge 冲突待解决」的中间状态,在这种状态下 commit,Git 就会自动地帮你添加「这是一个 merge commit」的提交信息。

放弃解决冲突,取消 merge

同理,由于现在 Git 仓库处于冲突待解决的中间状态,所以如果你最终决定放弃这次 merge,也需要执行一次 merge --abort 来手动取消它:

1
git merge --abort

输入这行代码,你的 Git 仓库就会回到 merge 前的状态。

特殊情况 2:HEAD 领先于目标 commit

如果 merge 时的目标 commitHEAD 处的 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/masterorigin/HEAD 是对远端仓库的 masterHEAD 的本地镜像。`git pull 实际上是由 2 步组成的:

  • 第一步:git fetch 下载远端仓库内容,origin/masterorigin/HEAD 引用得到了更新,也就是上面这个动图中的第一步:origin/masterorigin/HEAD 移动到了最新的 commit
  • 第二步:git merge origin/HEAD ,就会产生 fast forward,本地的 HEADmaster 就会指向origin/HEAD 对应的 commit,也就是最新的 commit

总结

  1. merge 的含义:从两个 commit「分叉」的位置起,把目标 commit 的内容应用到当前 commitHEAD 所指向的 commit),并生成一个新的 commit
  2. merge 的适用场景:
    1. 单独开发的 branch 用完了以后,合并回原先的 branch
    2. git pull 的内部自动操作。
  3. merge 的三种特殊情况:
    1. 冲突
      1. 原因:当前分支和目标分支修改了同一部分内容,Git 无法确定应该怎样合并;
      2. 应对方法:解决冲突后手动 commit
    2. HEAD 领先于目标 commit:Git 什么也不做,空操作;
    3. HEAD 落后于目标 commit:fast-forward。

rebase

rebase 的意思是,给你的 commit 序列重新设置基础点(也就是设置新的父 commit)。展开来说就是,把你指定的 commit 以及它所在的 commit 串,以指定的目标 commit 为基础,依次重新提交一次。

例如,你现在想把 branch1 分支,合并到 master 分支。

如果使用 merge

1
git merge branch1


如果把 merge 换成 rebase,可以这样操作:

1
2
git checkout branch1
git rebase master

表示以 master 为基点,把 branch1 分支的所有 commit,以 master 为基础,依次重新提交一次。rebase 后的 commit 虽然内容和 rebase 之前相同,但它们已经是不同的 commits


rebase 之后,记得切回 mastermerge 一下,把 master 移到最新的 commit

1
2
git checkout master
git merge branch1

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

>

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


> > 所以,为了避免和远端仓库发生冲突,一般不要从 master 向其他 branch 执行 rebase 操作。而如果是 master 以外的 branch 之间的 rebase(比如 branch1branch2 之间),就不必这么多费一步,直接 rebase 就好。

一个最佳实践是:永远不要 rebase 一个已经分享的分支,只能 rebase 你自己使用的私有分支。

小结

假设你使用 rebase,把 branch1 分支合并到 master,步骤如下:

  • git checkout branch1
  • git rebase master
  • git checkout master
  • git merge branch1

查看每个 branch 之间的关系图

log –graph –decorate –oneline –simplify-by-decoration –all```
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

说明:

– decorate:会让 `git log` 显示每个 `commit` 的引用(如:分支、tag等)

– oneline: 一行显示
– simplify-by-decoration: 只显示被 `branch` 或` tag` 引用的 `commit`

–all: 表示显示所有的 `branch`,如果你只想显示特定的 `branch`,那么就改为 `branch` 的名字。





# commit --amend

如果你提交了代码,但发现有几个字写错了,不想为了几个错别字而重新添加一条 `commit`:

ech "hello worlf" > a.txt git add * git commit -m "change a.txt"

1
2
3
4
5
6
7



<div align="center"><img src="https://image.zhangxiann.com/20200831132405.png"/></div><br>
那么你可以使用`commit --amend`

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

<div align="center"><img src="https://image.zhangxiann.com/20200831132615.png"/></div><br>
Git 会把你带到提交信息编辑界面。可以看到,提交信息默认是当前提交的提交信息。你可以修改或者保留它,然后保存退出。然后,你的最新 `commit` 就被更新了。



<div align="center"><img src="https://image.zhangxiann.com/amend.gif"/></div><br>
需要注意的有一点:`commit --amend` 并不是直接修改原 `commit` 的内容,而是生成一条新的 `commit`。

**所以,这种方法只能修改还没有 push 的 `commit`,如果你想修改的 `commit` 已经同步到远程仓库,那么不能使用这种方法。因为 `rebase` 的本质是创建一个新的 `commit` 来替换旧的 `commit`,因此当你 `push` 到远程仓库时,由于本地仓库和远程仓库的历史 `commit` 无法对应,会导致 `push` 失败。**

<div align="center"><img src="https://image.zhangxiann.com/20200831140113.png"/></div><br>
但你可以选择,用本地仓库的 `commits`,强行覆盖远程仓库的 `commits`。
git push origin master -f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

`-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` 失败。**

<div align="center"><img src="https://image.zhangxiann.com/20200831135640.png"/></div><br>
但你可以选择,用本地仓库的 `commits`,强行覆盖远程仓库的 `commits`。
git push origin master -f
1
2
3
4
5
6

`-f` 是 `--force` 的缩写,意为「忽略冲突,强制 `push`」。



假设你想修改倒数第 2 个 `commit`,那么使用:
git rebase -i HEAD^^
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

>说明:在 Git 中,有两个「偏移符号」: `^` 和 `~`。
>
>`^` 的用法:在 `commit` 的后面加一个或多个 `^` 号,可以把 `commit` 往回偏移,偏移的数量是 `^` 的数量。例如:`master^` 表示 `master` 指向的 `commit` 之前的那个 `commit`; `HEAD^^` 表示 `HEAD` 所指向的 `commit` 往前数两个 `commit`。
>
>`~` 的用法:在 `commit` 的后面加上 `~` 号和一个数,可以把 `commit` 往回偏移,偏移的数量是 `~` 号后面的数。例如:`HEAD~5` 表示 `HEAD` 指向的 `commit`往前数 5 个 `commit`。

`HEAD` 会指向

<div align="center"><img src="https://image.zhangxiann.com/rebase-i.gif"/></div><br>
如果没有 `-i` 参数的话,这种「原地 rebase」相当于空操作,会直接结束。而在加了 `-i` 后,就会跳到一个新的界面:

<div align="center"><img src="https://image.zhangxiann.com/20200831140551.png"/></div><br>
这个编辑界面的最顶部,列出了将要「被 rebase」的所有 `commit`s,也就是倒数第二个 `commit` 「增加常见笑声集合」和最新的 `commit`「增加常见哭声集合」。需要注意,这个排列是正序的,旧的 `commit` 会排在上面,新的排在下面。每个 `commit` 默认的操作都是 `pick`,表示保留这个 `commit` 的修改。

选择你要修改的 `commit`,把 `pick` 改为 `edit`。**那么 `HEAD` 就会指向 `edit` 对应的 `commit`**,这里是 `bac9224`。

<div align="center"><img src="https://image.zhangxiann.com/20200831140735.png"/></div><br>
然后退出这个界面,Git 也会显示,当前你处于交互式 rebase 中。

<div align="center"><img src="https://image.zhangxiann.com/20200831142619.png"/></div><br>

修改完成后,使用 `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` 在历史上的交叉点作为起点。

<div align="center"><img src="https://image.zhangxiann.com/20200831144310.png"/></div><br>
如果在这里执行:
git rebase 第3个commit
1
2
3
4

那么 Git 会自动选取 `3` 和 `5` 的历史交叉点 `2` 作为 `rebase` 的起点,依次将 `4` 和 `5` 重新提交到 `3` 的路径上去。

而 `--onto` 参数,就可以额外给 rebase 指定它的起点。例如同样以上图为例,如果我只想把 `5` 提交到 `3` 上,不想附带上 `4`,那么我可以执行:
git rebase --onto 第3个commit 第4个commit branch1
1
2
3
4

`--onto` 参数后面有三个附加参数:目标 `commit`、起点 `commit`(注意:rebase 的时候会把起点排除在外)、终点 `commit`。所以上面这行指令就会从 `4` 往下数,拿到 `branch1` 所指向的 `5`,然后把 `5` 重新提交到 `3` 上去。

如果你想删除倒数第二个提交,可以用 `rebase --onto` 来撤销提交:
git rebase --onto HEAD^^ HEAD^ branch1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

上面这行代码的意思是:以倒数第二个 `commit` 为起点(起点不包含在 `rebase` 序列里),`branch1` 为终点,`rebase` 到倒数第三个 `commit` 上。

<div align="center"><img src="https://image.zhangxiann.com/rebase_onto"/></div><br>





# reset

有的时候,刚写完的 `commit` 写得实在太烂,连自己的都看不下去,与其修改它还不如丢掉重写。这种情况,就可以用 `reset` 来丢弃最新的提交。

<div align="center"><img src="https://image.zhangxiann.com/reset.gif"/></div><br>
你可以用 `reset --hard` 来撤销最新的 `commit`。
git reset --hard HEAD^
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

<div align="center"><img src="https://image.zhangxiann.com/reset2.gif"/></div><br>
不过,就像图上显示的,你被撤销的那条提交并没有消失,只是你不再用到它了。如果你在撤销它之前记下了它的 `SHA-1` 码,那么你还可以通过 `SHA-1` 来找到他它。





# 代码已经 push 上去了才发现写错

当错误的 `commit` 已经被 `push` 上去时,有两种方案:

1. 如果出错内容在私有 `branch`:在本地把内容修正后,使用 `git push origin 分支名 -f` 一次就可以解决;
2. 如果出错内容在 `master`:不要强制 `push`,而要用 `revert` 把写错的 `commit` 撤销。



## 1. 出错的内容在你自己的 branch

你在本地对已有的 `commit` 做修改,这时你再 `push` 就会失败,因为中央仓库包含本地没有的 `commit`s。但这个和前面讲过的情况不同,这次的冲突不是因为同事 `push` 了新的提交,而是因为你刻意修改了一些内容,这个冲突是你预料到的,你本来就希望用本地的内容覆盖掉中央仓库的内容。那么这时就不要乖乖听话,按照提示去先 `pull` 一下再 `push` 了,而是要选择「强行」`push`:
git push origin branch1 -f
1
2
3
4
5
6
7
8
9
10
11
12

`-f` 是 `--force` 的缩写,意为「忽略冲突,强制 `push`」。



## 2. 出错的内容已经合并到 master

同事的工作都在 `master` 上,你永远不知道你的一次强制 `push` 会不会洗掉同事刚发上去的新提交。所以除非你是人员数量和行为都完全可控的超小团队,可以和同事做到无死角的完美沟通,不然一定别在 `master` 上强制 `push`。

在这种时候,你只能退一步,选用另一种策略:增加一个新的提交,把之前提交的内容抹掉。例如之前你增加了一行代码,你希望撤销它,那么你就做一个删掉这行代码的提交;如果你删掉了一行代码,你希望撤销它,那么你就做一个把这行代码还原回来的提交。这种事做起来也不算麻烦,因为 Git 有一个对应的指令:`revert`。

它的用法很简单,你希望撤销哪个 `commit`,就把它填在后面。比如你想撤销最新的改动,使用:
git revert HEAD
1
2

如果你想撤销倒数第二个 `commit` 的改动,使用:
git revert HEAD^
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

上面这行代码就会增加一条新的 `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` 上,从而起到了「撤销」的效果:

<div align="center"><img src="https://image.zhangxiann.com/reset_benzhi.gif"/></div><br>

所以同理,`reset --hard` 不仅可以撤销提交,还可以用来把 `HEAD` 和 `branch` 移动到其他的任何地方。
git reset --hard branch2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

但是这样一来,你的 `master`分支就指向了 `branch2` 分支,如何把 `master` 指向原来的 `commit` 呢?

如果你记下了原来的 `commit`,那么可以使用 `git reset --hard commit_id`。

如果你忘了,那么你可以使用 `git reflog` 来引用的移动记录,默认是查看 `HEAD` 的移动记录。

<div align="center"><img src="https://image.zhangxiann.com/reset4.gif"/></div><br>

`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` 的用法很简单。当你手头有一件临时工作要做,需要把工作目录暂时清理干净,那么你可以:
git stash
1
2
3
4
5
6

就这么简单,你的工作目录的改动就被清空了,所有改动都被存了起来。

然后你就可以从你当前的工作分支切到 `master` 去给你的同事打包了……

打完包,切回你的分支,然后:
git stash pop
1
2
3
4

你之前存储的东西就都回来了。很方便吧?

> 注意:没有被 track 的文件(即从来没有被 add 过的文件不会被 stash 起来,因为 Git 会忽略它们。如果想把这些文件也一起 stash,可以加上 `-u` 参数,它是 `--include-untracked` 的简写。就像这样:
git stash -u
1
2
3
4
5
6
7
8
9
10



# reflog

`branch` 用完就删是好习惯,但有的时候,不小心手残删了一个还有用的 `branch` ,或者把一个 `branch` 删掉了才想起来它还有用,怎么办?

## reflog :引用的 log

`reflog` 是 "reference log" 的缩写,使用它可以查看 Git 仓库中的引用的移动记录。如果不指定引用,它会显示 `HEAD` 的移动记录。假如你误删了 `test` 这个 `branch`,那么你可以查看一下 `HEAD` 的移动历史:
git reflog
1
2
3

<div align="center"><img src="https://image.zhangxiann.com/20200831152410.png"/></div><br>
你可以看到最后一次切换到 `test` 的记录,这行记录前面的 `7eafad2` 就是 `test` 分支的最新 `commit`,所以你可以切换回 `7eafad2`,然后重新创建 `test` :
git checkout 7eafad2 git branch test git checkout test
1
2

后面两句可以合并为一句
git checkout -b test
1
2
3
4
5
6

>注意:不再被引用直接或间接指向的 `commit`s 会在一定时间后被 Git 回收,所以使用 `reflog` 来找回删除的 `branch` 的操作一定要及时,不然有可能会由于 `commit` 被回收而再也找不回来。



你还可以查看其他引用的移动历史,如:
git reflog master git reflog test ```

评论