找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2624

积分

0

好友

363

主题
发表于 7 小时前 | 查看: 0| 回复: 0

很多同学在使用Git的时候,对基础命令非常熟练,例如:

  • add
  • commit
  • push
  • pull

这些基础指令基本不会出错。但一旦遇到复杂问题,就容易开始“玄学操作”,比如:

  • reset --hard
  • rebase
  • force push
  • ……

每一步都如履薄冰。

关键在于,会用Git并不等同于理解Git。大部分人的学习路径是:

  1. 先背命令
  2. 能提交代码
  3. 能协作
  4. 逐渐把Git当成“文件上传工具”

这条路径能快速上手,但留下一个隐患:你知道“怎么做”,却不知道“为什么”。当你面对以下问题时,就会感到困惑:

  1. Git到底保存的是差异(diff),还是整个项目的快照?
  2. 为什么Git的分支创建如此轻量,几乎是秒级的?
  3. checkout 为什么那么快?它到底做了什么?
  4. reset --soft / --mixed / --hard 的差别,究竟是“改了哪里”?
  5. mergerebase 到底谁更好?它们本质上有什么区别?
  6. 为什么Git天生就是分布式的?离开GitHub也能完整工作?

这些问题都指向同一个核心:理解Git的底层模型。学习Git的初期目标是掌握命令,后期则需要了解其内部结构。

这篇文章将帮你建立一个稳固的Git心智模型。我们不从命令列表讲起,而是深入Git的底层世界,探讨:

  • Git内部到底存了哪些东西?
  • commit、branch、HEAD这些概念,在底层是什么?
  • 为什么Git能高效保存不同版本且不浪费空间?

只要理解了这个模型,许多看似复杂的命令都会变得简单。你将不再靠背命令,而是靠理解结构来驾驭Git。如果你希望系统性地了解更多开发者必备的基础与综合知识,欢迎访问云栈社区进行深入探讨。

一个关键认知:Git ≠ 文件管理工具

如果整篇文章你最需要记住一句话,那就是:

Git 不是一个文件管理工具。

这句话看似违背直觉,因为我们日常用Git做的事,几乎就是:

  • 改文件
  • 提交文件
  • 回滚文件
  • 比较文件差异

无论怎么看,Git都像一个文件管理工具。然而,这只是表象。Git看起来是在管理文件,但底层设计根本不关心“文件”这个概念。它不在意:

  • 文件是 .js 还是 .txt
  • 文件是不是“源代码”
  • 文件名是否被重命名过
  • 文件原来在哪个目录下

Git真正关心的只有一件事:当前这段内容是什么

换句话说,对Git而言,文件名是“外壳”,路径结构是“组织方式”,它真正关心的是内容本身

从底层设计看,Git是一个以“内容哈希”为索引的对象数据库。我们可以用一段极简的伪代码来模拟Git内部的存储模型:

// 一个极简的 Git 对象数据库示意
objectDatabase = {
  "e68ff1...": {
    type: "blob",
    content: "console.log('Hello Git')"
  },

  "a3c912...": {
    type: "tree",
    entries: [
      { name: "index.js", hash: "e68ff1..." }
    ]
  },

  "f9b2d4...": {
    type: "commit",
    tree: "a3c912...",
    parent: "b17c02...",
    author: "Alice",
    message: "Initial commit"
  }
}

这段伪代码揭示了几个核心要点:

  1. Git只存对象,不存版本
  2. 哈希值不是随机的,而是基于内容计算出来的
  3. commit本身并不存储代码

下面我们来详细解释这三点。

1. Git 只存对象,不存版本

在Git内部,不存在“版本号”的概念。那Git如何记住历史呢?答案就是哈希值。

hash → object

在上述伪代码中:

  • "e68ff1..." → 指向这个blob对象
  • "a3c912..." → 指向这个tree对象
  • "f9b2d4..." → 指向这个commit对象

哈希就是唯一ID。这个ID的含义是:“只要你拿到这个hash,我就能给你完全一样的内容”。

来看一个具体例子。用户第一次提交,有一个JS文件,内容为:

console.log("Hello Git")

此时Git会:

算 hash → 创建 blob
算 hash → 创建 tree
算 hash → 创建 commit

于是数据库里多了几个对象:

{
  "e68ff1": blob("Hello Git"),
  "a3c912": tree(index.js -> e68ff1),
  "f9b2d4": commit(tree: a3c912)
}

注意,Git并没有说这是v1版本,它只是记录:“我知道一个commit,hash是f9b2d4,它长这样。”

接下来用户第二次提交,将内容改为:

console.log("Hello Git v2")

Git再次执行相同流程:

{
  "9aa111": blob("Hello Git v2"),
  "bb2222": tree(index.js -> 9aa111),
  "cc3333": commit(tree: bb2222, parent: f9b2d4)
}

