Skip to content

Commit

Permalink
Add Git hook to rollback phantom migrations on branch switch (#110)
Browse files Browse the repository at this point in the history
  • Loading branch information
m-darbinyan authored Dec 23, 2024
1 parent 30ec5ee commit a8b4d93
Show file tree
Hide file tree
Showing 5 changed files with 390 additions and 1 deletion.
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,40 @@ Add the following line to your initializer file (`config/initializers/actual_db_
ActualDbSchema.config[:auto_rollback_disabled] = true
```

## Automatic Phantom Migration Rollback On Branch Switch

By default, the automatic rollback of migrations on branch switch is disabled. If you prefer to automatically rollback phantom migrations whenever you switch branches with `git checkout`, you can enable it in two ways:

### 1. Using Environment Variable

Set the environment variable `ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED` to `true`:

```sh
export ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED=true
```

### 2. Using Initializer
Add the following line to your initializer file (`config/initializers/actual_db_schema.rb`):

```ruby
ActualDbSchema.config[:git_hooks_enabled] = true
```

### Installing the Post-Checkout Hook
After enabling Git hooks in your configuration, run the rake task to install the post-checkout hook:

```sh
rake actual_db_schema:install_git_hooks
```

This task will prompt you to choose one of the three options:

1. Rollback phantom migrations with `db:rollback_branches`
2. Migrate up to the latest schema with `db:migrate`
3. Skip installing git hook

Based on your selection, a post-checkout hook will be installed or updated in your `.git/hooks` folder.

## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
Expand Down
4 changes: 3 additions & 1 deletion lib/actual_db_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
require_relative "actual_db_schema/patches/migration_proxy"
require_relative "actual_db_schema/patches/migrator"
require_relative "actual_db_schema/patches/migration_context"
require_relative "actual_db_schema/git_hooks"

require_relative "actual_db_schema/commands/base"
require_relative "actual_db_schema/commands/rollback"
Expand All @@ -30,7 +31,8 @@ class << self
self.config = {
enabled: Rails.env.development?,
auto_rollback_disabled: ENV["ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"].present?,
ui_enabled: Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present?
ui_enabled: Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present?,
git_hooks_enabled: ENV["ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"].present?
}

def self.migrated_folder
Expand Down
184 changes: 184 additions & 0 deletions lib/actual_db_schema/git_hooks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# frozen_string_literal: true

require "fileutils"

module ActualDbSchema
# Handles the installation of a git post-checkout hook that rolls back phantom migrations when switching branches
class GitHooks # rubocop:disable Metrics/ClassLength
include ActualDbSchema::OutputFormatter

POST_CHECKOUT_MARKER_START = "# >>> BEGIN ACTUAL_DB_SCHEMA"
POST_CHECKOUT_MARKER_END = "# <<< END ACTUAL_DB_SCHEMA"

POST_CHECKOUT_HOOK_ROLLBACK = <<~BASH
#{POST_CHECKOUT_MARKER_START}
# ActualDbSchema post-checkout hook (ROLLBACK)
# Runs db:rollback_branches on branch checkout.
if [ -f ./bin/rails ]; then
if [ -n "$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED" ]; then
GIT_HOOKS_ENABLED="$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"
else
GIT_HOOKS_ENABLED=$(./bin/rails runner "puts ActualDbSchema.config[:git_hooks_enabled]" 2>/dev/null)
fi
if [ "$GIT_HOOKS_ENABLED" == "true" ]; then
./bin/rails db:rollback_branches
fi
fi
#{POST_CHECKOUT_MARKER_END}
BASH

POST_CHECKOUT_HOOK_MIGRATE = <<~BASH
#{POST_CHECKOUT_MARKER_START}
# ActualDbSchema post-checkout hook (MIGRATE)
# Runs db:migrate on branch checkout.
if [ -f ./bin/rails ]; then
if [ -n "$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED" ]; then
GIT_HOOKS_ENABLED="$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"
else
GIT_HOOKS_ENABLED=$(./bin/rails runner "puts ActualDbSchema.config[:git_hooks_enabled]" 2>/dev/null)
fi
if [ "$GIT_HOOKS_ENABLED" == "true" ]; then
./bin/rails db:migrate
fi
fi
#{POST_CHECKOUT_MARKER_END}
BASH

def initialize(strategy: :rollback)
@strategy = strategy
end

def install_post_checkout_hook
return unless git_hooks_enabled?
return unless hooks_directory_present?

if File.exist?(hook_path)
handle_existing_hook
else
create_new_hook
end
end

private

def hook_code
@strategy == :migrate ? POST_CHECKOUT_HOOK_MIGRATE : POST_CHECKOUT_HOOK_ROLLBACK
end

def hooks_dir
@hooks_dir ||= Rails.root.join(".git", "hooks")
end

def hook_path
@hook_path ||= hooks_dir.join("post-checkout")
end

def git_hooks_enabled?
return true if ActualDbSchema.config[:git_hooks_enabled]

puts colorize("[ActualDbSchema] Git hooks are disabled in configuration. Skipping installation.", :gray)
end

def hooks_directory_present?
return true if Dir.exist?(hooks_dir)

puts colorize("[ActualDbSchema] .git/hooks directory not found. Please ensure this is a Git repository.", :gray)
end

def handle_existing_hook
return update_hook if markers_exist?
return install_hook if safe_install?

show_manual_install_instructions
end

def create_new_hook
contents = <<~BASH
#!/usr/bin/env bash
#{hook_code}
BASH

write_hook_file(contents)
print_success
end

def markers_exist?
contents = File.read(hook_path)
contents.include?(POST_CHECKOUT_MARKER_START) && contents.include?(POST_CHECKOUT_MARKER_END)
end

def update_hook
contents = File.read(hook_path)
new_contents = replace_marker_contents(contents)

if new_contents == contents
message = "[ActualDbSchema] post-checkout git hook already contains the necessary code. Nothing to update."
puts colorize(message, :gray)
else
write_hook_file(new_contents)
puts colorize("[ActualDbSchema] post-checkout git hook updated successfully at #{hook_path}", :green)
end
end

def replace_marker_contents(contents)
contents.gsub(
/#{Regexp.quote(POST_CHECKOUT_MARKER_START)}.*#{Regexp.quote(POST_CHECKOUT_MARKER_END)}/m,
hook_code.strip
)
end

def safe_install?
puts colorize("[ActualDbSchema] A post-checkout hook already exists at #{hook_path}.", :gray)
puts "Overwrite the existing hook at #{hook_path}? [y,n] "

answer = $stdin.gets.chomp.downcase
answer.start_with?("y")
end

def install_hook
contents = File.read(hook_path)
new_contents = <<~BASH
#{contents.rstrip}
#{hook_code}
BASH

write_hook_file(new_contents)
print_success
end

def show_manual_install_instructions
puts colorize("[ActualDbSchema] You can follow these steps to manually install the hook:", :yellow)
puts <<~MSG
1. Open the existing post-checkout hook at:
#{hook_path}
2. Insert the following lines into that file (preferably at the end or in a relevant section).
Make sure you include the #{POST_CHECKOUT_MARKER_START} and #{POST_CHECKOUT_MARKER_END} lines:
#{hook_code}
3. Ensure the post-checkout file is executable:
chmod +x #{hook_path}
4. Done! Now when you switch branches, phantom migrations will be rolled back automatically (if enabled).
MSG
end

def write_hook_file(contents)
File.open(hook_path, "w") { |file| file.write(contents) }
FileUtils.chmod("+x", hook_path)
end

def print_success
puts colorize("[ActualDbSchema] post-checkout git hook installed successfully at #{hook_path}", :green)
end
end
end
28 changes: 28 additions & 0 deletions lib/tasks/actual_db_schema.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

namespace :actual_db_schema do
desc "Install ActualDbSchema post-checkout git hook that rolls back phantom migrations when switching branches."
task :install_git_hooks do
extend ActualDbSchema::OutputFormatter

puts "Which Git hook strategy would you like to install? [1, 2, 3]"
puts " 1) Rollback phantom migrations (db:rollback_branches)"
puts " 2) Migrate up to latest (db:migrate)"
puts " 3) No hook installation (skip)"
answer = $stdin.gets.chomp

strategy =
case answer
when "1" then :rollback
when "2" then :migrate
else
:none
end

if strategy == :none
puts colorize("[ActualDbSchema] Skipping git hook installation.", :gray)
else
ActualDbSchema::GitHooks.new(strategy: strategy).install_post_checkout_hook
end
end
end
Loading

0 comments on commit a8b4d93

Please sign in to comment.