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.
@@ -1,789 +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 "../logo"
10
-
11
- # Override DatabaseTasks.migrate_status which is called by both
12
- # 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.
13
14
  module Railbow
14
15
  module MigrateStatusFormatter
15
- # Ghost migration data normalized for rendering, whether it came from
16
- # mighost's orphan classification or a live snapshot recovery.
17
- class GhostRow
18
- attr_reader :filename, :branch_name, :source, :superseded_by, :deleted_in_sha,
19
- :author_name, :author_email, :content
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
- print_help
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
- # Options from Railbow::Params
462
- since_value = Railbow::Params.since
463
- author_mode = Railbow::Params.git_author
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
- # Build version → filename lookup (needed for tables, author, or commit dates)
495
- version_to_file = {}
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
- # Build columns
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
- has_landed_tags = landed_dates.any? do |basename, cdate|
557
- v = basename[0..13]
558
- mig_date = begin
559
- Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
560
- rescue Date::Error
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