现在数据库里有两个commit对象:

f9b2d4  (旧 commit)
cc3333  (新 commit)

但Git依然没有“版本号”的概念。

2. hash 不是随机的,而是基于内容算出来的

前面提到的哈希值是基于内容计算的:

hash = SHA1(type + content)

这意味着:

  • 内容完全一样:计算出的hash一定相同
  • 内容即使只改动一个字符:计算出的hash就完全不同

3. commit 本身并不存储代码

仔细观察commit的伪结构:

"f9b2d4...": {
  type: "commit",
  tree: "a3c912...",
  parent: "b17c02...",
  author: "Alice",
  message: "Initial commit"
}

该对象有几个关键字段:

  • tree:定义当前这一刻项目的完整状态
  • parent:指向上一次提交的commit hash

3.1 tree

一句话总结:tree记录的是目录里每一项(文件/子目录)的 名字 + 类型 + 指向的对象hash。

假设项目结构如下:

project/
├── index.js
└── src/
    └── util.js

在Git里,对应关系是:

index.js  → blob(hash_index)
util.js   → blob(hash_util)
src/      → tree(hash_src)
project/  → tree(hash_root)

这里的关键点:文件对应blob类型,目录对应tree类型,tree内部存储的是目录内容清单。

我们展开一个tree对象的“伪结构”:

"a3c912...": {
  type: "tree",
  entries: [
    {
      name: "index.js",
      type: "blob",
      hash: "e68ff1..."
    },
    {
      name: "src",
      type: "tree",
      hash: "d41d8c..."
    }
  ]
}
  • name:当前目录下的文件名或目录名。
  • type:如果是文件,值为blob;如果是目录,值为tree
  • hash:指向真正的内容对象。
    • 如果是blob,hash指向文件内容
    • 如果是tree,hash指向子目录的tree

现在我们知道,tree的值也是一个hash。那么,tree的hash是如何得到的呢?

tree的hash是根据tree对象本身的内容计算出来的。 tree对象的内容完整描述了“当前目录在某一刻的结构状态”,包括:

  • 当前目录下每一项的name(文件名/子目录名)
  • 每一项的type(blob或tree)
  • 每一项所指向的对象hash

只要这三者中的任意一个发生变化,例如:

  • 新增或删除文件
  • 文件或目录被重命名
  • 文件内容变化(对应的blob hash改变)
  • 子目录内部结构变化(子tree的hash改变)

都意味着tree对象的内容发生了变化,其hash也必然随之改变。

3.2 parent

在commit对象中,parent字段指向上一次提交的commit hash。它看似只是一个普通引用,但Git的整个历史模型几乎都建立在这个字段之上

理解Git历史的关键:

  • Git中没有“第1版/第2版”的概念
  • Git的历史也不是一个数组

Git的历史是通过parent字段,一条一条连接起来的链式结构:

Git提交历史链式关系图

因此,Git的历史本质上就是一张有向无环图

Git的底层世界:四种对象模型

到目前为止,我们反复提到一个词:对象。接下来看看Git底层究竟有哪几种对象类型。

在Git内部,所有数据最终都被存储为对象,共有4种类型:

对象类型 存储内容 主要作用 哈希计算基础
Blob 文件内容 存储实际数据 文件内容
Tree 目录结构 记录文件组织 Tree内容+引用
Commit 项目快照 记录版本历史 Tree+Parent+元数据
Tag 版本标记 标记重要版本 Commit+标签信息

1. blob

blob只存储文件内容,不存储文件名和路径。例如,有一个 index.js 文件,内容为:

console.log("Hello Git")

Git在内部只会记录类似这样的信息:

文件:hello.txt(内容:"Hello Git")
      ↓
生成Blob对象:
哈希值:3b18e512dba79e2239a6c93a32514e27458b84dd
内容:Hello Git

至于这个内容叫不叫 index.js,以及放在哪个目录下,blob完全不知道。

这也是为什么文件重命名对Git来说几乎“零成本”,多个文件只要内容完全一致,就可以复用同一个blob。

2. tree

blob描述一个文件的内容,tree则描述一个目录的结构状态。

项目结构:
myproject/
├── README.md
├── src/
│   └── main.py
└── .gitignore

对应的Tree对象:
───────────────────────────
Tree:  a4b8c9d2...
├── blob 3b18e512...  README.md
├── tree 5f7e8d9c...
│   └── blob 8a9b2c1d...  main.py
└── blob 2x1y3z4a...  .gitignore
───────────────────────────

tree对象的值是一个清单,列出了该目录下的每一项。每一项都是一个对象,包含:

  • name:文件名/子目录名
  • type:blob 或 tree
  • hash:指向对应对象的hash

