#!/usr/bin/env ruby # Checks that Gemfile.lock and Gemfile.saas.lock are in sync for shared dependencies. # Since Gemfile.saas evals Gemfile, shared gems should have identical versions. # # Usage: # bin/bundle-drift [check] # check for drift (default subcommand) # bin/bundle-drift correct # restore alignment (Gemfile.saas.lock is authoritative) # bin/bundle-drift forward # push Gemfile.lock changes into Gemfile.saas.lock require "bundler" require "fileutils" GEMFILE_LOCK = "Gemfile.lock" GEMFILE_SAAS_LOCK = "Gemfile.saas.lock" class GemfileDriftChecker def initialize @oss_lockfile = parse_lockfile(GEMFILE_LOCK) @saas_lockfile = parse_lockfile(GEMFILE_SAAS_LOCK) end def check find_drift.tap do report it end end private def parse_lockfile(path) Bundler::LockfileParser.new(File.read(path)) end def find_drift oss_specs, saas_specs = specs_hash(@oss_lockfile), specs_hash(@saas_lockfile) shared_gems = oss_specs.keys & saas_specs.keys shared_gems.filter_map do |name| oss_version, saas_version = oss_specs[name], saas_specs[name] if oss_version != saas_version { name: name, oss: oss_version, saas: saas_version } end end.sort_by { |d| d[:name] } end def specs_hash(lockfile) lockfile.specs.to_h { |spec| [ spec.name, spec.version.to_s ] } end def report(drift) if drift.empty? puts "✓ Gemfile.lock and Gemfile.saas.lock are in sync" else puts "✗ Gemfile lock files have drifted!\n\n" name_width = [ drift.map { |d| d[:name].length }.max, "Gem".length ].max oss_width = [ drift.map { |d| d[:oss].length }.max, "Gemfile.lock".length ].max saas_width = [ drift.map { |d| d[:saas].length }.max, "Gemfile.saas.lock".length ].max puts " #{"Gem".ljust(name_width)} #{"Gemfile.lock".ljust(oss_width)} Gemfile.saas.lock" puts " #{"-" * name_width} #{"-" * oss_width} #{"-" * saas_width}" drift.each do |d| puts " #{d[:name].ljust(name_width)} #{d[:oss].ljust(oss_width)} #{d[:saas]}" end puts "\nRun 'bin/bundle-drift correct' to restore alignment." end end end class GemfileDriftCorrector def correct drift = GemfileDriftChecker.new.check return puts "\nNothing to correct." if drift.empty? puts "\nRestoring alignment (Gemfile.saas.lock is authoritative)...\n\n" # Save original for diff original_content = File.read(GEMFILE_LOCK) # Seed Gemfile.lock with Gemfile.saas.lock - Bundler will use these as version hints FileUtils.cp(GEMFILE_SAAS_LOCK, GEMFILE_LOCK) # Re-lock: Bundler prunes SaaS-only gems while preserving shared versions puts "▸ Re-locking Gemfile (seeded from Gemfile.saas.lock)" unless system("BUNDLE_GEMFILE=Gemfile bundle lock") File.write(GEMFILE_LOCK, original_content) abort("Failed to lock Gemfile. Restored original.") end puts "\n▸ Verifying alignment" new_drift = GemfileDriftChecker.new.check if new_drift.empty? puts "\n✓ Lock files are now in sync" show_diff(original_content, File.read(GEMFILE_LOCK)) else puts "\n✗ Lock files still have drift after correction." puts " Bundler couldn't resolve to matching versions." puts " Restoring original Gemfile.lock." File.write(GEMFILE_LOCK, original_content) exit 1 end end private def show_diff(original, corrected) require "tempfile" Tempfile.create("gemfile-lock-original") do |f| f.write(original) f.flush diff = `diff -u #{f.path} #{GEMFILE_LOCK} 2>/dev/null` unless diff.empty? puts "\nChanges made to Gemfile.lock:" puts diff end end end end class GemfileDriftForwarder def forward drift = GemfileDriftChecker.new.check return puts "\nNothing to forward." if drift.empty? puts "\nForwarding Gemfile.lock versions into Gemfile.saas.lock...\n\n" original_content = File.read(GEMFILE_SAAS_LOCK) patched_content = original_content.dup drift.each do |d| old_entry = "#{d[:name]} (#{d[:saas]})" new_entry = "#{d[:name]} (#{d[:oss]})" puts " #{old_entry} → #{new_entry}" patched_content.gsub!(old_entry, new_entry) end File.write(GEMFILE_SAAS_LOCK, patched_content) puts "\n▸ Verifying alignment" new_drift = GemfileDriftChecker.new.check if new_drift.empty? puts "\n✓ Lock files are now in sync" show_diff(original_content, patched_content) else puts "\n✗ Lock files still have drift after forwarding." puts " Restoring original Gemfile.saas.lock." File.write(GEMFILE_SAAS_LOCK, original_content) exit 1 end end private def show_diff(original, patched) require "tempfile" Tempfile.create("gemfile-saas-lock-original") do |f| f.write(original) f.flush diff = `diff -u #{f.path} #{GEMFILE_SAAS_LOCK} 2>/dev/null` unless diff.empty? puts "\nChanges made to Gemfile.saas.lock:" puts diff end end end end case command = ARGV[0] || "check" when "check" exit 1 unless GemfileDriftChecker.new.check.empty? when "correct" GemfileDriftCorrector.new.correct when "forward" GemfileDriftForwarder.new.forward else abort "Usage: bin/bundle-drift [check|correct|forward]" end