Git是目前世界上最先进的分布式版本控制系统,本文是对 git 的原理与用法的总结笔记。
版本控制系统
版本控制是一种记录一个或若干文件内容变化,以便将来查阅特定版本修订情况的系统。通过版本控制系统,你就可以将选定的文件回溯到之前的状态,甚至将整个项目都回退到过去某个时间点的状态;你可以比较文件的变化细节,查出最后是谁修改了哪个地方,从而找出导致怪异问题出现的原因,又是谁在何时报告了某个功能缺陷等等。 使用版本控制系统通常还意味着,就算你乱来一气把整个项目中的文件改的改删的删,你也照样可以轻松恢复到原先的样子,但额外增加的工作量却微乎其微。
本地版本控制系统
许多人习惯用复制整个项目目录的方式来保存不同的版本,或许还会改名加上备份时间以示区别。 这么做唯一的好处就是简单,但是特别容易犯错。 有时候会混淆所在的工作目录,一不小心会写错文件或者覆盖意想外的文件。
为了解决这个问题,人们很久以前就开发了许多种本地版本控制系统,大多都是采用某种简单的数据库来记录文件的历次更新差异。其中最流行的一种叫做 RCS,现今许多计算机系统上都还看得到它的踪影。 RCS 的工作原理是在硬盘上保存补丁集;通过应用所有的补丁,可以重新计算出各个版本的文件内容。
集中式版本控制系统
接下来人们又遇到一个问题,如何让在不同系统上的开发者协同工作?于是,集中化的版本控制系统(Centralized Version Control Systems,简称 CVCS)应运而生。 这类系统,诸如 CVS、SVN 以及 Perforce 等,都有一个单一的集中管理的服务器,保存所有文件的修订版本,而协同工作的人们都通过客户端连到这台服务器,取出最新的文件或者提交更新。 多年以来,这已成为版本控制系统的标准做法。
但这种模式有一个致命的缺点,就是中心的单点,如果服务器恰好坏了,那代码全部丢失,所以大公司一般都有很好的容灾机制。
分布式版本控制系统
随着开源运动的爆发,中心仓库模式很难适应全球多人写作的模式,于是乎分布式版本控制诞生了
DVCS结合了LVCS和CVCS两者的优点,本地仓库让一切都在本地,同时分布式的设计又让每一个节点都能成为远端
DVCS的缺点也是不容忽视的,本地仓库会导致首次clone变慢,其学习曲线优点陡峭(相对而言)
Git 工作模型
Snapshots, Not Differents
Git 和其他版本控制系统的主要差别在于,Git 只关心文件数据的整体是否发生变化,而大多数其他系统则只关心文件内容的具体差异。这类系统(CVS,Subversion,Perforce,Bazaar 等等)每次记录有哪些文件作了更新,以及都更新了哪些行的什么内容:
Conceptually, most other systems store information as a list of file-based changes.
Git 并不保存这些前后变化的差异数据。实际上,Git 更像是把变化的文件作快照后,记录在一个微型的文件系统中。每次提交更新时,它会纵览一遍所有文件的指纹信息并对文件作一快照,然后保存一个指向这次快照的索引。为提高性能,若文件没有变化,Git 不会再次保存,而只对上次保存的快照作一链接。Git 的工作方式就如下图所示:
Git thinks about its data more like a stream of snapshots.
This makes Git more like a mini filesystem with some incredibly powerful tools built on top of it, rather than simply a VCS.
The Three States
对任何一个文件,在Git内都有三种状态:
- 已提交(committed):表示该文件已经被安全的保存在本地数据库中了
- 已修改(modified):表示修改了某个文件,但还没有提交保存
- 已暂存(staged/index):表示把已修改的文件放在下次提交时要保存的清单中
Git 命令图解
git diff
git diff
显示两次提交之间的变动。
1 | # 显示暂存区和工作区的代码差异 |
git commit
命令图解
提交时,git用暂存区域的文件创建一个新的提交,并把此时的节点设为父节点。然后把当前分支指向新的提交节点。下图中,当前分支是master。 在运行命令之前,master指向ed489,提交后,master指向新的节点f0cec并以ed489作为父节点。
即便当前分支是某次提交的祖父节点,git会同样操作。下图中,在master分支的祖父节点maint分支进行一次提交,生成了1800b。 这样,maint分支就不再是master分支的祖父节点。
更改最近的commit
如果想更改一次提交,使用 git commit --amend
。git会使用与当前提交相同的父节点进行一次新提交,旧的提交会被取消。
git checkout
checkout命令用于从历史提交(或者暂存区域)中拷贝文件到工作目录,也可用于切换分支。
当给定某个文件名(或者打开-p选项,或者文件名和-p选项同时打开)时,git会从指定的提交中拷贝文件到暂存区域和工作目录。比如,git checkout HEAD~ foo.c
会将提交节点HEAD~(即当前提交节点的父节点)中的foo.c
复制到工作目录并且加到暂存区域中。(如果命令中没有指定提交节点,则会从暂存区域中拷贝内容。)注意当前分支不会发生变化。
当不指定文件名,而是给出一个(本地)分支时,那么HEAD标识会移动到那个分支(也就是说,我们“切换”到那个分支了),然后暂存区域和工作目录中的内容会和HEAD对应的提交节点一致。新提交节点(下图中的a47c3)中的所有文件都会被复制(到暂存区域和工作目录中);只存在于老的提交节点(ed489)中的文件会被删除;不属于上述两者的文件会被忽略,不受影响。
如果既没有指定文件名,也没有指定分支名,而是一个标签、远程分支、SHA-1值或者是像master~3类似的东西,就得到一个匿名分支,称作detached HEAD(被分离的HEAD标识)。这样可以很方便地在历史版本之间互相切换。比如说你想要编译1.6.6.1版本的git,你可以运行git checkout v1.6.6.1
(这是一个标签,而非分支名),编译,安装,然后切换回另一个分支,比如说git checkout master
。然而,当提交操作涉及到“分离的HEAD”时,其行为会略有不同,详情见在下面。
HEAD标识处于分离状态时的提交操作
当HEAD处于分离状态(不依附于任一分支)时,提交操作可以正常进行,但是不会更新任何已命名的分支。(你可以认为这是在更新一个匿名分支。)
一旦此后你切换到别的分支,比如说master,那么这个提交节点(可能)再也不会被引用到,然后就会被丢弃掉了。注意这个命令之后就不会有东西引用2eecb。
但是,如果你想保存这个状态,可以用命令git checkout -b *name*
来创建一个新的分支。
git reset
reset命令把HEAD指针指向另一个位置,并且有选择的变动工作目录和索引。也用来在从历史仓库中复制文件到索引,而不动工作目录。
Soft Reset
soft reset
会移动当前 HEAD 指针到某个特定的commit,与此同时当前工作目录的文件不变。
举个例子,我们在035cc
commit 新增了一个 index.js
文件,在 9e78i
commit 新增了一个 styles.css
文件,这个时候我们像撤销commit,不再要index.js
文件,但是想保留 styles.css
文件,这个时候我们执行 git reset --soft HEAD~2
,HEAD指针前移到ec5be
,但是在commit ec5be
之后添加的文件仍然保留 commit 9e78i
的状态。
Hard Reset
hard reset
不仅仅会移动当前 HEAD 指针到某个特定的commit,与此同时在该commit之后提交的所有文件修改都撤销。
撤销 add
1 | $ git reset HEAD # 撤销add的所有文件 |
git revert
git merge
命令图解
Merge操作会从目标 commit 和当前 commit (即 HEAD 所指向的 commit)分叉的位置起,把目标 commit 的路径上的所有 commit 的内容一并应用到当前 commit,然后自动生成一个新的 commit。
如下图所示,master 分支当前提交为ed489,other 分支提交为33104,他们的共同祖父节点为b325c,在master 分支执行 git merge other
会进行一次三方合并。结果是先保存当前目录和索引,然后和父节点33104一起做一次新提交。具体来说,执行git merge other
的过程中,会对比other分支的两次commit,相对于当前master分支的最新commit,得到改变的内容,合并成一次新的commit提交到master分支。
解决冲突
如果两个分支修改了相同的内容,merge 的时候就会发生冲突,git 不知道应该以哪个为准,会告诉你 merge 失败的原因,需要你来手动解决掉冲突,并重新 add、commit(改动不同文件或同一文件的不同行都不会产生冲突);或者使用git merge --abort
放弃解决冲突,取消merge。
Fast Forward
Fast Forward
是git在合并分支时候为了提高性能的默认方式。当待合并的分支有当前分支的所有commit的时候,不会创建新的commit,而是直接移动HEAD指针。
Fast Forward
会丢失在dev分支上的提交信息,为了保持原有dev分支上提交链的完成性,最佳实践是采用 no-fast-forward
模式来执行merge操作。
git cherry-pick
命令图解
cherry-pick命令”复制”一个提交节点并在当前分支做一次完全一样的新提交。
以上图为例,分别有master
和 topic
两个分支,在 master
分支的 ed489
commit 之后执行 git cherry-pick 2c33a
,可以直接将topic
分支上2c33a
的commit的内容应用到master分支上,同时创建了新的commit f142b
。
基本用法
1 | # 在target branch 上应用来自 source branch 的对应 commit 的内容 |
命令参数
git cherry-pick
命令的常用配置项如下。
(1)-e
,--edit
打开外部编辑器,编辑提交信息。
(2)-n
,--no-commit
只更新工作区和暂存区,不产生新的提交。
(3)-x
在提交信息的末尾追加一行(cherry picked from commit ...)
,方便以后查到这个提交是如何产生的。
(4)-s
,--signoff
在提交信息的末尾追加一行操作者的签名,表示是谁进行了这个操作。
(5)-m parent-number
,--mainline parent-number
如果原始提交是一个合并节点,来自于两个分支的合并,那么 Cherry pick 默认将失败,因为它不知道应该采用哪个分支的代码变动。
-m
配置项告诉 Git,应该采用哪个分支的变动。它的参数parent-number
是一个从1
开始的整数,代表原始提交的父分支编号。
1 | $ git cherry-pick -m 1 <commitHash> |
上面命令表示,Cherry pick 采用提交commitHash
来自编号1的父分支的变动。
一般来说,1号父分支是接受变动的分支(the branch being merged into),2号父分支是作为变动来源的分支(the branch being merged from)。
解决冲突
如果操作过程中发生代码冲突,Cherry pick 会停下来,让用户决定如何继续操作。
(1)--continue
用户解决代码冲突后,第一步将修改的文件重新加入暂存区(git add .
),第二步使用下面的命令,让 Cherry pick 过程继续执行。
1 | $ git cherry-pick --continue |
(2)--abort
发生代码冲突后,放弃合并,回到操作前的样子。
(3)--quit
发生代码冲突后,退出 Cherry pick,但是不回到操作前的样子
转移到另一个代码库
Cherry pick 也支持转移另一个代码库的提交,方法是先将该库加为远程仓库。
1 | $ git remote add target git://gitUrl |
上面命令添加了一个远程仓库target
。
然后,将远程代码抓取到本地。
1 | $ git fetch target |
上面命令将远程代码仓库抓取到本地。
接着,检查一下要从远程仓库转移的提交,获取它的哈希值。
1 | $ git log target/master |
最后,使用git cherry-pick
命令转移提交。
1 | $ git cherry-pick <commitHash> |
git rebase
命令图解
Rebase 是合并命令的另一种选择。git merge把两个父分支合并后进行一次提交,提交历史不是线性的。Rebase在当前分支上重演另一个分支的历史,提交历史是线性的。 本质上,这是线性化的自动的cherry-pick。
上面的命令都在topic分支中进行,而不是master分支,将topic分支的基准先设置为master的最新commit,然后重演自己的提交,并且把分支指向新的节点。注意旧提交没有被引用,将被回收。具体来说,你从 a47c3
处从master分支创建了你自己的 topic
来开发,提交了两次之后到了2c33a
。在这个期间,master
分支已经合并了来自于多个开发者的提交,进行到了da985
这个commit。这个时候,你在自己的topic
分支上执行 git rebase master
,重新设置了自己的基线。topic分支的基线改为da985
,然后重演169a6
和2c33a
两个提交,形成新的commit。
要限制回滚范围,使用--onto
选项。下面的命令在master分支上重演当前分支从169a6以来的最近几个提交,即2c33a。
冲突解决
rebase过程中,也许会出现冲突(conflict)
- git会停止rebase,需要解决冲突
- 解决完,使用
git add
添加冲突的文件,更新暂存区 git rebase --continue
继续剩下的rebasegit rebase --abort
终止rebase行为,并且feature会回到rebase开始之前的状态
1 | $ git rebase develop |
查看readme.md 内容
1 | Git tracks changes of files. |
选择保留HEAD
或者feature
的版本
1 | Git tracks changes of files. |
再提交:
1 | $ git add readme.md |
同样有git rebase --interactive
让你更方便的完成一些复杂操作,比如丢弃、重排、修改、合并提交。交互式的rebase有六个可以执行的操作。
reword
: Change the commit messageedit
: Amend this commitsquash
: Meld commit into the previous commitfixup
: Meld commit into the previous commit, without keeping the commit’s log messageexec
: Run a command on each commit we want to rebasedrop
: Remove the commit
压缩commit
丢弃commit
git remote
为了便于管理,Git要求每个远程主机都必须指定一个主机名。git remote
命令就用于管理主机名。
1 | # 显示所有远程仓库 |
git fetch
一旦远程主机的版本库有了更新,需要将这些更新取回本地,这时就要用到git fetch
命令。
1 | # 一般用法 |
git pull
git pull
命令的作用是,取回远程主机某个分支的更新,再与本地的指定分支合并。可以认为git pull是git fetch和git merge两个步骤的结合。
1 | $ git pull <远程主机名> <远程分支名>:<本地分支名> |
在某些场合,Git会自动在本地分支与远程分支之间,建立一种追踪关系(tracking)。比如,在git clone
的时候,所有本地分支默认与远程主机的同名分支,建立追踪关系,也就是说,本地的master
分支自动”追踪”origin/master
分支。
Git也允许手动建立追踪关系。
1 | $ git branch --set-upstream master origin/next |
上面命令指定master
分支追踪origin/next
分支。
如果当前分支与远程分支存在追踪关系,git pull
就可以省略远程分支名。
1 | $ git pull origin |
上面命令表示,本地的当前分支自动与对应的origin
主机”追踪分支”(remote-tracking branch)进行合并。
git push
git push
命令用于将本地分支的更新,推送到远程主机。它的格式与git pull
命令相仿。
1 | # 上传本地指定分支到远程仓库 |
注意,分支推送顺序的写法是<来源地>:<目的地>,所以git pull
是<远程分支>:<本地分支>,而git push
是<本地分支>:<远程分支>。
如果省略远程分支名,则表示将本地分支推送与之存在”追踪关系”的远程分支(通常两者同名),如果该远程分支不存在,则会被新建。
1 | $ git push origin master |
上面命令表示,将本地的master
分支推送到origin
主机的master
分支。如果后者不存在,则会被新建。
如果省略本地分支名,则表示删除指定的远程分支,因为这等同于推送一个空的本地分支到远程分支。
1 | $ git push origin :master |
上面命令表示删除origin
主机的master
分支。
如果远程主机的版本比本地版本更新,推送时Git会报错,要求先在本地做git pull
合并差异,然后再推送到远程主机。这时,如果你一定要推送,可以使用--force
选项。
1 | $ git push --force origin |
上面命令使用--force
选项,结果导致远程主机上更新的版本被覆盖。除非你很确定要这样做,否则应该尽量避免使用--force
选项。
git log
1 | # 显示当前分支的版本历史 |
git reflog
git show
1 | # 显示有变更的文件 |
git stash
git stash
会把所有未提交的修改(包括暂存和未暂存的)都保存起来,用于日后恢复当前工作目录
- 保存一个不必要但日后又想查看的提交
- 切换分支前先暂存,处理分支的其他事情
1 | $ git status |
stage是本地的,不会上传到git server
实际应用中,推荐给每个stash加一个message,使用git stash save
取代 git stash
1 | $ git stash save "test stash" |
- 可以使用
git stash list
命令,查看stash列表
1 | $ git stash list |
- 使用
git stash apply
命令可以通过名字指定那个stash,默认指定最近的(stash@{0}) - 使用
git stash pop
将stash中第一个stash删除,并将对应修改应用到当前的工作目录中 - 使用
git stash drop
,后面加上stash名,可以移除相应的stash;或者使用git stash clear
清空所有stash
默认情况下,git stash
会缓存:
- 添加到暂存区的修改(staged changes )
- Git跟踪但并未添加到暂存区的修改(unstaged changes)
但不会缓存:
- 在工作目录中新的文件(untracked files)
- 被忽略的文件(ignored files)
此时,使用-u
或者--include-untracked
可以stash untracked 文件;使用-a
或者--all
可以stash当前目录下的所有修改(慎用)
Git 工作流
Git Flow
Git Flow工作流的特点是项目会长期存在两个分支:
- 主分支master:对外发布版本;
- 开发分支develop:日常开发版本;
除了以上两个长期分支外,还会存在三种短期分支,开发完即合入master or develop,然后删除:
- 功能分支(feature branch)
- 补丁分支(hotfix branch)
- 预发分支(release branch)
Git flow的优点是清晰可控,缺点是需要同时维护两个长期分支。且该模式适合于”版本发布”的工作模式,即周期新的产出一个版本,即有特定的发布窗口。但对于“持续发布”,每次代码在master提交都需要进行部署发布的项目就没有意义,因为master和develop分支差别不大,还要维护两个长期版本。
Github Flow
顾名思义,这是GitHub推荐的一种工作流模式。长期只有一个master分支,根据需求从master拉取新分支,开发完成后向master发起一个Pull Request(PR),PR是一个通知,大家一起进行代码的评审和讨论,此过程中也可以不断提交修改,最后PR被接受,合入master,然后进行部署,删除分支,整个流程就结束了。如下图:
和Git Flow相比,正好相反,GitHub Flow适合于“持续发布”,每次代码在master提交都需要进行部署发布的项目。而对于“版本发布”的项目并不合适。
Gitlab Flow
顾名思义,这是GitLab推荐的工作流。它其实是一个Git Flow和GitHub Flow的一个结合。它上游只有一个master分支,且其作为上游分支,根据不同的环境建立不同的分支,所有的修改必须由”上游”向”下游”进行。
- 针对”持续发布”的项目,每个不同的环境建立不同的分支,例如:开发环境:master,预发布:pre_release,真实环境:online等等。
- 针对”版本发布”的项目,除了master分支外,针对稳定版本拉取一个分支,例如proj_stable_1.1。
所有的修改都必须先在上游master进行修复,然后合入对应的分支。这样GitLab Flow就可以很好的支持Git Flow和GitHub Flow。
Git 工具
Rewriting History
Signing Your Work
Submodules
Git Submodule 实质上是 Git 的包管理器,它允许你将一个 Git 仓库作为另一个 Git 仓库的子目录。 它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立。
开始使用子模块
我们将要演示如何在一个被分成一个主项目与几个子项目的项目上开发。
我们首先将一个已存在的 Git 仓库添加为正在工作的仓库的子模块。 你可以通过在 git submodule add
命令后面加上想要跟踪的项目的相对或绝对 URL 来添加新的子模块。 在本例中,我们将会添加一个名为 “DbConnector” 的库。
1 | $ git submodule add https://github.com/chaconinc/DbConnector |
默认情况下,子模块会将子项目放到一个与仓库同名的目录中,本例中是 “DbConnector”。 如果你想要放到其他地方,那么可以在命令结尾添加一个不同的路径。
如果这时运行 git status
,你会注意到几件事。
1 | $ git status |
首先应当注意到新的 .gitmodules
文件。 该配置文件保存了项目 URL 与已经拉取的本地目录之间的映射:
1 | [submodule "DbConnector"] |
如果有多个子模块,该文件中就会有多条记录。 要重点注意的是,该文件也像 .gitignore
文件一样受到(通过)版本控制。 它会和该项目的其他部分一同被拉取推送。 这就是克隆该项目的人知道去哪获得子模块的原因。
由于 .gitmodules
文件中的 URL 是人们首先尝试克隆/拉取的地方,因此请尽可能确保你使用的 URL 大家都能访问。 例如,若你要使用的推送 URL 与他人的拉取 URL 不同,那么请使用他人能访问到的 URL。 你也可以根据自己的需要,通过在本地执行 git config submodule.DbConnector.url <私有URL>
来覆盖这个选项的值。 如果可行的话,一个相对路径会很有帮助。
在 git status
输出中列出的另一个是项目文件夹记录。 如果你运行 git diff
,会看到类似下面的信息:
1 | $ git diff --cached DbConnector |
虽然 DbConnector
是工作目录中的一个子目录,但 Git 还是会将它视作一个子模块。当你不在那个目录中时,Git 并不会跟踪它的内容, 而是将它看作子模块仓库中的某个具体的提交。
如果你想看到更漂亮的差异输出,可以给 git diff
传递 --submodule
选项。
1 | $ git diff --cached --submodule |
当你提交时,会看到类似下面的信息:
1 | $ git commit -am 'added DbConnector module' |
注意 DbConnector
记录的 160000
模式。 这是 Git 中的一种特殊模式,它本质上意味着你是将一次提交记作一项目录记录的,而非将它记录成一个子目录或者一个文件。
最后,推送这些更改:
1 | $ git push origin master |
克隆含有子模块的项目
接下来我们将会克隆一个含有子模块的项目。 当你在克隆这样的项目时,默认会包含该子模块目录,但其中还没有任何文件:
1 | $ git clone https://github.com/chaconinc/MainProject |
其中有 DbConnector
目录,不过是空的。 你必须运行两个命令:git submodule init
用来初始化本地配置文件,而 git submodule update
则从该项目中抓取所有数据并检出父项目中列出的合适的提交。
1 | $ git submodule init |
现在 DbConnector
子目录是处在和之前提交时相同的状态了。
不过还有更简单一点的方式。 如果给 git clone
命令传递 --recurse-submodules
选项,它就会自动初始化并更新仓库中的每一个子模块, 包括可能存在的嵌套子模块。
1 | $ git clone --recurse-submodules https://github.com/chaconinc/MainProject |
如果你已经克隆了项目但忘记了 --recurse-submodules
,那么可以运行 git submodule update --init
将 git submodule init
和 git submodule update
合并成一步。如果还要初始化、抓取并检出任何嵌套的子模块, 请使用简明的 git submodule update --init --recursive
。
在包含子模块的项目上工作
现在我们有一份包含子模块的项目副本,我们将会同时在主项目和子模块项目上与队员协作。
从子模块的远端拉取上游修改
在项目中使用子模块的最简模型,就是只使用子项目并不时地获取更新,而并不在你的检出中进行任何更改。 我们来看一个简单的例子。
如果想要在子模块中查看新工作,可以进入到目录中运行 git fetch
与 git merge
,合并上游分支来更新本地代码。
1 | $ git fetch |
如果你现在返回到主项目并运行 git diff --submodule
,就会看到子模块被更新的同时获得了一个包含新添加提交的列表。 如果你不想每次运行 git diff
时都输入 --submodle
,那么可以将 diff.submodule
设置为 “log” 来将其作为默认行为。
1 | $ git config --global diff.submodule log |
如果在此时提交,那么你会将子模块锁定为其他人更新时的新代码。
如果你不想在子目录中手动抓取与合并,那么还有种更容易的方式。 运行 git submodule update --remote
,Git 将会进入子模块然后抓取并更新。
1 | $ git submodule update --remote DbConnector |
此命令默认会假定你想要更新并检出子模块仓库的 master
分支。 不过你也可以设置为想要的其他分支。 例如,你想要 DbConnector 子模块跟踪仓库的 “stable” 分支,那么既可以在 .gitmodules
文件中设置 (这样其他人也可以跟踪它),也可以只在本地的 .git/config
文件中设置。 让我们在 .gitmodules
文件中设置它:
1 | $ git config -f .gitmodules submodule.DbConnector.branch stable |
如果不用 -f .gitmodules
选项,那么它只会为你做修改。但是在仓库中保留跟踪信息更有意义一些,因为其他人也可以得到同样的效果。
这时我们运行 git status
,Git 会显示子模块中有“新提交”。
1 | $ git status |
如果你设置了配置选项 status.submodulesummary
,Git 也会显示你的子模块的更改摘要:
1 | $ git config status.submodulesummary 1 |
这时如果运行 git diff
,可以看到我们修改了 .gitmodules 文件,同时还有几个已拉取的提交需要提交到我们自己的子模块项目中。
1 | $ git diff |
这非常有趣,因为我们可以直接看到将要提交到子模块中的提交日志。 提交之后,你也可以运行 git log -p
查看这个信息。
1 | $ git log -p --submodule |
当运行 git submodule update --remote
时,Git 默认会尝试更新 所有 子模块, 所以如果有很多子模块的话,你可以传递想要更新的子模块的名字。
从项目远端拉取上游更改
现在,让我们站在协作者的视角,他有自己的 MainProject
仓库的本地克隆, 只是执行 git pull
获取你新提交的更改还不够:
1 | $ git pull |
默认情况下,git pull
命令会递归地抓取子模块的更改,如上面第一个命令的输出所示。 然而,它不会 更新 子模块。这点可通过 git status
命令看到,它会显示子模块“已修改”,且“有新的提交”。 此外,左边的尖括号(<)指出了新的提交,表示这些提交已在 MainProject 中记录,但尚未在本地的 DbConnector
中检出。 为了完成更新,你需要运行 git submodule update
:
1 | $ git submodule update --init --recursive |
请注意,为安全起见,如果 MainProject 提交了你刚拉取的新子模块,那么应该在 git submodule update
后面添加 --init
选项,如果子模块有嵌套的子模块,则应使用 --recursive
选项。
如果你想自动化此过程,那么可以为 git pull
命令添加 --recurse-submodules
选项(从 Git 2.14 开始)。 这会让 Git 在拉取后运行 git submodule update
,将子模块置为正确的状态。 此外,如果你想让 Git 总是以 --recurse-submodules
拉取,可以将配置选项 submodule.recurse
设置为 true
(从 Git 2.15 开始可用于 git pull
)。此选项会让 Git 为所有支持 --recurse-submodules
的命令使用该选项(除 clone
以外)。
在为父级项目拉取更新时,还会出现一种特殊的情况:在你拉取的提交中, 可能 .gitmodules
文件中记录的子模块的 URL 发生了改变。 比如,若子模块项目改变了它的托管平台,就会发生这种情况。 此时,若父级项目引用的子模块提交不在仓库中本地配置的子模块远端上,那么执行 git pull --recurse-submodules
或 git submodule update
就会失败。 为了补救,git submodule sync
命令需要:
1 | # 将新的 URL 复制到本地配置中 |
在子模块上工作
你很有可能正在使用子模块,因为你确实想在子模块中编写代码的同时,还想在主项目上编写代码(或者跨子模块工作)。 否则你大概只能用简单的依赖管理系统(如 Maven 或 Rubygems)来替代了。
现在我们将通过一个例子来演示如何在子模块与主项目中同时做修改,以及如何同时提交与发布那些修改。
到目前为止,当我们运行 git submodule update
从子模块仓库中抓取修改时, Git 将会获得这些改动并更新子目录中的文件,但是会将子仓库留在一个称作“游离的 HEAD”的状态。 这意味着没有本地工作分支(例如 “master” )跟踪改动。 如果没有工作分支跟踪更改,也就意味着即便你将更改提交到了子模块,这些更改也很可能会在下次运行 git submodule update
时丢失。如果你想要在子模块中跟踪这些修改,还需要一些额外的步骤。
为了将子模块设置得更容易进入并修改,你需要做两件事。 首先,进入每个子模块并检出其相应的工作分支。 接着,若你做了更改就需要告诉 Git 它该做什么,然后运行 git submodule update --remote
来从上游拉取新工作。 你可以选择将它们合并到你的本地工作中,也可以尝试将你的工作变基到新的更改上。
首先,让我们进入子模块目录然后检出一个分支。
1 | $ cd DbConnector/ |
然后尝试用 “merge” 选项来更新子模块。 为了手动指定它,我们只需给 update
添加 --merge
选项即可。 这时我们将会看到服务器上的这个子模块有一个改动并且它被合并了进来。
1 | $ cd .. |
如果我们进入 DbConnector 目录,可以发现新的改动已经合并入本地 stable
分支。 现在让我们看看当我们对库做一些本地的改动而同时其他人推送另外一个修改到上游时会发生什么。
1 | $ cd DbConnector/ |
如果我们现在更新子模块,就会看到当我们在本地做了更改时上游也有一个改动,我们需要将它并入本地。
1 | $ cd .. |
如果你忘记 --rebase
或 --merge
,Git 会将子模块更新为服务器上的状态。并且会将项目重置为一个游离的 HEAD 状态。
1 | $ git submodule update --remote |
即便这真的发生了也不要紧,你只需回到目录中再次检出你的分支(即还包含着你的工作的分支)然后手动地合并或变基 origin/stable
(或任何一个你想要的远程分支)就行了。
如果你没有提交子模块的改动,那么运行一个子模块更新也不会出现问题,此时 Git 会只抓取更改而并不会覆盖子模块目录中未保存的工作。
1 | $ git submodule update --remote |
如果你做了一些与上游改动冲突的改动,当运行更新时 Git 会让你知道。
1 | $ git submodule update --remote --merge |
你可以进入子模块目录中然后就像平时那样修复冲突。
发布子模块改动
现在我们的子模块目录中有一些改动。 其中有一些是我们通过更新从上游引入的,而另一些是本地生成的,由于我们还没有推送它们,所以对任何其他人都不可用。
1 | $ git diff |
如果我们在主项目中提交并推送但并不推送子模块上的改动,其他尝试检出我们修改的人会遇到麻烦, 因为他们无法得到依赖的子模块改动。那些改动只存在于我们本地的拷贝中。
为了确保这不会发生,你可以让 Git 在推送到主项目前检查所有子模块是否已推送。 git push
命令接受可以设置为 “check” 或 “on-demand” 的 --recurse-submodules
参数。 如果任何提交的子模块改动没有推送那么 “check” 选项会直接使 push
操作失败。
1 | $ git push --recurse-submodules=check |
如你所见,它也给我们了一些有用的建议,指导接下来该如何做。 最简单的选项是进入每一个子模块中然后手动推送到远程仓库,确保它们能被外部访问到,之后再次尝试这次推送。 如果你想要对所有推送都执行检查,那么可以通过设置 git config push.recurseSubmodules check
让它成为默认行为。
另一个选项是使用 “on-demand” 值,它会尝试为你这样做。
1 | $ git push --recurse-submodules=on-demand |
如你所见,Git 进入到 DbConnector 模块中然后在推送主项目前推送了它。 如果那个子模块因为某些原因推送失败,主项目也会推送失败。 你也可以通过设置 git config push.recurseSubmodules on-demand
让它成为默认行为。
合并子模块改动
如果你其他人同时改动了一个子模块引用,那么可能会遇到一些问题。 也就是说,如果子模块的历史已经分叉并且在父项目中分别提交到了分叉的分支上,那么你需要做一些工作来修复它。
如果一个提交是另一个的直接祖先(一个快进式合并),那么 Git 会简单地选择之后的提交来合并,这样没什么问题。
不过,Git 甚至不会尝试去进行一次简单的合并。 如果子模块提交已经分叉且需要合并,那你会得到类似下面的信息:
1 | $ git pull |
所以本质上 Git 在这里指出了子模块历史中的两个分支记录点已经分叉并且需要合并。 它将其解释为 “merge following commits not found” (未找到接下来需要合并的提交),虽然这有点令人困惑,不过之后我们会解释为什么是这样。
为了解决这个问题,你需要弄清楚子模块应该处于哪种状态。 奇怪的是,Git 并不会给你多少能帮你摆脱困境的信息,甚至连两边提交历史中的 SHA-1 值都没有。 幸运的是,这很容易解决。 如果你运行 git diff
,就会得到试图合并的两个分支中记录的提交的 SHA-1 值。
1 | $ git diff |
所以,在本例中,eb41d76
是我们的子模块中大家共有的提交,而 c771610
是上游拥有的提交。 如果我们进入子模块目录中,它应该已经在 eb41d76
上了,因为合并没有动过它。 如果不是的话,无论什么原因,你都可以简单地创建并检出一个指向它的分支。
来自另一边的提交的 SHA-1 值比较重要。 它是需要你来合并解决的。 你可以尝试直接通过 SHA-1 合并,也可以为它创建一个分支然后尝试合并。 我们建议后者,哪怕只是为了一个更漂亮的合并提交信息。
所以,我们将会进入子模块目录,基于 git diff
的第二个 SHA-1 创建一个分支然后手动合并。
1 | $ cd DbConnector |
我们在这儿得到了一个真正的合并冲突,所以如果想要解决并提交它,那么只需简单地通过结果来更新主项目。
1 | $ vim src/main.c (1) |
- 首先解决冲突
- 然后返回到主项目目录中
- 再次检查 SHA-1 值
- 解决冲突的子模块记录
- 提交我们的合并
这可能会让你有点儿困惑,但它确实不难。
有趣的是,Git 还能处理另一种情况。 如果子模块目录中存在着这样一个合并提交,它的历史中包含了的两边的提交,那么 Git 会建议你将它作为一个可行的解决方案。 它看到有人在子模块项目的某一点上合并了包含这两次提交的分支,所以你可能想要那个。
这就是为什么前面的错误信息是 “merge following commits not found”,因为它不能 这样 做。 它让人困惑是因为谁能想到它会尝试这样做?
如果它找到了一个可以接受的合并提交,你会看到类似下面的信息:
1 | $ git merge origin/master |
Git 建议的命令是更新索引,就像你运行了 git add
那样,这样会清除冲突然后提交。 不过你可能不应该这样做。你可以轻松地进入子模块目录,查看差异是什么,快进到这次提交,恰当地测试,然后提交它。
1 | $ cd DbConnector/ |
这些命令完成了同一件事,但是通过这种方式你至少可以验证工作是否有效,以及当你在完成时可以确保子模块目录中有你的代码。
子模的块技巧
你可以做几件事情来让用子模块工作轻松一点儿。
子模块遍历
有一个 foreach
子模块命令,它能在每一个子模块中运行任意命令。 如果项目中包含了大量子模块,这会非常有用。
例如,假设我们想要开始开发一项新功能或者修复一些错误,并且需要在几个子模块内工作。 我们可以轻松地保存所有子模块的工作进度。
1 | $ git submodule foreach 'git stash' |
然后我们可以创建一个新分支,并将所有子模块都切换过去。
1 | $ git submodule foreach 'git checkout -b featureA' |
你应该明白。 能够生成一个主项目与所有子项目的改动的统一差异是非常有用的。
1 | $ git diff; git submodule foreach 'git diff' |
在这里,我们看到子模块中定义了一个函数并在主项目中调用了它。 这明显是个简化了的例子,但是希望它能让你明白这种方法的用处。
有用的别名
你可能想为其中一些命令设置别名,因为它们可能会非常长而你又不能设置选项作为它们的默认选项。 我们在 Git 别名 介绍了设置 Git 别名, 但是如果你计划在 Git 中大量使用子模块的话,这里有一些例子。
1 | $ git config alias.sdiff '!'"git diff && git submodule foreach 'git diff'" |
这样当你想要更新子模块时可以简单地运行 git supdate
,或 git spush
检查子模块依赖后推送。
子模块的问题
然而使用子模块还是有一些小问题。
切换分支
例如,使用 Git 2.13 以前的版本时,在有子模块的项目中切换分支可能会造成麻烦。 如果你创建一个新分支,在其中添加一个子模块,之后切换到没有该子模块的分支上时,你仍然会有一个还未跟踪的子模块目录。
1 | $ git --version |
移除那个目录并不困难,但是有一个目录在那儿会让人有一点困惑。 如果你移除它然后切换回有那个子模块的分支,需要运行 submodule update --init
来重新建立和填充。
1 | $ git clean -fdx |
再说一遍,这真的不难,只是会让人有点儿困惑。
新版的 Git(>= 2.13)通过为 git checkout
命令添加 --recurse-submodules
选项简化了所有这些步骤, 它能为了我们要切换到的分支让子模块处于的正确状态。
1 | $ git --version |
当你在父级项目的几个分支上工作时,对 git checkout
使用 --recurse-submodules
选项也很有用, 它能让你的子模块处于不同的提交上。确实,如果你在记录了子模块的不同提交的分支上切换, 那么在执行 git status
后子模块会显示为“已修改”并指出“新的提交”。 这是因为子模块的状态默认不会在切换分支时保留。
这点非常让人困惑,因此当你的项目中拥有子模块时,可以总是使用 git checkout --recurse-submodules
。 (对于没有 --recurse-submodules
选项的旧版 Git,在检出之后可使用 git submodule update --init --recursive
来让子模块处于正确的状态)。
幸运的是,你可以通过 git config submodule.recurse true
设置 submodule.recurse
选项, 告诉 Git(>=2.14)总是使用 --recurse-submodules
。 如上所述,这也会让 Git 为每个拥有 --recurse-submodules
选项的命令(除了 git clone
) 总是递归地在子模块中执行。
从子目录切换到子模块
另一个主要的告诫是许多人遇到了将子目录转换为子模块的问题。 如果你在项目中已经跟踪了一些文件,然后想要将它们移动到一个子模块中,那么请务必小心,否则 Git 会对你发脾气。 假设项目内有一些文件在子目录中,你想要将其转换为一个子模块。 如果删除子目录然后运行 submodule add
,Git 会朝你大喊:
1 | $ rm -Rf CryptoLibrary/ |
你必须要先取消暂存 CryptoLibrary
目录。 然后才可以添加子模块:
1 | git rm -r CryptoLibrary |
现在假设你在一个分支下做了这样的工作。 如果尝试切换回的分支中那些文件还在子目录而非子模块中时——你会得到这个错误:
1 | $ git checkout master |
你可以通过 checkout -f
来强制切换,但是要小心,如果其中还有未保存的修改,这个命令会把它们覆盖掉。
1 | $ git checkout -f master |
当你切换回来之后,因为某些原因你得到了一个空的 CryptoLibrary
目录,并且 git submodule update
也无法修复它。 你需要进入到子模块目录中运行 git checkout .
来找回所有的文件。 你也可以通过 submodule foreach
脚本来为多个子模块运行它。
要特别注意的是,近来子模块会将它们的所有 Git 数据保存在顶级项目的 .git
目录中,所以不像旧版本的 Git,摧毁一个子模块目录并不会丢失任何提交或分支。
拥有了这些工具,使用子模块会成为可以在几个相关但却分离的项目上同时开发的相当简单有效的方法。
Git Configuration
Git Hooks
Git Internal
Git常用命令共有30多个,可运行git help
查看;但Git总共有130多个命令,可以通过git help -a
查看,这些命令可以分为高层命令和底层命令,底层命令被设计成unix风格,不常用
Git仓库下有一个.git目录,里面存储了git全部的秘密,一般包括下面的内容:
- config
- index
- HEAD
- hooks/
- logs/
- refs/
- objects/
下面会详细介绍没个部分都是什么
config
config是仓库的配置文件,一个典型的配置文件如下,我们创建的远端,分支都在配置文件里有表现; fetch操作的行为也是在这里配置的
1 | [core] |
objects
git通过一种算法可以得到任意文件的指纹(40位16进制数字),然后通过文件指纹存取数据,存取的数据都位于objects目录
Git常用命令共有30多个,可运行
git help
查看;但Git总共有130多个命令,可以通过git help -a
查看,这些命令可以分为高层命令和底层命令,底层命令被设计成unix风格,不常用Git仓库下有一个.git目录,里面存储了git全部的秘密,一般包括下面的内容:
- config
- index
- HEAD
- hooks/
- logs/
- refs/
- objects/
下面会详细介绍没个部分都是什么
config
config是仓库的配置文件,一个典型的配置文件如下,我们创建的远端,分支都在配置文件里有表现; fetch操作的行为也是在这里配置的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16[core]
repositoryformatversion = 0
filemode = false
bare = false
logallrefupdates = true
symlinks = false
ignorecase = true
[remote "origin"]
url = git@github.com:yanhaijing/zepto.fullpage.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
[branch "dev"]
remote = origin
merge = refs/heads/devobjects
git通过一种算法可以得到任意文件的指纹(40位16进制数字),然后通过文件指纹存取数据,存取的数据都位于objects目录
Git 从核心上来看不过是简单地存储键值对(key-value)。它允许插入任意类型的内容,并会返回一个键值,通过该键值可以在任何时候再取出该内容。Git存储的索引内容包含三种对象:
- commit对象:每次提交都会至少产生一个commit对象,它的内容包括:指向parent commit对象,根tree对象。
- tree对象:类似于目录,tree对象中包含多条记录,每条记录保存了本次快照的所有tree对象和blob对象。
- blob对象:类似于文件,保存具体的文件内容。
objects目录下有3种类型的数据:
- Blob
- Tree
- Commit
文件都被存储为blob类型的文件,可以通过内部命令
hash-object
写入数据1
2echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4然后通过
cat-file
取出数据1
2$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content文件夹被存储为tree类型的文件,文件内容如下所示
1
2git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0
100644 blob 47c6340d6459e05787f644c2447d2595f5d3a54b simplegit.rb一般我们系统中的目录,在git中会像下面这样存储
创建的提交节点被存储为commit类型数据,commit文件的内容如下
1
2
3
4
5
6$ git cat-file -p fdf4fc3
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Scott Chacon <schacon@gmail.com> 1243040974 -0700
committer Scott Chacon <schacon@gmail.com> 1243040974 -0700
first commit有三个提交的Git仓库可简化为下图所示
refs
refs目录存储都是引用文件,如本地分支,远端分支,标签等
- refs/heads/xxx 本地分支
- refs/remotes/origin/xxx 远端分支
- refs/tags/xxx 本地tag
引用文件的内容都是40位commit
上面只有提交的图补上分支后,如下所示
HEAD
HEAD文件存储的是当前所在的位置,其内容可以使分支名字,40位commit ID
1
2$ cat HEAD
refs/heads/master上面的图补上HEAD后,如下所示:
文件都被存储为blob类型的文件,可以通过内部命令hash-object
写入数据
1 | echo 'test content' | git hash-object -w --stdin |
然后通过cat-file
取出数据
1 | $ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4 |
文件夹被存储为tree类型的文件,文件内容如下所示
1 | git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0 |
一般我们系统中的目录,在git中会像下面这样存储
创建的提交节点被存储为commit类型数据,commit文件的内容如下
1 | $ git cat-file -p fdf4fc3 |
有三个提交的Git仓库可简化为下图所示
refs
refs目录存储都是引用文件,如本地分支,远端分支,标签等
- refs/heads/xxx 本地分支
- refs/remotes/origin/xxx 远端分支
- refs/tags/xxx 本地tag
引用文件的内容都是40位commit
上面只有提交的图补上分支后,如下所示
HEAD
HEAD文件存储的是当前所在的位置,其内容可以使分支名字,40位commit ID
1 | $ cat HEAD |
上面的图补上HEAD后,如下所示:
参考资料
- https://github.com/git-school/visualizing-git
- https://marklodato.github.io/visual-git-guide/index-zh-cn.html
- https://www.cnblogs.com/qcloud1001/p/10006556.html
- https://dev.to/lydiahallie/cs-visualized-useful-git-commands-37p1
- https://juejin.im/post/5c714d18f265da2d98090503
- https://yanhaijing.com/git/2017/02/08/deep-git-3/
- http://walkerdu.com/2019/11/25/git_basic/