Git 版本控制系统详解
Git 是全球最流行的分布式版本控制系统,由 Linus Torvalds 于 2005 年为 Linux 内核开发而创建。它将数据视为快照流(stream of snapshots),而非基于文件的增量差异(delta),几乎所有操作均为本地执行,速度极快。
1. 概述
1.1 Git 是什么
Git 本质上是一个内容寻址文件系统(content-addressable filesystem),其核心是一个简单的键值(key-value)数据库。任何内容都可以通过 git hash-object 存入 Git,Git 会返回一个 40 字符的 SHA-1 哈希作为唯一键。
- 快照而非差异:每次提交记录的是整个项目的快照,未变化的文件只保留链接指向之前存储的相同内容
- 近乎所有操作本地执行:完整的项目历史在本地磁盘上,无需网络即可浏览历史、提交代码
- 完整性保证:所有内容以 SHA-1 校验,无法在 Git 不知情的情况下篡改
- 一般只增数据:几乎所有操作都只向 Git 数据库添加数据,很难让系统执行不可撤销的操作
1.2 三种状态与三个区域
Git 中文件有三种状态——已修改(modified)、已暂存(staged)、已提交(committed),对应三个工作区域:
| 区域 | 说明 |
|---|---|
| 工作目录(Working Tree) | 从 Git 目录解压出来的某个版本的单一检出,放在磁盘上供使用和修改 |
| 暂存区(Staging Area / Index) | 存储在 .git/index,记录下一次提交的内容信息 |
| Git 目录(Repository / .git) | 存储项目的元数据和对象数据库,clone 时复制过来的核心部分 |
基本工作流:修改文件 → 暂存变更 → 提交快照。
1.3 Git 与 SVN/CVS 的核心差异
传统集中式 VCS(如 SVN、CVS)以文件差异列表(delta-based)的方式存储数据:记录每个文件随时间变化的增量。Git 则将数据视为小型文件系统的快照流:每次提交时对整个项目拍照,未变化的文件不重复存储,仅存一条链接。
2. 行为图(系统"做什么")
2.1 用例图 — Git 用户交互全景
2.2 活动图 — 基本工作流
2.3 状态机图 — 文件生命周期
Git 文件中文件在不同区域之间流转,构成完整的状态机:
2.4 序列图 — 提交过程
当用户执行 git commit 时,Git 内部如何创建 blob、tree、commit 三种对象:
3. 结构图(系统"由什么组成")
3.1 组件图 — 核心架构
Git 内部由四种核心对象类型(blob、tree、commit、tag)和引用系统(refs、HEAD)组成,协同工作构成完整版本控制系统:
3.2 对象模型
Git 底层四种对象类型及其关系:
| 对象类型 | 存储内容 | 对应概念 |
|---|---|---|
| blob | 文件内容(不含文件名) | 文件内容 / inode |
| tree | 目录条目列表:文件名 + blob/tree 引用 + 模式 | 目录 |
| commit | tree 引用、父 commit、作者、提交者、时间戳、消息 | 快照 |
| tag | 对象引用、标签类型、标签名、标签者、消息(仅 annotated) | 不可移动的分支引用 |
对象存储机制:每个对象存储时,Git 会构造一个 header(格式:"对象类型 内容长度\0"),与内容拼接后计算 SHA-1 哈希,再经 zlib 压缩写入 .git/objects/<前2字符>/<后38字符>。
3.3 部署图 — 分布式拓扑
Git 是分布式系统,多个仓库通过推拉操作实现同步:
4. 实战速查手册
4.1 仓库操作
| 命令 | 说明 |
|---|---|
git init | 初始化新仓库,创建 .git 目录 |
git clone <url> | 克隆远程仓库到本地,完整复制对象数据库 |
git remote add <name> <url> | 添加远程仓库引用 |
4.2 基本快照
| 命令 | 说明 |
|---|---|
git status | 查看工作区 & 暂存区状态 |
git diff | 查看工作区相对于暂存区的差异 |
git diff --staged | 查看暂存区相对于最新 commit 的差异 |
git add <file> | 将修改添加到暂存区(计算 blob 哈希加入 index) |
git commit -m "msg" | 将暂存区内容创建为新的 commit 对象 |
git rm <file> | 从工作区和暂存区删除文件 |
git mv <old> <new> | 移动/重命名文件 |
4.3 分支与合并
| 命令 | 说明 |
|---|---|
git branch | 列出本地分支 |
git branch <name> | 创建新分支(在 .git/refs/heads/<name> 写入 commit SHA-1) |
git checkout <branch> | 切换分支(更新 HEAD 和工作目录) |
git merge <branch> | 将指定分支合并到当前分支 |
git rebase <branch> | 将当前分支变基到目标分支 |
git cherry-pick <commit> | 将指定 commit 应用到当前分支 |
git stash | 暂存当前工作区修改 |
4.4 远程协作
| 命令 | 说明 |
|---|---|
git fetch <remote> | 从远程拉取对象和引用(不合并) |
git pull <remote> <branch> | fetch + merge(或 rebase) |
git push <remote> <branch> | 推送本地 commit 到远程 |
git push --tags | 推送所有标签到远程 |
4.5 历史与追溯
| 命令 | 说明 |
|---|---|
git log | 查看 commit 历史 |
git log --oneline --graph --all | 简洁图形化全分支历史 |
git blame <file> | 逐行显示文件修改作者和 commit |
git bisect | 二分查找引入 bug 的 commit |
git tag -a v1.0 -m "msg" | 创建带注释的标签(含 tag 对象) |
4.6 撤销与修复
| 命令 | 说明 |
|---|---|
git reset HEAD <file> | 从暂存区移除文件 |
git checkout -- <file> | 丢弃工作区的修改 |
git reset --soft HEAD~1 | 撤销最近一次 commit,保留暂存 |
git reset --mixed HEAD~1 | 撤销 commit + 暂存,保留工作区 |
git reset --hard HEAD~1 | 完全撤销 commit、暂存和工作区(危险) |
git revert <commit> | 生成一个新 commit 反向操作指定 commit |
git reflog | 查看 HEAD 和分支引用的变化记录 |
4.7 底层对象检查 (Plumbing)
| 命令 | 说明 |
|---|---|
git cat-file -t <sha1> | 查看 Git 对象的类型(blob/tree/commit/tag) |
git cat-file -p <sha1> | 打印 Git 对象的内容 |
git hash-object -w <file> | 将文件内容写入对象数据库,返回 SHA-1 |
git update-index --add --cacheinfo 100644 <sha1> <file> | 手动向 index 添加条目 |
git write-tree | 根据 index 内容创建 tree 对象 |
git commit-tree <tree-sha> -p <parent-sha> | 手动创建 commit 对象 |
git update-ref refs/heads/<branch> <sha1> | 手动更新分支引用 |
git gc | 垃圾回收,生成 packfile 优化存储 |
4.8 分支工作流示例
GitHub Flow
git checkout -b feature/xxx # 从 main 创建功能分支 # ... 开发 & 提交 ... git push origin feature/xxx # 推送功能分支 # 在 GitHub 上创建 Pull Request # 审查通过后合并到 main git checkout main git pull origin main # 拉取最新 main git branch -d feature/xxx # 删除本地功能分支
Git Flow(适用于有版本发布周期的项目)
# 主分支: main (生产) + develop (开发) git checkout -b release/1.0 develop # 创建发布分支 # ... 修复 bug ... git checkout main git merge --no-ff release/1.0 # 合并到主分支 git tag -a v1.0 -m "Release 1.0" # 打版本标签 git checkout develop git merge --no-ff release/1.0 # 合并回开发分支 git branch -d release/1.0
4.9 回滚到指定 commit
当需要将分支回滚到某个历史 commit 并推送到远程时,有三种方案,按安全性从低到高排列:
方案一:reset --hard + force push(最直接)
git reset --hard <commit-sha> git push --force origin <branch>
原理:直接将 HEAD 和分支引用移动到目标 commit,然后 force push 覆盖远程。
风险:被丢弃的 commit 从远程消失,若他人已基于这些 commit 开发,会导致冲突。
方案二:git revert(安全,不改写历史)
# 假设当前在 9b58a06,目标回滚到 6eef430 # 6eef430 之前有 2 个 commit 需要撤销 git revert --no-commit HEAD~1..HEAD git commit -m "Revert to <commit-sha>" git push origin <branch>
原理:生成一个新的 revert commit,反向操作目标 commit 之后的所有变更,不修改已有历史。
缺点:历史中多出一个 revert commit,不如方案一干净。
方案三:备份分支 + reset + force push(推荐)
# 1. 备份当前状态 git branch backup-<branch> # 2. 硬回滚 git reset --hard <commit-sha> # 3. 强制推送 git push --force origin <branch>
原理:在方案一的基础上先创建备份分支,保留当前 HEAD 的引用(仅本地),万一需要恢复可以随时切回。
git reset --hard backup-<branch> && git push --force origin <branch>适用场景:个人仓库的首选方案,兼顾干净历史和可恢复性。
三种方案对比
| 方案 | 改写历史 | 需 force push | 可恢复性 | 适合场景 |
|---|---|---|---|---|
| reset + force | 是 | 是 | 仅本地 reflog | 个人仓库 |
| git revert | 否 | 否 | 天然保留 | 共享分支 |
| 备份 + reset + force | 是 | 是 | 备份分支 | 个人仓库(推荐) |
4.10 git rebase 详解
git rebase 将一个分支的提交记录按顺序重放到目标分支的顶端,从而获得一条线性、整洁的历史。它本质上是"复制"提交——每个提交都会基于新基底重新生成,得到全新的 SHA-1 哈希,因此属于改写历史的操作。
4.10.1 rebase 与 merge 对比
| 对比项 | git rebase | git merge |
|---|---|---|
| 历史形状 | 线性(无分叉节点) | 存在分叉 + 合并节点(merge commit) |
| 改写历史 | 是(重放产生新 commit) | 否(新增 merge commit,原提交不变) |
| 冲突处理 | 逐个提交重放,冲突逐次出现 | 一次合并,冲突集中处理 |
| 安全性 | 不适用已推送/共享分支 | 可安全用于共享分支 |
| 可追溯性 | 丢失"何时、为何合并"的节点 | merge commit 保留合并上下文 |
4.10.2 基本用法
git rebase <branch> # 将当前分支的提交重放到 <branch> 顶端 git rebase origin/main # 同步远程最新,常用在 push 被拒后 git pull --rebase origin main # fetch + rebase(等价于上面两步) git rebase -i HEAD~3 # 交互式重写最近 3 个提交 git rebase --continue # 解决冲突后继续 git rebase --skip # 跳过当前提交(放弃该提交) git rebase --abort # 放弃整个 rebase,回到操作前状态
典型场景:本地 push 被拒绝(远程有新提交)时,用 git rebase origin/main 将本地提交重放到远程最新提交之上,再 push,避免产生多余的 merge commit。
4.10.3 交互式 rebase(rebase -i)
git rebase -i <base> 打开一个编辑列表,每行是一个待重放的提交,可通过修改命令词来改写历史:
| 命令 | 缩写 | 作用 |
|---|---|---|
pick | p | 保留该提交(默认) |
reword | r | 保留提交,但修改提交信息 |
edit | e | 停在该提交,允许修改内容后继续 |
squash | s | 将该提交合并到上一个提交(合并提交信息) |
fixup | f | 合并到上一个提交(丢弃本提交信息) |
drop | d | 删除该提交 |
exec | x | 在指定位置运行 shell 命令 |
git rebase -i HEAD~3 # 示例:合并最近 3 个提交为 1 个 # 编辑器中调整: # pick 1a2b3c feat: 功能 A # fixup 4d5e6f 补丁修正 # fixup 7g8h9i 格式调整 # 保存退出后,三个提交合并为一个
4.10.4 冲突处理
rebase 是逐提交重放,冲突会多次出现。处理流程:
git rebase origin/main # 触发冲突 git status # 查看冲突文件(<<<<<<< HEAD ======= >>>>>>> 标记) # 手动编辑冲突文件,保留正确内容 git add <file> # 标记已解决 git rebase --continue # 继续下一个提交 # 反复直到全部重放完成;想放弃则: git rebase --abort
git rebase --abort 可随时安全退回操作前状态(rebase 前 Git 已记录 ORIG_HEAD)。
4.10.5 黄金法则与风险
rebase 会改写 SHA-1,使其他协作者基于旧提交的工作与你的历史分叉,导致混乱。只对尚未推送的本地提交执行 rebase。
- 丢失提交信息:squash/fixup 会合并提交信息,原始的中间过程描述消失
- 与 force push 配合有风险:rebase 后 push 被拒时需
git push --force-with-lease(比 --force 安全,先检查远程未被他人更新) - 可恢复性:误操作后可
git reflog找回操作前的 SHA-1,再git reset --hard <sha>恢复
4.11 git pull 原理与冲突详解
4.11.1 pull 的内部原理
git pull 是一个组合命令,它从来不是单一操作,而是 fetch + merge 两个步骤的快捷方式(加 --rebase 则等价于 fetch + rebase):
git pull origin main # 等价于:git fetch origin && git merge origin/main git pull --rebase origin main # 等价于:git fetch origin && git rebase origin/main
| 步骤 | 命令 | 做了什么 | 是否改动工作区 |
|---|---|---|---|
| 第一步 | git fetch | 从远程下载缺失的对象,更新 refs/remotes/origin/<branch> 引用 | 否(只拉数据) |
| 第二步 | git merge | 把远程分支合并进当前本地分支 | 是 |
| 第二步(--rebase) | git rebase | 把本地未推送的提交重放到远程分支顶端 | 是 |
git fetch 而非 git pull。
4.11.2 为何会冲突:三方合并
Git 合并时不是简单的"逐行覆盖",而是采用三方合并(three-way merge),需要三个输入:
merge base(共同祖先) + 我方提交(HEAD) + 对方提交(origin/main) # 对比三方后,Git 自动合并双方互不影响的改动; # 只有三方中"我方和对方都改了同一处",才产生冲突
| 文件行状态 | 共同祖先 | 我方 HEAD | 对方 origin/main | Git 行为 |
|---|---|---|---|---|
| 仅一方修改 | 原内容 | 改了 | 未改 | 自动采用该方的修改 |
| 双方改不同行 | 原内容 | 改了 A 处 | 改了 B 处 | 自动合并两处改动 |
| 双方改同一行 | 原内容 | 改了 | 改了(不同内容) | 冲突,需人工解决 |
| 一方删文件、一方改文件 | 存在 | 删除 | 修改 | 冲突,需人工决定 |
4.11.3 冲突如何解决
冲突后 Git 暂停合并,在冲突文件中写入标记:
<<<<<<< HEAD ← 上方是我方版本 你写的代码 ======= ← 分隔线 别人写的代码 ← 下方是对方版本 >>>>>>>> origin/main
解决流程:
git status # 查看哪些文件冲突(Unmerged paths) # 手动编辑冲突文件:保留正确内容,删除 <<<<<<< ======= >>>>>>>> 标记行 git add <file> # 标记为已解决 git merge --continue # merge 时:完成合并提交 git rebase --continue # rebase 时:继续重放下一个提交 # 想放弃本次合并,回到操作前状态: git merge --abort # 或 git rebase --abort
git pull(保持同步)、少长时间停留在同一文件,可显著降低冲突概率。共享分支冲突用 merge 处理;本地未推送的提交冲突用 rebase 处理。
4.12 git worktree 原理详解
git worktree 让同一个仓库同时把多个分支检出到不同目录。一个仓库有 1 个主工作树(main worktree,即 git init/git clone 生成的那个),外加 0 到多个链接工作树(linked worktree)。所有工作树共享同一份对象库和 refs,但各有独立的 HEAD 和 index。
4.12.1 为什么需要 worktree
经典场景:你正在 feature 分支做一半重构,工作区一堆半成品,此时线上出现紧急 bug 需要立刻在 main 上修复。传统做法是 git stash 暂存改动再切分支,但工作区可能乱到不敢动;而 worktree 的解法是——另开一个目录检出 main,改完直接删掉目录,原来的工作区原封不动。
相比 git clone 多份仓库,worktree 的优势是:零拷贝对象库、共享分支命名空间,磁盘占用和"仓库间同步"成本都更低。
一个容易误解的点:worktree 复制的是「工作区文件」,不是「仓库本体」。每个工作树目录里都是一份完整的源码检出,所以磁盘上源码确实有 N 份;但 .git/objects 与 refs 只有一份,被所有工作树共享。这正是它和"切换分支"、clone 的本质区别:相比切换分支(工作区始终 1 份)它更贵——工作区从 1 份变 N 份;相比 clone(对象库也翻倍)它更省——对象库不复制。
4.12.2 存储原理(核心)
链接工作树的顶层是一个普通目录,但其中的 .git 是一个文件而非目录,内容只有一行:
gitdir: /path/main/.git/worktrees/<id>
主仓库的 .git/worktrees/<id>/ 目录则保存该工作树的私有管理文件。进入链接工作树后:
$GIT_DIR指向私有的.git/worktrees/<id>/(放HEAD、index、gitdir、可选locked、config.worktree)$GIT_COMMON_DIR指回主仓库的.git(放共享的objects/、refs/、config)
| 内容 | 共享(common)还是每树私有(per-worktree) |
|---|---|
objects/ 对象数据库 | 共享——所有工作树看到同一份历史 |
refs/(heads、tags、remotes) | 共享——同名分支在所有树中指向同一个 commit |
config | 共享(开启 extensions.worktreeConfig 后可用每树 config.worktree) |
HEAD(伪引用) | 每树私有——各工作树可处于不同分支 |
index 暂存区 | 每树私有——各自独立的暂存状态 |
refs/bisect、refs/worktree、refs/rewritten | 每树私有(refs 共享规则的例外) |
关键推论:因为 refs/heads 共享、分支是"某条 ref 指向的 commit"这一全局状态,所以同一个分支不能被两个工作树同时检出(否则两处的提交会互相打乱该分支的指向)。Git 默认拒绝,除非显式 --force。
4.12.3 常用命令
| 命令 | 说明 |
|---|---|
git worktree add <path> | 在 path 新建链接工作树,自动创建并检出与目录同名的新分支 |
git worktree add <path> <branch> | 在 path 检出已存在的分支 |
git worktree add -b <new> <path> <base> | 基于 base 新建分支 new 并检出到 path |
git worktree add -d <path> | 创建 detached HEAD 的临时工作树(试验/测试用) |
git worktree list | 列出所有工作树;-v 显示锁定/可清理原因,--porcelain 供脚本解析 |
git worktree move <wt> <new-path> | 移动工作树到新位置 |
git worktree remove <wt> | 删除工作树(仅限干净状态,强制用 -f) |
git worktree lock <wt> | 锁定工作树,防止其管理文件被自动清理;可加 --reason |
git worktree unlock <wt> | 解锁 |
git worktree prune | 清理工作目录已被手动删除的工作树的残留管理文件 |
git worktree repair [path] | 工作树被手动移动/仓库被搬走后,重建 gitdir 双向链接 |
# 紧急修 bug:不动当前工作区,另开目录在 main 上修 git worktree add -b emergency-fix ../repo-hotfix main cd ../repo-hotfix # ... 修改并提交 ... git commit -am "emergency fix" cd - git worktree remove ../repo-hotfix # 修完即删 # 查看所有工作树 git worktree list # /repo abcd1234 [main] # /repo-hotfix 5678abc [hotfix] # /tmp/repo-test 1234abc (detached HEAD)
# 并行开发:当前在 main,另开目录做 feature,两处同时推进 git worktree add -b feature/login ../repo-feature main # 基于 main 新建分支并检出 cd ../repo-feature # ...在 feature/login 上开发并提交... git add -A && git commit -m "feat: login" cd - # 回到主工作树(仍在 main) # 方式一:本地合并(不需要 push,合并是独立的一步,push 从不自动合并) git merge feature/login # 在 main 工作树里把 feature 合进来 git push origin main # 视需要再把合并结果推到远端 # 方式二:走远端 PR/MR(团队协作,多一道评审) git push -u origin feature/login # 只上传 feature 分支,main 不受影响 # 在平台上开 PR 合并;合并后再本地更新: # git fetch && git merge origin/main # 收尾:删除工作树,再删已合并分支 git worktree remove ../repo-feature git branch -d feature/login # 已合并才可 -d,否则用 -D
4.12.4 与 clone / stash 的对比
| 对比项 | git worktree | git clone(多副本) | git stash(切换) |
|---|---|---|---|
| 工作区文件 | N 份(各分支各一份完整检出) | N 份(各自完整检出) | 始终 1 份(同一时刻只落地一个分支) |
| 对象库 | 共享同一份 | 各自独立,磁盘翻倍 | 同一份 |
| 同时编辑的文件 | 多目录并存 | 多目录并存 | 只能一处 |
| 分支切换成本 | 无需切换 | 无需切换 | 需 stash + checkout |
| 多仓库间同步 | 无需(共享 refs) | 需各自 fetch/push | 无需 |
| 适用场景 | 同仓库并行多分支 | 完全隔离的实验/CI | 临时打断 |
4.12.5 注意事项
- 分支唯一检出:同一分支不能在两个工作树同时检出,否则需
--force(危险) - 主工作树不可删除:
git worktree remove不能作用于主工作树 - 手动删目录要 prune:直接
rm -rf工作树目录后,用git worktree prune清掉残留元数据(gc.worktreePruneExpire也会自动过期) - 搬运后要 repair:手动移动工作树或主仓库位置后,用
git worktree repair重建gitdir链接 - 可移动设备用 lock:工作树放在未常挂载的移动盘/网络盘时,
git worktree lock防止被误清理 - 子模块支持不完整:多个 checkout 总体仍是实验性特性,不推荐对超级项目做多重检出