railbow 0.4.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +79 -1
- data/lib/railbow/config.rb +3 -1
- data/lib/railbow/init.rb +16 -0
- data/lib/railbow/multi_db.rb +87 -0
- data/lib/railbow/params.rb +54 -0
- data/lib/railbow/status/ghosts.rb +144 -0
- data/lib/railbow/status/git_data.rb +327 -0
- data/lib/railbow/status/help.rb +121 -0
- data/lib/railbow/status/printer.rb +324 -0
- data/lib/railbow/status/section.rb +551 -0
- data/lib/railbow/table/column.rb +7 -2
- data/lib/railbow/table/renderer.rb +47 -18
- data/lib/railbow/tasks/migrate_status.rake +30 -756
- data/lib/railbow/version.rb +1 -1
- metadata +7 -1
|
@@ -1,780 +1,54 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative "../
|
|
5
|
-
require_relative "../
|
|
6
|
-
require_relative "../
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
#
|
|
13
|
-
#
|
|
3
|
+
require_relative "../multi_db"
|
|
4
|
+
require_relative "../status/help"
|
|
5
|
+
require_relative "../status/printer"
|
|
6
|
+
require_relative "../status/section"
|
|
7
|
+
|
|
8
|
+
# Overrides the two DatabaseTasks methods db:migrate:status runs through.
|
|
9
|
+
#
|
|
10
|
+
# Rails calls migrate_status once per database, inside with_temporary_pool_for_each
|
|
11
|
+
# - both for db:migrate:status and for db:migrate:status:<database_name>. Wrapping
|
|
12
|
+
# the loop is what lets railbow render the databases together instead of one
|
|
13
|
+
# blind table each.
|
|
14
14
|
module Railbow
|
|
15
15
|
module MigrateStatusFormatter
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def initialize(filename: nil, branch_name: nil, source: nil, superseded_by: nil,
|
|
23
|
-
deleted_in_sha: nil, author_name: nil, author_email: nil, content: nil)
|
|
24
|
-
@filename = filename
|
|
25
|
-
@branch_name = branch_name
|
|
26
|
-
@source = source
|
|
27
|
-
@superseded_by = superseded_by
|
|
28
|
-
@deleted_in_sha = deleted_in_sha
|
|
29
|
-
@author_name = author_name
|
|
30
|
-
@author_email = author_email
|
|
31
|
-
@content = content
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
def mighost_attr(obj, name)
|
|
38
|
-
obj.respond_to?(name) ? obj.public_send(name) : nil
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def mighost_snapshot_content(version)
|
|
42
|
-
Mighost::API.find_snapshot(version)&.content
|
|
43
|
-
rescue
|
|
44
|
-
nil
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def load_ghost_rows(versions, with_content: false)
|
|
48
|
-
# Detect once: OrphanedMigration carries the classification (supersession,
|
|
49
|
-
# deletion commit) that a bare snapshot doesn't, and already honors
|
|
50
|
-
# dismissals and hide_superseded.
|
|
51
|
-
orphans = begin
|
|
52
|
-
Mighost::API.orphaned_migrations.to_h { |o| [o.version.to_s, o] }
|
|
53
|
-
rescue
|
|
54
|
-
return {}
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
rows = {}
|
|
58
|
-
versions.each do |v|
|
|
59
|
-
# Absent from detect = deliberately suppressed (dismissed, or superseded
|
|
60
|
-
# with hide_superseded on) - render as plain NO FILE, don't re-recover.
|
|
61
|
-
next unless (orphan = orphans[v])
|
|
62
|
-
|
|
63
|
-
if orphan.filename && !orphan.filename.empty?
|
|
64
|
-
rows[v] = GhostRow.new(
|
|
65
|
-
filename: orphan.filename,
|
|
66
|
-
branch_name: orphan.branch_name,
|
|
67
|
-
source: mighost_attr(orphan, :source),
|
|
68
|
-
superseded_by: mighost_attr(orphan, :superseded_by),
|
|
69
|
-
deleted_in_sha: mighost_attr(orphan, :deleted_in_sha),
|
|
70
|
-
author_name: mighost_attr(orphan, :author_name),
|
|
71
|
-
author_email: mighost_attr(orphan, :author_email),
|
|
72
|
-
content: with_content ? mighost_snapshot_content(v) : nil
|
|
73
|
-
)
|
|
74
|
-
else
|
|
75
|
-
# Detect reads stored snapshots only. A version it lists without a
|
|
76
|
-
# filename has no snapshot yet, so fall back to live git/worktree
|
|
77
|
-
# recovery - keeps fresh clones working with zero setup.
|
|
78
|
-
snapshot = begin
|
|
79
|
-
Mighost::API.find_or_recover_snapshot(v)
|
|
80
|
-
rescue
|
|
81
|
-
nil
|
|
82
|
-
end
|
|
83
|
-
next unless snapshot&.filename && !snapshot.filename.empty?
|
|
84
|
-
|
|
85
|
-
rows[v] = GhostRow.new(
|
|
86
|
-
filename: snapshot.filename,
|
|
87
|
-
branch_name: snapshot.branch_name,
|
|
88
|
-
source: mighost_attr(snapshot, :source),
|
|
89
|
-
superseded_by: api_superseded_by(v),
|
|
90
|
-
deleted_in_sha: mighost_attr(snapshot, :deleted_in_sha),
|
|
91
|
-
author_name: mighost_attr(snapshot, :author_name),
|
|
92
|
-
author_email: mighost_attr(snapshot, :author_email),
|
|
93
|
-
content: with_content ? snapshot.content : nil
|
|
94
|
-
)
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
rows
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def api_superseded_by(version)
|
|
101
|
-
return nil unless Mighost::API.respond_to?(:superseded_by)
|
|
102
|
-
|
|
103
|
-
Mighost::API.superseded_by(version)
|
|
104
|
-
rescue
|
|
105
|
-
nil
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# One tag slot per ghost row; most informative wins.
|
|
109
|
-
def ghost_tag(ghost)
|
|
110
|
-
if ghost.superseded_by
|
|
111
|
-
"\e[38;5;245m≡ #{ghost.superseded_by}\e[38;5;217m"
|
|
112
|
-
elsif ghost.branch_name
|
|
113
|
-
if ghost.source == "worktree"
|
|
114
|
-
"\e[38;5;222m⌥ₜ#{ghost.branch_name}\e[38;5;217m"
|
|
115
|
-
else
|
|
116
|
-
"\e[38;5;222m⌥ #{ghost.branch_name}\e[38;5;217m"
|
|
117
|
-
end
|
|
118
|
-
elsif ghost.deleted_in_sha
|
|
119
|
-
"\e[38;5;245m✂ deleted in:#{ghost.deleted_in_sha[0, 8]}\e[38;5;217m"
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def git_migration_authors(migrate_dir)
|
|
124
|
-
output, _status = Railbow::GitUtils.capture2(
|
|
125
|
-
"log", "--format=COMMIT:%aN\t%aE", "--diff-filter=AR", "--name-status", "--", migrate_dir
|
|
126
|
-
)
|
|
127
|
-
return {names: {}, emails: {}} if output.empty?
|
|
128
|
-
|
|
129
|
-
names = {}
|
|
130
|
-
emails = {}
|
|
131
|
-
current_name = nil
|
|
132
|
-
current_email = nil
|
|
133
|
-
output.each_line do |line|
|
|
134
|
-
line = line.strip
|
|
135
|
-
if line.start_with?("COMMIT:")
|
|
136
|
-
parts = line.sub("COMMIT:", "").split("\t", 2)
|
|
137
|
-
current_name = parts[0]
|
|
138
|
-
current_email = parts[1]&.downcase
|
|
139
|
-
elsif !line.empty? && current_name
|
|
140
|
-
# --name-status lines: "A\tfilepath" or "Rnnn\told\tnew"
|
|
141
|
-
cols = line.split("\t")
|
|
142
|
-
status_code = cols[0]
|
|
143
|
-
filepath = if status_code&.start_with?("R")
|
|
144
|
-
# Renamed: map the destination (new) filename to the author
|
|
145
|
-
cols[2]
|
|
146
|
-
else
|
|
147
|
-
cols[1]
|
|
148
|
-
end
|
|
149
|
-
next unless filepath
|
|
150
|
-
basename = File.basename(filepath)
|
|
151
|
-
names[basename] ||= current_name
|
|
152
|
-
emails[basename] ||= current_email
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
{names: names, emails: emails}
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Returns a hash of basename → Date for when each migration file
|
|
159
|
-
# first appeared on the mainline (merge commit date via --first-parent).
|
|
160
|
-
def git_migration_landed_dates(migrate_dir)
|
|
161
|
-
output, _status = Railbow::GitUtils.capture2(
|
|
162
|
-
"log", "--first-parent", "--format=COMMIT:%cI", "--diff-filter=AR", "--name-status", "--", migrate_dir
|
|
163
|
-
)
|
|
164
|
-
return {} if output.empty?
|
|
165
|
-
|
|
166
|
-
dates = {}
|
|
167
|
-
current_date = nil
|
|
168
|
-
output.each_line do |line|
|
|
169
|
-
line = line.strip
|
|
170
|
-
if line.start_with?("COMMIT:")
|
|
171
|
-
current_date = begin
|
|
172
|
-
Date.parse(line.sub("COMMIT:", ""))
|
|
173
|
-
rescue Date::Error
|
|
174
|
-
nil
|
|
175
|
-
end
|
|
176
|
-
elsif !line.empty? && current_date
|
|
177
|
-
cols = line.split("\t")
|
|
178
|
-
filepath = cols[0]&.start_with?("R") ? cols[2] : cols[1]
|
|
179
|
-
next unless filepath
|
|
180
|
-
basename = File.basename(filepath)
|
|
181
|
-
dates[basename] ||= current_date
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
dates
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def current_git_email
|
|
188
|
-
output, _status = Railbow::GitUtils.capture2("config", "user.email")
|
|
189
|
-
output.strip.downcase
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def current_git_name
|
|
193
|
-
output, _status = Railbow::GitUtils.capture2("config", "user.name")
|
|
194
|
-
output.strip
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
def apply_branch_mask(branch, branch_mask)
|
|
198
|
-
return branch if branch_mask.empty?
|
|
199
|
-
|
|
200
|
-
return Railbow::Params.extract_branch_ticket(branch) if branch_mask == "auto"
|
|
201
|
-
|
|
202
|
-
re = begin
|
|
203
|
-
Regexp.new(branch_mask, Regexp::IGNORECASE)
|
|
204
|
-
rescue RegexpError
|
|
205
|
-
return branch
|
|
206
|
-
end
|
|
207
|
-
m = branch.match(re)
|
|
208
|
-
(m && m[1]) ? m[1] : branch
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def current_branch_name(branch_mask)
|
|
212
|
-
output, status = Railbow::GitUtils.capture2("rev-parse", "--abbrev-ref", "HEAD")
|
|
213
|
-
return "HEAD" unless status.success?
|
|
214
|
-
|
|
215
|
-
apply_branch_mask(output.strip, branch_mask)
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def detect_default_branch(override)
|
|
219
|
-
return override if override && !override.empty?
|
|
220
|
-
|
|
221
|
-
output, status = Railbow::GitUtils.capture2("symbolic-ref", "refs/remotes/origin/HEAD")
|
|
222
|
-
if status.success?
|
|
223
|
-
branch = output.strip.sub(%r{^refs/remotes/origin/}, "")
|
|
224
|
-
return branch unless branch.empty?
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
%w[main master].each do |candidate|
|
|
228
|
-
_, st = Railbow::GitUtils.capture2("rev-parse", "--verify", "refs/heads/#{candidate}")
|
|
229
|
-
return candidate if st.success?
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
"main"
|
|
16
|
+
# Every supported Rails version (7.2 through 8.1) routes the per-database
|
|
17
|
+
# loop through here. Arguments are forwarded verbatim so Rails keeps
|
|
18
|
+
# applying its own defaults.
|
|
19
|
+
def with_temporary_pool_for_each(*args, **kwargs, &block)
|
|
20
|
+
Railbow::MultiDb.batch { super(*args, **kwargs, &block) }
|
|
233
21
|
end
|
|
234
22
|
|
|
235
|
-
def git_branch_migration_origins(migrate_dir, base_branch, branch_mask)
|
|
236
|
-
merge_base_out, mb_status = Railbow::GitUtils.capture2("merge-base", "HEAD", base_branch)
|
|
237
|
-
return {} unless mb_status.success?
|
|
238
|
-
|
|
239
|
-
merge_base = merge_base_out.strip
|
|
240
|
-
diff_out, diff_status = Railbow::GitUtils.capture2(
|
|
241
|
-
"diff", "--name-status", "--diff-filter=AR", merge_base, "HEAD", "--", migrate_dir
|
|
242
|
-
)
|
|
243
|
-
return {} unless diff_status.success?
|
|
244
|
-
|
|
245
|
-
files = diff_out.each_line.map { |l|
|
|
246
|
-
cols = l.strip.split("\t")
|
|
247
|
-
cols[0]&.start_with?("R") ? cols[2] : cols[1]
|
|
248
|
-
}.compact.reject(&:empty?)
|
|
249
|
-
origins = {}
|
|
250
|
-
|
|
251
|
-
files.each do |filepath|
|
|
252
|
-
basename = File.basename(filepath)
|
|
253
|
-
|
|
254
|
-
# Find the commit that added or renamed this file
|
|
255
|
-
commit_out, cs = Railbow::GitUtils.capture2(
|
|
256
|
-
"log", "--diff-filter=AR", "--format=%H", "-1", "--", filepath
|
|
257
|
-
)
|
|
258
|
-
next unless cs.success?
|
|
259
|
-
commit = commit_out.strip
|
|
260
|
-
next if commit.empty?
|
|
261
|
-
|
|
262
|
-
# Find branches containing this commit
|
|
263
|
-
branches_out, bs = Railbow::GitUtils.capture2(
|
|
264
|
-
"branch", "--contains", commit, "--format=%(refname:short)"
|
|
265
|
-
)
|
|
266
|
-
next unless bs.success?
|
|
267
|
-
branches = branches_out.each_line.map(&:strip).reject(&:empty?)
|
|
268
|
-
next if branches.empty?
|
|
269
|
-
|
|
270
|
-
# Pick the branch that originally introduced the commit.
|
|
271
|
-
# 1. Filter out child branches: if branch A is an ancestor of branch B,
|
|
272
|
-
# the commit was introduced in A, not B.
|
|
273
|
-
# 2. Among remaining, prefer the branch with the MOST commits after the
|
|
274
|
-
# adding commit - it has been active longer since the commit was made,
|
|
275
|
-
# indicating it is the original branch (not a newer fork).
|
|
276
|
-
best = if branches.size == 1
|
|
277
|
-
branches.first
|
|
278
|
-
else
|
|
279
|
-
filtered = branches.reject do |b|
|
|
280
|
-
branches.any? do |other|
|
|
281
|
-
next false if other == b
|
|
282
|
-
_, st = Railbow::GitUtils.capture2("merge-base", "--is-ancestor", other, b)
|
|
283
|
-
st.success?
|
|
284
|
-
end
|
|
285
|
-
end
|
|
286
|
-
filtered = branches if filtered.empty?
|
|
287
|
-
|
|
288
|
-
if filtered.size == 1
|
|
289
|
-
filtered.first
|
|
290
|
-
else
|
|
291
|
-
filtered.max_by do |b|
|
|
292
|
-
count_out, _ = Railbow::GitUtils.capture2("rev-list", "--count", "#{commit}..#{b}")
|
|
293
|
-
count_out.strip.to_i
|
|
294
|
-
end
|
|
295
|
-
end
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
# Apply mask
|
|
299
|
-
label = best ? apply_branch_mask(best, branch_mask) : best
|
|
300
|
-
|
|
301
|
-
origins[basename] = label
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
origins
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
def git_uncommitted_migration_files(migrate_dir)
|
|
308
|
-
output, status = Railbow::GitUtils.capture2("status", "--porcelain", "--", migrate_dir)
|
|
309
|
-
return Set.new unless status.success?
|
|
310
|
-
|
|
311
|
-
result = Set.new
|
|
312
|
-
output.each_line do |line|
|
|
313
|
-
code = line[0..1]
|
|
314
|
-
|
|
315
|
-
if code[0] == "R"
|
|
316
|
-
# Rename: "R old -> new" or "R100 old -> new"
|
|
317
|
-
# Extract the destination (new) path
|
|
318
|
-
parts = line[3..].split(" -> ", 2)
|
|
319
|
-
filepath = (parts[1] || parts[0]).strip
|
|
320
|
-
elsif ["??", "A ", "AM", "M "].include?(code)
|
|
321
|
-
filepath = line[3..].strip
|
|
322
|
-
else
|
|
323
|
-
next
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
result << File.basename(filepath) unless filepath.empty?
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
# During an in-progress merge, files from MERGE_HEAD (e.g. main) appear
|
|
330
|
-
# as staged additions. Exclude them so they aren't tagged as ours.
|
|
331
|
-
result - git_incoming_merge_files(migrate_dir)
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
def detect_merge_source_label(branch_mask)
|
|
335
|
-
merge_head, _, status = Railbow::GitUtils.capture3("rev-parse", "MERGE_HEAD")
|
|
336
|
-
return nil unless status.success?
|
|
337
|
-
|
|
338
|
-
branches_out, _, bs = Railbow::GitUtils.capture3(
|
|
339
|
-
"branch", "--contains", merge_head.strip, "--format=%(refname:short)"
|
|
340
|
-
)
|
|
341
|
-
return nil unless bs.success?
|
|
342
|
-
|
|
343
|
-
branches = branches_out.each_line.map(&:strip).reject(&:empty?)
|
|
344
|
-
return nil if branches.empty?
|
|
345
|
-
|
|
346
|
-
branch = branches.first if branches.size == 1
|
|
347
|
-
branch ||= branches.find { |b| %w[main master develop].include?(b) }
|
|
348
|
-
branch ||= branches.first
|
|
349
|
-
|
|
350
|
-
apply_branch_mask(branch, branch_mask)
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
def git_incoming_merge_files(migrate_dir)
|
|
354
|
-
_, _, mh_status = Railbow::GitUtils.capture3("rev-parse", "MERGE_HEAD")
|
|
355
|
-
return Set.new unless mh_status.success?
|
|
356
|
-
|
|
357
|
-
output, _, status = Railbow::GitUtils.capture3(
|
|
358
|
-
"diff", "--name-only", "--diff-filter=AR", "HEAD", "MERGE_HEAD", "--", migrate_dir
|
|
359
|
-
)
|
|
360
|
-
return Set.new unless status.success?
|
|
361
|
-
|
|
362
|
-
Set.new(output.each_line.map { |l| File.basename(l.strip) }.reject(&:empty?))
|
|
363
|
-
end
|
|
364
|
-
|
|
365
|
-
def print_help
|
|
366
|
-
Railbow.print_logo
|
|
367
|
-
puts <<~HELP
|
|
368
|
-
|
|
369
|
-
Enhanced db:migrate:status
|
|
370
|
-
|
|
371
|
-
\e[1mUsage:\e[0m
|
|
372
|
-
[RBW_*=value ...] rake db:migrate:status
|
|
373
|
-
|
|
374
|
-
\e[1mOptions:\e[0m
|
|
375
|
-
RBW_SINCE=<period> Filter migrations by age (default: all)
|
|
376
|
-
Values: all, 2mo, 1w, 30d, 1y, etc.
|
|
377
|
-
Units: d (days), w (weeks), mo/m (months), y (years)
|
|
378
|
-
|
|
379
|
-
RBW_DATE=<mode> Date column format (default: full):
|
|
380
|
-
full - 2026-01-30 12:08:54 (column: Created At)
|
|
381
|
-
rel - ~3d ago
|
|
382
|
-
short - Jan 30 (column: Date)
|
|
383
|
-
custom(…) - user strftime, e.g. custom(%b %d, %Y)
|
|
384
|
-
|
|
385
|
-
RBW_VIEW=<options> Display options (comma-separated):
|
|
386
|
-
calendar - show month/year separator lines + week ticks
|
|
387
|
-
tables - parse migration files, show Tables column
|
|
388
|
-
|
|
389
|
-
RBW_COMPACT=<options> Compact display (comma-separated):
|
|
390
|
-
oneline - truncate instead of wrapping
|
|
391
|
-
dense - remove cell padding
|
|
392
|
-
noheader - hide table header row
|
|
393
|
-
maxw:<n> - cap column widths at n chars
|
|
394
|
-
hide:<col> - hide a column by name (repeatable)
|
|
395
|
-
|
|
396
|
-
RBW_CALENDAR=<options> Calendar sub-options (requires RBW_VIEW=calendar):
|
|
397
|
-
(empty) - month separators only, no week
|
|
398
|
-
markers at all
|
|
399
|
-
wticks - week tick marks on the date column
|
|
400
|
-
wdividers - a separator row per ISO week
|
|
401
|
-
counts - append "· N migrations" to every
|
|
402
|
-
separator row (that section)
|
|
403
|
-
label:<fmt> - strftime for month separators
|
|
404
|
-
(default: %b %Y W%V)
|
|
405
|
-
wlabel:<fmt> - strftime for week separators
|
|
406
|
-
(default: same as label, so the
|
|
407
|
-
week number never shifts)
|
|
408
|
-
|
|
409
|
-
RBW_GIT=<options> Git integration (comma-separated):
|
|
410
|
-
author - add an Author column (same as author:all)
|
|
411
|
-
author:all - add an Author column
|
|
412
|
-
author:me - highlight your own migrations
|
|
413
|
-
diff - tag migrations by git origin
|
|
414
|
-
base:<branch> - base branch for diff (default: auto-detected)
|
|
415
|
-
mask:<re> - regex to extract branch label
|
|
416
|
-
e.g. mask:(WS-[^/]+)/
|
|
417
|
-
mask:auto - auto-extract ticket id from branch name
|
|
418
|
-
|
|
419
|
-
RBW_PLAIN=1 Disable Railbow formatting (plain Rails output)
|
|
420
|
-
|
|
421
|
-
RBW_FORCE=1 Force Railbow formatting even when piped, in CI,
|
|
422
|
-
or called by an LLM agent (RBW_PLAIN=1 still wins)
|
|
423
|
-
|
|
424
|
-
RBW_HELP=1 Show this help message
|
|
425
|
-
|
|
426
|
-
\e[2mAuto-disabled when piped, in CI, or when called by an LLM agent.\e[0m
|
|
427
|
-
|
|
428
|
-
\e[1mExamples:\e[0m
|
|
429
|
-
rake db:migrate:status
|
|
430
|
-
RBW_SINCE=2mo RBW_VIEW=calendar rake db:migrate:status
|
|
431
|
-
RBW_CALENDAR=wdividers,counts rake db:migrate:status
|
|
432
|
-
RBW_VIEW=tables RBW_GIT=author rake db:migrate:status
|
|
433
|
-
RBW_GIT=author:me RBW_SINCE=3mo rake db:migrate:status
|
|
434
|
-
RBW_DATE=rel rake db:migrate:status
|
|
435
|
-
RBW_DATE=short rake db:migrate:status
|
|
436
|
-
RBW_DATE='custom(%b %d, %Y)' rake db:migrate:status
|
|
437
|
-
RBW_GIT=diff rake db:migrate:status
|
|
438
|
-
RBW_GIT=diff,base:develop rake db:migrate:status
|
|
439
|
-
RBW_GIT=diff,mask:(WS-[^/]+)/ rake db:migrate:status
|
|
440
|
-
|
|
441
|
-
HELP
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
public
|
|
445
|
-
|
|
446
23
|
def migrate_status
|
|
447
24
|
return super if Railbow.plain?
|
|
448
25
|
|
|
449
26
|
if Railbow::Params.help?
|
|
450
|
-
|
|
27
|
+
Railbow::Status::Help.print if Railbow::MultiDb.claim_help
|
|
451
28
|
return
|
|
452
29
|
end
|
|
453
30
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
formatter = Railbow::Formatters::Base.new
|
|
459
|
-
|
|
460
|
-
db_name = migration_connection_pool.db_config.database
|
|
461
|
-
puts "\n#{formatter.emoji(:status)} Database: #{formatter.cyan(db_name)}"
|
|
462
|
-
puts
|
|
463
|
-
|
|
464
|
-
db_list = migration_connection_pool.migration_context.migrations_status
|
|
465
|
-
|
|
466
|
-
if db_list.empty?
|
|
467
|
-
puts formatter.yellow(" No migrations found")
|
|
31
|
+
batch = Railbow::MultiDb.current
|
|
32
|
+
db_name = migration_connection_pool.db_config.name
|
|
33
|
+
if batch && !Railbow::Params.db_included?(db_name)
|
|
34
|
+
batch.skip(db_name)
|
|
468
35
|
return
|
|
469
36
|
end
|
|
470
37
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
author_mode = Railbow::Params.git_author
|
|
474
|
-
|
|
475
|
-
calendar_enabled = Railbow::Params.view_calendar?
|
|
476
|
-
ticks_enabled = Railbow::Params.calendar_wticks?
|
|
477
|
-
tables_enabled = Railbow::Params.view_tables?
|
|
478
|
-
author_enabled = %w[all me].include?(author_mode)
|
|
479
|
-
diff_enabled = Railbow::Params.git_diff?
|
|
480
|
-
date_format = Railbow::Params.date_format
|
|
481
|
-
nowrap_enabled = Railbow::Params.compact_oneline?
|
|
482
|
-
base_override = Railbow::Params.git_base
|
|
483
|
-
branch_mask = Railbow::Params.git_mask
|
|
484
|
-
|
|
485
|
-
# Filter by SINCE period (default: all)
|
|
486
|
-
since_cutoff = Railbow::Params.parse_since(since_value, context: "migrations")
|
|
487
|
-
if since_cutoff
|
|
488
|
-
total_count = db_list.size
|
|
489
|
-
cutoff_version = since_cutoff.strftime("%Y%m%d%H%M%S").to_i
|
|
490
|
-
db_list = db_list.select { |_, v, _| v.to_i >= cutoff_version }
|
|
491
|
-
|
|
492
|
-
skipped = total_count - db_list.size
|
|
493
|
-
if skipped > 0
|
|
494
|
-
puts formatter.dim(" (#{skipped} older migrations hidden - SINCE=#{since_value})")
|
|
495
|
-
puts
|
|
496
|
-
end
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
if db_list.empty?
|
|
500
|
-
puts formatter.yellow(" No migrations in the selected period")
|
|
501
|
-
return
|
|
502
|
-
end
|
|
503
|
-
|
|
504
|
-
# Build version → filename lookup (needed for tables, author, or commit dates)
|
|
505
|
-
version_to_file = {}
|
|
506
|
-
migration_connection_pool.migration_context.migrations.each do |m|
|
|
507
|
-
version_to_file[m.version.to_s] = m.filename
|
|
508
|
-
end
|
|
509
|
-
|
|
510
|
-
# Load git landed dates (always) and authors (if needed)
|
|
511
|
-
author_names = {}
|
|
512
|
-
author_emails = {}
|
|
513
|
-
landed_dates = {}
|
|
514
|
-
git_email = nil
|
|
515
|
-
git_name = nil
|
|
516
|
-
sample_file = version_to_file.values.first
|
|
517
|
-
if sample_file
|
|
518
|
-
migrate_dir = File.dirname(sample_file)
|
|
519
|
-
landed_dates = git_migration_landed_dates(migrate_dir)
|
|
520
|
-
if author_enabled
|
|
521
|
-
result = git_migration_authors(migrate_dir)
|
|
522
|
-
author_names = result[:names]
|
|
523
|
-
author_emails = result[:emails]
|
|
524
|
-
end
|
|
525
|
-
end
|
|
526
|
-
if author_enabled
|
|
527
|
-
git_email = current_git_email
|
|
528
|
-
git_name = current_git_name
|
|
529
|
-
end
|
|
530
|
-
|
|
531
|
-
# Load diff data if needed
|
|
532
|
-
branch_origins = {}
|
|
533
|
-
uncommitted_files = Set.new
|
|
534
|
-
incoming_merge_files = Set.new
|
|
535
|
-
merge_source_label = nil
|
|
536
|
-
if diff_enabled && sample_file
|
|
537
|
-
base_branch = detect_default_branch(base_override)
|
|
538
|
-
branch_origins = git_branch_migration_origins(migrate_dir, base_branch, branch_mask)
|
|
539
|
-
incoming_merge_files = git_incoming_merge_files(migrate_dir)
|
|
540
|
-
if incoming_merge_files.any?
|
|
541
|
-
merge_source_label = detect_merge_source_label(branch_mask)
|
|
542
|
-
end
|
|
543
|
-
uncommitted_files = git_uncommitted_migration_files(migrate_dir)
|
|
544
|
-
# Assign current branch as origin for uncommitted files
|
|
545
|
-
current_branch = current_branch_name(branch_mask)
|
|
546
|
-
uncommitted_files.each { |f| branch_origins[f] ||= current_branch }
|
|
547
|
-
end
|
|
548
|
-
|
|
549
|
-
# Load mighost ghost data for "NO FILE" migrations (if mighost gem is available)
|
|
550
|
-
mighost_snapshots = {}
|
|
551
|
-
mighost_available = defined?(Mighost::API) && Mighost.enabled?
|
|
552
|
-
if mighost_available
|
|
553
|
-
no_file_versions = db_list.select { |_, _, n| n.include?("NO FILE") }.map { |_, v, _| v.to_s }
|
|
554
|
-
mighost_snapshots = load_ghost_rows(no_file_versions, with_content: tables_enabled) if no_file_versions.any?
|
|
555
|
-
end
|
|
556
|
-
|
|
557
|
-
# Build columns
|
|
558
|
-
# Latest migration ID date - used to determine "fresh" landed badges
|
|
559
|
-
latest_version = db_list.last&.dig(1).to_s
|
|
560
|
-
latest_mig_date = begin
|
|
561
|
-
Date.new(latest_version[0..3].to_i, latest_version[4..5].to_i, latest_version[6..7].to_i)
|
|
562
|
-
rescue Date::Error
|
|
563
|
-
nil
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
has_landed_tags = landed_dates.any? do |basename, cdate|
|
|
567
|
-
v = basename[0..13]
|
|
568
|
-
mig_date = begin
|
|
569
|
-
Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
|
|
570
|
-
rescue Date::Error
|
|
571
|
-
nil
|
|
572
|
-
end
|
|
573
|
-
mig_date && (cdate - mig_date) > 7
|
|
574
|
-
end
|
|
575
|
-
needs_name_truncation = tables_enabled || author_mode == "all" || diff_enabled || has_landed_tags
|
|
576
|
-
name_col_width = needs_name_truncation ? 60 : nil
|
|
577
|
-
table_columns = [
|
|
578
|
-
Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true, accent: true),
|
|
579
|
-
Railbow::Table::Column.new(label: "Migration ID", sticky: true),
|
|
580
|
-
Railbow::Table::Column.new(label: (date_format == "full") ? "Created At" : "Date"),
|
|
581
|
-
Railbow::Table::Column.new(label: "Migration Name",
|
|
582
|
-
max_width: name_col_width,
|
|
583
|
-
truncate: needs_name_truncation)
|
|
584
|
-
]
|
|
585
|
-
table_columns << Railbow::Table::Column.new(label: "Who") if author_mode == "all"
|
|
586
|
-
if tables_enabled
|
|
587
|
-
tables_truncate_fn = ->(cell_raw, max_w) { formatter.table_tags_fitted(cell_raw, max_w) }
|
|
588
|
-
table_columns << Railbow::Table::Column.new(label: "Tables", truncate: nowrap_enabled, truncate_fn: tables_truncate_fn)
|
|
589
|
-
end
|
|
590
|
-
|
|
591
|
-
# Pre-resolve author name collisions for the "Who" column
|
|
592
|
-
author_display = if author_mode == "all"
|
|
593
|
-
all_raw_authors = author_names.values.compact
|
|
594
|
-
all_raw_authors << git_name if git_name
|
|
595
|
-
mighost_snapshots.each_value do |snap|
|
|
596
|
-
all_raw_authors << snap.author_name if snap.respond_to?(:author_name) && snap.author_name
|
|
597
|
-
end
|
|
598
|
-
Railbow::Params.format_authors(all_raw_authors)
|
|
599
|
-
else
|
|
600
|
-
{}
|
|
38
|
+
unless migration_connection_pool.schema_migration.table_exists?
|
|
39
|
+
Kernel.abort "Schema migrations table does not exist yet."
|
|
601
40
|
end
|
|
602
41
|
|
|
603
|
-
|
|
604
|
-
highlight_rows = Set.new
|
|
605
|
-
ghost_rows = Set.new
|
|
606
|
-
down_rows = Set.new
|
|
607
|
-
rows = db_list.each_with_index.map do |(status, version, name), idx|
|
|
608
|
-
# A pending migration is not in effect yet: grey the whole row out so it
|
|
609
|
-
# reads as inactive next to the applied ones.
|
|
610
|
-
down_rows << idx if status == "down"
|
|
611
|
-
colored_status = case status
|
|
612
|
-
when "up" then formatter.green_bold("up")
|
|
613
|
-
when "down" then formatter.yellow_bold("down")
|
|
614
|
-
else status
|
|
615
|
-
end
|
|
616
|
-
ghost_snapshot = name.include?("NO FILE") ? mighost_snapshots[version.to_s] : nil
|
|
617
|
-
if name.include?("NO FILE") && ghost_snapshot
|
|
618
|
-
ghost_rows << idx
|
|
619
|
-
# Mighost recovered this ghost migration - show ghost status + name + badge.
|
|
620
|
-
# A superseded ghost lives on under another version: stale bookkeeping,
|
|
621
|
-
# not a lost migration, so it gets a calmer glyph.
|
|
622
|
-
colored_status = ghost_snapshot.superseded_by ? "🪦" : "👻"
|
|
623
|
-
ghost_name = ghost_snapshot.filename
|
|
624
|
-
.sub(/\A\d+_/, "") # strip version prefix
|
|
625
|
-
.sub(/\.rb\z/, "") # strip extension
|
|
626
|
-
.tr("_", " ")
|
|
627
|
-
.gsub(/\b\w/, &:upcase) # titleize
|
|
628
|
-
ghost_badge = ghost_tag(ghost_snapshot)
|
|
629
|
-
if ghost_badge && name_col_width
|
|
630
|
-
tag_width = formatter.display_width(formatter.strip_ansi(ghost_badge))
|
|
631
|
-
available = name_col_width - tag_width - 2
|
|
632
|
-
ghost_name = formatter.truncate_str(ghost_name, available)
|
|
633
|
-
name_width = formatter.display_width(ghost_name)
|
|
634
|
-
padding = name_col_width - name_width - tag_width
|
|
635
|
-
display_name = "#{ghost_name}#{" " * [padding, 2].max}#{ghost_badge}"
|
|
636
|
-
elsif ghost_badge
|
|
637
|
-
display_name = "#{ghost_name} #{ghost_badge}"
|
|
638
|
-
else
|
|
639
|
-
display_name = ghost_name
|
|
640
|
-
end
|
|
641
|
-
elsif name.include?("NO FILE")
|
|
642
|
-
display_name = formatter.red("NO FILE")
|
|
643
|
-
else
|
|
644
|
-
display_name = name
|
|
645
|
-
end
|
|
646
|
-
|
|
647
|
-
if !name.include?("NO FILE")
|
|
648
|
-
filepath = version_to_file[version.to_s]
|
|
649
|
-
basename = filepath ? File.basename(filepath) : nil
|
|
650
|
-
|
|
651
|
-
# Diff tag (branch origin badge)
|
|
652
|
-
diff_tag = nil
|
|
653
|
-
if diff_enabled && basename
|
|
654
|
-
if incoming_merge_files.include?(basename)
|
|
655
|
-
colored_status = "#{colored_status} \e[38;5;213m\u2B07#{Railbow::Formatters::Base::RESET}"
|
|
656
|
-
diff_tag = formatter.diff_tag_merging(merge_source_label || "merge")
|
|
657
|
-
elsif uncommitted_files.include?(basename)
|
|
658
|
-
highlight_rows << idx
|
|
659
|
-
colored_status = "#{colored_status} \e[38;5;220m\u25c6#{Railbow::Formatters::Base::RESET}"
|
|
660
|
-
end
|
|
661
|
-
|
|
662
|
-
diff_tag ||= if branch_origins.key?(basename)
|
|
663
|
-
formatter.diff_tag_branch(branch_origins[basename])
|
|
664
|
-
end
|
|
665
|
-
end
|
|
666
|
-
|
|
667
|
-
# Landed badge: show ↪ date when commit date is >7 days after migration ID date
|
|
668
|
-
landed_tag = nil
|
|
669
|
-
if basename && landed_dates[basename]
|
|
670
|
-
v = version.to_s
|
|
671
|
-
mig_date = begin
|
|
672
|
-
Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
|
|
673
|
-
rescue Date::Error
|
|
674
|
-
nil
|
|
675
|
-
end
|
|
676
|
-
if mig_date && (landed_dates[basename] - mig_date) > 7
|
|
677
|
-
fresh = latest_mig_date && landed_dates[basename] >= latest_mig_date
|
|
678
|
-
landed_tag = formatter.landed_tag(landed_dates[basename], fresh: fresh)
|
|
679
|
-
end
|
|
680
|
-
end
|
|
42
|
+
section = Railbow::Status::Section.new(migration_connection_pool)
|
|
681
43
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
display_name = formatter.truncate_str(display_name, available)
|
|
688
|
-
name_width = formatter.display_width(formatter.strip_ansi(display_name))
|
|
689
|
-
padding = name_col_width - name_width - tags_width
|
|
690
|
-
display_name = "#{display_name}#{" " * [padding, 2].max}#{tags}"
|
|
691
|
-
elsif !tags.empty?
|
|
692
|
-
display_name = "#{display_name} #{tags}"
|
|
693
|
-
end
|
|
694
|
-
end
|
|
695
|
-
|
|
696
|
-
created_at = formatter.format_date(version, date_format)
|
|
697
|
-
row = [colored_status, version.to_s, created_at, display_name]
|
|
698
|
-
|
|
699
|
-
if author_enabled
|
|
700
|
-
if ghost_snapshot
|
|
701
|
-
# Use mighost snapshot author data for ghost migrations
|
|
702
|
-
if author_mode == "all"
|
|
703
|
-
ghost_author = ghost_snapshot.respond_to?(:author_name) ? ghost_snapshot.author_name : nil
|
|
704
|
-
raw = ghost_author || ""
|
|
705
|
-
row << (author_display[raw] || Railbow::Params.format_author(raw))
|
|
706
|
-
if git_email && ghost_snapshot.respond_to?(:author_email)
|
|
707
|
-
ghost_email = ghost_snapshot.author_email&.downcase
|
|
708
|
-
highlight_rows << idx if ghost_email && ghost_email == git_email
|
|
709
|
-
end
|
|
710
|
-
elsif author_mode == "me" && git_email && ghost_snapshot.respond_to?(:author_email)
|
|
711
|
-
ghost_email = ghost_snapshot.author_email&.downcase
|
|
712
|
-
highlight_rows << idx if ghost_email && ghost_email == git_email
|
|
713
|
-
end
|
|
714
|
-
else
|
|
715
|
-
filepath = version_to_file[version.to_s]
|
|
716
|
-
basename = filepath ? File.basename(filepath) : nil
|
|
717
|
-
|
|
718
|
-
# Uncommitted migrations have no git author - treat them as mine.
|
|
719
|
-
# Match by email first; fall back to author name to handle cases where
|
|
720
|
-
# the commit email differs from git config (e.g. GitHub noreply emails
|
|
721
|
-
# after squash-merge, or mailmap rewrites).
|
|
722
|
-
if author_mode == "all"
|
|
723
|
-
author = basename ? author_names[basename] : nil
|
|
724
|
-
raw = author || (basename ? git_name : "")
|
|
725
|
-
row << (author_display[raw] || Railbow::Params.format_author(raw))
|
|
726
|
-
if git_email
|
|
727
|
-
email = author_emails[basename]
|
|
728
|
-
name = author_names[basename]
|
|
729
|
-
highlight_rows << idx if email.nil? || email == git_email ||
|
|
730
|
-
(git_name && name && name.downcase == git_name.downcase)
|
|
731
|
-
end
|
|
732
|
-
elsif author_mode == "me" && basename && git_email
|
|
733
|
-
email = author_emails[basename]
|
|
734
|
-
name = author_names[basename]
|
|
735
|
-
highlight_rows << idx if email.nil? || email == git_email ||
|
|
736
|
-
(git_name && name && name.downcase == git_name.downcase)
|
|
737
|
-
end
|
|
738
|
-
end
|
|
739
|
-
end
|
|
740
|
-
|
|
741
|
-
if tables_enabled
|
|
742
|
-
tables = if ghost_snapshot&.content && !ghost_snapshot.content.empty?
|
|
743
|
-
Railbow::MigrationParser.extract_tables_from_content(ghost_snapshot.content)
|
|
744
|
-
else
|
|
745
|
-
Railbow::MigrationParser.extract_tables(version_to_file[version.to_s])
|
|
746
|
-
end
|
|
747
|
-
row << formatter.table_tags(tables)
|
|
748
|
-
end
|
|
749
|
-
|
|
750
|
-
row
|
|
751
|
-
end
|
|
752
|
-
|
|
753
|
-
# Calendar furniture: month separators, week separators, week ticks
|
|
754
|
-
calendar = if calendar_enabled
|
|
755
|
-
Railbow::Calendar.build(
|
|
756
|
-
db_list.map { |_, v, _| v.to_s },
|
|
757
|
-
weeks: Railbow::Params.calendar_wdividers?,
|
|
758
|
-
ticks: ticks_enabled,
|
|
759
|
-
counts: Railbow::Params.calendar_counts?,
|
|
760
|
-
month_label: Railbow::Params.calendar_label,
|
|
761
|
-
week_label: Railbow::Params.calendar_week_label
|
|
762
|
-
)
|
|
44
|
+
# Without a batch (a direct call, or a Rails that no longer routes
|
|
45
|
+
# through with_temporary_pool_for_each) the section prints on its own,
|
|
46
|
+
# which is exactly the pre-multi-database behavior.
|
|
47
|
+
if batch
|
|
48
|
+
batch.add(section)
|
|
763
49
|
else
|
|
764
|
-
Railbow::
|
|
50
|
+
Railbow::Status::Printer.new([section]).print
|
|
765
51
|
end
|
|
766
|
-
|
|
767
|
-
renderer = Railbow::Table::Renderer.new(
|
|
768
|
-
columns: table_columns,
|
|
769
|
-
theme: Railbow::Table::Themes::WALLS,
|
|
770
|
-
compact: Railbow::Params.compact_options,
|
|
771
|
-
aliases: Railbow::Config.table_aliases
|
|
772
|
-
)
|
|
773
|
-
tick_col = 2 # Date column index
|
|
774
|
-
puts renderer.render(rows,
|
|
775
|
-
separators: calendar.separators,
|
|
776
|
-
highlight_rows: highlight_rows, ghost_rows: ghost_rows, dim_rows: down_rows,
|
|
777
|
-
tick_rows: calendar.tick_rows, tick_col: tick_col)
|
|
778
52
|
end
|
|
779
53
|
end
|
|
780
54
|
end
|