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,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "date"
4
+ require_relative "calendar"
4
5
  require_relative "config"
5
6
  require_relative "name_formatter"
6
7
  require_relative "name_collision_resolver"
@@ -79,6 +80,17 @@ module Railbow
79
80
  (ENV["RBW_SINCE"] || Config.load["since"] || "all").strip.downcase
80
81
  end
81
82
 
83
+ # Floor on how many migrations survive the SINCE window. The window is a
84
+ # soft limit: when it leaves fewer rows than this, the oldest ones are
85
+ # pulled back in until the count is met. 0 disables the floor.
86
+ #
87
+ # Deliberately a knob of its own rather than a token inside RBW_SINCE, so
88
+ # an ad-hoc `RBW_SINCE=2mo` keeps the floor instead of silently dropping it.
89
+ def since_min
90
+ value = ENV["RBW_SINCE_MIN"] || Config.load["since_min"] || 0
91
+ [value.to_i, 0].max
92
+ end
93
+
82
94
  def sort
83
95
  (ENV["RBW_SORT"] || Config.load["sort"] || "file").strip.downcase
84
96
  end
@@ -214,6 +226,49 @@ module Railbow
214
226
  !val.nil?
215
227
  end
216
228
 
229
+ # --- Compound: RBW_DB ---
230
+
231
+ def db
232
+ parse_compound(ENV["RBW_DB"] || Config.load["db"])
233
+ end
234
+
235
+ # Render every database in one time-ordered table instead of a section
236
+ # each. Only meaningful when the databases keep separate migration sets.
237
+ def db_inline?
238
+ db["inline"] == true
239
+ end
240
+
241
+ # Draw the full table for every database, including the quiet ones that
242
+ # would otherwise collapse to a summary line.
243
+ def db_full?
244
+ db["full"] == true
245
+ end
246
+
247
+ # Expand only the first database, summarizing the rest in a line each - but
248
+ # never at the cost of hiding work: a database holding pending migrations
249
+ # is always drawn in full, since that is the one thing you may need to act
250
+ # on. RBW_DB=full overrides it, and it does not apply to inline runs.
251
+ def db_focus?
252
+ db["focus"] == true
253
+ end
254
+
255
+ def db_only
256
+ Array(db["only"]).select { |v| v.is_a?(String) }
257
+ end
258
+
259
+ def db_skip
260
+ Array(db["skip"]).select { |v| v.is_a?(String) }
261
+ end
262
+
263
+ # Whether a database, by its database.yml name, is part of this run.
264
+ def db_included?(name)
265
+ name = name.to_s
266
+ only = db_only
267
+ return only.include?(name) if only.any?
268
+
269
+ !db_skip.include?(name)
270
+ end
271
+
217
272
  # --- Compound: RBW_CALENDAR ---
218
273
 
219
274
  def calendar
@@ -226,8 +281,26 @@ module Railbow
226
281
  calendar["wticks"] == true
227
282
  end
228
283
 
284
+ # A separator row per ISO week, the fuller counterpart to wticks.
285
+ def calendar_wdividers?
286
+ return false unless view_calendar?
287
+
288
+ calendar["wdividers"] == true
289
+ end
290
+
291
+ # Append "· N migrations" to every separator row: how many it introduces.
292
+ def calendar_counts?
293
+ return false unless view_calendar?
294
+
295
+ calendar["counts"] == true
296
+ end
297
+
229
298
  def calendar_label
230
- calendar["label"] || "%b %Y W%V"
299
+ calendar["label"] || Calendar::DEFAULT_MONTH_LABEL
300
+ end
301
+
302
+ def calendar_week_label
303
+ calendar["wlabel"] || Calendar::DEFAULT_WEEK_LABEL
231
304
  end
232
305
  end
233
306
  end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railbow
