railbow 0.6.0 → 0.7.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,19 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "date"
4
+ require "time"
5
+ require_relative "../clock"
4
6
  require_relative "../git_utils"
5
7
  require_relative "../multi_db"
6
8
  require_relative "../params"
7
9
 
8
10
  module Railbow
9
11
  module Status
10
- # Every git lookup db:migrate:status makes about a migrations directory:
11
- # who wrote each migration, when it landed on the mainline, which branch
12
- # introduced it, and what is still uncommitted.
13
- #
14
- # One instance per migrations directory. Multi-database runs reuse the
15
- # instance across databases that share a directory, which is what keeps
16
- # `git log` from running once per database.
12
+ # Every git lookup db:migrate:status makes about one migrations directory:
13
+ # authors, landing dates, originating branches and uncommitted files.
17
14
  class GitData
18
15
  EMPTY_AUTHORS = {names: {}, emails: {}}.freeze
19
16
 
@@ -37,10 +34,8 @@ module Railbow
37
34
  load_diff(branch_mask, base_override) if diff_enabled && migrate_dir
38
35
  end
39
36
 
40
- # Shares one instance per migrations directory across a multi-database
41
- # run. Databases that share a directory (shards) or a run that renders
42
- # several databases would otherwise repeat the same full-history
43
- # `git log` once per database.
37
+ # One instance per migrations directory across a multi-database run, so
38
+ # the full-history `git log` does not repeat once per database.
44
39
  def self.for(migrate_dir:, **opts)
45
40
  cache = Railbow::MultiDb.current&.git_cache
46
41
  return new(migrate_dir: migrate_dir, **opts) unless cache
@@ -137,8 +132,8 @@ module Railbow
137
132
  line = line.strip
138
133
  if line.start_with?("COMMIT:")
139
134
  current_date = begin
140
- Date.parse(line.sub("COMMIT:", ""))
141
- rescue Date::Error
135
+ Railbow::Clock.in_zone(Time.iso8601(line.sub("COMMIT:", ""))).to_date
136
+ rescue ArgumentError
142
137
  nil
143
138
  end
144
139
  elsif !line.empty? && current_date
@@ -242,11 +237,9 @@ module Railbow
242
237
  branches_out.each_line.map(&:strip).reject(&:empty?)
243
238
  end
244
239
 
245
- # Picks the branch that originally introduced the commit.
246
- # 1. Drop child branches: if A is an ancestor of B, the commit came from A.
247
- # 2. Among the rest, prefer the branch with the most commits after the
248
- # adding commit - it has been active longest since, so it is the
249
- # original rather than a newer fork.
240
+ # Picks the branch that introduced the commit: child branches drop out
241
+ # (if A is an ancestor of B, the commit came from A), then the branch
242
+ # with the most commits since wins, as the original rather than a fork.
250
243
  def pick_origin_branch(branches, commit)
251
244
  return branches.first if branches.size == 1
252
245
 
@@ -33,6 +33,12 @@ module Railbow
33
33
  short - Jan 30 (column: Date)
34
34
  custom(…) - user strftime, e.g. custom(%b %d, %Y)
35
35
 
36
+ RBW_TZ=<zone> Timezone dates are shown in (default: local):
37
+ local - this machine's timezone
38
+ utc - UTC, the digits of the Migration ID
39
+ <name> - any zone Rails knows, e.g.
40
+ America/New_York
41
+
36
42
  RBW_VIEW=<options> Display options (comma-separated):
37
43
  calendar - show month/year separator lines + week ticks
38
44
  tables - parse migration files, show Tables column
@@ -86,7 +92,7 @@ module Railbow
86
92
  diff - tag migrations by git origin
87
93
  base:<branch> - base branch for diff (default: auto-detected)
88
94
  mask:<re> - regex to extract branch label
89
- e.g. mask:(WS-[^/]+)/
95
+ e.g. mask:(PS-[^/]+)/
90
96
  mask:auto - auto-extract ticket id from branch name
91
97
 
92
98
  RBW_PLAIN=1 Disable Railbow formatting (plain Rails output)
@@ -109,7 +115,7 @@ module Railbow
109
115
  RBW_DATE='custom(%b %d, %Y)' rake db:migrate:status
110
116
  RBW_GIT=diff rake db:migrate:status
111
117
  RBW_GIT=diff,base:develop rake db:migrate:status
