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