从内存与磁盘出发,彻底搞懂 Git 的存储原理

从内存与磁盘出发,彻底搞懂 Git 的存储原理

导读:各个社区里讲解 Git 的文章有很多,往往过于深入底层原理,劝退一众初学者,对小白友好的文章又大多以书桌、柜子等来类比,虽简单易懂但过渡并不自然。我认为其实如果你明白“内存是临时工作区、磁盘是永久存储区”,就能很容易理解 Git。这篇文章就用内存与磁盘的类比,带你彻底搞懂 Git 到底是什么。

目录


前言:为什么是“内存与磁盘”?

刚翻开 Git 教程时,很多人会在“概念海”中晕头转向——工作区、暂存区、本地仓库、远程仓库、commit、push、pull、merge、rebase……

其实,Git 的设计思路和计算机最基本的存储架构如出一辙

计算机 Git
内存(RAM) 工作目录(Working Directory)
剪贴板 暂存区(Staging Area)
本地硬盘 本地仓库(Local Repository)
网盘 远程仓库(Remote Repository)

从 Git 视角,未提交的修改等同于未持久化的内存数据。

提前说明:这个类比是从 Git 的视角出发的。相对于 Git 自己的数据库(.git/ 目录),工作目录中的文件就像“未保存的内存”——你任意修改,但 Git 不会认为它们是“已提交的版本”。至于你的工作目录文件实际存在操作系统磁盘上,那是另一个层面的事,不影响我们理解 Git 的存储层次。后面第九部分会专门讨论这个类比的边界。

Git 的四层架构和计算机的存储层次结构有着惊人的结构相似性。Git 的创造者 Linus Torvalds 也是 Linux 内核的开发者,长年与操作系统存储体系打交道——这种分层缓冲的思维模式,很可能潜移默化地影响了他。当然,Git 的每个设计都有其具体的工程动机,如:性能、完整性、分布式协作等(我们知道 Linux 内核有上万个贡献者,需要离线工作和快速合并),但这种相似性对于我们理解 Git 来说,是一个非常有用的思维桥梁。我认为理解了这一点,Git 就不再是散乱如沙的命令,而是一个顺理成章的存储系统。


一、我们每天都在用的“内存”和“磁盘”

1.1 内存(RAM):速度快但短暂

内存是电脑用来临时存放正在处理的数据的地方。它的特点是:

  • 速度快:CPU 可以直接读写,延迟极低
  • 容量有限:通常 8GB、16GB、32GB
  • 价格:高昂
  • 断电即失:关机或断电后,所有数据都会消失

你打开一个 Word 文档开始写论文或者工作报告,这些文字就存在内存里。如果不幸的是,你忘记保存电脑没电关机了,那文字就都没了——因为它们没有被正式存储下来,能正式存储数据的就是磁盘。

1.2 磁盘(SSD/HDD):速度慢但“永恒”

磁盘是用来永久存储数据的地方。它的特点是:

  • 速度慢:比内存慢几个数量级
  • 容量大:通常 256GB、512GB、1TB 甚至更大
  • 价格:便宜
  • 持久保存:断电后数据不会丢失

当你点击 Word 的「保存」按钮时,数据就从内存写入了磁盘。即使关机,下次打开文件还在。

1.3 一个关键操作:保存

在内存和磁盘之间,有一个最核心的操作——保存(Save)

内存(编辑中)─── 保存 ───▶ 磁盘(永久存储)

这个“保存”操作做了两件事:

  1. 把内存中的数据复制一份到磁盘
  2. 在磁盘上创建一个不可变的快照(文件)

这个模式很重要,因为 Git 的核心操作 git commit 本质上也是一次“保存”。


二、Git 的四层存储架构

Git 的存储系统可以分为四个区域,它们和计算机存储体系有着非常匹配的对应关系。

2.1 示意图

flowchart LR %% 样式定义 classDef workspace fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#000; classDef staging fill:#fff9c4,stroke:#fbc02d,stroke-width:2px,color:#000; classDef local fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#000; classDef remote fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000; subgraph Local [💻 本地环境] direction LR WS["📁 工作目录 (Workspace) [内存]"]:::workspace ST["📌 暂存区 (Index / Staging Area) [缓存区]"]:::staging LR["🗄️ 本地仓库 (Local Repository) [本地磁盘]"]:::local end subgraph Remote [🌐 远程环境] RR["☁️ 你的远程仓库 (Your Remote Repo) [比如 Gitee]"]:::remote UP["📤 上游仓库 (Upstream Repo) [其他用户]"]:::remote end %% 核心流向 WS -->|1️⃣ git add| ST ST -->|2️⃣ git commit| LR LR -->|3️⃣ git push| RR %% 回退与合并 LR -->|4️⃣ git restore / reset| WS LR -->|git merge| LR %% 与你的远程仓库交互 RR -.->|git fetch| LR RR -.->|git pull| WS %% 与上游仓库交互 (fork 工作流) UP -.->|git fetch upstream| LR LR -->|git merge upstream/main| LR

2.2 四个区域详解

