One of the really annoying things about working on a Rails project with other developers is the inevitable conflicts with migration numbering. We recently switched to Git for version control on Shopify, and we all really love the ease of branching and merging to our hearts’ content. Here is a snippet of my workflow today:

  1. Edit in my local “shipping” branch, adding some functionality to Shopify’s shipping system.
  2. Commit a simple migration which adds a column that’s necessary for the aforementioned functionality.
  3. Switch to my master branch and pull from our central repository to get the latest stuff.
  4. Notice that Cody has added some crazy migration with an SQL query that I don’t really want to understand.

Now, at this point, my usual required course of action would be to switch back to my shipping branch, migrate down, rename my migration (thankfully there was just one this time), then git-rebase onto the updated master branch and migrate back up again. This happens fairly often, and it makes the world a darker place.

So I wrote this script. Save it as .git/hooks/pre-rebase in your git repository and make it executable. Now whenever you rebase one of your local development branches it will detect potential migration conflicts with the target of the rebase and do most of the dirty work for you: it migrates down, renames your branch’s migrations so they’ll be at the end of the line, generates a new commit for you with the new renaming, then lets the rebase proceed.

It prompts before it does the renaming or committing, so if you really, really want to have “23_foo.rb” and “23_bar.rb” after your rebase, then all the power to you. The prompts also give you a chance to ctrl-C out of the whole thing if you realize that the migrations will need more tweaking.

#!/usr/bin/env ruby
# Save to .git/hooks/pre-rebase in your rails project and chmod +x
# 
# Checks for migration numbering conflicts and sorts them out automagically.
# Intended for use in multi-dev environments where you have a local branch for
# your own development that you rebase every so often onto an updated (i.e.
# freshly pulled) master branch. Conflicts will still come up, but this covers
# 90%+ of cases.

root_dir = File.expand_path(File.join(File.dirname(__FILE__), "..", ".."))
orig_dir = `pwd`.chomp

`cd #{root_dir}`

# uses parameters passed by git-rebase
upstream = ARGV[0]
subject = ARGV[1] || "HEAD" 
migration_changes = `git diff #{subject} #{upstream} --summary | grep db/migrate`
migration_ary= migration_changes.split(/\n/)

# not really deletions in terms of the rebase; just named so by the diff output
# really these "deletions" are just the current branch's migrations that don't exist
# in the target of the rebase
deletions = migration_ary.grep(/^\s*delete/)
other_changes = migration_ary - deletions

# filenames of any migrations affected in one way or another by the rebase
migration_files = migration_ary.map {|line| line.split.last }

version_pattern = /^db\/migrate\/(\d+)/

branch_migration_files = deletions.map {|line| line.split.last }.sort
deletion_versions = branch_migration_files.map {|file| file.match(version_pattern)[1].to_i }.sort
other_changes_versions = other_changes.map {|line| line.split.last.match(version_pattern)[1].to_i }.sort

# detect duplicate versions by looking at intersection of arrays
conflicts = deletion_versions & other_changes_versions
conflict_files = migration_files.select {|f| conflicts.include? f.match(version_pattern)[1].to_i }

if conflicts.empty?
  puts "\nNo migration conflicts detected." 
else
  puts "\n*** Migration conflicts detected" 
  puts "\nLocal branch migrations not present in rebase target:" 
  puts(deletions.map {|line| line.gsub(/^.*(db\/migrate)/,      ' \1')}.join("\n") + "\n")
  puts "\nMigrations in rebase target to be added or modified:" 
  puts(other_changes.map {|line| line.gsub(/^.*(db\/migrate)/,  ' \1')}.join("\n") + "\n")

  print "\nFix by migrating down and renaming branch migrations? (Y/n) " 
  response = STDIN.readline.chomp.strip
  if response == '' or response =~ /^[Yy]/

    # migrate down
    puts (cmd = "rake db:migrate VERSION=#{(migration_files.map {|file| file.match(version_pattern)[1].to_i }).min - 1}")
    puts `#{cmd}`
    last_version = other_changes_versions.max

    # move each migration unique to the current branch to the end of the migration list
    branch_migration_files.each do |file|
      last_version += 1
      `git mv #{file} #{new_name}`
      puts "\nmoved #{file}" 
      puts "to    #{new_name}" 
    end

    print "Generate a new commit? (Y/n) " 
    response = STDIN.readline.chomp.strip
    if response == '' or response =~ /^[Yy]/
      puts(cmd = "git commit -a -m \"Renamed migrations\"")
      puts `#{cmd}`
    end

    puts "\n*** Be sure to run rake db:migrate after rebase has completed.\n" 
  else
    puts "Okay, not doing anything then." 
  end
end

`cd #{orig_dir}`

exit 0

Leave a Reply