Git常用操作的底层原理详解

1,656 阅读11分钟

Git

本文将对git底层原理进行剖析,并对常用命令Git内部所做工作进行详解。

同时结合场景进行说明。

结构图

Git关系图

  • Workspace:工作区(当前用户操作修改的区域)
  • Index/Stage:暂存区 (add后的区域)
  • Repository:仓库区或本地仓库(commit后的区域)
  • Remote:远程仓库(push后的区域)

.git文件夹

  • hooks/:存放一些脚本,结合特定操作触发,一般是提交的各个阶段。例如commit格式检查
  • logs/:记录,git log/reflog
  • objects/:存放所有的git对象
  • info/
    • exclude:排除提交规则,但是不会上传到remote
  • refs/:主要存储分支和标签的引用
  • config:项目配置项
  • description:用于GitWeb,忽略
  • HEAD:当前位置指针
  • index:也称为stage,是个对objects/的索引文件。
  • COMMIT_EDITMSG:存储最后一次的commit message
  • FETCH_HEAD:这个文件作用在于追踪远程分支的拉取与合并

Git commit 内部抽象

首先在了解Git操作之前,需要理清Git内部的构成。

image-20200520095107940

Objects

  • git add命令之后 生成object文件在objects/目录下,由index块和commit引用。

  • 每次保存都是全量,并不是增量。

HEAD

  • 指向当前工作区所在commit结点。

branchname

  • 分支名(例如master),也是一个指针,只会跟随HEAD移动,例如commit reset等操作。

  • 删除分支,只是删除指针,不会清除commit结点。

  • commit结点只会由git自行gc垃圾回收。一般来说会回收游离结点。

FETCH_HEAD

  • 拉取的远程分支commit的当前指针

此部分结合具体操作进行讲解。

命令操作及其原理

结合commit内部抽象,进行详细说明。

(只按照常规操作及默认配置,不考虑有一些技巧可以达到随意变更的效果)

整体过程

  • 工作区--> add-->暂存区--> commit-->本地仓库区--> push-->远程仓库区
  • 远程仓库区--> fetch-->使用refs\remotes下对应分支文件记录远程分支末端commit_id 和 本地仓库区 --> merge-->工作区
  • 远程仓库区--> pull-->使用refs\remotes下对应分支文件记录远程分支末端commit_id and 本地仓库区 and 工作区

配置Git相关信息

git config [--global]/[--local] user.name "your name"
git config [--global]/[--local] user.email "your email"

用于配置git用户信息,例如在github等协作平台中看到的编辑者用户名

初始化

git init # 初始化git仓库

git add . # 将当前工作区所有变更暂存到缓冲区,也可以指定单个文件
e.g.
git add <文件名> 

git commit -m "<变更说明>" # 提交到本地仓库,并携带变更说明,这里经常结合Hook使用

此时会生成第一个commit结点。

checkout

git checkout用于移动HEAD指针,但不会移动当前分支指针.

如图所示,执行git checkout xxxxxx4,(xxxxxx4 为 commit id),内部结构会发生如下变化

image-20200520095249378

对应的git 终端会返回

image-20200520095406139

此时指向的是一个没有分支指向的结点xxxxx4,这样的指向,会带来很多问题。

  • 如果此时commit一个提交,则会在xxxx4处开辟一个分支生成commitid 假设为xxxxxa,但却没有分支名/指针指向,只有当前HEAD指向,那么如果HEAD移动到其他结点,新的xxxxxa常规操作就无法指向,也就会成为游离结点,git会针对这类结点进行gc垃圾回收,则此次commit无效。大致结构如图所示

    image-20200520100050819

    执行git checkout master 切换回master

    image-20200520100216325

checkout之后,HEAD指向改变,本次未修改的文件会被覆盖。未保存的修改如果只是追加,则不会被覆盖,但如果改动复杂,git会禁止切换分支,需要先stash一下。

  • 常用命令
git checkout -- filename 	# 用于回退文件(若已提交,则无效)
git checkout commitid 		# 用于切换到快照
git checkout branchname 	# 切换分支

上面说到branchname本质就是个指针,那么与commitid等价,所以2、3效果一致。

