Files
2025-11-30 08:28:57 +08:00

25 KiB

Platform-Specific Bash Scripting

Comprehensive guide to handling platform differences in bash scripts across Linux, macOS, Windows (Git Bash/WSL), and containers.


⚠️ WINDOWS GIT BASH / MINGW PATH CONVERSION

CRITICAL REFERENCE: For complete Windows Git Bash path conversion and shell detection guidance, see:

📄 windows-git-bash-paths.md

This comprehensive guide covers:

  • Automatic path conversion behavior (Unix → Windows)
  • MSYS_NO_PATHCONV and MSYS2_ARG_CONV_EXCL usage
  • cygpath manual conversion tool
  • Shell detection methods ($OSTYPE, uname, $MSYSTEM)
  • Claude Code specific issues (#2602 snapshot path conversion)
  • Common problems and solutions
  • Cross-platform scripting patterns

Git Bash path conversion is the #1 source of Windows bash scripting issues. Always consult the dedicated guide when working with Windows/Git Bash.


WARNING: WINDOWS GIT BASH / MINGW PATH CONVERSION

CRITICAL REFERENCE: For complete Windows Git Bash path conversion and shell detection guidance, see:

windows-git-bash-paths.md

This comprehensive guide covers:

  • Automatic path conversion behavior (Unix to Windows)
  • MSYS_NO_PATHCONV and MSYS2_ARG_CONV_EXCL usage
  • cygpath manual conversion tool
  • Shell detection methods ($OSTYPE, uname, $MSYSTEM)
  • Claude Code specific issues (#2602 snapshot path conversion)
  • Common problems and solutions
  • Cross-platform scripting patterns

Git Bash path conversion is the #1 source of Windows bash scripting issues. Always consult the dedicated guide when working with Windows/Git Bash.


Table of Contents

  1. Platform Detection
  2. Linux Specifics
  3. macOS Specifics
  4. Windows (Git Bash) - See windows-git-bash-paths.md for complete guide - See windows-git-bash-paths.md for complete guide
  5. Windows (WSL)
  6. Container Environments
  7. Cross-Platform Patterns
  8. Command Compatibility Matrix

🚨 CRITICAL GUIDELINES

Windows File Path Requirements

MANDATORY: Always Use Backslashes on Windows for File Paths

When using Edit or Write tools on Windows, you MUST use backslashes (\) in file paths, NOT forward slashes (/).

Examples:

  • WRONG: D:/repos/project/file.tsx
  • CORRECT: D:\repos\project\file.tsx

This applies to:

  • Edit tool file_path parameter
  • Write tool file_path parameter
  • All file operations on Windows systems

Documentation Guidelines

NEVER create new documentation files unless explicitly requested by the user.

  • Priority: Update existing README.md files rather than creating new documentation
  • Repository cleanliness: Keep repository root clean - only README.md unless user requests otherwise
  • Style: Documentation should be concise, direct, and professional - avoid AI-generated tone
  • User preference: Only create additional .md files when user specifically asks for documentation

Platform Detection

Comprehensive Detection Script

#!/usr/bin/env bash

detect_os() {
    case "$OSTYPE" in
        linux-gnu*)
            if grep -qi microsoft /proc/version 2>/dev/null; then
                echo "wsl"
            else
                echo "linux"
            fi
            ;;
        darwin*)
            echo "macos"
            ;;
        msys*|mingw*|cygwin*)
            echo "gitbash"
            ;;
        *)
            echo "unknown"
            ;;
    esac
}

detect_distro() {
    # Only for Linux
    if [[ -f /etc/os-release ]]; then
        # shellcheck source=/dev/null
        source /etc/os-release
        echo "$ID"
    elif [[ -f /etc/redhat-release ]]; then
        echo "rhel"
    elif [[ -f /etc/debian_version ]]; then
        echo "debian"
    else
        echo "unknown"
    fi
}

detect_container() {
    if [[ -f /.dockerenv ]]; then
        echo "docker"
    elif grep -q docker /proc/1/cgroup 2>/dev/null; then
        echo "docker"
    elif [[ -n "$KUBERNETES_SERVICE_HOST" ]]; then
        echo "kubernetes"
    else
        echo "none"
    fi
}

# Usage
OS=$(detect_os)
DISTRO=$(detect_distro)
CONTAINER=$(detect_container)

echo "OS: $OS"
echo "Distro: $DISTRO"
echo "Container: $CONTAINER"

Environment Variables for Detection

# Check various environment indicators
check_environment() {
    echo "OSTYPE: $OSTYPE"
    echo "MACHTYPE: $MACHTYPE"
    echo "HOSTTYPE: $HOSTTYPE"

    # Kernel info
    uname -s    # Operating system name
    uname -r    # Kernel release
    uname -m    # Machine hardware
    uname -p    # Processor type

    # More detailed
    uname -a    # All information
}

# Platform-specific variables
# Linux:   OSTYPE=linux-gnu
# macOS:   OSTYPE=darwin20.0
# Git Bash: OSTYPE=msys
# Cygwin:  OSTYPE=cygwin
# WSL:     OSTYPE=linux-gnu (but with Microsoft in /proc/version)

Linux Specifics

Linux-Only Features

# /proc filesystem
get_process_info() {
    local pid=$1

    if [[ -d "/proc/$pid" ]]; then
        echo "Command: $(cat /proc/$pid/cmdline | tr '\0' ' ')"
        echo "Working dir: $(readlink /proc/$pid/cwd)"
        echo "Executable: $(readlink /proc/$pid/exe)"
    fi
}

# systemd
check_systemd() {
    if command -v systemctl &> /dev/null; then
        systemctl status my-service
        systemctl is-active my-service
        systemctl is-enabled my-service
    fi
}

# cgroups
check_cgroups() {
    if [[ -d /sys/fs/cgroup ]]; then
        cat /sys/fs/cgroup/memory/memory.limit_in_bytes
    fi
}

# inotify for file watching
watch_directory() {
    if command -v inotifywait &> /dev/null; then
        inotifywait -m -r -e modify,create,delete /path/to/watch
    fi
}

Distribution-Specific Commands

# Package management
install_package() {
    local package=$1

    if command -v apt-get &> /dev/null; then
        # Debian/Ubuntu
        sudo apt-get update
        sudo apt-get install -y "$package"
    elif command -v yum &> /dev/null; then
        # RHEL/CentOS
        sudo yum install -y "$package"
    elif command -v dnf &> /dev/null; then
        # Fedora
        sudo dnf install -y "$package"
    elif command -v pacman &> /dev/null; then
        # Arch
        sudo pacman -S --noconfirm "$package"
    elif command -v zypper &> /dev/null; then
        # openSUSE
        sudo zypper install -y "$package"
    elif command -v apk &> /dev/null; then
        # Alpine
        sudo apk add "$package"
    else
        echo "Error: No supported package manager found" >&2
        return 1
    fi
}

# Service management
manage_service() {
    local action=$1
    local service=$2

    if command -v systemctl &> /dev/null; then
        # systemd (most modern distros)
        sudo systemctl "$action" "$service"
    elif command -v service &> /dev/null; then
        # SysV init
        sudo service "$service" "$action"
    else
        echo "Error: No supported service manager found" >&2
        return 1
    fi
}

GNU Coreutils (Linux Standard)

# GNU-specific features
# These work on Linux but may not work on macOS/BSD

# sed with -i (in-place editing)
sed -i 's/old/new/g' file.txt         # Linux
sed -i '' 's/old/new/g' file.txt      # macOS requires empty string

# date with flexible parsing
date -d "yesterday" +%Y-%m-%d         # Linux
date -v-1d +%Y-%m-%d                  # macOS

# stat with -c format
stat -c "%s" file.txt                 # Linux (file size)
stat -f "%z" file.txt                 # macOS

# readlink with -f (canonicalize)
readlink -f /path/to/file             # Linux
# macOS doesn't have -f, use greadlink or:
python -c "import os; print(os.path.realpath('$file'))"

# GNU find with -printf
find . -type f -printf "%p %s\n"      # Linux
find . -type f -exec stat -f "%N %z" {} \;  # macOS

macOS Specifics

BSD vs GNU Commands