第一层:工作目录 ≈ 内存(RAM)

像内存一样,工作目录是你正在工作的地方。你在里面新建文件、修改代码、删除内容——所有操作都是实时的、即时的,但也都是临时的

关键

  • 文件状态是最新的
  • 修改不会自动保存——如果你不做任何 Git 操作就关闭了编辑器,修改仍然在文件里(因为操作系统帮你保存到了磁盘),但 Git 不知道你改了什么
# 你在"内存"中修改了文件
vim 你的文件名
# 此时 Git 还不知道你改了
# 就像你在内存中改了数据,但还没点"保存"

注:工作目录就是你能直接看到的项目文件夹。


第二层:暂存区 ≈ 剪贴板 / 缓存区

这是一个中间缓冲地带,用来标记你的哪些修改准备提交。就像剪贴板——你复制了一段内容到剪贴板,确认无误后再粘贴到目标位置。

它的存在是非常重要的,比如你同时修改了 3 个文件,但这时领导说哪些改好了先交上去看一看,此时只有其中 1 个修改完了,另外 2 个还待补充。如果没有暂存区,你只能把目前的文件同时提交,要么先不提交等最后做完一起交,没有中间选项,而暂存区给了你选择权,就可以只选择提交 1 个文件了,等领导看完那个文件,这时你就已经把另外两个改完了,过程十分丝滑顺畅。

# 只把 file1.py 加入暂存区(准备提交)
git add file1.py

# file2.py 和 file3.py 的修改留在工作目录(不提交)

关键

  • 暂存区仍然在“内存层面”——从类比角度它是临时的中间状态。(注:暂存区实际存储在 .git/index 文件中,后文 5.4 节会详述。)
  • 你可以反复 git add,每次都会更新暂存区的快照
  • git status 命令就是对比「工作目录」和「暂存区」的差异
# 把修改从"内存"复制到"缓存区"
git add src/main.py

# 查看状态:哪些在缓存区(绿色),哪些还在内存(红色)
git status

注:git add 不是添加新文件的意思,而是把这个文件的修改加入待提交清单,可以理解为把这个东西先放进购物车,之后买不买再说。


第三层:本地仓库 ≈ 本地磁盘

打开文件资源管理器到你git过的项目文件夹中,你能看到.git/ 目录下的所有内容,是 Git 的核心数据库,存储了项目的完整版本历史。

就像磁盘是你的电脑的永久存储一样,本地仓库是 Git 的永久存储。每次 git commit,就相当于一次保存到磁盘的操作。

关键

  • 数据是持久化的——即使你删除了整个项目文件,只要 .git 目录还在,所有历史记录都还在
  • 每次 commit 都会生成一个不可变的快照(就像磁盘上保存的文件,除非你主动删除,否则不会改变)
  • commit 是原子操作——要么完整保存,要么不保存,不会出现“保存了一半”的情况
# 把暂存区的内容"保存到磁盘"
git commit -m "feat: 添加用户登录功能"

这个命令到底做了什么?

  1. 读取暂存区中的文件快照
  2. 为每个文件生成一个唯一的哈希值(SHA-1)
  3. 将文件内容压缩后存入 .git/objects/ 目录
  4. 创建一个 commit 对象,记录:谁提交的、什么时候提交的、提交信息是什么、上一个 commit 是谁
  5. 更新分支指针(比如 main)指向这个新的 commit

这就像你点击 Word 的“保存”按钮后,操作系统做了这些事:

  1. 读取内存中的文档内容
  2. 将数据写入磁盘的特定扇区
  3. 更新文件的修改时间
  4. 更新文件系统的目录索引

注:git commit 其实就是 Git 世界的“Ctrl+S”。但和 Word 不同的是,Git 每次保存都会创建一个新版本,而不是覆盖旧版本,所以这意味着你可以随时回到任何一个历史版本。


第四层:远程仓库 ≈ 网盘

远程仓库就是你托管在 GitHub、GitLab、Gitee 等服务器上的仓库副本。

就像百度网盘、夸克网盘等一样,远程仓库是你的代码的云端备份团队共享空间

关键

  • 不在你本地电脑上——即使你的电脑坏了,代码还在
  • 可以多人同时访问——团队成员可以各自从远程仓库拉取代码、推送修改
  • 是协作的核心——没有远程仓库,Git 就只是一个本地的版本控制工具
# 把本地"磁盘"的数据同步到"网络磁盘"
git push origin main

# 从"网络磁盘"同步最新数据到本地
git pull origin main

注:push —— 传,pull —— 取


三、一次完整的 Git 工作流

接下来我将用一个完整的例子说明具体要怎么操作。

3.1 使用 winget 命令下载安装 Git (推荐)

winget 是微软官方的 Windows 包管理器,在 Windows 10 1809 版本及更高版本中已经内置了,无需额外安装。

打开 CMD (命令提示符) 或 PowerShell,输入以下命令并回车:

winget install --id Git.Git -e --source winget

执行后,winget 就会自动下载并安装 Git。等待安装完成,然后重启 CMD 就可以正常使用了。

