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,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "active_support"
|
|
6
|
+
require "active_support/core_ext/time"
|
|
7
|
+
|
|
8
|
+
module Mighost
|
|
9
|
+
class Snapshot
|
|
10
|
+
attr_accessor :id, :version, :content, :filename, :branch_name, :git_sha, :content_hash, :migrated_at, :source,
|
|
11
|
+
:dismissed, :dismissed_at, :author_name, :author_email
|
|
12
|
+
|
|
13
|
+
# Special version used to store scan metadata
|
|
14
|
+
SCAN_METADATA_VERSION = "__scan_metadata__"
|
|
15
|
+
|
|
16
|
+
def initialize(attrs = {})
|
|
17
|
+
attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def table_name
|
|
22
|
+
"mighost_snapshots"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ensure_table_exists!
|
|
26
|
+
db.execute(<<~SQL)
|
|
27
|
+
CREATE TABLE IF NOT EXISTS #{table_name} (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
version TEXT NOT NULL UNIQUE,
|
|
30
|
+
content TEXT NOT NULL,
|
|
31
|
+
filename TEXT NOT NULL,
|
|
32
|
+
branch_name TEXT,
|
|
33
|
+
git_sha TEXT,
|
|
34
|
+
content_hash TEXT,
|
|
35
|
+
migrated_at TEXT NOT NULL,
|
|
36
|
+
source TEXT DEFAULT 'file',
|
|
37
|
+
dismissed INTEGER DEFAULT 0,
|
|
38
|
+
dismissed_at TEXT,
|
|
39
|
+
author_name TEXT,
|
|
40
|
+
author_email TEXT
|
|
41
|
+
)
|
|
42
|
+
SQL
|
|
43
|
+
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_#{table_name}_version ON #{table_name}(version)")
|
|
44
|
+
migrate_columns!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_by_version(version)
|
|
48
|
+
ensure_table_exists!
|
|
49
|
+
row = db.get_first_row(
|
|
50
|
+
"SELECT * FROM #{table_name} WHERE version = ? LIMIT 1",
|
|
51
|
+
[version.to_s]
|
|
52
|
+
)
|
|
53
|
+
return nil unless row
|
|
54
|
+
|
|
55
|
+
row_to_snapshot(row)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def find_or_recover(version)
|
|
59
|
+
existing = find_by_version(version)
|
|
60
|
+
return existing if existing
|
|
61
|
+
|
|
62
|
+
require_relative "git_recovery"
|
|
63
|
+
git_data = GitRecovery.find_migration(version)
|
|
64
|
+
if git_data
|
|
65
|
+
return store(
|
|
66
|
+
version: version,
|
|
67
|
+
content: git_data[:content],
|
|
68
|
+
filename: git_data[:filename],
|
|
69
|
+
branch_name: git_data[:branch],
|
|
70
|
+
git_sha: git_data[:sha],
|
|
71
|
+
source: "git",
|
|
72
|
+
author_name: git_data[:author_name],
|
|
73
|
+
author_email: git_data[:author_email]
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
require_relative "worktree_recovery"
|
|
78
|
+
wt_data = WorktreeRecovery.find_migration(version)
|
|
79
|
+
return nil unless wt_data
|
|
80
|
+
|
|
81
|
+
store(
|
|
82
|
+
version: version,
|
|
83
|
+
content: wt_data[:content],
|
|
84
|
+
filename: wt_data[:filename],
|
|
85
|
+
branch_name: wt_data[:branch],
|
|
86
|
+
source: "worktree",
|
|
87
|
+
author_name: wt_data[:author_name],
|
|
88
|
+
author_email: wt_data[:author_email]
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def store(version:, content:, filename:, branch_name: nil, git_sha: nil, source: "file", author_name: nil, author_email: nil)
|
|
93
|
+
ensure_table_exists!
|
|
94
|
+
content_hash = Digest::SHA256.hexdigest(content)
|
|
95
|
+
migrated_at = Time.current.iso8601
|
|
96
|
+
|
|
97
|
+
existing = find_by_version(version)
|
|
98
|
+
if existing
|
|
99
|
+
db.execute(<<~SQL, [content, filename, branch_name, git_sha, content_hash, migrated_at, source, author_name, author_email, version.to_s])
|
|
100
|
+
UPDATE #{table_name}
|
|
101
|
+
SET content = ?, filename = ?, branch_name = ?, git_sha = ?, content_hash = ?, migrated_at = ?, source = ?,
|
|
102
|
+
author_name = ?, author_email = ?
|
|
103
|
+
WHERE version = ?
|
|
104
|
+
SQL
|
|
105
|
+
else
|
|
106
|
+
db.execute(<<~SQL, [version.to_s, content, filename, branch_name, git_sha, content_hash, migrated_at, source, author_name, author_email])
|
|
107
|
+
INSERT INTO #{table_name} (version, content, filename, branch_name, git_sha, content_hash, migrated_at, source, author_name, author_email)
|
|
108
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
109
|
+
SQL
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
find_by_version(version)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Store scan context metadata (branch and SHA when scan was performed)
|
|
116
|
+
def store_scan_metadata(branch:, sha:)
|
|
117
|
+
ensure_table_exists!
|
|
118
|
+
store(
|
|
119
|
+
version: SCAN_METADATA_VERSION,
|
|
120
|
+
content: "",
|
|
121
|
+
filename: "",
|
|
122
|
+
branch_name: branch,
|
|
123
|
+
git_sha: sha,
|
|
124
|
+
source: "metadata"
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Retrieve scan context metadata
|
|
129
|
+
def scan_metadata
|
|
130
|
+
ensure_table_exists!
|
|
131
|
+
record = find_by_version(SCAN_METADATA_VERSION)
|
|
132
|
+
return nil unless record
|
|
133
|
+
|
|
134
|
+
{branch: record.branch_name, sha: record.git_sha}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Dismiss a migration (mark as hidden)
|
|
138
|
+
def dismiss(version)
|
|
139
|
+
ensure_table_exists!
|
|
140
|
+
existing = find_by_version(version)
|
|
141
|
+
|
|
142
|
+
unless existing
|
|
143
|
+
store(
|
|
144
|
+
version: version,
|
|
145
|
+
content: "",
|
|
146
|
+
filename: "",
|
|
147
|
+
source: nil
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
dismissed_at = Time.current.iso8601
|
|
152
|
+
db.execute(
|
|
153
|
+
"UPDATE #{table_name} SET dismissed = ?, dismissed_at = ? WHERE version = ?",
|
|
154
|
+
[1, dismissed_at, version.to_s]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
find_by_version(version)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Undismiss a migration (restore visibility)
|
|
161
|
+
def undismiss(version)
|
|
162
|
+
ensure_table_exists!
|
|
163
|
+
existing = find_by_version(version)
|
|
164
|
+
return nil unless existing
|
|
165
|
+
|
|
166
|
+
db.execute(
|
|
167
|
+
"UPDATE #{table_name} SET dismissed = ?, dismissed_at = ? WHERE version = ?",
|
|
168
|
+
[0, nil, version.to_s]
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
find_by_version(version)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Get all dismissed versions
|
|
175
|
+
def dismissed_versions
|
|
176
|
+
ensure_table_exists!
|
|
177
|
+
db.execute(
|
|
178
|
+
"SELECT version FROM #{table_name} WHERE dismissed = 1 AND version != ?",
|
|
179
|
+
[SCAN_METADATA_VERSION]
|
|
180
|
+
).flatten
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def delete_by_version(version)
|
|
184
|
+
ensure_table_exists!
|
|
185
|
+
db.execute("DELETE FROM #{table_name} WHERE version = ?", [version.to_s])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def all_snapshots
|
|
189
|
+
ensure_table_exists!
|
|
190
|
+
db.execute(
|
|
191
|
+
"SELECT * FROM #{table_name} WHERE version != ? ORDER BY migrated_at DESC",
|
|
192
|
+
[SCAN_METADATA_VERSION]
|
|
193
|
+
).map { |row| row_to_snapshot(row) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Lightweight version - only metadata, no content (for status display)
|
|
197
|
+
def all_metadata
|
|
198
|
+
ensure_table_exists!
|
|
199
|
+
cols = %i[version filename branch_name git_sha migrated_at source dismissed author_name author_email]
|
|
200
|
+
db.execute(
|
|
201
|
+
"SELECT version, filename, branch_name, git_sha, migrated_at, source, dismissed, author_name, author_email FROM #{table_name} WHERE version != ?",
|
|
202
|
+
[SCAN_METADATA_VERSION]
|
|
203
|
+
).map do |row|
|
|
204
|
+
record = new(cols.zip(row).to_h)
|
|
205
|
+
record.dismissed = record.dismissed == 1
|
|
206
|
+
record
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def all_versions
|
|
211
|
+
ensure_table_exists!
|
|
212
|
+
db.execute(
|
|
213
|
+
"SELECT version FROM #{table_name} WHERE version != ?",
|
|
214
|
+
[SCAN_METADATA_VERSION]
|
|
215
|
+
).flatten
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Close and forget the database handle. Needed when storage_path changes
|
|
219
|
+
# (the handle is memoized on first access).
|
|
220
|
+
def reset_db!
|
|
221
|
+
@db&.close
|
|
222
|
+
@db = nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
def db
|
|
228
|
+
@db ||= begin
|
|
229
|
+
require "sqlite3"
|
|
230
|
+
SQLite3::Database.new(resolve_storage_path)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def migrate_columns!
|
|
235
|
+
columns = db.execute("PRAGMA table_info(#{table_name})").map { |c| c[1] }
|
|
236
|
+
|
|
237
|
+
unless columns.include?("source")
|
|
238
|
+
db.execute("ALTER TABLE #{table_name} ADD COLUMN source TEXT DEFAULT 'file'")
|
|
239
|
+
end
|
|
240
|
+
unless columns.include?("dismissed")
|
|
241
|
+
db.execute("ALTER TABLE #{table_name} ADD COLUMN dismissed INTEGER DEFAULT 0")
|
|
242
|
+
end
|
|
243
|
+
unless columns.include?("dismissed_at")
|
|
244
|
+
db.execute("ALTER TABLE #{table_name} ADD COLUMN dismissed_at TEXT")
|
|
245
|
+
end
|
|
246
|
+
unless columns.include?("author_name")
|
|
247
|
+
db.execute("ALTER TABLE #{table_name} ADD COLUMN author_name TEXT")
|
|
248
|
+
end
|
|
249
|
+
unless columns.include?("author_email")
|
|
250
|
+
db.execute("ALTER TABLE #{table_name} ADD COLUMN author_email TEXT")
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
SNAPSHOT_COLUMNS = %i[id version content filename branch_name git_sha content_hash migrated_at source dismissed dismissed_at author_name author_email].freeze
|
|
255
|
+
private_constant :SNAPSHOT_COLUMNS
|
|
256
|
+
|
|
257
|
+
def row_to_snapshot(row)
|
|
258
|
+
record = new(SNAPSHOT_COLUMNS.zip(row).to_h)
|
|
259
|
+
record.dismissed = record.dismissed == 1
|
|
260
|
+
record
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def resolve_storage_path
|
|
264
|
+
path = Mighost.configuration.storage_path
|
|
265
|
+
|
|
266
|
+
if defined?(Rails) && Rails.root
|
|
267
|
+
Rails.root.join(path).to_s
|
|
268
|
+
else
|
|
269
|
+
File.expand_path(path, Dir.pwd)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mighost"
|
|
4
|
+
|
|
5
|
+
namespace :mighost do
|
|
6
|
+
desc "Show available commands"
|
|
7
|
+
task :help do
|
|
8
|
+
puts <<~HELP
|
|
9
|
+
Mighost - Rollback Rails migrations even when the file is missing
|
|
10
|
+
|
|
11
|
+
Commands:
|
|
12
|
+
rails mighost:status Show orphaned migrations (in DB but no file)
|
|
13
|
+
rails mighost:rollback Rollback orphaned migrations (interactive)
|
|
14
|
+
rails mighost:scan Capture migrations + search git for orphans
|
|
15
|
+
rails mighost:list (ls) List stored migration records
|
|
16
|
+
rails mighost:dismiss Dismiss orphans (interactive, or VERSION=x)
|
|
17
|
+
rails mighost:undismiss Restore dismissed migrations
|
|
18
|
+
rails mighost:dismissed List dismissed migrations
|
|
19
|
+
rails mighost:cleanup Remove stale records
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
VERSION=x Target specific version (rollback, dismiss, undismiss)
|
|
23
|
+
VERSION=a..b Version range: a to b inclusive
|
|
24
|
+
VERSION=..b All versions up to b
|
|
25
|
+
VERSION=a.. All versions from a onwards
|
|
26
|
+
RESCAN=1 Force re-capture all snapshots (scan)
|
|
27
|
+
FORCE=1 Skip confirmations
|
|
28
|
+
OLD=1 Dismiss only old unrecoverable migrations (dismiss)
|
|
29
|
+
SHOW_DISMISSED=1 Include dismissed in status
|
|
30
|
+
LIMIT=N Limit output rows (list)
|
|
31
|
+
DEBUG=1 Verbose output (status)
|
|
32
|
+
|
|
33
|
+
Typical workflow:
|
|
34
|
+
1. rails mighost:scan # First time setup
|
|
35
|
+
2. rails mighost:status # Check for orphaned migrations
|
|
36
|
+
3. rails mighost:rollback # Rollback interactively
|
|
37
|
+
4. rails mighost:dismiss # Dismiss unrecoverable orphans
|
|
38
|
+
|
|
39
|
+
HELP
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
desc "Show orphaned migrations (in DB but no file). DEBUG=1, SHOW_DISMISSED=1"
|
|
43
|
+
task status: :environment do
|
|
44
|
+
ui = Mighost.ui
|
|
45
|
+
Mighost::OrphanDetector.debug = ENV["DEBUG"] == "1"
|
|
46
|
+
show_dismissed = ENV["SHOW_DISMISSED"] == "1"
|
|
47
|
+
|
|
48
|
+
orphans = Mighost::API.orphaned_migrations(include_dismissed: show_dismissed)
|
|
49
|
+
ui.render_orphans(orphans)
|
|
50
|
+
|
|
51
|
+
unless show_dismissed
|
|
52
|
+
dismissed_count = Mighost::API.dismissed_count
|
|
53
|
+
ui.hint "#{dismissed_count} dismissed migration(s) hidden. Use SHOW_DISMISSED=1 to show." if dismissed_count.positive?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
non_recoverable = orphans.reject(&:recoverable?)
|
|
57
|
+
if non_recoverable.any? && Mighost::OrphanDetector.branch_changed?
|
|
58
|
+
ui.hint "Branch changed since last scan. Run 'rails mighost:scan' to update recovery info."
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
desc "Rollback orphaned migrations. VERSION=xxx for specific, FORCE=1 to skip confirmation"
|
|
63
|
+
task rollback: :environment do
|
|
64
|
+
ui = Mighost.ui
|
|
65
|
+
version = ENV["VERSION"]
|
|
66
|
+
force = ENV["FORCE"] == "1"
|
|
67
|
+
|
|
68
|
+
if version
|
|
69
|
+
begin
|
|
70
|
+
Mighost::API.rollback_version!(version)
|
|
71
|
+
ui.success "Successfully rolled back migration #{version}"
|
|
72
|
+
rescue Mighost::SnapshotNotFound
|
|
73
|
+
ui.error "No record found for version #{version}"
|
|
74
|
+
rescue Mighost::RollbackFailed => e
|
|
75
|
+
ui.error "Failed to rollback: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
next
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
orphans = Mighost::API.orphaned_migrations.select(&:recoverable?)
|
|
81
|
+
|
|
82
|
+
if orphans.empty?
|
|
83
|
+
ui.info "No recoverable orphaned migrations found."
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
ui.render_recoverable_list(orphans)
|
|
88
|
+
|
|
89
|
+
results = {rolled_back: [], failed: [], skipped: []}
|
|
90
|
+
rollback_all = force
|
|
91
|
+
|
|
92
|
+
orphans.each do |orphan|
|
|
93
|
+
unless rollback_all
|
|
94
|
+
response = ui.confirm_each?(orphan)
|
|
95
|
+
case response
|
|
96
|
+
when :quit
|
|
97
|
+
ui.warning "Aborted."
|
|
98
|
+
break
|
|
99
|
+
when :no
|
|
100
|
+
results[:skipped] << orphan.version
|
|
101
|
+
next
|
|
102
|
+
when :all
|
|
103
|
+
rollback_all = true
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
Mighost::API.rollback_version!(orphan.version)
|
|
109
|
+
results[:rolled_back] << orphan.version
|
|
110
|
+
ui.success " Rolled back #{orphan.version}"
|
|
111
|
+
rescue => e
|
|
112
|
+
results[:failed] << {version: orphan.version, error: e.message}
|
|
113
|
+
ui.error " Failed: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
ui.info "Done: #{results[:rolled_back].size} rolled back, #{results[:skipped].size} skipped, #{results[:failed].size} failed"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
desc "Scan and capture existing migrations, search git for orphans. RESCAN=1 to force re-capture all"
|
|
121
|
+
task scan: :environment do
|
|
122
|
+
ui = Mighost.ui
|
|
123
|
+
result = Mighost::API.scan!(rescan: ENV["RESCAN"] == "1")
|
|
124
|
+
|
|
125
|
+
ui.render_scan_details(result[:details])
|
|
126
|
+
ui.render_scan_results(
|
|
127
|
+
captured: result[:captured],
|
|
128
|
+
git_recovered: result[:git_recovered],
|
|
129
|
+
worktree_recovered: result[:worktree_recovered],
|
|
130
|
+
branch: result[:branch],
|
|
131
|
+
sha: result[:sha]
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
desc "List stored migration records (LIMIT=N to limit)"
|
|
136
|
+
task list: :environment do
|
|
137
|
+
limit = ENV["LIMIT"]&.to_i
|
|
138
|
+
records = Mighost::API.all_snapshots
|
|
139
|
+
total_count = records.count
|
|
140
|
+
display_records = limit&.positive? ? records.first(limit) : records
|
|
141
|
+
|
|
142
|
+
Mighost.ui.render_records(display_records, limit: limit, total_count: total_count)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
task ls: :list
|
|
146
|
+
|
|
147
|
+
desc "Clean up stale records for migrations no longer in schema_migrations"
|
|
148
|
+
task cleanup: :environment do
|
|
149
|
+
ui = Mighost.ui
|
|
150
|
+
Mighost::Snapshot.ensure_table_exists!
|
|
151
|
+
migrated_versions = Mighost::Base.connection
|
|
152
|
+
.select_values("SELECT version FROM schema_migrations")
|
|
153
|
+
.map(&:to_s)
|
|
154
|
+
|
|
155
|
+
all_record_versions = Mighost::Snapshot.all_versions
|
|
156
|
+
stale_versions = all_record_versions - migrated_versions
|
|
157
|
+
|
|
158
|
+
if stale_versions.empty?
|
|
159
|
+
ui.info "No stale records to clean up."
|
|
160
|
+
next
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
ui.warning "Found #{stale_versions.count} stale record(s):"
|
|
164
|
+
stale_versions.each { |v| ui.info " #{v}" }
|
|
165
|
+
|
|
166
|
+
unless ENV["FORCE"] == "1"
|
|
167
|
+
unless ui.confirm?("Delete these records?")
|
|
168
|
+
ui.warning "Aborted."
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
stale_versions.each { |v| Mighost::Snapshot.delete_by_version(v) }
|
|
174
|
+
ui.success "Cleaned up #{stale_versions.count} record(s)."
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
desc "Dismiss orphaned migrations (hide from status). VERSION=x, VERSION=a..b (range), OLD=1"
|
|
178
|
+
task dismiss: :environment do
|
|
179
|
+
ui = Mighost.ui
|
|
180
|
+
version_param = ENV["VERSION"]
|
|
181
|
+
old_only = ENV["OLD"] == "1"
|
|
182
|
+
|
|
183
|
+
orphans = Mighost::API.orphaned_migrations
|
|
184
|
+
|
|
185
|
+
if old_only
|
|
186
|
+
orphans = orphans.select(&:suggested_for_dismiss?)
|
|
187
|
+
if orphans.empty?
|
|
188
|
+
ui.info "No old unrecoverable migrations to dismiss."
|
|
189
|
+
ui.hint "Migrations must be older than 6 months and not recoverable."
|
|
190
|
+
next
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if orphans.empty?
|
|
195
|
+
ui.info "No orphaned migrations to dismiss."
|
|
196
|
+
next
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
orphan_versions = orphans.map(&:version).sort
|
|
200
|
+
orphan_map = orphans.index_by(&:version)
|
|
201
|
+
|
|
202
|
+
to_dismiss = if version_param
|
|
203
|
+
Mighost::Formatting.parse_version_range(version_param, orphan_versions)
|
|
204
|
+
else
|
|
205
|
+
orphan_versions
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if to_dismiss.empty?
|
|
209
|
+
ui.warning "No matching orphaned migrations found."
|
|
210
|
+
next
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
ui.render_dismiss_preview(to_dismiss, orphan_map)
|
|
214
|
+
|
|
215
|
+
results = {dismissed: [], skipped: []}
|
|
216
|
+
dismiss_all = ENV["FORCE"] == "1"
|
|
217
|
+
|
|
218
|
+
to_dismiss.each do |version|
|
|
219
|
+
orphan = orphan_map[version]
|
|
220
|
+
name = orphan&.filename || "(unknown)"
|
|
221
|
+
|
|
222
|
+
unless dismiss_all
|
|
223
|
+
response = ui.confirm_dismiss?(version, name)
|
|
224
|
+
case response
|
|
225
|
+
when :quit
|
|
226
|
+
ui.warning "Aborted."
|
|
227
|
+
break
|
|
228
|
+
when :no
|
|
229
|
+
results[:skipped] << version
|
|
230
|
+
next
|
|
231
|
+
when :all
|
|
232
|
+
remaining = to_dismiss[to_dismiss.index(version)..]
|
|
233
|
+
ui.render_dismiss_remaining(remaining, orphan_map)
|
|
234
|
+
|
|
235
|
+
if ui.confirm?("Confirm dismiss all #{remaining.count}?")
|
|
236
|
+
remaining.each do |v|
|
|
237
|
+
Mighost::API.dismiss(v)
|
|
238
|
+
results[:dismissed] << v
|
|
239
|
+
end
|
|
240
|
+
ui.success "Dismissed #{remaining.count} migration(s)."
|
|
241
|
+
else
|
|
242
|
+
ui.warning "Aborted."
|
|
243
|
+
end
|
|
244
|
+
break
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
Mighost::API.dismiss(version)
|
|
249
|
+
results[:dismissed] << version
|
|
250
|
+
ui.success " Dismissed #{version}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
ui.info "Done: #{results[:dismissed].size} dismissed, #{results[:skipped].size} skipped"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
desc "Undismiss migrations (restore visibility). VERSION=x or VERSION=a..b"
|
|
257
|
+
task undismiss: :environment do
|
|
258
|
+
ui = Mighost.ui
|
|
259
|
+
version_param = ENV["VERSION"]
|
|
260
|
+
|
|
261
|
+
unless version_param
|
|
262
|
+
ui.error "VERSION required. Use same format as dismiss."
|
|
263
|
+
next
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
dismissed = Mighost::Snapshot.dismissed_versions.sort
|
|
267
|
+
|
|
268
|
+
if dismissed.empty?
|
|
269
|
+
ui.info "No dismissed migrations."
|
|
270
|
+
next
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
targets = Mighost::Formatting.parse_version_range(version_param, dismissed)
|
|
274
|
+
|
|
275
|
+
if targets.empty?
|
|
276
|
+
ui.warning "No matching dismissed migrations found."
|
|
277
|
+
next
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
targets.each do |version|
|
|
281
|
+
Mighost::API.undismiss(version)
|
|
282
|
+
ui.success "Undismissed #{version}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
ui.info "Restored #{targets.count} migration(s) to status output."
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
desc "List dismissed migrations"
|
|
289
|
+
task dismissed: :environment do
|
|
290
|
+
dismissed = Mighost::Snapshot.dismissed_versions.sort
|
|
291
|
+
|
|
292
|
+
if dismissed.empty?
|
|
293
|
+
Mighost.ui.info "No dismissed migrations."
|
|
294
|
+
next
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
dismissed_snapshots = dismissed.map { |v| [v, Mighost::API.find_snapshot(v)] }
|
|
298
|
+
Mighost.ui.render_dismissed_list(dismissed_snapshots)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "active_support"
|
|
5
|
+
require "active_support/core_ext/object/blank"
|
|
6
|
+
require_relative "git_metadata"
|
|
7
|
+
|
|
8
|
+
module Mighost
|
|
9
|
+
# Searches other git worktrees for migration files that exist on disk
|
|
10
|
+
# but haven't been committed yet (invisible to git log --all)
|
|
11
|
+
module WorktreeRecovery
|
|
12
|
+
class << self
|
|
13
|
+
# Find migration file in other worktrees
|
|
14
|
+
# Returns { content:, filename:, branch:, worktree_path: } or nil
|
|
15
|
+
def find_migration(version)
|
|
16
|
+
return nil unless GitMetadata.git_available?
|
|
17
|
+
|
|
18
|
+
worktrees = list_worktrees
|
|
19
|
+
return nil if worktrees.empty?
|
|
20
|
+
|
|
21
|
+
current_root = git_root
|
|
22
|
+
return nil unless current_root
|
|
23
|
+
|
|
24
|
+
worktrees.each do |wt|
|
|
25
|
+
next if wt[:path] == current_root
|
|
26
|
+
|
|
27
|
+
result = find_in_worktree(wt, version)
|
|
28
|
+
return result if result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# List all git worktrees with their paths and branches
|
|
35
|
+
def list_worktrees
|
|
36
|
+
output = `git worktree list --porcelain 2>/dev/null`.strip
|
|
37
|
+
return [] if output.empty?
|
|
38
|
+
|
|
39
|
+
worktrees = []
|
|
40
|
+
current = {}
|
|
41
|
+
|
|
42
|
+
output.each_line do |line|
|
|
43
|
+
line = line.chomp
|
|
44
|
+
if line.start_with?("worktree ")
|
|
45
|
+
current = {path: line.sub("worktree ", "")}
|
|
46
|
+
elsif line.start_with?("branch ")
|
|
47
|
+
ref = line.sub("branch ", "")
|
|
48
|
+
# refs/heads/feature-branch -> feature-branch
|
|
49
|
+
current[:branch] = ref.sub(%r{^refs/heads/}, "")
|
|
50
|
+
elsif line == "detached"
|
|
51
|
+
current[:branch] = nil
|
|
52
|
+
elsif line.empty? && current[:path]
|
|
53
|
+
worktrees << current
|
|
54
|
+
current = {}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Last entry (no trailing blank line)
|
|
59
|
+
worktrees << current if current[:path]
|
|
60
|
+
|
|
61
|
+
worktrees
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def find_in_worktree(wt, version)
|
|
67
|
+
pattern = File.join(wt[:path], "db", "migrate", "#{version}_*.rb")
|
|
68
|
+
matches = Dir.glob(pattern)
|
|
69
|
+
return nil if matches.empty?
|
|
70
|
+
|
|
71
|
+
file = matches.first
|
|
72
|
+
content = File.read(file)
|
|
73
|
+
|
|
74
|
+
author_name, author_email = worktree_git_user(wt[:path])
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
content: content,
|
|
78
|
+
filename: File.basename(file),
|
|
79
|
+
branch: wt[:branch],
|
|
80
|
+
worktree_path: wt[:path],
|
|
81
|
+
author_name: author_name,
|
|
82
|
+
author_email: author_email
|
|
83
|
+
}
|
|
84
|
+
rescue => e
|
|
85
|
+
warn "[Mighost] Failed to read worktree migration #{file}: #{e.message}"
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def worktree_git_user(path)
|
|
90
|
+
escaped = Shellwords.escape(path)
|
|
91
|
+
name = `git -C #{escaped} config user.name 2>/dev/null`.strip
|
|
92
|
+
email = `git -C #{escaped} config user.email 2>/dev/null`.strip
|
|
93
|
+
[name.presence, email.presence]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def git_root
|
|
97
|
+
result = `git rev-parse --show-toplevel 2>/dev/null`.strip
|
|
98
|
+
result.empty? ? nil : result
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|