Study Skills · Learn

Git 三个状态为什么会不一致?

不把 Git 当成“pull / reset 命令集合”:用对象图、引用、暂存区、工作区和部署产物五个层次,解释本地前端、GitHub main 与服务器 checkout 为什么可以同时正确却不相等。

Git / Version Control / DevOps/learn · system50–70 分钟中等 → 深入2026-08-31
学习进度0 / 5
本页目录
Primer

① 先把后文会反复出现的词认清楚

规则:先知道术语是什么,再用它们做综合判断。卡片收起时就能看到一句话直觉;需要严格定义、边界和案例作用时再展开。

先认词:这一课最容易卡住的不是命令,而是把对象身份、引用位置、工作目录状态和真实运行版本混成“代码版本”。

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 关联。
怎么观察
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/file
前两条可以计算/读取 blob OID,cat-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 自动组合时,需要人或规则判断最终语义。
当前案例
冲突数量说明自动合并受阻,不代表某一边天然“错”。
Orient

② 现在再看这段材料到底在说什么

一句话:Git 的“状态不一致”不是一个 bug,而是它允许多个引用、多个工作区和多个历史分支独立演化的直接结果。真正的问题是:你有没有把每个状态变量说清楚。

把 Git 仓库抽象成四元组: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 修改”要求它们在相关比较上相等:

TH=TI=TW

但这个等式里根本没有 origin/main。所以 clean 与 up-to-date 是两个正交命题:一个回答“有没有本地未提交变化”,另一个回答“当前 commit 与某个远端 ref 的图关系是什么”。

工程目标:不要追求“所有 SHA 越快一样越好”。收敛顺序应该是:先知道独有数据在哪里 → 建立可恢复引用 → 在非生产分支集成 → 验证 → 更新权威 main → 最后部署并证明运行版本。
Connect

③ 概念关系与最小知识闭包

blob / tree / commit→ Commit DAG→ refs / branch→ HEAD→ index→ working tree
remote GitHub main→ git fetch→ origin/main→ merge / rebase / ff→ local main / HEAD→ build / deploy→ runtime artifact
两个图同时存在:上面第一行是 Git 内部数据模型;第二行是协作/交付数据流。理解第一行能解释命令语义,理解第二行能避免把“fetch 了”误解成“服务器已经发布”。

最小知识闭包 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,粗略可以理解:

ahead(A,B)=|Reach(A)∖Reach(B)|

behind(A,B)=|Reach(B)∖Reach(A)|

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 分别改变什么

操作主要改变 refsindex / 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 的架构约束上,哪些旧改动仍代表你真正想保留的行为?

批量 ours/theirs 的危险:它把“解决冲突”退化成“选一边覆盖”,会丢失另一边非冲突但仍相关的语义。正确做法是先理解双方变化,再逐功能验证。

最小知识闭包 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/功能证据确认没有独有价值,再做清理。

Reconstruct

④ 把真实案例重新还原

案例数据

共同基线: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 自动验收。

案例的真正目标:把一个看似杂乱的 Git 状态问题,转化成对象图、refs、未提交状态、构建产物四张清单;然后只对有独有信息的地方做保护和集成。
Map

⑤ 概念如何映射到现实

你听到的口语严格翻译应该用什么证据
“服务器已经拉到最新了”是 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 个仅本地可达 commitsrev-list --left-right --count
“冲突 48 个文件”自动三方合并有 48 个路径需要人工语义决策dry-run/integration merge + conflict list
“把服务器同步到 main”保护独有状态后,使部署来源收敛到已验收的 main commitbackup 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。
Attempt

⑥ 现在把它当成一道题

1服务器执行 git fetch 后显示 origin/main 已是最新 SHA,但 git rev-parse HEAD 仍是旧 SHA。请精确描述 fetch 改变了什么、没有改变什么。
2为什么 git status 显示 clean 不能推出“服务器已经是 GitHub 最新代码”?请用 HEAD / index / working tree / origin/main 四个对象回答。
3A 分支相对 B 显示 ahead 66 / behind 621。请用可达集合解释这两个数字,不要用“新旧”这种模糊词。
4什么时候 fast-forward 是可能的?什么时候必须进行真正的历史集成?请用 ancestor 关系表达。
5服务器有一个未推送独有 commit。为什么先创建 backup branch 比“反正还有 reflog”更合理?
6merge、rebase、cherry-pick 都能把改动带到新基线。对于公开共享历史、大批前端历史、单个服务器热修,分别更可能怎么选?说明理由。
7如果最终 HEAD = origin/main = GitHub main,但用户仍看到旧版本,Git 层之外至少还需要检查哪三个对象?
8请为一个由 Coding Agent 自动执行的“服务器与 main 收敛”流程写 6 条不可违反的安全不变量。

Hint → Answer

⑦ 分级提示,最后就是实际答案

Hint 1 · 方向

先禁止自己想 pull/reset。把状态拆成:对象图、refs、index/working tree、worktree/stash、runtime artifact,然后分别问“哪里有独有信息”。