# Detect and use GNU versions if available
setup_commands_macos() {
    # Install GNU commands: brew install coreutils gnu-sed gnu-tar findutils
    if command -v gsed &> /dev/null; then
        SED=gsed
    else
        SED=sed
    fi

    if command -v ggrep &> /dev/null; then
        GREP=ggrep
    else
        GREP=grep
    fi

    if command -v greadlink &> /dev/null; then
        READLINK=greadlink
    else
        READLINK=readlink
    fi

    if command -v gdate &> /dev/null; then
        DATE=gdate
    else
        DATE=date
    fi

    if command -v gstat &> /dev/null; then
        STAT=gstat
    else
        STAT=stat
    fi

    export SED GREP READLINK DATE STAT
}

# Usage
setup_commands_macos
$SED -i 's/old/new/g' file.txt  # Works on both platforms

macOS-Specific Features

# macOS filesystem (case-insensitive by default on APFS/HFS+)
check_case_sensitivity() {
    touch /tmp/test_case
    if [[ -f /tmp/TEST_CASE ]]; then
        echo "Filesystem is case-insensitive"
    else
        echo "Filesystem is case-sensitive"
    fi
    rm -f /tmp/test_case /tmp/TEST_CASE
}

# macOS extended attributes
# Set extended attribute
xattr -w com.example.myattr "value" file.txt

# Get extended attribute
xattr -p com.example.myattr file.txt

# List all extended attributes
xattr -l file.txt

# Remove extended attribute
xattr -d com.example.myattr file.txt

# macOS Spotlight
# Disable indexing for directory
mdutil -i off /path/to/directory

# Search with mdfind (Spotlight from command line)
mdfind "kMDItemFSName == 'filename.txt'"

# macOS clipboard
# Copy to clipboard
echo "text" | pbcopy

# Paste from clipboard
pbpaste

# macOS notifications
# Display notification
osascript -e 'display notification "Build complete" with title "Build Status"'

# macOS open command
# Open file with default application
open file.pdf

# Open URL
open https://example.com

# Open current directory in Finder
open .

Homebrew Package Management

# Check if Homebrew is installed
if command -v brew &> /dev/null; then
    # Install package
    brew install package-name

    # Update Homebrew
    brew update

    # Upgrade packages
    brew upgrade

    # Search for package
    brew search package-name

    # Get package info
    brew info package-name
fi

Windows (Git Bash)

Git Bash Environment

# Git Bash uses MSYS2 runtime
# Provides Unix-like environment on Windows

# Path handling
convert_path() {
    local path=$1

    if command -v cygpath &> /dev/null; then
        # Convert Unix path to Windows
        windows_path=$(cygpath -w "$path")
        echo "$windows_path"

        # Convert Windows path to Unix
        unix_path=$(cygpath -u "C:\\Users\\user\\file.txt")
        echo "$unix_path"
    else
        # Manual conversion (Git Bash)
        # /c/Users/user → C:\Users\user
        echo "${path//\//\\}" | sed 's/^\\//'
    fi
}

# Git Bash path conventions
# C:\Users\user → /c/Users/user
# D:\data → /d/data

# Home directory
echo "$HOME"           # /c/Users/username
echo "$USERPROFILE"    # Windows-style path

# Temp directory
echo "$TEMP"           # Windows temp
echo "$TMP"            # Windows temp
echo "/tmp"            # Git Bash temp (usually C:\Users\username\AppData\Local\Temp)

Limited Features in Git Bash

# Features NOT available in Git Bash:

# 1. No systemd
# Use Windows services instead:
# sc query ServiceName
# net start ServiceName

# 2. Limited signal support
# SIGTERM works, but some signals behave differently

# 3. No /proc filesystem
# Use wmic or PowerShell:
# wmic process get processid,commandline

# 4. Process handling differences
# ps command is available but limited
ps -W  # Show Windows processes

# 5. File permissions are simulated
# chmod works but doesn't map directly to Windows ACLs

# 6. Symbolic links require administrator privileges
# Or Developer Mode enabled in Windows 10+

Windows-Specific Workarounds

# Run PowerShell commands from Git Bash
run_powershell() {
    local command=$1
    powershell.exe -Command "$command"
}

# Example: Get Windows version
run_powershell "Get-ComputerInfo | Select-Object WindowsVersion"

# Run cmd.exe commands
run_cmd() {
    local command=$1
    cmd.exe /c "$command"
}

