我所理解的 Git 最佳实践(10):用 Node.js 检查 Git 路径,提前发现 Windows 检出问题
- 从工作区到一条可审阅的提交
- 什么时候用 rebase,什么时候用 merge
- 利用 amend 和 rebase 保持提交简洁
- 在 GitHub 和 GitLab 配置 PR 的合入方式
- 共享测试分支与按需求部署
- 弄清工作树、嵌套仓库和远端的归属
- 没有远端时怎样用 Git 维护项目
- 为什么切到 orphan 分支后源码不见了
- 先量清仓库体积,再决定怎么迁移
- 用 Node.js 检查 Git 路径,提前发现 Windows 检出问题(当前)
我在临时仓库里提交了一个 notes?.md。用 Windows 上的 Git 克隆时,对象已经下载,Git 却在创建工作区文件时停住了。给克隆命令加上 --no-checkout,同一个提交就能完整下载。
error: invalid path 'notes?.md'
fatal: unable to checkout working tree
warning: Clone succeeded, but checkout failed.
另一个临时仓库里,Readme.md 和 readme.md 各有一份内容。在同一台 Windows 机器上克隆,Git 报了路径碰撞警告,却返回退出码 0。用 Git 可以列出两个路径,打开工作区只看到一个文件。前一个例子是单个名称无法写入,后一个是两个名称落到了同一个位置。这两种问题都可以在提交前从 Git 索引里找出来。
下载对象和写入工作区是两步
Git 的提交引用一棵树,树里记录每项的名字和指向的对象。git fetch 把对象和引用取到本地,git checkout 才会按树里的路径创建工作区文件。普通 git clone 连着做这两步。git pull 先获取远端对象,再把选中的分支更新整合到当前分支,更新工作区时也会遇到同样的路径限制。Git clone 文档 · Git pull 文档
我用 Git for Windows 2.45.1 做了以下对照。大小写一行的结果来自默认不区分大小写的工作目录。
| 提交中的路径 | 获取对象 | Windows 工作区 |
|---|---|---|
notes?.md |
fetch 和 clone --no-checkout 成功 |
普通 clone 和已有仓库中的快进 pull 都报 invalid path |
Readme.md 和 readme.md |
两个路径都在 Git 树里 | clone 返回 0 并报告冲突,工作区只留下一个文件 |
下一条提交将 notes?.md 改名为 notes.md |
两个提交仍可读取 | 克隆修好的分支成功,切回旧提交仍报 invalid path |
如果手上已有一次检出失败的克隆,可以在里面运行 git ls-tree -r --name-only HEAD,直接看目标提交的路径。要恢复当前分支,在能检出该路径的 macOS 或 Linux 工作区改名,例如 git mv -- 'notes?.md' notes.md,同步引用后正常提交。新提交的树可检出,旧提交依然可追溯。Geist 字体仓库里曾有一个名为 sources/|| 的文件,后来也是通过一条普通提交删除了这个路径。Git ls-tree 文档 · Geist 问题记录 · 修复提交
每一级目录名都要满足命名规则
Windows 的常规文件名与目录名都要避开这 9 个半角字符、U+0000 到 U+001F 的控制字符,以及结尾的空格和点。CON、PRN、AUX、NUL、COM1 到 COM9、LPT1 到 LPT9 等是保留设备名。加上扩展名仍受限,例如 NUL.md。COM 和 LPT 后面的上标数字 ¹、²、³ 也在微软列出的范围里。Windows 文件命名规则
< > : " / \ | ? *
在 Git 路径里,/ 已经用来分隔目录和文件。检查 draft./notes.md 时要拆出 draft. 和 notes.md。第一级目录名末尾的点就是问题所在。类似地,Docs/intro.md 和 docs/readme.md 的完整路径转成小写后依然不同,逐级比较才能发现 Docs 和 docs 在不区分大小写的文件系统上会合成同一个目录。
Linux 的路径用 / 分隔目录,文件名中也不能有空字节。? 等符号仍可出现在 Linux 文件名里,所以它们能随提交进入仓库。Windows 上的中文全角标点可以保留,例如 说明(上):参数?.md。Linux 路径规则
脚本也把 Git for Windows 拒绝的 CONIN$、CONOUT$ 和 LPT0 列为保留名。路径过长则是另一类检出问题,core.longpaths 处理的是长度。Git for Windows 的路径判断 · Git for Windows 长路径说明
Node.js 读取整个 Git 索引,Husky 负责触发
Git 索引里有已经跟踪的旧路径,也有这次暂存的新路径。假设 Docs/intro.md 上周就进了仓库,这次只加 docs/readme.md。如果脚本只看本次改动,就少了用于比对的旧路径。因此脚本用 git ls-files 读取完整索引。
git ls-files --cached --deduplicate --full-name -z -- ':/'
-z 让 Git 用 NUL 分隔路径,文件名里的空格和换行会留在同一个条目里。:/ 从仓库根匹配,--full-name 输出以仓库根为基准的路径,所以从子目录执行也会检查其他目录。脚本按 / 拆段,一边查 Windows 保留名称,一边用小写的完整前缀保存第一次出现的拼写。再次遇到同一个小写键而原拼写不同,就报告碰撞。Git ls-files 文档 · Git 路径匹配语法
把完整脚本保存到项目的 scripts/check-git-paths.mjs。用 Node.js 22 或更高版本、Git 2.45 或更高版本即可运行。脚本用 Node.js 内置模块调用 Git,不需要 npm 库。发现问题会打印具体路径并返回退出码 1。
node scripts/check-git-paths.mjs
已经用 Husky 的项目,可以把这条命令放进 .husky/pre-commit,和现有检查一起执行。Git 在提交前调用 hook,Husky 运行其中的命令,Node.js 用退出码把检查结果交回给 Git。手动运行和提交时运行的是同一个脚本。Git pre-commit hook 文档 · Husky 调用 Node.js 脚本
# .husky/pre-commit
node scripts/check-git-paths.mjs
可以用下面几组路径验证。需要创建坏路径时,在大小写敏感的 macOS 或 Linux 临时仓库里试。先把 Docs/intro.md 单独提交,再暂存 docs/readme.md,就能验证脚本会拿新路径和已有路径比较。
| 索引中的路径 | 脚本结果 |
|---|---|
说明(上):参数?.md |
通过 |
notes?.md、NUL.md、draft./notes.md |
分别报告禁用字符、保留设备名和目录名末尾的点 |
Docs/intro.md 和 docs/readme.md |
报告 Docs 与 docs 的大小写冲突 |
脚本按 UTF-8 解码索引路径,大小写比较用 JavaScript 的小写转换。它覆盖文中的常见冲突,实际文件系统的 Unicode 等价规则、路径长度和挂载配置仍需要在目标环境验证。这个脚本读取的是索引。排查已经存在的远端提交时,git ls-tree -rz --name-only --full-tree <提交> 可以直接枚举那棵树,包括尚未写到磁盘上的名字。Git ls-tree 文档
Python 的 pre-commit 提供现成规则
Git 自带 pre-commit 这个事件。Python 分发的同名 pre-commit 是规则运行器,check-illegal-windows-names 则定义在 pre-commit/pre-commit-hooks 规则仓库里。它用 files 正则筛选路径,遇到匹配项就报告失败。大小写冲突由另一条规则 check-case-conflict 检查。pre-commit 官方文档 · v6.0.0 的规则定义
用 python -m pip install pre-commit 安装 CLI 后,把下面的配置放到 .pre-commit-config.yaml,再执行 pre-commit run --all-files 手动检查。CLI 根据 repo 和 rev 获取规则,按 id 选择要运行的检查。
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-illegal-windows-names
- id: check-case-conflict
如果 Husky 已经管理 hook,可以在 .husky/pre-commit 里执行 pre-commit run。我用 Husky 9.1.7 和 pre-commit 4.7.0 验证过这种接法。此时 pre-commit CLI 要在 hook 的 PATH 里。直接执行 pre-commit install 会因为 Husky 已设置 core.hooksPath 而拒绝安装。pre-commit 运行说明 · 4.7.0 的安装逻辑
这个例子的版本有一个可复现的边界。v6.0.0 默认的 files 表达式能匹配 docs/draft./notes.md,却跳过 draft./notes.md。它对中间目录结尾的判断要求目录前面已有一个 /,首层目录没有这个斜杠。上面的 Node.js 脚本每段单独检查,所以两者都能命中。
pre-commit run --all-files 从 Git 跟踪的文件开始选,还会过滤当前工作区里不存在的路径。检查一个还没检出的提交时,直接枚举 Git 树更合适。pre-commit 的工作区筛选逻辑
这个规则集合还提供 check-merge-conflict、check-added-large-files、check-json、check-yaml 和 detect-private-key 等。团队如果还要检查配置语法、冲突标记或大文件,可以从同一配置按需选择规则。v6.0.0 规则用法
下载量和 Stars 衡量的对象不同
Node.js 也有文件名校验库。filename-reserved-regex 提供两条匹配式,分别查保留字符和设备名。valid-filename 封装成布尔判断,但我实测 5.0.0 会接受 draft. 和结尾带空格的名称,因此要先核对它的语义再拿来做 Windows 路径检查。这里的脚本只有两类判断,文件名规则可以直接写出来,也能用相同的路径样本验证。filename-reserved-regex 4.1.0 · valid-filename 5.0.0
下表的下载窗口是 2026 年 11 月 2 日至 8 日。npm 使用官方下载 API,PyPI 使用 PyPI Stats 汇总的公开数据,排除已知镜像。Stars 是 11 月 10 日北京时间的 GitHub 快照。npm 下载 API · PyPI 规则库下载 · PyPI 运行器下载 · PyPI Stats 统计口径
| 包 | 作用 | 当周下载量 | GitHub Stars |
|---|---|---|---|
filename-reserved-regex |
文件名匹配库 | 16,040,663 | 53 |
valid-filename |
文件名判断库 | 182,461 | 61 |
pre-commit-hooks |
规则集合 | 514,498 | 6,746 |
husky |
Git hook 管理工具 | 49,503,221 | 35,385 |
pre-commit |
多语言 hook 运行器 | 36,462,905 | 15,689 |
filename-reserved-regex 会作为其他 npm 包的依赖被下载,例如 valid-filename 和 filenamify。Python 那边,使用 repo 引用 GitHub 规则仓库时,pre-commit 会从 Git 获取源码,那次获取不会计入 PyPI 包下载量。Stars 又对应整个仓库。所以上面能比较包的分发量和仓库关注度,无法从中分离出实际启用 Windows 文件名检查的项目数量。filenamify 依赖 · pre-commit 的 Git 获取实现
总结
排查 Windows 检出时,先看目标提交树,再对照工作区。invalid path 指向一个无法写入的名字,paths have collided 则提示多个路径争用了同一位置。提交前把完整索引交给路径检查脚本,就能在本机看到具体的目录或文件名。手动运行和 Husky 共用这条命令。