分支基础

Git 的版本控制不是单一主线的,而是维持一个版本链表结构,如下图所示

特点如下:

  • 链表的每一个结点代表一次正式提交(commit对象),每一个正式提交结点都对应数据库中的一个正式的版本快照
  • 链表的每一个结点都有至少一个父结点(除了最初的结点),并且可能存在两个父结点(出现在分支合并时)
  • 链表最新的一头通常需要一个指针来确定头部位置,这个指针就是分支,例如默认分支就是 main 或 master
  • 链表可以有很多个分叉,链表的分叉也可能再次合并或分叉,每一个链表分叉的最新结点都通过一个分支定位,否则就处于游离状态
  • 当前所处的状态通过HEAD指针标记,它是一个二级指针,通常指向一个分支,可以自由切换指向任何一个存在的分支,并操作这个分支的移动
  • 提交会新建一个提交结点,并指向 HEAD 当前所间接指向的结点,然后 HEAD 指向的分支会前移到新结点上
  • 链表中可能存在游离的结点:既没有结点指向它,也没有分支指向它。这可能由版本回撤导致,此时这个结点代表的版本数据仍然保存在数据库中,但是版本历史以及正常操作中无法直接获取,需要高级的命令才可以恢复数据

分支管理(branch)

使用git branch命令可以创建或管理分支,创建的新分支会指向当前 HEAD 所间接指向的提交结点

1
git branch branch_name

加入-d选项可以删除指定分支

1
git branch -d branch_name

注意:如果目标分支没有被合并则会删除失败,因为此时的删除操作可能导致数据丢失,必须提前合并或者使用-D选项强制删除。

使用-m--move选项可以对分支进行重命名

1
git branch -m master main

如果希望列出所有的分支,直接使用 git branch 不带任何选项或参数,如果加上 -v 选项则会更详细地列出分支以及指向的提交结点信息

1
2
git branch
git branch -v

我们可以直接筛选出相对于当前分支,已经合并过来的分支,或尚未合并过来的分支

1
2
git branch --merged
git branch --no-merged

默认只显示本地分支,如果希望显示远程跟踪分支,需要 -r 选项,或者使用 -a 选项显示本地分支和远程跟踪分支

1
2
git branch -r
git branch -a

注意:在尚未进行一次本地提交时,是不存在本地分支和本地提交结点的,默认分支main或master都在第一次本地提交之后创建,在第一次提交之前是没有意义的

分支切换(switch)

git switch 也可以切换分支,相当于把 git checkout 的一部分功能专门拆分为一个单独命令,例如

1
git switch branch_name

-c 可以在切换时直接新建分支,如果分支已经存在,则操作失败, -C 选项则会新建分支,如果已存在直接重置分支

1
2
git switch -c branch_name
git switch -C branch_name

A->B 的分支切换具体内容是:

  • 首先 HEAD 从A指向另一个分支B
  • 然后基于分支指向的提交信息,尝试B初始化 index 和工作目录
  • 尝试A目前的未提交修改(包括在 index 的修改和在工作目录的修改),在新的 index 和工作目录中重演
  • 检查是否可以无冲突地重演这些修改,如果可以则切换;否则可能造成数据损失,拒绝切换
  • 换言之:切换前的 index 和工作目录并不要求是干净的,切换后的 index 和工作目录也不一定是干净的,顺利执行的切换会保留这些可重演的修改

注意这里的切换和 git reset --hard 相比是安全无冲突的,分支切换不会造成任何数据丢失(除非加上强制切换的参数选项):

  • 切换会保证:未提交的更改都可以在A->B->A再次切换回来之后完全恢复,不会存在负面效果(个人理解)
  • 对于暂存在 index 中或者仍然在工作目录中的修改,如果可以视作对切换之后状态的修改(类似于 rebase 的重演),那么仍然会保留,否则拒绝切换,几个例子:
    • 例如切换前后都有 a 文件,当前对 a 文件有未提交修改,切换后可以重演这里的修改行为,可以正常切换
    • 例如切换前有 a 文件,但是切换的目的地没有 a 文件,如果存在对 a 文件的未提交修改(无论是否暂存到 index),切换后无法重演这里的修改行为,那么切换会被拒绝
    • 例如切换前后都没有 a 文件,新建 a 文件(无论是否暂存到 index),可以正常切换
  • 即便切换是数据安全的,但是还是建议在切换前后保持 index 和工作目录是干净的。

