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}
使用 apply 再 drop 比直接 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 状态错误。
reset、restore、revert 与 reflog
选择撤销方式时先判断要移动的是引用、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 和内容验证,不要机械选择一侧。
可靠解决流程:
- 理解共同祖先与两侧各自意图。
- 编辑出满足当前目标的第三种结果,不局限于二选一。
- 移除冲突标记,暂存已解决文件。
- 运行与冲突区域相关的测试、类型检查和构建。
- 查看最终 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...B 在 git 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,1 到 127 中除 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 与构建产物之间具备可追溯关系。