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