注:git checkout在切换分支时也有相似的语法,实际上git switch就是拆分和继承了原本git checkout命令的一部分功能,现在更推荐使用git switch

  • 将 HEAD 指向另一个分支

    1
    git checkout branch_name

  • -b 可以在切换时直接新建分支,如果分支已经存在,则操作失败, -B 选项则会新建分支,如果已存在直接重置已存在的分支

    1
    2
    git checkout -b branch_name
    git checkout -B branch_name

分支合并(merge)

两个分支可以合并,将两条开发线的修改合并,假设 HEAD 指向的当前分支 main 指向结点 A,被合并的分支 test 指向结点 B,那么分支合并至少有以下几类情形:

  1. 如果A <- ... <- B,那么就会执行快进合并,将 main 直接前移到 test,也指向 B
  2. 如果B <- ... <- A,那么无事发生(视作 test 已经被 main 合并过了)
  3. 其他情形下,需要考虑 A 和 B 的最早公共祖先为 C 结点,进行三分合并,此时以 C 为基准,如果只有一方修改了,那么接收这个修改,如果两方都进行了修改(文件状态为both modified),视作冲突无法取舍
    1. 如果没有冲突,将会自动生成一个新结点 D,D 有两个父节点 A 和 B,分支 main 前移指向新的结点 D
    2. 如果存在冲突,那么就会进入冲突处理的特殊状态,冲突文件的状态为 unmerged ,可能因为两个分支都对它进行了修改(相比于公共祖先 C),需要手动修改这些冲突文件并清理标记,重新执行 git add ,此时 Git 认为冲突处理完成,再执行 git commit ,然后和无冲突时一样生成新的提交结点

分支合并的命令如下

1
2
git checkout master
git merge iss53

这会将 iss53 分支合并到当前的 master 分支,如果可以快进合并就不会生成新的提交结点,否则进行三方合并,生成新的提交结点并将 master 前移,而 iss53 不会移动,三方合并前后的示意图如下

如果在分支合并过程中发现了问题,可以取消这次合并,回到 merge 之前的状态

1
git merge --abort

git merge命令支持一个选项--squash,它的作用就是在达到 git merge 命令分支合并效果的同时,保持主分支提交历史的简洁,例如

1
2
git checkout master
git merge --squash iss53

它会用两者merge合并后会产生的结果覆盖工作目录和缓冲区,但是并不产生任何提交,我们需要手动创建一个提交。 这个新的提交虽然事实上保留了 iss53 分支上发生的更改,但是在提交历史中只会指向一个父节点,这可以让 master 分支保持简单的线形提交历史。 此时 iss53 分支处于游离状态,但是我们可以丢弃和删除它,因为更改已经被合并到了 master 分支中。

git merge命令还支持一个选项--no-ff,它的作用就是在让 git merge 命令即使在可以快进合并的简单情况下,仍然选择创建一个独立的合并提交,这样可以保留分支的合并历史,让项目的历史记录更加清晰。

1
2
git checkout master
git merge --no-ffh iss53

分支变基(rebase)

分支变基是相对于分支合并的另一种处理方式,由于分支的产生和分支合并会导致整个链表结构非常复杂,而分支变基则会以另一种简单近似线性的方法维护整个链表,同时也可以将两条开发线上的修改合并。

仍然考虑三分合并的例子,假设 HEAD 指向的当前分支 master 指向结点 A,被合并的分支 experiment 指向结点 B,A 和 B 的最早公共祖先为 C 结点,将 experiment 变基到 master,就是将从 C 到 B 的历次修改在 A 的基础上重演一遍,得到一系列新的提交结点,最终结点为 B'(test 指针也相应移动到 B')

与合并不同,我们切换到 experiment 分支上进行变基操作

1
2
git checkout experiment
git rebase master

分支变基前后的示意图如下

注意到此时 master 还停留在原地,而 experiment 指向最前方的变基结果,还需要执行一次快进合并将 master 前移

1
2
git checkout master
git merge experiment

变基过程中同样也可能会出现冲突问题,解决方法为

  1. 修改冲突文件
  2. git add重新添加
  3. git merge --continue继续变基

或者可以取消这次变基,回到 rebase 之前的状态

1
git rebase --abort

注意:

  • 分支合并和变基最终结点的结果都是一样的,只不过链表和提交历史不一样了
  • 如果一直采用变基而不是合并,那么在版本链表所有的分支都会指向一条主线的各个结点
  • 变基后,原本 C 到 B 的那些结点不会被删除,但是通过正常方法无法访问,需要特殊的技巧

压缩提交历史 (rebase -i)

一个很常见的需求是:压缩看起来很长但是毫无价值的commit历史,例如将一个开发分支feature/xxx想要合并到main分支中,我们并不关心这个开发分支中的那些小的commit,将其合并为一个commit就可以保证commit历史足够简洁。

