#!/usr/bin/env bash # Every tracked path must be one Windows can hold. 6 October 2026: a screenshot named after an address carried a colon # (docs/plans/site-ui-3-shots/after/address_igneumdev:qz9h....jpg), actions/checkout on windows-latest failed with # "invalid path" (git exit 128), and every Windows build of the tree died at the checkout for forty minutes. # # Rules (NTFS and the Win32 namespace): # no : * ? " < > | in a name, and no control character; # no name ending in a dot or a space; # no reserved device name as a name or as the stem of one (CON, PRN, AUX, NUL, COM1-9, LPT1-9, any case); # no path longer than 240 characters (MAX_PATH is 260 and a checkout prefix takes the rest). # # tools/ci/windows-paths-check.sh every tracked path (CI, the pre-push gate) # tools/ci/windows-paths-check.sh --staged the paths being committed (the pre-commit hook) # tools/ci/windows-paths-check.sh --self-test the rules fire on each bad shape and pass a good one # Exit 1 with the offending paths listed. set -euo pipefail check_paths() { # stdin: one path per line. Prints one line per offence. Returns 1 if any. One awk pass (a subprocess per path took # 23 s over 2,500 files on the Mac; this takes well under a second). awk ' function reserved(name, stem) { stem = name; sub(/\..*$/, "", stem); return toupper(stem) ~ /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/ } { p = $0; if (p == "") next if (p ~ /[:*?"<>|]/ || p ~ /[\001-\037\177]/) { print " " p " (a character Windows forbids: one of : * ? \" < > | or a control character)"; bad = 1; next } if (p ~ /[. ]$/ || p ~ /(^|\/)[^\/]*[. ]\//) { print " " p " (a name ending in a dot or a space)"; bad = 1; next } if (length(p) > 240) { print " " p " (" length(p) " characters; over 240)"; bad = 1; next } n = split(p, parts, "/") for (i = 1; i <= n; i++) if (reserved(parts[i])) { print " " p " (reserved device name " parts[i] ")"; bad = 1; break } } END { exit bad ? 1 : 0 }' } if [ "${1:-}" = "--self-test" ]; then fails=0 for bad in 'docs/shots/address_igneumdev:qz9h.jpg' 'a/b/what?.md' 'a/trailing./x' 'a/trailing ' 'docs/nul.txt' 'x/COM1' 'x/LpT3.log' "$(printf 'd/%0.s' $(seq 1 125))f.txt"; do if printf '%s\n' "$bad" | check_paths >/dev/null; then echo "self-test failed: accepted '$bad'"; fails=1; fi done for good in 'docs/plans/site-ui-3-shots/after/address_igneumdev-qz9h.jpg' 'tools/ci/windows-paths-check.sh' 'a/console.log' 'a/null.rs' 'a/com10.txt' 'a/.gitignore' 'a/b.c.d'; do if ! printf '%s\n' "$good" | check_paths >/dev/null; then echo "self-test failed: rejected '$good'"; fails=1; fi done [ "$fails" = 0 ] && echo "self-test passed: colon, question mark, trailing dot, trailing space, NUL, COM1, LpT3 and a 250-character path fail; seven ordinary paths pass" exit $fails fi cd "$(git rev-parse --show-toplevel)" if [ "${1:-}" = "--staged" ]; then list="$(git diff --cached --name-only --diff-filter=ACR -z | tr '\0' '\n')"; what="staged paths" else list="$(git ls-files -z | tr '\0' '\n')"; what="tracked paths" fi if out="$(printf '%s\n' "$list" | check_paths)"; then echo "windows-paths: every one of $(printf '%s\n' "$list" | grep -c . || true) $what is valid on Windows" else echo "windows-paths: these $what cannot exist on Windows (rename them before committing):"; printf '%s\n' "$out"; exit 1 fi