reset

reset常用于回退版本,本质上也是移动指针,但与checkout区别在于,移动HEAD的同时,也会移动分支名指针。(不会移动远程分支指针)

image-20200520103212924

这样xxxxx5 commit就成了游离结点,会被git回收,以此做到版本回退。

  • 其实git reset之后使用git log看不到记录,但git reflog还是可以看到的。如果一顿操作毁尸灭迹,那还有何意义呢。

参数

--hard 	# 覆盖暂存区和工作目录
--soft  # 保留暂存区和工作目录
--mixed # 默认,保留工作目录,清空暂存区

分支操作

git branch branchname 		# 新建分支
git branch -d branchname 	# 删除分支
git checkout branchname 	# 切换分支

这一部分在checkout中已做解释。删除分支需要注意是否被合并,如果被合并则无法删除路径,因为有后继结点,不是游离结点。

合并提交

git merge branchname		# 将branchname分支合并到当前分支
git rebase branchname 		# 将当前分支变基到branchname分支上

merge

这里需要对git分支相关的原理进行说明,才可以理顺merge rebase及使用过程中容易出错的点。

  • 如果是在同一链路,则直接移动指针,fast-forward
  • 如果不在同一链路,则会产生新的commit

image-20200520095107940

如上图,现在执行git merge branchA,commit链路会产生下图的变化:

image-20200520095004687

  • 可以看到所谓merge合并,只是在当前分支将另一分支的变动xxxxx6合并,并处理冲突,然后产生一个新的结点xxxxx7,
  • reset了HEAD指针(包含master指针)指向最新的commit7.
  • branchA指针并没有移动。此时commit有两个前驱commit结点,这就导致了,如果删除branchA,也不会删除分支链路。

rebase

git merge branchname命令执行过后,会留下被合并分支的路径。如果不想留下路径,可以使用rebase变基操作。

目前分支结构如下

image-20200520110550823

由test变基到master分支后:

image-20200520110912815

过程中会多次处理冲突。

可以看到,将test不同于master分支的结点重新commit,形成新的结点commitid。

  • 会移动当前分支test指针,不会变更附加分支master指针。

  • 如果需要移动master指针,可以尝试git reset向后移动。但是不建议这么用,建议再merge或者rebase一次。此时merge则会触发fast-forward直接移动指针。

    image-20200520124958350

回退

上面提到reset可以回退,这里进行下总结。

未存入index

如果是没有自动保存的文件,放弃保存即可。

如果已保存,则使用checkout命令

git checkout -- filename  	# -- 必须有,否则成了切换分支命令
git checkout . 				# 回退当前目录所有文件
# checkout只会回退之前已追踪文件,新文件不会被包含

已使用 git add 提交到 index

git reset HEAD readme.md
git reset HEAD .

已使用 git commit 提交代码

git reset --hard HEAD^ # 回退到上一次commit
git reset --hard commitid # 回退到指定commit,会回退分支指向
git checkout commitid # 不会回退分支名指向,

注意:

  1. 需要注意checkout之后HEAD游离的情况,这是因为Git commit 是在HEAD后追加 而不是branch后。
  2. reset之后,会出现远程push不上去的问题,这是因为落后了远程的提交,需要git pull再合并,但是这样的话之前的结点会显示,比如直接使用git revert

git revert

# 选取需要撤销的commitid
git revert commitid # 撤销该版本的改动,需要处理冲突

远程操作

git remote add <远程源名> url # 添加远程仓库源
git remote rm <远程源名> # 清除远程仓库源

git push <远程源名> <本地分支名>:<远程分支名> # 将本地分支推到指定的远程分支
e.g. 
git push origin master # 同名分支可简写
git push -u origin master # -u 参数用于指定默认源,后续操作直接git push即可

git pull <远程源名> <远程分支>:<本地分支> # 将远程分支的变更同步到本地,通常在commit前执行一次
# 这里有个小区别,如果不加本地分支,并且本地无此分支,则不会自动新建