可以用git rebase实现,例如有如下几个连续的commit(xxi代表提交的哈希id)

1
[main] == xxx0 -> xxx1 -> xxx2 -> xxx3 --> xxx4 == [HEAD,feature/xxx]

我们可以将这几个commit合并为一个,操作如下(这里的xxx0或者说HEAD~4是最旧的版本,最终会合并得到一个基于这个最旧版本的commit)

1
2
3
git rebase -i xxx0
# or
git rebase -i HEAD~4

然后就会进入交互式编辑页面,其中的内容形如

1
2
3
4
5
6
pick xxx1
pick xxx2
pick xxx3
pick xxx4

...

手动进行修改:除了第一个pick,其它的均改成squash(或缩写s),得到下面的内容

1
2
3
4
5
6
pick xxx1
s xxx2
s xxx3
s xxx4

...

这表明对后续的几个全部视作基于xxx1进行的squash操作,然后保存退出, 我们会进入一个新的提交信息编辑页面,修改提交信息,然后保存退出即可。

最终得到的commit历史如下

1
[main] == xxx0 -> xxx1(new) == [HEAD,feature/xxx]

Git 标签

标签包括两类:

  1. 第一类是轻量标签,它实质上相当于一个不可变的分支,指向某一个 commit 对象。创建轻量标签

    1
    git tag v1.4-lw

  2. 第二类是附注标签,它实质上一个独立的 tag 对象,它需要自带 message,指向某一个 commit 对象,或者其他对象。创建附注标签,需要-a选项,可以使用-m附带 message,否则 Git 会启动编辑器

    1
    git tag -a v1.4 -m "version 1.4"

打标签在缺省时自动针对最近的一次提交,当然也可以使用哈希值指定历史上的任意一次提交

1
git tag v0.0 907e

查看所有标签,支持通配符筛选,不区分两类标签

1
2
3
git tag

git tag -l "v1.8.5*"

查看标签的实质

1
git show v1.0

对于轻量标签,会展示指向的 commit 对象信息;对于附注标签则会展示标签对象自身的信息,包括附加的 message

删除标签

1
git tag -d v1.0-lw

注:

  • 附注标签会向数据库中添加一个对象,而轻量标签不会
  • 虽然两类标签本质上不同,但是主要的针对标签的操作并不会区分它们;
  • git log通常也可以查看到标签信息

标签和分支的实质是非常类似的,但是两者的定位区别在于:

  • 标签不可变,仅相当于里程碑,例如v1.2.0标记一个版本;
  • 分支可变,例如分支release-1.2,这代表1.2版本以及对它的后续维护(通常仅限于BUG修复,不会引入新功能)。

Git stash

有时当某项工作正在进行中,所有东西都进入了混乱的状态,而这时你需要切换到另一个分支做一点别的事情, 但是并不值得因为做了一半的工作创建一次单独的提交,针对这个问题的解决办法是 git stash 命令。

git stash 命令提供一个栈,用于临时保存当前工作区和暂存区的所有修改,便于切换分支或处理其他任务。 在完成后可以将改动重现,甚至在不同分支上重现。(重现是有条件的,如果存在冲突则会提示先解决冲突)

创建 stash,将当前状态存储到栈顶(与此同时,自动清理当前的工作区和暂存区)

1
2
3
git stash push
# or
git stash

默认情况下,Git 并不会记录未跟踪的新文件以及忽略的文件,可以使用选项-u-a将它们加上。

查看 stash 栈

1
git stash list

通过索引查看指定 stash 的信息(栈顶的索引为0,然后逐渐增加),默认显示栈顶的 stash

1
2
git stash show
git stash show stash@{n}

注意:stash@{n} 命令在某些shell(例如fish)中可能有解析错误。

应用存储的 stash 的两种命令如下(默认使用栈顶的 stash)

1
2
3
4
5
git stash pop
git stash apply

git stash pop stash@{2}
git stash apply stash@{2}

两者的主要区别在于:pop 命令在成功应用时会自动删除它(如果重现失败则不会自动删除),apply 命令则始终不会删除 stash。

注意:在应用 stash 时并不会恢复暂存区,除非加上 --index 选项。

对于 stash 栈,我们还需要对应的删除命令,包括

1
2
git stash drop stash@{2}    # 删除指定 stash
git stash clear # 清空所有 stash

如果在当前状态应用 stash 出现了比较麻烦的冲突,我们可以直接创建一个独立的分支(基于创建 stash 时 HEAD 所在的结点)来应用 stash

1
git stash branch <branchname>