Challenges Managing DB Migrations Across Branches

If you’re anything like me, your daily life as a software engineer leads to many different tasks. Some of these involve writing your own code, debugging an issue on production, reviewing a teammate’s pull request, and the list goes on and on. This can lead to having to context switch throughout the day which can end up causing your database to get into some interesting states as you navigate around branches and their related migrations.

Working with branches that have intermixed migrations can cause a headache. How many times have you run a rails db:migrate:status command to find a migration file is missing on the branch you’re currently on? You might see something like the following:

up     20220510165510  Adding a new table
up     20220512203644  Removing a column
up     20220516145812  ********** NO FILE **********
up     20220516173053  Adding another new table
up     20220520200857  ********** NO FILE **********

This scenario is problematic when you attempt to run a rails db:rollback if you’re attempting to remove the new table that was added on your current branch, but there’s a migration blocking you from another branch. Attempting to run would present you with the following:

rake aborted!
ActiveRecord::UnknownMigrationVersionError:

No migration with version number 20220520200857.


Tasks: TOP => db:rollback
(See full trace by running task with --trace)

You could always take the version number that comes from you rails db:migrate:status command and roll back the change in a very specific way, via a rails db:migrate:down VERSION=20220516173053. What happens, though, if you’re trying to roll back multiple migrations? If we didn’t have the blocking migration, for example, we could have simply done a rails db:rollback STEP=3. With this blocking migration we would have to run the rails db:migrate:down command multiple times for each version we’re looking to roll back.

This can be slow and painful if you’ve got a lot of migrations to deal with and/or you’re doing a lot of context switching. How can this be better?

Branch Based Task for Rollbacks

As any engineer would do, I thought to myself that “there has to be a better way” and set out to write a better “rollback” command. This has gone through a few different iterations as I’ve included this in a few different Rails apps over the years, but my current happy iteration is the following. This code can be added to a rails app in lib/tasks/db.rake and can be run via rails db:rollback:branch.

BASE_BRANCH_NAME = "main"

namespace :db do

  namespace :rollback do
    desc 'Runs a "db:migrate:down VERSION=xx" for all migrations that exist on a branch and do not exist on the #{BASE_BRANCH_NAME} branch'
    task branch: :environment do
      # Allow the user to pass in a true, 1, etc. to only display the migrations that would be
      #   rolled back.  Excluding this env var or otherwise setting to false will actually
      #   perform the rollback
      do_rollback = ActiveRecord::Type::Boolean.new.cast(ENV.fetch("DRY_RUN", false))

      committed_migrations = `git diff --name-only --diff-filter=A #{BASE_BRANCH_NAME} $(git rev-parse --abbrev-ref HEAD) | grep db/migrate`
      current_migrations   = `git status | grep db/migrate`

      added_migrations = (current_migrations.split("\n") + committed_migrations.split("\n"))

      added_migrations.map! { |file| File.basename(file).split("_").first } unless do_rollback

      added_migrations = added_migrations
        .sort
        .reverse
        .uniq

      added_migrations.each do |m|
        if do_rollback
          puts m
        else
          ENV['VERSION'] = m
          Rake::Task['db:migrate:down'].execute
        end
      end
    end
  end

end