跳到正文
前端知识库
Git

Git 原理、安全工作流与资深面试

从对象模型、引用和暂存区深入到变基、冲突、恢复、发布协作与可审计工程实践。

10 分钟Git · 协作 · 版本控制 · 工程化 · 面试

Git 命令很多,但日常工作真正需要掌握的是状态流转:修改先进入工作区,再进入暂存区,最后成为不可变的提交。

三个区域

  • 工作区(Working Tree):正在编辑、还未进入暂存区的文件。
  • 暂存区(Index):下一次提交准备包含的快照。
  • 本地仓库(Repository):由提交组成的历史。

先看状态,再执行修改历史的命令:

git status --short
git diff
git diff --staged

git diff 查看工作区与暂存区的差异;git diff --staged 查看暂存区与最新提交的差异。

日常提交

# 选择性暂存,避免把无关修改塞进同一次提交
git add src/user.ts
git add -p

# 再次核对将要提交的内容
git diff --staged

# 创建语义明确的提交
git commit -m "fix: handle expired user sessions"

一个提交应只表达一个完整意图。格式化、重构与行为修改尽量分开,后续审查和回滚会更容易。

分支工作流

现代 Git 推荐使用 switch 创建和切换分支:

# 从当前提交创建功能分支
git switch -c feat/search

# 切回主分支并同步远端
git switch main
git pull --ff-only

# 查看本地和远端分支
git branch --all

# 删除已合并的本地分支
git branch -d feat/search

git pull --ff-only 会在存在分叉时停止,避免一次无意识的合并提交。

合并与变基

merge 保留真实的分支结构,适合公共分支;rebase 重放提交,让个人功能分支历史更线性。

# 在功能分支上吸收最新主分支
git fetch origin
git rebase origin/main

# 解决冲突后继续
git add <resolved-file>
git rebase --continue

# 放弃本次变基
git rebase --abort

不要对其他人已经基于其开发的公共提交执行 rebase。它会改写提交 ID,并迫使协作者处理重复历史。

查看历史和定位问题

# 紧凑分支图
git log --graph --decorate --oneline --all

# 查看某次提交
git show <commit>

# 查看文件每一行最后由谁修改
git blame src/user.ts

# 在提交历史中搜索文本
git log -S "legacyApi" --oneline

排查回归时,可以用二分定位第一个错误提交:

git bisect start
git bisect bad
git bisect good <known-good-commit>

# 每轮验证后标记
git bisect good  # 或 git bisect bad

git bisect reset

安全撤销

选择命令前先判断修改是否已经提交、是否已经共享。

# 丢弃某个未暂存文件的修改
git restore src/user.ts

# 把文件移出暂存区,但保留工作区内容
git restore --staged src/user.ts

# 为已共享提交创建反向提交,适合公共分支
git revert <commit>

# 修改最近一次提交信息或补充文件,仅用于尚未共享的提交
git commit --amend

git reset --hard 会同时移动分支并覆盖工作区,执行前必须确认目标提交和本地未保存内容。公共分支优先使用 revert

临时保存与恢复

# 保存已跟踪文件和未跟踪文件
git stash push -u -m "wip: search dialog"

git stash list
git stash show -p stash@{0}

# 恢复并保留 stash 记录
git stash apply stash@{0}

# 恢复成功后再删除
git stash drop stash@{0}

使用 applydrop 比直接 pop 更稳妥:发生冲突时,原记录仍然存在。

Git 对象模型与提交身份

Git 底层保存内容寻址对象,而不是简单保存“每个文件的差异”:

  • blob 保存文件内容,不包含文件名。
  • tree 保存目录快照,把名称和模式映射到 blob 或子 tree。
  • commit 指向一棵 tree、父提交以及作者、提交者和说明。
  • annotated tag 指向某个对象,并带标签作者、说明和可选签名。

对象 ID 由对象类型、长度和内容共同计算。文件内容不变时可以复用同一个 blob。commit 内容包含父提交和元数据,因此即使文件快照相同,只要父提交、时间或说明变化,提交 ID 也会不同。

git cat-file -t HEAD
git cat-file -p HEAD
git ls-tree HEAD

分支本质上是指向某个 commit 的可移动引用。HEAD 通常符号指向当前分支;处于 detached HEAD 时,它直接指向一个提交。创建提交会让当前分支引用向新 commit 移动,而已有 commit 对象本身不会被修改。

理解这套模型后,merge 是创建多父提交,rebase 是复制并重放提交,reset 是移动引用并可同步 index/工作区,不再是几组孤立命令。

Index 不只是“待提交文件夹”