112
- RBW_GIT=diff,mask:(WS-[^/]+)/ rake db:migrate:status
118
+ RBW_GIT=diff,mask:(PS-[^/]+)/ rake db:migrate:status
113
119
  RBW_DB= rake db:migrate:status
114
120
  RBW_DB=only:primary rake db:migrate:status
115
121
  RBW_DB=inline rake db:migrate:status
@@ -9,12 +9,9 @@ require_relative "../table"
9
9
 
10
10
  module Railbow
11
11
  module Status
12
- # Turns collected sections into output. The only place db:migrate:status
13
- # writes to stdout, which is what lets a multi-database run share column
14
- # widths and print a single header.
15
- #
16
- # A run holding one database renders exactly as it did before multi-database
17
- # support existed: no overview line, no section rule, nothing new.
12
+ # The only place db:migrate:status writes to stdout, which is what lets a
13
+ # multi-database run share column widths and print a single header. A
14
+ # single-database run gets no overview line and no section rule.
18
15
  class Printer
19
16
  RULE = "─"
20
17
  COLLAPSED = "⋯"
@@ -88,9 +85,8 @@ module Railbow
88
85
  puts render_table(section)
89
86
  end
90
87
 
91
- # Says what the window hid, and admits when the floor overrode it: the
92
- # table then holds rows older than the window it names, and a bare
93
- # "hidden - SINCE=70d" would read as a contradiction next to them.
88
+ # Admits when the floor overrode the window: a bare "hidden - SINCE=90d"
89
+ # would contradict the older rows shown below it.
94
90
  def hidden_note(section)
95
91
  return nil unless section.hidden_count > 0
96
92
 
@@ -110,8 +106,7 @@ module Railbow
110
106
  puts
111
107
  puts section_header(section, renderer.fixed_width(widths))
112
108
 
113
- # Reachable with RBW_DB=full, which asks for every section to be drawn
114
- # in full, including the ones that would otherwise have collapsed.
109
+ # Reachable with RBW_DB=full, which draws even quiet sections in full.
115
110
  if section.state == :no_migrations
116
111
  puts formatter.yellow(" No migrations found")
117
112
  return
@@ -123,10 +118,9 @@ module Railbow
123
118
  puts render_table(section, renderer)
124
119
  end
125
120
 
126
- # RBW_DB=inline: every database in one time-ordered table, each row
127
- # carrying a Db badge. The calendar then spans the whole application
128
- # rather than restarting per database, which is the point of asking for
129
- # it. Quiet databases still collapse below the table.
121
+ # RBW_DB=inline: every database in one time-ordered table with a Db
122
+ # badge per row, so the calendar spans the whole application. Quiet
123
+ # databases still collapse below the table.
130
124
  def print_inline
131
125
  live = sections.reject { |s| collapse?(s) || s.rows.empty? }
132
126
  collapsed = sections - live
@@ -149,9 +143,8 @@ module Railbow
149
143
  collapsed.each { |section| puts collapsed_line(section) }
150
144
  end
151
145
 
152
- # Sorted by version, so the databases interleave by when each migration
153
- # was written. Ties fall back to config order, which keeps two databases
154
- # that share a timestamp in database.yml order rather than an arbitrary one.
146
+ # Sorted by version; ties fall back to database.yml order rather than an
147
+ # arbitrary one.
155
148
  def inline_entries(live)
156
149
  assigner = Railbow::ColorAssigner.new(live.map { |s| inline_label(s) })
157
150
 
@@ -209,27 +202,21 @@ module Railbow
209
202
  puts formatter.dim("#{COLLAPSED} #{count_label(skipped.size, "database")} hidden: #{skipped.join(", ")}")
210
203
  end
211
204
 
212
- # A quiet database says everything it has to say in one line. Nothing in
213
- # the window means there is no table to draw either, so collapsing loses
214
- # nothing - the counts below carry what the rows would have shown.
215
- #
216
- # RBW_DB=focus collapses the busy ones too, keeping the first database as
217
- # the one you actually read. Pending migrations veto that: a database
218
- # with work waiting is drawn in full however focused the run is.
205
+ # A quiet database has no table to draw, so one line says it all.
206
+ # RBW_DB=focus collapses all but the first database too, unless one has
207
+ # migrations pending.
219
208
  def collapse?(section)
220
209
  return false if Railbow::Params.db_full?
