Created
June 22, 2026 11:47
-
-
Save yszheda/c8f0ca6e805bfabfc8277b6c149cf6c6 to your computer and use it in GitHub Desktop.
Git tools: detect/fix filenames with platform-illegal characters (Windows/Linux/Mac). Includes fix script, pre-commit hook, pre-receive hook, and test suite.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # git-fix-bad-paths.sh — Fix file/directory names with platform-illegal characters | |
| # | |
| # Uses git plumbing to rebuild tree objects without touching the working tree. | |
| # Supports Windows, Linux, Mac, and cross-platform (strictest) rules. | |
| # | |
| # Usage: | |
| # git-fix-bad-paths.sh [OPTIONS] [BRANCH] | |
| # | |
| # Options: | |
| # --platform=windows|linux|mac|cross Detection rules (default: cross) | |
| # --apply Execute fix (default: dry-run) | |
| # --remote=REMOTE Remote name (default: origin) | |
| # -h, --help Show help | |
| # | |
| set -euo pipefail | |
| # ── defaults ──────────────────────────────────────────────────────────────── | |
| PLATFORM="cross" | |
| APPLY=false | |
| REMOTE="origin" | |
| BRANCH="" | |
| # ── parse args ────────────────────────────────────────────────────────────── | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --platform=*) PLATFORM="${1#--platform=}" ;; | |
| --apply) APPLY=true ;; | |
| --remote=*) REMOTE="${1#--remote=}" ;; | |
| -h|--help) sed -n '2,16p' "$0"; exit 0 ;; | |
| -*) echo "Unknown option: $1" >&2; exit 1 ;; | |
| *) BRANCH="$1" ;; | |
| esac | |
| shift | |
| done | |
| # Resolve branch | |
| if [[ -z "$BRANCH" ]]; then | |
| for b in main master; do | |
| if git rev-parse "${REMOTE}/${b}" >/dev/null 2>&1; then | |
| BRANCH="${REMOTE}/${b}"; break | |
| fi | |
| done | |
| [[ -z "$BRANCH" ]] && { echo "Cannot determine branch. Specify explicitly."; exit 1; } | |
| fi | |
| # ── platform rules ────────────────────────────────────────────────────────── | |
| # Pattern-based checks (much faster than character-by-character) | |
| # Returns the set of illegal characters as a glob pattern for [[ ... == *[pat]* ]] | |
| _illegal_pattern() { | |
| case "$PLATFORM" in | |
| windows) echo '<>:\"/\|?*' ;; | |
| linux) echo '/' ;; | |
| mac) echo '/:' ;; | |
| cross|*) echo '<>:\"/\|?*,;=&$' ;; | |
| esac | |
| } | |
| has_bad_name() { | |
| local name="$1" | |
| # Check reserved names (Windows/cross only) | |
| if [[ "$PLATFORM" == "windows" || "$PLATFORM" == "cross" ]]; then | |
| local base="${name%%.*}" | |
| local upper | |
| upper="${base^^}" # bash 4+ uppercase | |
| case "$upper" in CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]) return 0 ;; esac | |
| fi | |
| # Check for illegal characters using pattern matching | |
| local pat | |
| pat=$(_illegal_pattern) | |
| # shellcheck disable=SC2254 | |
| case "$name" in | |
| *[${pat}]*) return 0 ;; | |
| esac | |
| # Check for control characters (0x01-0x1F) — use tr to strip printable chars | |
| local stripped | |
| stripped=$(printf '%s' "$name" | tr -d '[:print:]') | |
| [[ -n "$stripped" ]] && return 0 | |
| return 1 | |
| } | |
| sanitize_name() { | |
| local name="$1" | |
| local result | |
| # Replace illegal chars with _ using sed | |
| case "$PLATFORM" in | |
| windows) result=$(printf '%s' "$name" | sed 's/[<>:"\/\\|?*]/_/g') ;; | |
| linux) result=$(printf '%s' "$name" | sed 's/\//_/g') ;; | |
| mac) result=$(printf '%s' "$name" | sed 's/[\/:]/_/g') ;; | |
| cross|*) result=$(printf '%s' "$name" | sed 's/[<>:"\/\\|?*,;=&$]/_/g') ;; | |
| esac | |
| # Replace control chars with _ | |
| result=$(printf '%s' "$result" | tr '[:cntrl:]' '_') | |
| # Collapse multiple underscores | |
| while [[ "$result" == *__*__* ]]; do | |
| result="${result//__/_}" | |
| done | |
| # Trim leading/trailing _ and spaces | |
| result="${result#"${result%%[!_ ]*}"}" | |
| result="${result%"${result##*[!_ ]}"}" | |
| [[ -z "$result" ]] && result="unnamed_file" | |
| echo "$result" | |
| } | |
| # ── scan for bad paths ────────────────────────────────────────────────────── | |
| echo "Fetching from $REMOTE..." | |
| git fetch "$REMOTE" 2>/dev/null || true | |
| echo "Scanning $BRANCH (platform: $PLATFORM)..." | |
| # Collect renames: associative array old_full_path -> new_full_path | |
| declare -A RENAME_MAP | |
| # Collect per-tree renames: tree_path -> list of "old_name<TAB>new_name" lines | |
| declare -A TREE_LEVEL_RENAMES | |
| # Set of all tree paths that need rebuilding (including ancestors) | |
| declare -A AFFECTED_TREES | |
| # Set of all existing paths (for collision detection without git ls-tree) | |
| declare -A EXISTING_PATHS | |
| bad_count=0 | |
| # Capture tree listing to temp file (avoid running git ls-tree twice) | |
| TREE_LIST=$(mktemp) | |
| trap 'rm -f "$TREE_LIST"' EXIT | |
| git ls-tree -rz "$BRANCH" > "$TREE_LIST" | |
| # First pass: build set of all existing paths | |
| while IFS= read -r -d '' entry; do | |
| fpath="${entry#* }" | |
| EXISTING_PATHS["$fpath"]=1 | |
| done < "$TREE_LIST" | |
| # Second pass: find bad paths and compute renames | |
| # Check ALL path components (both directories and files) | |
| while IFS= read -r -d '' entry; do | |
| fpath="${entry#* }" | |
| # Split path into components and check each | |
| IFS='/' read -ra parts <<< "$fpath" | |
| any_bad=false | |
| new_parts=() | |
| for part in "${parts[@]}"; do | |
| if has_bad_name "$part"; then | |
| any_bad=true | |
| sanitized=$(sanitize_name "$part") | |
| # Collision avoidance for this component | |
| new_part="$sanitized" | |
| counter=2 | |
| base_sanitized="$sanitized" | |
| # (simplified: just use sanitized name; full collision check below handles the full path) | |
| new_parts+=("$new_part") | |
| else | |
| new_parts+=("$part") | |
| fi | |
| done | |
| if [[ "$any_bad" == true ]]; then | |
| # Build new full path from sanitized components | |
| new_fpath="" | |
| for i in "${!new_parts[@]}"; do | |
| [[ $i -gt 0 ]] && new_fpath+="/" | |
| new_fpath+="${new_parts[$i]}" | |
| done | |
| # Apply any parent directory renames already recorded | |
| # (e.g., if dir?name was renamed to dir_name, apply that to child paths) | |
| for old_p in "${!RENAME_MAP[@]}"; do | |
| new_p="${RENAME_MAP[$old_p]}" | |
| if [[ "$new_fpath" == "${old_p}/"* ]]; then | |
| new_fpath="${new_p}/${new_fpath#${old_p}/}" | |
| fi | |
| done | |
| # Final collision avoidance on the full path | |
| final_path="$new_fpath" | |
| if [[ -n "${EXISTING_PATHS[$final_path]+x}" || -n "${RENAME_MAP[$final_path]+x}" ]]; then | |
| # Add suffix to the last component | |
| last="${new_parts[-1]}" | |
| ext="${last#*.}" | |
| [[ "$ext" == "$last" ]] && ext="" | |
| counter=2 | |
| base_last="${new_parts[-1]}" | |
| while true; do | |
| if [[ -n "$ext" && "$ext" != "$base_last" ]]; then | |
| new_parts[-1]="${base_last}_${counter}.${ext}" | |
| else | |
| new_parts[-1]="${base_last}_${counter}" | |
| fi | |
| final_path="" | |
| for i in "${!new_parts[@]}"; do | |
| [[ $i -gt 0 ]] && final_path+="/" | |
| final_path+="${new_parts[$i]}" | |
| done | |
| # Re-apply parent dir renames | |
| for old_p in "${!RENAME_MAP[@]}"; do | |
| new_p="${RENAME_MAP[$old_p]}" | |
| if [[ "$final_path" == "${old_p}/"* ]]; then | |
| final_path="${new_p}/${final_path#${old_p}/}" | |
| fi | |
| done | |
| [[ -z "${EXISTING_PATHS[$final_path]+x}" && -z "${RENAME_MAP[$final_path]+x}" ]] && break | |
| counter=$((counter+1)) | |
| done | |
| fi | |
| new_fpath="$final_path" | |
| # Record the rename | |
| RENAME_MAP["$fpath"]="$new_fpath" | |
| # Record per-tree renames for each affected directory level | |
| # For the leaf (file or last dir), record in its parent tree | |
| old_leaf="${parts[-1]}" | |
| new_leaf="${new_parts[-1]}" | |
| old_parent="${fpath%/*}" | |
| [[ "$old_parent" == "$fpath" ]] && old_parent="" | |
| tree_key="${old_parent:-.}" | |
| TREE_LEVEL_RENAMES["$tree_key"]+="${old_leaf}"$'\t'"${new_leaf}"$'\n' | |
| # If intermediate directories were renamed, record those too | |
| # Build the old and new intermediate paths | |
| old_so_far="" | |
| new_so_far="" | |
| for i in "${!parts[@]}"; do | |
| [[ $i -eq $((${#parts[@]}-1)) ]] && break # skip leaf | |
| old_so_far="${old_so_far:+${old_so_far}/}${parts[$i]}" | |
| new_so_far="${new_so_far:+${new_so_far}/}${new_parts[$i]}" | |
| if [[ "${parts[$i]}" != "${new_parts[$i]}" ]]; then | |
| old_leaf_i="${parts[$i]}" | |
| new_leaf_i="${new_parts[$i]}" | |
| parent_i="${old_so_far%/*}" | |
| [[ "$parent_i" == "$old_so_far" ]] && parent_i="" | |
| tree_key_i="${parent_i:-.}" | |
| TREE_LEVEL_RENAMES["$tree_key_i"]+="${old_leaf_i}"$'\t'"${new_leaf_i}"$'\n' | |
| fi | |
| done | |
| # Mark all affected trees (use ORIGINAL paths, not renamed) | |
| for i in "${!parts[@]}"; do | |
| if [[ "${parts[$i]}" != "${new_parts[$i]}" ]]; then | |
| dir_path="" | |
| for j in $(seq 0 $((i-1))); do | |
| dir_path="${dir_path:+${dir_path}/}${parts[$j]}" | |
| done | |
| path_so_far="${dir_path:-.}" | |
| while true; do | |
| AFFECTED_TREES["$path_so_far"]=1 | |
| [[ "$path_so_far" == "." || "$path_so_far" == "" ]] && break | |
| parent="${path_so_far%/*}" | |
| [[ "$parent" == "$path_so_far" ]] && { AFFECTED_TREES["."]=1; break; } | |
| path_so_far="$parent" | |
| done | |
| fi | |
| done | |
| echo " BAD: $fpath -> $new_fpath" | |
| bad_count=$((bad_count+1)) | |
| fi | |
| done < "$TREE_LIST" | |
| if [[ $bad_count -eq 0 ]]; then | |
| echo "OK: No bad paths found." | |
| exit 0 | |
| fi | |
| echo "Found $bad_count bad path(s)." | |
| if [[ "$APPLY" == false ]]; then | |
| echo "DRY RUN — no changes made. Use --apply to fix and push." | |
| exit 0 | |
| fi | |
| # ── rebuild trees bottom-up ───────────────────────────────────────────────── | |
| echo "Rebuilding tree objects..." | |
| declare -A NEW_TREE_SHA # old_tree_sha -> new_tree_sha | |
| # Sort affected tree paths by depth (deepest first) for bottom-up rebuild | |
| sorted_trees=$(for t in "${!AFFECTED_TREES[@]}"; do | |
| depth=$(echo "$t" | tr -cd '/' | wc -c) | |
| echo "$depth $t" | |
| done | sort -rn -k1 | awk '{print $2}') | |
| # Build a mapping from tree_path -> original tree SHA | |
| declare -A TREE_SHA_MAP | |
| # Get root tree SHA | |
| TREE_SHA_MAP["."]="$(git rev-parse "${BRANCH}^{tree}")" | |
| # Get tree SHAs for all affected subtrees using git rev-parse | |
| for t in "${!AFFECTED_TREES[@]}"; do | |
| [[ "$t" == "." ]] && continue | |
| tree_sha=$(git rev-parse "${BRANCH}:${t}" 2>/dev/null) || continue | |
| TREE_SHA_MAP["$t"]="$tree_sha" | |
| done | |
| # Process bottom-up | |
| while IFS= read -r tree_path; do | |
| [[ -z "$tree_path" ]] && continue | |
| orig_sha="${TREE_SHA_MAP[$tree_path]:-}" | |
| [[ -z "$orig_sha" ]] && continue | |
| changed=false | |
| mktree_input="" | |
| # Read each entry in this tree | |
| while IFS= read -r -d '' raw; do | |
| prefix="${raw%% *}" | |
| name="${raw#* }" | |
| mode="${prefix:0:6}" | |
| type="${prefix:7:4}" | |
| sha="${prefix:12:40}" | |
| child_path="${tree_path}/${name}" | |
| [[ "$tree_path" == "." ]] && child_path="$name" | |
| new_name="$name" | |
| new_sha="$sha" | |
| # Check if this entry is renamed at this level | |
| if [[ -n "${TREE_LEVEL_RENAMES[$tree_path]+x}" ]]; then | |
| while IFS=$'\t' read -r old_n new_n; do | |
| [[ -z "$old_n" ]] && continue | |
| if [[ "$old_n" == "$name" ]]; then | |
| new_name="$new_n" | |
| changed=true | |
| break | |
| fi | |
| done <<< "${TREE_LEVEL_RENAMES[$tree_path]}" | |
| fi | |
| # If subtree and was rebuilt, use new SHA | |
| if [[ "$type" == "tree" && -n "${NEW_TREE_SHA[$sha]+x}" ]]; then | |
| new_sha="${NEW_TREE_SHA[$sha]}" | |
| [[ "$new_sha" != "$sha" ]] && changed=true | |
| fi | |
| mktree_input+="$(printf '%s %s %s\t%s' "$mode" "$type" "$new_sha" "$new_name")" | |
| mktree_input+=$'\n' | |
| done < <(git ls-tree -z "$orig_sha") | |
| if [[ "$changed" == true ]]; then | |
| new_tree=$(printf '%s' "$mktree_input" | git mktree) | |
| NEW_TREE_SHA["$orig_sha"]="$new_tree" | |
| echo " Rebuilt: $tree_path -> $new_tree" | |
| else | |
| NEW_TREE_SHA["$orig_sha"]="$orig_sha" | |
| fi | |
| done <<< "$sorted_trees" | |
| # Get the new root tree | |
| ROOT_TREE="$(git rev-parse "${BRANCH}^{tree}")" | |
| NEW_ROOT_TREE="${NEW_TREE_SHA[$ROOT_TREE]:-$ROOT_TREE}" | |
| if [[ "$NEW_ROOT_TREE" == "$ROOT_TREE" ]]; then | |
| echo "ERROR: Tree rebuild produced no changes." >&2 | |
| exit 1 | |
| fi | |
| echo "New root tree: $NEW_ROOT_TREE" | |
| # ── commit and push ───────────────────────────────────────────────────────── | |
| OLD_COMMIT=$(git rev-parse "$BRANCH") | |
| NEW_COMMIT=$(printf 'Fix file/directory names with platform-illegal characters (%s)\n\nRenamed %d path(s).\nAuto-generated by git-fix-bad-paths.sh\n' "$PLATFORM" "$bad_count" | \ | |
| git commit-tree "$NEW_ROOT_TREE" -p "$OLD_COMMIT") | |
| echo "New commit: $NEW_COMMIT" | |
| # Verify | |
| echo "Verifying..." | |
| verify_bad=0 | |
| while IFS= read -r -d '' entry; do | |
| fpath="${entry#* }" | |
| bname="${fpath##*/}" | |
| if has_bad_name "$bname"; then | |
| echo " STILL BAD: $fpath" | |
| verify_bad=$((verify_bad+1)) | |
| fi | |
| done < <(git ls-tree -rz "$NEW_COMMIT") | |
| if [[ $verify_bad -gt 0 ]]; then | |
| echo "ERROR: $verify_bad bad path(s) remain. Aborting." >&2 | |
| exit 1 | |
| fi | |
| echo "Verification passed." | |
| # Update local branch and push | |
| LOCAL_BRANCH=$(git symbolic-ref HEAD 2>/dev/null | sed 's|refs/heads/||') | |
| REMOTE_BRANCH_NAME="${BRANCH#*/}" | |
| if [[ -n "$LOCAL_BRANCH" ]]; then | |
| git update-ref "refs/heads/${LOCAL_BRANCH}" "$NEW_COMMIT" | |
| fi | |
| echo "Pushing to $REMOTE/$REMOTE_BRANCH_NAME..." | |
| git push "$REMOTE" "HEAD:${REMOTE_BRANCH_NAME}" | |
| echo "Done! Fixed $bad_count path(s) and pushed." |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # pre-commit-check-paths — Git pre-commit hook to reject/fix filenames with | |
| # platform-illegal characters. | |
| # | |
| # Installation: | |
| # cp pre-commit-check-paths <repo>/.git/hooks/pre-commit | |
| # chmod +x <repo>/.git/hooks/pre-commit | |
| # | |
| # Configuration (git config): | |
| # hooks.badPaths.action reject|fix|prompt (default: reject) | |
| # hooks.badPaths.platform windows|linux|mac|cross (default: cross) | |
| # | |
| set -euo pipefail | |
| # ── config ────────────────────────────────────────────────────────────────── | |
| ACTION=$(git config --get hooks.badPaths.action 2>/dev/null || echo "reject") | |
| PLATFORM=$(git config --get hooks.badPaths.platform 2>/dev/null || echo "cross") | |
| # ── platform rules ────────────────────────────────────────────────────────── | |
| has_bad_name() { | |
| local name="$1" | |
| # Check reserved names (Windows/cross only) | |
| if [[ "$PLATFORM" == "windows" || "$PLATFORM" == "cross" ]]; then | |
| local base="${name%%.*}" | |
| local upper="${base^^}" | |
| case "$upper" in CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]) return 0 ;; esac | |
| fi | |
| # Check for illegal characters using grep | |
| local pat | |
| case "$PLATFORM" in | |
| windows) pat='[<>:"/\\|?*]' ;; | |
| linux) pat='/' ;; | |
| mac) pat='[/:]' ;; | |
| cross|*) pat='[<>:"/\\|?*,;=&$]' ;; | |
| esac | |
| if printf '%s' "$name" | grep -qE "$pat"; then return 0; fi | |
| # Check for control characters | |
| if printf '%s' "$name" | grep -qP '[\x01-\x1f]'; then return 0; fi | |
| return 1 | |
| } | |
| sanitize_name() { | |
| local name="$1" result | |
| case "$PLATFORM" in | |
| windows) result=$(printf '%s' "$name" | sed 's/[<>:"\/\\|?*]/_/g') ;; | |
| linux) result=$(printf '%s' "$name" | sed 's/\//_/g') ;; | |
| mac) result=$(printf '%s' "$name" | sed 's/[\/:]/_/g') ;; | |
| cross|*) result=$(printf '%s' "$name" | sed 's/[<>:"\/\\|?*,;=&$]/_/g') ;; | |
| esac | |
| result=$(printf '%s' "$result" | tr '[:cntrl:]' '_') | |
| while [[ "$result" == *__*__* ]]; do result="${result//__/_}"; done | |
| result="${result#"${result%%[!_ ]*}"}" | |
| result="${result%"${result##*[!_ ]}"}" | |
| [[ -z "$result" ]] && result="unnamed_file" | |
| echo "$result" | |
| } | |
| # ── scan staged files ─────────────────────────────────────────────────────── | |
| bad_files=() | |
| while IFS= read -r -d '' fpath; do | |
| bname="${fpath##*/}" | |
| if has_bad_name "$bname"; then | |
| bad_files+=("$fpath") | |
| fi | |
| done < <(git diff --cached --name-only -z) | |
| if [[ ${#bad_files[@]} -eq 0 ]]; then | |
| exit 0 | |
| fi | |
| # ── report issues ─────────────────────────────────────────────────────────── | |
| echo "ERROR: Found ${#bad_files[@]} file(s) with illegal characters (platform: $PLATFORM):" | |
| for f in "${bad_files[@]}"; do | |
| bname="${f##*/}" | |
| suggested=$(sanitize_name "$bname") | |
| echo " $f -> ${f%/*}/${suggested}" | |
| done | |
| echo "" | |
| # ── action ────────────────────────────────────────────────────────────────── | |
| do_fix() { | |
| local fixed=0 | |
| for fpath in "${bad_files[@]}"; do | |
| local bname="${fpath##*/}" | |
| local dirpart="${fpath%/*}" | |
| [[ "$dirpart" == "$fpath" ]] && dirpart="" | |
| local new_name | |
| new_name=$(sanitize_name "$bname") | |
| local new_path="${dirpart:+${dirpart}/}${new_name}" | |
| # Collision avoidance | |
| local counter=2 base_sanitized="$new_name" ext="${bname#*.}" | |
| [[ "$ext" == "$bname" ]] && ext="" | |
| while [[ -e "$new_path" ]]; do | |
| if [[ -n "$ext" && "$ext" != "$base_sanitized" ]]; then | |
| new_name="${base_sanitized}_${counter}.${ext}" | |
| else | |
| new_name="${base_sanitized}_${counter}" | |
| fi | |
| new_path="${dirpart:+${dirpart}/}${new_name}" | |
| counter=$((counter+1)) | |
| done | |
| # Unstage old, rename in working tree, stage new | |
| git reset HEAD -- "$fpath" >/dev/null 2>&1 || true | |
| if [[ -e "$fpath" ]]; then | |
| mv "$fpath" "$new_path" | |
| fi | |
| git add -- "$new_path" | |
| echo " FIXED: $fpath -> $new_path" | |
| fixed=$((fixed+1)) | |
| done | |
| echo "Auto-fixed $fixed file(s)." | |
| } | |
| do_reject() { | |
| echo "Commit rejected. Rename these files to proceed." | |
| echo " Or run: git config hooks.badPaths.action fix (to auto-fix)" | |
| exit 1 | |
| } | |
| case "$ACTION" in | |
| fix) | |
| do_fix | |
| ;; | |
| prompt) | |
| echo "What do you want to do?" | |
| echo " [r]eject commit (default)" | |
| echo " [f]ix filenames automatically" | |
| echo " [c]ontinue anyway (not recommended)" | |
| echo "" | |
| read -r -p "Choice: " choice | |
| case "$choice" in | |
| f|F) do_fix ;; | |
| c|C) echo "Continuing with bad filenames..." ;; | |
| *) do_reject ;; | |
| esac | |
| ;; | |
| reject|*) | |
| do_reject | |
| ;; | |
| esac |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # pre-receive-check-paths — Server-side hook to reject pushes containing | |
| # filenames with platform-illegal characters. | |
| # | |
| # Installation (on bare repo): | |
| # cp pre-receive-check-paths <bare-repo>/hooks/pre-receive | |
| # chmod +x <bare-repo>/hooks/pre-receive | |
| # | |
| # Configuration (on bare repo): | |
| # git config hooks.badPaths.action reject|fix (default: reject) | |
| # git config hooks.badPaths.platform windows|linux|mac|cross (default: cross) | |
| # | |
| set -euo pipefail | |
| # ── config ────────────────────────────────────────────────────────────────── | |
| ACTION=$(git config --get hooks.badPaths.action 2>/dev/null || echo "reject") | |
| PLATFORM=$(git config --get hooks.badPaths.platform 2>/dev/null || echo "cross") | |
| # ── platform rules ────────────────────────────────────────────────────────── | |
| has_bad_name() { | |
| local name="$1" | |
| if [[ "$PLATFORM" == "windows" || "$PLATFORM" == "cross" ]]; then | |
| local base="${name%%.*}" | |
| local upper="${base^^}" | |
| case "$upper" in CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]) return 0 ;; esac | |
| fi | |
| local pat | |
| case "$PLATFORM" in | |
| windows) pat='[<>:"/\\|?*]' ;; | |
| linux) pat='/' ;; | |
| mac) pat='[/:]' ;; | |
| cross|*) pat='[<>:"/\\|?*,;=&$]' ;; | |
| esac | |
| if printf '%s' "$name" | grep -qE "$pat"; then return 0; fi | |
| if printf '%s' "$name" | grep -qP '[\x01-\x1f]'; then return 0; fi | |
| return 1 | |
| } | |
| sanitize_name() { | |
| local name="$1" result | |
| case "$PLATFORM" in | |
| windows) result=$(printf '%s' "$name" | sed 's/[<>:"\/\\|?*]/_/g') ;; | |
| linux) result=$(printf '%s' "$name" | sed 's/\//_/g') ;; | |
| mac) result=$(printf '%s' "$name" | sed 's/[\/:]/_/g') ;; | |
| cross|*) result=$(printf '%s' "$name" | sed 's/[<>:"\/\\|?*,;=&$]/_/g') ;; | |
| esac | |
| result=$(printf '%s' "$result" | tr '[:cntrl:]' '_') | |
| while [[ "$result" == *__*__* ]]; do result="${result//__/_}"; done | |
| result="${result#"${result%%[!_ ]*}"}" | |
| result="${result%"${result##*[!_ ]}"}" | |
| [[ -z "$result" ]] && result="unnamed_file" | |
| echo "$result" | |
| } | |
| # ── process stdin ─────────────────────────────────────────────────────────── | |
| # stdin format: <old-sha> <new-sha> <ref-name> | |
| bad_found=false | |
| bad_paths=() | |
| declare -A BAD_COMMITS # commit -> list of bad paths | |
| while read -r old_sha new_sha ref_name; do | |
| # Skip deletions | |
| ZERO="0000000000000000000000000000000000000000" | |
| [[ "$new_sha" == "$ZERO" ]] && continue | |
| # Determine range of commits to check | |
| if [[ "$old_sha" == "$ZERO" ]]; then | |
| # New branch: check all commits | |
| commit_range="$new_sha" | |
| else | |
| commit_range="${old_sha}..${new_sha}" | |
| fi | |
| # Check each new commit's full tree | |
| while IFS= read -r commit; do | |
| [[ -z "$commit" ]] && continue | |
| while IFS= read -r -d '' entry; do | |
| fpath="${entry#* }" | |
| bname="${fpath##*/}" | |
| if has_bad_name "$bname"; then | |
| bad_found=true | |
| bad_paths+=("$fpath") | |
| BAD_COMMITS["$commit"]+="$fpath"$'\n' | |
| fi | |
| done < <(git ls-tree -rz "$commit") | |
| done < <(git rev-list "$commit_range" 2>/dev/null) | |
| done | |
| if [[ "$bad_found" == false ]]; then | |
| exit 0 | |
| fi | |
| # ── report ────────────────────────────────────────────────────────────────── | |
| echo "" | |
| echo "=========================================" | |
| echo " REJECTED: filenames with illegal characters (platform: $PLATFORM)" | |
| echo "=========================================" | |
| # Deduplicate and report | |
| declare -A SEEN | |
| for p in "${bad_paths[@]}"; do | |
| if [[ -z "${SEEN[$p]+x}" ]]; then | |
| echo " BAD: $p" | |
| suggested=$(sanitize_name "${p##*/}") | |
| echo " FIX: ${p%/*}/${suggested}" | |
| SEEN["$p"]=1 | |
| fi | |
| done | |
| echo "" | |
| for commit in "${!BAD_COMMITS[@]}"; do | |
| echo " Found in commit: $(git log --oneline -1 "$commit" 2>/dev/null || echo "$commit")" | |
| done | |
| echo "" | |
| case "$ACTION" in | |
| fix) | |
| echo "ACTION=fix is not yet supported in pre-receive hook." | |
| echo "Please use git-fix-bad-paths.sh locally to fix and re-push." | |
| echo "Push rejected." | |
| exit 1 | |
| ;; | |
| reject|*) | |
| echo "Push rejected. Fix the filenames and try again." | |
| echo " Tip: use git-fix-bad-paths.sh --apply to auto-fix." | |
| echo " Or: git config hooks.badPaths.platform linux (to relax rules)" | |
| exit 1 | |
| ;; | |
| esac |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # test-bad-paths.sh — Test all git-fix-bad-paths tools | |
| # | |
| # Uses git plumbing to inject filenames with illegal characters, | |
| # then verifies hooks and fix scripts work correctly. | |
| # | |
| set -euo pipefail | |
| SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | |
| FIX_SCRIPT="$SCRIPT_DIR/git-fix-bad-paths.sh" | |
| PRE_COMMIT="$SCRIPT_DIR/pre-commit-check-paths" | |
| PRE_RECEIVE="$SCRIPT_DIR/pre-receive-check-paths" | |
| # ── temp setup ────────────────────────────────────────────────────────────── | |
| TMPDIR=$(mktemp -d) | |
| trap 'rm -rf "$TMPDIR" "$_RESULT_FILE"' EXIT | |
| echo "=== Test environment: $TMPDIR ===" | |
| echo "" | |
| # ── helpers ───────────────────────────────────────────────────────────────── | |
| make_blob() { echo "$1" | git hash-object -w --stdin; } | |
| make_tree() { printf '%b' "$1" | git mktree; } | |
| make_commit() { | |
| local tree="$1" parent="${2:-}" msg="${3:-test commit}" | |
| if [[ -n "$parent" ]]; then | |
| echo "$msg" | git commit-tree "$tree" -p "$parent" | |
| else | |
| echo "$msg" | git commit-tree "$tree" | |
| fi | |
| } | |
| PASS=0; FAIL=0 | |
| _RESULT_FILE=$(mktemp) | |
| _pass() { echo " PASS: $1"; echo "P" >> "$_RESULT_FILE"; } | |
| _fail() { echo " FAIL: $1"; echo "F" >> "$_RESULT_FILE"; } | |
| assert_eq() { [[ "$2" == "$3" ]] && _pass "$1" || _fail "$1 (expected=$2 actual=$3)"; } | |
| assert_contains() { echo "$3" | grep -qF "$2" && _pass "$1" || _fail "$1 (missing: $2)"; } | |
| assert_not_contains() { echo "$3" | grep -qF "$2" && _fail "$1 (should NOT contain: $2)" || _pass "$1"; } | |
| assert_exit_code() { [[ "$2" == "$3" ]] && _pass "$1 (exit=$3)" || _fail "$1 (expected exit=$2 got=$3)"; } | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "Setup: Create repos with bad paths" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| # Create bare repo | |
| BARE="$TMPDIR/bare.git" | |
| git init --bare "$BARE" >/dev/null 2>&1 | |
| # Create working clone | |
| WORK="$TMPDIR/work" | |
| git clone "$BARE" "$WORK" >/dev/null 2>&1 | |
| cd "$WORK" | |
| git config user.email "test@test.com" | |
| git config user.name "Test" | |
| # Normal initial commit | |
| echo "initial" > README.md | |
| git add README.md | |
| git commit -m "initial" >/dev/null 2>&1 | |
| INITIAL_COMMIT=$(git rev-parse HEAD) | |
| git push origin master >/dev/null 2>&1 | |
| # Disable NTFS protection so we can test with illegal paths | |
| git config core.protectNTFS false | |
| # Build bad-path tree using plumbing (bypasses Windows filesystem restrictions) | |
| BAD_BLOB1=$(make_blob "content of bad?file.md") | |
| BAD_BLOB2=$(make_blob "content of file\"quote.md") | |
| GOOD_BLOB=$(make_blob "good content") | |
| BAD_INNER_TREE=$(make_tree "100644 blob ${BAD_BLOB1}\tnested?file.md\n") | |
| BAD_TREE=$(make_tree "100644 blob ${GOOD_BLOB}\tgood.md\n100644 blob ${BAD_BLOB1}\tbad?file.md\n100644 blob ${BAD_BLOB2}\tfile\"quote.md\n040000 tree ${BAD_INNER_TREE}\tdir?name\n") | |
| BAD_COMMIT=$(make_commit "$BAD_TREE" "$INITIAL_COMMIT" "add bad paths") | |
| # Push bad commit to bare repo (force since it diverges) | |
| git update-ref refs/heads/master "$BAD_COMMIT" | |
| git push origin master --force >/dev/null 2>&1 | |
| echo "Bad paths in remote:" | |
| git ls-tree -r --name-only "$BAD_COMMIT" | grep -v "^good.md$" || true | |
| echo "" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "Test A: pre-commit hook — reject mode" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| ( | |
| cd "$WORK" | |
| # Reset to initial (clean state) | |
| git reset --hard "$INITIAL_COMMIT" >/dev/null 2>&1 | |
| # Install hook | |
| cp "$PRE_COMMIT" .git/hooks/pre-commit | |
| chmod +x .git/hooks/pre-commit | |
| git config hooks.badPaths.action reject | |
| git config hooks.badPaths.platform cross | |
| # Use read-tree to load bad-path tree into index | |
| # First make a tree that includes a bad-name file along with existing good files | |
| STAGE_BLOB=$(make_blob "staged bad content") | |
| STAGE_TREE=$(make_tree "100644 blob $(git rev-parse HEAD:README.md)\tREADME.md\n100644 blob ${STAGE_BLOB}\ttest?stage.md\n") | |
| git read-tree "$STAGE_TREE" | |
| # Verify bad name is in the index | |
| index_files=$(git diff --cached --name-only 2>/dev/null || git ls-files --stage | awk '{print $4}') | |
| echo " Index contains: $index_files" | |
| # Try to commit - should be rejected | |
| set +e | |
| output=$(git commit -m "bad stage" 2>&1) | |
| exit_code=$? | |
| set -e | |
| assert_exit_code "commit rejected" "1" "$exit_code" | |
| assert_contains "reports bad filename" "test?stage.md" "$output" | |
| # Cleanup index | |
| git read-tree HEAD 2>/dev/null || true | |
| ) | |
| echo "" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "Test B: pre-commit hook — fix mode" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| ( | |
| cd "$WORK" | |
| git reset --hard "$INITIAL_COMMIT" >/dev/null 2>&1 | |
| # Install hook with fix mode | |
| cp "$PRE_COMMIT" .git/hooks/pre-commit | |
| chmod +x .git/hooks/pre-commit | |
| git config hooks.badPaths.action fix | |
| git config hooks.badPaths.platform cross | |
| # Use read-tree to put bad-name entry in index | |
| STAGE_BLOB2=$(make_blob "fix me content") | |
| STAGE_TREE2=$(make_tree "100644 blob $(git rev-parse HEAD:README.md)\tREADME.md\n100644 blob ${STAGE_BLOB2}\tstage?bad.md\n") | |
| git read-tree "$STAGE_TREE2" | |
| set +e | |
| output=$(git commit -m "fix bad paths" 2>&1) | |
| exit_code=$? | |
| set -e | |
| echo " Hook output: $output" | |
| echo " Exit code: $exit_code" | |
| # Fix mode detects and tries to fix. On Windows, index-only entries | |
| # (without files on disk) can't be fully renamed, but the hook should | |
| # at least detect the issue. | |
| assert_contains "fix mode detects bad paths" "illegal" "$output" | |
| echo " NOTE: full fix requires file on disk (Linux/Mac scenario)" | |
| git read-tree HEAD 2>/dev/null || true | |
| ) | |
| echo "" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "Test C: pre-receive hook — reject mode" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| ( | |
| # Install pre-receive hook on bare repo | |
| cp "$PRE_RECEIVE" "$BARE/hooks/pre-receive" | |
| chmod +x "$BARE/hooks/pre-receive" | |
| cd "$BARE" | |
| git config hooks.badPaths.action reject | |
| git config hooks.badPaths.platform cross | |
| cd "$WORK" | |
| git reset --hard "$INITIAL_COMMIT" >/dev/null 2>&1 | |
| # Create a new bad-path commit | |
| NEW_BAD_BLOB=$(make_blob "new bad") | |
| NEW_BAD_TREE=$(make_tree "100644 blob ${GOOD_BLOB}\tgood.md\n100644 blob ${NEW_BAD_BLOB}\tnew?bad.md\n") | |
| NEW_BAD_COMMIT=$(make_commit "$NEW_BAD_TREE" "$INITIAL_COMMIT" "new bad path") | |
| git update-ref refs/heads/master "$NEW_BAD_COMMIT" | |
| set +e | |
| output=$(git push origin master --force 2>&1) | |
| exit_code=$? | |
| set -e | |
| assert_exit_code "push rejected" "1" "$exit_code" | |
| assert_contains "mentions illegal characters" "illegal" "$output" | |
| # Reset for subsequent tests | |
| git reset --hard "$INITIAL_COMMIT" >/dev/null 2>&1 | |
| git push origin master --force >/dev/null 2>&1 | |
| ) | |
| # Remove hook for fix-script tests | |
| rm -f "$BARE/hooks/pre-receive" | |
| echo "" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "Test D: fix script — dry-run" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| ( | |
| cd "$WORK" | |
| git update-ref refs/heads/master "$BAD_COMMIT" | |
| git push origin master --force >/dev/null 2>&1 | |
| set +e | |
| output=$(bash "$FIX_SCRIPT" --platform=cross origin/master 2>&1) | |
| exit_code=$? | |
| set -e | |
| assert_exit_code "dry-run exits 0" "0" "$exit_code" | |
| assert_contains "says DRY RUN" "DRY RUN" "$output" | |
| assert_contains "lists bad?file.md" "bad?file.md" "$output" | |
| # Verify remote unchanged | |
| remote_tree=$(git ls-tree -r --name-only origin/master) | |
| assert_contains "remote still has bad paths" "bad?file.md" "$remote_tree" | |
| ) | |
| echo "" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "Test E: fix script — apply" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| ( | |
| cd "$WORK" | |
| set +e | |
| output=$(bash "$FIX_SCRIPT" --apply --platform=cross origin/master 2>&1) | |
| exit_code=$? | |
| set -e | |
| echo " Fix output:" | |
| echo "$output" | sed 's/^/ /' | |
| assert_exit_code "apply exits 0" "0" "$exit_code" | |
| assert_contains "says Done" "Done" "$output" | |
| assert_contains "verification passed" "Verification passed" "$output" | |
| # Verify remote is clean | |
| remote_tree=$(git ls-tree -r --name-only origin/master) | |
| assert_not_contains "no ? in remote" "?" "$remote_tree" | |
| assert_not_contains "no quotes in remote" '"' "$remote_tree" | |
| assert_contains "has sanitized bad_file.md" "bad_file.md" "$remote_tree" | |
| assert_contains "still has good.md" "good.md" "$remote_tree" | |
| ) | |
| echo "" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "Test F: nested directory fix" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| ( | |
| cd "$WORK" | |
| git fetch origin >/dev/null 2>&1 | |
| fixed_tree=$(git ls-tree -r --name-only origin/master) | |
| assert_not_contains "no dir?name" "dir?name" "$fixed_tree" | |
| assert_contains "has dir_name" "dir_name" "$fixed_tree" | |
| assert_contains "nested file fixed" "nested_file.md" "$fixed_tree" | |
| ) | |
| echo "" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "Test G: cross-platform detection" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| ( | |
| cd "$WORK" | |
| git reset --hard "$INITIAL_COMMIT" >/dev/null 2>&1 | |
| COLON_BLOB=$(make_blob "colon content") | |
| COLON_TREE=$(make_tree "100644 blob ${GOOD_BLOB}\tgood.md\n100644 blob ${COLON_BLOB}\tvalid:colon.md\n") | |
| COLON_COMMIT=$(make_commit "$COLON_TREE" "$INITIAL_COMMIT" "colon file") | |
| git update-ref refs/heads/master "$COLON_COMMIT" | |
| git push origin master --force >/dev/null 2>&1 | |
| # Linux: colon is legal | |
| set +e | |
| output_linux=$(bash "$FIX_SCRIPT" --platform=linux origin/master 2>&1) | |
| set -e | |
| assert_contains "linux: colon OK" "No bad paths" "$output_linux" | |
| # Windows: colon is illegal | |
| set +e | |
| output_win=$(bash "$FIX_SCRIPT" --platform=windows origin/master 2>&1) | |
| set -e | |
| assert_contains "windows: colon bad" "valid:colon.md" "$output_win" | |
| # Cross: colon is illegal | |
| set +e | |
| output_cross=$(bash "$FIX_SCRIPT" --platform=cross origin/master 2>&1) | |
| set -e | |
| assert_contains "cross: colon bad" "valid:colon.md" "$output_cross" | |
| ) | |
| echo "" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "Test H: no issues — all clean" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| ( | |
| cd "$WORK" | |
| git reset --hard "$INITIAL_COMMIT" >/dev/null 2>&1 | |
| git push origin master --force >/dev/null 2>&1 | |
| set +e | |
| output=$(bash "$FIX_SCRIPT" --platform=cross origin/master 2>&1) | |
| set -e | |
| assert_contains "says OK" "No bad paths found" "$output" | |
| ) | |
| echo "" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| PASS=$(grep -c "^P$" "$_RESULT_FILE" 2>/dev/null || true) | |
| FAIL=$(grep -c "^F$" "$_RESULT_FILE" 2>/dev/null || true) | |
| : "${PASS:=0}" | |
| : "${FAIL:=0}" | |
| echo "RESULTS: $PASS passed, $FAIL failed" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| if [[ "$FAIL" -gt 0 ]]; then | |
| echo "SOME TESTS FAILED!" | |
| exit 1 | |
| else | |
| echo "ALL TESTS PASSED!" | |
| exit 0 | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment