Git原理


Git设计

命令所在git版本:2.21.0

远程仓库

分布式的主仓库

本地仓库

分布式的本地仓库

工作区

顾名思义,平时都是在工作区

暂存区

加入暂存区优点:

  • 实现部分提交
  • 不在工作区创建状态文件、这样不会污染工作区
  • 记录文件的修改时间等信息,提高文件比较的效率

本地仓库是如何存放项目历史版本

这是项目的三个版本,Version1中有两个文件A和B,然后修改了A,变成了A1,形成了Version2,接着又修改了B变为B1,形成了Version3

下面是测试上图的操作

mkdir git_test && cd git_test && git init
touch A
touch B
git add .
git commit -m 'first commit'

echo test1 >> A
git add . && git commit -m 'edit A'

echo test2 >> B
git add . && git commit -m 'edit B'

如果我们把项目的每个版本都保存到本地仓库,需要保存至少6个文件,而实际上,只有4个不同的文件,A、A1、B、B1。
为了节省存储的空间,我们要想一个方法将同样的文件只需要保存一份。这就引入了Sha-1算法

可以使用git命令计算文件的 sha-1 值。

echo 'test content' | git hash-object --stdin

d670460b4b4aece5915caf5c68d12f560a9fe3e4

SHA-1将文件中的内容通过通过计算生成一个 40 位长度的hash值,可以作为文件ID、校验文件是否更改

这样就能以新的方法保存文件

.git目录结构

先给项目建一个远程仓库并推送上去

git remote add origin git@github.com:wenjy/git_test.git
git branch -M main
git push -u origin main
git pull

可以先ll .git看一下整个.git的目录结构:

COMMIT_EDITMSG
HEAD
ORIG_HEAD
FETCH_HEAD
config
description
index
hooks/
info/
logs/
objects/
refs/

文件 COMMIT_EDITMSG

此文件是一个临时文件,存储最后一次提交的信息内容,git commit 命令之后打开的编辑器就是在编辑此文件,而你退出编辑器后,git 会把此文件内容写入 commit 记录

cat .git/COMMIT_EDITMSG 输出:edit B

文件 HEAD

此文件永远存储当前位置指针,就像 linux 中的 $PWD 变量和命令提示符的箭头一样,永远指向当前位置,表明当前的工作位置。在 gitHEAD 永远指向当前正在工作的那个 commit

HEAD具体可以分为下面两种:

  • 分支HEAD
    HEAD 存储一个分支的 ref,运行:cat .git/HEAD 通常会显示:ref: refs/heads/main

这说明你目前正在 main 分支工作。此时你的任何 commit,默认自动附加到 main 分支之上

git cat-file -p HEAD, 显示详细的提交信息:

tree 03790b886a0e1df0d63b0ffa6767a424b128b887
parent b1781029acad16e1a60a095e06ad5ca5fb41105d
author wenjiangyi <wenjy1314@gmail.com> 1674874438 +0800
committer wenjiangyi <wenjy1314@gmail.com> 1674874438 +0800

edit B
  • 孤立 HEAD

HEAD 不关联任何分支,只指向某个 commit,运行 git checkout b17810,你会看到如下信息:

Note: checking out 'b17810'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b <new-branch-name>

HEAD is now at b178102 edit A

相信很多人一开始使用 git 都会对这段信息头大,其实它只是告诉你 HEAD 这个文件中存储的信息已不再是一个分支信息,运行:cat .git/HEAD,看到:

b1781029acad16e1a60a095e06ad5ca5fb41105d

看到区别了吗?HEAD 指向一个40字符的 SHA-1 提交记录,git 已经不知道你在哪个分支工作了,所以你如果生成新的 commitgit 不知道往哪里 push,你只能做些实验性代码自嗨一把,无法影响到任何分支,也无法与人协同。这就是所谓的 'detached HEAD' state

文件 ORIG_HEAD

正因为 HEAD 比较重要,此文件会在你进行危险操作时备份 HEADORIG_HEAD,如以下操作时会触发备份:

git reset
git merge
git rebase
git pull

文件 FETCH_HEAD

这个文件作用在于追踪远程分支的拉取与合并,与其相关的命令有 git pull/fetch/merge

git pull 命令相当于执行以下两条命令:

git fetch
git merge FETCH_HEAD

并且,此时会默默备份 HEADORIG_HEAD

看看 FETCH_HEAD 里面有什么内容:

cat .git/FETCH_HEAD
cf661ddf1f225646dd3bee23112c6f2e8a650c43  branch 'main' of github.com:wenjy/git_test

文件 config

此文件存储项目本地的 git 设置,典型内容如下:

[core]
        repositoryformatversion = 0
        filemode = false
        bare = false
        logallrefupdates = true
        symlinks = false
        ignorecase = true
[remote "origin"]
        url = git@github.com:wenjy/git_test.git
        fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
        remote = origin
        merge = refs/heads/main

这是典型的 INI 配置文件,每个 section 可包含多个 variable = value,其中 [core] 字段包含各种 git 的参数设置,如 ignorecase = true 表示忽略文件名大小写

  • [core] 段的内容跟 git config 命令对应

执行以下命令:

git config user.name abc
git config user.email abc@abc.com

会在 config 文件中追加以下内容:

[user]
        name = abc
        email = abc@abc.com

git config --global 影响的则是全局配置文件 ~/.gitconfig

  • [remote] 段表示远程仓库配置

详见 Git Internals - The Refspec,注意这里的 + 与 * 的含义

  • [branch] 段表示分支同步设置

假设当前在 master 分支,执行 git pull 若出现以下提示:

There is no tracking information for the current branch.
Please specify which branch you want to merge with.
See git-pull(1) for details.

   git pull <remote> <branch>

就说明 .git/config 文件缺少对应的 [branch "master"] 字段
解决方案为:

git branch -u origin/master master

# 或者执行一次 push
git push -u origin master

会出现提示:Branch 'master' set up to track remote branch 'master' from 'origin'.

其实就是生成以下内容在 .git/config中:

[branch "master"]
        remote = origin
        merge = refs/heads/master

你去手动编辑 .git/config,效果一样。这就是 upstream 的真正含义,即生成 config 中的这段配置

文件 description

这个文件仅供 GitWeb 程序使用

文件夹 hooks/

存放 git hooks,用于在 git 命令前后做检查或做些自定义动作,也就是一些预定义的钩子,下面是自带的示例文件

prepare-commit-msg.sample  # git commit 之前,编辑器启动之前触发,传入 COMMIT_FILE,COMMIT_SOURCE,SHA1
commit-msg.sample          # git commit 之前,编辑器退出后触发,传入 COMMIT_EDITMSG 文件名
pre-commit.sample          # git commit 之前,commit-msg 通过后触发,譬如校验文件名是否含中文
pre-push.sample            # git push 之前触发

pre-receive.sample         # git push 之后,服务端更新 ref 前触发
update.sample              # git push 之后,服务端更新每一个 ref 时触发,用于针对每个 ref 作校验等
post-update.sample         # git push 之后,服务端更新 ref 后触发

pre-rebase.sample          # git rebase 之前触发,传入 rebase 分支作参数
applypatch-msg.sample      # 用于 git am 命令提交信息校验
pre-applypatch.sample      # 用于 git am 命令执行前动作
fsmonitor-watchman.sample  # 配合 core.fsmonitor 设置来更好监测文件变化

参考 https://git-scm.com/docs/githooks

如果要启用某个 hook,只需把 .sample 删除即可,然后编辑其内容来实现相应的逻辑

比如我们要校验每个 commit message 至少要包含两个单词,否则就提示并拒绝提交,将 commit-msg.sample 改为 commit-msg 后,编辑如下:

#!/bin/sh
grep -q '\S\s\+\S' $1 || { echo '提交信息至少为两个单词' && exit 1; }

这样当提交一个 commit 时,会执行 bash 命令: .git/hooks/commit-msg .git/COMMIT_EDITMSG,退出值不为 0,就拒绝提交

例如Gerrit代码评审的客户端就会利用钩子来生成 Change-ID

文件夹 info/

此文件夹基本就有两个文件:

  • 文件 info/exclude 用于排除规则,与 .gitignore 功能类似。
  • 可能会包含文件 info/refs ,用于跟踪各分支的信息。此文件一般通过命令 git update-server-info 生成,里面的内容:
cat .git/info/refs
cf661ddf1f225646dd3bee23112c6f2e8a650c43        refs/heads/main
cf661ddf1f225646dd3bee23112c6f2e8a650c43        refs/remotes/origin/main
059ed3dd3aa7fbc7111376adc3712971d029b738        refs/tags/v1.0.0
cf661ddf1f225646dd3bee23112c6f2e8a650c43        refs/tags/v1.0.0^{}

这表示 main 分支所指向的文件对象 hash 值为:cf661ddf1f225646dd3bee23112c6f2e8a650c43

文件 info/refs 对于 搭建 git 服务器 来说至关重要

文件夹 logs/

记录了操作信息,git reflog 命令以及像 HEAD@{1} 形式的路径会用到。如果删除此文件夹(危险!),那么依赖于 reflog 的命令就会报错

文件夹 objects/

此文件夹简单说,就是 git的数据库,更具体的下面说

数据库中存储的数据内容

我们看 .git/objects 下的文件

  • Version1
  • Version2
  • Version3

使用 sha-1 的前两位创建了文件夹,剩下的38位为文件名,先称呼这些文件为 obj 文件

这么多的 obj 文件,就保存了我们代码提交的所有记录。对于这些 obj 文件,其实分为四种类型,分别是 blobtreecommittag

执行 git cat-file --batch-check --batch-all-objects

  • blob

A、A1、B、B1 就是 blob 类型的 obj,用来存放项目文件的内容,但是不包括文件的路径、名字、格式等其它描述信息。项目的任意文件的任意版本都是以blob的形式存放的

我们想看下里面到底存的什么?其实这些文件都经过了压缩,压缩形式为 zlib

显示结果都是 type size+内容 形式,这就是 object 文件的存储格式:

[type] [size][NULL][content]

type 可选值:commit, tree, blob, tag,NULL 就是C语言里的字符结束符:\0size 就是 NULL后内容的字节长度。

type 的几种类型可以使用 git cat-file -t hash 看到,内容可以用 git cat-file -p hash 看到

所以 blob 文件就是对原文件内容的全量拷贝,同时前面加了 blob size\0,而文件名称的 hash 值计算是计算整体字符的 SHA-1 值:

  • tree

用来表示目录。我们知道项目就是一个目录,目录中有文件、有子目录。因此 tree 中有 blob、子tree,且都是使用 sha-1值引用的。
这是与目录对应的。从顶层的 tree 纵览整个树状的结构,叶子结点就是blob,表示文件的内容,非叶子结点表示项目的目录,顶层的 tree 对象就代表了当前项目的快照

  • commit

表示一次提交,有parent字段,用来引用父提交。指向了一个顶层 tree,表示了项目的快照,还有一些其它的信息,比如上一个提交,committer、author、message 等信息

  • tag

添加一个tag git tag -a v1.0.0 -m 'add tag v1.0.0',查看文件内容如下:

暂存区

暂存区是一个文件,路径为:.git/index

它是一个二进制文件,但是我们可以使用命令git ls-files --stage来查看其中的内容

100644 a5bce3fd2565d8f458555a0c6f42d0504a848bd5 0       A
100644 180cf8328022becee9aaa2577a8f84ea2b9f3827 0       B

第二列就是sha-1 hash值,相当于内容的外键,指向了实际存储文件内容的blob。第三列是文件的冲突状态,第四列是文件的路径名

操作暂存区的场景是这样的,每当编辑好一个或几个文件后,把它加入到暂存区,然后接着修改其他文件,改好后放入暂存区,循环反复。直到修改完毕,最后使用 commit 命令,将暂存区的内容永久保存到本地仓库