221
210
  return true if section.quiet?
222
- # Focus shapes the sectioned view. Applying it to a merged table would
223
- # drop every database but the first out of the very thing that exists
224
- # to hold all of them.
211
+ # Focus would empty the inline table of every database but the first.
225
212
  return false if Railbow::Params.db_inline?
226
213
  return false unless Railbow::Params.db_focus?
227
214
 
228
215
  !focused?(section) && !section.pending_in_view?
229
216
  end
230
217
 
231
- # The first database in database.yml, which is conventionally primary and
232
- # is the one Rails migrates first.
218
+ # The first database in database.yml: conventionally primary, and the one
219
+ # Rails migrates first.
233
220
  def focused?(section)
234
221
  section.equal?(sections.first)
235
222
  end
@@ -254,7 +241,7 @@ module Railbow
254
241
  end
255
242
  end
256
243
 
257
- # "──── primary · westside-dev ──── 111 of 1489 ────", ruled out to where
244
+ # "──── primary · pawshop-dev ──── 12 of 240 ────", ruled out to where
258
245
  # the last column starts so it frames the table rather than floating.
259
246
  def section_header(section, rule_width)
260
247
  label = section.db_names.join(" + ")
@@ -279,9 +266,8 @@ module Railbow
279
266
  nil
280
267
  end
281
268
 
282
- # A bare "10 of 50" reads as "10 fell inside the window". When the floor
283
- # overrode the window the selection is a tail rather than a window, so it
284
- # says "last 10 of 50" - the same words the single-database note uses.
269
+ # "last 10 of 50" when the floor overrode the window, since a bare
270
+ # "10 of 50" reads as "10 fell inside the window".
285
271
  def shown_count(section)
286
272
  shown = section.total_count - section.hidden_count
287
273
  section.floor_applied? ? "last #{shown}" : shown.to_s
@@ -12,16 +12,11 @@ require_relative "ghosts"
12
12
 
13
13
  module Railbow
14
14
  module Status
15
- # One migrations directory's worth of db:migrate:status.
16
- #
17
- # Usually that is one database. Databases sharing a migrations_paths (the
18
- # shape horizontal sharding takes) see the same files and differ only in
19
- # what they have applied, so they merge into a single section carrying a
15
+ # One migrations directory's worth of db:migrate:status. Databases sharing
16
+ # a migrations_paths (horizontal sharding) merge into one section with a
20
17
  # status per database.
21
- #
22
- # Rows are built lazily, on first read, because merging has to finish
23
- # before the status cells can be built. A section never prints: Printer
24
- # owns output, which is what lets sections share column widths.
18
+ # Rows are built lazily because merging must finish first. Printer owns
19
+ # output, which is what lets sections share column widths.
25
20
  class Section
26
21
  # Index of the Date column, where calendar week ticks are drawn.
27
22
  TICK_COL = 2
@@ -42,8 +37,11 @@ module Railbow
42
37
  attr_reader :databases, :migrations_key, :state, :hidden_count, :total_count,
43
38
  :since_value, :entries, :ghosts
44
39
 
45
- # True when the time window left too few rows and the floor topped the
46
- # section back up, which is worth saying out loud: the table then shows
40
+ # What stock Rails prints for the databases in this section, shown if
41
+ # railbow fails to render it.
42
+ attr_accessor :fallback_output
43
+
44
+ # True when the floor topped the section back up, so the table shows
47
45
  # migrations from outside the window it advertises.
48
46
  def floor_applied?
49
47
  @floor_applied == true
@@ -64,8 +62,7 @@ module Railbow
64
62
  collect(pool)
65
63
  end
66
64
 
67
- # The database this section is named after. Multi-database sections use
68
- # #databases; this is the single-database convenience.
65
+ # Single-database convenience; multi-database sections use #databases.
69
66
  def database
70
67
  databases.first[:database]
71
68
  end
@@ -82,9 +79,8 @@ module Railbow
82
79
  migrations_key == other.migrations_key
83
80
  end
84
81
 
85
- # Folds another database that runs the same migration files into this
86
- # section: its statuses join the existing rows, its identity joins the
87
- # header, and anything already built is discarded.
82
+ # Folds in another database running the same migration files. Anything
83
+ # already built is discarded.
88
84
  def merge!(other)
89
85
  other.entries.each do |version, entry|
90
86
  mine = (@entries[version] ||= {name: entry[:name], statuses: {}})
