Git入门

git用了很久其实也只会add commit push这三个操作,我现在用三篇blog来解释一下底层git的实现原理,和分支管理多人协作等。

1:Git是内容寻址库

在敲任何命令之前,我们必须先建立一个最核心的认知:Git 本质上是一个基于 SHA-1 哈希的键值对数据库,版本控制只是它的上层应用

1:什么是内容寻址

当你向Git中存入任何内容时,Git会做两件事:

  • 对内容执行 SHA-1 哈希算法,生成一个 40 位的十六进制字符串(比如 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
  • 以这个哈希值为 "键(key)",原始内容为 "值(value)",存入数据库

这个特性决定了 Git 的三个核心优势:

  • 绝对完整性:任何内容的哪怕一个字节的修改,都会导致哈希值完全不同,Git 可以轻松校验数据是否被篡改
  • 自动去重:完全相同的内容无论存入多少次,只会生成一个哈希值,只存储一份
  • 分布式可靠性:每个克隆的仓库都包含完整的数据库,任意一个副本损坏都可以从其他机器恢复

2:亲手创建一个Git对象

我们不用 git init,先手动体验 Git 的数据库功能:

bash 复制代码
# 创建一个空目录并进入
mkdir git-test && cd git-test

# 创建一个内容为 "hello git" 的文件
echo "hello git" > test.txt

# 让 Git 计算这个文件的哈希值(不存入数据库)
git hash-object test.txt
# 输出:3b18e512dba79e4c8300dd08aeb37f8e728b8dad

# 加上 -w 参数,将内容写入 Git 数据库
git hash-object -w test.txt
# 输出同样的哈希值

现在我们看看目录里发生了什么:

bash 复制代码
# 查看当前目录,你会发现多了一个 .git 目录!
ls -la
# 输出:drwxr-xr-x  3 user  group  96 May 20 10:00 .git

# 进入 .git/objects 目录,这就是 Git 的数据库
cd .git/objects && ls -la
# 输出:drwxr-xr-x  3 user  group  96 May 20 10:00 3b

# 进入 3b 目录,你会看到剩下的 38 位哈希值作为文件名
cd 3b && ls
# 输出:18e512dba79e4c8300dd08aeb37f8e728b8dad

Git 为了优化文件系统性能,会把哈希值的前两位作为目录名,后 38 位作为文件名。现在我们可以用 Git 命令读取这个数据库里的内容:

bash 复制代码
# 查看对象内容(可以只写前 7 位哈希值,Git 会自动补全)
git cat-file -p 3b18e51
# 输出:hello git

# 查看对象类型
git cat-file -t 3b18e51
# 输出:blob

我们刚刚手动创建了 Git 中最基础的对象 ------Blob 对象,它只存储文件的原始内容,不包含任何元数据(文件名、权限、修改时间等)。

2:初始化仓库.git目录

刚才我们通过 git hash-object -w 间接创建了 .git 目录,现在我们正式初始化一个标准的 Git 仓库,完整解析 .git 目录的结构。

bash 复制代码
# 回到项目根目录,先删除刚才的 .git 目录
cd ../.. && rm -rf .git

# 正式初始化 Git 仓库
git init
# 输出:Initialized empty Git repository in /path/to/git-test/.git/

现在查看完整的 .git 目录结构:

bash 复制代码
tree .git
# 输出(精简后):
.git
├── branches/          # 已废弃,仅用于向后兼容
├── config             # 仓库级配置文件
├── description        # 仓库描述,仅用于 GitWeb 服务
├── HEAD               # 指向当前所在分支的指针(核心!)
├── hooks/             # 钩子脚本目录,可自定义自动化操作
│   ├── pre-commit.sample
│   ├── post-commit.sample
│   └── ...
├── info/
│   └── exclude        # 全局忽略文件,优先级高于 .gitignore
├── objects/           # Git 对象数据库(核心!)
│   ├── info/
│   └── pack/
└── refs/              # 引用目录(核心!)
    ├── heads/        # 本地分支引用
    └── tags/         # 标签引用

我们重点讲解三个核心文件 / 目录,其他的可以暂时不用管:

1: .git/HEAD

这是一个只有一行内容的文本文件,它告诉 Git:"你现在在哪个分支上"。

bash 复制代码
cat .git/HEAD
# 输出:ref: refs/heads/master

这说明当前我们在 master 分支上。注意,这里 HEAD 不是直接指向一个提交,而是指向一个分支引用,这种状态叫做 "符号引用"。

2: .git/refs/

SHA-1 哈希值虽然唯一,但完全无法记忆。Git 用 "引用(Ref)" 解决这个问题:引用本质上就是一个文本文件,里面只存一个 SHA-1 哈希值,相当于给这个哈希值起了一个人类可读的名字。

  • .git/refs/heads/:存放本地分支引用,每个文件对应一个分支
  • .git/refs/tags/:存放标签引用
  • .git/refs/remotes/:存放远程仓库的分支引用(后面会讲)

现在我们看看 master 分支的引用:

bash 复制代码
cat .git/refs/heads/master
# 输出:cat: .git/refs/heads/master: No such file or directory

为什么不存在?因为我们还没有任何提交,master 分支还没有被真正创建。Git 的分支只有在有提交指向它的时候才会实际存在。

3: .git/objects/

这就是我们之前见过的对象数据库,所有的文件内容、目录结构、提交信息都存在这里。现在它还是空的,等我们执行 git addgit commit 后,这里就会出现我们创建的各种对象。

3:完整的提交流程

现在我们来完成第一次真正的提交,并且每执行一步,就停下来看 .git 目录的变化。Git 的所有操作都围绕三个核心区域展开,我们会把每个命令和这三个区域的变化对应起来:

  • 工作区:你电脑上能直接编辑的文件目录
  • 暂存区(Index) :存放在 .git/index,临时存放即将提交的修改
  • 版本库 :存放在 .git/objects/,永久存储所有提交的版本快照

1:在工作区修改文件

我们先创建两个文件,模拟真实的开发场景:

cpp 复制代码
# 创建 README.md 和 main.c 文件
echo "# Git 测试项目" > README.md
echo "int main() { return 0; }" > main.c

# 查看工作区状态
git status

git status 的输出会告诉你:当前在 master 分支,没有任何提交,有两个未跟踪的文件(Untracked files)。

此时 .git 目录的变化:没有任何变化!Git 不会自动跟踪工作区的文件,除非你明确告诉它要这么做。

2:git add:将修改添加到暂存区

现在我们把这两个文件添加到暂存区:

bash 复制代码
git add README.md main.c
# 或者用 git add . 一次性添加当前目录所有修改

看.git目录发生了什么

1:对象数据库多了两个Blob对象
bash 复制代码
find .git/objects -type f
# 输出:
# .git/objects/79/874c4a6748a9277082874784f5b3106f308e0b
# .git/objects/8b/13791973e043695332443745309206f4645908

我们分别查看这两个对象:

bash 复制代码
git cat-file -p 79874c4  # README.md 的内容
# 输出:# Git 测试项目

git cat-file -p 8b13797  # main.c 的内容
# 输出:int main() { return 0; }

这说明:git add 的本质,就是为工作区中修改过的每个文件创建对应的 Blob 对象,存入数据库

2:生成了.git.index文件(暂存区)
bash 复制代码
ls -la .git/index
# 输出:-rw-r--r--  1 user  group  144 May 20 10:30 .git/index

.git/index 是一个二进制文件,它记录了暂存区的所有信息:每个文件的文件名、权限、最后修改时间,以及对应的 Blob 对象哈希值。我们可以用 git ls-files 命令查看暂存区的内容:

bash 复制代码
git ls-files -s
# 输出:
# 100644 79874c4a6748a9277082874784f5b3106f308e0b 0    README.md
# 100644 8b137973e043695332443745309206f4645908 0    main.c

这说明:暂存区本质上就是一个 "文件路径 → Blob 哈希值" 的映射表。它不存储文件内容本身,只存储指向内容的指针。

3:git commit :将暂存区永久存入版本库

现在我们把暂存区的内容提交到版本库:

bash 复制代码
git commit -m "第一次提交:初始化项目"
# 输出:
# [master (root-commit) a1b2c3d] 第一次提交:初始化项目
#  2 files changed, 2 insertions(+)
#  create mode 100644 README.md
#  create mode 100644 main.c

见证奇迹的时刻!现在看 .git 目录的变化

1:又多了两个对象:Tree对象和Commit对象
bash 复制代码
find .git/objects -type f
# 现在一共有 4 个对象了!

我们先看刚才生成的 Commit 对象(用你自己的提交哈希值):

bash 复制代码
git cat-file -p a1b2c3d
# 输出:
# tree 4d83cfc9e7a35b6f1a9d5e7f8a3b6f1a9d5e7f8
# author Your Name <your@email.com> 1716200000 +0800
# committer Your Name <your@email.com> 1716200000 +0800
# 
# 第一次提交:初始化项目

Commit 对象是版本历史的基本单位,它存储了:

  • 指向根 Tree 对象的哈希值
  • 作者和提交者信息(姓名、邮箱、时间戳)
  • 提交信息
  • 父提交哈希值(第一次提交没有父提交,所以没有这一行)

接下来我们看这个 Tree 对象:

bash 复制代码
git cat-file -p 4d83cfc
# 输出:
# 100644 blob 79874c4a6748a9277082874784f5b3106f308e0b    README.md
# 100644 blob 8b137973e043695332443745309206f4645908    main.c

Tree 对象对应文件系统中的目录,它存储了该目录下所有文件和子目录的信息:

  • 文件权限(100644 表示普通文件)
  • 对象类型(blob 表示文件,tree 表示子目录)
  • 对象哈希值
  • 文件名

如果我们有子目录,Git 会递归创建 Tree 对象。比如我们创建一个 src/utils.c 文件,提交后会生成:

  • 一个 Blob 对象存储 utils.c 的内容
  • 一个 Tree 对象存储 src 目录的信息(指向 utils.c 的 Blob)
  • 根 Tree 对象会新增一个条目指向 src 的 Tree 对象
2:分枝引用被创建并更新
bash 复制代码
cat .git/refs/heads/master
# 输出:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b

现在 master 分支的引用文件终于存在了,它的内容就是我们刚才这次提交的哈希值。这就是 Git 分支的本质:一个指向 Commit 对象的可移动指针

3:HEAD仍然指向master分支
bash 复制代码
cat .git/HEAD
# 输出:ref: refs/heads/master

4:提交流程完整总结

现在我们把整个提交流程的底层变化串起来:

  1. 你在工作区修改文件
  2. 执行 git add
    • 为每个修改过的文件创建 Blob 对象,存入 .git/objects/
    • 更新 .git/index 文件,记录文件路径和 Blob 哈希值的映射
  3. 执行 git commit
    • 根据暂存区的内容创建根 Tree 对象
    • 创建 Commit 对象,指向根 Tree 对象,并记录提交信息
    • 将当前分支的指针(如 master)移动到这个新的 Commit 对象
    • HEAD 仍然指向当前分支

一个非常重要的认知 :Git 每次提交保存的不是 "文件的差异",而是整个项目目录的完整快照。如果一个文件在两次提交之间没有变化,Git 不会重复存储它,只会复用之前的 Blob 对象哈希值。

4:版本回退:三个参数的底层本质

现在我们修改一下文件,做第二次提交,然后演示版本回退:

bash 复制代码
# 修改 main.c 文件
echo "int main() { printf(\"hello world\"); return 0; }" > main.c

# 提交
git add main.c
git commit -m "第二次提交:添加 hello world 输出"

现在我们有两个提交了:

bash 复制代码
git log --pretty=oneline
# 输出:
# e4f5g6h (HEAD -> master) 第二次提交:添加 hello world 输出
# a1b2c3d 第一次提交:初始化项目

版本回退的核心命令是 git reset,它有三个常用参数,很多人搞不清它们的区别。其实只要从 "三个区域" 的角度去理解,就非常简单:git reset 的本质是 "移动 HEAD 指针",不同参数决定了是否同步更新暂存区和工作区

1:git reset --soft:只移动HEAD指针

bash 复制代码
# 回退到上一个版本(第一次提交)
git reset --soft HEAD^

底层变化

  • 只修改了 .git/refs/heads/master 的内容,变成了第一次提交的哈希值 a1b2c3d
  • .git/index(暂存区)没有变化
  • 工作区的文件没有变化

现在状态

bash 复制代码
git status
# 输出:Changes to be committed:
#   modified:   main.c

相当于我们回到了 "已经执行完 git add,还没执行 git commit" 的状态。适用场景:刚提交完发现写错了提交信息,或者漏加了一个文件,想重新提交。

2:git reset --mixed(默认):移动HEAD指针+重置暂存区

这是不带参数时 git reset 的默认行为:

bash 复制代码
# 先回到第二次提交
git reset --hard e4f5g6h

# 用默认模式回退
git reset HEAD^

底层变化

  • 修改了 .git/refs/heads/master 的内容
  • 用目标提交的 Tree 对象重置了 .git/index 文件
  • 工作区的文件没有变化

现在的状态

bash 复制代码
git status
# 输出:Changes not staged for commit:
#   modified:   main.c

相当于我们回到了 "修改了文件,还没执行 git add" 的状态。适用场景:不小心把错误的文件添加到了暂存区,想撤回重新 add。

3:git reset --hard:移动HEAD+重置暂存区+重置工作区

这是最危险也最常用的模式:

bash 复制代码
# 先回到第二次提交
git reset --hard e4f5g6h

# 彻底回退到第一次提交
git reset --hard HEAD^

底层变化

  • 修改了 .git/refs/heads/master 的内容
  • 用目标提交的 Tree 对象重置了 .git/index 文件
  • 用目标提交的 Tree 对象重置了工作区的所有文件

现在的状态

bash 复制代码
git status
# 输出:nothing to commit, working tree clean

cat main.c
# 输出:int main() { return 0; }  # 回到了第一次提交的内容

4:急救措施git reflog

--hard 回退错了版本,发现最新的代码没了。别慌,git reflog 能帮你找回所有操作记录。

bash 复制代码
# 查看所有操作历史
git reflog
# 输出:
# a1b2c3d (HEAD -> master) HEAD@{0}: reset: moving to HEAD^
# e4f5g6h HEAD@{1}: commit: 第二次提交:添加 hello world 输出
# a1b2c3d (HEAD -> master) HEAD@{2}: commit (initial): 第一次提交:初始化项目

git reflog 记录了 HEAD 指针的每一次移动,哪怕是已经被删除的提交也能看到。现在我们可以轻松回到第二次提交:

reflog 的底层原理 :所有的操作记录都存在 .git/logs/ 目录下,每个分支都有一个对应的日志文件:

bash 复制代码
cat .git/logs/refs/heads/master
# 你会看到所有对 master 分支的操作记录

5:远程仓库:分布式协作的底层逻辑

Git 是分布式版本控制系统,这意味着每个开发者的电脑上都有一个完整的仓库。远程仓库(比如 GitHub、Gitee)本质上只是一个 "公共的服务器仓库",用来方便大家交换各自的修改。

1:关联远程仓库

首先我们在 GitHub 上创建一个空的仓库,然后关联到本地:

bash 复制代码
# 关联远程仓库,origin 是远程仓库的默认别名
git remote add origin git@github.com:你的用户名/git-test.git

# 查看远程仓库信息
git remote -v
# 输出:
# origin  git@github.com:你的用户名/git-test.git (fetch)
# origin  git@github.com:你的用户名/git-test.git (push)

底层变化 :Git 在 .git/config 文件中添加了远程仓库的配置:

bash 复制代码
cat .git/config
# 会看到新增了:
# [remote "origin"]
#   url = git@github.com:你的用户名/git-test.git
#   fetch = +refs/heads/*:refs/remotes/origin/*

2:git push将本地提交推送到远程

现在我们把本地的 master 分支推送到远程:

bash 复制代码
git push -u origin master

push 命令的底层逻辑

  1. 本地 Git 与远程仓库建立连接
  2. 比较本地分支和远程分支的提交历史
  3. 将本地有但远程没有的所有 Commit、Tree、Blob 对象全部上传到远程仓库的 .git/objects/ 目录
  4. 更新远程仓库的对应分支引用(比如 refs/heads/master
  5. -u 参数表示建立 "追踪关系",之后再 push 就不用写 origin master

底层变化 :本地 .git/refs/remotes/origin/master 文件被创建,内容是远程 master 分支的最新提交哈希值。这个文件叫做 "远程跟踪分支",它是远程分支在本地的一个只读镜像。

3:git clone克隆远程仓库

当你克隆一个远程仓库时,Git 会做以下几件事:

  1. 创建一个空的本地仓库
  2. 将远程仓库的所有对象(Commit、Tree、Blob)完整下载到本地 .git/objects/ 目录
  3. 在本地创建所有远程分支的远程跟踪分支(如 origin/master
  4. 创建一个本地 master 分支,指向与 origin/master 相同的提交
  5. 检出 master 分支的内容到工作区

4:git fetch和git pull

  • git fetch origin master :只下载远程 master 分支的最新提交和对象,更新本地的 origin/master 远程跟踪分支,但不会修改本地的 master 分支和工作区
  • git pull origin master :等价于 git fetch origin master + git merge origin/master,先下载再合并到本地当前分支

最佳实践 :推荐优先使用 git fetch,然后手动查看远程的修改,确认没问题后再执行 git merge。这样可以避免自动合并带来的意外冲突。

相关推荐
Ws_1 小时前
Git + Gerrit 第八课:reset 与 revert 撤销提交
git
Qres8211 小时前
hexo博客上传github page
git·github·hexo
繁星星繁2 小时前
Git 入门之道:从版本流转到基础操作
大数据·git·elasticsearch
wh_xia_jun18 小时前
Git 分支合并操作备忘录
git
满天星830357719 小时前
【Git】原理及使用(三)(分支管理)
linux·git
像风一样的男人@1 天前
warning: could not find UI helper ‘git-credential-manager-ui‘
git·ui
代钦塔拉1 天前
Git & GitHub 从入门到精通:全流程实战教程
git·github
晚风吹红霞1 天前
Linux下的趣味编程 —— 进度条、Git版本控制与GDB调试实战
linux·运维·git
xlq223221 天前
7.git
git