这个过程其实就是构建项目快照的过程,当我们提交时,git 会使用暂存区的这些信息生成tree对象,也就是项目快照,永久保存到数据库中。因此也可以说暂存区是用来构建项目快照的区域

文件状态

文件的状态可以分为两类。一类是暂存区与本地仓库比较得出的状态,另一类是工作区与暂存区比较得出的状态。

为什么要分成两类的原因也很简单,因为第一类状态在提交时,会直接写入本地仓库。而第二种则不会。一个文件可以同时拥有两种状态

一个文件可能既有上面的 modified 状态,又有下面 modified 状态,但其实他们表示了不同的状态,git 会使用绿色和红色把这两中 modified 状态区分开来

分支

分支的目的是让我们可以并行的进行开发。比如我们当前正在开发功能,但是需要修复一个紧急bug,我们不可能在这个项目正在修改的状态下修复 bug,因为这样会引入更多的bug

分支的实现其实很简单,也就是前面介绍的.git/HEAD 文件,我们可以先看一下 ,它保存了当前的分支

cat .git/HEAD
ref: refs/heads/main

其实这个 ref 表示的就是一个分支,它也是一个文件,我们可以继续看一下这个文件的内容:

cat .git/refs/heads/main
cf661ddf1f225646dd3bee23112c6f2e8a650c43

可以看到分支存储了一个 object,我们可以使用 git cat-file -p cf661ddf1f225646dd3bee23112c6f2e8a650c43 命令继续查看该 object 的内容

tree 03790b886a0e1df0d63b0ffa6767a424b128b887
parent b1781029acad16e1a60a095e06ad5ca5fb41105d
author wenjiangyi <wenjy1314@gmail.com> 1674874438 +0800
committer wenjiangyi <wenjy1314@gmail.com> 1674874438 +0800

edit B

分支指向了一次提交,为什么分支指向一个提交的原因,其实也是git中的分支为什么这么轻量的答案
因为分支就是指向了一个 commit 的指针,当我们提交新的 commit,这个分支的指向只需要跟着更新就可以了,而创建分支仅仅是创建一个指针

高层命令

在 git 中分为两种类型的命令,一种是完成底层工作的工具集,称为底层命令,另一种是对用户更友好的高层命令。一条高层命令,往往是由多条底层命令组成的

add commit

每当将修改的文件加入到暂存区git 都会根据文件的内容计算出 sha-1,并将内容转换成 blob,写入数据库。然后使用 sha-1 值更新该列表中的文件项。
暂存区的文件列表中,每一个文件名,都会对应一个sha-1值,用于指向文件的实际内容。最后提交的那一刻,git会将这个列表信息转换为项目的快照,也就是 tree 对象。写入数据库,并再构建一个commit对象,写入数据库。然后更新分支指向

Conflicts

git 中的分支十分轻量,因此我们在使用git的时候会频繁的用到分支。不可不免的需要将新创建的分支合并。

git 中合并分支有两种选择:mergerebase。但是,无论哪一种,都有可能产生冲突。因此我们先来看一下冲突的产生

图上的情况,并不是移动分支指针就能解决问题的,它需要一种合并策略。首先,我们需要明确的是谁和谁的合并,是 2,34,5,6的合并吗?
说到分支,我们总会联想到线,就会认为是线的合并。其实不是的,真实合并的是 3 和 6。因为每一次提交都包含了项目完整的快照,即合并只是 treetree 的合并

我们可以先想一个简单的算法。用来比较3和6。但是我们还需要一个比较的标准,如果只是3和6比较,那么3与6相比,添加了一个文件,也可以说成是6与3比删除了一个文件,这无法确切表示当前的冲突状态。
因此我们选取他们的两个分支的分歧点(merge base)作为参考点,进行比较。

比较时,相对于 merge base(提交1)进行比较

首先把1、3、6中所有的文件做一个列表,然后依次遍历这个列表中的文件。现在我们拿列表中的一个文件进行举例,把在提交1、3、6中的该文件分别称为版本1版本3版本6

