Last active
September 12, 2025 09:34
-
-
Save dlnilsson/32283eb658958ce4dbb0bcf6365a114c to your computer and use it in GitHub Desktop.
refresh migrations, used with github.com/golang-migrate/migrate
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 | |
| set -euo pipefail | |
| DIR="migrations" | |
| error() { echo -e "\033[31m[ERROR]\033[0m $*" >&2; exit 1; } | |
| info() { echo -e "\033[32m[INFO]\033[0m $*"; } | |
| warn() { echo -e "\033[33m[WARN]\033[0m $*"; } | |
| command -v git >/dev/null 2>&1 || error "git is required but not installed." | |
| command -v fzf >/dev/null 2>&1 || error "fzf is required but not installed." | |
| command -v bat >/dev/null 2>&1 || PREVIEW_CMD="cat {}" | |
| PREVIEW_CMD=${PREVIEW_CMD:-"bat --style=plain --color=always {} || cat {}"} | |
| [ -d "$DIR" ] || error "Directory '$DIR' does not exist." | |
| git rev-parse --is-inside-work-tree >/dev/null 2>&1 || error "Not inside a git repository." | |
| ensure_clean_staging() { | |
| local staged non_migrations | |
| non_migrations=$(git diff --cached --name-only -- . ":(exclude)${DIR%/}/**" 2>/dev/null || true) | |
| if [ -z "${non_migrations}" ]; then | |
| staged=$(git diff --cached --name-only 2>/dev/null || true) | |
| if [ -n "${staged}" ]; then | |
| non_migrations=$(printf '%s\n' "$staged" | grep -v -E "^${DIR%/}/" || true) | |
| fi | |
| fi | |
| if [ -n "${non_migrations}" ]; then | |
| echo "Staged changes outside '$DIR':" | |
| printf ' - %s\n' "$non_migrations" | |
| error "You already have staged changes outside '$DIR'. Commit or unstage them first." | |
| fi | |
| } | |
| check_duplicate_numbers() { | |
| local conflicts | |
| conflicts=$(find "$DIR" -type f -name "*.sql" \ | |
| | awk -F/ '{print $NF}' \ | |
| | awk -v RS='' ' | |
| function base(x){ | |
| sub(/^[0-9]+_/,"",x) | |
| sub(/\.(up|down)\.sql$/,"",x) | |
| sub(/_down$/,"",x) | |
| sub(/_up$/,"",x) | |
| return x | |
| } | |
| { | |
| if (match($0,/^([0-9]+)_/,m)) { | |
| n=m[1]; b=base($0) | |
| if (!(b in seen[n])) { | |
| seen[n][b]=1 | |
| counts[n]++ | |
| basenames[n]=(basenames[n] ? basenames[n] "|" b : b) | |
| } | |
| } | |
| } | |
| END { | |
| for (n in counts) { | |
| if (counts[n] > 2) { | |
| print "HARD|" n "|" basenames[n] | |
| } else if (counts[n] == 2) { | |
| print "FUZZY|" n "|" basenames[n] | |
| } | |
| } | |
| }') | |
| if [ -n "$conflicts" ]; then | |
| echo | |
| while IFS= read -r line; do | |
| [ -z "$line" ] && continue | |
| kind=$(echo "$line" | cut -d'|' -f1) | |
| num=$(echo "$line" | cut -d'|' -f2) | |
| bases=$(echo "$line" | cut -d'|' -f3-) | |
| if [ "$kind" = "HARD" ]; then | |
| echo -e "\033[31m[ERROR]\033[0m Number $num used by multiple distinct basenames:" | |
| IFS='|' read -ra arr <<< "$bases" | |
| for b in "${arr[@]}"; do | |
| find "$DIR" -type f -name "${num}_${b}*.sql" -printf " %P\n" | |
| done | |
| return 1 | |
| elif [ "$kind" = "FUZZY" ]; then | |
| echo -e "\033[33m[WARN]\033[0m Number $num may contain a typo (two similar basenames):" | |
| IFS='|' read -ra arr <<< "$bases" | |
| for b in "${arr[@]}"; do | |
| find "$DIR" -type f -name "${num}_${b}*.sql" -printf " %P\n" | |
| done | |
| fi | |
| done <<< "$conflicts" | |
| fi | |
| return 0 | |
| } | |
| ensure_clean_staging | |
| check_duplicate_numbers || error "Resolve duplicates before continuing." | |
| if [ -n "$(git status --porcelain --untracked-files=no)" ]; then | |
| warn "You have unstaged changes." | |
| read -rp "Continue anyway? [y/N]: " ans | |
| [[ "$ans" =~ ^[Yy]$ ]] || exit 1 | |
| fi | |
| SELECTED_UP_FILE=$(find "$DIR" -type f -name "*.up.sql" | sort -r | \ | |
| fzf --prompt="Select a migration to refresh number: " \ | |
| --preview="$PREVIEW_CMD") | |
| [ -z "$SELECTED_UP_FILE" ] && { warn "No file selected."; exit 0; } | |
| LAST_NUM=$(find "$DIR" -type f -name "*.up.sql" \ | |
| | sed -E 's#.*/([0-9]+)_.*#\1#' \ | |
| | sort -n \ | |
| | tail -n1) | |
| if [ -z "$LAST_NUM" ]; then | |
| NEXT_NUM="000001" | |
| else | |
| NEXT_DEC=$((10#$LAST_NUM + 1)) | |
| NEXT_NUM=$(printf "%06d" "$NEXT_DEC") | |
| fi | |
| BASENAME=$(basename "$SELECTED_UP_FILE" | sed -E 's/^[0-9]+_//;s/.up.sql$//') | |
| UP_FILE="$SELECTED_UP_FILE" | |
| NEW_UP_FILE="$DIR/${NEXT_NUM}_${BASENAME}.up.sql" | |
| if [[ "${1:-}" == "--dry-run" ]]; then | |
| info "Dry-run mode:" | |
| echo " Would rename: $UP_FILE -> $NEW_UP_FILE" | |
| else | |
| git mv "$UP_FILE" "$NEW_UP_FILE" | |
| info "Renamed: $UP_FILE -> $NEW_UP_FILE" | |
| fi | |
| DOWN_FILE=$(find "$DIR" -type f -name "*_${BASENAME}.down.sql" | head -n1 || true) | |
| if [ -n "$DOWN_FILE" ]; then | |
| NEW_DOWN_FILE="$DIR/${NEXT_NUM}_${BASENAME}.down.sql" | |
| if [[ "${1:-}" == "--dry-run" ]]; then | |
| echo " Would rename: $DOWN_FILE -> $NEW_DOWN_FILE" | |
| else | |
| git mv "$DOWN_FILE" "$NEW_DOWN_FILE" | |
| info "Renamed: $DOWN_FILE -> $NEW_DOWN_FILE" | |
| fi | |
| else | |
| warn "No corresponding .down.sql file found for base name: $BASENAME" | |
| fi | |
| if ! check_duplicate_numbers; then | |
| warn "Rolling back renames due to conflicting numbers." | |
| git reset --hard HEAD || { | |
| warn "git reset failed, stashing instead." | |
| git stash push -k -u -m "rollback from refresh_migration" | |
| } | |
| error "Aborted due to conflicting migration numbers." | |
| fi | |
| if [[ "${1:-}" == "--dry-run" ]]; then | |
| info "Dry-run complete. No changes made." | |
| exit 0 | |
| fi | |
| git add "$DIR" | |
| ensure_clean_staging | |
| git status --short | |
| read -rp "Create commit with only migration changes? [y/N]: " confirm | |
| if [[ "$confirm" =~ ^[Yy]$ ]]; then | |
| git commit -m "migrations: bumped ${NEXT_NUM}_${BASENAME}" | |
| info "Commit created successfully!" | |
| else | |
| warn "Changes staged but not committed. Run 'git commit' manually if needed." | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment