Pro Git 读后感¶
接触git已有了一坤年 🐔,但一致停留在所谓的“表层学习”,多少有些“为了用而学”,并没有深入理解git设计的精髓与哲学。
在开发初期,由于经验、水平等的限制,我们不会对git有太多的要求,因此有时候跟着chatgpt之类的“点对点教学”即可。在此之前,我也在博客里写了不少关于Git的开发简介,不过还是零散了些,并且当年太过稚嫩,有些东西只是学会了,并没有理解背后设计的哲学与奥妙。
但随着项目的不断扩大,git的使用也会变得越来越复杂,此时,如果我们对git的理解还停留在“表层”,开发效率将会大大降低 🤡
在这个背景下,随着很多细碎的知识点已经掌握,我决定将git的完整教程过一遍(这里会按顺序整理一些常用的操作),旨在脉络清晰、逻辑贯通 🚀
我选择的材料是 Pro Git 中文版(第二版),本文将有大量实例来源于这本书,在此致谢 🎉
起始点¶
什么是VCS¶
版本控制: 记录一个或若干文件内容变化,以便将来查阅特定版本修订情况的系统。
采用版本控制系统(VCS, Version Control System)是个明智的选择:
- 有了它我们就可以将某个文件回溯到之前的状态,甚至将整个项目都回退到过去某个时间点的状态
- 我们可以比较文件的变化细节,查出最后是谁修改了哪个地方,从而找出导致怪异问题出现的原因
- 又是谁在何时报告了某个功能缺陷
使用版本控制系统通常还意味着,就算我们喝多了,把整个项目中的文件进行不过脑子地删改;事后我们也照样可以轻松恢复到原先的模样,额外增加的工作量微乎其微✌️
分布式版本控制系统¶
git是一种非常典型的 分布式版本控制系统 (Distributed Version Control System, DVCS)
DVCS的特点是“分布式”和“去中心化”,学过dSDN的都知道,这样的好处是防止中心单点遇害导致整体数据全部报废
那么基于“分布式”的特征,我们可以显而易见地看出:
客户端并不只提取最新版本的文件快照,而是把代码仓库完整地镜像下来。如此,任何一处协同工作用的服务器发生故障,事后都可以用任何一个镜像出来的本地仓库恢复。因为每一次的克隆操作,实际上都是一次对代码仓库的完整备份 🚀
更进一步,许多这类系统都可以指定和若干不同的远端代码仓库进行交互。
Git的设计哲学¶
直接记录快照,而非差异比较
Git 更像是把数据看作是对小型文件系统的一组快照。每次我们提交更新,或在 Git 中保存项目状态时,它主要对当时的全部文件制作一个快照并保存这个快照的索引。
为了高效,如果文件没有修改,Git 不再重新存储该文件,而是只保留一个链接指向之前存储的文件。Git 对待数据更像是一个 快照流。
近乎所有操作都是本地执行
意味着我们离线或者没有 VPN 时,几乎可以进行任何操作。 如我们在飞机或火车上想做些工作,我们能愉快地提交,直到有网络连接时再上传。
整个操作交互,只有“上传到远程仓库”这一步才需要网络连接。
Git使用SHA-1哈希值保证数据完整性
这个地方分成两个层次:
1) Git保证数据完成性
Git 中所有数据在存储前都计算校验和,然后以校验和来引用。这意味着不可能在 Git 不知情时更改任何文件内容或目录内容。
这个功能建构在 Git 底层,是构成 Git 哲学不可或缺的部分。若我们在传送过程中丢失信息或损坏文件,Git 就能发现。
2) Git采用SHA-1哈希值进行“文件链接”
Text Only | |
---|---|
1 |
|
Git 中使用这种哈希值的情况很多,我们将经常看到这种哈希值。实际上,Git 数据库中保存的信息都是以文件内容的哈希值来索引,而不是文件名。
这就可以解释在VSCode的Git Graph插件中,如果我们的操作是“恢复到先前的某个commit”,我们需要:
Bash | |
---|---|
1 |
|
这里的 commit-hash
可以这样获得 (Copy Commit Hash to Clipboard
):
Git 基础¶
获取Git仓库¶
在现有目录中初始化仓库
不管是对“空文件夹”还是“现有的未被git初始化的文件夹”,我们都只需要进入该项目目录并输入:
Bash | |
---|---|
1 |
|
此时,我们仅仅是做了一个初始化的操作,我们的项目里的文件还没有被跟踪🧐
克隆现有的仓库
Bash | |
---|---|
1 2 3 4 |
|
记录每次更新到仓库¶
特此说明:我们工作目录下的每一个文件都不外乎这两种状态:已跟踪(tracked
)或未跟踪(untracked
)。
- 已跟踪的文件是指那些被纳入了版本控制的文件,在上一次快照中有它们的记录,在工作一段时间后,它们的状态可能处于:
- 未修改
- 已修改
- 已放入暂存区
- 工作目录中除已跟踪文件以外的所有其它文件都属于未跟踪文件,它们既不存在于上次快照的记录中,也没有放入暂存区。
很显然,初次克隆某个仓库的时候,工作目录中的所有文件都属于已跟踪文件,并处于未修改状态。
编辑过某些文件之后,由于自上次提交后我们对它们做了修改(modefied
),Git 将它们标记为已修改文件。 我们逐步将这些修改过的文件放入暂存区(staged
),然后提交(commit
)所有暂存了的修改,如此反复。所以使用 Git 时文件的生命周期如下:
我们会紧接着介绍add / commit / push / stash
这些常用指令与各个状态的对应关系🍺
检查当前文件状态
Bash | |
---|---|
1 |
|
这时会有类似的输出:
Bash | |
---|---|
1 2 3 |
|
说明现在的工作目录相当干净。换句话说,所有已跟踪文件在上次提交后都未被更改过。
此外,上面的信息还表明,当前目录下没有出现任何处于未跟踪状态的新文件,否则 Git 会在这里列出来。
最后,该命令还显示了当前所在分支,现在的分支名是 master
,这也是默认的分支名。
Bash | |
---|---|
1 2 3 4 5 6 7 |
|
可以看到:新建的 README
文件出现在 Untracked files
下面。
未跟踪的文件意味着 Git 在之前的快照(提交)中没有这些文件;Git 不会自动将之纳入跟踪范围 ,除非开发者明明白白地告诉它“我需要跟踪该文件”。
如果我需要将它纳入“追踪范围”,则需要使用 git add
指令。
跟踪新文件
使用命令 git add
开始跟踪一个文件。 所以,要跟踪 README 文件,运行:
Bash | |
---|---|
1 |
|
此时再运行 git status 命令,会看到 README 文件已被跟踪,并处于暂存状态:
Bash | |
---|---|
1 2 3 4 5 |
|
只要在 Changes to be committed
这行下面的,就说明是已暂存状态。
add
After git add
, 相应文件会:
- 被追踪 (
tracked
) - 被放入暂存区 (
stage
)
这里还需要说明的是:如果添加的是文件名,说明本次添加的是一个文件;如果是路径,说明 本次添加的是一个目录,它会递归添加这个目录下的所有文件系统。
假设你有一个目录结构如下:
Text Only | |
---|---|
1 2 3 4 5 |
|
- 如运行
git add file1.txt
,那么只有file1.txt
文件的修改会被添加到暂存区。 - 如运行
git add project/
,那么 Git 会自动递归地将project/
目录下的所有文件(包括file2.txt
和subfolder/file3.txt
)添加到暂存区。
暂存已修改文件
依然是建立在之前的步骤上,我们现在已经add README
了
现在我们来修改一个已被跟踪的文件。 如果你修改了一个名为 CONTRIBUTING.md
的已被跟踪的文件,然后运行 git status
命令,会看到下面内容:
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 |
|
文件 CONTRIBUTING.md
出现在 Changes not staged for commit
这行下面,说明已跟踪文件的内容发生了变化,但还没有放到暂存区。
要暂存这次更新,需要运行 git add
命令:
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
现在两个文件都已暂存,下次提交时就会一并记录到仓库✌️
假设此时,你想要在 CONTRIBUTING.md
里再加条注释,重新编辑存盘后,准备好提交。不过且慢,再运行 git status
看看:
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
怎么回事?现在 CONTRIBUTING.md
文件同时出现在暂存区和非暂存区。
这怎么可能呢? 实际上: Git 只不过暂存了你运行 git add 命令时的版本。
如果你现在提交,CONTRIBUTING.md
的版本是你最后一次运行 git add
命令时的那个版本,而不是工作目录中的当前版本。
所以,运行了 git add
之后又作了修订的文件,需要重新运行 git add
把最新版本重新暂存起来!
忽略文件
一般我们总会有些文件无需纳入 Git 的管理,也不希望它们总出现在未跟踪文件列表。
通常都是些自动生成的文件,比如日志文件,或者编译过程中创建的临时文件等。
文件 .gitignore 的格式规范如下:
- 所有空行或者以
#
开头的行都会被 Git 忽略 - 可以使用标准的 glob 模式匹配(标准化正则表达式)
- 匹配模式可以以(
/
)开头防止递归 - 匹配模式可以以(
/
)结尾指定目录 - 要忽略指定模式以外的文件或目录,可以在模式前加上惊叹号(
!
)取反
Text Only | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
提交更新
git commit
这种方式会启动文本编辑器以便输入本次提交的说明。
切换默认文本编辑器
默认会启用 shell 的环境变量 $EDITOR
所指定的软件,一般都是 vim
或 emacs
。当然也可以使用 git config --global core.editor
命令设定你喜欢的编辑软件
比如我想设置成vim:
Bash | |
---|---|
1 |
|
在 commit
命令后添加 -m
选项,将提交信息与命令放在同一行,如下所示:
Bash | |
---|---|
1 2 3 4 |
|
跳过使用暂存区域,一键暂存并提交
每次都要 add / commit
不累吗🐶
有个懒人狂喜的方式:
Bash | |
---|---|
1 |
|
-a -m
只要在提交的时候,给 git commit
加上 -a
选项,Git 就会自动把所有已经跟踪过的文件暂存起来一并提交,从而跳过 git add
步骤!
删除文件
这里的“删除”存在两个维度,需要因地制宜:
- 第一个是:“让git不再追踪”
- 第二个是:“让文件在硬盘上消失”
如果需要:git不追踪 + 硬盘消失
仅仅 rm -rf xxx
是不够的,因为此时硬盘无此文件,git仍在追踪,这实际上会导致错误
正确写法:
Bash | |
---|---|
1 2 3 |
|
如果需要:git不追踪 + 硬盘仍存在
实际使用场景
我们想把文件从 Git 仓库中删除(亦即从暂存区域移除),但仍然希望保留在当前工作目录中。换句话说,你想让文件保留在磁盘,但是并不想让 Git 继续跟踪。
这里有一个非常非常实用的场景!!!
当你忘记添加 .gitignore
文件,不小心把一个很大的日志文件或一堆 .a
这样的编译生成文件添加到暂存区时,这一做法尤其有用 💰
为达到这一目的,使用 --cached
选项:
Bash | |
---|---|
1 2 3 4 5 |
|
glob
mode
glob
是一种用于匹配文件路径模式的技术,通常与文件系统中的文件和目录名相关联。它的全称是 global
,意味着它在文件系统中“全局”地匹配特定的模式。
它类似于正则表达式,但更简单,专门用于文件路径和名称的匹配。
*
:匹配任意数量的字符(包括零个字符)
例如,*.txt
会匹配所有扩展名为 .txt
的文件(如 file.txt
,data.txt
)
?
:匹配一个任意字符
例如,file?.txt
会匹配 file1.txt
,fileA.txt
等,但不匹配 file10.txt
[]
:匹配字符集中的一个字符
例如,file[1-3].txt
会匹配 file1.txt
,file2.txt
,file3.txt
{}
:匹配多个选项中的一个
例如,file{1,2,3}.txt
会匹配 file1.txt
,file2.txt
和 file3.txt
文件重命名
基本上跟shell中的mv
智能识别一样:
Bash | |
---|---|
1 |
|
运行 git mv
的本质是相当于运行了下面三条命令:
Bash | |
---|---|
1 2 3 |
|
此时查看状态信息,可以清晰地看到关于重命名操作的说明:
Text Only | |
---|---|
1 2 3 4 5 6 7 |
|
查看提交历史
这里我们直接提供现代化工具:git graph
可以很清晰地看出commit树杈,它还有很多功能值得探索!
示例:
撤消操作¶
有时候我们提交完了才发现漏掉了几个文件没有添加,或者提交信息写错了。 此时,可以运行带有 --amend
选项的提交命令尝试重新提交:
Bash | |
---|---|
1 |
|
这个命令会将暂存区中的文件提交。如果自上次提交以来你还未做任何修改(例如,在上次提交后马上执行了此命令),那么快照会保持不变,而你所修改的只是提交信息。
我是超级马大哈,因此这个指令对我来说使用的相当频繁🤡
解析一下:
amend 意思是“修正”或“修改”
- 覆盖原提交:
git commit --amend
会用新的提交覆盖旧的提交,这意味着旧的提交会丢失,只保留修改后的提交。- 它会重新创建一个新的提交对象,即使提交内容没有变化,提交的哈希值也会发生变化。
- 只适用于未推送的提交 :在提交被推送到远程仓库之前,使用
git commit --amend
是安全的。- 如果你已经将提交推送到远程仓库,修改历史提交会导致问题,因为它会改变提交的哈希值,并可能会影响其他协作成员的工作。
撤销指令
Bash | |
---|---|
1 |
|
用处1: 修改上次笔误的commit信息
无需多言
用处2: 修改上次commit的文件集合
例如,你提交后发现忘记了暂存某些需要的修改,可以像下面这样操作:
Bash | |
---|---|
1 2 3 |
|
最终你只会有一个提交 - 第二次提交将代替第一次提交的结果!
远程仓库的使用¶
查看当前仓库有哪些远程备胎
如果你的远程仓库不止一个,该命令会将它们全部列出。 例如,与几个协作者合作的,拥有多个远程仓库的仓库看起来像下面这样:
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 |
|
一般我们把自己的最直接/最频繁使用的开发仓库,约定俗成地命名成 origin
添加远程仓库
Bash | |
---|---|
1 2 |
|
远程仓库的移除与重命名
1) 重命名:
如果想要重命名引用的名字可以运行 git remote rename
去修改一个远程仓库的简写名。 例如,想要将 pb
重命名为 paul
,可以用 git remote rename
这样做:
Bash | |
---|---|
1 2 |
|
值得注意的是这同样也会修改你的远程分支名字。那些过去引用 pb/master
的现在会引用 paul/master
。
2) 删除:
Bash | |
---|---|
1 2 |
|
从远程仓库中抓取与拉取
Bash | |
---|---|
1 |
|
这个命令会访问远程仓库,从中拉取所有你还没有的数据。执行完成后,你将会拥有那个远程仓库中所有分支的引用,可以随时合并或查看。
注意 git fetch
命令会将数据拉取到你的本地仓库 - 它并不会自动合并或修改你当前的工作。
因此,当 all the things are ready to go,你必须手动将其合并入(merge
)你的工作!
事实上,pull = fetch + merge
😊 我们会在后面详细说明pull绑定的远程分支是谁 :))
推送到远程仓库
当你想分享你的项目时,必须将其推送到上游。 这个命令很简单:git push [remote-name] [branch-name]
。
当你想要将 master
分支推送到 origin
服务器时(再次说明,克隆时通常会自动帮你设置好那两个名字),那么运行这个命令就可以将你所做的备份到服务器:
Bash | |
---|---|
1 |
|
Tip
Pro-Git 电子书中还提到了“打标签”和“别名”两个章节,由于我的使用频率不高,所以不做整理了
Cheat Sheet¶
git reset --hard <commit-hash>
强制回到某一次commit快照
git config --global core.editor "vim"
设置默认文本编辑器
git config --global --list
查看全局配置信息
git commit -a -m "add and commit ..."
省一次add,直接暂存并提交
git rm --cached README
文件存在,git撤销追踪
git commit --amend
撤回上一次提交
Bash | |
---|---|
1 2 3 |
|
Git 分支¶
分支简介与设计原理¶
- Git 保存的不是文件的变化或者差异,而是一系列不同时刻的 文件快照 。在进行提交操作时,Git 会保存一个提交对象(commit object)。
- 知道了 Git 保存数据的方式,我们可以很自然的想到: 该 提交对象 会包含一个 指向暂存内容快照的指针。
- 不仅仅是这样,该提交对象还包含了作者的姓名和邮箱、提交时输入的信息以及指向它的父对象的指针。
Warning
- 首次提交产生的提交对象没有父对象
- 普通提交操作产生的提交对象有一个父对象
- 而由多个分支合并产生的提交对象有多个父对象
创建新分支
本质上,就是创建一个可以移动的新的指针
Bash | |
---|---|
1 |
|
这会在当前分支(eg. master
)的最新commit上新建“分支”(testing
),注意这只是创建,并不会自动切换到新建的分支
我在哪个分支上
HEAD
就是用来标识自己现在所在的分支的
切换分支
Bash | |
---|---|
1 |
|
比如现在我们切换到刚才新建的testing
分支上:
当你在新建分支(testing
)上做了一次commit后,实际上现在的tree长成这样:
如果你在checkout回master后又做了一次commit,现在你可以清晰地看出,这棵树产生了分叉:
分支切换会改变你工作目录中的文件
在切换分支时,一定要注意你工作目录里的文件会被改变。如果是切换到一个较旧的分支,你的工作目录会恢复到该分支最后一次提交时的样子。如果 Git 不能干净利落地完成这个任务,它将禁止切换分支。
这点一定要注意⚠️,很容易犯错!
想想为什么会这样:
git采用blob对象装每次commit对应的快照(snapshot
),对应的文件当然是不一样的🐛
分支的新建与合并¶
简单的就不重复了,这里就记录点有意思的理论知识:
Fast-Forward
在合并的时候,你应该注意到了 "快进(fast-forward
)" 这个词。 由于当前 master
分支所指向的提交是你当前提交(有关 hotfix
的提交)的直接上游,所以 Git 只是简单的将指针向前移动。 换句话说,当你试图合并两个分支时,如果顺着一个分支走下去能够到达另一个分支,那么 Git 在合并两者的时候,只会简单的将指针向前推进(指针右移),因为这种情况下的合并操作没有需要解决的分歧——这就叫做 “快进(fast-forward
)”。
Bash | |
---|---|
1 2 3 4 5 6 |
|
Diverged-Merge
假设我们处在这样的一个阶段,现在我们想把iss53
合并(merge
)进master
:
Bash | |
---|---|
1 2 3 4 5 6 |
|
注意这里的信息跟上面 Fats-Forward
不一样啊!
在这种情况下,你的开发历史从一个更早的地方开始分叉开来(diverged
)。 因为,master
分支所在提交并不是 iss53
分支所在提交的直接祖先,Git 不得不做一些额外的工作。 出现这种情况的时候,Git 会使用两个分支的末端所指的快照(C4
和 C5
)以及这两个分支的工作祖先(C2
),做一个简单的三方合并:
和之前Fast-Forward直接合并的策略(将分支指针向前推进)所不同的是,Git 将此次三方合并的结果做了一个新的快照并且自动创建一个新的提交指向它。
这个被称作一次合并提交,它的特别之处在于他有不止一个父提交。
需要指出的是,Git 会自行决定选取哪一个提交作为最优的共同祖先,并以此作为合并的基础 🚀
Merge Strategy
- Fast-Forward: 直接快进,相当于指针前移
- Diverged-Merged: 自动找一个最优的共同祖先,将此次三方合并的结果做了一个新的快照并且自动创建一个新的提交指向它
Conflicts when merging
有时候合并操作不会如此顺利。
如果你在两个不同的分支中,对 同一个文件的同一个部分进行了不同的修改 ,Git 就没法干净的合并它们。 如果你对 #53
问题的修改和有关 hotfix
的修改都涉及到同一个文件的同一处,在合并它们的时候就会产生合并冲突:
Bash | |
---|---|
1 2 3 4 |
|
此时 Git 做了合并,但是没有自动地创建一个新的合并提交。
Git 会暂停下来,等待你去解决合并产生的冲突。你可以在合并冲突后的任意时刻使用 git status
命令来查看那些因包含合并冲突而处于未合并(unmerged)状态的文件:
这里最推荐的是,一定要在VSCode中使用Git Graph
插件!
这样的话,此时你需要做的是打开VSCode,左侧菜单栏:
这里的感叹号标识的文件都是有CONFLICTS
的!
点击每个冲突的文件,你可以很清晰地看到哪里出问题了,全部修改完就ok了👌
现在你全部修改完了,已经没有任何的冲突了,需要做的是:
可以输入 git commit
来完成合并提交。默认情况下提交信息类似:
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
分支管理¶
显示本地所有分支
Bash | |
---|---|
1 |
|
显示本地+远程所有分支
Bash | |
---|---|
1 |
|
删除分支
Bash | |
---|---|
1 |
|
这里需要说明的是,当待删除分支没有merge进主分支时,上述 -d
指令会失效🤡
举个例子:
步骤 1: 创建分支并做修改
在 main
分支上,创建一个新分支 feature
:
Bash | |
---|---|
1 |
|
在 feature
分支上做一些修改,提交更改:
Bash | |
---|---|
1 2 3 |
|
假设你还没有把 feature
分支上的更改合并到 main
分支。
步骤 2: 尝试删除未合并的分支
切换回 main
分支:
Bash | |
---|---|
1 2 |
|
此时,你会看到如下的错误提示:
Bash | |
---|---|
1 2 |
|
为什么会这样?
git branch -d
会检查你要删除的分支(在这里是feature
)是否已经完全合并到当前分支(在这里是main
)。由于你在feature
分支上做了修改并提交,但是没有将这些更改合并回main
分支,所以 Git 判断feature
分支上的更改还没有被整合到main
分支中。- 为什么要设置这个机制:
- 为了 防止你丢失在
feature
分支上的更改 ,Git 拒绝删除它,并给出提示,告知你必须先合并(或者使用强制删除)。
- 为了 防止你丢失在
-d
/ -D
git branch -d
:安全删除,要求分支已合并到当前分支。如果未合并,Git 会拒绝删除,以防止丢失更改git branch -D
:强制删除,不会检查是否已合并,可能导致丢失未合并的更改
分支开发工作流¶
长期分支
许多使用 Git 的开发者都喜欢使用这种方式来工作,比如只在 master 分支上保留完全稳定的代码——有可能仅仅是已经发布或即将发布的代码。 他们还有一些名为 develop 或者 next 的平行分支,被用来做后续开发或者测试稳定性——这些分支不必保持绝对稳定,但是一旦达到稳定状态,它们就可以被合并入 master 分支了。 这样,在确保这些已完成的特性分支(短期分支,比如之前的 iss53 分支)能够通过所有测试,并且不会引入更多 bug 之后,就可以合并入主干分支中,等待下一次的发布。
事实上我们刚才讨论的,是随着你的提交而不断右移的指针。 稳定分支的指针总是在提交历史中落后一大截,而前沿分支的指针往往比较靠前。
通常把他们想象成流水线(work silos)可能更好理解一点,那些经过测试考验的提交会被遴选到更加稳定的流水线上去。
特性分支
特性分支是一种短期分支,它被用来实现单一特性或其相关工作。
你已经在上一节中你创建的 iss53
和 hotfix
特性分支中看到过这种用法。你在上一节用到的特性分支(iss53 和 hotfix 分支)中提交了一些更新,并且在它们合并入主干分支之后,你又删除了它们。
这项技术能使你 快速并且完整地进行上下文切换(context-switch
) —— 因为你的工作被分散到不同的流水线中,在不同的流水线中每个分支都仅与其目标特性相关,因此,在做代码审查之类的工作的时候就能更加容易地看出你做了哪些改动。
远程分支¶
我们只介绍“远程追踪分支”,它才是我们日常使用的菜
远程跟踪分支是远程分支状态的引用。它们是你不能移动的本地引用 ,当你做任何网络通信操作时,它们会自动移动。
什么是远程追踪分支
远程跟踪分支像是你上次连接到远程仓库时,那些分支所处状态的书签。它们以 (remote)/(branch)
形式命名。
例如,如果你想要看你最后一次与远程仓库 origin
通信时 master
分支的状态,你可以查看 origin/master
分支。 你与同事合作解决一个问题并且他们推送了一个 iss53
分支,你可能有自己的本地 iss53
分支;但是在服务器上的分支会指向 origin/iss53
的提交。
这可能有一点儿难以理解,让我们来看一个例子。假设你的网络里有一个在 git.ourcompany.com
的 Git 服务器:
- 如果你从这里克隆,Git 的
clone
命令会为你自动将其命名为origin
,拉取它的所有数据 - 创建一个指向它的
master
分支的指针,并且在本地将其命名为origin/master
。 - Git 也会给你一个与
origin
的master
分支在指向同一个地方的本地master
分支,这样你就有工作的基础。
为什么要git fetch
如果你在本地的 master
分支做了一些工作,然而在同一时间,其他人推送提交到 git.ourcompany.com
并更新了它的 master
分支,那么你的提交历史将向不同的方向前进。但是,只要你不与 origin
服务器连接,你的本地 origin/master
指针就不会移动。
如果要同步你的工作,运行 git fetch origin
命令。 这个命令 查找 “origin” 是哪一个服务器(在本例中,它是 git.ourcompany.com
),从中抓取本地没有的数据,并且更新本地数据库 ,移动 origin/master
指针指向新的、更新后的位置。
推送
当我在本地完成了我的部分,很显然我需要推送到远程仓库,让大家一起共享(更改)
Bash | |
---|---|
1 2 |
|
下一次其他协作者从服务器上抓取数据时,他们会在本地生成一个远程分支 origin/serverfix
,指向服务器的 serverfix
分支的引用:
Bash | |
---|---|
1 2 3 4 5 6 7 |
|
引用,无实际数据
要特别注意的一点是:当抓取到新的远程跟踪分支时,本地不会自动生成一份可编辑的副本(拷贝)。
换一句话说,这种情况下,不会有一个新的 serverfix
分支 - 只有一个不可以修改的 origin/serverfix
指针。
fetch之后,现在可以运行 git merge origin/serverfix
将这些工作合并到当前所在的分支。
拉取
当 git fetch
命令从服务器上抓取本地没有的数据时,它并不会修改工作目录中的内容。它只会获取数据然后让你自己合并。
然而,有一个命令叫作 git pull
在大多数情况下它的含义是一个 git fetch
紧接着一个 git merge
命令。
如果有一个像之前章节中演示的设置好的跟踪分支,不管它是显式地设置还是通过 clone
或 checkout
命令为你创建的,git pull
都会查找当前分支所跟踪的服务器与分支,从服务器上抓取数据然后尝试合并入那个远程分支。
由于 git pull
的写法并不够清晰,很容易令人困惑,所以通常单独显式地使用 fetch
与 merge
命令会更好一些。
变基 (rebase)¶
在 Git 中整合来自不同分支的修改主要有两种方法:merge
以及 rebase
merge
我们已经在上面介绍的很清楚了,这里我们展开说说 rebase
🔥
一言以蔽之,rebase
有利于维护一条线性的commit链 🚀
Tip
在正式介绍之前我们直接给出结论,适合细品
rebase
有利于维护整个commit记录的线性👍- 只对尚未推送或分享给别人的本地修改执行变基操作清理历史(强制线性),从不对已推送至别处的提交执行变基操作⚠️
现在我们以这个情景为例:
如果实行merge,那么我们的commit记录长这样:
还有一种方法:你可以提取在 C4 中引入的补丁和修改,然后在 C3 的基础上应用一次。在 Git 中,这种操作就叫做 变基。
你可以使用 rebase
命令将提交到某一分支上的所有修改都移至另一分支上,就好像“重新播放”一样 😍
Bash | |
---|---|
1 2 3 4 |
|
它的原理是首先找到这两个分支(即当前分支 experiment
、变基操作的目标基底分支 master
)的最近共同祖先 C2,然后对比当前分支相对于该祖先的历次提交,提取相应的修改并存为临时文件,然后将当前分支指向目标基底 C3, 最后以此将之前另存为临时文件的修改依序应用。
现在回到 master 分支,进行一次快进合并(Fast Forward
)。
Bash | |
---|---|
1 2 |
|
此时,C4' 指向的快照就和上面使用 merge 命令的例子中 C5 指向的快照一模一样了。
rebase
and merge
无论是通过变基,还是通过三方合并,整合的最终结果所指向的快照始终是一样的,只不过提交历史不同罢了。变基是将一系列提交按照原有次序依次应用到另一分支上,而合并是把最终结果合在一起。
这两种整合方法的最终结果没有任何区别,但是变基使得提交历史更加整洁。
你在查看一个经过变基的分支的历史记录时会发现,尽管实际的开发工作是并行的,但它们看上去就像是串行的一样,提交历史是一条直线没有分叉。
因此,最简单的 git rebase
流程是:
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
有趣的例子
同祖先特性分支跨同祖先与main进行rebase
操作:
Bash | |
---|---|
1 |
|
语意:“取出 client 分支,找出处于 client 分支和 server 分支的共同祖先之后的修改,然后把它们在 master 分支上重放一遍”
变基的风险
在这一节刚开始的时候,我们提到了一条金科玉律:
只对尚未推送或分享给别人的本地修改执行变基操作清理历史(强制线性),从不对已推送至别处的提交执行变基操作⚠️
为什么要这样呢?看看这个例子(变基的风险 in Pro-Git)就知道了!
Cheat Sheet¶
Bash | |
---|---|
1 2 3 4 |
|
Bash | |
---|---|
1 2 3 |
|
Bash | |
---|---|
1 2 |
|
Bash | |
---|---|
1 2 3 4 |
|
Bash | |
---|---|
1 2 3 4 5 6 |
|
Bash | |
---|---|
1 2 3 4 |
|
服务器上的 Git¶
一个远程仓库通常只是一个裸仓库(bare repository
)— 即一个没有当前工作目录的仓库。
简单的说,裸仓库就是你工程目录内的 .git
子目录内容,不包含其他资料。
协议¶
git官方介绍的四种协议:
- 本地协议 (Local protocol)
- HTTP 协议
- Smart HTTP
- Dumb HTTP
- SSH 协议
- Git 协议
这里面我认为值得讲讲的(稍微有点生活用途)是 SSH 和 Git 协议
(1) 对于 SSH 协议:
优势: 安全 + 高效
很多时候git push
,如果你是HTTP协议,会卡很长时间,还会失败,用SSH成功概率会大很多
缺点: 不能通过它实现匿名访问。
即便只要读取数据,使用者也要有通过 SSH 访问你的主机的权限,这使得 SSH 协议不利于开源的项目(项目主管总不能给每个成员都分配对应连接的密钥吧😅)。
如果我的origin已经是http了,咋补救呢
如果我的origin
仓库已经是http协议了,如何用ssh协议,因为我需要上传的速度与效率?
以 https://github.com/root-hbx/hypatia.git
和 git@github.com:root-hbx/hypatia.git
为例
可以使用 git remote set-url
命令:
Bash | |
---|---|
1 |
|
之后,可以通过以下命令测试与 GitHub 的 SSH 连接:
Bash | |
---|---|
1 |
|
第一次连接时,会提示你确认 GitHub 的主机密钥。确认后,它会显示类似以下的信息,表示连接成功:
Text Only | |
---|---|
1 |
|
(2) 对于 Git 协议:
这是包含在 Git 里的一个特殊的守护进程;它监听在一个特定的端口(9418),类似于 SSH 服务,但是访问无需任何授权。
这个“访问无需授权”问题很大啊🤔
Git协议的优点是:它在上述四种协议中访问速度最快,使用与 SSH 相同的数据传输机制,但是省去了加密和授权的开销
如果你的项目有很大的访问量,或者你的项目很庞大并且不需要为写进行用户授权,架设 Git 守护进程来提供服务是不错的选择🚀
在服务器上搭建 Git¶
这里并不会一步步从bare repo开始讲起,我们直接讲点跟日常开发相关的
问题1: 如何进行用户验证与权限管理
架设 Git 服务最复杂的地方在于用户管理。 如果需要仓库对特定的用户可读,而给另一部分用户读写权限,那么访问和许可安排就会比较困难。
(1) 直接在服务器上注册同一账户访问
在主机上建立一个 git 账户,让每个需要写权限的人发送一个 SSH 公钥,然后将其加入 git 账户的 ~/.ssh/authorized_keys
文件。
这样一来,所有人都将通过 git 账户访问主机。这点不会影响提交的数据 —— 访问主机用的身份不会影响提交对象的提交者信息。
我们实验室用的就是这种管理方式😄
(2) 基于LDAP进行管理
LDAP(Lightweight Directory Access Protocol)是一种应用协议,主要用于访问和管理分布式目录服务。
目录服务是一种特殊类型的数据库,用于存储、查询和管理大量的资源信息,通常用于组织、用户、权限等的集中管理。
这里可以回忆一下CS161的Project2对于用户注册的管理方式,跟这个LDAP思想一模一样🚀
放个实例:
假设你使用 SSH 登录某个服务器,在这个过程中,LDAP 可以作为身份验证的后端。具体流程如下:
- 用户尝试通过 SSH 登录服务器。
- SSH 服务器收到请求后,检查配置是否要求通过 LDAP 认证。
- 如果需要,SSH 服务器向 LDAP 服务器发送请求,查询用户的凭证(如用户名和密码)。
- LDAP 服务器查询用户目录,验证用户名和密码是否正确。
- 如果验证成功,LDAP 返回认证成功的结果,SSH 服务器允许用户登录。
自建 GitLab¶
感觉离我还有点遥远,就先不学了🤡
第三方托管的选择¶
如果不想设立自己的 Git 服务器,你可以选择将你的 Git 项目托管到一个外部专业的托管网站。
这带来了一些好处:一个托管网站可以用来快速建立并开始项目,且无需进行服务器维护和监控工作。即使你在内部设立并且运行了自己的服务器,你仍然可以把你的开源代码托管在公共托管网站 - 这通常更有助于开源社区来发现和帮助你。
很显然,首选是github
我们这里就不做赘述了
Cheat Sheet¶
Bash | |
---|---|
1 2 |
|
Git 工具¶
提交集合 (.. and ...)¶
我们可以用提交集合来解决 “这个分支还有哪些提交尚未合并到主分支?” 的问题
使用场景多体现在“merge之前检查一下会改动哪些文件”
双点
最常用的指明提交区间语法是双点(..
)。这种语法可以让 Git 选出在一个分支中而不在另一个分支中的提交:
格式:显示在分支A中但不在分支B中的提交(commit
)
Bash | |
---|---|
1 |
|
你想要查看 experiment 分支中还有哪些提交尚未被合并入 master 分支。 你可以使用 master..experiment
来让 Git 显示这些提交。也就是 “在 experiment 分支中而不在 master 分支中的提交”。 为了使例子简单明了,我使用了示意图中提交对象的字母来代替真实日志的输出,所以会显示:
Bash | |
---|---|
1 2 3 |
|
反过来,如果你想查看在 master 分支中而不在 experiment 分支中的提交,你只要交换分支名即可。experiment..master
会显示在 master 分支中而不在 experiment 分支中的提交:
Bash | |
---|---|
1 2 3 |
|
另一个常用的场景是查看你即将推送到远端的内容:
回忆一下,这其实是在对比你本地的HEAD
和origin/<BRANCH>
Bash | |
---|---|
1 |
|
这个命令会输出在你当前分支中而不在远程 origin 中的提交😄
三点
这个语法可以选择出被两个引用中的一个包含但又不被两者同时包含的提交
还是以这张图为例🦆
如果你想看 master 或者 experiment 中包含的但不是两者共有的提交,你可以执行:
Bash | |
---|---|
1 2 3 4 5 |
|
这种情形下,log 命令的一个常用参数是 --left-right
,它会显示每个提交到底处于哪一侧的分支。 这会让输出数据更加清晰:
Bash | |
---|---|
1 2 3 4 5 |
|
总结
储藏与清理 (stash)¶
这个命令实在是太常用了 😍 🚀
有时,当你在项目的一部分上已经工作一段时间后,所有东西都进入了混乱的状态,而这时你想要切换到另一个分支做一点别的事情。
问题是,你不想仅仅因为过会儿回到这一点而为做了一半的工作单独创建一次提交。针对这个问题的答案是 git stash
命令 🎉
我觉得我之前写的另一篇笔记对这个问题解析的比较透彻,here
我一般自用的选择是:
Bash | |
---|---|
1 2 3 4 |
|
签署工作 (GPG)¶
Git 虽然是密码级安全的,但它不是万无一失的。如果你从因特网上的其他人那里拿取工作,并且想要验证提交是不是真正地来自于可信来源,Git 提供了几种通过 GPG 来签署和验证工作的方式。
我觉得我之前写的这篇笔记讲的比较生动形象,空投 👀
签署提交
就跟之前那篇笔记里写的一样
签署标签
除了可以签署commit外,还可以签署tag
这样我们在下载时可以验证“这个包是不是开发者自己推出的”还是“恶意攻击者的炸弹包” 💣
举个例子,我们会经常看见开发者官方提供让用户自行验证packet是否正确:
如果已经设置好一个 GPG 私钥,可以使用它来签署新的标签:
1) 签署:
Bash | |
---|---|
1 2 |
|
例如,给包的v1.5版本签署如下
Bash | |
---|---|
1 2 3 4 5 |
|
如果在那个标签上运行 git show
,会看到你的 GPG 签名附属在后面:
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
2)验证:
要验证一个签署的标签,可以运行 git tag -v [tag-name]
。 这个命令使用 GPG 来验证签名。
为了验证能正常工作,签署者的公钥需要在你的钥匙链中:
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
如果没有签署者的公钥,那么你将会得到类似下面的东西:
Text Only | |
---|---|
1 2 3 |
|
GPG Configure in Local Machine
在本地机器上配置 Git 使用 GPG 签名(我推荐全局启用):
Bash | |
---|---|
1 2 |
|
这样每次 commit
都会默认使用 GPG 签名。
如何验证已经在当前设备上开启全局GPG签名:
在CLI中输入 git config --global --list
在输出中查找以下两行:
Bash | |
---|---|
1 2 |
|
如果这两行存在,说明当前设备已全局启用了 GPG 签名,并且指定了用于签名的 GPG 密钥 ID。
重写历史¶
这里在原书中写了很大一堆,但是我只认为“修改最后一次提交”是比较常用的:
对于你的最近一次提交,你往往想做两件事情:修改提交信息,或者修改你添加、修改和移除的文件的快照。
修改提交信息
如果,你只是想修改最近一次提交的提交信息,那么很简单:
Bash | |
---|---|
1 |
|
这会把你带入文本编辑器,里面包含了你最近一条提交信息,供你修改。当保存并关闭编辑器后,编辑器将会用你输入的内容替换最近一条提交信息。
另外修改文件
如果你已经完成提交,又因为之前提交时忘记添加一个新创建的文件,想通过添加或修改文件来更改提交的快照,也可以通过类似的操作来完成。
通过修改文件然后运行 git add
或 git rm
一个已追踪的文件,随后运行 git commit --amend
拿走当前的暂存区域并使其做为新提交的快照。
Warning
需要强调的是,上述git commit --amend
只使用于:
还没push上去的情况!!!
一旦已经push了,就没任何办法了
重置与检出(reset and checkout)¶
我们经常会使用 checkout
和 reset
指令,但是你真的理解它们背后的机理吗?
Git 主要的目的是通过操纵这三棵树 (Working Directory
/ Index
/ HEAD
) 来以更加连续的状态记录项目的快照。
这一部分我强烈建议看看pro-git官方解析
Part 1: Checkout Branch
当检出(checkout
)一个分支时,它会修改 HEAD 指向新的分支引用,将 索引 填充为该次提交的快照,然后将 索引 的内容复制到 工作目录 中。
Part 2: Reset
1) Soft Reset
Bash | |
---|---|
1 |
|
同时移动HEAD
和Current_Branch_Ptr
指向对应的commit:
它本质上是撤销了上一次 git commit
命令。当你在运行 git commit
时,Git 会创建一个新的提交,并移动 HEAD
所指向的分支来使其指向该提交。
当你将它 reset
回 HEAD~
(HEAD 的父结点)时,其实就是把该分支移动回原来的位置,而 不会改变索引和工作目录。
现在你可以更新索引并再次运行 git commit
来完成 git commit --amend
所要做的事情了
Note
很显然,git commit --amend
等价于:
Bash | |
---|---|
1 2 |
|
2) Mixed Reset
Bash | |
---|---|
1 |
|
如果指定 --mixed
选项,reset 将会在这时 (折返到Index) 停止。
这也是默认行为,所以如果没有指定任何选项(在本例中只是 git reset HEAD~
),这就是命令将会停止的地方。
理解一下发生的事情:它依然会 撤销上一次提交,但还会 取消暂存 所有的东西。 于是,我们回滚到了所有 git add
和 git commit
的命令执行之前。
3) Hard Reset
Bash | |
---|---|
1 |
|
这样写就:撤销了最后的提交、git add
和 git commit
命令以及工作目录中的所有工作。
Warning
--hard
标记是 reset
命令唯一的危险用法,它也是 Git 会真正地销毁数据的仅有的几个操作之一
我们给reset
总结一下:
Cmd | Meaning | Next |
---|---|---|
git reset --soft <HASH> |
撤销到HEAD 步 |
git commit |
git reset [--mixed] <HASH> |
撤销到Index 步 (default) |
git add and git commit |
git reset --hard <HASH> |
撤销到Working Dirctory 步 |
Add files and git add and git commit |
Part 3: Checkout
现在我们来介绍 checkout
和 reset
之间的区别。和 reset
一样,checkout
也操纵三棵树👌
运行 git checkout [branch]
与运行 git reset --hard [branch]
非常相似,它会更新所有三棵树使其看起来像 [branch]
,不过有两点重要的区别:
For Working Directory
checkout
对工作目录是安全的,它会通过检查来确保不会将已更改的文件吹走;而 reset --hard
则会不做检查就全面地替换所有东西⚠️
HEAD Itself / HEAD Branch
reset 会移动 HEAD 分支 的指向,而 checkout 只会移动 HEAD 自身 来指向另一个分支 🚀
举个例子:
例如,假设我们有 master
和 develop
分支,它们分别指向不同的提交;我们现在在 develop
上(所以 HEAD
指向它)。
如果我们运行 git reset master
,那么 develop
自身现在会和 master
指向同一个提交。
而如果我们运行 git checkout master
的话,develop
不会移动,HEAD
自身会移动。现在 HEAD
将会指向 master
。
高级合并¶
Git 的哲学是聪明地决定无歧义的合并方案,但是如果有冲突,它不会尝试智能地自动解决它。因此,如果很久之后才合并两个分叉的分支,你可能会撞上一些问题。
开发经验告诉我,当你决定merge的时候,大概率你需要手动解决一部分冲突,git可没法帮你做决策😄
问题1: 合并时有冲突,怎么办
我们现在站在“有冲突”的merge背景下:
Bash | |
---|---|
1 2 3 4 |
|
此时你可以有很多种选择:
1) 中断一次合并
你可能不想处理冲突这种情况,完全可以通过 git merge --abort
来简单地退出合并。
git merge --abort
选项会尝试恢复到你运行合并前的状态。但当运行命令前,如果在工作目录中有未储藏、未提交的修改时它不能完美处理,除此之外它都works well 🚀
--abort
exceptions
假设你在 feature 分支上工作,然后你想将 main 分支的更改合并到 feature 分支。
当前状态: 你在 feature 分支上,进行了修改但还没有提交。这时你运行:
Bash | |
---|---|
1 2 |
|
准备合并: 然后你决定将 main 分支合并进来:
Bash | |
---|---|
1 2 |
|
遇到冲突: 合并过程中,Git 发现了冲突,你需要手动解决冲突。这时你发现有一些修改没有提交,而且你不想解决这些冲突,或者你只是想放弃这个合并。
运行 git merge --abort
: 你运行:
Bash | |
---|---|
1 |
|
如果你的工作目录在合并前是干净的(即没有未提交的修改),那么这个命令会将你的 feature 分支恢复到合并之前的状态,冲突也会被取消,万事顺利 🔥
但如果在合并之前,你有未提交的修改(没有使用 git stash
),Git 就不能完美恢复,你的修改和合并操作会混在一起,这时候可能会导致文件冲突或一些不可预料的情况。
比较推荐的操作是:
在使用git merge --abort
前,保证工作环境的干净:
- 在merge前,先
git stash
- merge前,就直接把先前的push了
或者也可以换一种撤销方式:
Bash | |
---|---|
1 |
|
就像这里,使用copy commit hash to clipboard
即可
2) 手动修复合并
在VSCode中使用冲突解决预览器,自己手动 resolve conflicts即可
解决完,再commit ✅
问题2: 合并后,发现不该合并,如何撤回
假设现在我们在一个特性分支 topic
上工作,不小心将其合并到 master
中,现在提交历史看起来是这样:
有两种方法来解决这个问题!
1) 修复引用
如果这个不想要的合并提交只存在于你的本地仓库中,最简单且最好的解决方案是移动分支到你想要它指向的地方。
大多数情况下,如果你在错误的 git merge
后运行 git reset --hard HEAD~
,这会重置分支指向所以它们看起来像这样:
Bash | |
---|---|
1 2 |
|
我认为这是最完美的方法,也没有什么后效性,好得很😍
2) 还原提交
这个方法依赖于 revert
指令,建议了解清楚后再做,因为它的特性有点奇怪⚠️
笔者曾经在不熟悉的情况下尝试,酿成大祸,具体错误可以见这篇blog
我们还是以上面的为例:
如果移动分支指针并不适合你,Git 给你一个生成一个新提交的选项,提交将会撤消一个已存在提交的所有修改。Git 称这个操作为 “还原”,在这个特定的场景下,你可以像这样调用它:
Bash | |
---|---|
1 2 3 4 |
|
-m 1
标记指出 “mainline” 需要被保留下来的父结点。
当你引入一个合并到 HEAD(git merge topic)
,新提交有两个父结点:第一个是 HEAD(C6),第二个是将要合并入分支的最新提交(C4)。
在本例中,我们想要撤消所有由父结点 #2(C4)
合并引入的修改,同时保留从父结点 #1(C4)
开始的所有内容。
上述指令达成的效果是:
新的提交 ^M
与 C6
有完全一样的内容,所以从这儿开始就像合并从未发生过,除了“现在还没合并”的提交依然在 HEAD
的历史中。
你以为万事大吉了吗?🐶
其实并没有,这样有非常非常坏的后效性,我给你举个例子:
现在,如果你尝试再次合并 topic
到 master
Git 会感到困惑:
Bash | |
---|---|
1 2 |
|
topic 中并没有东西不能从 master 中追踪到达。更糟的是,如果你在 topic 中增加工作然后再次合并,Git 只会引入被还原的合并 之后 的修改。
具体来说,当你此时修复好问题并给出commit C7时,准备把它再次merge进master分支,会发现:
所有C3和C4涉及到的文件都不会再自动进行追踪了!⚠️
这是一个非常非常坏的现象,我们竟然要开始肉眼对比了😱
现在我们要思考两件事情:
1. 为什么revert会有如此奇怪的现象
我们必须要谈到的是,git本身具有“记忆性”,如果它的历史脉络里存在“删除”这个动作,它会记住删除的这些文件/代码行 并在未来不进行自动追踪⚠️
git revert
会有这种看起来“奇怪”的行为,主要是因为它的工作方式是通过生成一个新的提交来撤销某个历史提交的修改,而撤销的提交本质上是“记录”了撤销操作的。
这种操作的本质是添加一个新的修改记录,它并不会像 git reset
或 git checkout
那样直接改变历史😱
git revert
并没有直接修改历史,而是增加了一个撤销的记录。撤销的提交并不会让 Git 忘记之前的合并操作,它只是反向作用于之前的修改(M
/ ^M
)。
因此,历史中保留了“合并”与“撤销合并”的记录 。换句话说,Git 会在后续的操作中记住“撤销”这个动作,不会再将已撤销的内容再次引入,除非你明确地取消撤销操作。
2. 事已至此,如何修复
解决这个最好的方式是 撤消还原原始的合并 ,因为现在你想要引入被还原出去的修改,然后 创建一个新的合并提交:
换成人话就是:给^M
再revert一次 🚀
Bash | |
---|---|
1 2 3 |
|
在本例中,M
与 ^M
抵消了。^^M
事实上合并入了 C3 与 C4 的修改,C8 合并了 C7 的修改,所以现在 topic 已经完全被合并了。
梳理一下
M
将C3和C4拉进来^M
将C3和C4踢出,并不再追踪C3和C4^^M
撤销“将C3和C4踢出,并不再追踪C3和C4” -> 拉回C3/C4,并继续追踪
子模块 (submodule)¶
为什么需要讲submodule?这就像你在python中总是要引入第三方库一样,你用git有时也会考虑引入“独立开发好”的库🧱
这个库也许是第三方库,或者你独立开发的,用于多个父项目的库。
现在问题来了:你想要把它们当做两个独立的项目,同时又想在一个项目中使用另一个。
我们可以很轻易地想到一个直接的方法:
将“待引入库”直接复制进现有的git仓库
这样会有一个非常现实的问题,尤其是针对开发频繁的大型项目👀
比如这个库正在频繁更新,如果我是hard-code复制进来的,未来将怎么跟“最新更改的待引入库”进行同步呢?
因此:
Git 通过子模块来解决这个问题!
子模块允许你将一个 Git 仓库作为另一个 Git 仓库的子目录。它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立 🎉🎉🎉
详细讲解¶
为现有仓库引入子模块
我们首先将一个已存在的 Git 仓库添加为正在工作的仓库的子模块。你可以通过在 git submodule add
命令后面加上想要跟踪的项目 URL 来添加新的子模块。在本例中,我们将会添加一个名为 “DbConnector” 的库。
Bash | |
---|---|
1 2 3 4 5 6 7 |
|
默认情况下,子模块会将子项目放到一个与仓库同名的目录中,本例中是 “DbConnector”。如果你想要放到其他地方,那么可以在命令结尾添加一个不同的路径。
如果这时运行 git status
,你会注意到几件事。
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
这里你会发现:
- 在当前工作目录下,新建了一个子文件系统目录,叫
DbConnector
虽然 DbConnector 是工作目录中的一个子目录,但 Git 还是会将它视作一个子模块。 当你不在那个目录中时,Git 并不会跟踪它的内容, 而是将它看作该仓库中的一个特殊提交。 - 新建了一个文件
.gitmodules
,它的工作模式就像.gitignore
一样 注意到新的.gitmodules
文件: 该配置文件保存了项目 URL 与已经拉取的本地目录之间的映射Bash 1 2 3 4
$ cat .gitmodules [submodule "DbConnector"] path = DbConnector url = https://github.com/chaconinc/DbConnector
克隆含有子模块的项目
1) 直接clone:
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
你会发现:含有子模块的目录已经clone下来了,但是它本身是空的!⚠️
此时你站在空的“子模块仓库”,必须运行两个命令:
git submodule init
用来初始化本地配置文件git submodule update
则从该项目中抓取所有数据并检出父项目中列出的合适的提交
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 |
|
2) 递归clone:
如果给 git clone
命令传递 --recursive
选项,它就会自动初始化并更新仓库中的每一个子模块:
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
拉取上游修改/更新
在项目中使用子模块的最简模型,就是只使用子项目并不时地获取更新,而并不在你的检出中进行任何更改。
如果想要在子模块中查看新工作,可以 进入到子模块目录 中运行 git fetch
与 git merge
,合并上游分支来更新本地代码:
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
以后可以在子模块仓库目录下使用 git pull origin main
或者 git push
等来进行更新与合并等操作。
TL;DR¶
上面写的实在是又臭又长,我自己写完都懒得看😅
受这篇Blog的启发,简单总结一下submodule的最常见用法:
1) Clone 包含子模块的项目
对于你的主仓库项目合作者来说,如果只是 git clone
去下载主仓库的内容,那么你会发现子模块仓库的文件夹内是空的!
可以分初始化和更新子模块两步走的方式来下载子模块仓库的内容:
Bash | |
---|---|
1 2 |
|
但是,如果你是第一次使用 git clone
下载主仓库的所有项目内容的话,我建议一步到位地clone下来:
Bash | |
---|---|
1 |
|
以后可以在子模块仓库目录下使用 git pull origin main
或者 git push
等来进行更新与合并等操作,跟在主项目中一样。
此时若把上述 添加子模块 的修改更新到主仓库的 GitHub 上去的话,会看到相应子模块仓库的文件夹图标会有些不同:
这里的@
表示“超链接”,点击就会跳转到这个“库”本身对应的git仓库:
此时还要留意的是,在终端 Git 命令操作下,位于主仓库目录中除了子模块外的任何子目录下进行的 commit 操作,都会记到主仓库下。
只有在子模块目录内的任何 commit 操作,才会记到子模块仓库下。如下面的示例:
Bash | |
---|---|
1 2 3 4 5 6 |
|
2) 查看本仓库有哪些submodule
Bash | |
---|---|
1 |
|
3) 添加子模块
添加一个远程仓库项目 https://github.com/iphysresearch/GWToolkit.git
子模块到一个已有主仓库项目中。
代码形式是:
Bash | |
---|---|
1 |
|
比如下面的例子:
Text Only | |
---|---|
1 |
|
这时,你会看到一个名为 GWToolkit
的文件夹在你的主仓库目录中。
这个子模块的文件目录里是空的,因此你需要初始化 + 更新:
Bash | |
---|---|
1 2 3 |
|
或者也可以触发连招🚀
Bash | |
---|---|
1 2 |
|
4) 删除子模块
删除子模块比较麻烦,需要手动删除相关的文件,否则在添加子模块时有可能出现错误 同样以删除 GWToolkit
子模块仓库文件夹为例:
记住,你需要删除四样东西:
- 删除子模块文件夹
Bash 1 2
git rm --cached GWToolkit rm -rf GWToolkit
- 删除
.gitmodules
文件中相关子模块的信息,类似于:Bash 1 2 3
[submodule "GWToolkit"] path = GWToolkit url = https://github.com/iphysresearch/GWToolkit.git
- 删除
.git/config
中相关子模块信息,类似于:Bash 1 2 3
[submodule "GWToolkit"] url = https://github.com/iphysresearch/GWToolkit.git active = true
- 删除
.git
文件夹中的相关子模块文件Bash 1
rm -rf .git/modules/GWToolkit
Cheat Sheet¶
Bash | |
---|---|
1 2 3 |
|
Bash | |
---|---|
1 2 3 |
|
Bash | |
---|---|
1 2 3 4 |
|
Bash | |
---|---|
1 2 3 4 5 6 |
|
Bash | |
---|---|
1 2 3 |
|
Bash | |
---|---|
1 2 3 |
|
Bash | |
---|---|
1 2 3 4 |
|
Bash | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
Bash | |
---|---|
1 2 |
|
Bash | |
---|---|
1 2 3 4 5 6 7 8 |
|
Git 命令¶
这里集锦一些我日常使用的git指令,就当是一个大型的 cheat sheet 了😄
1)在.gitignore中添加新item
如果那个文件已被追踪:
Bash | |
---|---|
1 2 3 4 |
|
如果那个文件还没被追踪:
Bash | |
---|---|
1 2 3 |
|
2)分支对比,我经常在git pull前使用
本地 A B 分支对比:
Bash | |
---|---|
1 |
|
本地A分支 与 对应的远程仓库分支对比:
Bash | |
---|---|
1 2 3 |
|
3)全局配置信息设置
设置默认文本编辑器:
git config --global core.editor "vim"
查看全局配置信息:
git config --global --list
设置GPG密钥:
Bash | |
---|---|
1 2 |
|
4)远程仓库有关
基础操作:
Bash | |
---|---|
1 2 3 4 |
|
将远程仓库的URL进行替换 (常见于HTTP切换到SSH):
Bash | |
---|---|
1 2 |
|
5)分支模型基础
检出(checkout)分支:
Bash | |
---|---|
1 2 3 4 |
|
合并分支:
Bash | |
---|---|
1 2 3 |
|
删除分支:
Bash | |
---|---|
1 2 |
|
在merge时面对conflicts的做法:
Bash | |
---|---|
1 2 3 4 |
|
拉取远程仓库最新改动并变化到本地:
Bash | |
---|---|
1 2 3 4 5 6 |
|
变基(rebase)操作:
Bash | |
---|---|
1 2 3 4 |
|
6)commit/reset的骚操作
git commit -a -m "add and commit ..."
省一次add,直接暂存并提交git rm --cached README
文件存在,git撤销追踪 常见于搭配.gitignore
git commit --amend
撤回上一次提交 等价于:Bash 1 2
git reset --soft [<COMMIT_HASH>]/[HEAD~] git commit -m "new commit msg"
reset
命令:Bash 1 2 3
git reset --soft <COM_HASH> # then: git commit git reset --mixed <COM_HASH> # then: git add | git commit git reset --hard <COM_HASH> # then: Add files | git add | git commit
7)集合操作
\(A \setminus (A \cap B)\)
Bash | |
---|---|
1 2 3 |
|
\((A \cup B) \setminus (A \cap B)\)
Bash | |
---|---|
1 2 3 |
|
8)签署Tag
Bash | |
---|---|
1 2 3 4 5 6 |
|
9)stash用法
常用于:一项工作做了一半,要切换到另一个分支上,单独做一个commit显得很🤡
Bash | |
---|---|
1 2 3 4 5 |
|
10)submodule子模块
下载子模块:
Bash | |
---|---|
1 2 3 4 5 6 7 |
|
更新子模块:
Bash | |
---|---|
1 2 3 4 5 |
|
在主项目里查看子模块有哪些:
Bash | |
---|---|
1 2 |
|
添加子模块:
Bash | |
---|---|
1 2 3 4 5 |
|
删除子模块:
这个比较麻烦,要删去四个成分,看看之前笔记,用的不多其实