存在下面3种情况:

没有冲突

版本1版本3版本6sha-1 值完全相同

没有同文件冲突,自动合并

版本3版本6至少一个与版本1状态相同(指的是sha-1值相同或都不存在),这种情况可以自动合并。比如1中存在一个文件,3对该文件进行修改,而6中删除了这个文件,则以6为准就可以了

需手动解决冲突

版本3版本6都与版本1的状态不同,情况复杂一些,自动合并策略很难生效,需要手动解决。我们来看一下这种状态的定义

冲突状态定义:

  • 1 and 3: DELETED_BY_THEM;

  • 1 and 6: DELETED_BY_US;

  • 3 and 6: BOTH_ADDED;

  • 1 and 3 and 6: BOTH_MODIFIED

我们拿第一种情况举例,文件有两种状态 1 和 3,1 表示该文件存在于 commit 1(也就是MERGE_BASE),3 表示该文件在 commit 3 (master 分支)中被修改了,没有 6,也就是该文件在 commit 6(feature 分支)被删除了,总结来说这种状态就是 DELETED_BY_THEM

可以再看一下第四种情况,文件有三种状态 1、3、6,1 表示 commit 1(MERGE_BASE)中存在,3 表示 commit 3(master 分支)进行了修改,6 表示(feature 分支)也进行了修改,总结来说就是 BOTH_MODIFIED(双方修改)。

遇到不可自动合并冲突时,git会将这些状态写入到暂存区。与我们讨论不同的是,git使用1,2,3标记文件,1表示文件的base版本,2表示当前的分支的版本,3表示要合并分支的版本

merge

在解决完冲突后,我们可以将修改的内容提交为一个新的提交。这就是 merge

可以看到 merge 是一种不修改分支历史提交记录的方式,这也是我们常用的方式。但是这种方式在某些情况下使用 起来不太方便,比如当我们创建了 pr、mr 或者 将修改补丁发送给管理者,管理者在合并操作中产生了冲突,还需要去解决冲突,这无疑增加了他人的负担

rebase 会把从 Merge Base 以来的所有提交,以补丁的形式一个一个重新达到目标分支上。这使得目标分支合并该分支的时候会直接 Fast Forward,即不会产生任何冲突。提交历史是一条线,这对强迫症患者可谓是一大福音

rebase

如果我们想要看 rebase 实际上做了什么,有一个方法,那就是用“慢镜头”来看rebase的整个操作过程。rebase 提供了交互式选项(参数 -i),我们可以针对每一个patch,选择你要进行的操作

通过这个交互式选项,我们可以”单步调试”rebase操作

其实 rebase 主要在 .git/rebase-merge 下生成了两个文件,分别为 git-rebase-tododone 文件,这两个文件的作用光看名字就可以看得出来。
git-rebase-todo 存放了 rebase 将要操作的 commit。而 done 存放正在操作或已经操作完毕的 commit。比如我们这里,git-rebase-todo 存放了 4、5、6,三个提交

首先 gitsha-1 为 4 的 commit 放入 done。表示正在操作 4,然后将 4 以补丁的形式打到 3 上,形成了新的提交 4’。这一步是可能产生冲突的,如果有冲突,需要解决完冲突之后才能继续操作

接着讲 sha-1 为 5 的提交放入 done 文件,然后将 5 以补丁的形式打到 4’ 上,形成 5’。

再接着将 sha-1 为 6 的提交放入 done 文件,然后将 6 以补丁的形式打到 5’ 上,形成 6’。最后移动分支指针,使其指向最新的提交 6’ 上。这就完成了 rebase 的操作

从刚才的图中,我们就可以看到 rebase 的一个缺点,那就是修改了分支的历史提交。如果已经将分支推送到了远程仓库,会导致无法将修改后的分支推送上去,必须使用 -f 参数(force)强行推送
所以使用 rebase 最好不要在公共分支上进行操作

Checkout

切换分支、或者切换到某一次提交

Checkout 前的状态如下:

首先 checkout 找到目标提交(commit),目标提交中的快照也就是 tree 对象就是我们要检出的项目版本

checkout 首先根据tree生成暂存区的内容,再根据 tree 与其包含的 blob 转换成我们的项目文件。然后修改 HEAD 的指向,表示切换分支

可以看到 checkout 并没有修改提交的历史记录。只是将对应版本的项目内容提取出来

Revert

如果我们想要用一个用一个反向提交恢复项目的某个版本,那就需要 revert 来协助我们完成了。什么是反向提交呢,就是旧版本添加了的内容,要在新版本中删除,旧版本中删除了的内容,要在新版本中添加。这在分支已经推送到远程仓库的情境下非常有用

Revert 之前:

revert 也不会修改历史提交记录,实际的操作相当于是检出目标提交的项目快照到工作区暂存区,然后用一个新的提交完成版本的“回退”

Revert 之后:

Reset

reset 操作与 revert 很像,用来在当前分支进行版本的“回退”,不同的是,reset 是会修改历史提交记录的

reset 常用的选项有三个,分别是 —soft, —mixed, —hard。他们的作用域依次增大

soft 会仅仅修改分支指向。而不修改工作区与暂存区的内容,我们可以接着做一次提交,形成一个新的 commit。这在我们撤销临时提交的场景下显得比较有用

reset --soft 前:

reset --soft 后:

mixedsoft 的作用域多了一个 暂存区。实际上 mixed 选项与 soft 只差了一个 add 操作

reset --mixed 前:

reset --mixed 后:

hard 会作用域又比 mixed 多了一个 工作区

reset --hard 前:

reset --hard 后:

hard 选项会导致工作区内容“丢失”

在使用 hard 选项时,一定要确保知道自己在做什么,不要在迷糊的时候使用这条选项。如果真的误操作了,也不要慌,因为只要 git 一般不会主动删除本地仓库中的内容,根据你丢失的情况,可以进行找回,比如在丢失后可以使用 git reset --hard ORIG_HEAD 立即恢复,或者使用 reflog 命令查看之前分支的引用

stash

有时,我们在一个分支上做了一些工作,修改了很多代码,而这时需要切换到另一个分支干点别的事。但又不想将只做了一半的工作提交。在曾经这样做过,将当前的修改做一次提交,message 填写 half of work,然后切换另一个分支去做工作,完成工作后,切换回来使用 reset —soft 或者是 commit amend

git 为了帮我们解决这种需求,提供了 stash 命令

stash工作区暂存区中的内容做一个提交,保存起来,然后使用reset hard选项恢复工作区与暂存区内容。我们可以随时使用 stash apply 将修改应用回来

stash 实现思路将我们的修改提交到本地仓库,使用特殊的分支指针(.git/refs/stash)引用该提交,然后在恢复的时候,将该提交恢复即可。我们可以更进一步,看看 stash 做的提交是什么样的结构

如图所示,如果我们提供了 —include-untracked 选项,git 会将 untracked 文件做一个提交,但是该提交是一个游离的状态,接着将暂存区的内容做一个提交。最后将工作区的修改做一个提交,并以untracked 的提交、暂存区 的提交、基础提交为父提交。

搞这么复杂,是为了提供更灵活地选项,我们可以选择性的恢复其中的内容。比如恢复 stash 时,可以选择是否重建 index,即与 stash 操作时完全一致的状态

bisect

项目发布到线上的项目出现了bug,而经过排查,却找不到问 bug 的源头。我们还有一种方法,那就是先找到上一次好的版本,从上一次到本次之间的所有提交依次尝试,一一排查。直到找到出现问题的那一次提交,然后分析 bug 原因。

git 为我们想到了这样的场景,同样是刚才的思路,但是使用二分法进行查找。这就是 bisect 命令

使用该命令很简单,

git bisect start

git bisect bad HEAD

git bisect good v4.1

git 会计算中间的一个提交,然后我们进行测试

References

http://www.uml.org.cn/pzgl/201608044.asp


文章作者: 江湖义气
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 江湖义气 !
  目录