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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +21 -0
- data/README.md +189 -0
- data/lib/generators/mighost/install_generator.rb +22 -0
- data/lib/generators/mighost/templates/initializer.rb.tt +15 -0
- data/lib/mighost/api.rb +155 -0
- data/lib/mighost/capturer.rb +59 -0
- data/lib/mighost/configuration.rb +36 -0
- data/lib/mighost/formatting.rb +89 -0
- data/lib/mighost/git_metadata.rb +31 -0
- data/lib/mighost/git_recovery.rb +129 -0
- data/lib/mighost/migrator_extension.rb +30 -0
- data/lib/mighost/orphan_detector.rb +173 -0
- data/lib/mighost/plain_ui.rb +225 -0
- data/lib/mighost/railtie.rb +27 -0
- data/lib/mighost/rollbacker.rb +113 -0
- data/lib/mighost/snapshot.rb +274 -0
- data/lib/mighost/tasks.rake +300 -0
- data/lib/mighost/version.rb +5 -0
- data/lib/mighost/worktree_recovery.rb +102 -0
- data/lib/mighost.rb +53 -0
- metadata +121 -0
|
@@ -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
|