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.
@@ -1,780 +1,54 @@
1
1
  # frozen_string_literal: true
2
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 "../calendar"
10
- require_relative "../logo"
11
-
12
- # Override DatabaseTasks.migrate_status which is called by both
13
- # db:migrate:status and db:migrate:status:<database_name> tasks.
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
- # Ghost migration data normalized for rendering, whether it came from
17
- # mighost's orphan classification or a live snapshot recovery.
18
- class GhostRow
19
- attr_reader :filename, :branch_name, :source, :superseded_by, :deleted_in_sha,
20
- :author_name, :author_email, :content
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
- print_help
27
+ Railbow::Status::Help.print if Railbow::MultiDb.claim_help
451
28
  return
452
29
  end
453
30
 
454
- unless migration_connection_pool.schema_migration.table_exists?
455
- Kernel.abort "Schema migrations table does not exist yet."
456
- end
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
- # Options from Railbow::Params
472
- since_value = Railbow::Params.since
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
- # Build rows and track highlight/ghost/down indices
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
- # Append tags to display_name with right-alignment
683
- tags = [landed_tag, diff_tag].compact.join(" ")
684
- if !tags.empty? && name_col_width
685
- tags_width = formatter.display_width(formatter.strip_ansi(tags))
686
- available = name_col_width - tags_width - 2
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::Calendar.none
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