diff --git a/README.md b/README.md index bb551c8..489f9e9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/actual_db_schema.rb b/lib/actual_db_schema.rb index 21cdf8a..ba3b112 100644 --- a/lib/actual_db_schema.rb +++ b/lib/actual_db_schema.rb @@ -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" @@ -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 diff --git a/lib/actual_db_schema/git_hooks.rb b/lib/actual_db_schema/git_hooks.rb new file mode 100644 index 0000000..3364b28 --- /dev/null +++ b/lib/actual_db_schema/git_hooks.rb @@ -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 diff --git a/lib/tasks/actual_db_schema.rake b/lib/tasks/actual_db_schema.rake new file mode 100644 index 0000000..4823fa4 --- /dev/null +++ b/lib/tasks/actual_db_schema.rake @@ -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 diff --git a/test/rake_task_git_hooks_install_test.rb b/test/rake_task_git_hooks_install_test.rb new file mode 100644 index 0000000..18b7ac7 --- /dev/null +++ b/test/rake_task_git_hooks_install_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "test_helper" + +describe "actual_db_schema:install_git_hooks" do + let(:utils) { TestUtils.new } + let(:hook_path) { utils.app_file(".git/hooks/post-checkout") } + + before do + FileUtils.mkdir_p(utils.app_file(".git/hooks")) + Rails.application.load_tasks + ActualDbSchema.config[:git_hooks_enabled] = true + end + + after do + FileUtils.rm_rf(utils.app_file(".git/hooks")) + Rake::Task["actual_db_schema:install_git_hooks"].reenable + end + + describe "when .git/hooks directory is missing" do + before do + FileUtils.rm_rf(utils.app_file(".git/hooks")) + end + + it "does not attempt installation and shows an error message" do + utils.simulate_input("1") do + Rake::Task["actual_db_schema:install_git_hooks"].invoke + end + refute File.exist?(hook_path) + assert_match( + %r{\[ActualDbSchema\] .git/hooks directory not found. Please ensure this is a Git repository.}, + TestingState.output + ) + end + end + + describe "when Git hooks are disabled in config" do + before do + ActualDbSchema.config[:git_hooks_enabled] = false + end + + it "skips installation and shows an error message" do + utils.simulate_input("1") do + Rake::Task["actual_db_schema:install_git_hooks"].invoke + end + refute File.exist?(hook_path) + assert_match( + /\[ActualDbSchema\] Git hooks are disabled in configuration\. Skipping installation\./, + TestingState.output + ) + end + end + + describe "when user chooses rollback" do + it "installs the rollback snippet in post-checkout" do + refute File.exist?(hook_path) + utils.simulate_input("1") do + Rake::Task["actual_db_schema:install_git_hooks"].invoke + end + assert File.exist?(hook_path) + contents = File.read(hook_path) + assert_includes(contents, "db:rollback_branches") + refute_includes(contents, "db:migrate") + end + end + + describe "when user chooses migrate" do + it "installs the migrate snippet in post-checkout" do + refute File.exist?(hook_path) + utils.simulate_input("2") do + Rake::Task["actual_db_schema:install_git_hooks"].invoke + end + assert File.exist?(hook_path) + contents = File.read(hook_path) + assert_includes(contents, "db:migrate") + refute_includes(contents, "db:rollback_branches") + end + end + + describe "when user chooses none" do + it "skips installing the post-checkout hook" do + refute File.exist?(hook_path) + utils.simulate_input("3") do + Rake::Task["actual_db_schema:install_git_hooks"].invoke + end + refute File.exist?(hook_path) + assert_match(/\[ActualDbSchema\] Skipping git hook installation\./, TestingState.output) + end + end + + describe "when post-checkout hook already exists" do + before do + File.write(hook_path, "#!/usr/bin/env bash\n# Existing content\n") + end + + it "appends content if user decides to overwrite" do + utils.simulate_input("1\ny") do + Rake::Task["actual_db_schema:install_git_hooks"].invoke + end + contents = File.read(hook_path) + assert_includes(contents, "db:rollback_branches") + assert_includes(contents, "# Existing content") + end + + it "does not change file and shows manual instructions if user declines overwrite" do + utils.simulate_input("2\nn") do + Rake::Task["actual_db_schema:install_git_hooks"].invoke + end + contents = File.read(hook_path) + refute_includes(contents, "db:migrate") + assert_includes(contents, "# Existing content") + assert_match(/\[ActualDbSchema\] You can follow these steps to manually install the hook/, TestingState.output) + end + end + + describe "existing post-checkout hook with markers" do + before do + File.write( + hook_path, + <<~BASH + #!/usr/bin/env bash + echo "some existing code" + + # >>> BEGIN ACTUAL_DB_SCHEMA + echo "old snippet" + # <<< END ACTUAL_DB_SCHEMA + BASH + ) + end + + it "updates the snippet if markers exist" do + utils.simulate_input("2") do + Rake::Task["actual_db_schema:install_git_hooks"].invoke + end + contents = File.read(hook_path) + refute_includes(contents, "old snippet") + assert_includes(contents, "db:migrate") + assert_includes(contents, "some existing code") + end + end +end