@@ -92,6 +88,7 @@ module Railbow
92
88
  mine[:statuses].merge!(entry[:statuses])
93
89
  end
94
90
  @databases.concat(other.databases)
91
+ @fallback_output = [fallback_output, other.fallback_output].compact.join
95
92
  @ghosts = other.ghosts.merge(@ghosts)
96
93
  @total_count = [@total_count, other.total_count].max
97
94
  @hidden_count = [@hidden_count, other.hidden_count].max
@@ -107,10 +104,8 @@ module Railbow
107
104
  databases.size > 1
108
105
  end
109
106
 
110
- # Whether any row this section would render is still pending. Answered
111
- # from the collected entries, so asking costs no git work - which is the
112
- # point, since it is asked in order to decide whether to build the rows
113
- # at all.
107
+ # Answered from collected entries, so it costs no git work: it is asked
108
+ # to decide whether to build the rows at all.
114
109
  def pending_in_view?
115
110
  entries.any? { |_, entry| entry[:statuses].value?("down") }
116
111
  end
@@ -223,11 +218,9 @@ module Railbow
223
218
  @latest_applied_version = applied.last&.dig(1)&.to_s
224
219
  end
225
220
 
226
- # The time window is a soft limit. Whatever it leaves, the floor tops the
227
- # result back up to RBW_SINCE_MIN rows, so a database with five
228
- # migrations shows all five rather than hiding the two that happen to be
229
- # old. The window is still what decides whether the section reads as
230
- # recent - see #collect.
221
+ # The time window is a soft limit: the floor tops the result back up to
222
+ # RBW_SINCE_MIN rows. The window alone still decides whether the section
223
+ # reads as recent (see #collect).
231
224
  def apply_since_filter(db_list)
232
225
  since_cutoff = Railbow::Params.parse_since(since_value, context: "migrations")
233
226
  unless since_cutoff
@@ -235,7 +228,7 @@ module Railbow
235
228
  return db_list
236
229
  end
237
230
 
238
- cutoff_version = since_cutoff.strftime("%Y%m%d%H%M%S").to_i
231
+ cutoff_version = Railbow::Clock.version_at(since_cutoff).to_i
239
232
  @window_count = db_list.count { |_, v, _| v.to_i >= cutoff_version }
240
233
 
241
234
  # Not Comparable#clamp: the floor can exceed the total, and clamp
@@ -313,13 +306,9 @@ module Railbow
313
306
  @calendar = build_calendar
314
307
  end
315
308
 
316
- # The name column only needs capping when something else competes for the
317
- # row: table tags, an author column, branch badges or landed badges.
318
- #
319
- # On a terminal too narrow for the full row the width budget degrades the
320
- # table instead of letting it wrap: the name column shrinks first, then
321
- # Tables is dropped, then the formatted date (the raw Migration ID keeps
322
- # the timestamp), then Who.
309
+ # On a narrow terminal the table degrades instead of wrapping: the name
310
+ # column shrinks first, then Tables, the date (the Migration ID keeps the
311
+ # timestamp) and Who are dropped, in that order.
323
312
  def build_columns
324
313
  @name_col_width = needs_name_truncation? ? NAME_COL_WIDTH : nil
325
314
 
@@ -332,6 +321,7 @@ module Railbow
332
321
  Table::Column.new(label: "Migration Name",
333
322
  max_width: @name_col_width,
334
323
  truncate: !@name_col_width.nil?,
324
+ truncate_fn: ->(cell_raw, max_w) { formatter.name_with_tags_fitted(cell_raw, max_w) },
335
325
  shrinkable: true, shrink_floor: NAME_COL_MIN_WIDTH)
336
326
  ]
337
327
  cols << Table::Column.new(label: "Who", droppable: 3) if author_mode == "all"
@@ -376,9 +366,8 @@ module Railbow
376
366
  name = entry[:name]
377
367
  statuses = entry[:statuses]
378
368
 
379
- # A pending migration is not in effect yet: grey the whole row out so
380
- # it reads as inactive next to the applied ones. In a shard group only
381
- # a row pending everywhere reads as inactive.
369
+ # Pending rows are greyed out as not in effect; in a shard group only
370
+ # a row pending everywhere.
382
371
  @down_rows << idx if statuses.values.all? { |s| s == "down" }
383
372
 