注意winget 默认安装在 C:\Program Files\Git 路径下。

3.2 在 Gitee 上新建仓库 (其他平台同理)

image

  1. 新建仓库,填写仓库名称,确定路径(网页会自动生成与你的仓库名称相关的内容)等。

  2. 选择公开还是私有

⚠️不想开源公布一定记得选择私有!!!

  1. 最后三个选项根据需要选择即可。

  2. 点击创建。

image

根据需要选择 HTTPS 方式或SSH方式。

这两种方式就像是去仓库取东西时的两种不同“通行证”。

简单来说:SSH 更方便(一劳永逸),HTTPS 更通用(但需要密码或令牌)。

以下是详细的对比:

🔑 SSH(推荐:一劳永逸)

  • 原理:你在电脑上生成一对“钥匙”(公钥和私钥)。你把公钥给 Gitee 保管,私钥留在自己电脑里。
  • 体验
    • 免密操作:配置好后,你每次 git pushgit pull 时,Git 会自动用私钥验证身份,不需要输入密码
    • 更安全:基于密钥加密,比密码更难破解。
  • 缺点:第一次配置稍微麻烦一点(需要生成密钥并复制到 Gitee 设置里)。
  • 地址格式:通常以 git@ 开头,例如:git@gitee.com:lynli/my-warehouse.git

🔒 HTTPS(通用:每次都要验证)

  • 原理:就像登录网页一样,每次操作都需要提供账号和密码。
  • 体验
    • 需要验证:每次推送代码时,Git 都会弹窗让你输入用户名和密码(或者私人令牌)。
    • 容易受阻:现在很多平台(包括 Gitee 和 GitHub)为了安全,已经不再支持直接用“登录密码”操作了,必须使用“私人令牌”代替密码,这有时候会让人困惑。
  • 优点:不需要配置 SSH Key,在任何电脑上只要有账号密码(或令牌)就能直接克隆代码。
  • 地址格式:通常以 https:// 开头,例如:https://gitee.com/lynli/my-warehouse.git

❓ 该怎么选?

强烈建议你选择 SSH。

虽然第一次配置需要多花 2 分钟,但以后你再也不用担心输错密码、令牌过期或者每次都要弹窗输入密码的烦恼了。

3.3 在电脑终端里配置你的远程仓库

将你打算提交的本地电脑的目录下点击鼠标右键选择 Open git bash here

进行以下操作:

    1. 配置用户名
git config --global user.name "username"
    1. 配置邮箱
git config --global user.email address@gmail.com

注意:
如果用户名包含空格(如 Zhang San),需要加引号;否则可加可不加。
邮箱地址通常不含空格,一般不需要加引号。

    1. 初始化当前目录为 Git 仓库
git init
    1. 更新远程地址

根据 3.2 中你选择的 HTTPS 方式或 SSH 方式进行远程连接。

如果你决定使用 SSH:
只需要在 Git Bash 里运行这行命令,把远程地址换一下即可:

git remote add origin git@gitee.com:lynli/my-warehouse.git

(注意:把后面 @ 那一串换成你的 SSH 地址)

    1. 将当前内容全部提交到暂存区
git add .
    1. 将当前暂存区内容提交到本地仓库
git commit -m "这里可以备注你的提交内容/更新内容"
    1. 将当前本地仓库内容提交到远程仓库
git push -u origin main
# 或
# git push origin master

Gitee 等平台新建仓库的默认分支名已逐渐从 master 改为 main。若远程是 main,而本地是 master,直接推送可能失败或创建多余分支。
可以先用 git branch -M main 重命名本地分支,或根据远程实际分支名推送。

到目前为止,你的代码已经经历了完整的四层旅程:

内存(当前目录下已编辑好的文件)→ 缓存(git add)→ 磁盘(git commit)→ 网络磁盘(git push)

如果后续增加了文件内容,可以重复上方 5、6、7代码继续提交。

3.4 一个更复杂的场景:选择性提交

你同时改了 3 个文件,但只想提交其中 2 个:

# 修改了三个文件
vim index.js       # 修改了登录逻辑(已完成)
vim style.css      # 修改了样式(已完成)
vim config.js      # 修改了配置(还没改完,不想提交)

# 只把完成的两个文件加入缓存
git add index.js style.css

# 确认状态
git status
# 输出:
# Changes to be committed:     ← 在缓存区(绿色,会提交)
#   index.js
#   style.css
# Changes not staged for commit: ← 还在内存(红色,不提交)
#   config.js

# 提交(只有缓存区的两个文件会被"保存到磁盘")
git commit -m "feat: 完成登录和样式修改"

这就是暂存区存在的意义——让你精确控制每次提交的内容


四、反向操作——从磁盘恢复到内存

理解了保存,我们再来看看恢复。这也是很多新手容易困惑的地方。

4.1 撤销工作目录的修改:从磁盘覆盖内存

# 你在 index.js 里改了一堆东西,改崩了,想恢复到上次 commit 的状态
git restore index.js