Tip

  1. 分支推送顺序的写法是<来源地>:<目的地>,所以git pull是<远程分支>:<本地分支>,而git push是<本地分支>:<远程分支>。

  2. 注意 不可以写成 git push origin :master 这样等同于推送了空的分支 进而清空master分支。 等价于git push origin --delete <分支名> ,这一类远端指令最好不用

git pull 与 git pull --rebase

默认配置下,git pull origin master 等同于git fetch origin master + git merge FETCH_HEAD

git pull --rebase origin master 等同于git fetch origin master + git rebase FETCH_HEAD

  • git fetch 会将远程分支更新到本地仓库中,由FETCH_HEAD指向
  • 然后和本地合并一样,操作的是目的分支和FETCH_HEAD,可以参考上面部分进行理解

分支不一致

初始如下

image-20200520121908282

到github上从commit “2.0” 后新建test分支,并提交一个commit "g"

本地提交一个新的commit “2.1”,然后新建test分支,并提交一个commit “2.2”

至此,做到test本地和远程链路不一致。

本地:

image-20200520123618809

很显然push不上去。

在test分支git pull

image-20200520123940765

有时即使是pull下来,也是无法直接合并的,需要手动指定分支合并origin/test

image-20200520124335945

此时再push即可,以为相比于之前,产生了新的commit “3.0”,冲突结点包含在了不同的链路,都成为了本次commit “3.0”的前驱。

其实本质上就是本地/远程多了自身的commit,将test分支链路独立,即可看明白。

子模块

实际上push的子模块只是个commitid引用,是相互隔离的。

image-20200520154411446

可以看到这个目录只是单个文件而已,存储的是引用版本的commit。

image-20200520155008591

对应github中显示的@也是此版本commit id

image-20200520155231849

场景:此时无子模块

  • 添加子模块
git submodule add url [pathname]

此时会生成
|-.gitmodules文件,包含引用信息
|- 引用目录,
同时会clone子模块内容。

github中项目的引用目录会显示有@蓝色标识。

场景:使用已有子模块的git项目

git clone url 						# 会克隆子模块
# 此时子模块目录为空,或者并没有生成目录(因为没有上传目录)

git submodule init
git submodule update --recursive
git clone --recursive url 			# 会克隆子模块,并包含目录文件

# 如果没有包含,则继续
git submodule init
git submodule update --recursive

此部分命令可查看官方文档

子模块版本

指定子模块版本

  1. 进入子目录
  2. 执行 git checkout commitid

image-20200520155517949

可以看到并没有更改内容,只是修改了commitid而已。

对应的github也进行了修改

image-20200520155602362

更新子模块版本

git submodule foreach git pull
# 默认更新到最新版本
# 或进入子目录,checkout切换想要的版本

场景:push代码

  • module包含在.gitignore中,则不会上传变更,也无法切换版本,一般不这么做。

  • module若不在.gitignore中,如果子模块修改,需要先到子仓库执行提交,才能回到父仓库提交。

Stash

git stash命令的作用就是将目前还不想提交的但是已经修改的内容进行保存至堆栈中。

包括未add 和已add到暂存区的。如果已add,pop后需要重新add。

git stash
git stash pop

应用场景主要是

  1. 将处理未提交变更保存到stash中,以免影响分支操作。

  2. 需要切换分支开发另一任务,而本次开发未到提交阶段。

  3. 在很多时候需要注意工作区是否会被覆盖等问题,也可以先使用stash

.gitignore

参考写法

/target/  # 仅排除当前目录下的target下的所有文件
target/  # 递归排除当前目录下的target下的所有文件,第二个斜杠一定要有

*.a     # 递归排除所有.a结尾文件
!*.a	# 不排除.a结尾文件,优先级更高,用于对排除中的部分文件提取出来
/*.a	# 仅排除当前目录下的.a文件

doc/*.txt # 忽略 doc/notes.txt, 例如不包括 doc/server/arch.txt

doc/**/*.pdf # 忽略所有在doc/下的.pdf 文件 

其他

git rm -r --cached . 	# 清除缓存 解决.gitignore不起作用问题
git log 				# 查看历史提交
git commit -amend 		# 修改最后一次commit msg。保存了当前commit,便于修改
  • 遇事不决 git status 会有提示