# Example: Set Windows environment variable
run_cmd "setx MY_VAR value"

# Check if running with admin privileges
is_admin() {
    net session &> /dev/null
    return $?
}

if is_admin; then
    echo "Running with administrator privileges"
else
    echo "Not running as administrator"
fi

# Windows line endings (CRLF vs LF)
fix_line_endings() {
    local file=$1

    # Convert CRLF to LF
    dos2unix "$file"

    # Or with sed
    sed -i 's/\r$//' "$file"

    # Convert LF to CRLF
    unix2dos "$file"

    # Or with sed
    sed -i 's/$/\r/' "$file"
}

Git Bash Best Practices

# Always handle spaces in Windows paths
process_file() {
    local file="$1"  # Always quote!

    # Windows paths often have spaces
    # C:\Program Files\...
}

# Use forward slashes when possible
cd /c/Program\ Files/Git  # Works
cd "C:\Program Files\Git" # Also works, but...
cd C:\\Program\ Files\\Git # Avoid

# Set Git config for line endings
git config --global core.autocrlf true  # Windows
git config --global core.autocrlf input # Linux/macOS

# Check Git Bash version
bash --version
uname -a  # Shows MINGW or MSYS

Windows (WSL)

WSL1 vs WSL2

# Detect WSL version
detect_wsl_version() {
    if grep -qi microsoft /proc/version; then
        if [[ $(uname -r) =~ microsoft ]]; then
            echo "WSL 1"
        elif [[ $(uname -r) =~ WSL2 ]]; then
            echo "WSL 2"
        else
            # Check kernel version
            if [[ $(uname -r) =~ ^4\. ]]; then
                echo "WSL 1"
            else
                echo "WSL 2"
            fi
        fi
    else
        echo "Not WSL"
    fi
}

# WSL1 limitations:
# - No full syscall compatibility
# - File I/O slower on Windows filesystem
# - No Docker/containers (needs WSL2)

# WSL2 improvements:
# - Full Linux kernel
# - Better filesystem performance
# - Docker/container support
# - Near-native Linux performance

Windows Filesystem Access

# Access Windows drives from WSL
# Mounted at /mnt/c, /mnt/d, etc.

# List Windows drives
ls /mnt/

# Access Windows user directory
WINDOWS_HOME="/mnt/c/Users/$USER"
cd "$WINDOWS_HOME"

# File permissions on Windows filesystem
# Files on /mnt/c are owned by root but accessible
# Permissions are simulated

# Best practice: Use WSL filesystem for Linux files
# Use /home/username, not /mnt/c/...
# Much faster, especially in WSL1

WSL Interoperability

# Run Windows executables from WSL
# .exe files are automatically executable

# Run Windows commands
cmd.exe /c dir
notepad.exe file.txt
explorer.exe .  # Open current directory in Windows Explorer

# Run PowerShell
powershell.exe -Command "Get-Date"

# Pipe between Linux and Windows
cat file.txt | clip.exe  # Copy to Windows clipboard

# Environment variables
# Windows environment is accessible with WSLENV

# Share environment variable from Windows to WSL
# In PowerShell:
# $env:WSLENV = "MYVAR/p"
# This converts Windows paths to WSL paths

WSL-Specific Configuration

# /etc/wsl.conf configuration
cat > /etc/wsl.conf << 'EOF'
[automount]
enabled = true
root = /mnt/
options = "metadata,umask=22,fmask=11"

[network]
generateHosts = true
generateResolvConf = true

[interop]
enabled = true
appendWindowsPath = true
EOF

# Apply: wsl.exe --shutdown (from PowerShell)

# Network differences
# WSL1: Shares network with Windows
# WSL2: NAT network, different IP

# Get WSL IP address
ip addr show eth0 | grep -oP '(?<=inet\s)\d+(\.\d+){3}'

# Access Windows services from WSL2
# Use Windows IP, not localhost
# Or use: localhost (WSL2 has localhost forwarding)

Container Environments

Docker Considerations

# Minimal base images often lack bash
# alpine: Only has /bin/sh by default
# debian:slim: Has bash
# ubuntu: Has bash

# Check if bash is available
if [ -f /bin/bash ]; then
    exec /bin/bash "$@"
