Skip to content

Instantly share code, notes, and snippets.

@yszheda
Created June 22, 2026 11:47
Show Gist options
  • Select an option

  • Save yszheda/c8f0ca6e805bfabfc8277b6c149cf6c6 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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."
#!/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
#!/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
#!/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