1. 现象拆解:Windows 上改了大小写,Push 后却原封不动
上周三上午,我刚打开电脑就被产品同事在群里 @ 了个体无完肤:"我们仓库的 readme 怎么又变回大写了?我昨天明明在文件夹里把它改成了小写。"我还没来得及回复,后端同事紧跟着补了一句:"我这边 git status 是干净的,什么都没提交。"那一瞬间我就明白了,这不是产品操作失误,也不是 Git 客户端抽风,而是 Git 世界里最经典的"大小写陷阱"又登场了。
这个坑几乎每个混用 Windows、macOS、Linux 的团队都会踩到,最常见的主角就是 README.md 和 readme.md。它的诡异之处在于:你在资源管理器或者 Finder 里辛辛苦苦把文件名的大小写改了,git status 却一片岁月静好;等你 push 完,同事在 Linux 上一拉,看到的居然还是旧名字。更极端的情况是,仓库里同时出现了 README.md 和 readme.md 两份文件,Windows 上 clone 下来却只看到一份,另一份像幽灵一样"消失"了。今天这篇文章,我把这个问题的来龙去脉、排查思路和修复方案完整梳理一遍,Windows 办公、Linux 部署、macOS 开发的读者都能直接用上。
1.1 一次典型事故的完整时间线
我把那次事故的时间线还原一下,大家对照着看自己是不是也经历过:
- 产品同事在 Windows 上打开资源管理器,把文件
README.md重命名为readme.md。Windows 的文件系统是大小写不敏感的,但会保留你输入的大小写,所以重命名立刻生效,文件夹里显示的确实是readme.md。 - 她打开 Git Bash 跑
git status,惊喜地发现一切正常——注意,是"一切正常"这四个字最要命。 - 她顺手
git add -A && git commit -m "rename readme"再 push,全流程没有报一个错。 - 第二天后端同事在 Linux 服务器上
git pull,发现文件还是README.md,产品同事当场崩溃。
反过来,如果是在 Linux 上把 README.md 改成 readme.md,提交后 Windows 用户去 pull,文件名倒是会跟着变成小写。也就是说,这个坑在 Linux → Windows 的方向上是通的,但在 Windows → 远端的方向上静默失效。这种不对称性就是团队里经常互相甩锅的根源:Linux 同事说"我这边好好的",Windows 同事说"我这边也好好的",两边都没说谎。
1.2 "静默失败"的元凶:core.ignorecase 的善意
问题出在 Git 的 core.ignorecase 配置项上。这个配置在 Git 初始化仓库或者 clone 的时候会自动检测:如果当前文件系统是大小写不敏感的(Windows 的 NTFS、macOS 默认的 APFS),Git 就把 core.ignorecase 自动设为 true;如果是大小写敏感的文件系统(Linux 的 ext4、xfs),就自动设为 false。
它的本意是好的:让 Git 在比较索引和工作区文件时,不看文件名的"颜值大小写",只看文件内容有没有变。这样 Windows 用户在 IDE 里写代码时,IDE 偶尔把某个文件名的大小写改了一下,Git 不会立刻把它当成"删了一个文件、又新增了一个文件"来大肆渲染。
但副作用就是:当你在 Windows 上故意把 README.md 改成 readme.md 时,Git 认为"这俩不就是同一个文件吗",于是索引里仍然记着 README.md,工作区里却成了 readme.md。两边路径在大小写不敏感的比较下"相等",内容又没变,自然干干净净一片祥和。你以为提交了重命名,实际上远端仓库的树里依然躺着 README.md。
更坑的是,就算你在这种状态下执行 git add -A,在很多 Git 版本里,add 也会被 ignorecase=true 遮住,照样不会把新路径写进索引。这也是为什么我后来给团队定了一条铁律:改文件名的大小写,永远不要用操作系统自带的重命名,必须用 git mv(具体方案在第四部分)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 机制拆解:Git 存文件名的方式与文件系统的"性格"
要彻底搞懂这个陷阱,得先跳出"Git 是一个软件"的想法,把它拆成三层:对象库、索引、工作区。文件名在这三层里的待遇完全不一样。
2.1 三层结构里,文件名分别是怎么存的
- 对象库(objects):所有提交的树对象(tree)里,文件名是以原始字节的形式存储的。Git 不区分、也不转换大小写,
README.md和readme.md在 Git 看来就是两个完全不同的路径,可以同时存在于同一个树里。 - 索引(index):也就是暂存区,同样以原始字节记录路径。你
git add进去的是什么路径,索引里就是什么路径。 - 工作区(working tree):这是唯一和操作系统文件系统打交道的一层。文件名最终落在哪个盘、显示成什么大小写,完全由文件系统说了算。
所以"Git 区分大小写吗"这个问题,准确答案是:Git 的对象库和索引用的是"最严格"的字节比较,天然区分大小写;但 Git 在工作区做状态比较时,会被 core.ignorecase 这个配置"降级"成大小写不敏感。真正让整个体系变得混乱的,是不同文件系统对大小写的不同态度。
2.2 三种文件系统的"性格"对照
我做了个表格,直接把常见文件系统对大小写的态度列清楚:
| 文件系统 | 典型平台 | 是否区分大小写 | 是否保留大小写 | 对 Git 的实际影响 |
|---|---|---|---|---|
| NTFS | Windows | 不区分 | 保留 | clone 后文件显示为仓库里的首写大小写;用资源管理器改大小写,Git 看不见 |
| APFS / HFS+(默认) | macOS | 不区分 | 保留 | 行为同 Windows,Finder 里改大小写同样静默失效 |
| ext4 / xfs / APFS(区分大小写) | Linux / 部分 Mac 用户 | 区分 | 按提交原样显示 | 能同时存 README.md 和 readme.md;是暴露问题的"照妖镜" |
注意 macOS 那一行:很多人以为只有 Windows 才有这个坑,其实苹果默认的 APFS 也是大小写不敏感的,只是"保留大小写"这一点让日常使用感知不强。如果你团队里有 Mac 开发同学,请把本文的结论同样套用在他身上。
2.3 同一个提交,在不同系统上长着两张脸
因为对象库和索引都区分大小写,而工作区不区分,同一个提交在不同系统上 checkout 出来的"长相"可能完全不一样。
最典型的案例是:某位 Linux 开发者在已有 README.md 的仓库里,又新建了一个内容完全不同的 readme.md 并提交。远端仓库此时合法地同时存在两个文件(Git 本身允许,不报错)。Windows 或 macOS 同事一 clone,事情就变得疯狂了:因为磁盘上只能存在一个同名文件,checkout 时先写 README.md,再写 readme.md,后者会覆盖前者对应的那个目录项。最终磁盘上只剩一个文件,内容可能是两个 blob 里后写入的那份。
此时如果两个文件内容不一致,git status 会报出一堆莫名其妙的状态,比如某个路径显示"已删除"或"被修改";如果内容恰好一致,status 可能又是干净的。无论哪种情况,索引里那个被"覆盖"的路径都处于半失真状态,后续任何 commit 都可能把远端仓库里本来合法的一个文件误删掉。这种"一手造成、二手接盘"的数据混乱,比单纯的改名失败难处理得多。
3. 排查手段:把"看不见的差异"逼到台面上
遇到大小写问题,第一反应不要是去改代码,先去把现状摸清楚。下面这套排查命令,我在任何一台疑似中招的机器上都会完整跑一遍。
3.1 第一步:确认 core.ignorecase 当前到底什么值
bash复制git config --get core.ignorecase
在默认的 Windows 或 macOS 仓库里,你会看到 true;在 Linux 仓库里是 false。如果某台机器上这个值是被人为改过的,那问题面会更大,先把值确认了再往下走。
提示:
core.ignorecase是 Git 自动检测的,正常情况下交给 Git 就好。在大小写不敏感的文件系统上强行设成false,会引发更诡异的误报(文件明明没改,status 却天天显示 modified),所以不要试图用改配置来"压住"这个问题。
3.2 第二步:找出仓库里所有大小写重复的文件
这是最核心的一招,用于发现那些"同时存在 README.md 和 readme.md"的脏数据:
bash复制git ls-files | sort -f | uniq -Di
解释一下这条管道:git ls-files 列出索引里所有路径;sort -f 按大小写不敏感的方式排序,让 README.md 和 readme.md 变成相邻行;uniq -Di 中的 -D 把所有重复组的每一行都打印出来,-i 比较时忽略大小写。运行后只要输出里出现成对的文件名,就说明索引里确实同时存在大小写仅不同的路径——这就是最危险的情况。
如果只想看 readme 相关文件,可以配合 grep:
bash复制git ls-files | grep -i readme
git ls-tree -r --name-only HEAD | grep -i readme
后一条命令看的是 HEAD 提交的树,而非索引,能区分"问题在索引里"还是"问题已经进了提交历史"。
3.3 第三步:对比 blob 哈希,确认工作区与索引的错位
当 git status 显示干净,但你又怀疑文件名大小写有问题时,用哈希对比最直观:
bash复制git rev-parse HEAD:README.md # 仓库 HEAD 里 README.md 对应 blob
git rev-parse :README.md # 索引里 README.md 对应 blob
git hash-object readme.md # 磁盘上 readme.md 内容对应 blob
正常情况下,这三个哈希应该一致。如果前两个不同,说明索引和 HEAD 已经分叉;如果后两个不同,说明磁盘内容和索引对不上,但 Git 因为 ignorecase=true 没报警。这个"哈希对不上但 status 干净"的状态,就是大小写陷阱最典型的现场证据。
我习惯把这三条命令封装成一个 alias,遇到可疑情况直接一条龙诊断:
bash复制git config --global alias.casecheck '!f() { echo "ignorecase=$(git config --get core.ignorecase)"; echo "--- duplicates ---"; git ls-files | sort -f | uniq -Di; echo "--- readme related ---"; git ls-files | grep -i readme; }; f'
运行 git casecheck,几秒钟就能把仓库的大小写健康状况看个大概。
4. 修复方案:三种典型场景三条路线
搞清楚症状之后,按照实际场景选择对应的修法。这里我把最常见的三种情况分开讲,每一步都可以直接照抄。
4.1 场景 A:只想把 README.md 改成 readme.md(或反向)
这是最普通的诉求。先说结论:不要用系统自带重命名,用 git mv,而且一定要走"中间临时名"两步法:
bash复制git mv README.md README.tmp.md
git mv README.tmp.md readme.md
git commit -m "chore: 统一 readme 文件名为小写"
git push
为什么不能直接 git mv README.md readme.md?因为在一台 core.ignorecase=true 的机器上,Git 判断目标路径 readme.md 和源路径 README.md 是"同一个文件",于是要么报 destination exists,要么干脆把这个移动当 no-op 忽略掉。改成临时名后,README.tmp.md 和 README.md 无论如何都不冲突,两步移动就能把索引里的路径可靠地拧到目标大小写。
如果 git mv 因为某些原因不顺手(比如工作区已经被改乱),还有一组兜底的"索引手术":
bash复制git rm --cached README.md
git add readme.md
git commit -m "chore: 统一 readme 文件名为小写"
git push
git rm --cached 强制让索引忘掉 README.md 这个路径,git add readme.md 再把磁盘当前文件登记为小写路径,绕开了 ignorecase 的遮蔽。
4.2 场景 B:仓库里同时存在 README.md 和 readme.md
这种情况必须立刻处理,因为它会在每个 Windows/macOS 同事的 clone 里制造数据混乱。处理原则只有一个:明确保留谁,删掉谁,并在 commit message 里写清楚这是路径规范化的提交。
假设保留 README.md、删除 readme.md:
bash复制# 如果磁盘上的文件被覆盖了,先从索引恢复保留的那一份
git checkout HEAD -- README.md
# 把冗余的小写路径从索引里移出
git rm --cached readme.md
# 确认索引里只剩一个 readme 相关路径
git ls-files | grep -i readme
git commit -m "fix: 移除 readme.md 重复路径,统一使用 README.md"
git push
要注意的是,如果两个文件内容本来就不同,那你其实是在做一个更有分量的决定:把历史上某个人提交的 readme.md 内容彻底从默认分支移除。建议先把两者的内容对比一眼,确认自己删的不是别人想留的东西。
我遇到过另一个问题:有人问"能不能把两个都保留?我觉得现在的小写 readme 也很有用"。答案是不行。在远端仓库的字节层面可以保留,但只要你还有同事在使用大小写不敏感的文件系统,这两个文件对他就永远只有一个真实存在。留着双份相当于埋下一颗不定时炸弹,任何一次 checkout 都有可能让其中一份悄悄"消失"。
4.3 场景 C:历史提交里大小写混乱,需要彻底清洗
如果脏数据已经深入历史(比如过去几十个提交都混着大小写),只修 HEAD 是治标不治本。这时可以用 git filter-repo(比 filter-branch 快且安全)做历史重写:
bash复制# 先备份仓库
git clone --mirror origin repo-backup.git
# 把历史中所有的 readme.md 统一重命名为 README.md
git filter-repo --path-rename readme.md:README.md --force
# 把清洗后的历史强推(务必提前通知全团队)
git remote add origin <新的远端地址>
git push origin --force --all
注意:
--path-rename只处理纯大小写改名;如果你还想同时剔除某个路径,需要用--invert-paths的组合,建议先把git filter-repo的文档读一遍再操作。历史重写意味着所有已 clone 的同事都要重新拉取基线,这是在"历史干净"和"团队扰动"之间做权衡。绝大多数团队我会建议只清理默认分支的最近几个提交,把更老的历史当成既定事实接受,比强行重写要稳妥得多。
三种场景的对照总结如下:
| 场景 | 症状 | 根因 | 推荐修复 |
|---|---|---|---|
| A:单文件名大小写改不动 | Windows 改完 push,Linux 拉下来还是旧名 | ignorecase=true 遮蔽路径变化 |
两步 git mv 或 rm --cached + add |
| B:仓库同时存在两个大小写文件 | Windows clone 后只有一份,status 异常 | 文件系统不敏感与仓库字节敏感冲突 | 保留一个,rm --cached 另一个 |
| C:历史多处大小写混乱 | 切换分支反复出现文件丢失/报告 | 历史树中路径大小写不统一 | filter-repo 重写历史并强推 |
5. 团队防坑:让大小写陷阱从源头消失
修一次容易,难的是让全队以后不再制造新的问题。我在团队里推了三个习惯,成本很低,效果却很扎实。
5.1 用 git mv 代替资源管理器和 Finder 改名
把这条写进团队约定:任何文件名大小写调整,一律走 git mv。哪怕是普通改名,也建议用 git mv,因为操作系统重命名不会同步更新索引里的路径记录,而 git mv 一步完成"磁盘移动 + 索引更新",不会给后续留下"文件变了但 Git 不知道"的空窗期。
操作习惯上,我先 git mv 旧名 新名,再 git status 确认变化是 renamed: 而不是 deleted: + untracked:,最后正常提交。只要看到的是 renamed,说明路径更新已经进了索引,push 之后远端必然是新的名字。
5.2 CI 里加一道"重复路径"检查
在人会失误的地方,让机器把关口。在 CI 的第一步加一个检查脚本,内容是第二节里那条黄金命令,一旦发现大小写重复路径就立即使流水线失败:
bash复制#!/usr/bin/env bash
set -euo pipefail
echo "检查仓库中是否存在大小写仅不同的重复路径..."
dup=$(git ls-files | sort -f | uniq -Di || true)
if [ -n "$dup" ]; then
echo "发现大小写重复路径,请立即处理:"
echo "$dup"
exit 1
fi
echo "检查通过"
在 GitHub Actions 里可以这样挂:
yaml复制- name: Case-sensitivity check
run: |
dup=$(git ls-files | sort -f | uniq -Di || true)
if [ -n "$dup" ]; then
echo "Found case-insensitive duplicates:"; echo "$dup"; exit 1;
fi
同样的逻辑也可以塞进 pre-commit 钩子,但注意 .git/hooks 下的钩子不会被 clone 共享,所以 CI 检查才是真正的门禁,本地钩子只是提前提醒。
5.3 命名规范、文档链接与 macOS 分区提醒
最后是一些软约束。第一,新文档统一使用全大写基础名:README.md、CHANGELOG.md、CONTRIBUTING.md,并且仓库内引用这些文件的相对链接,大小写必须和实际路径完全一致。GitHub 的网页端渲染 readme 时确实不挑大小写,但 Linux 服务器、Docker 镜像、自动化脚本里一个 ls README.md 就能原形毕露。
第二,提醒 Mac 开发同学注意自己磁盘的分区格式。默认 APFS 是大小写不敏感的,也有少数人为了"和 Linux 一致"把 Mac 分区挖成大小写敏感,结果反而在 clone 那些已经混入大小写脏数据的仓库时,看到和 Windows 同事完全相反的现象。两种 Mac 用户会在同一次协作里互相怀疑对方"看到的是不是同一个仓库"。
第三,代码评审时顺手看一眼变更列表里有没有纯改文件大小写的提交,有的话提醒提交者把历史里的重复路径一并清掉,别只修了表面。
我在实际团队里推完这三件事之后,大小写相关的"灵异事件"基本绝迹。最后分享一个个人习惯:每次 push 之前,我都会花三秒钟跑一下 git ls-files | sort -f | uniq -Di,确认输出为空再走提交流程。这一条命令的成本几乎为零,却能让 README.md 和 readme.md 之间的战争从此画上句号。