暂存区保存下一棵 tree 的候选内容,并在冲突时保存同一路径的多个 stage。它允许把工作区的一部分改动组成一个提交:

git add -p
git diff --staged
git commit

部分暂存前要保证代码在暂存快照中仍可构建。例如工作区已经改了函数签名和调用方,但只暂存其中一半,最终提交会处于不可用状态。可用 git stash push --keep-index 临时隐藏未暂存内容,然后针对 index 对应版本运行验证。

git status 中的两列分别描述 index 相对 HEAD、工作区相对 index 的状态。同一文件可以同时“已暂存且又有未暂存修改”,这不是 Git 状态错误。

resetrestorerevertreflog

选择撤销方式时先判断要移动的是引用、index、工作区,还是要新增反向历史。

  • git restore <path>:让工作区路径恢复到指定来源,默认来自 index。
  • git restore --staged <path>:用 HEAD 内容更新 index,工作区不变。
  • git reset --soft <commit>:只移动当前分支,index 和工作区不变。
  • git reset --mixed <commit>:移动分支并重置 index,工作区不变;这是默认模式。
  • git reset --hard <commit>:三者一起更新,未保存内容可能丢失。
  • git revert <commit>:新增一个抵消目标变更的 commit,适合已共享历史。

引用移动通常会记录在 reflog,可用于找回误删分支或错误 reset 前的提交:

git reflog --date=local
git branch rescue <lost-commit>

reflog 是本地记录,不会推送,也有过期和垃圾回收期限。它是恢复手段,不是备份策略。未提交且被 reset --hard 覆盖的普通工作区内容不一定存在于对象库中,不能承诺可恢复。

Merge、Rebase 与 Cherry-pick 的本质

三者都在整合历史,但产生的图不同:

  • merge 找到共同祖先,计算两侧变化并创建一个包含两个父提交的新提交;快进时只移动引用。
  • rebase 找到当前分支独有提交,把每个补丁重放到新基线,生成新的 commit ID。
  • cherry-pick 把指定提交引入的变化应用到当前位置,也会创建新 commit。

cherry-pick 复制的是变更,不建立原分支的祖先关系。后续再 merge 原分支时,要留意重复语义、发布分支回合并和冲突。

决定是否 rebase 的关键不是“代码是否已推送”,而是其他人是否已经基于这些 commit 工作。个人远端分支可以在协作约定下改写,推送时使用:

git push --force-with-lease

--force-with-lease 会验证远端引用仍是自己预期的旧值,降低覆盖他人新提交的风险;普通 --force 缺少这层保护。

冲突的三个版本与解决策略

三方合并基于共同祖先(base)、当前侧(ours)和另一侧(theirs)。冲突表示 Git 无法可靠自动组合,不代表一定是两个人改了完全相同的一行;重命名、删除与内容修改也会冲突。

git ls-files -u
git diff --ours -- path/to/file
git diff --theirs -- path/to/file

ours/theirs 的语义会随操作变化。普通 merge 中 ours 是当前分支;rebase 过程中,当前工作基线与正在重放的提交容易和直觉相反,因此先用 git status 和内容验证,不要机械选择一侧。

可靠解决流程:

  1. 理解共同祖先与两侧各自意图。
  2. 编辑出满足当前目标的第三种结果,不局限于二选一。
  3. 移除冲突标记,暂存已解决文件。
  4. 运行与冲突区域相关的测试、类型检查和构建。
  5. 查看最终 diff,确认没有静默丢掉一侧逻辑。

长期分支反复遇到相同冲突时,可以启用 rerere 记录并复用解决方案,但自动复用后仍要审查与测试。

git config rerere.enabled true
git rerere status

精确定位历史变化

不同问题使用不同搜索方式:

# 某段文本的出现或消失,比较字符串出现次数
git log -S 'featureFlag' --oneline --all

# diff 中匹配正则的行发生变化
git log -G 'featureFlag\s*=' -p --all

# 跟踪文件历史和重命名
git log --follow -- path/to/file

# 查看两个分支从共同祖先以来分别改了什么
git diff main...feature

A..B 常用于“B 可达但 A 不可达的提交”,A...Bgit diff 中表示共同祖先到 B 的差异,而在 git log 中表示两侧对称差集。命令语境不同,面试时不要笼统说成一个含义。

git blame 给出最后修改某行的提交,不等同于最初作者或责任归属。先用 blame 定位提交,再读提交说明、上下文和后续历史。

自动化 Bisect

当有可重复的通过/失败命令时,二分可以自动运行:

git bisect start <bad-commit> <good-commit>
git bisect run npm test -- --runInBand path/to/regression.test.ts
git bisect reset

