① 先把后文会反复出现的词认清楚
先认词:这一课最容易卡住的不是命令,而是把对象身份、引用位置、工作目录状态和真实运行版本混成“代码版本”。
Commit / SHA · 提交 / 提交哈希Commit 是封存的一版历史;它由 Git 对象 ID 标识,传统 SHA-1 仓库常简称为 SHA。
- 直觉
- Commit 像存档点,对象 ID 像这个存档点的内容寻址标识。
- 边界
- 现代 Git 也支持 SHA-256 对象格式,所以“SHA”是常见口语,严格概念是 Object ID。
Blob Object ID (OID) · 内容寻址指纹Git 根据文件字节内容生成 blob 对象 ID;它证明内容身份,不证明页面或业务正确。
- 直觉
- blob OID 可以理解成“这段精确文件字节在 Git 对象库里的身份证”。内容一模一样时,不同路径可以复用同一个 blob。
- 严格定义
- 对文件字节 x,可抽象为
OID_blob = H("blob " || len(x) || NUL || x)。传统 Git 对象格式使用 SHA-1,Git 也支持 SHA-256 对象格式;文件名和路径不在 blob 中,而由 tree 把 mode、name 与 blob OID 关联。 - 怎么观察
前两条可以计算/读取 blob OID,git hash-object path/to/file git rev-parse HEAD:path/to/file git cat-file -t <OID> git cat-file -p <OID> git ls-tree HEAD path/to/filecat-file查看对象类型或内容,ls-tree则能看到路径名如何由 tree 指向 blob。- 边界
- 相同 blob OID 能证明两份对象的字节身份一致,却不能证明这些字节满足业务语义,也不能证明浏览器渲染正确。应区分 Artifact Identity、Semantic Correctness 与 Rendered Correctness。
- 当前案例
- 之前
docs/lessons与examples可以拥有同一个 HTML blob OID,但两边包含的是同一个会破坏 MathML 的 CSS,因此它们是“一致地错误”。这正说明 blob/hash consistency 只能作为身份门禁,不能替代 Chromium 等真实 Runtime 的渲染 QA。
Branch · 分支指向某个提交、会随着新提交向前移动的引用。
- 直觉
- 它像贴在提交历史上的可移动书签。
HEAD · 当前检出位置表示当前 Git 工作目录“我现在站在哪个分支或提交上”。
- 当前案例
- 服务器 HEAD 停在旧提交时,fetch 最新 origin/main 也不会自动切换工作目录。
git fetch · 获取远端信息下载远端新对象并更新远程跟踪引用,但通常不改变当前 Working Tree。
- 当前案例
- fetch 后本地可以知道 origin/main 最新位置,但 HEAD 不会自动跟过去。
origin/main · 远程跟踪引用本地记录的“最近一次看到远端 main 在哪里”的指针。
- 边界
- 它是一份本地远端认知,不是当前 checkout,也不是远端服务器本身。
git checkout · 检出 / 切换让某个工作目录真正摊开指定分支或提交对应的文件。
- 边界
- 知道 origin/main 在哪里,与把当前工作目录 checkout 到那里是两回事。
Working Tree · 工作树当前工作目录实际摊开的文件,以及相对索引/HEAD 尚未提交的变化。
- 边界
- clean 只表示没有未提交变化,不表示 HEAD 是最新版。
Git worktree · Git 额外工作目录让同一个对象库同时拥有多个独立 checkout 工作目录。
- 边界
- Git worktree 是机制名;Working Tree 是某个 checkout 当前摊开的文件状态。
ahead / behind · 领先 / 落后比较两个 refs 的可达提交集合,统计双方各自独有的提交数量。
- 严格定义
- 它是成对比较关系,不是一个分支的绝对属性;本质上接近 Reach(A) 与 Reach(B) 的集合差计数。
Fast-forward Merge · 快进合并目标历史包含当前历史时,只移动引用就能完成合并。
- 边界
- 两边都产生独有提交后,就不能只靠快进。
Merge Conflict · 合并冲突双方修改无法被 Git 自动组合时,需要人或规则判断最终语义。
- 当前案例
- 冲突数量说明自动合并受阻,不代表某一边天然“错”。
② 现在再看这段材料到底在说什么
一句话:Git 的“状态不一致”不是一个 bug,而是它允许多个引用、多个工作区和多个历史分支独立演化的直接结果。真正的问题是:你有没有把每个状态变量说清楚。
S = (G, R, I, W)。其中 G 是不可变对象组成的提交图,R 是 branches/tags/remote-tracking refs 等可移动引用,I 是 index,W 是 working tree。大多数 Git 困惑都来自把这四个东西混成“代码版本”。例如,“服务器已经 fetch 最新 main”只改变了 R 里的 origin/main;它不会自动移动当前 HEAD,不会自动改 index/working tree,更不会自动重建 Docker 镜像或重启生产进程。于是完全可能同时出现:
GitHub main = 84382576
server origin/main= 84382576 # 服务器已经知道远端在哪
server HEAD = c5e35ae # 服务器实际 checkout 仍在别处
working tree = clean # 但相对 c5e35ae 没有未提交改动
runtime image = ??? # 还需要单独证明
第一层:对象与历史
commit 不会因为分支移动而“变成另一个 commit”。先保护独有对象,再考虑把引用收敛。
第二层:引用关系
main、origin/main、HEAD 都只是名字/指针。比较它们必须解析到具体 commit,并检查祖先/可达关系。
第三层:未提交状态
index、working tree、untracked、stash、worktree 可能保存唯一内容;hard reset 之前必须先盘点。
clean 的严格含义为什么很窄
简化地说,如果把当前 HEAD 快照记作 T_H、index 快照记作 T_I、工作区可跟踪内容记作 T_W,那么“没有 staged/unstaged 修改”要求它们在相关比较上相等:
但这个等式里根本没有 origin/main。所以 clean 与 up-to-date 是两个正交命题:一个回答“有没有本地未提交变化”,另一个回答“当前 commit 与某个远端 ref 的图关系是什么”。
③ 概念关系与最小知识闭包
最小知识闭包 1:分支关系是偏序,不是时间排序
设提交图为 DAG G=(V,E),边从子 commit 指向父 commit。定义 B ≼ A 表示从 A 沿父边可以到达 B,也就是 B 是 A 的 ancestor。这个关系是偏序:有些 A、B 互不为祖先,它们发生了真正分叉。
因此“哪个 commit 时间更晚”不能替代“哪个包含哪个”。fast-forward 是否可能,只取决于祖先关系:如果当前 commit 是目标 commit 的 ancestor,就可以只移动 ref;否则需要 merge、rebase、cherry-pick 或其他显式集成。
最小知识闭包 2:ahead / behind 的数学含义
给定两个 tips A、B 和共同基线 M,粗略可以理解:
git merge-base A B
git rev-list --left-right --count A...B
git log --oneline --left-right A...B
所以“server 比 GitHub behind 631、ahead 1”和“frontend 比 GitHub behind 621、ahead 66”描述的是两组不同集合差,完全可以同时成立。
最小知识闭包 3:fetch、pull、switch、merge 分别改变什么
| 操作 | 主要改变 refs | index / working tree | 历史图 | 关键风险 |
|---|---|---|---|---|
git fetch | 更新 remote-tracking refs,如 origin/main | 通常不改当前工作区 | 下载对象但不重写本地分支 | 低;但 fetch 后仍要理解分叉 |
git switch branch | 改变 HEAD 指向 | 检出目标分支快照 | 不创建新历史 | 未提交变化可能阻止或被带过去 |
git merge B | 成功后移动当前分支 | 更新到合并结果 | 可能新建 merge commit | 冲突需要逐项解决 |
git rebase B | 当前分支最终指向新复制 commits | 随过程更新 | 重写 commit IDs | 共享历史需要谨慎 |
git reset --hard X | 移动当前分支/HEAD | 强制改 index 与 working tree | 旧 commit 可能变为仅 reflog 可达 | 高:未保护数据可丢 |
git revert X | 新增 commit 后移动当前分支 | 更新为反向变更结果 | 不删除原历史 | 语义冲突但共享历史更安全 |
最小知识闭包 4:为什么 reflog 不是备份策略
reflog 记录本地 ref/HEAD 曾经指向哪里,很多误操作后确实能用于恢复。但 reflog 有保留期限、是本地性的,也可能在垃圾回收后失去对象。成熟流程应该在危险操作前主动建立明确 branch/tag 或远端备份 ref,而不是事后祈祷 reflog 还在。
git branch backup/server-20260831 c5e35ae
git branch backup/frontend-20260831 a0580dec
git show --stat backup/server-20260831
git log --oneline origin/main..backup/server-20260831
最小知识闭包 5:冲突数量不是“谁对谁错”
48 个冲突文件只说明 Git 无法自动合并那些路径/片段。它既不证明前端分支错误,也不证明 main 应该被覆盖。冲突解决是一个语义判断:在最新 main 的架构约束上,哪些旧改动仍代表你真正想保留的行为?
最小知识闭包 6:Git 收敛和生产收敛是两件事
设最终权威 commit 为 M。Git 收敛可以要求 local main = origin/main = GitHub main = M;但生产收敛还要增加 runtime_source = M 或至少可验证的 artifact digest → M 映射。否则你会得到“仓库看起来全对,线上仍旧”的假阳性。
最小知识闭包 7:三方合并为什么需要 merge base
Git 合并不是简单比较 A 和 B 两份最终文件,然后决定谁覆盖谁。典型三方合并会同时看共同祖先 M、当前分支 A、待合并分支 B:分别计算 M→A 与 M→B 的变化,再尝试组合。只有两边对同一语义区域做了无法自动协调的修改时,才产生冲突。
这解释了一个重要现象:两个分支最终文件看起来差异巨大,也不一定产生很多冲突;反过来,即使只改了少量行,只要双方碰到同一上下文,也可能冲突。冲突数量因此不能直接当“分支质量分数”。
最小知识闭包 8:commit identity 和 patch identity 不是一回事
rebase、cherry-pick 会创建新的 commit,即使代码变化“看起来一样”,commit ID 也通常不同,因为父提交、元数据等发生变化。因此判断“这个修复是否已经以等价补丁进入 main”有时不能只用 SHA 相等,还需要看 patch、文件 diff、测试和功能证据。
这也是为什么大规模历史整理后,旧分支可能显示很多“独有 commits”,但其中部分语义已经以新 commit 形式进入主线。此时不能机械地把所有旧 commit 再 cherry-pick 一遍,否则可能重复应用同一修改。
最小知识闭包 9:删除 branch 为什么不等于删除历史
branch 只是 ref。删除 branch 主要删除一个名字;只要其 commit 仍被其他 refs、reflog 或对象可达关系引用,历史对象仍可能存在。但如果某一串 commit 只有这个 ref 能稳定到达,删除后就降低了可发现性和可恢复性,未来垃圾回收也可能清理不可达对象。
所以“这个分支看起来没用了”不是删除依据。先用 git branch --merged、git log main..branch、比较 patch/功能证据确认没有独有价值,再做清理。
④ 把真实案例重新还原
案例数据
共同基线:36b470e7
GitHub main:84382576
└─ 从共同基线独有 +621 commits
本地 frontend:a0580dec
└─ 从共同基线独有 +66 commits
└─ 与最新 main 试合并:48 个冲突文件
106 server HEAD:c5e35ae
└─ 相对某比较基线有独有 +1 commit
106 origin/main:84382576
└─ 已 fetch 到 GitHub main
另有临时 worktree:0fb8cad7
└─ 相对 main 落后若干 commit
第一问:服务器到底“同步了没有”?
答案必须拆成三句话:服务器的 origin/main 已同步到远端观察结果;服务器的当前 HEAD 没有同步到 main;实际运行服务版本未知,需要继续查构建/容器/进程。只说“同步了”或“没同步”都过于粗糙。
第二问:为什么不能先在 106 上 reset --hard?
因为服务器有一个独有 commit c5e35ae。在确认这个 commit 的内容、价值和备份引用之前,hard reset 会把当前分支直接移到另一个点并覆盖工作区/index。即使对象暂时可通过 reflog 找回,也是在主动降低可恢复性。
第三问:前端 66 个 commit 应该怎么进 main?
不是把“66 个 commit 全保留”作为目标,而是把“这些历史中仍然需要的产品/工程行为”作为目标。先从最新 main 创建 integration 分支,在非生产环境做合并或重放,处理冲突后跑测试。若历史非常嘈杂,也可以通过分段 cherry-pick、重新整理 commit 或将功能重新实现到新架构,但必须保留审计线索。
第四问:临时 worktree 为什么也要清点?
因为每个 worktree 有自己的 HEAD/index/working tree。主目录 clean 不代表临时 worktree 没有独有未提交改动。收敛前应执行:
git worktree list --porcelain
git status --short
git stash list
git branch --all --verbose --no-abbrev
再分别进入需要检查的 worktree 查看状态。
第五问:怎么把事实记录成机器可验证证据?
# refs
git rev-parse HEAD
git rev-parse refs/remotes/origin/main
# 图关系
git merge-base HEAD origin/main
git rev-list --left-right --count HEAD...origin/main
# 独有历史
git log --oneline origin/main..HEAD
git log --oneline HEAD..origin/main
# 未提交状态
git status --porcelain=v1
git diff
git diff --cached
这些命令分别回答“指针在哪里”“图关系是什么”“谁有独有 commits”“工作区/index 是否有独有未提交状态”,比一句“看起来差不多”更适合 Agent/CI 自动验收。
⑤ 概念如何映射到现实
| 你听到的口语 | 严格翻译 | 应该用什么证据 |
|---|---|---|
| “服务器已经拉到最新了” | 是 origin/main 更新了,还是 HEAD/main 更新了,还是运行进程更新了? | rev-parse + runtime version |
| “工作树是干净的” | HEAD↔index、index↔working tree 没有未提交差异 | git status --porcelain |
| “这个分支更新” | 它和比较对象的 ancestor/reachability 关系是什么? | merge-base、rev-list ... |
| “本地领先 66 个” | 相对指定 ref 有 66 个仅本地可达 commits | rev-list --left-right --count |
| “冲突 48 个文件” | 自动三方合并有 48 个路径需要人工语义决策 | dry-run/integration merge + conflict list |
| “把服务器同步到 main” | 保护独有状态后,使部署来源收敛到已验收的 main commit | backup refs + fast-forward/deploy + smoke |
| “Git 已经一样了” | refs 相等不代表运行产物一致 | Git SHA + image digest/build metadata + health |
危险命令按“是否会销毁当前可见状态”分类
| 类别 | 典型命令 | 默认策略 |
|---|---|---|
| 只读观察 | status / log / diff / rev-parse / merge-base | 优先使用,先建立事实 |
| 增加可恢复性 | branch backup/... / tag / push backup ref | 在重写或丢弃前执行 |
| 可控集成 | merge / cherry-pick / rebase on integration branch | 在非生产工作区进行并测试 |
| 破坏性收敛 | reset --hard / clean / branch -D | 只有独有信息已证明被保护且目标明确时才用 |
给 AI Coding Agent 的验收合同
1. 不允许根据“branch 名相同”宣称版本一致。
2. 必须报告 HEAD / local main / origin/main 的完整 SHA。
3. 必须报告 ahead/behind 的比较对。
4. 发现独有 commit、stash、dirty worktree 时禁止先 hard reset。
5. 集成在非生产分支完成并通过测试后,才允许更新 canonical main。
6. 部署完成必须读回 runtime version / image digest 和 health。
⑥ 现在把它当成一道题
git fetch 后显示 origin/main 已是最新 SHA,但 git rev-parse HEAD 仍是旧 SHA。请精确描述 fetch 改变了什么、没有改变什么。git status 显示 clean 不能推出“服务器已经是 GitHub 最新代码”?请用 HEAD / index / working tree / origin/main 四个对象回答。HEAD = origin/main = GitHub main,但用户仍看到旧版本,Git 层之外至少还需要检查哪三个对象?⑦ 分级提示,最后就是实际答案
Hint 1 · 方向
先禁止自己想 pull/reset。把状态拆成:对象图、refs、index/working tree、worktree/stash、runtime artifact,然后分别问“哪里有独有信息”。
Hint 2 · 结构
用 ancestor/reachability 解释 ahead/behind;用 backup refs 提高可恢复性;复杂集成放到最新 main 派生的非生产分支;部署最后做。
Hint 3 · 接近答案
最终证明不是“SHA 看起来一样”,而是 GitHub main = origin/main = local main = M,并且运行镜像/二进制能够追溯到 M,health/smoke 通过。
答案 · 实际答案
参考答案的主线:把“同步 Git”从一个命令问题改写成一个状态收敛问题。
阶段 0:定义权威与禁止动作
先定义 GitHub main 是最终 canonical 线,但在完成独有历史清点之前,禁止任何会降低可恢复性的操作:reset --hard、clean -fd、删除分支/worktree、强推覆盖远端。canonical 并不意味着其他状态可以不经检查直接丢弃。
阶段 1:只读 inventory
git status --porcelain=v1
git rev-parse HEAD
git rev-parse main
git rev-parse origin/main
git worktree list --porcelain
git stash list
git branch --all --verbose --no-abbrev
git merge-base HEAD origin/main
git rev-list --left-right --count HEAD...origin/main
git log --oneline origin/main..HEAD
输出要结构化记录:每个 ref 的 SHA、比较对象、ahead/behind、独有 commit、dirty files、stash、worktree。此阶段目标不是修,而是把“未知”变成事实。
阶段 2:保护所有独有信息
git branch backup/server-c5e35ae c5e35ae
git branch backup/frontend-a0580dec a0580dec
# 如果需要跨机器恢复,应把明确 backup ref 推到受控远端
# git push origin backup/server-c5e35ae
# git push origin backup/frontend-a0580dec
若 worktree 有未提交内容,先明确 commit 到备份分支或用可审计方式保存;stash 只能当临时手段,不应让关键状态长期只存在 stash 中。
阶段 3:以最新 main 创建非生产集成线
git fetch origin
git switch -c integrate/frontend-20260831 origin/main
# 服务器独有热修仍有价值时,先显式重放
git cherry-pick c5e35ae
# 再集成前端历史(示意)
git merge backup/frontend-a0580dec
如果出现 48 个冲突,逐功能处理,并结合最新 main 的架构判断保留语义。不能把“全部 ours”或“全部 theirs”当默认策略。冲突解决后跑单元测试、构建、关键浏览器/CLI smoke 和必要回归。
阶段 4:只有验收通过才更新 main
假设验收后的 commit 为 M。此时才把 canonical main fast-forward/合并到 M 并推送。CI 与评审关注的是 M 的完整证据,而不是“我的集成分支本地能跑”。
阶段 5:服务器部署收敛
git fetch origin
git switch main
git merge --ff-only origin/main
git rev-parse HEAD
git rev-parse origin/main
git status --porcelain=v1
--ff-only 的价值是:如果服务器 main 有未解释分叉,它会直接拒绝偷偷制造新的 merge commit。若失败,重新回到 inventory,而不是立刻 hard reset。
阶段 6:证明真正运行的是 M
这是很多 Git 教程缺的一步。若系统通过 Docker、Go 二进制、Node build 或其他 artifact 发布,还要核对:
# 例:应用自己暴露版本
curl -sS https://service.example.com/version
# 例:Docker
docker inspect --format '{{.Image}}' app
docker image inspect IMAGE --format '{{json .RepoDigests}}'
# 再做业务 smoke / health
curl -fsS https://service.example.com/health
最佳实践是在 build 时把 commit SHA 写入版本端点、二进制 metadata 或 OCI image labels。这样能验证 runtime → artifact → source commit M 的可追溯关系。
八条安全不变量
- 任何破坏性命令之前,所有独有 commit 和未提交状态都必须有明确可恢复引用或归档。
- 所有 ahead/behind 结论必须携带“比较的是哪两个 refs”。
origin/main只代表 fetch 后的远端跟踪状态,不冒充 HEAD。- clean 只证明未提交状态,不冒充 up-to-date。
- 生产服务器不作为复杂冲突的首选集成场所。
- 公共共享历史不在没有协调的情况下被 rebase/force-push 改写。
- canonical main 更新前必须有针对集成结果的测试证据。
- Git SHA 收敛后必须继续验证真正运行的 artifact 与同一 SHA 对应。
迁移能力
这套模型不只用于你这次的 106 服务器。以后遇到 Agent 并行分支、多个 worktree、CI checkout、release branch、hotfix、回滚,只需要持续问四个问题:对象图里发生了什么?哪些 refs 指向哪里?index/working tree 有没有独有状态?真正运行的 artifact 来自哪个 commit? 只要这四层分开,Git 就不再是一组玄学命令。
进一步阅读:gitrevisions · git merge-base · git reset · git rebase。