├── .gitignore ├── Gemfile ├── MIT-LICENSE ├── README.markdown ├── Rakefile ├── bin └── hookup ├── hookup.gemspec └── lib └── hookup.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle 2 | /Gemfile.lock 3 | /pkg/* 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | gemspec 3 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Tim Pope 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | Hookup 2 | ====== 3 | 4 | Hookup takes care of Rails tedium like bundling and migrating through 5 | Git hooks. It fires after events like 6 | 7 | * pulling in upstream changes 8 | * switching branches 9 | * stepping through a bisect 10 | * conflict in schema 11 | 12 | Usage 13 | ----- 14 | 15 | gem install hookup 16 | cd yourproject 17 | hookup install 18 | 19 | ### Bundling 20 | 21 | Each time your current HEAD changes, hookup checks to see if your 22 | `Gemfile`, `Gemfile.lock`, or gem spec has changed. If so, it runs 23 | `bundle check`, and if that indicates any dependencies are unsatisfied, 24 | it runs `bundle install`. 25 | 26 | ### Migrating 27 | 28 | Each time your current HEAD changes, hookup checks to see if any 29 | migrations have been added, deleted, or modified. Deleted and modified 30 | migrations are given the `rake db:migrate:down` treatment, then `rake 31 | db:migrate` is invoked to bring everything else up to date. 32 | 33 | Hookup provides a `-C` option to change to a specified directory prior to 34 | running `bundle` or `rake`. This should be used if your `Gemfile` and 35 | `Rakefile` are in a non-standard location. 36 | 37 | To use a non-standard `db` directory (where `schema.rb` and `migrate/` 38 | live), add `--schema-dir="database/path"` to the `hookup post-checkout` 39 | line in `.git/hooks/post-checkout`. 40 | 41 | To force reloading the database if migrating fails, add 42 | `--load-schema="rake db:reset"` to the `hookup post-checkout` line in 43 | `.git/hooks/post-checkout`. 44 | 45 | ### Schema Resolving 46 | 47 | Each time there's a conflict in `db/schema.rb` on the 48 | `Rails::Schema.define` line, hookup resolves it in favor of the newer of 49 | the two versions. 50 | 51 | ### Skip Hookup 52 | 53 | Set the `SKIP_HOOKUP` environment variable to skip hookup. 54 | 55 | SKIP_HOOKUP=1 git checkout master 56 | 57 | ### Removing Hookup 58 | 59 | hookup remove 60 | 61 | ChangeLog 62 | --------- 63 | 64 | [See it on the wiki](https://github.com/tpope/hookup/wiki/ChangeLog) 65 | 66 | License 67 | ------- 68 | 69 | Copyright (c) Tim Pope. MIT License. 70 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | -------------------------------------------------------------------------------- /bin/hookup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $:.push File.expand_path("../../lib", __FILE__) 3 | require 'hookup' 4 | Hookup.run(*ARGV) 5 | -------------------------------------------------------------------------------- /hookup.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "hookup" 5 | s.version = "1.2.5" 6 | s.platform = Gem::Platform::RUBY 7 | s.authors = ["Tim Pope"] 8 | s.email = ["code@tp"+'ope.net'] 9 | s.homepage = "https://github.com/tpope/hookup" 10 | s.summary = %q{Automate the bundle/migration tedium of Rails with Git hooks} 11 | s.description = %q{Automatically bundle and migrate your Rails app when switching branches, merging upstream changes, and bisecting.} 12 | 13 | s.rubyforge_project = "hookup" 14 | 15 | s.files = `git ls-files`.split("\n") 16 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 17 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 18 | s.require_paths = ["lib"] 19 | end 20 | -------------------------------------------------------------------------------- /lib/hookup.rb: -------------------------------------------------------------------------------- 1 | class Hookup 2 | 3 | class Error < RuntimeError 4 | end 5 | 6 | class Failure < Error 7 | end 8 | 9 | EMPTY_DIR = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' 10 | 11 | def self.run(*argv) 12 | new.run(*argv) 13 | rescue Failure => e 14 | puts e 15 | exit 1 16 | rescue Error => e 17 | puts e 18 | exit 19 | end 20 | 21 | def run(*argv) 22 | if argv.empty? 23 | install 24 | else 25 | command = argv.shift 26 | begin 27 | send(command.tr('-', '_'), *argv) 28 | rescue NoMethodError 29 | raise Error, "Unknown command #{command}" 30 | rescue ArgumentError 31 | raise Error, "Invalid arguments for #{command}" 32 | end 33 | end 34 | end 35 | 36 | def git_dir 37 | unless @git_dir 38 | @git_dir = %x{git rev-parse --git-dir}.chomp 39 | raise Error unless $?.success? 40 | end 41 | @git_dir 42 | end 43 | 44 | def bundler? 45 | !!ENV['BUNDLE_GEMFILE'] 46 | end 47 | 48 | def make_command(command) 49 | bundler? ? command.insert(0, "bundle exec ") : command 50 | end 51 | 52 | def post_checkout_file 53 | File.join(git_dir, 'hooks', 'post-checkout') 54 | end 55 | 56 | def info_attributes_file 57 | File.join(git_dir, 'info', 'attributes') 58 | end 59 | 60 | def install 61 | append(post_checkout_file, 0777) do |body, f| 62 | f.puts "#!/bin/bash" unless body 63 | f.puts make_command(%(hookup post-checkout "$@")) if body !~ /hookup/ 64 | end 65 | 66 | append(info_attributes_file) do |body, f| 67 | map = 'db/schema.rb merge=railsschema' 68 | f.puts map unless body.to_s.include?(map) 69 | end 70 | 71 | system 'git', 'config', 'merge.railsschema.driver', make_command('hookup resolve-schema %A %O %B %L') 72 | 73 | puts "Hooked up!" 74 | end 75 | 76 | def remove 77 | body = IO.readlines(post_checkout_file) 78 | body.reject! { |item| item =~ /hookup/ } 79 | File.open(post_checkout_file, 'w') { |file| file.puts body.join } 80 | 81 | body = IO.readlines(info_attributes_file) 82 | body.reject! { |item| item =~ /railsschema/ } 83 | File.open(info_attributes_file, 'w') { |file| file.puts body.join } 84 | 85 | system 'git', 'config', '--unset', 'merge.railsschema.driver' 86 | 87 | puts "Hookup removed!" 88 | end 89 | 90 | def append(file, *args) 91 | Dir.mkdir(File.dirname(file)) unless File.directory?(File.dirname(file)) 92 | body = File.read(file) if File.exist?(file) 93 | File.open(file, 'a', *args) do |f| 94 | yield body, f 95 | end 96 | end 97 | protected :append 98 | 99 | def post_checkout(*args) 100 | PostCheckout.new(ENV, *args).run 101 | end 102 | 103 | class PostCheckout 104 | 105 | attr_reader :old, :new, :env 106 | 107 | def partial? 108 | @partial 109 | end 110 | 111 | def schema_dir 112 | File.join(working_dir, env['HOOKUP_SCHEMA_DIR']) 113 | end 114 | 115 | def possible_schemas 116 | %w(development_structure.sql schema.rb structure.sql).map do |file| 117 | File.join schema_dir, file 118 | end 119 | end 120 | 121 | def working_dir 122 | env['HOOKUP_WORKING_DIR'] || '.' 123 | end 124 | 125 | def initialize(environment, *args) 126 | @env ||= environment.to_hash.dup 127 | require 'optparse' 128 | opts = OptionParser.new 129 | opts.banner = "Usage: hookup post-checkout " 130 | opts.on('-Cdirectory', 'cd to directory') do |directory| 131 | env['HOOKUP_WORKING_DIR'] = directory 132 | end 133 | opts.on('--schema-dir=DIRECTORY', 'Path to DIRECTORY containing schema.rb and migrate/') do |directory| 134 | env['HOOKUP_SCHEMA_DIR'] = directory 135 | end 136 | opts.on('--load-schema=COMMAND', 'Run COMMAND on migration failure') do |command| 137 | env['HOOKUP_LOAD_SCHEMA'] = command 138 | end 139 | opts.parse!(args) 140 | 141 | @old = args.shift 142 | if @old == '0000000000000000000000000000000000000000' 143 | @old = EMPTY_DIR 144 | elsif @old.nil? 145 | @old = '@{-1}' 146 | end 147 | @new = args.shift || 'HEAD' 148 | @partial = (args.shift == '0') 149 | 150 | env['HOOKUP_SCHEMA_DIR'] = 'db' unless env['HOOKUP_SCHEMA_DIR'] && File.directory?(schema_dir) 151 | end 152 | 153 | def run 154 | return if skipped? || env['GIT_REFLOG_ACTION'] =~ /^(?:pull|rebase)/ 155 | unless partial? 156 | bundle 157 | migrate 158 | end 159 | end 160 | 161 | def bundler? 162 | File.exist?('Gemfile') 163 | end 164 | 165 | def bundle 166 | return unless bundler? 167 | if %x{git diff --name-only #{old} #{new}} =~ /^Gemfile|\.gemspec$/ 168 | begin 169 | # If Bundler in turn spawns Git, it can get confused by $GIT_DIR 170 | git_dir = ENV.delete('GIT_DIR') 171 | %x{bundle check} 172 | unless $?.success? 173 | puts "Bundling..." 174 | Dir.chdir(working_dir) do 175 | system("bundle | grep -v '^Using ' | grep -v ' is complete'") 176 | end 177 | end 178 | ensure 179 | ENV['GIT_DIR'] = git_dir 180 | end 181 | end 182 | end 183 | 184 | def migrate 185 | schemas = possible_schemas.select do |schema| 186 | status = %x{git diff --name-status #{old} #{new} -- #{schema}}.chomp 187 | rake 'db:create' if status =~ /^A/ 188 | status !~ /^D/ && !status.empty? 189 | end 190 | 191 | return if schemas.empty? 192 | 193 | migrations = %x{git diff --name-status #{old} #{new} -- #{schema_dir}/migrate}.scan(/.+/).map {|l| l.split(/\t/) } 194 | begin 195 | migrations.select {|(t,f)| %w(D M).include?(t)}.reverse.each do |type, file| 196 | begin 197 | system 'git', 'checkout', old, '--', file 198 | unless rake 'db:migrate:down', "VERSION=#{File.basename(file)}" 199 | raise Error, "Failed to rollback #{File.basename(file)}" 200 | end 201 | ensure 202 | if type == 'D' 203 | system 'git', 'rm', '--force', '--quiet', '--', file 204 | else 205 | system 'git', 'checkout', new, '--', file 206 | end 207 | end 208 | end 209 | 210 | if migrations.any? {|(t,f)| %w(A M).include?(t)} 211 | rake 'db:migrate' 212 | end 213 | 214 | ensure 215 | changes = %x{git diff --name-status #{new} -- #{schemas.join(' ')}} 216 | 217 | unless changes.empty? 218 | system 'git', 'checkout', '--', *schemas 219 | 220 | puts "Schema out of sync." 221 | 222 | fallback = env['HOOKUP_LOAD_SCHEMA'] 223 | if fallback && fallback != '' 224 | puts "Trying #{fallback}..." 225 | system fallback 226 | end 227 | end 228 | end 229 | end 230 | 231 | def rake(*args) 232 | Dir.chdir(working_dir) do 233 | if File.executable?('bin/rake') 234 | system 'bin/rake', *args 235 | elsif bundler? 236 | system 'bundle', 'exec', 'rake', *args 237 | else 238 | system 'rake', *args 239 | end 240 | end 241 | end 242 | 243 | def skipped? 244 | env['SKIP_HOOKUP'] 245 | end 246 | 247 | end 248 | 249 | def resolve_schema(a, o, b, marker_size = 7) 250 | system 'git', 'merge-file', "--marker-size=#{marker_size}", a, o, b 251 | body = File.read(a) 252 | resolve_schema_version body, ":version =>" 253 | resolve_schema_version body, "version:" 254 | File.open(a, 'w') { |f| f.write(body) } 255 | if body.include?('<' * marker_size.to_i) 256 | raise Failure, 'Failed to automatically resolve schema conflict' 257 | end 258 | end 259 | 260 | def resolve_schema_version(body, version) 261 | asd = "ActiveRecord::Schema.define" 262 | body.sub!(/^<+ .*\n#{asd}\(#{version} ([0-9_]+)\) do\n=+\n#{asd}\(#{version} ([0-9_]+)\) do\n>+ .*/) do 263 | "#{asd}(#{version} #{[$1, $2].max}) do" 264 | end 265 | end 266 | 267 | end 268 | --------------------------------------------------------------------------------