跳到正文

我所理解的 Git 最佳实践(10):用 Node.js 检查 Git 路径,提前发现 Windows 检出问题

我所理解的 Git 最佳实践

  1. 从工作区到一条可审阅的提交
  2. 什么时候用 rebase,什么时候用 merge
  3. 利用 amend 和 rebase 保持提交简洁
  4. 在 GitHub 和 GitLab 配置 PR 的合入方式
  5. 共享测试分支与按需求部署
  6. 弄清工作树、嵌套仓库和远端的归属
  7. 没有远端时怎样用 Git 维护项目
  8. 为什么切到 orphan 分支后源码不见了
  9. 先量清仓库体积,再决定怎么迁移
  10. 用 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 对象下载完成后,Windows 检出可能因保留字符失败,或因大小写冲突只写出一个文件

我用 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
检查 Git 路径的 Node.js 脚本正在加载代码工作区...

已经用 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 共用这条命令。