测试脚本退出码 0 表示 good,1127 中除 125 外表示 bad,125 表示当前提交无法测试并跳过。每个历史提交必须具备可执行环境;构建系统变化时,可能需要一个兼容脚本或手工标记 skip。

第一个引入失败的提交不一定是根本责任点:它可能暴露旧缺陷,或依赖后来才出现的数据。bisect 提供搜索证据,仍需阅读变化和验证因果。

团队分支与发布策略

Trunk-based、短生命周期功能分支、GitHub Flow 和发布分支都有适用场景。选择时比较发布频率、自动化测试、并行版本、合规审计和回滚能力。

高质量协作通常包含:

  • 小而完整的提交,PR 描述说明背景、风险和验证方式。
  • 主分支保护,要求检查和审查通过,不直接改写历史。
  • 功能未完成时使用 feature flag,而不是维护数月长分支。
  • 发布使用不可变 tag 或构建 provenance,确保源码与产物可追溯。
  • 热修复明确回合并到仍受支持的所有分支,避免下次发布重新引入问题。

squash merge 让主线每个 PR 一个提交,历史紧凑但丢失中间提交;merge commit 保留完整拓扑;rebase merge 形成线性历史。团队应基于回滚、bisect 和审计需求统一约定,而不是把某一种图形当作绝对正确。

多任务与大型仓库工具

git worktree 可以在同一仓库对象库上同时检出多个分支,适合一边开发一边处理紧急修复:

git worktree add ../project-hotfix -b fix/urgent origin/main
git worktree list

同一分支不能同时检出到两个 worktree。完成后先确认目录内没有需要保留的内容,再移除 worktree。它比频繁 stash 更能保持任务上下文清晰。

大型二进制资产不适合普通 Git 历史,可评估 Git LFS;超大单仓库还可能使用 sparse-checkout 和 partial clone 减少工作区与对象下载。但工具只能缓解规模,仍需控制生成物和无意义大文件进入历史。

安全与供应链

  • 提交前使用 secret scanner,泄漏后要立即轮换凭证,删除历史不能让已泄漏密钥重新安全。
  • 签名 commit/tag 可以验证作者控制的密钥与对象完整性,但不证明代码本身可信。
  • .gitignore 只影响未跟踪文件,已经跟踪的密钥不会因新增 ignore 规则自动消失。
  • Git hooks 适合快速本地反馈,但可被跳过;关键检查必须在 CI 和分支保护中强制。
  • 拉取未知仓库时谨慎执行其中脚本,代码仓库本身就是供应链输入。

资深面试追问

Rebase 为什么会改变 commit ID?

commit 对象包含父提交 ID。rebase 把补丁应用到新父提交上,即使文件结果相同,父字段已经变化,因此产生新对象和新 ID;时间或冲突解决也可能进一步改变内容。(深入阅读:Merge、Rebase 与 Cherry-pick 的本质

Merge 和 rebase 如何选择?

共享历史优先保持稳定,可 merge;个人尚未被他人依赖的分支可 rebase 整理并吸收主线。还要结合团队是否要求线性历史、是否依赖 merge commit 表达发布边界,以及冲突是否更适合集中或逐提交解决。(深入阅读:Merge、Rebase 与 Cherry-pick 的本质

如何撤销已经发布到主分支的错误提交?

通常创建 revert commit,保留审计链并让所有协作者通过普通拉取获得修复。如果目标是 merge commit,需要明确主线父节点。之后单独修复并重新提交;直接 reset 和强推主分支会改写共享历史,除非发生极特殊安全事件并有团队协调。(深入阅读:reset、restore、revert 与 reflog

Git 如何保存分支?

分支是 refs/heads/* 下指向 commit 的引用,可打包存储;HEAD 通常符号指向当前分支。提交后 Git 创建 tree/commit 对象并原子更新该引用。删除分支只是删除引用,只要提交仍被其他引用或 reflog 可达,对象不会立即消失。(深入阅读:Git 对象模型与提交身份

提交前检查

  • git status --short 中只有本次任务相关文件。
  • git diff --staged 没有密钥、构建产物或调试日志。
  • 测试、类型检查和格式检查已经通过。
  • 提交信息描述了“为什么改”,而不只是“改了什么文件”。
  • 推送前确认当前分支和上游:git branch -vv
  • 改写个人远端历史时使用 --force-with-lease,并确认没有协作者依赖。
  • 冲突解决后验证两侧业务意图,不只确认冲突标记已经消失。
  • 发布 commit、tag 与构建产物之间具备可追溯关系。