4
+ module Status
5
+ # Recovery of "NO FILE" migrations through the optional mighost gem: rows
6
+ # applied to the database whose migration file is no longer on disk.
7
+ module Ghosts
8
+ GHOST_FG = "\e[38;5;217m" # matches Table::Renderer::GHOST_FG, the row it sits in
9
+ MUTED = "\e[38;5;245m"
10
+ BRANCH = "\e[38;5;222m"
11
+
12
+ # A ghost normalized for rendering, whether it came from mighost's orphan
13
+ # classification or from a live snapshot recovery.
14
+ class Row
15
+ attr_reader :filename, :branch_name, :source, :superseded_by, :deleted_in_sha,
16
+ :author_name, :author_email, :content
17
+
18
+ def initialize(filename: nil, branch_name: nil, source: nil, superseded_by: nil,
19
+ deleted_in_sha: nil, author_name: nil, author_email: nil, content: nil)
20
+ @filename = filename
21
+ @branch_name = branch_name
22
+ @source = source
23
+ @superseded_by = superseded_by
24
+ @deleted_in_sha = deleted_in_sha
25
+ @author_name = author_name
26
+ @author_email = author_email
27
+ @content = content
28
+ end
29
+ end
30
+
31
+ module_function
32
+
33
+ def available?
34
+ defined?(Mighost::API) && Mighost.enabled?
35
+ end
36
+
37
+ def load(versions, with_content: false)
38
+ # Detect once: OrphanedMigration carries the classification (supersession,
39
+ # deletion commit) that a bare snapshot does not, and already honors
40
+ # dismissals and hide_superseded.
41
+ orphans = begin
42
+ Mighost::API.orphaned_migrations.to_h { |o| [o.version.to_s, o] }
43
+ rescue
44
+ return {}
45
+ end
46
+
47
+ rows = {}
48
+ versions.each do |v|
49
+ # Absent from detect = deliberately suppressed (dismissed, or superseded
50
+ # with hide_superseded on) - render as plain NO FILE, do not re-recover.
51
+ next unless (orphan = orphans[v])
52
+
53
+ row = if orphan.filename && !orphan.filename.empty?
54
+ from_orphan(orphan, v, with_content)
55
+ else
56
+ from_live_recovery(v, with_content)
57
+ end
58
+ rows[v] = row if row
59
+ end
60
+ rows
61
+ end
62
+
63
+ def from_orphan(orphan, version, with_content)
64
+ Row.new(
65
+ filename: orphan.filename,
66
+ branch_name: orphan.branch_name,
67
+ source: read_attr(orphan, :source),
68
+ superseded_by: read_attr(orphan, :superseded_by),
69
+ deleted_in_sha: read_attr(orphan, :deleted_in_sha),
70
+ author_name: read_attr(orphan, :author_name),
71
+ author_email: read_attr(orphan, :author_email),
72
+ content: with_content ? snapshot_content(version) : nil
73
+ )
74
+ end
75
+
76
+ # Detect reads stored snapshots only. A version it lists without a filename
77
+ # has no snapshot yet, so fall back to live git/worktree recovery - that
78
+ # keeps fresh clones working with zero setup.
79
+ def from_live_recovery(version, with_content)
80
+ snapshot = begin
81
+ Mighost::API.find_or_recover_snapshot(version)
82
+ rescue
83
+ nil
84
+ end
85
+ return nil unless snapshot&.filename && !snapshot.filename.empty?
86
+
87
+ Row.new(
88
+ filename: snapshot.filename,
89
+ branch_name: snapshot.branch_name,
90
+ source: read_attr(snapshot, :source),
91
+ superseded_by: api_superseded_by(version),
92
+ deleted_in_sha: read_attr(snapshot, :deleted_in_sha),
93
+ author_name: read_attr(snapshot, :author_name),
94
+ author_email: read_attr(snapshot, :author_email),
95
+ content: with_content ? snapshot.content : nil
96
+ )
97
+ end
98
+
99
+ def read_attr(obj, name)
100
+ obj.respond_to?(name) ? obj.public_send(name) : nil
101
+ end
102
+
103
+ def snapshot_content(version)
104
+ Mighost::API.find_snapshot(version)&.content
105
+ rescue
106
+ nil
107
+ end
108
+
109
+ def api_superseded_by(version)
110
+ return nil unless Mighost::API.respond_to?(:superseded_by)
111
+
112
+ Mighost::API.superseded_by(version)
113
+ rescue
114
+ nil
115
+ end
116
+
117
+ # One tag slot per ghost row; the most informative wins. Each ends in the
118
+ # ghost foreground rather than a reset, so the rest of the row keeps its
119
+ # ghost styling.
120
+ def tag(ghost)
121
+ if ghost.superseded_by
122
+ "#{MUTED}≡ #{ghost.superseded_by}#{GHOST_FG}"
123
+ elsif ghost.branch_name
124
+ if ghost.source == "worktree"
125
+ "#{BRANCH}⌥ₜ#{ghost.branch_name}#{GHOST_FG}"
126
+ else
127
+ "#{BRANCH}⌥ #{ghost.branch_name}#{GHOST_FG}"
128
+ end
129
+ elsif ghost.deleted_in_sha
130
+ "#{MUTED}✂ deleted in:#{ghost.deleted_in_sha[0, 8]}#{GHOST_FG}"
131
+ end
132
+ end
133
+
134
+ # The name a ghost row displays, derived from its recovered filename.
135
+ def display_name(ghost)
136
+ ghost.filename
137
+ .sub(/\A\d+_/, "") # strip version prefix
138
+ .sub(/\.rb\z/, "") # strip extension
139
+ .tr("_", " ")
140
+ .gsub(/\b\w/, &:upcase) # titleize
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "../git_utils"
5
+ require_relative "../multi_db"
6
+ require_relative "../params"
7
+
8
+ module Railbow
9
+ 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.
17
+ class GitData
18
+ EMPTY_AUTHORS = {names: {}, emails: {}}.freeze
19
+
20
+ attr_reader :migrate_dir, :author_names, :author_emails, :landed_dates,
21
+ :git_email, :git_name, :branch_origins, :uncommitted_files,
22
+ :incoming_merge_files, :merge_source_label
23
+
24
+ # migrate_dir is nil when no migration file could be located, in which
25
+ # case every file-scoped lookup is skipped and the accessors stay empty.
26
+ def initialize(migrate_dir:, author_enabled:, diff_enabled:, base_override: "", branch_mask: "")
27
+ @migrate_dir = migrate_dir
28
+ @author_names = {}
29
+ @author_emails = {}
30
+ @landed_dates = {}
31
+ @branch_origins = {}
32
+ @uncommitted_files = Set.new
33
+ @incoming_merge_files = Set.new
34
+ @merge_source_label = nil
35
+
36
+ load_authors(author_enabled)
37
+ load_diff(branch_mask, base_override) if diff_enabled && migrate_dir
38
+ end
39
+
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.
44
+ def self.for(migrate_dir:, **opts)
45
+ cache = Railbow::MultiDb.current&.git_cache
46
+ return new(migrate_dir: migrate_dir, **opts) unless cache
47
+
48
+ cache[[migrate_dir, opts]] ||= new(migrate_dir: migrate_dir, **opts)
49
+ end
50
+
51
+ # Extracts a display label from a branch name. "auto" uses the built-in
52
+ # ticket pattern; anything else is a regex whose first capture wins.
53
+ def self.apply_branch_mask(branch, branch_mask)
54
+ return branch if branch_mask.empty?
55
+ return Railbow::Params.extract_branch_ticket(branch) if branch_mask == "auto"
56
+
57
+ re = begin
58
+ Regexp.new(branch_mask, Regexp::IGNORECASE)
59
+ rescue RegexpError
60
+ return branch
61
+ end
62
+ m = branch.match(re)
63
+ (m && m[1]) ? m[1] : branch
64
+ end
65
+
66
+ private
67
+
68
+ def apply_branch_mask(branch, branch_mask)
69
+ self.class.apply_branch_mask(branch, branch_mask)
70
+ end
71
+
72
+ def load_authors(author_enabled)
73
+ if migrate_dir
74
+ @landed_dates = migration_landed_dates
75
+ if author_enabled
76
+ result = migration_authors
77
+ @author_names = result[:names]
78
+ @author_emails = result[:emails]
79
+ end
80
+ end
81
+ return unless author_enabled
82
+
83
+ @git_email = current_git_email
84
+ @git_name = current_git_name
85
+ end
86
+
87
+ def load_diff(branch_mask, base_override)
88
+ base_branch = detect_default_branch(base_override)
89
+ @branch_origins = branch_migration_origins(base_branch, branch_mask)
90
+ @incoming_merge_files = incoming_merge_files_from_git
91
+ @merge_source_label = detect_merge_source_label(branch_mask) if @incoming_merge_files.any?
92
+ @uncommitted_files = uncommitted_migration_files
93
+
94
+ # Uncommitted files have no commit to attribute, so they belong to
95
+ # whatever branch is checked out right now.
96
+ current_branch = current_branch_name(branch_mask)
97
+ @uncommitted_files.each { |f| @branch_origins[f] ||= current_branch }
98
+ end
99
+
100
+ def migration_authors
101
+ output, _status = Railbow::GitUtils.capture2(
102
+ "log", "--format=COMMIT:%aN\t%aE", "--diff-filter=AR", "--name-status", "--", migrate_dir
103
+ )
104
+ return EMPTY_AUTHORS.dup if output.empty?
105
+
106
+ names = {}
107
+ emails = {}
108
+ current_name = nil
109
+ current_email = nil
110
+ output.each_line do |line|
111
+ line = line.strip
112
+ if line.start_with?("COMMIT:")
113
+ parts = line.sub("COMMIT:", "").split("\t", 2)
114
+ current_name = parts[0]
115
+ current_email = parts[1]&.downcase
116
+ elsif !line.empty? && current_name
117
+ basename = name_status_basename(line)
118
+ next unless basename
119
+ names[basename] ||= current_name
120
+ emails[basename] ||= current_email
121
+ end
122
+ end
123
+ {names: names, emails: emails}
124
+ end
125
+
126
+ # basename => Date the migration first appeared on the mainline (the
127
+ # merge commit date, via --first-parent).
128
+ def migration_landed_dates
129
+ output, _status = Railbow::GitUtils.capture2(
130
+ "log", "--first-parent", "--format=COMMIT:%cI", "--diff-filter=AR", "--name-status", "--", migrate_dir
131
+ )
132
+ return {} if output.empty?
133
+
134
+ dates = {}
135
+ current_date = nil
136
+ output.each_line do |line|
137
+ line = line.strip
138
+ if line.start_with?("COMMIT:")
139
+ current_date = begin
140
+ Date.parse(line.sub("COMMIT:", ""))
141
+ rescue Date::Error
142
+ nil
143
+ end
144
+ elsif !line.empty? && current_date
145
+ basename = name_status_basename(line)
146
+ next unless basename
147
+ dates[basename] ||= current_date
148
+ end
149
+ end
150
+ dates
151
+ end
152
+
153
+ # --name-status lines are "A\tfilepath" or "Rnnn\told\tnew"; a rename
154
+ # attributes to its destination.
155
+ def name_status_basename(line)
156
+ cols = line.split("\t")
157
+ filepath = cols[0]&.start_with?("R") ? cols[2] : cols[1]
158
+ filepath ? File.basename(filepath) : nil
159
+ end
160
+
161
+ def current_git_email
162
+ output, _status = Railbow::GitUtils.capture2("config", "user.email")
163
+ output.strip.downcase
164
+ end
165
+
166
+ def current_git_name
167
+ output, _status = Railbow::GitUtils.capture2("config", "user.name")
168
+ output.strip
169
+ end
170
+
171
+ def current_branch_name(branch_mask)
172
+ output, status = Railbow::GitUtils.capture2("rev-parse", "--abbrev-ref", "HEAD")
173
+ return "HEAD" unless status.success?
174
+
175
+ apply_branch_mask(output.strip, branch_mask)
176
+ end
177
+
178
+ def detect_default_branch(override)
179
+ return override if override && !override.empty?
180
+
181
+ output, status = Railbow::GitUtils.capture2("symbolic-ref", "refs/remotes/origin/HEAD")
182
+ if status.success?
183
+ branch = output.strip.sub(%r{^refs/remotes/origin/}, "")
184
+ return branch unless branch.empty?
185
+ end
186
+
187
+ %w[main master].each do |candidate|
188
+ _, st = Railbow::GitUtils.capture2("rev-parse", "--verify", "refs/heads/#{candidate}")
189
+ return candidate if st.success?
190
+ end
191
+
192
+ "main"
193
+ end
194
+
195
+ def branch_migration_origins(base_branch, branch_mask)
196
+ merge_base_out, mb_status = Railbow::GitUtils.capture2("merge-base", "HEAD", base_branch)
197
+ return {} unless mb_status.success?
198
+
199
+ merge_base = merge_base_out.strip
200
+ diff_out, diff_status = Railbow::GitUtils.capture2(
201
+ "diff", "--name-status", "--diff-filter=AR", merge_base, "HEAD", "--", migrate_dir
202
+ )
203
+ return {} unless diff_status.success?
204
+
205
+ files = diff_out.each_line.map { |l|
206
+ cols = l.strip.split("\t")
207
+ cols[0]&.start_with?("R") ? cols[2] : cols[1]
208
+ }.compact.reject(&:empty?)
209
+ origins = {}
210
+
211
+ files.each do |filepath|
212
+ basename = File.basename(filepath)
213
+ commit = adding_commit(filepath)
214
+ next unless commit
215
+
216
+ branches = branches_containing(commit)
217
+ next if branches.empty?
218
+
219
+ best = pick_origin_branch(branches, commit)
220
+ origins[basename] = best ? apply_branch_mask(best, branch_mask) : best
221
+ end
222
+
223
+ origins
224
+ end
225
+
226
+ def adding_commit(filepath)
227
+ commit_out, cs = Railbow::GitUtils.capture2(
228
+ "log", "--diff-filter=AR", "--format=%H", "-1", "--", filepath
229
+ )
230
+ return nil unless cs.success?
231
+
232
+ commit = commit_out.strip
233
+ commit.empty? ? nil : commit
234
+ end
235
+
236
+ def branches_containing(commit)
237
+ branches_out, bs = Railbow::GitUtils.capture2(
238
+ "branch", "--contains", commit, "--format=%(refname:short)"
239
+ )
240
+ return [] unless bs.success?
241
+
242
+ branches_out.each_line.map(&:strip).reject(&:empty?)
243
+ end
244
+
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.
250
+ def pick_origin_branch(branches, commit)
251
+ return branches.first if branches.size == 1
252
+
253
+ filtered = branches.reject do |b|
254
+ branches.any? do |other|
255
+ next false if other == b
256
+ _, st = Railbow::GitUtils.capture2("merge-base", "--is-ancestor", other, b)
257
+ st.success?
258
+ end
259
+ end
260
+ filtered = branches if filtered.empty?
261
+ return filtered.first if filtered.size == 1
262
+
263
+ filtered.max_by do |b|
264
+ count_out, _ = Railbow::GitUtils.capture2("rev-list", "--count", "#{commit}..#{b}")
265
+ count_out.strip.to_i
266
+ end
267
+ end
268
+
269
+ def uncommitted_migration_files
270
+ output, status = Railbow::GitUtils.capture2("status", "--porcelain", "--", migrate_dir)
271
+ return Set.new unless status.success?
272
+
273
+ result = Set.new
274
+ output.each_line do |line|
275
+ code = line[0..1]
276
+
277
+ if code[0] == "R"
278
+ # Rename: "R old -> new" or "R100 old -> new". Take the destination.
279
+ parts = line[3..].split(" -> ", 2)
280
+ filepath = (parts[1] || parts[0]).strip
281
+ elsif ["??", "A ", "AM", "M "].include?(code)
282
+ filepath = line[3..].strip
283
+ else
284
+ next
285
+ end
286
+
287
+ result << File.basename(filepath) unless filepath.empty?
288
+ end
289
+
290
+ # During an in-progress merge, files from MERGE_HEAD (e.g. main) show up
291
+ # as staged additions. Exclude them so they are not tagged as ours.
292
+ result - incoming_merge_files_from_git
293
+ end
294
+
295
+ def detect_merge_source_label(branch_mask)
296
+ merge_head, _, status = Railbow::GitUtils.capture3("rev-parse", "MERGE_HEAD")
297
+ return nil unless status.success?
298
+
299
+ branches_out, _, bs = Railbow::GitUtils.capture3(
300
+ "branch", "--contains", merge_head.strip, "--format=%(refname:short)"
301
+ )
302
+ return nil unless bs.success?
303
+
304
+ branches = branches_out.each_line.map(&:strip).reject(&:empty?)
305
+ return nil if branches.empty?
306
+
307
+ branch = branches.first if branches.size == 1
308
+ branch ||= branches.find { |b| %w[main master develop].include?(b) }
309
+ branch ||= branches.first
310
+
311
+ apply_branch_mask(branch, branch_mask)
312
+ end
313
+
314
+ def incoming_merge_files_from_git
315
+ _, _, mh_status = Railbow::GitUtils.capture3("rev-parse", "MERGE_HEAD")
316
+ return Set.new unless mh_status.success?
317
+
318
+ output, _, status = Railbow::GitUtils.capture3(
319
+ "diff", "--name-only", "--diff-filter=AR", "HEAD", "MERGE_HEAD", "--", migrate_dir
320
+ )
321
+ return Set.new unless status.success?
322
+
323
+ Set.new(output.each_line.map { |l| File.basename(l.strip) }.reject(&:empty?))
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logo"
4
+
5
+ module Railbow
6
+ module Status
7
+ module Help
8
+ module_function
9
+
10
+ def print
11
+ Railbow.print_logo
12
+ puts <<~HELP
13
+
14
+ Enhanced db:migrate:status
15
+
16
+ \e[1mUsage:\e[0m
17
+ [RBW_*=value ...] rake db:migrate:status
18
+
19
+ \e[1mOptions:\e[0m
20
+ RBW_SINCE=<period> Filter migrations by age (default: all)
21
+ Values: all, 2mo, 1w, 30d, 1y, etc.
22
+ Units: d (days), w (weeks), mo/m (months), y (years)
23
+
24
+ RBW_SINCE_MIN=<n> Never show fewer than n migrations, however
25
+ old (default: 10). The window above is a
26
+ soft limit: a database with five migrations
27
+ shows all five rather than reporting an
28
+ empty period. 0 disables the floor.
29
+
30
+ RBW_DATE=<mode> Date column format (default: full):
31
+ full - 2026-01-30 12:08:54 (column: Created At)
32
+ rel - ~3d ago
33
+ short - Jan 30 (column: Date)
34
+ custom(…) - user strftime, e.g. custom(%b %d, %Y)
35
+
36
+ RBW_VIEW=<options> Display options (comma-separated):
37
+ calendar - show month/year separator lines + week ticks
38
+ tables - parse migration files, show Tables column
39
+
40
+ RBW_COMPACT=<options> Compact display (comma-separated):
41
+ oneline - truncate instead of wrapping
42
+ dense - remove cell padding
43
+ noheader - hide table header row
44
+ maxw:<n> - cap column widths at n chars
45
+ hide:<col> - hide a column by name (repeatable)
46
+
47
+ RBW_CALENDAR=<options> Calendar sub-options (requires RBW_VIEW=calendar):
48
+ (empty) - month separators only, no week
49
+ markers at all
50
+ wticks - week tick marks on the date column
51
+ wdividers - a separator row per ISO week
52
+ counts - append "· N migrations" to every
53
+ separator row (that section)
54
+ label:<fmt> - strftime for month separators
55
+ (default: %b %Y W%V)
56
+ wlabel:<fmt> - strftime for week separators
57
+ (default: same as label, so the
58
+ week number never shifts)
59
+
60
+ RBW_DB=<options> Multi-database runs (default: focus).
61
+ An empty value expands every database into
62
+ its own section, in database.yml order,
63
+ with column widths shared so the tables
64
+ line up. Databases sharing a migrations
65
+ path (shards) merge into one table with a
66
+ status per database.
67
+ focus - expand only the first
68
+ database, summarizing the
69
+ rest in a line each. A
70
+ database with migrations
71
+ pending is always expanded
72
+ only:<name> - render only this database
73
+ (repeatable)
74
+ skip:<name> - drop this database
75
+ (repeatable)
76
+ full - draw every database in full,
77
+ overriding focus and never
78
+ collapsing a quiet one
79
+ inline - one merged table ordered by
80
+ version, with a Db column
81
+
82
+ RBW_GIT=<options> Git integration (comma-separated):
83
+ author - add an Author column (same as author:all)
84
+ author:all - add an Author column
85
+ author:me - highlight your own migrations
86
+ diff - tag migrations by git origin
87
+ base:<branch> - base branch for diff (default: auto-detected)
88
+ mask:<re> - regex to extract branch label
89
+ e.g. mask:(WS-[^/]+)/
90
+ mask:auto - auto-extract ticket id from branch name
91
+
92
+ RBW_PLAIN=1 Disable Railbow formatting (plain Rails output)
93
+
94
+ RBW_FORCE=1 Force Railbow formatting even when piped, in CI,
95
+ or called by an LLM agent (RBW_PLAIN=1 still wins)
96
+
97
+ RBW_HELP=1 Show this help message
98
+
99
+ \e[2mAuto-disabled when piped, in CI, or when called by an LLM agent.\e[0m
100
+
101
+ \e[1mExamples:\e[0m
102
+ rake db:migrate:status
103
+ RBW_SINCE=2mo RBW_VIEW=calendar rake db:migrate:status
104
+ RBW_CALENDAR=wdividers,counts rake db:migrate:status
105
+ RBW_VIEW=tables RBW_GIT=author rake db:migrate:status
106
+ RBW_GIT=author:me RBW_SINCE=3mo rake db:migrate:status
107
+ RBW_DATE=rel rake db:migrate:status
108
+ RBW_DATE=short rake db:migrate:status
109
+ RBW_DATE='custom(%b %d, %Y)' rake db:migrate:status
110
+ RBW_GIT=diff rake db:migrate:status
111
+ RBW_GIT=diff,base:develop rake db:migrate:status
112
+ RBW_GIT=diff,mask:(WS-[^/]+)/ rake db:migrate:status
113
+ RBW_DB= rake db:migrate:status
114
+ RBW_DB=only:primary rake db:migrate:status
115
+ RBW_DB=inline rake db:migrate:status
116
+
117
+ HELP
118
+ end
119
+ end
120
+ end
121
+ end