diff --git a/bump-version.sh b/bump-version.sh index 4f7e4c3..59a1f69 100755 --- a/bump-version.sh +++ b/bump-version.sh @@ -1,8 +1,9 @@ #!/bin/bash # -# Version Bump Script — works with any project +# bump-version.sh — Universal version bump tool # -# Finds version strings in any file format and updates them together. +# Finds version strings in any project, shows what will change, +# asks for confirmation, and updates them. Supports undo. # # Supported patterns: # JSON: "version": "1.2.3" @@ -15,11 +16,13 @@ # setup.cfg: version = 1.2.3 # # Usage: -# ./bump-version.sh # interactive -# ./bump-version.sh 1.2.3 # set all to 1.2.3 -# ./bump-version.sh patch # bump patch: 0.9.0 → 0.9.1 -# ./bump-version.sh minor # bump minor: 0.9.0 → 0.10.0 -# ./bump-version.sh major # bump major: 0.9.0 → 1.0.0 +# ./bump-version.sh # interactive +# ./bump-version.sh patch # bump patch: 0.9.0 → 0.9.1 +# ./bump-version.sh minor # bump minor: 0.9.0 → 0.10.0 +# ./bump-version.sh major # bump major: 0.9.0 → 1.0.0 +# ./bump-version.sh 1.2.3 # set all to 1.2.3 +# ./bump-version.sh undo # revert last bump +# ./bump-version.sh --help # show help # set -e @@ -27,34 +30,76 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR" +UNDO_FILE="$SCRIPT_DIR/.version-bump-undo" + # Colors GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' RED='\033[0;31m' DIM='\033[2m' +BOLD='\033[1m' NC='\033[0m' # Directories to skip SKIP_DIRS="node_modules|dist|build|\.git|__pycache__|\.venv|venv|\.egg-info|vendor" -# File extensions to search -SEARCH_EXTS="json|toml|yaml|yml|py|php|sh|bash|html|js|css|cfg|ini|txt|md|xml|plist|gradle|gemspec|podspec|csproj|props" +# --- Help --- +show_help() { + cat << 'HELPEOF' +bump-version.sh — Universal version bump tool -# Find all files that might contain version strings +USAGE: + ./bump-version.sh Interactive mode + ./bump-version.sh patch Bump patch: 0.9.0 → 0.9.1 + ./bump-version.sh minor Bump minor: 0.9.0 → 0.10.0 + ./bump-version.sh major Bump major: 0.9.0 → 1.0.0 + ./bump-version.sh 1.2.3 Set to exact version + ./bump-version.sh undo Revert last version bump + ./bump-version.sh --help Show this help + +HOW IT WORKS: + 1. Scans all text files for version-like patterns + 2. Groups files by version number (the "version profile") + 3. Only updates files matching the most common version + 4. Shows a preview of all changes before applying + 5. Asks for confirmation (unless --yes flag is used) + 6. Saves undo information for easy rollback + +SUPPORTED PATTERNS: + "version": "1.2.3" JSON (package.json, manifest.json, etc.) + version = "1.2.3" TOML, Python (pyproject.toml, setup.py) + version: 1.2.3 YAML (helm charts, GitHub Actions) + __version__ = "1.2.3" Python packages + Version: 1.2.3 PHP/WordPress plugin/theme headers + VERSION="1.2.3" Shell scripts + v1.2.3 HTML footers, display strings + +SAFETY: + - Only updates files matching the current version (ignores unrelated X.Y.Z) + - Shows all changes before applying and asks for confirmation + - Saves undo info so you can revert with ./bump-version.sh undo + - Skips node_modules, dist, build, .git, __pycache__, venv, vendor + - Skips binary files and files over 500KB + - Skips itself (won't match examples in this help text) + +EXAMPLES: + ./bump-version.sh # shows versions, pick what to do + ./bump-version.sh patch # quick patch bump, still confirms + ./bump-version.sh 1.0.0 --yes # set to 1.0.0 without confirmation + ./bump-version.sh undo # oops, go back +HELPEOF + exit 0 +} + +# --- Find version files --- find_version_files() { - local files="" - - # Use find + grep for maximum compatibility while IFS= read -r file; do [ -z "$file" ] && continue - # Skip binary files and large files [ ! -f "$file" ] && continue local size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo 0) [ "$size" -gt 500000 ] && continue - - files="$files -$file" + echo "$file" done < <(find . -type f \( \ -name "*.json" -o -name "*.toml" -o -name "*.yaml" -o -name "*.yml" \ -o -name "*.py" -o -name "*.php" -o -name "*.sh" -o -name "*.bash" \ @@ -66,11 +111,11 @@ $file" -o -name "setup.py" -o -name "setup.cfg" -o -name "pyproject.toml" \ -o -name "package.json" -o -name "composer.json" -o -name "Gemfile" \ \) 2>/dev/null | grep -Ev "$SKIP_DIRS" | grep -v "bump-version") - - echo "$files" } -# Extract version from a file — tries all known patterns +# --- Extract version from file --- +# Only matches version DECLARATIONS, not arbitrary X.Y.Z in text. +# Each pattern requires a keyword context (version, VERSION, __version__, etc.) get_version_from_file() { local file="$1" local ver="" @@ -83,28 +128,24 @@ get_version_from_file() { ver=$(grep -oP '(?:__)?version\s*=\s*["\x27]\K[0-9]+\.[0-9]+\.[0-9]+' "$file" 2>/dev/null | head -1) [ -n "$ver" ] && echo "$ver" && return - # YAML: version: X.Y.Z or version: "X.Y.Z" + # YAML: version: X.Y.Z (must be at start of line) ver=$(grep -oP '^version:\s*["\x27]?\K[0-9]+\.[0-9]+\.[0-9]+' "$file" 2>/dev/null | head -1) [ -n "$ver" ] && echo "$ver" && return - # PHP/WordPress: Version: X.Y.Z (in comment header) + # PHP/WordPress: Version: X.Y.Z ver=$(grep -oP 'Version:\s*\K[0-9]+\.[0-9]+\.[0-9]+' "$file" 2>/dev/null | head -1) [ -n "$ver" ] && echo "$ver" && return - # Shell: VERSION="X.Y.Z" or VERSION='X.Y.Z' + # Shell: VERSION="X.Y.Z" ver=$(grep -oP 'VERSION\s*=\s*["\x27]?\K[0-9]+\.[0-9]+\.[0-9]+' "$file" 2>/dev/null | head -1) [ -n "$ver" ] && echo "$ver" && return - # HTML/display: " vX.Y.Z" or ">vX.Y.Z" + # HTML display: " vX.Y.Z" or ">vX.Y.Z" (requires v prefix — not bare numbers) ver=$(grep -oP '[ >]v\K[0-9]+\.[0-9]+\.[0-9]+' "$file" 2>/dev/null | head -1) [ -n "$ver" ] && echo "$ver" && return - - # XML/plist: X.Y.Z near version key - ver=$(grep -A1 -i 'version' "$file" 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1) - [ -n "$ver" ] && echo "$ver" && return } -# Get the most common version (the "current" version) +# --- Get current version (most common across files) --- get_current_version() { local versions="" while IFS= read -r file; do @@ -116,35 +157,93 @@ get_current_version() { echo "$versions" | tr ' ' '\n' | grep -v '^$' | sort | uniq -c | sort -rn | head -1 | awk '{print $2}' } -# Show all version occurrences +# --- Show all version occurrences grouped by version --- show_versions() { - echo -e "${CYAN}Files with version strings:${NC}" + local current="$1" + local files_list="" + + echo -e "${CYAN}Version profile:${NC}" echo "" + + # Collect all versions + declare -A version_files while IFS= read -r file; do [ -z "$file" ] && continue local ver=$(get_version_from_file "$file") if [ -n "$ver" ]; then local rel="${file#./}" - echo -e " ${GREEN}$ver${NC} $rel" + if [ -z "${version_files[$ver]+x}" ]; then + version_files[$ver]="$rel" + else + version_files[$ver]="${version_files[$ver]}|$rel" + fi fi done <<< "$(find_version_files)" + + # Show grouped by version + for ver in $(echo "${!version_files[@]}" | tr ' ' '\n' | sort -V); do + if [ "$ver" = "$current" ]; then + echo -e " ${GREEN}$ver${NC} ${BOLD}(current — will be updated)${NC}" + else + echo -e " ${DIM}$ver (different — will NOT be changed)${NC}" + fi + IFS='|' read -ra files <<< "${version_files[$ver]}" + for f in "${files[@]}"; do + if [ "$ver" = "$current" ]; then + echo -e " ${GREEN}✓${NC} $f" + else + echo -e " ${DIM}· $f${NC}" + fi + done + done echo "" } -# Replace version in all files +# --- Preview changes --- +preview_changes() { + local old="$1" + local new="$2" + + echo -e "${YELLOW}Changes to be made:${NC}" + echo "" + + local count=0 + while IFS= read -r file; do + [ -z "$file" ] && continue + local ver=$(get_version_from_file "$file") + if [ "$ver" = "$old" ]; then + local rel="${file#./}" + # Show the actual line that will change + local line=$(grep -n "$old" "$file" 2>/dev/null | head -1) + local linenum=$(echo "$line" | cut -d: -f1) + local content=$(echo "$line" | cut -d: -f2-) + local newcontent=$(echo "$content" | sed "s/$old/$new/g") + echo -e " ${BOLD}$rel${NC} (line $linenum)" + echo -e " ${RED}- $content${NC}" + echo -e " ${GREEN}+ $newcontent${NC}" + echo "" + count=$((count + 1)) + fi + done <<< "$(find_version_files)" + + echo -e " ${CYAN}$count file(s) will be modified${NC}" + echo "" +} + +# --- Apply changes --- replace_version() { local old="$1" local new="$2" local escaped_old=$(echo "$old" | sed 's/\./\\./g') - echo -e "${YELLOW}Replacing $old → $new${NC}" - echo "" + # Save undo info + echo "$new $old" > "$UNDO_FILE" + local undo_files="" while IFS= read -r file; do [ -z "$file" ] && continue local ver=$(get_version_from_file "$file") if [ "$ver" = "$old" ]; then - # Replace all known version patterns # JSON: "version": "X.Y.Z" sed -i "s/\(\"version\"\s*:\s*\"\)$escaped_old\"/\1$new\"/g" "$file" 2>/dev/null # TOML/Python: version = "X.Y.Z" or __version__ = "X.Y.Z" @@ -160,14 +259,57 @@ replace_version() { local rel="${file#./}" echo -e " ${GREEN}✓${NC} $rel" + undo_files="$undo_files $rel" fi done <<< "$(find_version_files)" + # Append file list to undo + echo "$undo_files" >> "$UNDO_FILE" + echo "" - echo -e "${GREEN}Done! All versions set to $new${NC}" + echo -e "${GREEN}Done! $old → $new${NC}" + echo -e "${DIM}Run ./bump-version.sh undo to revert${NC}" } -# Bump a version component +# --- Undo --- +do_undo() { + if [ ! -f "$UNDO_FILE" ]; then + echo -e "${RED}No undo information found. Nothing to revert.${NC}" + exit 1 + fi + + local versions=$(head -1 "$UNDO_FILE") + local current=$(echo "$versions" | awk '{print $1}') + local previous=$(echo "$versions" | awk '{print $2}') + + echo -e "${YELLOW}Undo: $current → $previous${NC}" + echo "" + + # Just do a version replace in the other direction + local escaped_current=$(echo "$current" | sed 's/\./\\./g') + + while IFS= read -r file; do + [ -z "$file" ] && continue + local ver=$(get_version_from_file "$file") + if [ "$ver" = "$current" ]; then + sed -i "s/\(\"version\"\s*:\s*\"\)$escaped_current\"/\1$previous\"/g" "$file" 2>/dev/null + sed -i "s/\(\(__\)\?version\s*=\s*[\"']\)$escaped_current/\1$previous/g" "$file" 2>/dev/null + sed -i "s/\(^version:\s*[\"']\?\)$escaped_current/\1$previous/g" "$file" 2>/dev/null + sed -i "s/\(Version:\s*\)$escaped_current/\1$previous/g" "$file" 2>/dev/null + sed -i "s/\(VERSION\s*=\s*[\"']\?\)$escaped_current/\1$previous/g" "$file" 2>/dev/null + sed -i "s/\([ >]v\)$escaped_current/\1$previous/g" "$file" 2>/dev/null + + local rel="${file#./}" + echo -e " ${GREEN}✓${NC} $rel" + fi + done <<< "$(find_version_files)" + + rm -f "$UNDO_FILE" + echo "" + echo -e "${GREEN}Reverted to $previous${NC}" +} + +# --- Bump version component --- bump_version() { local ver="$1" local part="$2" @@ -179,45 +321,94 @@ bump_version() { esac } +# --- Confirm --- +confirm() { + local msg="$1" + read -p "$msg [y/N] " answer + case "$answer" in + y|Y|yes|YES) return 0 ;; + *) return 1 ;; + esac +} + # --- Main --- +# Parse flags +YES_FLAG=false +for arg in "$@"; do + case "$arg" in + --help|-h) show_help ;; + --yes|-y) YES_FLAG=true ;; + undo) do_undo; exit 0 ;; + esac +done + +# Get first non-flag argument +ACTION="" +for arg in "$@"; do + case "$arg" in + --*|-*) continue ;; + undo) continue ;; + *) ACTION="$arg"; break ;; + esac +done + CURRENT=$(get_current_version) if [ -z "$CURRENT" ]; then - echo -e "${RED}No version strings found in the project.${NC}" + echo -e "${RED}No version declarations found in the project.${NC}" + echo -e "${DIM}Looking for patterns like \"version\": \"X.Y.Z\" or VERSION=\"X.Y.Z\"${NC}" + echo -e "${DIM}Run ./bump-version.sh --help for supported formats${NC}" exit 1 fi # Handle command-line argument -if [ -n "$1" ]; then - case "$1" in +if [ -n "$ACTION" ]; then + case "$ACTION" in patch|minor|major) - NEW=$(bump_version "$CURRENT" "$1") - show_versions - replace_version "$CURRENT" "$NEW" - exit 0 + NEW=$(bump_version "$CURRENT" "$ACTION") ;; [0-9]*) - show_versions - replace_version "$CURRENT" "$1" - exit 0 + if ! [[ "$ACTION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo -e "${RED}Invalid version format. Use X.Y.Z (e.g. 1.0.0)${NC}" + exit 1 + fi + NEW="$ACTION" ;; *) - echo "Usage: $0 [patch|minor|major|X.Y.Z]" + echo -e "${RED}Unknown action: $ACTION${NC}" + echo "Usage: $0 [patch|minor|major|X.Y.Z|undo|--help]" exit 1 ;; esac + + show_versions "$CURRENT" + preview_changes "$CURRENT" "$NEW" + + if [ "$YES_FLAG" = false ]; then + if ! confirm "Apply these changes?"; then + echo "Cancelled." + exit 0 + fi + fi + + replace_version "$CURRENT" "$NEW" + exit 0 fi # Interactive mode -show_versions +show_versions "$CURRENT" -echo -e "Current version: ${GREEN}$CURRENT${NC}" +echo -e "Current version: ${GREEN}${BOLD}$CURRENT${NC}" echo "" echo " [1] Bump patch: $CURRENT → $(bump_version "$CURRENT" patch)" echo " [2] Bump minor: $CURRENT → $(bump_version "$CURRENT" minor)" echo " [3] Bump major: $CURRENT → $(bump_version "$CURRENT" major)" echo " [4] Enter custom version" +if [ -f "$UNDO_FILE" ]; then + prev=$(head -1 "$UNDO_FILE" | awk '{print $2}') + echo " [u] Undo last bump (revert to $prev)" +fi echo " [q] Quit" echo "" read -p "Choice: " choice @@ -227,14 +418,23 @@ case "$choice" in 2) NEW=$(bump_version "$CURRENT" minor) ;; 3) NEW=$(bump_version "$CURRENT" major) ;; 4) - read -p "Enter version: " NEW + read -p "Enter version (X.Y.Z): " NEW if ! [[ "$NEW" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo -e "${RED}Invalid version format. Use X.Y.Z${NC}" exit 1 fi ;; + u|U) do_undo; exit 0 ;; q|Q|"") echo "Cancelled."; exit 0 ;; *) echo -e "${RED}Invalid choice.${NC}"; exit 1 ;; esac +echo "" +preview_changes "$CURRENT" "$NEW" + +if ! confirm "Apply these changes?"; then + echo "Cancelled." + exit 0 +fi + replace_version "$CURRENT" "$NEW"