多个tree可以层层嵌套,从而完整描述整个项目的目录结构。理解这些核心数据结构是高效使用Git的关键,查阅专业的技术文档能帮你更深入地掌握其设计哲学。

3. commit

如果说blob表示内容,tree表示结构,那么commit的职责是把“某一刻的完整项目状态”固定下来。一个commit有两个核心作用:

  1. 通过 tree 字段指向一棵完整的目录树
  2. 通过 parent 字段指向历史中的上一个commit
    Commit: 3c5d8f1a2b9e7c4d6a1f3e5b8c2d9a0f
    Tree: a4b8c9d2...              ← 指向此次提交的项目快照
    Parent: 8x7y6z5a...            ← 指向父提交(上一个版本)
    Author: John Doe <john@example.com>
    Date: Wed Jan 14 2026 10:30:00
    Message: "Add new feature to main.py"

    正因为如此,我们说:commit本身并不存代码,它只是快照的入口和历史的连接点

4. tag

前三种对象我们已经熟悉:

  • commit用hash唯一标识一次提交
  • tree描述项目结构
  • parent串起历史

但这里存在一个现实问题:hash对机器友好,对人却不友好。例如 f9b2d4a1c3e7... 对人来说几乎没有语义。

这正是tag对象存在的意义。tag的核心作用是给commit起一个稳定、有意义、不随时间变化的名字。例如:

v1.0.0 → commit abc123

这行信息意味着:

  • v1.0.0 代表项目的一个正式版本,它明确指向某一次commit
  • 无论后续分支如何推进,这个指向关系都不会改变

这也是为什么在实际工程中,发布版本几乎总是使用tag,而不是依赖某个分支名。

标签分为两种:

  1. 轻量级标签:只是指向Commit的指针
  2. 附注标签:包含标签信息的对象
轻量级标签:
refs/tags/v1.0 → 3c5d8f1a2b9e...

附注标签对象:
Tag: f4e5d6c7...
Name: v1.0
Target: 3c5d8f1a2b9e...       ← 指向某个Commit
Tagger: John Doe <john@example.com>
Date: Wed Jan 14 2026 15:45:00
Message: "Release version 1.0"
tag和branch的区别(容易混淆): 对比项 branch tag
是否会移动 会,随着新提交前进 不会
语义 当前开发线 历史中的某一刻
常见用途 日常开发 版本发布 / 里程碑

一句话总结:branch表示“现在走到哪了”,tag表示“当时停在这里”。

四种对象的关系图

Tag (标签)
  │
  └─→ Commit (提交)
       │
       ├─→ Parent Commit (父提交) ──→ ...  → 最初提交
       │
       └─→ Tree (树/目录结构)
            │
            ├─→ Blob (文件内容)
            ├─→ Blob (文件内容)
            └─→ Tree (子目录)
                 │
                 └─→ Blob (文件内容)

典型的版本历史:
┌──────┐    ┌──────┐    ┌──────┐
│ C1   │───▶│ C2   │───▶│ C3   │
└──────┘    └──────┘    └──────┘
  │          │          │
  ▼          ▼          ▼
Tree1      Tree2      Tree3
  │          │          │
  └─Blobs   └─Blobs   └─Blobs

v1.0 标签指向 C1
v2.0 标签指向 C3

这四种对象相互关联,形成了Git强大的版本控制能力。

实际工作流程

最后,我们通过一个实际工作流程,加深对这4种对象的理解。

操作1:创建并提交文件

echo "Hello" > file.txt
git add file.txt
git commit -m "Initial commit"

Git内部执行过程:

  1. 创建Blob对象:存储 "Hello" 内容
  2. 创建Tree对象:记录 file.txt 指向该Blob
  3. 创建Commit对象:指向该Tree,记录作者/时间/信息
  4. master分支指向这个Commit

操作2:修改文件并再次提交

echo "Hello World" > file.txt
git add file.txt
git commit -m "Update content"

Git内部执行过程:

  1. 创建新的Blob对象:存储 "Hello World" 内容
  2. 创建新的Tree对象:记录 file.txt 指向新的Blob
  3. 创建新的Commit对象:指向新的Tree,Parent指向前一个Commit
  4. master分支指向这个新Commit

操作3:打标签

git tag v1.0

Git内部执行过程: 创建Tag对象(或简单的引用),指向当前Commit。

关于Git工作原理的上半部分(核心对象模型)就先介绍到这里。理解了这些底层概念,你就能更从容地应对复杂的版本控制场景。




上一篇:系统设计面试避坑指南:从背架构到懂权衡的思维跃迁
下一篇:Qt信号与槽机制:从Qt4 protected到Qt5 public的权限变化与迁移建议
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-24 16:34 , Processed in 0.296947 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表