0. 参考文档
本文中绝大部分内容来自以下两个文档,想要深入了解某一个主题的,建议仔细查看原文档。
- (详细):ProGit(中文版)
- (入门):Git-Recipes
1. 基本概念
1.1 文件状态
- 已修改(modified):对应工作区(Working directory),包括新增和修改的文件;
- 已暂存(staged):对应暂存区(Staging area);
- 已提交(committed):对应提交历史(Git directory)。
简单理解其相互关系:
已修改 - add -> 已暂存 - commit -> 已提交 - checkout -> 已修改
1.2 提交与分支
提交(Commit)和分支(Branch)的关系,可以类比链表与指针。Git 中的分支,本质上是个指向 commit 对象的可变指针。
如下图所示,提交历史共有 3 次提交记录,每一次提交可以类比为链表中的一个节点,节点包含指向下一个节点(上一次提交)的指针信息。
另外还有三个特殊的指针,master
和testing
是两个分支,可以类比为两个指向链表头的指针;HEAD
是一个特殊的指针,用于指示当前工作区所在的位置。
如下图所示,是在testing
分支上工作后,进行了一次提交。master
仍然指向原来的提交,testing
指向最新的提交,而由于当前在testing
分支上工作,HEAD
随着 testing 一起向前移动了一步。
如下图所示,如果切换到master
分支上,并且又进行了一次提交,则master
和HEAD
一起向前移动,指向87ab2
这次提交。
综上所属,分支是一个指针,而不是一个容器。
在 Git 的概念里,分支是一个很轻量化的概念,新建、修改和移除分支的代价都不大。因此也推荐保持分支的短期性,一个分支专注于一项特定的任务,即来即走。
1.3 合并(merge)
合并操作的对象都是分支,包括:
- 源分支:包含新增内容的分支;
- 目标分支:需要并入新增内容,会发生改变的分支。
合并有两种形式:
- 快速向前合并(Fast-forward merge)
- 三方合并(Three-way merge)
1.3.1 快速向前合并
当目标分支是源分支的直接上游时,合并时会采用快速向前的方式,并出现“Fast forward”提示。
操作实质:把目标分支的指针直接向前移动到源分支的指针所指向的位置。
如下图所示,包含master
、hotfix
、iss53
三个分支,由于当前master
是hotfix
的直接上游,将采用 Fast-forward 的合并方式。
合并结果如下图所示,合并之后master
和hotfix
分支指向同一个提交。此时hotfix
分支已经完成历史使命,可以删除了。
1.3.2 三方合并
然后回到iss53
分支进行开发工作,又进行了一次提交后,需要将改动内容合并入master
,而此时,这两个分支已经分岔了。Git 会用两个分支的末端(C4 和 C5)以及它们的共同祖先(C2)进行一次三方合并计算。
Git 对三方合并后的结果重新做一个新的快照,并自动创建一个指向它的提交对象(C6),如下图所示。此时iss53
分支已经完成历史使命,也可以删除了。
1.3.3 冲突解决
并不是每一次合并操作都非常顺利,有时可能会产生冲突。
<重要原则>:快速向前合并从原理上就不会产生冲突,冲突只会发生在三方合并的时候。
冲突产生的原因:简单理解,可以认为是 C4 的提交,和 C3-C5 的提交,修改了文件的同一个部分。Git 无法自动合并,必须由人来裁决。
冲突解决的步骤:解决冲突的步骤与进行一次新的提交的步骤几乎一样。(1)修改冲突的代码,(2)将修改过的文件加入缓冲区(add 操作),(3)进行一次合并提交(commit 操作)。
1.4 衍合(rebase)
1.4.1 基本原理
衍合是除了合并操作以外,另一种把一个分支中的修改整合到另一个分支中的办法。
回到两个分支进行了各自的提交而导致分岔的情况下,如下图所示。
现象这样的场景:
当你在experiment
分支上工作时,有其他人向master
分支上推送了新的内容。
你想要把master
中的更新同步到experiment
分支中,如果使用 merge 操作会产生一个额外的合并提交。
而 rebase 的操作逻辑是:把在 C3 里产生的变化补丁在 C4 的基础上重新打一遍。操作结果如下图所示。
此时,你的experiment
分支包含了master
分支上的最新内容,可以以此为基础继续开发。(默认master
分支上的内容都是稳定的,需要所有人向其兼容的)。
如果需要把experiment
分支上的内容也并入master
分支,可以执行安全的快速向前合并。结果如下图所示。
1.4.2 重要守则
git rebase
是 Git 操作中的黑魔法,用好了可以化腐朽为神奇,用不好会带来灾难性后果。
<重要原则>:绝不要在公共的分支上使用它!!!
用更白话一点的说法:从分岔点开始往后的提交,如果已经 push 过,那就已经是公共的提交了,这个分支就是公共分支,必须假设其他人的工作会依赖于这些公共提交,也就不能再用 rebase 操作了。
因为衍合的过程改变了分支的历史,原来的 C3 变成了 C3',如果之前 C3 已经发布到了远程,则在本地变更为 C3'后,远程分支与本地分支不一致了,会导致后续的 push 操作无法进行。
因为在进行 push 操作的时候,将本地分支推送到关联的远程分支时,本质上也是一个 Fast-forward 模式的合并操作。
1.5 远程服务器
虽然只在本地工作也能使用 Git,但为了协作,一般会有一个代码托管的远程服务器,运行一个 Git 服务。
1.5.1 远程分支和本地分支
本质上,每一个 Git 库的副本,都有自己的一套分支,而不同副本之间的分支可以进行关联追踪。
在一个本地库上执行git branch -a
,典型会有有如下显示(部分删节):
develop
* feature_xxx
master
remotes/origin/HEAD -> origin/master
remotes/origin/develop
remotes/origin/feature_xxx
remotes/origin/master
前三个是本地分支,带remotes
前缀的是远程分支,origin
代表服务器名。
执行git branch -vv
查看分支的追踪关系,有如下显示(部分删节):
develop 1bf6d01 [origin/develop]
* feature_xxx 7f3d5c9 [origin/feature_xxx]
master 40f9f56 [origin/master]
一些有效但不那么准确的理解:
- 远程分支和本地分支是不同的分支;
- 具有追踪关系的远程/本地分支之间,通过 pull/push 操作进行同步,合并方式限定为 Fast-forward。
2. Git 工作流
使用一个简化的Git Flow
工作流。
2.1 Git 分支类型
将会使用master
、develop
、feature
三种分支,暂时不使用hotfix
、release
分支。
-
master
分支:- 仓库的主分支,包含最近发布的可稳定使用的版本;
- 一般由仓库管理员从
develop
分支进行合并,不能直接向master
进行推送; -
master
分支始终存在,不可删除。 -
master
的每一个 commit 都应该打上标签,作为对外发布的版本号。
-
develop
分支:- 主要开发分支,基于
master
创建,始终包含最新完成功能的代码以及 bug 修复后的代码; - 接受从
feature
发起的合并请求,不能直接向develop
进行推送; -
develop
分支始终存在,不可删除。
- 主要开发分支,基于
-
feature
分支:- 基于
develop
创建,用于某一个特定的新功能/新特性开发; -
feature
分支可同时存在很多个,用于多个功能同时开发; -
feature
分支属于临时分支,当合并入develop
后,建议选择删除,如有继续开发的需要,重新基于develop
创建新的feature
分支。
- 基于
develop
基于master
创建,并且在develop
向前演进的过程中,master
不会接受其他来源的提交和合并。因此,每一次develop
向master
分支的合并,都应该是 Fast-forward 模式的。feature
基于develop
创建,并最终合并入develop
,在此过程中,develop
可能会合并入其他feature
分支的内容,如何保证提交 PR 时不产生合并冲突,在下一节详细讨论。- 以文档内容为主的 WIKI 库不设
develop
分支,围绕master
分支进行协作。
2.2 基本原则
几项 Git 协作的基本原则:
-
重要:在远程服务器上,只会进行 Fast-forward 模式的合并,并且是通过 Pull-Request 进行。在提交 PR 之前,需要确认目标分支(一般是
develop
)是源分支(一般是feature
)的直接上游。可以通过提交图进行确认。 -
重要:所有的冲突都在本地解决,在远程没有解决冲突的途径。
-
保持每一次 Commit 有明确的意义,必要时通过交互式的 rebase 操作,对 Commit 进行整理。
整理方法参见说明。
-
保证分支命名规范且有意义,保证 commit-message 包含简明且充分的信息。
branch-name 和 commit-message 的规范参见协作规范文档。
-
保持合适的推送频率(Push 操作,非 Commit 操作)
针对正在执行的任务,如果 3 天以上都没有 Push,无法让其他人同步进度,则说明提交频率过低;如果在一个时段内连续提交,疯狂刷屏,则说明推送频率过高。
建议针对某一个具体的开发任务,一天推送 1 至 2 次是一个合适的频率;可以在每天工作结束前,整理当天的 Commit,并执行一次 Push 操作。
2.3 典型工作流程
2.3.1 仓库初始化
从 Server 端通过git clone
获取仓库一个完整的副本,并在本地新建feature_0
分支。
2.3.2 合并流程
本地feature_0
分支有了两个新的提交(C3&C4),在此同时,远程的origin/develop
接受了feature_1
分支的 PR,向前推进到了 C5。如下图所示:
不合理的做法:
此时,如果直接将本地的feature_0
分支推送到远程,则origin/develop
和origin/feature_0
处于分岔的状态。如下图所示:
按照一般 PR 的规则,无法将origin/feature_0
合并入origin/develop
,因为目标分支并不是源分支的直接上游。如果强行合并,若刚好两个分支没有修改过同一个文件,则可以侥幸合并成功。
但并不推荐这么做,因为违反了 PR 只做 Fast-forward 操作的原则。且很多时候会产生冲突,在网页上无法处理。
如下图所示:
合理的做法:
在推送feature_0
分支之前,先将本地的develop
分支和远程的origin/develop
进行同步(通过git pull
操作)。如下图所示:
在本地进行衍合操作,将feature_0
分支的提交,更新到develop
分支之前。
# 在 feature_0 分支下
git rebase develop
如果发生冲突,则按照命令行的提示手动解决冲突。正确 rebase 后的结果,如下图所示:
在此基础上,本地分支继续开发,又向前推进了一次提交,然后同步到远程的origin/feature_0
分支上。此时,origin/develop
是origin/feature_0
的直接上游,可以实现 Fast-forward 模式的合并。(假设在此期间origin/develop
没有再接受新的 PR,如有,则重复上面的流程)。结果如下图所示:
2.3.3 衍合的注意事项
回到最初分岔的阶段,如下图所示:
假设已经将feature_0
分支推送到远程,与origin/feature_0
同步过一次。
若此时想基于origin/develop
最新提交的基础,进行继续开发,先将更新拉取到本地,如下图所示:
在本地执行之前一样的衍合操作,将feature_0
分支添加到develop
分支的头部,会发现本地的feature_0
和远程的origin/feature_0
不一致了,此时无法再执行 Push 操作,因为本地和远程发生了冲突。如下图所示:
如果已经将提交推送至远程,有两种后续的解决方案:
(1)通过 merge 而非 rebase 进行合并,在本地执行三方合并
同时要求在本地解决可能的合并冲突。结果如下图所示:
此时将feature_0
推送到远程,与origin/feature_0
进行同步。这时,origin/develop
是origin/feature_0
的直接上游,符合 PR 的规则,可以正常提交合并请求。结果如下图所示:
(2)如果本地已经使用了 rebase 操作进行合并,可以强制推送进行同步
强制推送git push -f
是另一个比较危险的操作。
<重要原则>:一定只在自己的工作分支上使用!!!一定不能向其他人的工作分支执行强制推送!!!
除非万不得已,不要使用强制推送。在推送前,需要反复确认本地分支包含了所有需要的工作内容。
推送后,远程分支和本地分支已经一致,并且origin/develop
是origin/feature_0
的直接上游。结果如下图所示:
2.3.4 再谈合并与衍合
-
git rebase
和git merge
做的事其实是一样的,它们都被设计来将一个分支的更改并入另一个分支,只是实现方式不同,最终的文件结果是一致的。 -
git merge
的好处是,不会对历史进行任何更改,是安全的;缺点是,每次合并都会引入一个额外的提交(比如上图的 C6),如果上游分支非常活跃,一定程度会污染本地的开发分支,形成一个非常复杂的开发历史。 -
git rebase
的好处是,开发历史会非常整洁和线性,没有不必要的合并提交;缺点是,这个操作包含一定的风险性,会变更开发历史,所以必须严格遵守衍合的操作守则——绝不要在公共的分支上使用它!!!任何衍合操作都不应该更改已经同步到远程服务器的提交。 -
git rebase
的一个额外的好处是,可以用来清理提交历史,让历史中的每一次提交都包含明确的开发意义,便于回顾和追溯。这意味着,在开发过程中,本地可以进行相对随意的 Commit,在整体 Push 之前,通过
git rebase -i
对开发历史进行整理,合并部分 Commit,修改 commit-message。
2.3.5 推荐的操作方法
综上所述,一个推荐的基于 git 协作的开发流程:
- 将本地的
develop
分支与远程的origin/develop
进行同步,基于最新的develop
,新建本地的开发分支feature
; - 在
feature
分支上工作,正常进行git commit
操作; - (可选)开发过程中关注
origin/develop
的更新,如果更新的内容与feature
开发的内容相关,则拉取到本地,通过git rebase
操作,合并入feature
; - 在执行
git push
前,如有必要,通过git rebase -i
对提交历史进行整理,注意只清理还未推送到远程的提交; - 在执行
git push
前,必须确认origin/develop
的状态,执行一次git pull
操作,如果有更新,则拉取到本地,通过git rebase
操作,合并入feature
; - 确认
feature
在合并origin/develop
的最新更新后,依然能够按照预想的方式正常运行,如果有问题则修正; - 执行
git push
操作,将feature
推送到远程,与origin/feature
进行同步。
3. Git 配置
在使用 Git 前,对其进行有效的配置,可以达到事半功倍的效果。
3.1 配置层级
Git 的配置包含三个层级,每一级别的配置会覆盖上层的相同配置:
-
系统配置:
/etc/gitconfig
文件,设置时配合--system
选项; -
用户配置:
~/.gitconfig
文件,设置时配合--global
选项; -
项目配置:
./git/config
文件。
3.2 必须配置
设置名字和邮箱,用于识别用户,会记录到每一次 commit 的信息中。
git config --global user.name "John Doe"
git config --global user.email johndoe@example.com
3.3 建议配置
3.3.1 文本编辑器
git config --global core.editor vim
建议用 vim,如果不习惯可以用 gedit、code 之类代替。
3.3.2 commit-message 模板
使用 commit-message 模板可以减少每次 commit 操作时需要固定输入的内容。
设置模板文件:
在$HOME/.gitmessage.txt
中或新建任意文本文件,写入如下内容:
<feat>(XXX):
<>
启用模板文件:
git config --global commit.template $HOME/.gitmessage.txt
3.3.3 自动换行符
Windows 使用回车和换行两个字符来结束一行(CRLF),而 Mac 和 Linux 只使用换行一个字符(LF)。
# 只在window开发,linux/windows运行
$ git config --global core.autocrlf true
# linux开发和运行
$ git config --global core.autocrlf input
# 只在windows开发和运行
$ git config --global core.autocrlf false
4. 建议
使用命令行操作,减少对 IDE 内置工具的依赖。
- 掌握命令行操作,能够让你更好地理解 Git 的工作原理;
- IDE 简化了操作,但也增加了误操作的可能。