git 进阶
[TOC]
我最早通过廖雪峰的 git 教程入门的 git 使用. 在实际工作中对基础的命令能够比较熟练地使用. 但是实际一些场景中遇到的一些问题,还是只能查别人的具体命令步骤,但无法了解为什么这么做,到了一下次出现问题时还是只能上网查与我问题完全一致的人的操作步骤.因此为了知其然以及知其所以然,我通过极客时间的《玩转 git 三剑客》这门课程重新入门了 git.这门课与一般网络上速成的操作教程不同,课程是先通过介绍基础的存储原理开始,通过每一步的操作演示,有浅入深地介绍 git 的基础与应用,并不是罗列命令列表.课程的后半段是对于 github 与 gitlab 的使用介绍,我3倍速过了一下,发现对于初学者还是非常友好的.
我把课程中学习到的知识与实际工作的感触,以及包括官网文档在内的其他平台搜集到的不错的分享总结成本文.本文不是命令速查手册,也不是具体场景下的解决步骤总结,而是从 git 的基础概念与框架出发理解 git 的设计哲学,能够在认识其基础的基础上加速更高级用法的使用与理解,以解决更富挑战性的场景与问题.
git 的入门我还是推荐廖雪峰的 git 教程.至于命令检索,官网文档与 -h,--help
就足够了.
考虑到常用命令的检索时间,可以下载一份 git 操作的 cheatsheet (Gitlab 的还不错, PDF下载地址),打印出来方便查阅.文末还有一页纸的设计,可以参考一下.
git 包括如下功能模块:
这些模块对应了很多我们版本管理的各种需求.
先举个版本管理的例子,然后抽象出需求与痛点.
记得本科做机械设计的课题报告时,四个人分工写各自分担的部分,写成 word,还有附录有 autoCAD 的画图文件,调研时手机拍的照片等文件.初期大家的东西交集不多,只需要埋头写好自己的东西填到 U 盘里公共位置处的 word 文档/文件夹里面就可以了.到了中后期问题就来了.例如 U 盘更新不及时,理论上有更新了需要更新到 U 盘里,但是宿舍间有距离,A 的更新被 B 依赖, C D暂时不依赖,因此A 觉得通过 QQ 发给 B就行了,导致C D 处版本不同步(代码仓库问题).U 盘在 C 处保管,C 每次操作都直接在 U 盘里进行,一旦错误了,导致之前的版本无法追溯(工作区与代码库未设隔离的问题).B 在本地电脑里通过文件夹命名与 word 文档与图片 auto CAD的重命名管理更改与版本,当累计起来较多版本后,已经无法追溯较早时期的更改内容,想要回退很多时候只能从很早的阶段重来浪费时间(版本文件的存储管理问题).画图的 D 的分工相对独立,并且希望渲染出更酷的3D效果,因此埋头研究渲染,导致后面 A 给他的尺寸变动,D 发现他必须又得再来一遍,并且有些渲染效果失效(分支管理问题).班里有其他组的同学希望不劳而获,想拷走项目文件做参考,甚至擅自修改了一些东西,然后组内成员无法分清到底是谁修改的(配置管理问题).每次大版本出来都要对文件依次重命名,打包邮件发给助教审核,很繁琐,组内谁都不愿意做(钩子实现自动化问题).中途有一个别的组的人觉得他的之前的工作可以放进来增加趣味性,但是要对整个文件结构做出较大调整,只得作罢(模块化与打补丁问题).
通过对比上图中 git 的模块,我们可以发现 git 的几个主要功能模块基本上解决了我们日常版本管理的痛点.
下面就对图中的模块一一展开.当然 git 还有更多的内容,例如打包,远程端的校验,分布式,其他版本系统向 git 的迁移等.
存储方式
要点:
- git 管理数据对象,核心是键值对数据库(key-value data store),是通过唯一的 ID,即 SHA1 的 checksum 值(git 不是直接计算文件的 SHA1而是会添加一些头字符进去然后计算)完成的,不是根据原始的目录/文件结构存储的.至于文件结构是通过 tree 对象实现的.
- 对象共分为4类: blob,tree,commit,tag.
- git 的命令分为底层(plumbing)命令与上层命令(porcelain).可以使用底层命令直接操作对象,例如对 tree 使用通过
git write-tree
命令将暂存区内容写入一个树对象.调用commit-tree
命令创建一个提交对象. - git 使用 zlib 压缩版本库的内容,提升空间利用率以及传输效率.
- 从根本上来讲 git 是一个内容寻址(content-addressable)文件系统,并在此之上提供了一个版本控制系统的用户界面.git 的内部数据库会保存每个文件的所有版本的全部内容而不是它们的差异(不是像某些版本管理系统做增量管理,节省空间).
当一个文件的名字被修改,内容未被修改,git 能够很明确指出文件名字被修改了,因为 git 会通过分析内容发现改名前后的文件其实内容一样.
对象类的关系
对于版本管控的人而言,一般只需要关注提交 commit,一个 commit 包含了如下信息(当前版本信息的快照).
- 包含的 tree 对象的指针
- 父 commit
- 代码作者
- commit 提交者
- 提交时间
- 提交备注
基于这些信息我们基本上可以实现版本的管控:谁在什么时候修改了什么内容.可以是说 commit 是 git 的版本管控的基本单元.一个版本其实就是某个分支上的具体某一个 commit,它由历史上以及其他分支上的父 commit 组成(单向链表)而来(有点马尔可夫链的意思了…)
tag 可以类似为一个 commit 的起了一个别名,能够方便我们快速找到某个 commit/版本.包含一个标签创建者信息,一个日期,一段注释信息,以及一个 commit 的指针.
git 中存在两种类型的标签:附注标签和轻量标签.
轻量标签,产生一个对 commit 对象的固定的引用:
1 | git update-ref refs/tags/v1.0 cac0cab538b970a37ea1e769cbbde608743bc96d |
附注标签:
git 会创建一个标签对象,并记录一个引用来指向该标签对象,而不是直接指向提交对象
1 | git tag -a v1.1 1a410efbd13591db07496601ebc7a059dd55cfe9 -m 'test tag' |
tree 则是目录,所有的文件都是放到目录中的.目录通过指针实现嵌套的以及整个目录体系.
blob 则是文件,既可以是文本文件也可以是二进制文件(git 虽然不擅长管理二进制文件,也不该由 git 管理二进制文件). 文件会被放入 tree 中.
commit tree 与 blob 之间的关系,视频课程里的图很是经典,借用到这里来.
创建如下目录结构
1 | ├── images |
SHA1值为 415c5c 开头的 commit 包含当前 git 的 root 目录,其父 commit ID 为 9c6861,本次提交的内容为 “Add style.css”.
root 的目录下面有 index.html 以及 readme 两个文件以及一个 images 与 styles 两个目录, 两个目录里的文件分别为 git-logo.png 以及 style.css.
实际操作
在一个文件夹下初始化一个仓库. 在隐藏的目录 .git 下可以看到如下结构:
1 | ├── branches #分支信息 |
这时候 objects 变化为如下
1 | ├── 23 |
通过 git cat-file
查看 SHA1 ID 对应文件的相关信息.
1 | # 打印 ID 代表的文件内容 |
输出为
1 | i am style.css file #style.css里的内容 |
git rev-parse
可以反推完整版本的 SHA1 值.
1 | git rev-parse e8c7365c |
对于 commit 以及 tag 对象,操作是一样的.
HEAD 以及引用
HEAD 是一个符号引用(symbolic reference),指向某一个 commit 对象的指针,既可以是目前所在的 branch 下最新的 commit, 也可以是分离状态(后面展开)下的某个 commit.图解如下, A2,A1,A0 为 commit, 默认存在的 master 分支指向最新的 commit,也就是 HEAD 指向的位置.
指针就意味着可以通过加减单位存储单位找到附近的对象(这里 HEAD 指针只能往一个方向移动,单向链表). 具体的使用场景例子如下:
1 | git diff HEAD HEAD^1 #与父 commit 的区别 |
除了 HEAD 以外还有别的引用:
- 分支
git 分支的本质也是一个指向某一系列 commit 之首的指针或引用. - 标签 tag
- 远程引用(remote reference)存在
.git/refs/remotes/origin/
里,是只读的.用来指向远程的 HEAD.
commit 是操作基本单元
从上面我们可以看到 tag, HEAD, branch 名都可以当作 commit 的引用/指针.因此在后面的很多命令当中,我们操作的都是 commit, 这样我们就可以更容易记住命令,以及命令的本质.
文件状态
在 git 中一个文件的状态被分为四种,通过 git status
命令可以查看到一些文件状态的改变.untracked 一般为新加入/删除的文件.前三个状态是在工作区内,第四个 staged 是指文件已经被存到暂存区内,如果所有变化的文件都 staged 了的化,工作区就是 clean 的.
分区管理
先放图
从左到右依次为存档区,工作区,暂存区,本地代码库,上游代码库(可以是远端服务器也可也是本地的备份库). 设置区的意义在于隔离各种变化,设置缓冲以及存储代码.
图片来自于:http://ndpsoftware.com/git-cheatsheet.html#loc=workspace;
下面分区讲解每个区与其他区的交互(更改与同步).
工作区
这里是我们修改代码的地方,在这里修改好的东西我们会提交到暂存区(暂存区后觉得没问题了推到本地的代码库),如果觉得确实没问题也可以直接提交到本地的代码库,这样就完成了一次修改的备份.对于没有修改好的东西,我们可以将其放入存档区,然后处理别的事情,等别的事情处理完了再从存档区提取存档接着进行修改(很像游戏的存档).
下图是工作区与其他各区的关系:
工作区与暂存区
1 | git status #工作区与暂存区的差异,概略性 |
工作区与本地代码库
1 | git diff <commit/branch> #比较与某个 commit 或者是 branch 的差异 |
工作区与上游代码区
1 | git clone #复制整个代码包括历史信息 |
工作区与存档区
1 | git stash push #将工作区的内容存到存档区中 |
工作区内部
1 | git clean #删除所有 untracked 状态的文件 |
暂存区
暂存区存在的意义是给程序员一个缓冲区,虽然麻烦了一次,但是安全系数却增大了一倍.暂存区仅仅与工作区以及本地代码区交互.
暂存区与本地代码库
1 | git reset #让最新提交的指针回到以前某个时点,该时点之后的提交都从历史中消失.--mixed 选项为默认选项,本地仓和暂存区都回滚. |
这里图中是不是错了,git reset
的 --soft
指令是本地代码库回滚,暂存区与工作区不变.按图中的示意应该是 --mixed
选项.
本地代码库
如果一个人只管理自己的版本,并且不需要使用 git 备份.只要到本地代码库其实就可以了.但是考虑到协作性以及备份,还是要把本地的代码库推送到上游的代码库中.
本地代码库与上游代码库
1 | git fetch #复制上游代码库到本地代码库 |
本地代码库内部
1 | git log |
上游代码库
上游代码库既可以是用来备份用,也可以是远程服务器用来共享代码与协作的.
上游代码库也可以进行与本地代码库中的一样的操作,这里不再赘述.
存档区
在介绍工作区的时候已经引入了存档区的一种用法.这里再介绍一种场景.git pull
下来的代码是经过其他人修改过的代码,尤其是修改区域有可能是重叠的.如果直接 pull 到工作区导致很多冲突,修改冲突的过程中可能会污染工作区,导致我们 pull 之前工作区的修改消失了.这个时候可以在 pull 之前先 git stash push
到存档区,存储一下.然后再 pull 下来,git pop apply
出存档,修改冲突,并且我们不会丢失之前工作区的修改.
存档区与本地仓库区
1 | git stash branch <new branchname> #把本地仓库区的branch 存储到存档区中 |
存档区内部
1 | git stash list #所有的存档 |
git 的传输协议
本地仓库与上游仓库区之间需要进行数据的传输,下面是几种传输协议:
智能协议与哑协议的区别
- 智能协议显示传输进度
- 智能传输速度较快(会进行打包)
本地备份的示意图,多点备份:
分支协作
要点:
- 分支可以用来隔离协作时的代码.常用的策略是主分支是稳定的,大家都从主分支上拉取代码,然后创建子模块/个人的分支,修改好之后再合入主分支之中.常见的分支类型有 featue 新功能开发分支, debug 修复分支, release 版本发布分支等.
- 分支本质上就是 commit 的树状结构,对于每个树干及树枝衍生出来的树枝都有一个 branch 的名字.换句话说,从任意 commit 上都可以发展出一条分支出来.
- 分支与分区的差别:这是两个并行的概念,分支的概念更靠近数据结构,而分区的概念则是物理存储的位置.
- 分支仅存在工作区/本地代码库/上游代码库中.上游代码库分支的名字开头为
remotes/
. - 所有的 commit 都需要与一个(仅一个) branch 绑定,有时候为了进行删除 commit 等场景 HEAD 会变成 detached HEAD(分离头指针).
分离头指针可以脱离分支结构进行代码库的修改,但是无法与分支进行绑定,很可能会导致分离状态下修改的内容全部丢失.不推荐这么做.
修复:可以新建一个branch与分离期间修改的内容绑定 git branch branch_name commit_id.
分支操作
除了常规的数据对象操作:增删查(包括对比)改以外还有合并的操作.同时对上游代码库的分支的操作稍有特殊.
1 | git branch #查看当前分支 |
分支合并
分支合并稍有些麻烦,冲突会在下一节讲解.此处讲一下合并时的 fast forward
与 non fast forward
选项.
Fast Forward(
--ff
)
Fast Forward意为”快进模式”.为默认选项.主要使用在多分支合并的情况下.即:当前分支合并另一个分支的时候,如果合并的过程中没有Conflict冲突的时候,则会通过直接移动两个分支的指针,来达到合并的过程.Fast Forward有一个弊端:删除分支后,会丢掉分支的所有信息(例如某个 commit 到底是原来在哪个分支上的).No Fast Forward(
--no-ff
)
如果要强制禁用Fast Forward模式,Git就会在merge时生成一个新的commit,这样,从分支历史上就可以看出分支信息.
针对 commit 的操作
不管是 branch 的合并, 不同区之间的合并, 回退等操作本质上都是对 commit 的增删查改+合并的操作,如果理解了 commit 的操作,再理解功能性的命令就会容易很多.
更改(包括更换)
- 更换上一次 commit
git commit --amend -m [message]
:使用一次新的commit,替代上一次提交,如果代码没有任何新变化,则用来改写上一次commit的提交信息. - 更换更早的 commit
git rebase -i PARENT_COMMIT_ID
指定待修改的 commit 的信息的父 commit ID,-i
为交互模式.有多种模式可选,-pick
直接替换,-reword
修改,-drop
丢弃等.
这会导致修改后以及其之后的所有子 commit 的 hash 值全部变掉,风险很大.在于他人分享的主分支上,切忌rebase.
rebase 是基于分离头指针完成的.
合并
连续几个 commit 合并
git rebase -i PARENT_COMMIT
对合并对象选择保留选项-pick
, 其他commit 对象选择选项-squash
.不连续的
把需要被合并的 commit 对象 使用-squash
选项并剪切到合并到的 commit 对象那一行的下面.
对比
1 | git diff COMMIT_ID_1 COMMIT_ID_2 |
删除
1 | git reset #删除 COMMIT_ID 之后的所有 commit 对象,通过选项设置删除的影响区域 |
commit 被删除了并不意味着 git 与磁盘中彻底删除了存储对象(因为 git 有自动的垃圾回收机制,会凑够一定量的悬空 dangling 对象后统一清除,在自动清除前还是被存储的).
git reflog
与git fsck
工具还是有可能找回已经删除的 commit 并恢复.可以参考官网文档.
冲突解决的原理
- 不会发生冲突的情况
只要不是更改同一个文件的同一个区域就不算冲突,Auto-merging 的默认操作是同时保留所有的变化.
- 产生冲突的情况
修改同一个文件的同一个位置.可以手动修改 Auto-merging 后的冲突文件(冲突文件中会被 git 系统插入有用的引导信息).
- 文件名出现修改
只有一处更改了文件名,其他处都没有修改文件名,而是修改了文件内容,这个时候文件名的修改不被认为是冲突,直接按照变化均采用的 Auto-merge.
多处修改了文件名,报冲突,需要手动选择如何抉择,通过 git rm / git add 选择删除/保留具体那一个.
配置管理
配置共有三个作用域
1 | git config --local #默认配置,只对仓库有效,在.git 文件夹下有 config 文件中 |
配置内容
1 | git config --global user.name ‘your_name’ |
低层的配置能覆盖高层的配置,例如,local 的设置会覆盖 global.
别名
1 | git config --global alias.co checkout # git checkout = git c0 |
跳过某些文件
通过在 git 的 root 目录下添加 .gitignore
文件来实现不追踪管理某些文件/文件夹.
引用规范
用来创建负责的远程与本地引用之间的复杂映射关系. 例如:每次只拉取远程的 master 分支,而不是所有分支. 只需要修改.git/config
文件:
1 | [remote "origin"] |
引用规范的格式由一个可选的 +
号和紧随其后的 <src>:<dst>
组成, 其中 <src>
是一个模式(pattern),代表远程版本库中的引用; <dst>
是本地跟踪的远程引用的位置. +
号告诉 git 即使在不能快进的情况下也要(强制)更新引用.
默认情况下,引用规范由 git remote add origin
命令自动生成, git 获取服务器中 refs/heads/
下面的所有引用,并将它写入到本地的 refs/remotes/origin/
中.
推送
也可以适用于推送,每次只推送本地的 master 到远程.
1 | [remote "origin"] |
删除
借助类似下面的命令通过引用规范从远程服务器上删除引用:
1 | git push origin :topic #把 <src> 留空,意味着把远程版本库的 topic 分支定义为空值,也就是删除它. |
不能在模式中使用部分通配符.所以像下面这样的引用规范是不合法的:
fetch = +refs/heads/qa*:refs/remotes/origin/qa*
子模块
有种情况我们经常会遇到:某个工作中的项目需要包含并使用另一个项目. 也许是第三方库,或者你独立开发的,用于多个父项目的库. 现在问题来了:你想要把它们当做两个独立的项目,同时又想在一个项目中使用另一个.
git 通过子模块来解决这个问题. 子模块允许你将一个 git 仓库作为另一个 git 仓库的子目录. 它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立.
增加
新建
1
git submodule add <仓库地址> <本地路径>
生成了新文件.gitmodules,在里面增加了对SubModule的一些描述.
复制
1
git submodule update --init --recursive <仓库地址>
修改
切到 submodule 的目录,然后做修改,然后 commit 和 push .
这里的坑在于,默认 git submodule update 并不会将 submodule 切到任何branch,所以,默认下 submodule 的 HEAD 是处于游离状态的(‘detached HEAD’ state).所以在修改前,记得一定要用git checkout master
将当前的 submodule 分支切换到 master,然后才能做修改和提交.
更新
1 | git submodule update --remote |
删除
1.删除.gitsubmodule里相关部分
2.删除.git/config 文件里相关字段
3.删除子仓库目录.
然后执行命令:
1 | git rm --cached <本地路径> |
钩子
和其它版本控制系统一样,git 能在特定的重要动作发生时触发自定义脚本. 有两组这样的钩子:客户端的和服务器端的. 客户端钩子由诸如提交和合并这样的操作所调用,而服务器端钩子作用于诸如接收被推送的提交这样的联网操作.
安装一个钩子
钩子都被存储在 git 目录下的 hooks 子目录中. 也即绝大部分项目中的 .git/hooks .Git 默认会在这个目录中放置一些示例脚本. 这些脚本除了本身可以被调用外,它们还透露了被触发时所传入的参数. 所有的示例都是 shell 脚本,其中一些还混杂了 Perl 代码,不过,任何正确命名的可执行脚本都可以正常使用. 这些示例的名字都是以 .sample 结尾,如果你想启用它们,得先移除这个后缀.
把一个正确命名(不带扩展名)且可执行的文件放入 .git 目录下的 hooks 子目录中,即可激活该钩子脚本.
钩子的类型
加粗的为常用钩子
Hook | 调用时机 | 说明 |
---|---|---|
pre-applypatch |
git am 执行前 |
|
applypatch-msg |
git am 执行前 |
|
post-applypatch |
git am 执行后 |
不影响git am 的结果 |
pre-commit |
git commit 执行前 |
可以用git commit --no-verify 绕过 |
commit-msg |
git commit 执行前 |
可以用git commit --no-verify 绕过 |
post-commit |
git commit 执行后 |
不影响git commit 的结果 |
pre-merge-commit |
git merge 执行前 |
可以用git merge --no-verify 绕过 |
prepare-commit-msg |
git commit 执行后,编辑器打开之前 |
|
pre-rebase |
git rebase 执行前 |
|
post-checkout |
git checkout 或git switch 执行后 |
如果不使用--no-checkout 参数,则在git clone 之后也会执行. |
post-merge |
git commit 执行后 |
在执行git pull 时也会被调用 |
pre-push |
git push 执行前 |
|
pre-receive |
git-receive-pack 执行前 |
|
post-receive |
git-receive-pack 执行后 |
不影响git-receive-pack 的结果 |
update |
和 pre-receive 类似,不同在于会为每一个准备更新的分支各运行一次 |
|
post-update |
当 git-receive-pack 对 git push 作出反应并更新仓库中的引用时 |
|
push-to-checkout |
当git-receive-pack 对git push 做出反应并更新仓库中的引用时, 以及当推送试图更新当前被签出的分支且 receive.denyCurrentBranch 配置被设置为 updateInstead 时 |
|
pre-auto-gc |
git gc --auto 执行前 |
|
post-rewrite |
执行git commit --amend 或git rebase 时 |
|
sendemail-validate |
git send-email 执行前 |
|
fsmonitor-watchman |
配置core.fsmonitor 被设置为.git/hooks/fsmonitor-watchman 或 .git/hooks/fsmonitor-watchmanv2 时 |
|
p4-pre-submit |
git-p4 submit 执行前 |
可以用git-p4 submit --no-verify 绕过 |
p4-prepare-changelist |
git-p4 submit 执行后,编辑器启动前 |
可以用git-p4 submit --no-verify 绕过 |
p4-changelist |
git-p4 submit 执行并编辑完changelist message 后 |
可以用git-p4 submit --no-verify 绕过 |
p4-post-changelist |
git-p4 submit 执行后 |
|
post-index-change |
索引被写入到read-cache.c do_write_locked_index 后 |
打补丁
git 提供了两种补丁方案,一是用git diff
生成的UNIX标准补丁.diff
文件,二是git format-patch
生成的 git专用.patch
文件..diff
文件只是记录文件改变的内容,不带有 commit 记录信息,多个 commit 可以合并成一个.diff
文件..patch
文件带有记录文件改变的内容,也带有 commit 记录信息,每个 commit 对应一个.patch
文件.
在 git下,我们可以使用.diff
文件也可以使用.patch
文件来打补丁,主要应用场景有:CodeReview,代码迁移等.
具体操作可以参考:https://juejin.cn/post/6844903646384095245
更多资源
官方教程:https://git-scm.com/book/zh/v2
github 上教程整理,7.2k star:https://github.com/xirong/my-git
一页纸版本的cheat sheet.
参考链接:
https://yanhaijing.com/git/2017/02/09/deep-git-4/
https://segmentfault.com/a/1190000020297996