小贴士:你可能在网上看到过 git checkout -- <file> 的写法。Git 2.23(2019 年)将 git checkout 拆分成了两个语义更清晰的命令:

  • git restore <file>:恢复工作目录中的文件(替代 git checkout -- <file>
  • git switch <branch>:切换分支(替代 git checkout <branch>

git checkout 至今仍然可用(兼容旧版本),但推荐使用新命令,语义更明确,不容易混淆。本文后续统一使用新命令。

发生了什么? Git 从本地仓库(磁盘)中取出上次保存的版本,覆盖工作目录(内存)中的文件。

本地仓库(磁盘)─── 覆盖 ───▶ 工作目录(内存)

就像你在 Word 里改崩了,选择“不保存”关闭文件,然后重新打开——文件就恢复到了上次保存的状态。

⚠️ 注意:这个操作会丢弃工作目录中的所有未提交修改,不可恢复!

4.2 撤销暂存区的修改:从缓存退回内存

# 你 git add 了,但后悔了,想从缓存区撤出来
git restore --staged index.js
# 或者旧版写法
git reset HEAD index.js

发生了什么? 文件的修改从暂存区(缓存)退回到工作目录(内存),但修改内容本身不会丢失——只是不再被标记为“准备提交”。

暂存区(缓存)─── 退回 ───▶ 工作目录(内存)

就像你把一段文字复制到了剪贴板,但后来觉得不对,又清空了剪贴板——文字本身还在原文档里,只是剪贴板里没了。

4.3 撤销 commit:从磁盘退回缓存或内存

这是三种不同程度的“撤销保存”:

# 方式一:软撤销 —— commit 撤销,但修改保留在缓存区
git reset --soft HEAD~1

# 方式二:混合撤销(默认)—— commit 撤销,修改退回工作目录(未暂存)
git reset --mixed HEAD~1
# 等同于
git reset HEAD~1

# 方式三:硬撤销 —— commit 撤销,修改全部丢弃 ⚠️ 危险!
git reset --hard HEAD~1

用内存/磁盘的类比来理解:

命令 类比 修改保留在哪?
--soft 撤销了「保存」操作,但文件还在「剪贴板」里 暂存区(缓存)
--mixed 撤销了「保存」操作,文件退回「编辑器」里,但没放在剪贴板 工作目录(内存,未暂存)
--hard 撤销了「保存」操作,并且清空了编辑器 哪都不在,彻底丢失

⚠️ 注意git reset --hard 是最危险的操作之一!它会永久删除你的修改。在使用之前,请确保你真的不需要这些修改了。

补充:如果已经 push 到远程了怎么办?

如果你已经把 commit 推送到了远程仓库,并且别人可能已经基于它工作了,不要用 git reset(因为会改写历史,导致队友混乱)。这时候应该用 git revert

# 创建一个"反向修改"的新 commit,安全地撤销某个 commit
git revert <commit-hash>
git push origin main

git revert 不会删除任何历史,只是增加一个新的 commit 把改动撤销回去。这是团队协作中最安全的做法。

4.4 git stash:内存休眠

有时候你正在开发一个功能,改了一半,突然需要切换到另一个分支去修一个紧急 bug。但你的修改还没完成,不想 commit,也不想丢弃。怎么办?

# 把当前工作目录和暂存区的修改"冻结"
git stash
# 输出:Saved working directory and index state WIP on main

# 此时工作目录是干净的,你可以自由切换分支
git switch bugfix/urgent-fix
# ... 修完 bug,提交 ...

# 切回来,"唤醒"之前冻结的修改
git switch main
git stash pop
# 输出:Dropped refs/stash@{0} (abc123...)

类比git stash 就像你把电脑休眠了——内存中的所有数据被保存到磁盘上的一个临时文件,电脑关机后可以随时恢复到休眠前的状态。

工作目录 + 暂存区 ── stash ──▶ .git/refs/stash(临时磁盘空间)
                                        │
                                  stash pop
                                        ▼
                              工作目录 + 暂存区(恢复原状)

五、深入 Git 底层——磁盘上到底存了什么?

前面我们用类比理解了 Git 的操作逻辑,现在让我们打开 .git 目录,看看“磁盘”上到底存了什么。

5.1 .git 目录结构

your-project/
├── .git/
│   ├── objects/       ← 所有数据的"磁盘扇区"(核心存储)
│   ├── refs/          ← 分支和标签的"指针"
│   │   ├── heads/     ← 本地分支
│   │   └── remotes/   ← 远程分支
│   ├── HEAD           ← 当前所在分支的指针
│   ├── index          ← 暂存区(二进制文件)
│   ├── config         ← 仓库配置
│   └── logs/          ← 操作日志
├── src/
├── README.md
└── ...

5.2 objects 目录:Git 的「硬盘扇区」

.git/objects/ 是 Git 存储所有数据的地方。每个文件、每次提交、每个目录结构,都会被存储为一个对象(Object),用 SHA-1 哈希值作为唯一标识。

注:Git 目前仍默认使用 SHA-1,但正在向更安全的 SHA-256 迁移。文章对此不做深入讨论。

Git 有三种核心对象类型:

Blob 对象:文件内容

Blob(Binary Large Object)存储的是文件的内容,不包含文件名。

# 查看某个文件对应的 blob 对象
git hash-object src/main.py
# 输出:a1b2c3d4e5f6...(40 位 SHA-1 哈希)

# 这个哈希值对应的文件存储在
# .git/objects/a1/b2c3d4e5f6...

SHA-1 的输出是 160 位(20 字节),用十六进制表示就是 40 个字符。

关键特性:Git 是内容寻址的——相同内容的文件,无论文件名是什么,都只存储一份。这意味着如果你把一个文件复制了 100 份(内容相同),Git 只会存储一个 blob 对象。

就像磁盘上的文件去重功能——系统发现两个文件内容完全一样,就只存一份,用两个指针指向它。

Tree 对象:目录结构

Tree 对象存储的是目录结构——一个目录下有哪些文件,以及它们对应的 blob 或 tree。

tree (abc123)
├── blob (def456) → "README.md" 的内容
├── blob (789abc) → "index.js" 的内容
└── tree (def789) → src/ 目录
    ├── blob (111222) → "main.py" 的内容
    └── blob (333444) → "utils.py" 的内容

你可以把 tree 想象成文件系统中的文件夹——它记录了目录下有哪些条目,以及每个条目指向哪里。

Commit 对象:提交记录

Commit 对象是 Git 版本控制的核心——它把一切串联起来。

commit (xyz789)
├── tree: abc123          ← 指向当前版本的目录结构
├── parent: aaa111        ← 指向上一个 commit(形成链表)
├── author: 张三           ← 谁写的
├── committer: 张三        ← 谁提交的
└── message: "feat: 添加登录功能"  ← 提交信息

关键:每个 commit 都指向一个 tree(目录结构快照),同时指向上一个 commit(parent)。这样,所有的 commit 就形成了一条链表——这就是 Git 的「版本历史」。

commit3 (最新) ──→ tree3 ──→ blob3a, blob3b, tree3c
    │
    └── parent ──→ commit2 ──→ tree2 ──→ blob2a, blob2b
                        │
                        └── parent ──→ commit1 ──→ tree1 ──→ blob1a

就像磁盘上的文件版本——每次保存都创建一个新版本,但旧版本不会被删除,你可以随时回溯。

5.3 HEAD 和分支:指针的指针

HEAD 是一个特殊的指针,它指向你当前所在的分支

HEAD ──→ main ──→ commit3 (最新的 commit)

当你切换分支时,HEAD 就指向另一个分支:

git switch develop
# HEAD ──→ develop ──→ commit5

分支本质上就是一个指向某个 commit 的可移动指针。当你创建新 commit 时,分支指针就自动向前移动:

# 当前在 main 分支,HEAD → main → commit3
git commit -m "new feature"
# 现在 HEAD → main → commit4(main 指针向前移动了)

用磁盘类比:HEAD 就像你当前打开的文件夹路径,分支就像书签——标记了你在版本历史中的位置。

5.4 .git/index:暂存区的真面目

暂存区不是一个抽象概念,它是一个真实的二进制文件——.git/index。你可以用 git ls-files --stage 命令查看它的内容:

git ls-files --stage
# 输出:
# 100644 a1b2c3d4... 0   README.md
# 100644 e5f6g7h8... 0   src/main.py

每一行记录了:

  • 文件权限(100644 = 普通文件)
  • blob 哈希值(指向 objects 目录中的具体对象)
  • 暂存区编号(用于 merge 冲突)
  • 文件路径

所以暂存区的本质就是一个清单——上面列出了:下次 commit 时应该包含哪些文件的哪个版本。


六、那些让人困惑的操作,用类比重新理解

6.1 git clone = 从网盘下载整个文件夹

git clone https://github.com/someone/project.git

这就像你从百度网盘下载了一个文件夹到本地。它会:

  1. 下载所有文件内容(blob 对象)
  2. 下载所有目录结构(tree 对象)
  3. 下载所有版本历史(commit 对象)
  4. 创建分支指针和 HEAD

所以 git clone 下载的不是当前版本的文件,而是整个仓库的完整历史

6.2 git pull = 下载 + 合并

git pull origin main

git pull 其实是两个操作的组合:

git pull = git fetch + git merge
  • git fetch:从远程仓库下载最新的 commit 和对象,但不修改工作目录(就像从网盘下载了文件,但放在了「收件箱」,还没有打开)
  • git merge:把下载的内容和当前工作目录合并(就像打开收件箱里的文件,和当前文档合并)

6.3 git merge = 把两份文档合并成一份

git merge feature-branch

当你和同事同时修改了同一个文件的不同部分,git merge 会尝试自动合并。如果修改了同一行,就会产生冲突(Conflict)——Git 不知道该保留哪个版本,需要你手动决定。

<<<<<<< HEAD(你的修改)
背景色:白色
=======
背景色:黑色
>>>>>>> feature-branch(同事的修改)

就像两个人同时编辑同一份 Word 文档的不同段落,Word 可以自动合并;但如果两个人改了同一段话,Word 就会让你手动选择保留哪个。

6.4 git rebase = 整理你的保存历史

git rebase main

rebase 的意思是变基——把你的 commit 历史重新排列,让它看起来像是在最新的 main 分支上依次提交的。

# rebase 前:
main:     A ── B ── C
                  \
feature:           D ── E

# rebase 后:
main:     A ── B ── C
                  \
feature:           D' ── E'(D 和 E 被重新创建,基于 C)

用磁盘类比:就像你有一份文档的历史版本 v1、v2、v3,然后你发现 v2 和 v3 其实是基于旧版本的。你可以把 v2 和 v3 的修改重新应用到最新版本上,得到一个更干净的历史。

⚠️ 注意rebase改写历史(创建新的 commit 对象)。如果你已经 push 到远程仓库并且别人已经基于这些 commit 工作了,不要 rebase——否则会造成混乱。黄金法则:只对本地未 push 的 commit 做 rebase。

6.5 git log = 查看保存历史

git log --oneline --graph
# 输出:
# * abc1234 (HEAD -> main) feat: 添加深色模式
# * def5678 fix: 修复登录 bug
# * ghi9012 init: 初始化项目

这就像你查看 Word 文档的版本历史或操作系统的文件修改记录——每次保存都有一条记录,你可以看到谁在什么时候做了什么修改。


七、常见场景实战

场景一:改崩了,想回到上一个版本

# 查看历史,找到想回到的版本
git log --oneline

# 方式一:只是想看看旧版本(不修改分支,随时可以回来)
git checkout abc1234    # abc1234 是某个 commit 的哈希(此处用 checkout 是因为要进入"分离 HEAD"状态)
# 看完了,切回来
git switch main

# 方式二:确定要回到旧版本(会丢弃之后的修改)
git reset --hard abc1234

切换到某个历史 commit 时,只能用 git checkout <commit-hash>,因为 git switch 只接受分支名。Git 会提示你处于'分离 HEAD'状态——这意味着你不在任何分支上,你的新修改不会属于任何分支。

场景二:commit 信息写错了

# 修改最后一次 commit 的信息
git commit --amend -m "正确的提交信息"

这就像你保存了一个文件,然后发现文件名写错了——你可以重命名(修改 commit 信息),但文件内容不变。

场景三:提交到了错误的分支

# 你在 main 分支上做了修改并 commit 了,但其实应该在 feature 分支上

# 1. 创建 feature 分支并切换过去(把 commit 带走)
git branch feature
git switch feature

# 2. 把 main 分支退回到上一个 commit
git reset --hard HEAD~1

# 现在 feature 分支上有你的 commit,main 分支上没有了

场景四:不小心 push 了敏感信息

# 假设你不小心把密码 commit 并 push 了

# 1. 从历史中彻底删除这个文件
git filter-branch --force --index-filter \
  'git rm --cached --ignore-unmatch config/passwords.yml' \
  --prune-empty --tag-name-filter cat -- --all

# 2. 强制推送(覆盖远程历史)
git push origin --force --all

⚠️ 严重警告:一旦密码被 push 到公开仓库,即使你删除了,别人可能已经看到了。正确的做法是立即更换密码,而不是仅仅从 Git 历史中删除。

# 安装 git-filter-repo
pip install git-filter-repo

# 使用示例:从历史中彻底删除某个文件
git filter-repo --path config/passwords.yml --invert-paths

⚠️ 注意:git filter-branch 已被 Git 官方标记为过时,不过这个命令仍然有效,但推荐使用 git-filter-repo 工具。


八、Git 存储的几个“反直觉”事实

事实一:Git 存储的是快照,不是差异

很多人以为 Git 只存储每次修改的差异,但实际上,Git 默认存储的是完整的文件快照——每次 commit 都会保存当时每个文件的完整内容。

不过,Git 底层会通过 git gc 把松散对象打包为 packfile,使用差异压缩来节省磁盘空间。所以逻辑上是快照,物理上依然高效。

事实二:Git 几乎不会丢失数据

只要一个 commit 被创建了(即使你后来 reset 了),它的对象仍然存在于 .git/objects/ 中,直到垃圾回收清理它。你可以用 git reflog 找到所有曾经存在过的 commit:

git reflog
# 输出:
# abc1234 HEAD@{0}: reset: moving to HEAD~1
# def5678 HEAD@{1}: commit: 添加新功能
# ghi9012 HEAD@{2}: commit: 修复 bug

即使你 reset --hard 了,只要你知道 commit 的哈希值,就能恢复:

git checkout def5678  # 恢复到"丢失"的 commit

这就像你删除了一个文件,但它还在回收站里——只要没被清空,就能恢复。

事实三:分支只是指针,创建和删除几乎没有成本

创建一个分支只是在 .git/refs/heads/ 下创建了一个包含 40 字符哈希值的文件。所以 Git 的分支操作是瞬间完成的,无论项目有多大。

# 创建分支:只是写了一个 41 字节的文件
git branch feature-xyz

# 删除分支:只是删除了一个文件
git branch -d feature-xyz

事实四:.git 目录可能比你的项目文件还大

对于长期维护的项目,.git 目录可能非常大(几百 MB 甚至几 GB),因为它存储了完整的版本历史。这就是为什么 GitHub 有文件大小限制,也有 .gitignore 文件的存在——让你排除不需要版本控制的文件(如编译产物、依赖包等)。


九、类比的边界——“内存与磁盘”不能表示的地方

前面我们用“内存与磁盘”的类比把 Git 的核心概念讲了一遍。这个类比在宏观层面非常有效——它能帮你快速建立直觉,理解每个操作的本质。但到了细节层面,这个类比有些地方是不对应的。
你可以跳过这一节,不影响基本使用,但如果你好奇“为什么我的工作目录文件没丢”,这一节就是为你准备的。

9.1 不一致之一:工作目录的文件其实在磁盘上

你的项目文件夹(工作目录)里的文件,是操作系统帮你持久化到磁盘上的。即使你断电重启,文件还在——这和内存“断电即失”的特性完全不同。

从 Git 的视角来看,工作目录中的文件是游离在版本控制之外的——它们存在,但 Git 不认为它们是已保存的版本。只有 git add + git commit 之后,Git 才认可这个版本已被记录。

所以更准确的说法是:

工作目录 = 操作系统的磁盘,但相对于 Git 来说是“未保存的内存”
本地仓库 = Git 自己的磁盘(.git/objects/)

9.2 不一致之二:磁盘保存是覆盖式的,Git commit 是追加式的

当你用 Word 保存文件时,新版本会覆盖旧版本——旧版本就没了(除非有版本历史功能)。但 Git 的 commit 是追加式的——每次 commit 都创建一个全新的快照,旧版本永远不会被覆盖

传统磁盘保存:  v1 → v2 → v3(v1 和 v2 被覆盖,不可恢复)
Git commit:    v1 → v2 → v3(v1 和 v2 仍然存在,随时可回溯)

这意味着 Git 的“磁盘”更像是一种追加写入日志(Append-Only Log),而不是传统意义上的覆盖式存储:

  • 你可以随时回到任何一个历史版本(git checkout <hash>
  • 即使你 git reset 了,旧版本的数据仍然在 .git/objects/ 中(直到垃圾回收)
  • 这也是为什么 .git 目录会越来越大——它从不主动删除旧数据

9.3 不一致之三:CPU 缓存是自动管理的,暂存区是手动管理的

在真实的计算机体系中,CPU 缓存(L1/L2/L3)的读写是硬件自动完成的——你不需要(也无法)手动控制哪些数据进入缓存、哪些被淘汰。但 Git 的暂存区需要你手动操作

git add file.js    # 手动把文件加入暂存区
git add file2.js   # 再手动加一个
git commit         # 手动触发"保存"

没有 git add,你的修改就永远不会进入暂存区;没有 git commit,暂存区的内容永远不会被保存。每一步都需要你显式地告诉 Git

9.4 不一致之四:内存中的数据可以原地修改,Git 的对象是不可变的

在真实的内存中,你可以随时修改任何一个字节:

data = [1, 2, 3]
data[0] = 999  # 原地修改,内存中的值直接变了

但 Git 中的对象(blob、tree、commit)一旦创建就是不可变的——你不能修改一个已有的 blob 对象,只能创建一个新的。每个对象的内容和它的 SHA-1 哈希值是一一绑定的:内容变了,哈希就变了,就是一个全新的对象。

修改前:blob(abc123) → "Hello World"
修改后:blob(def456) → "Hello Git"    ← 这是一个全新的对象,abc123 仍然存在

这种不可变性是 Git 能够保证数据完整性的基础——任何篡改都会导致哈希值不匹配,从而被立即发现。

9.5 不一致之五:内存和磁盘之间没有「分支」概念

在操作系统中,内存就是内存,磁盘就是磁盘,没有「分支」的概念。但 Git 的本地仓库可以有无数个分支,每个分支指向不同的 commit,代表不同的开发线。

main:     A → B → C → E
                 \
feature:          D → F

这种分支模型是 Git 作为版本控制工具的核心能力,在内存/磁盘的类比中找不到对应物。

不过,如果你把分支理解为书签——标记了你在版本历史中的不同位置——就比较好理解了。就像你在看一本厚书时,可以在不同章节夹上不同的书签,随时翻到任意一个书签的位置。

9.6 一张表总结所有不一致

维度 真实的内存/磁盘 Git
数据持久性 内存断电即失 工作目录的文件不会丢失
写入方式 覆盖式(新版本替换旧版本) 追加式(旧版本永远保留)
缓存管理 硬件自动管理 手动 git add
数据可变性 内存中的数据可原地修改 Git 对象创建后不可变
分支概念 不存在 核心功能
寻址方式 物理地址 / 虚拟地址 内容寻址(SHA-1 哈希)

十、最佳实践——如何用好 Git

10.1 频繁 commit,小步提交

就像你应该经常按 Ctrl+S 一样,你应该频繁 commit。每次只做一个小改动,就 commit 一次。这样:

  • 如果出错了,可以精确地回退到任何一个点
  • commit 信息可以清晰地描述每个改动
  • 团队协作时,减少合并冲突
# 好的实践:每个小改动一个 commit
git add login.js && git commit -m "feat: 添加登录表单验证"
git add api.js && git commit -m "feat: 添加登录 API 调用"
git add style.css && git commit -m "style: 调整登录页面样式"

# 不好的实践:一堆改动一个 commit
git add . && git commit -m "做了很多修改"  # ← 太模糊了

10.2 写好 commit 信息

Commit 信息就像文件的「备注」——你以后回看的时候,需要知道这个版本改了什么。推荐使用 Conventional Commits 规范:

<type>(<scope>): <subject>

type:
  feat:     新功能
  fix:      修复 bug
  docs:     文档修改
  style:    格式调整(不影响功能)
  refactor: 重构(不新增功能,不修复 bug)
  test:     测试相关
  chore:    构建/工具链相关

10.3 善用 .gitignore

.gitignore 就像你告诉操作系统这些文件不要备份

# 依赖包(可以重新安装,不需要版本控制)
node_modules/

# 编译产物(可以重新生成)
dist/
build/

# 环境配置(包含敏感信息)
.env
.env.local

# 操作系统生成的文件
.DS_Store
Thumbs.db

# IDE 配置(每个人不同)
.vscode/
.idea/

10.4 提交前检查

# 提交前看看自己到底要提交什么
git diff             # 工作目录 vs 暂存区
git diff --cached    # 暂存区 vs 最新 commit
git diff HEAD        # 工作目录 vs 最新 commit

# 确认无误后再提交
git commit -m "feat: xxx"

就像你在点击 Word 的“保存”之前,应该先检查一下文档内容是否正确。


总结

速查表

你想做什么 Git 命令 类比
编辑文件 直接修改 在内存中修改数据
标记准备提交 git add <file> 复制到剪贴板/缓存
查看当前状态 git status 检查内存和缓存的差异
查看具体改动 git diff 对比内存和缓存的差异
保存版本 git commit -m "..." 保存到磁盘(Ctrl+S)
上传到云端 git push 上传到网盘
从云端下载 git pull 从网盘下载
撤销文件修改 git restore <file> 从磁盘恢复到内存
撤销暂存 git restore --staged <file> 从缓存退回内存
撤销 commit(本地,未 push) git reset HEAD~1 撤销保存,文件退回编辑器(未暂存)
撤销 commit(已 push,安全方式) git revert <commit> 创建一个反向 commit,不删历史
查看历史 git log 查看保存记录
切换版本 git checkout <hash> 打开历史版本的文件
临时冻结修改 git stash 电脑休眠
恢复冻结 git stash pop 从休眠唤醒

FAQ 速查——常见问题

Q1:我改崩了,怎么回到上一个版本?

A1:如果你想丢弃所有未暂存的文件修改(工作区的改动),相当于把所有改了一半但没保存的文件全部还原。用 git restore .

A2:如果已经 commit 了,用 git reset --hard HEAD~1 回到上一个版本。
(⚠️ 会丢掉之后的所有修改)

Q2:我不小心 git add 了不想提交的文件,怎么办?

A:用 git restore --staged <文件名> 把它从暂存区撤回来,文件本身的修改不会丢。

Q3:commit 之后想再加一个文件进去,不想新建 commit,可以吗?

A:可以。先用 git add 把那个文件加入暂存区,然后运行 git commit --amend --no-edit。这会把这个文件合并到上一次 commit 里,不产生新的 commit 记录。

Q4:怎么撤销已经 push 到远程的 commit?

A:如果是个人分支且确定没人基于它工作,可以用 git reset --hard <正确的commit> 然后 git push --force。否则,请用 git revert <要撤销的commit>,它会生成一个新的反向 commit,安全无害。

Q5:我 fork 了别人的仓库后仓库更新了内容,怎么同步原仓库的新更新?

A:先添加原仓库为上游 git remote add upstream <原仓库URL>,然后 git fetch upstream,再合并 git merge upstream/main(或使用 git pull upstream main 一步完成)。


Git 的设计哲学其实非常朴素:分层存储,精确控制,永不丢失。它和操作系统的存储体系一样,都是把数据按照 临时 → 缓冲 → 永久 → 远程 的层次组织起来。

理解了内存与磁盘的类比,就不需要死记硬背 Git 的命令了——每次操作,你只需要问自己一个问题:

我现在要把数据从哪一层移到哪一层?

答案就呼之欲出了。


📢 声明:本文借助AI辅助工具进行资料整理与初稿生成,所有内容均经过作者本人的详细核对、修改与编排,文责自负。

posted @ 2026-04-28 18:11  Lyn_Li  阅读(122)  评论(0)    收藏  举报