Skip to content

Instantly share code, notes, and snippets.

@dlnilsson
Last active September 12, 2025 09:34
Show Gist options
  • Select an option

  • Save dlnilsson/32283eb658958ce4dbb0bcf6365a114c to your computer and use it in GitHub Desktop.

Select an option

Save dlnilsson/32283eb658958ce4dbb0bcf6365a114c to your computer and use it in GitHub Desktop.
refresh migrations, used with github.com/golang-migrate/migrate
#!/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