else
    exec /bin/sh "$@"
fi

# Container detection
is_docker() {
    if [[ -f /.dockerenv ]] || grep -q docker /proc/1/cgroup 2>/dev/null; then
        return 0
    else
        return 1
    fi
}

# PID 1 problem in containers
# Your script might be PID 1, which means:
# - Zombie process reaping is your responsibility
# - Signals behave differently

# Solution: Use tini or dumb-init
# Or handle signals explicitly
handle_sigterm() {
    # Forward to child processes
    kill -TERM "$child_pid" 2>/dev/null
    wait "$child_pid"
    exit 0
}

trap handle_sigterm SIGTERM

# Start main process
main_process &
child_pid=$!
wait "$child_pid"

Kubernetes Considerations

# Kubernetes-specific environment variables
if [[ -n "$KUBERNETES_SERVICE_HOST" ]]; then
    echo "Running in Kubernetes"

    # Access Kubernetes API
    KUBE_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
    KUBE_CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt

    # Get pod name
    POD_NAME=${POD_NAME:-$(hostname)}

    # Get namespace
    NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
fi

# Health checks
# Kubernetes expects:
# - HTTP probe on specific port
# - Or command that exits 0 for success

# Liveness probe handler
handle_health_check() {
    # Check if application is healthy
    if check_health; then
        exit 0
    else
        exit 1
    fi
}

# Readiness probe handler
handle_readiness_check() {
    # Check if ready to serve traffic
    if is_ready; then
        exit 0
    else
        exit 1
    fi
}

# Graceful shutdown for rolling updates
# Kubernetes sends SIGTERM, waits (default 30s), then SIGKILL
trap 'graceful_shutdown' SIGTERM

graceful_shutdown() {
    echo "Received SIGTERM, shutting down gracefully..."

    # Stop accepting new connections
    # Finish processing existing requests
    # Close connections
    # Exit

    exit 0
}

Container Best Practices

# Don't assume specific users/groups exist
# Many containers run as non-root or random UID

# Check current user
if [[ $EUID -eq 0 ]]; then
    echo "Running as root"
else
    echo "Running as user $EUID"
fi

# Handle arbitrary UIDs (OpenShift)
# Files in mounted volumes may not be owned by container user
# Solution: Add current user to group, use group permissions

# Minimal dependencies
# Container images should be small
# Don't install unnecessary packages

# Use absolute paths or set PATH explicitly
export PATH=/usr/local/bin:/usr/bin:/bin

# Environment variables for configuration
# Don't hardcode values, use env vars
DATABASE_URL=${DATABASE_URL:-postgres://localhost/db}

# Logging to stdout/stderr
# Container orchestrators capture these
echo "Log message"       # To stdout
echo "Error message" >&2 # To stderr

# Don't write to filesystem (except for tmpfs)
# Containers are ephemeral
# Use volumes for persistent data

Cross-Platform Patterns

Portable Command Wrapper

# Create wrappers for platform-specific commands
setup_portable_commands() {
    local os
    os=$(detect_os)

    case "$os" in
        linux)
            SED=sed
            READLINK="readlink -f"
            DATE=date
            STAT="stat -c"
            GREP=grep
            ;;
        macos)
            # Prefer GNU versions if available
            SED=$(command -v gsed || echo sed)
            READLINK=$(command -v greadlink || echo "echo")  # No -f on BSD
            DATE=$(command -v gdate || echo date)
            STAT=$(command -v gstat || echo stat)
            GREP=$(command -v ggrep || echo grep)
            ;;
        gitbash)
            SED=sed
            READLINK=readlink  # Git Bash has GNU tools
            DATE=date
            STAT=stat
            GREP=grep
            ;;
    esac

    export SED READLINK DATE STAT GREP
}

# Use the wrappers
setup_portable_commands
$SED -i 's/old/new/g' file.txt

Cross-Platform Temp Files

# Portable temporary file creation
create_temp_file() {
    # Works on all platforms
    local temp_file
    temp_file=$(mktemp) || {
        # Fallback if mktemp doesn't exist
        temp_file="/tmp/script.$$.$RANDOM"
        touch "$temp_file"
    }

    echo "$temp_file"
}