384
373
  ghost = name.include?(NO_FILE) ? ghosts[version] : nil
@@ -425,8 +414,7 @@ module Railbow
425
414
  @status_aliases[status] || status
426
415
  end
427
416
 
428
- # Builds the Migration Name cell and right-aligns whatever badges it
429
- # carries. Yields a status indicator when the row earns one.
417
+ # Yields a status indicator when the row earns one.
430
418
  def name_cell(name:, version:, ghost:, latest_mig_date:)
431
419
  if ghost
432
420
  return with_tags(Ghosts.display_name(ghost), Ghosts.tag(ghost), strip: false)
@@ -467,8 +455,8 @@ module Railbow
467
455
  formatter.landed_tag(landed, fresh: latest_mig_date && landed >= latest_mig_date)
468
456
  end
469
457
 
470
- # Pads the name so its tags sit flush against the right edge of the
471
- # column. Falls back to a two-space gap when the column is uncapped.
458
+ # Tags sit flush right in a capped column, two spaces after the name
459
+ # otherwise.
472
460
  def with_tags(name, tags, strip: true)
473
461
  return name if tags.nil? || tags.empty?
474
462
  return "#{name} #{tags}" unless @name_col_width
@@ -552,10 +540,7 @@ module Railbow
552
540
  end
553
541
 
554
542
  def version_date(version)
555
- v = version.to_s
556
- Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
557
- rescue Date::Error
558
- nil
543
+ Railbow::Clock.from_version(version)&.to_date
559
544
  end
560
545
  end
561
546
  end
@@ -61,10 +61,9 @@ module Railbow
61
61
 
62
62
  rows = prepare_rows(rows)
63
63
 
64
- # Width budget: when even fully shrunk columns cannot bring the table
65
- # under the terminal width, sacrifice the most expendable column and
66
- # render without it. One column per pass - the reduced renderer asks
67
- # again with what is left.
64
+ # When even fully shrunk columns overflow the terminal, drop the most
65
+ # expendable column and render again. One column per pass: the reduced
66
+ # renderer asks again with what is left.
68
67
  if (drop = drop_index_to_fit(rows))
