mighost 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "shellwords"
5
+ require_relative "git_metadata"
6
+
7
+ module Mighost
8
+ # Searches git history to find migration files that exist in other branches/commits
9
+ module GitRecovery
10
+ class << self
11
+ # Find migration file content from git history
12
+ # Returns { content:, filename:, branch:, sha:, commit_date: } or nil
13
+ def find_migration(version)
14
+ return nil unless GitMetadata.git_available?
15
+
16
+ # Search for the migration file across all branches using --all
17
+ pattern = "db/migrate/#{version}_*.rb"
18
+
19
+ result = find_latest_commit_with_file(pattern)
20
+ return nil unless result
21
+
22
+ content = get_file_content(result[:sha], result[:path])
23
+ return nil unless content
24
+
25
+ {
26
+ content: content,
27
+ filename: File.basename(result[:path]),
28
+ branch: result[:branch],
29
+ sha: result[:sha][0, 12],
30
+ commit_date: result[:date],
31
+ author_name: result[:author_name],
32
+ author_email: result[:author_email]
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ def find_latest_commit_with_file(pattern)
39
+ # Use git log --all to search across ALL refs in a single command
40
+ # %D shows decorated refs (branches/tags pointing to commit)
41
+ # --name-only includes the filename in output
42
+ # --diff-filter=d excludes deletion commits: without it, a migration whose
43
+ # last touch was a delete resolves to the deleting commit, where the file
44
+ # no longer exists and `git show` fails
45
+ fmt = "%H\t%aI\t%D\t%aN\t%aE"
46
+ cmd = "git log --all -1 --diff-filter=d --format='#{fmt}' --name-only -- #{Shellwords.escape(pattern)} 2>/dev/null"
47
+ output = `#{cmd}`.strip
48
+ return nil if output.empty?
49
+
50
+ lines = output.split("\n").map(&:strip).reject(&:empty?)
51
+ return nil if lines.empty?
52
+
53
+ # First non-empty line: commit info (sha\tdate\tdecorated_refs\tauthor_name\tauthor_email)
54
+ commit_line = lines[0]
55
+ sha, date_str, decorated_refs, author_name, author_email = commit_line.split("\t", 5)
56
+ return nil unless sha && date_str
57
+
58
+ # Second non-empty line: the file path
59
+ file_path = lines[1]
60
+ return nil if file_path.nil? || file_path.empty?
61
+
62
+ date = begin
63
+ Time.parse(date_str)
64
+ rescue
65
+ nil
66
+ end
67
+ return nil unless date
68
+
69
+ # Extract branch name from decorated refs (may be empty if commit is not a branch tip)
70
+ branch = extract_branch_from_decorated_refs(decorated_refs)
71
+
72
+ # If no branch found from decoration, try to find which branch contains this commit
73
+ branch ||= find_branch_containing_commit(sha)
74
+
75
+ {
76
+ sha: sha,
77
+ path: file_path,
78
+ branch: branch,
79
+ date: date,
80
+ author_name: author_name,
81
+ author_email: author_email
82
+ }
83
+ end
84
+
85
+ def extract_branch_from_decorated_refs(refs)
86
+ return nil if refs.nil? || refs.strip.empty?
87
+
88
+ # Decorated refs look like: "HEAD -> main, origin/main, feature-branch"
89
+ # Extract the first branch-like ref
90
+ refs.split(",").each do |ref|
91
+ ref = ref.strip
92
+ ref = ref.sub(/^HEAD -> /, "")
93
+ ref = ref.sub(%r{^origin/}, "")
94
+ next if ref.empty? || ref.start_with?("tag:")
95
+
96
+ return ref
97
+ end
98
+ nil
99
+ end
100
+
101
+ def find_branch_containing_commit(sha)
102
+ # Find branches that contain this commit
103
+ output = `git branch -a --contains #{sha} 2>/dev/null`.strip
104
+ return nil if output.empty?
105
+
106
+ # Parse output - each line is a branch name, * = current branch, + = worktree-checked-out
107
+ lines = output.split("\n").map { |l| l.sub(/^[*+]?\s*/, "").strip }
108
+ return nil if lines.empty?
109
+
110
+ # Prefer local branches over remote, clean up names
111
+ lines.each do |branch|
112
+ next if branch.start_with?("remotes/")
113
+ return branch
114
+ end
115
+
116
+ # Fall back to first remote branch, cleaned up
117
+ first = lines.first
118
+ first&.sub(%r{^remotes/origin/}, "")
119
+ end
120
+
121
+ def get_file_content(sha, path)
122
+ content = `git show #{Shellwords.escape("#{sha}:#{path}")} 2>/dev/null`
123
+ return nil unless $?.success?
124
+
125
+ content
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "capturer"
4
+
5
+ module Mighost
6
+ module MigratorExtension
7
+ def record_version_state_after_migrating(version)
8
+ super
9
+
10
+ # Only capture on UP migrations, not DOWN
11
+ return unless up?
12
+
13
+ migration = migrations.find { |m| m.version == version }
14
+ return unless migration
15
+
16
+ Capturer.new(version, migration.filename).capture
17
+ end
18
+
19
+ private
20
+
21
+ def up?
22
+ @direction == :up
23
+ end
24
+ end
25
+ end
26
+
27
+ # Prepend to ActiveRecord::Migrator
28
+ ActiveSupport.on_load(:active_record) do
29
+ ActiveRecord::Migrator.prepend(Mighost::MigratorExtension)
30
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/object/blank"
5
+ require "active_support/core_ext/enumerable"
6
+ require_relative "snapshot"
7
+ require_relative "git_metadata"
8
+
9
+ module Mighost
10
+ class OrphanDetector
11
+ class OrphanedMigration
12
+ attr_reader :version, :filename, :branch_name, :git_sha, :migrated_at, :source, :dismissed, :author_name, :author_email
13
+
14
+ def initialize(version:, filename: nil, branch_name: nil, git_sha: nil, migrated_at: nil, source: nil, dismissed: false, author_name: nil, author_email: nil)
15
+ @version = version
16
+ @filename = filename
17
+ @branch_name = branch_name
18
+ @git_sha = git_sha
19
+ @migrated_at = migrated_at
20
+ @source = source
21
+ @dismissed = dismissed
22
+ @author_name = author_name
23
+ @author_email = author_email
24
+ end
25
+
26
+ def recoverable?
27
+ source.present?
28
+ end
29
+
30
+ def recovery_source
31
+ case source
32
+ when "file" then :snapshot
33
+ when "git" then :branch
34
+ when "worktree" then :worktree
35
+ end
36
+ end
37
+
38
+ # Parse version timestamp (YYYYMMDDHHmmss) -> Time
39
+ def created_at
40
+ return @created_at if defined?(@created_at)
41
+
42
+ @created_at = begin
43
+ if version =~ /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/
44
+ Time.new($1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i)
45
+ end
46
+ rescue ArgumentError
47
+ nil
48
+ end
49
+ end
50
+
51
+ # Is migration older than threshold?
52
+ def old?(threshold: Mighost.configuration.dismiss_old_threshold)
53
+ return false unless created_at
54
+
55
+ created_at < threshold.ago
56
+ end
57
+
58
+ # Should suggest dismissal? (unrecoverable AND old)
59
+ def suggested_for_dismiss?
60
+ !recoverable? && old?
61
+ end
62
+
63
+ # Is actionable? (recoverable OR not old enough to suggest dismissal)
64
+ def actionable?
65
+ !suggested_for_dismiss?
66
+ end
67
+ end
68
+
69
+ class << self
70
+ attr_accessor :debug
71
+
72
+ # Detect orphaned migrations using cached snapshot data only (instant)
73
+ # @param include_dismissed [Boolean] whether to include dismissed migrations (default: false)
74
+ def detect(include_dismissed: false)
75
+ # Load everything upfront for performance
76
+ versions = migrated_versions
77
+ file_versions = migration_file_versions
78
+ dismissed_set = Snapshot.dismissed_versions.to_set
79
+ snapshots_by_version = Snapshot.all_metadata.index_by(&:version)
80
+
81
+ log "Found #{versions.count} migrated versions, #{file_versions.count} files on disk"
82
+
83
+ orphaned = []
84
+ versions.each do |version|
85
+ # Skip if file exists on disk
86
+ next if file_versions.include?(version)
87
+
88
+ # Skip dismissed unless requested
89
+ is_dismissed = dismissed_set.include?(version)
90
+ next if is_dismissed && !include_dismissed
91
+
92
+ snapshot = snapshots_by_version[version]
93
+ log " #{version} - orphaned#{", has snapshot (#{snapshot.source})" if snapshot}"
94
+
95
+ orphaned << OrphanedMigration.new(
96
+ version: version,
97
+ filename: snapshot&.filename,
98
+ branch_name: snapshot&.branch_name,
99
+ git_sha: snapshot&.git_sha,
100
+ migrated_at: snapshot&.migrated_at,
101
+ source: snapshot&.source,
102
+ dismissed: is_dismissed,
103
+ author_name: snapshot&.author_name,
104
+ author_email: snapshot&.author_email
105
+ )
106
+ end
107
+
108
+ log "Done. Found #{orphaned.count} orphaned migrations."
109
+ orphaned.sort_by(&:version).reverse
110
+ end
111
+
112
+ def orphaned_count
113
+ detect.count
114
+ end
115
+
116
+ def recoverable_count
117
+ detect.count(&:recoverable?)
118
+ end
119
+
120
+ # Get stored scan metadata
121
+ def scan_metadata
122
+ Snapshot.scan_metadata
123
+ end
124
+
125
+ # Check if current branch/sha differs from last scan
126
+ def branch_changed?
127
+ metadata = scan_metadata
128
+ return false unless metadata
129
+
130
+ current_branch = GitMetadata.current_branch
131
+ current_sha = GitMetadata.current_sha
132
+
133
+ metadata[:branch] != current_branch || metadata[:sha] != current_sha
134
+ end
135
+
136
+ private
137
+
138
+ def log(msg)
139
+ puts "[mighost] #{msg}" if debug
140
+ end
141
+
142
+ def migrated_versions
143
+ Mighost::Base.connection
144
+ .select_values("SELECT version FROM #{schema_migrations_table}")
145
+ .map(&:to_s)
146
+ end
147
+
148
+ def schema_migrations_table
149
+ Mighost::Base.connection.quote_table_name("schema_migrations")
150
+ end
151
+
152
+ # Load all migration file versions at once (single glob per path)
153
+ def migration_file_versions
154
+ versions = Set.new
155
+ migration_paths.each do |path|
156
+ Dir.glob(File.join(path, "*.rb")).each do |file|
157
+ version = File.basename(file).split("_").first
158
+ versions << version
159
+ end
160
+ end
161
+ versions
162
+ end
163
+
164
+ def migration_paths
165
+ if defined?(Rails) && Rails.application
166
+ Rails.application.config.paths["db/migrate"].to_ary
167
+ else
168
+ ["db/migrate"]
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/object/blank"
5
+ require_relative "formatting"
6
+
7
+ module Mighost
8
+ # PlainUI is the default UI adapter for mighost.
9
+ # It outputs plain, uncolored text suitable for any terminal.
10
+ #
11
+ # To provide custom (rich) output, replace this adapter:
12
+ # Mighost.ui = YourCustomUI.new
13
+ #
14
+ # Adapter interface:
15
+ # render_orphans(orphans)
16
+ # render_records(records, limit:, total_count:)
17
+ # render_recoverable_list(orphans)
18
+ # render_scan_details(details)
19
+ # render_scan_results(captured:, git_recovered:, worktree_recovered:, branch:, sha:)
20
+ # render_dismiss_preview(versions, orphan_map)
21
+ # render_dismissed_list(dismissed_snapshots)
22
+ # confirm?(prompt) -> Boolean
23
+ # confirm_each?(orphan) -> :yes, :no, :all, :quit
24
+ # confirm_dismiss?(version, name) -> :yes, :no, :all, :quit
25
+ # success(msg), error(msg), warning(msg), info(msg), hint(msg)
26
+ #
27
+ class PlainUI
28
+ include Formatting
29
+
30
+ def render_orphans(orphans)
31
+ if orphans.empty?
32
+ info "No orphaned migrations found."
33
+ return
34
+ end
35
+
36
+ actionable, suggested = orphans.partition(&:actionable?)
37
+
38
+ recoverable_count = orphans.count(&:recoverable?)
39
+ suggested_count = suggested.count
40
+
41
+ header_parts = ["#{orphans.count} total", "#{recoverable_count} recoverable"]
42
+ header_parts << "#{suggested_count} suggested to dismiss" if suggested_count.positive?
43
+ puts "\nOrphaned migrations (#{header_parts.join(", ")}):\n\n"
44
+
45
+ actionable.each { |o| puts " #{orphan_line(o)}" }
46
+
47
+ if actionable.any? && suggested.any?
48
+ puts "\n Suggested to dismiss (#{suggested.count} old, unrecoverable):\n"
49
+ end
50
+
51
+ suggested.each { |o| puts " #{orphan_line(o)}" }
52
+
53
+ if suggested_count.positive?
54
+ puts "\nTip: Run 'rails mighost:dismiss OLD=1' to bulk-dismiss the #{suggested_count} old migrations."
55
+ end
56
+
57
+ if recoverable_count.positive?
58
+ puts "\nTip: Run 'rails mighost:rollback' to rollback recoverable migrations."
59
+ end
60
+ end
61
+
62
+ def render_records(records, limit: nil, total_count: nil)
63
+ if records.empty?
64
+ info "No migration records stored."
65
+ return
66
+ end
67
+
68
+ count_text = if limit && total_count && total_count > records.count
69
+ "#{records.count} of #{total_count}"
70
+ else
71
+ records.count.to_s
72
+ end
73
+
74
+ output = []
75
+ output << "\nMigration records (#{count_text}):\n\n"
76
+ records.each do |record|
77
+ version = format_version(record.version)
78
+ name = format_name(record.filename)
79
+ branch = truncate(record.branch_name || "-", 25)
80
+ sha = record.git_sha || "-"
81
+ captured = format_captured_at(record.migrated_at)
82
+ author = truncate(record.author_name || "-", 20)
83
+
84
+ output << " #{version} #{name.ljust(42)} #{branch.ljust(27)} #{sha.ljust(14)} #{captured} #{author}\n"
85
+ end
86
+
87
+ text = output.join
88
+
89
+ if $stdout.tty? && records.size > 30 && !limit
90
+ pager = ENV["PAGER"] || "less"
91
+ IO.popen(pager, "w") { |io| io.puts text }
92
+ else
93
+ print text
94
+ end
95
+ end
96
+
97
+ def render_recoverable_list(orphans)
98
+ puts "\nRecoverable orphaned migrations:"
99
+ orphans.each do |orphan|
100
+ name = orphan.filename || "(unknown)"
101
+ puts " #{orphan.version} - #{name}"
102
+ end
103
+ end
104
+
105
+ def render_scan_details(details)
106
+ details.each do |detail|
107
+ case detail[:type]
108
+ when :captured
109
+ info "Captured: #{detail[:filename]}"
110
+ when :git_recovered
111
+ info "Git recovered: #{detail[:filename]} (from #{detail[:branch]})"
112
+ when :worktree_recovered
113
+ info "Worktree recovered: #{detail[:filename]} (from #{detail[:branch]})"
114
+ end
115
+ end
116
+ end
117
+
118
+ def render_scan_results(captured:, git_recovered:, worktree_recovered:, branch:, sha:)
119
+ puts ""
120
+ success "Scan complete:"
121
+ info " Captured #{captured} migration(s) from files"
122
+ info " Recovered #{git_recovered} orphan(s) from git history"
123
+ info " Recovered #{worktree_recovered} orphan(s) from worktrees" if worktree_recovered > 0
124
+ info " Context: #{branch} @ #{sha}"
125
+ end
126
+
127
+ def render_dismiss_preview(versions, orphan_map)
128
+ puts "\nFound #{versions.count} orphaned migration(s) to dismiss:\n"
129
+ preview_count = 10
130
+ versions.first(preview_count).each do |v|
131
+ puts " #{v} (#{format_version(v)})"
132
+ end
133
+ if versions.count > preview_count
134
+ remaining = versions.count - preview_count
135
+ puts " ... and #{remaining} more up to #{format_version(versions.last)}"
136
+ end
137
+ puts ""
138
+ end
139
+
140
+ def render_dismiss_remaining(versions, orphan_map)
141
+ puts "\nDismissing all remaining:"
142
+ versions.each do |v|
143
+ o = orphan_map[v]
144
+ puts " #{v} - #{o&.filename || "(unknown)"}"
145
+ end
146
+ end
147
+
148
+ def render_dismissed_list(dismissed_snapshots)
149
+ puts "\nDismissed migrations (#{dismissed_snapshots.count}):\n\n"
150
+ dismissed_snapshots.each do |version, snapshot|
151
+ name = snapshot&.filename.presence || "(unknown)"
152
+ dismissed_at = snapshot&.dismissed_at
153
+ date_str = dismissed_at ? " (dismissed #{dismissed_at[0, 10]})" : ""
154
+ puts " #{version} - #{name}#{date_str}"
155
+ end
156
+ puts "\nTip: Use 'rails mighost:undismiss VERSION=x' to restore."
157
+ end
158
+
159
+ def confirm?(prompt)
160
+ print "\n#{prompt} [y/N] "
161
+ response = $stdin.gets.chomp.downcase
162
+ response == "y"
163
+ end
164
+
165
+ def confirm_each?(orphan)
166
+ name = orphan.filename || "(unknown)"
167
+ version = format_version(orphan.version)
168
+ recovery = format_recovery(orphan.recovery_source)
169
+
170
+ print "\nRollback #{version} #{name} [#{recovery}]? [y/n/a/q] "
171
+ parse_multi_choice
172
+ end
173
+
174
+ def confirm_dismiss?(version, name)
175
+ print "\nDismiss #{format_version(version)} #{name}? [y/n/a/q] "
176
+ parse_multi_choice
177
+ end
178
+
179
+ def success(msg)
180
+ puts msg
181
+ end
182
+
183
+ def error(msg)
184
+ puts msg
185
+ end
186
+
187
+ def warning(msg)
188
+ puts msg
189
+ end
190
+
191
+ def info(msg)
192
+ puts msg
193
+ end
194
+
195
+ def hint(msg)
196
+ puts "Hint: #{msg}"
197
+ end
198
+
199
+ private
200
+
201
+ def parse_multi_choice
202
+ response = $stdin.gets.chomp.downcase
203
+ case response
204
+ when "y", "yes" then :yes
205
+ when "n", "no", "" then :no
206
+ when "a", "all" then :all
207
+ when "q", "quit" then :quit
208
+ else :no
209
+ end
210
+ end
211
+
212
+ def orphan_line(orphan)
213
+ version = orphan.version
214
+ name = format_name(orphan.filename)
215
+ recovery = orphan.recoverable? ? "recoverable (#{format_recovery(orphan.recovery_source)})" : "not recoverable"
216
+ branch = orphan.branch_name ? "branch:#{truncate(orphan.branch_name, 30)}" : ""
217
+ author = orphan.author_name ? "author:#{truncate(orphan.author_name, 20)}" : ""
218
+
219
+ parts = [version, name.ljust(42), recovery.ljust(24)]
220
+ parts << branch unless branch.empty?
221
+ parts << author unless author.empty?
222
+ parts.join(" ")
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Mighost
6
+ class Railtie < Rails::Railtie
7
+ railtie_name :mighost
8
+
9
+ initializer "mighost.configure" do
10
+ Mighost.configuration.enabled = !Rails.env.production?
11
+ end
12
+
13
+ initializer "mighost.setup_migrator_extension" do
14
+ ActiveSupport.on_load(:active_record) do
15
+ require_relative "migrator_extension"
16
+ end
17
+ end
18
+
19
+ rake_tasks do
20
+ load File.expand_path("tasks.rake", __dir__)
21
+ end
22
+
23
+ generators do
24
+ require_relative "../generators/mighost/install_generator"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "active_support"
5
+ require "active_support/core_ext/object/blank"
6
+ require_relative "snapshot"
7
+ require_relative "git_recovery"
8
+ require_relative "worktree_recovery"
9
+
10
+ module Mighost
11
+ class Rollbacker
12
+ attr_reader :version, :content, :filename, :recovery_source
13
+
14
+ def initialize(version)
15
+ @version = version.to_s
16
+ load_recovery_data
17
+ end
18
+
19
+ def rollback!
20
+ raise SnapshotNotFound, "No snapshot or git recovery found for version #{version}" unless can_rollback?
21
+
22
+ temp_file = write_temp_migration
23
+ begin
24
+ load_and_execute_down(temp_file)
25
+ remove_from_schema_migrations
26
+ Snapshot.delete_by_version(version) if recovery_source == :snapshot
27
+ ensure
28
+ temp_file.close
29
+ temp_file.unlink
30
+ end
31
+ end
32
+
33
+ def can_rollback?
34
+ content.present?
35
+ end
36
+
37
+ private
38
+
39
+ def load_recovery_data
40
+ # Try snapshot first
41
+ snapshot = Snapshot.find_by_version(version)
42
+ if snapshot
43
+ @content = snapshot.content
44
+ @filename = snapshot.filename
45
+ @recovery_source = :snapshot
46
+ return
47
+ end
48
+
49
+ # Fallback to git recovery
50
+ git_data = GitRecovery.find_migration(version)
51
+ if git_data
52
+ @content = git_data[:content]
53
+ @filename = git_data[:filename]
54
+ @recovery_source = :branch
55
+ return
56
+ end
57
+
58
+ # Fallback to worktree recovery (uncommitted files in other worktrees)
59
+ wt_data = WorktreeRecovery.find_migration(version)
60
+ if wt_data
61
+ @content = wt_data[:content]
62
+ @filename = wt_data[:filename]
63
+ @recovery_source = :worktree
64
+ end
65
+ end
66
+
67
+ def write_temp_migration
68
+ temp = Tempfile.new(["migration_#{version}_", ".rb"])
69
+ temp.write(content)
70
+ temp.flush
71
+ temp
72
+ end
73
+
74
+ def load_and_execute_down(temp_file)
75
+ class_name = extract_class_name
76
+ unless class_name
77
+ raise RollbackFailed, "Could not find migration class in #{filename}"
78
+ end
79
+
80
+ load temp_file.path
81
+
82
+ unless Object.const_defined?(class_name)
83
+ raise RollbackFailed, "Loading #{filename} did not define #{class_name}"
84
+ end
85
+
86
+ Object.const_get(class_name).new.migrate(:down)
87
+ rescue ActiveRecord::IrreversibleMigration => e
88
+ raise RollbackFailed, "Migration #{version} is irreversible: #{e.message}"
89
+ rescue RollbackFailed
90
+ raise
91
+ rescue => e
92
+ raise RollbackFailed, "Failed to rollback #{version}: #{e.message}"
93
+ end
94
+
95
+ def extract_class_name
96
+ match = content.match(/class\s+(\w+)\s*<\s*ActiveRecord::Migration(\[\d+\.\d+\])?/)
97
+ match[1] if match
98
+ end
99
+
100
+ def remove_from_schema_migrations
101
+ conn = Mighost::Base.connection
102
+ conn.execute(
103
+ "DELETE FROM schema_migrations WHERE version = #{conn.quote(version)}"
104
+ )
105
+ end
106
+
107
+ class << self
108
+ def rollback_version!(version)
109
+ new(version).rollback!
110
+ end
111
+ end
112
+ end
113
+ end