# Portable temporary directory
create_temp_dir() {
    local temp_dir
    temp_dir=$(mktemp -d) || {
        # Fallback
        temp_dir="/tmp/script.$$.$RANDOM"
        mkdir -p "$temp_dir"
    }

    echo "$temp_dir"
}

# Clean up temp files on exit
TEMP_DIR=$(create_temp_dir)
trap 'rm -rf "$TEMP_DIR"' EXIT

Cross-Platform File Paths

# Normalize paths across platforms
normalize_path() {
    local path="$1"

    # Remove trailing slashes
    path="${path%/}"

    # Convert backslashes to forward slashes (Windows)
    path="${path//\\//}"

    # Resolve . and ..
    # Use Python for reliable normalization
    if command -v python3 &> /dev/null; then
        path=$(python3 -c "import os; print(os.path.normpath('$path'))")
    elif command -v python &> /dev/null; then
        path=$(python -c "import os; print(os.path.normpath('$path'))")
    fi

    echo "$path"
}

# Get absolute path (cross-platform)
get_absolute_path() {
    local path="$1"

    # Try readlink -f (Linux, Git Bash)
    if readlink -f "$path" &> /dev/null; then
        readlink -f "$path"
    # Try realpath (most platforms)
    elif command -v realpath &> /dev/null; then
        realpath "$path"
    # Fallback to Python
    elif command -v python3 &> /dev/null; then
        python3 -c "import os; print(os.path.abspath('$path'))"
    # Fallback to cd
    elif [[ -d "$path" ]]; then
        (cd "$path" && pwd)
    else
        (cd "$(dirname "$path")" && echo "$(pwd)/$(basename "$path")")
    fi
}

Cross-Platform Process Management

# Find process by name (cross-platform)
find_process() {
    local process_name="$1"

    if command -v pgrep &> /dev/null; then
        pgrep -f "$process_name"
    else
        ps aux | grep "$process_name" | grep -v grep | awk '{print $2}'
    fi
}

# Kill process by name (cross-platform)
kill_process() {
    local process_name="$1"

    if command -v pkill &> /dev/null; then
        pkill -f "$process_name"
    else
        local pids
        pids=$(find_process "$process_name")
        if [[ -n "$pids" ]]; then
            kill $pids
        fi
    fi
}

Command Compatibility Matrix

Command Linux macOS Git Bash Notes
sed -i ✓* macOS needs sed -i ''
date -d macOS uses -v
readlink -f macOS needs greadlink
stat -c macOS uses -f
grep -P macOS doesn't support PCRE
find -printf macOS doesn't have -printf
xargs -r macOS doesn't have -r
ps aux ✓* Git Bash has limited output
ls --color macOS uses -G
du -b macOS doesn't support bytes
mktemp Works on all platforms
timeout macOS needs gtimeout

Legend:

  • ✓ = Supported
  • ✗ = Not supported
  • ✓* = Supported with limitations

Testing Across Platforms

# Test script on multiple platforms
test_platforms() {
    local script="$1"

    echo "Testing on current platform: $(detect_os)"
    bash -n "$script" || {
        echo "Syntax error!"
        return 1
    }

    # Run ShellCheck
    if command -v shellcheck &> /dev/null; then
        shellcheck "$script" || return 1
    fi

    # Run the script
    bash "$script" || return 1

    echo "Tests passed on $(detect_os)"
}

# CI/CD matrix testing
# Use GitHub Actions, GitLab CI, etc. to test on multiple platforms

Example GitHub Actions matrix:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/checkout@v3
      - name: Test script
        run: bash test.sh

Summary

Key takeaways for cross-platform bash scripts:

  1. Always detect the platform before using platform-specific features
  2. Use portable commands or provide fallbacks
  3. Test on all target platforms (CI/CD with matrix builds)
  4. Avoid platform-specific assumptions (file paths, users, services)
  5. Use ShellCheck to catch portability issues
  6. Prefer POSIX compliance when possible for maximum portability
  7. Document platform requirements in script comments
  8. Provide GNU alternatives on macOS when needed
  9. Handle path differences carefully (especially Windows)
  10. Test in containers if that's your deployment target

For maximum portability: stick to POSIX shell (#!/bin/sh) and avoid bashisms unless you control the deployment environment.