69
68
  return dropped_renderer(drop).render(
70
69
  rows.map { |row| row.reject.with_index { |_, i| i == drop } },
@@ -76,7 +75,6 @@ module Railbow
76
75
  resolved = resolve_widths(rows)
77
76
  shrink_to_fit!(resolved, rows)
78
77
 
79
- # In oneline mode, truncate non-sticky non-last columns at resolved width
80
78
  if @compact[:oneline]
81
79
  rows = rows.map { |row|
82
80
  row.each_with_index.map { |cell, i|
@@ -134,7 +132,7 @@ module Railbow
134
132
  if col.fixed?
135
133
  w = col.width
136
134
  elsif i == last
137
- # Last column: don't pad, will be wrapped if it overflows
135
+ # The last column flexes to the terminal, so it is never padded
138
136
  w = 0
139
137
  else
140
138
  header_w = display_width(effective_label(col))
@@ -205,8 +203,6 @@ module Railbow
205
203
  "#{pad}#{content}#{RESET}#{pad}"
206
204
  }
207
205
 
208
- # Join prefix parts with per-position separators.
209
- # Loop index i joins column i-1 and column i (separator index = i-1).
210
206
  # dup: appending to prefix_parts.first itself would corrupt the parts
211
207
  # that render_last_cell measures for the wrapped-line indent.
212
208
  prefix = prefix_parts.first.to_s.dup
@@ -216,7 +212,6 @@ module Railbow
216
212
 
217
213
  last_cell_raw = row[last].to_s
218
214
 
219
- # Separator before the last column has index (last - 1)
220
215
  last_sep = separator_at(last - 1, tick_col)
221
216
  render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: last_sep, highlight: highlight, ghost: ghost, dim: dim)
222
217
  end
@@ -225,7 +220,6 @@ module Railbow
225
220
  pad = effective_padding
226
221
  sep = col_sep || theme.col_separator
227
222
 
228
- # Truncate if configured via column settings
229
223
  if columns[last].truncate && columns[last].max_width
230
224
  last_cell_raw = truncate_str(last_cell_raw, columns[last].max_width)
231
225
  end
@@ -235,7 +229,7 @@ module Railbow
235
229
  prefix_width = compute_prefix_width(widths, last)
236
230
  last_col_max = term_w ? [term_w - prefix_width - display_width(pad), MIN_LAST_COL].max : nil
237
231
 
238
- # Use custom truncate_fn if available (e.g. table tags with +N)
232
+ # A column's own truncate_fn knows its content (table tags fold into +N)
239
233
  if columns[last].truncate_fn && last_col_max &&
240
234
  display_width(last_cell_plain) > last_col_max
241
235
  last_cell_raw = columns[last].truncate_fn.call(last_cell_raw, last_col_max)
@@ -243,7 +237,6 @@ module Railbow
243
237
  return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
244
238
  end
245
239
 
246
- # Truncate to terminal width (by whole words) instead of wrapping
247
240
  if columns[last].truncate && !columns[last].max_width && last_col_max &&
248
241
  display_width(last_cell_plain) > last_col_max
249
242
  last_cell_raw = truncate_by_words(last_cell_raw, last_col_max)
@@ -251,7 +244,6 @@ module Railbow
251
244
  return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
252
245
  end
253
246
 
254
- # In oneline mode, truncate instead of wrapping
255
247
  if @compact[:oneline] && last_col_max && display_width(last_cell_plain) > last_col_max
256
248
  last_cell_raw = truncate_by_words(last_cell_raw, last_col_max)
257
249
  last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
@@ -260,17 +252,21 @@ module Railbow
260
252
 
261
253
  last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
262
254
 
263
- if last_col_max && display_width(strip_ansi(last_cell_raw)) > last_col_max
264
- blank_prefix = prefix_parts.map { |part|
265
- " " * display_width(strip_ansi(part))
266
- }.join(sep)
267
-
268
- wrapped = ansi_word_wrap(last_cell_raw, last_col_max)
269
- "#{prefix}#{sep}#{pad}#{wrapped.first}#{RESET}#{pad}\n" +
270
- wrapped[1..].map { |line| "#{blank_prefix}#{sep}#{pad}#{line}#{RESET}#{pad}" }.join("\n")
271
- else
272
- "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
273
- end
255
+ whole = "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
256
+ return whole unless last_col_max && display_width(strip_ansi(last_cell_raw)) > last_col_max
257
+
258
+ # Wrap only when every line fits beside the fixed columns. Otherwise the
259
+ # terminal re-wraps each padded continuation line, leaving a blank line
260
+ # before every fragment, so the value stays on its row whole.
261
+ room = term_w - prefix_width - display_width(pad)
262
+ wrapped = ansi_word_wrap(last_cell_raw, last_col_max)
263
+ return whole unless wrapped.all? { |line| display_width(strip_ansi(line)) <= room }
264
+
265
+ blank_prefix = prefix_parts.map { |part|
266
+ " " * display_width(strip_ansi(part))
267
+ }.join(sep)
268
+ "#{prefix}#{sep}#{pad}#{wrapped.first}#{RESET}#{pad}\n" +
269
+ wrapped[1..].map { |line| "#{blank_prefix}#{sep}#{pad}#{line}#{RESET}#{pad}" }.join("\n")
274
270
  end
275
271
 
276
272
  def style_cell(content, highlight: false, ghost: false, dim: false, accent: false)
@@ -294,14 +290,13 @@ module Railbow
294
290
  def compute_prefix_width(widths, last)
295
291
  pad_w = display_width(effective_padding)
296
292
  sep_w = display_width(theme.col_separator)
297
- # Each non-last column: pad + content + pad, joined by separator
293
+ # Padded fixed columns, the separator after each, and the last
294
+ # column's leading pad.
298
295
  total = 0
299
296
  (0...last).each do |i|
300
297
  total += pad_w + widths[i] + pad_w
301
298
  end
302
- # Separators between columns + trailing separator before last column
303
299
  total += sep_w * last
304
- # Plus pad on last column
305
300
  total += pad_w
306
301
  total
307
302
  end
@@ -388,7 +383,11 @@ module Railbow
388
383
 
389
384
  widths[i] -= cut
390
385
  overflow -= cut
391
- rows.each { |row| row[i] = truncate_ansi(row[i].to_s, widths[i]) }
386
+ # A column that knows how to re-fit its own cells does it here: a
387
+ # blind cut from the right would drop whatever the cell parked at its
388
+ # far edge (right-aligned tags) instead of shortening the content.
389
+ fit = col.truncate_fn || method(:truncate_ansi)
390
+ rows.each { |row| row[i] = fit.call(row[i].to_s, widths[i]) }
392
391
  end
393
392
  end
394
393
 
@@ -409,11 +408,9 @@ module Railbow
409
408
  widths
410
409
  end
411
410
 
412
- # What the table needs from the terminal: every fixed column plus a
413
- # reserve for the flexing last column, which truncates down to
414
- # MIN_LAST_COL but never below it. The last column's header label counts
415
- # too - it is drawn as-is, so a label wider than every cell would poke
416
- # past the terminal edge otherwise.
411
+ # Every fixed column plus a reserve for the last column, which truncates
412
+ # down to MIN_LAST_COL but no further. Its header label counts too: it is
413
+ # drawn as-is, so a label wider than every cell would poke past the edge.
417
414
  def required_width(widths, rows)
418
415
  last = columns.size - 1
419
416
  last_w = rows.map { |row| display_width(strip_ansi(row[last].to_s)) }.max || 0
@@ -563,14 +560,13 @@ module Railbow
563
560
  value_aliases = @aliases[:values]
564
561
  return rows unless value_aliases&.any?
565
562
 
566
- # Build column index → value alias map
567
563
  col_map = {}
568
564
  columns.each_with_index do |col, i|
569
565
  next unless col.aliased
570
566
 
571
567
  label = col.label
572
568
  col_map[i] = value_aliases[label] if value_aliases[label]
573
- # Also look up by original name if column was renamed by alias
569
+ # Value aliases may name a renamed column by its original label
574
570
  original = @reverse_col_aliases[label]
575
571
  col_map[i] = value_aliases[original] if original && value_aliases[original]
576
572
  end
@@ -591,7 +587,7 @@ module Railbow
591
587
 
592
588
  def apply_cell_alias(cell, aliases_for_col)
593
589
  plain = strip_ansi(cell)
594
- # Try exact match first, then prefix match for cells with appended indicators
590
+ # Exact match first, then a prefix match for cells with an indicator appended
595
591
  replacement = aliases_for_col[plain]
596
592
  if replacement
597
593
  cell.sub(plain) { replacement }
@@ -1,21 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
3
4
  require_relative "../multi_db"
4
5
  require_relative "../status/help"
5
6
  require_relative "../status/printer"
6
7
  require_relative "../status/section"
7
8
 
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.
9
+ # Rails calls migrate_status once per database inside
10
+ # with_temporary_pool_for_each, for db:migrate:status and
11
+ # db:migrate:status:<database_name> alike. Wrapping that loop is what lets
12
+ # railbow render the databases together instead of one blind table each.
14
13
  module Railbow
15
14
  module MigrateStatusFormatter
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.
15
+ # Rails 7.2 through 8.1 all route through here. Arguments pass verbatim so
16
+ # Rails keeps applying its own defaults.
19
17
  def with_temporary_pool_for_each(*args, **kwargs, &block)
20
18
  Railbow::MultiDb.batch { super(*args, **kwargs, &block) }
21
19
  end
@@ -39,17 +37,35 @@ module Railbow
39
37
  Kernel.abort "Schema migrations table does not exist yet."
40
38
  end
41
39
 
42
- section = Railbow::Status::Section.new(migration_connection_pool)
40
+ # Outside the guard: an error here is Rails' own (a database that is
41
+ # down) and must raise as it would without railbow. Captured while the
42
+ # pool is still open, it is also the fallback.
43
+ stock = railbow_capture_stdout { super }
43
44
 
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)
49
- else
50
- Railbow::Status::Printer.new([section]).print
45
+ Railbow.guard(-> { $stdout.write(stock) }) do
46
+ section = Railbow::Status::Section.new(migration_connection_pool)
47
+ section.fallback_output = stock
48
+
49
+ # No batch (a direct call, or a Rails that skips
50
+ # with_temporary_pool_for_each): the section prints on its own.
51
+ if batch
52
+ batch.add(section)
53
+ else
54
+ Railbow::Status::Printer.new([section]).print
55
+ end
51
56
  end
52
57
  end
58
+
59
+ private
60
+
61
+ def railbow_capture_stdout
62
+ original = $stdout
63
+ $stdout = StringIO.new
64
+ yield
65
+ $stdout.string
66
+ ensure
67
+ $stdout = original
68
+ end
53
69
  end
54
70
  end
55
71