Git命令工作机制
Git 是当前最广泛使用的版本控制系统,具备非常强大的版本控制能力。Git 有非常多的命令,很多人被种类繁多的命令搞的非常头大,也会经常忘记。本文尝试从原理角度来介绍 Git 常用命令,以便于加深对 Git 原理的理解。只有理解了原理,才能知道这些命令到底对仓库做了什么,进而才能更好使用命令,也更加不容易忘记。作为一个版本控制系统,理解其原理也是十分必要的,否则,很多命令根本不敢用,用错了可能会造成严重后果,搞丢代码的成本是非常大的,谁都不想自己或他人做工作被一键抹除。知道原理后,即使一不小心操作错了,也可以坦然处之,轻松恢复。
相比命令行工具,可视化的 GUI 就要直观和方便很多,但是很多时候一样可能因为对于具体做什么不了解而操作错误,并且,目前常用的 GUI 软件,都未提供全部的命令行操作能力,GUI 很多时候,也没有命令方便快捷。
仓库概述
Git 是一个分布式的版本控制系统,不同于 svn 只有一个中心仓库,必须能连接到 svn 服务才能提交,Git 可以随时提交而不依赖于中心仓库,因为本地存在一个独立的仓库,完全支持离线操作。本地仓库和远程仓库是一种比较松散的关系。
Git 中,每个仓库可以存在多个仓库副本,这个仓库暂且称为 “原始仓库”,“原始仓库” 因为需要备份或协作等方面的需要通常存储在托管服务器上。
仓库副本通常是通过 clone
或 Fork
(本质其实也是clone
) 创建的,仓库副本和原始仓库是上下游关系,这些副本仓库可能在本地,也可能在某台服务器上。
原始仓库如果托管在托管服务器上,通过 Fork
的方式可以创建很多位于托管服务上的副本,这些副本在不同的账户下,Fork
并不是 Git 提供的,而是托管服务提供的。Fork
出来的仓库副本与原始仓库之间的关系也由托管服务记录和维护的,这些副本仓库都有一个相同的 上游仓库
(即原始仓库),同时副本仓库也可以继续被 Fork
产生多个 下游仓库
,下游仓库可以通过 PullRequest/MergeRequest
向上游仓库发起跨仓库的 merge
请求,进行仓库间的交互。这些位于托管服务上的仓库,不论是原始仓库还是Fork
出来的副本仓库,都可以被 clone
到本地创建本地仓库,本地仓库也是一个副本。
本地仓库可以是原始仓库的副本,也可以是原始仓库的某副本的副本,相对于本地仓库,这些仓库称之为远程仓库,本地版本库可以同时对应多个远程仓库。本地仓库通过 push
、fetch
等命令和远程仓库同步。
通过前面的描述,引申出了几个概念:原始仓库 <-> 副本仓库、上游仓库 <-> 下游仓库、远程仓库 <-> 本地仓库,这些概念都是相对而言的,便于进行角色区分。
仓库间的交互:
- 远程仓库 和 本地仓库 之间,主要进行的是同步,本地修改同步到远程,或远程同步到本地。
- 远程仓库 和 远程仓库 之间,主要进行的是跨仓库的分支合并,也就是
PullRequest/MergeRequest
。
在未使用 Fork
的模式中,就没有 2
这种情况了。
本地仓库主要用来完成内容变更,远程仓库主要用来完成多人协作与数据备份。
托管服务上,Fork
创建的仓库副本关系如下:
1 | base/Base |
这些仓库都可被有权限的用户 pull
本地仓库,并正在修改之后 push
远程仓库。
为什么需要 Fork
?
- 避免分配原始仓库的访问权限给无关用户,防止仓库被破坏
- 本地仓库无法直接 Merge 到远程仓库,远程副本仓库能够提供了合并到原始仓库的能力
- 合并需要发起
PullRequest/MergeRequest
提供了代码审核的窗口
Git 的单个仓库的工作原理是比较简单的,但是当多个不同角色的仓库同时存在协同工作的时候,确实非常复杂的,有非常多的玩法。只有真正的理解内部原理,方能运用自如。
Git 的命令是建立在这些底层模型上的,命令也正是为处理并维护这些关系而设计的。所以也只要有理解原理,才能熟练运用这些命令。
下面开始进入正题。
仓库创建
任何操作开始前,首先要有个一个仓库。
Init
核心功能:
- 创建一个新的空 Git 仓库,或者重新初始化一个已经存在的 Git 仓库。
常规方式创建一个仓库:
1 | $ git init workspace |
以上创建的仓库,仓库文件 存放在 工作区目录 workspace
的子目录 .git
下。
分离的方式创建一个仓库:
1 | $ git init --separate-git-dir=.tig workspace |
从上面的目录结构可以看到,相比常规方式创建仓库,通过分离的方式创建的仓库将 仓库(.git)
从 工作区目录(workspace)
中分离出去。仓库(.git)
不再是一个目录,而是包含指向仓库路径的一个文件,实际上利用这一特性,可以创建多个工作区共享同一仓库,也就可以支持同一个仓库拥有多个工作区。
另外也可以通过环境变量 GIT_DIR=path/to/repo.git
指定 仓库(.git)
的路径,下面这种方式和上面的方式是等价的(注意:相对路径不同)。
1 | $ GIT_DIR=../.tig git init workspace |
不仅如此,Git 还支持将 Objects 从 仓库(.git)
中移出,通过 GIT_OBJECT_DIRECTORY=$GIT_DIR/objects
Git 对象存储路径,不过这种用法在本地很少见。
有兴趣可以用以下命令做实验:
1 | $ GIT_OBJECT_DIRECTORY=../objects GIT_DIR=../.tig git init workspace |
默认情况下,仓库在创建的过程中,拷贝了一些的模版文件到 仓库(.git)
目录下,默认的模版路径和文件示例如下:
1 | $ tree /usr/local/Cellar/git/2.18.0/share/git-core/templates |
可以看到,模版目录里面的内容,和实际仓库的内容相同的,用模版创建仓库时,就是原封不动的将仓库内容拷贝到仓库目录下。
基于这一点,我们可以以一个已经存在的仓库作为模版,创建另一个仓库,Git 会把模板路径下的文件的拷贝到新的仓库下。
可以在运行的时候通过 --template=
或 GIT_TEMPLATE_DIR
环境变量 或 init.templateDir
配置 指定模版路径位置。
还可以通过 git init --bare
参数创建一个裸仓库,裸仓库的指没有工作区的仓库。
1 | $ git init --bare |
因为无工作区,仓库文件直接放在当前目录下了,可以手动创建工作区并链接到仓库目录。
在一个已经存在的仓库目录中运行 init
命令是安全的,它不会覆盖原来已经存在的文件(包括仓库和工作区)。
重新运行 init
的可以用来安装新添加的模版,或者用来将仓库分离到其他的位置。
新创建的仓库的仓库配置:
1 | [core] |
只有 core
相关的几个配置项。
通过以上对命令及参数的效果的参考,可以了解到 Git 文档中不为人知的一些内容。
初始化仓库是 Git 工作流中的第一步,通常发生在本地,在托管服务创建仓库之后,仓库并未初始化,需要在本地创建并初始化仓库后,同步到远程,然后再同步本地。
通常情况下,都是需要两个仓库的,一个本地仓库,一个远程仓库,以支持复杂的开发工作流。
Clone
核心功能:
- 初始化并配置仓库,记录本地仓库和远程仓库的对应关系,包括仓库和分支映射关系
- 克隆仓库到一个新的目录,实际上就是
git init
- 为每一个被克隆仓库中的分支创建对应的远程追踪分支
- 从克隆仓库的当前活动分支创建并检出初始分支到工作区目录
- 克隆仓库到一个新的目录,实际上就是
- 同步仓库
- 通过
git fetch
更新所有远程追踪分支 - 通过
git pull
合并远程master
分支到本地master
分支,快速合并
- 通过
类似于如下过程:
git init
-> git remote set-url origin git://...
-> git fetch
-> git pull
-> git checkout HEAD
通过以上核心功能可以看到,Clone
实际上是对多个命令功能的组合,并进行了一些配置工作。这样我们可以猜测,Clone
命令的很多参数应该是从其他命令继承过来的。
克隆一个远程仓库需要存在一个远程仓库,并且有一个可以访问的远程仓库的地址,Git支持多种访问协议,最常见的如 git://
和 https://
。
详见:GIT-URLS
下面是一个常见的 clone
操作:
1 | $ git clone git@github.com:liuyanjie/spec.git |
通过输出内容可以看到,Clone
主要都做了哪些事情,可以对比上面的过程描述。
1 | $ cat spec/.git/config |
git clone
之后的仓库配置中,可以看到多出了以上内容,配置了对应的远程仓库地址及追踪关系,配置了本地 master
对应的远程分支,该配置为 Git 默认配置,一般不需要修改。
常见用法:
1 | # 克隆本地仓库 默认是硬链接的,关闭需要加 --no-hardlinks |
在 Clone
的过程中,通过一些参数可以有效的减少 Clone
的等待时间,如在 CI 的构建流程中,可以提高构建时间。
了解 git clone
命令的实际工作流程,能够了解 clone
的过程做了什么以及能做到什么,日常使用也用不到很多复杂的操作,关于更多的命令参数,可自行通过文档了解。
仓库同步
因为 Git 是一个分布式的版本控制系统,同时存在多个仓库副本,仓库副本之间的同步是非常重要的一环。不同于许多分布式系统(例如分布式数据库)能够自动完成节点间的数据同步,Git 无法自动的完成仓库同步,所以仓库同步完全依赖于使用者自行通过各类操作界面完成。
数据同步的内容主要有:分支、标签、数据等内容
数据同步基于 RefSpec ,它描述了本地仓库与远程仓库间分支和标签的映射关系及同步策略。
下面示例中 +refs/heads/*:refs/remotes/origin/*
即为 RefSpec
。
1 | $ cat .git/config |
Ref
示例中的 RefSpec
表明:远程仓库中所有分支 refs/heads/*
,对应到本地仓库下所有分支 refs/remotes/origin/*
,分支名称不变。如果需要改变分支名称,则需要配置针对分支特定的 RefSpec
。
在了解 RefSpec
之前,需要先了解下 Ref
:Git 内部原理 - Git References
如同描述的一样,RefSpec
描述了 remote-refs
和 local-refs
的对应关系。
RefSpec 写法示例:
1 | +refs/heads/*:refs/remotes/origin/* |
RefSpec
的格式是一个可选的 +
号,接着是 <src>:<dst>
的格式,这里 <src>
是远程仓库的引用格式,<dst>
是将要记录在本地仓库的引用格式。可选的 +
号告诉 Git 在即使不能快速演进的情况下,也去强制更新它,也就是与远程保持强一致的同步。
从远程仓库获取指定数据到本地仓库,如:
1 | branch master <==> +refs/heads/master:+refs/remotes/origin/master |
示例:
1 | $ cat .git/config |
以上对应关系:
local-branch@local | remote-branch@local | remote-branch@remote |
---|---|---|
master |
origin/master |
master |
feature/travis-ci |
origin/feature/travis-ci |
feature/travis-ci |
refs/heads/feature/travis-ci |
refs/remotes/origin/feature/travis-ci |
refs/heads/feature/travis-ci |
表格中,只描述了存在一个远程仓库 origin
的情形,实际上是可能存在多个远程仓库的。
注意:RefSpec
描述了 remote-branch@local
和 remote-branch@remote
之间的对应关系,并不是 local-branch@local
和 remote-branch@remote
之间的关系,它们之间的存在的追踪关系在其他配置项中描述。
local-branch@local
下的 分支,是在本地存在的分支,可能从远程某个分支 checkout
,也可能是本地新建的。
RefSpec
可以应用在命令行中,但是一般不会出现在命令行中,而是由某些命令自动写在配置文件中,并在某些命令执行时自动应用配置。
例如:git remote add remote-name
,Git 会获取远端上 refs/heads/
下面的所有引用,并将它写入到本地的 refs/remotes/remote-name
。
1 | git remote add liuyanjie git@github.com:liuyanjie/knowledge.git |
以下几种方式是等价的:
1 | git log master |
通常都是使用省略 refs/heads/
和 refs/remotes/
的形式。
以上示例中 RefSpec
中包含 *
会使 Git 拉取所有远程分支到本地,如果想让Git只拉取固定的分支,可以将 *
修改为指定的分支名。
也可以在命令行上指定多个 RefSpec
,如:
1 | git fetch origin master:refs/remotes/origin/master topic:refs/remotes/origin/topic |
同样,也可以将以上命令行中的 RefSpec
写入配置中:
1 | [remote "origin"] |
以上,feature
可以看做是命名空间,划分不同的分支类型。
上面描述都是拉取时 RefSpec
的作用,同样推送是也需要 RefSpec
1 | git push origin master:refs/heads/qa/master |
推送一个空分支可以删除远程分支
1 | git push origin :refs/heads/qa/master |
RefSpec
描述了本地仓库分支和远程仓库分支的对应关系。很多时候可以省略,因为 Git 包含了很多默认行为。
远程仓库 refs/heads/*
中 的分支大都是 其他 本地仓库
同步到远程的。
远程仓库 refs/heads/*
中 创建
的新分支,在同步数据的时候默认会被拉到本地,删除
的分支默认不会在本地进行同步删除,修改
的分支会被更新,并与本地追踪的开发分支进行合并。
以上,通过 RefSpec
描述的 本地仓库 和 远程仓库 中 分支 是如何对应的,了解了 本地仓库 和 远程仓库 之间的对应关系。
git remote
管理本地仓库对应的一组远程仓库,包括 查看、更新、添加、删除、重命名、设置 等一系列操作
该命令的主要工作是在维护配置文件,也就是维护 .git/config
,通常当不记得命令的时候,可以直接修改配置文件,因为配置文件格式很简单,很容易记忆。
1 | git remote [-v | --verbose] |
1 | git remote # 列出已经存在的远程分支 |
1 | $ git remote show origin |
git fetch
从另外一个仓库下载 Refs,以及完成他们的变更历史所需要的 Objects,追踪的远程分支将会被更新。
从一个或多个其他存储库中获取分支,以及完成它们的历史记录所需的对象,追踪的远程分支将会被更新(具体策略取决于 RefSpec
)。
默认情况下,还会获取指向要获取分支的历史记录上的标签,效果是获取指向您感兴趣的分支的标签。分支和标签统称为 Refs
。也可以改变这种行为。
git fetch
的主要工作就是和远程同步 Refs
,而 Refs
可以 被 创建、修改、删除
,所以 fetch
操作必然应该能够同步这些变化。
.git/FETCH_HEAD
:是一个版本链接,记录在本地的一个文件中,指向着目前已经从远程仓库取下来的分支的末端版本。
1 | $ cat .git/FETCH_HEAD |
执行过 fetch
操作的项目都会存在一个 FETCH_HEAD
文件,其中每一行对应于远程服务器的一个分支。当前分支指向的 FETCH_HEAD
,就是这个文件第一行对应的那个分支。
从本质上来说,唯一能从服务器下拉取数据的只有 fetch
,其他命令的下拉数据的操作都是基于 fetch
的,所以 fetch
必然需要能够尽可能处理所有下拉数据时可能出现的情况。
Options:
[shallow] 限制下拉指定的提交数:
--depth=<depth>
--deepen=<depth>
[shallow]限制下拉指定的提交时间:
--shallow-since=<date>
--shallow-exclude=<revision>
[deep]
--unshallow
,deep clone
--update-shallow
[prune] 剪枝操作
远程仓库可能对已有的分支标签进行删除,而本地仓库并未删除,需要同步删除操作
-p
--prune
-p
--prune-tags
[tags] 默认情况下,
git fetch
会下拉tag
-t
--tags
【默认】下拉标签-n
--no-tags
不下拉标签
子模块
--recurse-submodules-default=[yes|on-demand]
--recurse-submodules[=yes|on-demand|no]
--no-recurse-submodules
--submodule-prefix=<path>
再看对应关系:
local-branch@local | remote-branch@local | remote-branch@remote |
---|---|---|
master |
origin/master |
master |
feature/travis-ci |
origin/feature/travis-ci |
feature/travis-ci |
refs/heads/feature/travis-ci |
refs/remotes/origin/feature/travis-ci |
refs/heads/feature/travis-ci |
git fetch
将 remote-branch@remote
fetch remote-branch@local
,而 RefSpec(+refs/heads/*:refs/remotes/origin/*)
前面的 +
使 Git 在不能快速前进的情况下也强制更新,所以不会出现 remote-branch@remote --merge--> remote-branch@local
的情况,实际上合并是不合理的行为,因为本地的 refs/remotes/origin/*
就是与远程保持同步的,如果合并了,就不同步了,更重要的是,远程分支可能修改了分支历史,如果合并,修改前的内容又合并进版本库了,有可能还需要解决冲突,而之后的 remote-branch@local --merge--> local-branch@local
又会有可能合并。
1 | git fetch # 获取 所有远程仓库 上的所有分支,将其记录到 .git/FETCH_HEAD 文件中 |
1 | git fetch --depth=3 --no-tags --progress origin +refs/heads/master:refs/remotes/origin/master +refs/heads/release/*:refs/remotes/origin/release/* |
示例:
1 | $ git fetch --prune --progress --verbose --dry-run |
--prune
只能清理 .git/refs/remotes/remote-name
目录下的远程追踪分支,而不会删除 .git/refs/heads
下的本地分支,即使这些分支已经合并,这些分支的清理需要特定的命令。
清理本地已合并的分支:
1 | git branch --merged | egrep -v "(^\*|master|develop|release)" # 查看确认 |
1 | $ git branch --merged | egrep -v "(^\*|master|develop|release)" | xargs git branch -d |
清理远程已合并的分支:
1 | $ git branch -r --merged | egrep -v "(^\*|master|develop|release)" | sed 's/origin\//:/' # 查看确认 |
1 | $ git branch -r --merged | egrep -v "(^\*|master|develop|release)" | sed 's/origin\//:/' | xargs -n 1 git push origin |
1 | $ git fetch origin master:refs/remotes/origin/master topic:refs/remotes/origin/topic |
在上面这个例子中, master
分支因为不是一个可以 快速演进
的引用而拉取操作被拒绝。你可以在 RefSpec
之前使用一个 +
号来重载这种行为。
输出格式:
1 | <flag> <summary> <from> -> <to> [<reason>] |
输出格式详细介绍见:OUTPUT
fetch
负责将 远程仓库 更新到 远程仓库在本地的对应部分,其他工作又其他 命令 负责。
在实际使用中,大多数时候都是使用 pull
间接的使用 fetch
。
git pull
将来自远程存储库的更改合并到当前分支中
1 | git pull [options] [<repository> [<refspec>…]] |
1 | git pull origin master # 获取远程分支 master 并 merge 到当前分支 |
默认模式下,git pull
等价于以下两步:
1 | git fetch |
特例:
1 | git fetch |
更确切的说,git pull
已指定的参数运行 git fetch
,然后 调用 git merge
合并 检索到的分支头到当前分支,通过 --rebase
参数,git merge
也可以被替换成 git rebase
。
假定有如下的历史,并且当前分支是 master
:
1 | master on origin |
调用 git pull
时,首先需要 fetch
变更从远处分支,下拉之后的仓库状态:
1 | master on origin |
因为 远程分支 master (C) 已经和 本地分支 master (G) 已经处于分离状态,此时,git merge
合并 origin/master
到 master
。
1 | master on origin |
以上过程发生了一次 远程
合并到 本地
的情形,git 会自动生成类似下面的 commit message
:
1 | Merge branch 'master' of github.com:liuyanjie/knowledge into master |
出现 远程
合并到 本地
的情形 在 Git 中是一种不良好的实践,应该极力避免甚至是禁止出现,这种情形在多个人同时在同一个分支上开发的时候非常容易出现。
记住一点:一般来书,分支
是要合并到远程服务器上的分支,而不是远程服务分支合并到本地分支的。
在实际开发过程中,所有的合并操作都应该发生在远程服务器上,保持所有的分支有清晰的历史。同样,也应该避免不必要的合并,甚至是禁止合并。
一般情况下,创建了分支必然需要通过合并来将分支上的内容整合到分支的基上,但是也有不合并的其他方法
合并产生的 Commit
并未给版本库带来新的改变,但是却使版本历史不够清晰了。
合并使分支历史从单向链表变成了有向图,一堆线杂乱无章交错,分支历史难以理解。
合并产生的 Commit
有两个或多个父 Commit
, Reset
难以进行。
如何避免 本地合并?
- 在
commit
之前先pull
,避免分叉。 - 在
commit
之后立即push
,使其他人的本地仓库能及时获取到最新的commit
。
知道一定会 发生本地 合并时如何处理?
git pull --ff-only
orgit fetch
git rebase origin/master
已经出现 本地合并 如何解决?
git reset C
重置当前分支到C
,F
G
会重新回到暂存区。git commit -am "commit message"
重新提交。git push
解决之后的分支图:
1 | master on origin |
假设版本库当前的状态如下:
1 | master on origin |
以上版本库库满足快速前进的条件,可以进行快速前进 --ff
:
1 | master on origin |
以上版本库满足快速前进的条件,可以进行快速前进 --ff
:
1 | master on origin |
快速前进不产生新的 Commit
,效果上只移动分支头即可,默认情况下进行就是快速前进
在能够进行快速前进的情况下,也可以强制进行合并,如下:
1 | master on origin |
所以 git pull
的参数主要由 git fetch
和 git merge
的参数组成。
git pull
的运行过程:
- 首先,基于本地的
FETCH_HEAD
记录,比对本地的FETCH_HEAD
记录与远程仓库的版本号 - 然后通过
git fetch
获得当前指向的远程分支的后续版本的数据 - 最后通过
git merge
将其与本地的当前分支合并
若有多个 remote,git pull remote_name 所做的事情是:
- 获取
[remote_name]
下的所有分支 - 寻找本地分支有没有
tracking
这些分支的,若有则merge
这些分支,若没有则merge
当前分支
另外,若只有一个 remote,假设叫 origin,那么 git pull 等价于 git pull origin;平时养成好习惯,没谱的时候都把【来源】带上。
怎么知道 tracking
了没有?
- 如果你曾经这么推过:
git push -u origin master
,那么你执行这条命令时所在的分支就已经tracking to origin/master
了 - 如果你记不清了:
cat .git/config
,由此可见,tracking
的本质就是指明pull
的merge
动作来源
总结:
git pull = git fetch + git merge
git fetch
拿到了远程所有分支的更新,cat .git/FETCH_HEAD
可以看到其状态,若是not-for-merge
则不会有接下来的merge
动作merge
动作的默认目标是当前分支,若要切换目标,可以直接切换分支merge
动作的来源则取决于你是否有tracking
,若有则读取配置自动完成,若无则请指明【来源】
pull
时还可能存在远程分支不存在的情况
1 | $ git checkout -b test |
1 | $ git pull |
需要提及的一点是:
pull
操作,不应该涉及 合并
或 变基
操作,即 pull
应该总是 快速前进 的。
再看对应关系:
head@local | remote@local | remote@remote |
---|---|---|
master |
origin/master |
master |
feature/travis-ci |
origin/feature/travis-ci |
feature/travis-ci |
refs/heads/feature/travis-ci |
refs/remotes/origin/feature/travis-ci |
refs/heads/feature/travis-ci |
git pull
在 git fetch
的基础之上增加了 git merge
,将 远程分支对应的本地分支
合并到 追踪的本地开发分支
git push
使用本地引用更新远程引用,同时发送完成给定引用所必需的对象。
git push
是与 git pull
相对应的推送操作,同样需要能够推送本地的多种情形的变更到远程仓库。git 向远程仓库推送的操作只有 push
。
1 | git push |
1 | git push # 如果当前分支只有一个追踪分支,那么主机名都可以省略 |
推送模式:
- simple 模式: 不带任何参数的git push,默认只推送当前分支。2.0以上版本,默认此方式。
- matching模式: 会推送所有有对应的远程分支的本地分支。
1 | git config --global push.default matching |
1 | $ git push |
1 | git push --delete ref... |
推送代码到服务器与拉取代码到本地其实是相同的,所以服务代码推送到服务全之后,同样有可能出现需要合并的情况,如推送者本地仓库在没有 pull
后进行 commit
后 push
,导致本地代码和远程服务器代码分叉,此时服务端也要面临合并问题,合并就有可能产生冲突,但是服务端没有解决冲突的能力,所以实质上服务端是禁止发生合并的,只能进行快速前进。当不能快速前进,服务端会返回错误给客户端,错误会提示先 pull
再 push
。此时,pull
操作是一定会进行 merge
的,可能需要处理 merge
,此时就需要处理前面提到的处理本地合并的问题了。
git submodule
初始化、更新或检查子模块
gitsubmodules - mounting one repository inside another
1 | git submodule [--quiet] add [-b <branch>] [-f|--force] [--name <name>] |
添加
1 | git submodule add -b master --name knowledge --reference=/Volumes/Data/Data/ws/knowledge -- git@github.com:liuyanjie/knowledge.git ./third_parts/knowledge |
1 | $ git status |
1 | $ git commit -m "..." |
1 | $ git push |
分支管理
Git 是一个分布式的结构,有本地版本库和远程版本库,便有了本地分支和远程分支的区别了。
本地分支和远程分支在 git push
的时候可以随意指定,交错对应,只要不出现版本从图即可。
git-branch
创建、修改、删除、查看、重命名、复制分支
1 | # 创建分支 |
git branch
只能操作本地仓库,无法直接操作远程仓库,操作远程仓库必须通过 git push
。
remotes/origin/*
下的分支删除:
1 | git push --delete <branch-name> |
以上命令在删除远程仓库的分支的同时,同步删除 remotes/origin/*
下的分支
1 | git branch -d <remote-name>/<branch-name> |
以上命令删除 remotes/origin/*
下的分支,但是远程分支并未删除,在 git fetch
后还会拉下来,所以这种删除无意义。
分支类型:
- 远程分支(remote-branch),远程服务器上的分支,
refs/heads/*
@remote,是远程追踪分支
的上游分支
。 - 远程追踪分支(remote-tracking branch),远程服务器对应在本地的分支,与
远程分支
存在追踪
关系,可能是本地分支
的上游分支
。 - 本地分支(local branch),
refs/heads/*
@local,可能与远程追踪分支
存在追踪
关系。
分支关系:
- 追踪分支(tracking branch),能够主动追踪其他分支,自动跟随其他分支变化更新的分支。
- 上游分支(upstream branch),被追踪的分支。
Checking out a
local branch
from aremote-tracking branch
automatically creates what is called a“tracking branch”
(and the branch it tracks is called an“upstream branch”
).
只有把概念定义清楚,才能够进行准确的描述,要不然都可能带来理解上的偏差。
git-tag
创建、删除、查看、校验标签
1 | git tag [-a | -s | -u <keyid>] [-f] [-m <msg> | -F <file>] [-e] <tagname> [<commit> | <object>] |
1 | # 查看分支 |
与分支不同,git push
默认不推送标签到远程,所以需要主动推送标签:
1 | git push --tags |
同样,git tag
只能操作本地仓库,无法直接操作远程仓库,操作远程仓库必须通过 git push
,通常也不会直接操作远程仓库。
1 | git push --delete <tag-name> |
清理 远程不能存在本地存在 的标签:
1 | git tag -l | xargs git tag -d ; git fetch --tags |
标签并不像分支那样,存在远程标签/本地标签等区分,所以也不存在本地标签与远程标签之间的对应关系,自然也就不需要维护对应关系。
git-checkout
- 切换分支并检出内容到工作区,也可创建分支
检出已存在的分支
1 | git checkout <branch> |
创建并检出分支
1 | git checkout -b|-B <new_branch> [<start-point>] |
检出tree-ish
1 | git checkout [<tree-ish>] [--] <pathspec>… |
检出内容到本地的时候会发生什么?
- 本地是干净的,无任何修改
- 本地存在新增加的文件
- 本地存在修改后未提交的文件
Ref:DETACHED HEAD
HEAD 通常指向某一个分支,这一分支即是当前工作的分支。当 HEAD 不再指向分支的时候,仓库即处于 DETACHED HEAD
状态。
1 | $ git checkout ccdd28a |
处于这种状态下的仓库,如果进行修改并且提交,就会很危险,因为没有任何分支指向新的提交,当 HEAD
切换到其他位置的时候,当前的修改就不容易找不到了。
如果需要基于此节点进行修改,需要先基于此节点创建分支。
git-merge
将两个或多个分支历史合并在一起
1 | git merge |
有如下版本库:
1 | A---B---C topic |
1 | git merge topic |
合并后
1 | A---B---C topic |
squash mode
1 | git merge --squash topic |
1 | A---B---C topic |
--squash
效果相当于将 topic 分支上的多个 commit A-B-C 合并成一个 ABC,放在当前分支上,原来的 commit 历史则没有拿过来。
判断是否使用 --squash
选项最根本的标准是,待合并分支上的历史是否有意义。版本历史记录的应该是代码的发展,而不是开发者在编码时的活动。
只有在开发分支上每个 commit 都有其独自存在的意义,并且能够编译通过的情况下,才应该选择缺省的合并方式来保留 commit 历史。
fast forward mode
1 | A---B---C topic |
1 | git merge --ff topic |
1 | A---B---C topic master |
合并的前提是:准备合并的两个 commit
不在一条直线上,在一条直线上可以进行快速前进,也可以使用 --no-ff
强制合并(无意义)。
合并的过程中需要处理可能得冲突,未冲突的文件将会进行自动合并,在新版本的tree
中产生一个新版本的blob
,所以Git能够完整检出不需要依赖历史中的commit
,只需要当前的commit
。
合并的结果是:产生一个新的 commit
,实际上,squash mode
fast forward mode
并不是真正意义上的合并。
冲突:
冲突有两种类型,一种是树冲突,修改/删除同一文件,另一种是文件冲突,修改了同一文件中的相同内容。
冲突是如何判断的?
1 | A---B---C topic |
假如有文件 README.md
在 E
,且 topic
和 master
都有修改此文件,合并 topic
到 master
时,冲突检查的依据不是对比 README.md@topic
和 README.md@master
是否相同,而是对比 README.md@topic
和 README.md@master
相对于 E
的变化。即使是 README.md
文件在被修改后的内容是相同的,也会产生冲突。而冲突产生的文件,就是将 相对于 E
,都合并到同一个文件中,并交由用户解决。
1 | <<<<<<< yours:sample.txt |
最佳实践:
- 尽量避免在本地使用
merge
,也尽量避免在本地发生Merge
。 merge
时,本地不要有未提交的更改,这些修改可能会在中断合并时丢失。
基本操作
git add
添加文件到索引中,为下一次提交准备内容。
将工作区的修改添加到暂存区中,此命令使用在工作树中找到的最新内容更新索引,以准备为下次提交暂存内容。
典型的情况下,将整个文件添加到暂存区中,通过特定的选项,也可以将工作区修改的部分内容加到暂存区中。
暂存区保存工作树内容的快照,并将此快照作为下一次提交的内容。因此,在对工作树进行任何更改之后,在运行commit命令之前,必须使用add命令将任何新的或修改的文件添加到索引中。
默认情况下,git add
不会添加忽略的文件,git add -f
会进行强制添加。
git rm
从工作区和暂存区移除文件
1 | git rm [-f | --force] [-n] [-r] [--cached] [--ignore-unmatch] [--quiet] [--] <file>… |
1 | git rm *.txt |
等价于:
1 | rm *.txt |
仅从暂存区删除内容
1 | git rm --cached *.txt |
git mv
重命名或移动文件,同步更新暂存区
1 | git mv <options>… <args>… |
1 | git mv old_name new_name # 重命名 |
git diff
Show changes between commits, commit and working tree, etc
1 | git diff [options] [<commit>] [--] [<path>…] |
1 | git diff # 查看尚未暂存的文件更新了哪些部分,不加参数直接输入。 |
git commit
Record changes to the repository
1 | git commit # 提交的是暂存区里面的内容,也就是 Changes to be committed 中的文件。 |
对于 commit 来说,最重要的是,每一次 commit 都应该是一个完整的提交,而且应该有个规范清晰的注释。
Commit message 和 Change log 编写指南
git status
显示工作树状态
1 | git status [<options>…] [--] [<pathspec>…] |
1 | git status # 显示状态 |
git reset
重置工作区,将当前分支回退到某一节点
1 | git reset [-q] [<tree-ish>] [--] <paths>… |
git reset
会修改当前分支头从某一个 <commit-id>
移动到另外的一个指定的 <commit-id>
如:
1 | HEAD |
当前活跃的分支是 topic
1 | git reset A |
执行以上操作后:
1 | HEAD |
此时,HEAD -> topic -> A
,B、C 此时处于悬挂状态,如同普通的对象一样,没有任何引用后,会被 Git GC 回收。
执行此操作后,B、C 两点虽然依然存在于仓库中,但是它们已经逻辑上脱离了Git。
此时,B、C 两点提交的内容怎么办?是不是就丢失了呢?
Git 给了我们多种选择:
- –soft,B、C 提交的内容不会回到工作区和暂存区。因为当前工作区内容是基于 C 修改的,所以实际上并无内容丢失。
- –mixed,B、C 提交的内容回到暂存区,但是工作区内容不变,也就是某些文件处于
修改未提交状态
。同上,也无内容丢失。 - –hard,B、C 提交的内容不会回到工作区和暂存区,同时暂存区和工作区回到A点状态,B、C 提交的内容以及工作区后续的修改全部丢失。
如果 reset
误操作操作怎么办?
如果存在上游分支,可以通过上游分支恢复
1
2git reset master^2
git reset origin/master可以通过
reflog
恢复reflog
记录 HEAD 的变化,所以可以通过reflog
找到reset
之前的HEAD
的位置,但是前提是后续节点未被垃圾回收。1
2
3
4
5
6
7
8
9
10
11git reflog
58c1d5d (HEAD -> master, origin/master) HEAD@{0}: commit: update git
ccdd28a (test2, test1, test) HEAD@{1}: checkout: moving from test to master
ccdd28a (test2, test1, test) HEAD@{2}: checkout: moving from master to test
ccdd28a (test2, test1, test) HEAD@{3}: commit: git update
e081fb3 HEAD@{4}: commit: update python
d26f671 HEAD@{5}: commit: update
33db13f HEAD@{6}: commit (amend): update and format
5c41033 HEAD@{7}: commit: update and format
e56ec4e HEAD@{8}: commit: 移除乱码字符
6595b95 HEAD@{9}: commit: feat(): add hexo.yaml
未提交的内容是很难就行恢复的,所有在进行 reset
操作时,要将工作区的内容提交。
reset 除了将工作区回退到某个节点之外,常用的应用就是将后续的多个提交合并为一个提交,因为后续提交的内容可以回到暂存区或工作区中。
在某些Git工作流中,要求将多个提交合并成一个之后才能合并到上游分支。
git revert
1 | git revert [--[no-]edit] [-n] [-m parent-number] [-s] [-S[<keyid>]] <commit>… |
1 | git revert HEAD~3 |
git revert
用于撤销一个或多个提交,并建立一个新的提交。commit
中所做的修改都会被移除掉,相当于 commit
反向操作。
git revert
通常用户快速回滚。
示例如下:
1 | HEAD |
1 | git revert C |
1 | HEAD |
C'
是一个全新的 Commit
与 C
是不同的,但是这种情况下,C'
与 C
中的 tree
是相同的。
git rebase
变基操作,基指的是起始提交,即参数中常见的
1 | git rebase [-i | --interactive] [<options>] [--exec <cmd>] [--onto <newbase>] [<upstream> [<branch>]] |
示例:
1 | A---B---C ← topic |
1 | git rebase master |
1 | A'--B'--C' ← topic |
以上,通过变基操作,将topic分支的 E
调整到了 G
。
变基操作的原理:将 A B C 基于 G 重新提交,提交的过程可能与 F G 存在冲突,需要解决冲突。
变基操作的应用:
- 保持与上游分支同步,同步上游分支的最新版本
- 合并时存在冲突,通过变基操作解决冲突