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.
@@ -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)