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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +79 -1
- data/lib/railbow/config.rb +3 -1
- data/lib/railbow/init.rb +16 -0
- data/lib/railbow/multi_db.rb +87 -0
- data/lib/railbow/params.rb +54 -0
- data/lib/railbow/status/ghosts.rb +144 -0
- data/lib/railbow/status/git_data.rb +327 -0
- data/lib/railbow/status/help.rb +121 -0
- data/lib/railbow/status/printer.rb +324 -0
- data/lib/railbow/status/section.rb +551 -0
- data/lib/railbow/table/column.rb +7 -2
- data/lib/railbow/table/renderer.rb +47 -18
- data/lib/railbow/tasks/migrate_status.rake +30 -756
- data/lib/railbow/version.rb +1 -1
- metadata +7 -1
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../formatters/base"
|
|
6
|
+
require_relative "../migration_parser"
|
|
7
|
+
require_relative "../params"
|
|
8
|
+
require_relative "../table"
|
|
9
|
+
require_relative "../calendar"
|
|
10
|
+
require_relative "git_data"
|
|
11
|
+
require_relative "ghosts"
|
|
12
|
+
|
|
13
|
+
module Railbow
|
|
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
|
|
20
|
+
# 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.
|
|
25
|
+
class Section
|
|
26
|
+
# Index of the Date column, where calendar week ticks are drawn.
|
|
27
|
+
TICK_COL = 2
|
|
28
|
+
|
|
29
|
+
# Width the Migration Name column is capped at once anything competes
|
|
30
|
+
# with it for horizontal space (tags, authors, table names).
|
|
31
|
+
NAME_COL_WIDTH = 60
|
|
32
|
+
|
|
33
|
+
NO_FILE = "NO FILE"
|
|
34
|
+
|
|
35
|
+
# A version one database in a shard group has and another does not.
|
|
36
|
+
ABSENT = "·"
|
|
37
|
+
|
|
38
|
+
attr_reader :databases, :migrations_key, :state, :hidden_count, :total_count,
|
|
39
|
+
:since_value, :entries, :ghosts
|
|
40
|
+
|
|
41
|
+
# True when the time window left too few rows and the floor topped the
|
|
42
|
+
# section back up, which is worth saying out loud: the table then shows
|
|
43
|
+
# migrations from outside the window it advertises.
|
|
44
|
+
def floor_applied?
|
|
45
|
+
@floor_applied == true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(pool)
|
|
49
|
+
@formatter = Formatters::Base.new
|
|
50
|
+
@databases = [{name: pool.db_config.name, database: pool.db_config.database}]
|
|
51
|
+
@migrations_key = migrations_key_for(pool)
|
|
52
|
+
@entries = {}
|
|
53
|
+
@ghosts = {}
|
|
54
|
+
@hidden_count = 0
|
|
55
|
+
@total_count = 0
|
|
56
|
+
@window_count = 0
|
|
57
|
+
@floor_applied = false
|
|
58
|
+
@since_value = Railbow::Params.since
|
|
59
|
+
|
|
60
|
+
collect(pool)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The database this section is named after. Multi-database sections use
|
|
64
|
+
# #databases; this is the single-database convenience.
|
|
65
|
+
def database
|
|
66
|
+
databases.first[:database]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def db_name
|
|
70
|
+
databases.first[:name]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def db_names
|
|
74
|
+
databases.map { |d| d[:name] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def same_migrations?(other)
|
|
78
|
+
migrations_key == other.migrations_key
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Folds another database that runs the same migration files into this
|
|
82
|
+
# section: its statuses join the existing rows, its identity joins the
|
|
83
|
+
# header, and anything already built is discarded.
|
|
84
|
+
def merge!(other)
|
|
85
|
+
other.entries.each do |version, entry|
|
|
86
|
+
mine = (@entries[version] ||= {name: entry[:name], statuses: {}})
|
|
87
|
+
mine[:name] = entry[:name] if mine[:name].include?(NO_FILE)
|
|
88
|
+
mine[:statuses].merge!(entry[:statuses])
|
|
89
|
+
end
|
|
90
|
+
@databases.concat(other.databases)
|
|
91
|
+
@ghosts = other.ghosts.merge(@ghosts)
|
|
92
|
+
@total_count = [@total_count, other.total_count].max
|
|
93
|
+
@hidden_count = [@hidden_count, other.hidden_count].max
|
|
94
|
+
@pending_count = [pending_count, other.pending_count].max
|
|
95
|
+
@ghost_total = [ghost_count, other.ghost_count].max
|
|
96
|
+
@latest_applied_version = [latest_applied_version, other.latest_applied_version].compact.max
|
|
97
|
+
@state = other.state if state_rank(other.state) > state_rank(@state)
|
|
98
|
+
reset_built
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def sharded?
|
|
103
|
+
databases.size > 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether any row this section would render is still pending. Answered
|
|
107
|
+
# from the collected entries, so asking costs no git work - which is the
|
|
108
|
+
# point, since it is asked in order to decide whether to build the rows
|
|
109
|
+
# at all.
|
|
110
|
+
def pending_in_view?
|
|
111
|
+
entries.any? { |_, entry| entry[:statuses].value?("down") }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def tick_col
|
|
115
|
+
TICK_COL
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Nothing in the window and nothing pending: the section has no table
|
|
119
|
+
# worth drawing, only a line saying so.
|
|
120
|
+
def quiet?
|
|
121
|
+
state != :ok
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def pending_count
|
|
125
|
+
@pending_count ||= 0
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def ghost_count
|
|
129
|
+
@ghost_total ||= 0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
attr_reader :latest_applied_version
|
|
133
|
+
|
|
134
|
+
def latest_applied_date
|
|
135
|
+
version_date(latest_applied_version) if latest_applied_version
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def columns
|
|
139
|
+
build! unless @built
|
|
140
|
+
@columns
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def rows
|
|
144
|
+
build! unless @built
|
|
145
|
+
@rows
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def highlight_rows
|
|
149
|
+
build! unless @built
|
|
150
|
+
@highlight_rows
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def ghost_rows
|
|
154
|
+
build! unless @built
|
|
155
|
+
@ghost_rows
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def down_rows
|
|
159
|
+
build! unless @built
|
|
160
|
+
@down_rows
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def calendar
|
|
164
|
+
build! unless @built
|
|
165
|
+
@calendar
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
attr_reader :formatter, :git, :version_to_file
|
|
171
|
+
|
|
172
|
+
def migrations_key_for(pool)
|
|
173
|
+
paths = Array(pool.migration_context.migrations_paths)
|
|
174
|
+
paths.map { |p| File.expand_path(p.to_s) }.sort
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def state_rank(state)
|
|
178
|
+
{no_migrations: 0, none_in_period: 1, ok: 2}.fetch(state, 0)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def reset_built
|
|
182
|
+
@built = false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def collect(pool)
|
|
186
|
+
db_list = pool.migration_context.migrations_status
|
|
187
|
+
@total_count = db_list.size
|
|
188
|
+
record_totals(db_list)
|
|
189
|
+
|
|
190
|
+
if db_list.empty?
|
|
191
|
+
@state = :no_migrations
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
db_list = apply_since_filter(db_list)
|
|
196
|
+
|
|
197
|
+
# State tracks recency, not row count: a section whose only rows were
|
|
198
|
+
# pulled in by the floor still has nothing recent to say, and should
|
|
199
|
+
# still collapse in a multi-database run.
|
|
200
|
+
@state = @window_count.zero? ? :none_in_period : :ok
|
|
201
|
+
return if db_list.empty?
|
|
202
|
+
|
|
203
|
+
@entries = db_list.to_h do |status, version, name|
|
|
204
|
+
[version.to_s, {name: name, statuses: {db_name => status}}]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
@version_to_file = {}
|
|
208
|
+
pool.migration_context.migrations.each do |m|
|
|
209
|
+
@version_to_file[m.version.to_s] = m.filename
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Counted over every migration, not just the ones in the window, so a
|
|
214
|
+
# collapsed section can still report what it is hiding.
|
|
215
|
+
def record_totals(db_list)
|
|
216
|
+
@pending_count = db_list.count { |status, _, _| status == "down" }
|
|
217
|
+
@ghost_total = db_list.count { |_, _, name| name.to_s.include?(NO_FILE) }
|
|
218
|
+
applied = db_list.select { |status, _, _| status == "up" }
|
|
219
|
+
@latest_applied_version = applied.last&.dig(1)&.to_s
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The time window is a soft limit. Whatever it leaves, the floor tops the
|
|
223
|
+
# result back up to RBW_SINCE_MIN rows, so a database with five
|
|
224
|
+
# migrations shows all five rather than hiding the two that happen to be
|
|
225
|
+
# old. The window is still what decides whether the section reads as
|
|
226
|
+
# recent - see #collect.
|
|
227
|
+
def apply_since_filter(db_list)
|
|
228
|
+
since_cutoff = Railbow::Params.parse_since(since_value, context: "migrations")
|
|
229
|
+
unless since_cutoff
|
|
230
|
+
@window_count = db_list.size
|
|
231
|
+
return db_list
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
cutoff_version = since_cutoff.strftime("%Y%m%d%H%M%S").to_i
|
|
235
|
+
@window_count = db_list.count { |_, v, _| v.to_i >= cutoff_version }
|
|
236
|
+
|
|
237
|
+
# Not Comparable#clamp: the floor can exceed the total, and clamp
|
|
238
|
+
# raises when its lower bound sits above its upper one.
|
|
239
|
+
keep = [@window_count, floor].max
|
|
240
|
+
keep = db_list.size if keep > db_list.size
|
|
241
|
+
@floor_applied = keep > @window_count
|
|
242
|
+
if keep.zero?
|
|
243
|
+
@hidden_count = db_list.size
|
|
244
|
+
return []
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Taken by rank rather than by slicing the tail, so the same rows are
|
|
248
|
+
# kept whatever order Rails hands the list over in.
|
|
249
|
+
threshold = db_list.map { |_, v, _| v.to_i }.sort[-keep]
|
|
250
|
+
filtered = db_list.select { |_, v, _| v.to_i >= threshold }
|
|
251
|
+
@hidden_count = db_list.size - filtered.size
|
|
252
|
+
filtered
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def floor
|
|
256
|
+
[Railbow::Params.since_min, 0].max
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def load_git
|
|
260
|
+
sample_file = version_to_file.values.first
|
|
261
|
+
GitData.for(
|
|
262
|
+
migrate_dir: sample_file ? File.dirname(sample_file) : nil,
|
|
263
|
+
author_enabled: author_enabled?,
|
|
264
|
+
diff_enabled: Railbow::Params.git_diff?,
|
|
265
|
+
base_override: Railbow::Params.git_base,
|
|
266
|
+
branch_mask: Railbow::Params.git_mask
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def load_ghosts
|
|
271
|
+
return {} unless Ghosts.available?
|
|
272
|
+
|
|
273
|
+
versions = entries.select { |_, e| e[:name].include?(NO_FILE) }.keys
|
|
274
|
+
return {} if versions.empty?
|
|
275
|
+
|
|
276
|
+
Ghosts.load(versions, with_content: tables_enabled?)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def author_mode
|
|
280
|
+
@author_mode ||= Railbow::Params.git_author
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def author_enabled?
|
|
284
|
+
%w[all me].include?(author_mode)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def tables_enabled?
|
|
288
|
+
return @tables_enabled unless @tables_enabled.nil?
|
|
289
|
+
|
|
290
|
+
@tables_enabled = Railbow::Params.view_tables?
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Git lookups and ghost recovery happen here rather than during collect,
|
|
294
|
+
# so a section that ends up collapsed never pays for them.
|
|
295
|
+
def build!
|
|
296
|
+
@built = true
|
|
297
|
+
@columns = []
|
|
298
|
+
@rows = []
|
|
299
|
+
@highlight_rows = Set.new
|
|
300
|
+
@ghost_rows = Set.new
|
|
301
|
+
@down_rows = Set.new
|
|
302
|
+
@calendar = Railbow::Calendar.none
|
|
303
|
+
return if entries.empty?
|
|
304
|
+
|
|
305
|
+
@git = load_git
|
|
306
|
+
@ghosts = load_ghosts
|
|
307
|
+
@columns = build_columns
|
|
308
|
+
@rows = build_rows
|
|
309
|
+
@calendar = build_calendar
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# The name column only needs capping when something else competes for the
|
|
313
|
+
# row: table tags, an author column, branch badges or landed badges.
|
|
314
|
+
def build_columns
|
|
315
|
+
@name_col_width = needs_name_truncation? ? NAME_COL_WIDTH : nil
|
|
316
|
+
|
|
317
|
+
cols = [
|
|
318
|
+
Table::Column.new(label: "Status", max_width: status_col_width,
|
|
319
|
+
sticky: true, accent: true, aliased: !sharded?),
|
|
320
|
+
Table::Column.new(label: "Migration ID", sticky: true),
|
|
321
|
+
Table::Column.new(label: (date_format == "full") ? "Created At" : "Date"),
|
|
322
|
+
Table::Column.new(label: "Migration Name",
|
|
323
|
+
max_width: @name_col_width,
|
|
324
|
+
truncate: !@name_col_width.nil?)
|
|
325
|
+
]
|
|
326
|
+
cols << Table::Column.new(label: "Who") if author_mode == "all"
|
|
327
|
+
if tables_enabled?
|
|
328
|
+
truncate_fn = ->(cell_raw, max_w) { formatter.table_tags_fitted(cell_raw, max_w) }
|
|
329
|
+
cols << Table::Column.new(label: "Tables",
|
|
330
|
+
truncate: Railbow::Params.compact_oneline?, truncate_fn: truncate_fn)
|
|
331
|
+
end
|
|
332
|
+
cols
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# One glyph plus a space per database, and never narrower than the single
|
|
336
|
+
# database case, which also has to fit a trailing indicator.
|
|
337
|
+
def status_col_width
|
|
338
|
+
[6, 3 * databases.size].max
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def date_format
|
|
342
|
+
@date_format ||= Railbow::Params.date_format
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def needs_name_truncation?
|
|
346
|
+
tables_enabled? || author_mode == "all" || Railbow::Params.git_diff? || landed_tags?
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Only migrations that landed well after they were written earn a badge,
|
|
350
|
+
# so a repo that always merges promptly never pays for the column space.
|
|
351
|
+
def landed_tags?
|
|
352
|
+
git.landed_dates.any? do |basename, landed|
|
|
353
|
+
mig_date = version_date(basename[0..13])
|
|
354
|
+
mig_date && (landed - mig_date) > 7
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def build_rows
|
|
359
|
+
versions = entries.keys.sort
|
|
360
|
+
latest_mig_date = version_date(versions.last)
|
|
361
|
+
author_display = build_author_display
|
|
362
|
+
|
|
363
|
+
versions.each_with_index.map do |version, idx|
|
|
364
|
+
entry = entries[version]
|
|
365
|
+
name = entry[:name]
|
|
366
|
+
statuses = entry[:statuses]
|
|
367
|
+
|
|
368
|
+
# A pending migration is not in effect yet: grey the whole row out so
|
|
369
|
+
# it reads as inactive next to the applied ones. In a shard group only
|
|
370
|
+
# a row pending everywhere reads as inactive.
|
|
371
|
+
@down_rows << idx if statuses.values.all? { |s| s == "down" }
|
|
372
|
+
|
|
373
|
+
ghost = name.include?(NO_FILE) ? ghosts[version] : nil
|
|
374
|
+
@ghost_rows << idx if ghost
|
|
375
|
+
|
|
376
|
+
status_cell = build_status_cell(statuses, ghost)
|
|
377
|
+
display_name = name_cell(
|
|
378
|
+
name: name, version: version, ghost: ghost, latest_mig_date: latest_mig_date
|
|
379
|
+
) do |indicator|
|
|
380
|
+
status_cell = "#{status_cell} #{indicator}"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
row = [status_cell, version, formatter.format_date(version, date_format), display_name]
|
|
384
|
+
row << author_cell(version, ghost, author_display) if author_mode == "all"
|
|
385
|
+
track_highlight(idx, version, ghost) if author_enabled?
|
|
386
|
+
row << formatter.table_tags(tables_for(version, ghost)) if tables_enabled?
|
|
387
|
+
row
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def build_status_cell(statuses, ghost)
|
|
392
|
+
return status_glyph(statuses[db_name], ghost) unless sharded?
|
|
393
|
+
|
|
394
|
+
databases.map { |db| status_glyph(statuses[db[:name]], ghost) }.join(" ")
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# A sharded section resolves the up/down aliases itself: the renderer
|
|
398
|
+
# applies them by matching the whole cell, which a cluster never is.
|
|
399
|
+
def status_glyph(status, ghost)
|
|
400
|
+
# A superseded ghost lives on under another version: stale bookkeeping,
|
|
401
|
+
# not a lost migration, so it gets a calmer glyph.
|
|
402
|
+
return ghost.superseded_by ? "🪦" : "👻" if ghost && status
|
|
403
|
+
|
|
404
|
+
case status
|
|
405
|
+
when "up" then formatter.green_bold(sharded? ? status_alias("up") : "up")
|
|
406
|
+
when "down" then formatter.yellow_bold(sharded? ? status_alias("down") : "down")
|
|
407
|
+
when nil then formatter.dim(ABSENT)
|
|
408
|
+
else status
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def status_alias(status)
|
|
413
|
+
@status_aliases ||= Railbow::Config.value_aliases["Status"] || {}
|
|
414
|
+
@status_aliases[status] || status
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Builds the Migration Name cell and right-aligns whatever badges it
|
|
418
|
+
# carries. Yields a status indicator when the row earns one.
|
|
419
|
+
def name_cell(name:, version:, ghost:, latest_mig_date:)
|
|
420
|
+
if ghost
|
|
421
|
+
return with_tags(Ghosts.display_name(ghost), Ghosts.tag(ghost), strip: false)
|
|
422
|
+
end
|
|
423
|
+
return formatter.red(NO_FILE) if name.include?(NO_FILE)
|
|
424
|
+
|
|
425
|
+
basename = basename_for(version)
|
|
426
|
+
tags = []
|
|
427
|
+
|
|
428
|
+
diff_tag = nil
|
|
429
|
+
if Railbow::Params.git_diff? && basename
|
|
430
|
+
if git.incoming_merge_files.include?(basename)
|
|
431
|
+
yield "\e[38;5;213m⬇#{Formatters::Base::RESET}"
|
|
432
|
+
diff_tag = formatter.diff_tag_merging(git.merge_source_label || "merge")
|
|
433
|
+
elsif git.uncommitted_files.include?(basename)
|
|
434
|
+
yield "\e[38;5;220m◆#{Formatters::Base::RESET}"
|
|
435
|
+
end
|
|
436
|
+
diff_tag ||= formatter.diff_tag_branch(git.branch_origins[basename]) if git.branch_origins.key?(basename)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
tags << landed_tag(basename, version, latest_mig_date)
|
|
440
|
+
tags << diff_tag
|
|
441
|
+
|
|
442
|
+
with_tags(name, tags.compact.join(" "))
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Landed badge: the migration reached the mainline more than a week after
|
|
446
|
+
# it was written, which usually means a long-lived branch.
|
|
447
|
+
def landed_tag(basename, version, latest_mig_date)
|
|
448
|
+
return nil unless basename
|
|
449
|
+
|
|
450
|
+
landed = git.landed_dates[basename]
|
|
451
|
+
return nil unless landed
|
|
452
|
+
|
|
453
|
+
mig_date = version_date(version)
|
|
454
|
+
return nil unless mig_date && (landed - mig_date) > 7
|
|
455
|
+
|
|
456
|
+
formatter.landed_tag(landed, fresh: latest_mig_date && landed >= latest_mig_date)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Pads the name so its tags sit flush against the right edge of the
|
|
460
|
+
# column. Falls back to a two-space gap when the column is uncapped.
|
|
461
|
+
def with_tags(name, tags, strip: true)
|
|
462
|
+
return name if tags.nil? || tags.empty?
|
|
463
|
+
return "#{name} #{tags}" unless @name_col_width
|
|
464
|
+
|
|
465
|
+
tags_width = formatter.display_width(formatter.strip_ansi(tags))
|
|
466
|
+
name = formatter.truncate_str(name, @name_col_width - tags_width - 2)
|
|
467
|
+
name_width = formatter.display_width(strip ? formatter.strip_ansi(name) : name)
|
|
468
|
+
padding = @name_col_width - name_width - tags_width
|
|
469
|
+
"#{name}#{" " * [padding, 2].max}#{tags}"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def build_author_display
|
|
473
|
+
return {} unless author_mode == "all"
|
|
474
|
+
|
|
475
|
+
raw = git.author_names.values.compact
|
|
476
|
+
raw << git.git_name if git.git_name
|
|
477
|
+
ghosts.each_value { |g| raw << g.author_name if g.author_name }
|
|
478
|
+
Railbow::Params.format_authors(raw)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def author_cell(version, ghost, author_display)
|
|
482
|
+
raw = if ghost
|
|
483
|
+
ghost.author_name || ""
|
|
484
|
+
else
|
|
485
|
+
basename = basename_for(version)
|
|
486
|
+
author = basename ? git.author_names[basename] : nil
|
|
487
|
+
author || (basename ? git.git_name : "")
|
|
488
|
+
end
|
|
489
|
+
author_display[raw] || Railbow::Params.format_author(raw)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Uncommitted migrations have no git author, so they are treated as mine.
|
|
493
|
+
# Match by email first, then by name, to survive a commit email that
|
|
494
|
+
# differs from git config (GitHub noreply addresses after a squash-merge,
|
|
495
|
+
# or a mailmap rewrite).
|
|
496
|
+
def track_highlight(idx, version, ghost)
|
|
497
|
+
return unless git.git_email
|
|
498
|
+
|
|
499
|
+
if ghost
|
|
500
|
+
email = ghost.author_email&.downcase
|
|
501
|
+
@highlight_rows << idx if email && email == git.git_email
|
|
502
|
+
return
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
basename = basename_for(version)
|
|
506
|
+
# A row with no file has no author to compare against, which the two
|
|
507
|
+
# modes read differently: author:all treats "nobody else's" as mine,
|
|
508
|
+
# author:me requires a real file before claiming anything.
|
|
509
|
+
return if author_mode == "me" && basename.nil?
|
|
510
|
+
|
|
511
|
+
email = git.author_emails[basename]
|
|
512
|
+
author = git.author_names[basename]
|
|
513
|
+
@highlight_rows << idx if email.nil? || email == git.git_email ||
|
|
514
|
+
(git.git_name && author && author.downcase == git.git_name.downcase)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def tables_for(version, ghost)
|
|
518
|
+
if ghost&.content && !ghost.content.empty?
|
|
519
|
+
Railbow::MigrationParser.extract_tables_from_content(ghost.content)
|
|
520
|
+
else
|
|
521
|
+
Railbow::MigrationParser.extract_tables(version_to_file[version])
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def basename_for(version)
|
|
526
|
+
filepath = version_to_file[version]
|
|
527
|
+
filepath ? File.basename(filepath) : nil
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def build_calendar
|
|
531
|
+
return Railbow::Calendar.none unless Railbow::Params.view_calendar?
|
|
532
|
+
|
|
533
|
+
Railbow::Calendar.build(
|
|
534
|
+
entries.keys.sort,
|
|
535
|
+
weeks: Railbow::Params.calendar_wdividers?,
|
|
536
|
+
ticks: Railbow::Params.calendar_wticks?,
|
|
537
|
+
counts: Railbow::Params.calendar_counts?,
|
|
538
|
+
month_label: Railbow::Params.calendar_label,
|
|
539
|
+
week_label: Railbow::Params.calendar_week_label
|
|
540
|
+
)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def version_date(version)
|
|
544
|
+
v = version.to_s
|
|
545
|
+
Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
|
|
546
|
+
rescue Date::Error
|
|
547
|
+
nil
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
end
|
data/lib/railbow/table/column.rb
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
module Railbow
|
|
4
4
|
module Table
|
|
5
5
|
class Column
|
|
6
|
-
attr_reader :label, :width, :min_width, :max_width, :align, :truncate, :truncate_fn,
|
|
6
|
+
attr_reader :label, :width, :min_width, :max_width, :align, :truncate, :truncate_fn,
|
|
7
|
+
:sticky, :accent, :aliased
|
|
7
8
|
|
|
8
9
|
# accent: the column carries its own meaning through color (a status
|
|
9
10
|
# glyph, say), so it keeps that color when the row is dimmed.
|
|
10
|
-
|
|
11
|
+
# aliased: value aliases from config may rewrite this column's cells.
|
|
12
|
+
# Off for cells the caller already resolved, such as a cluster of status
|
|
13
|
+
# glyphs, which no whole-cell alias could ever match.
|
|
14
|
+
def initialize(label:, width: :auto, min_width: nil, max_width: nil, align: :left, truncate: false, truncate_fn: nil, sticky: false, accent: false, aliased: true)
|
|
11
15
|
@label = label
|
|
12
16
|
@width = width
|
|
13
17
|
@min_width = min_width
|
|
@@ -17,6 +21,7 @@ module Railbow
|
|
|
17
21
|
@truncate_fn = truncate_fn
|
|
18
22
|
@sticky = sticky
|
|
19
23
|
@accent = accent
|
|
24
|
+
@aliased = aliased
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
def fixed?
|
|
@@ -16,35 +16,40 @@ module Railbow
|
|
|
16
16
|
|
|
17
17
|
attr_reader :columns, :theme
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
# min_widths raises the resolved width of each column to at least the
|
|
20
|
+
# given value, which is how several tables rendered in one run line their
|
|
21
|
+
# columns up with each other. An explicit compact maxw still wins.
|
|
22
|
+
def initialize(columns:, theme:, compact: {}, aliases: {}, min_widths: nil)
|
|
20
23
|
@compact = compact
|
|
21
24
|
@aliases = aliases
|
|
25
|
+
@min_widths = min_widths
|
|
22
26
|
@reverse_col_aliases = aliases[:columns]&.invert || {}
|
|
23
27
|
@columns = apply_hidden_columns(columns)
|
|
24
28
|
@theme = theme
|
|
25
29
|
end
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
# The widths this table would resolve to on its own. Callers rendering
|
|
32
|
+
# several tables together take the per-column maximum and feed it back as
|
|
33
|
+
# min_widths.
|
|
34
|
+
def column_widths(rows)
|
|
35
|
+
return [] if columns.empty?
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
resolve_widths(prepare_rows(rows))
|
|
38
|
+
end
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
# Width of everything left of the last column. The last column flexes to
|
|
41
|
+
# the terminal, so this is where furniture drawn around the table (a
|
|
42
|
+
# section rule, say) can stop without running past it.
|
|
43
|
+
def fixed_width(widths)
|
|
44
|
+
return 0 if columns.empty? || widths.nil? || widths.empty?
|
|
35
45
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
row.each_with_index.map { |cell, i|
|
|
39
|
-
col = columns[i]
|
|
40
|
-
if col&.truncate && col.max_width && i < columns.size - 1
|
|
41
|
-
truncate_str(cell.to_s, col.max_width)
|
|
42
|
-
else
|
|
43
|
-
cell
|
|
44
|
-
end
|
|
45
|
-
}
|
|
46
|
-
}
|
|
46
|
+
compute_prefix_width(widths, columns.size - 1)
|
|
47
|
+
end
|
|
47
48
|
|
|
49
|
+
def render(rows, separators: {}, highlight_rows: Set.new, ghost_rows: Set.new, dim_rows: Set.new, tick_rows: Set.new, tick_col: nil)
|
|
50
|
+
return "" if columns.empty?
|
|
51
|
+
|
|
52
|
+
rows = prepare_rows(rows)
|
|
48
53
|
resolved = resolve_widths(rows)
|
|
49
54
|
|
|
50
55
|
# In oneline mode, truncate non-sticky non-last columns at resolved width
|
|
@@ -77,6 +82,25 @@ module Railbow
|
|
|
77
82
|
|
|
78
83
|
private
|
|
79
84
|
|
|
85
|
+
# Everything that changes a cell's content, and therefore its width,
|
|
86
|
+
# before widths are resolved: hidden columns, value aliases, and the
|
|
87
|
+
# pre-truncation of non-last columns that cap themselves.
|
|
88
|
+
def prepare_rows(rows)
|
|
89
|
+
rows = remap_rows(rows) if @hidden_indices&.any?
|
|
90
|
+
rows = apply_value_aliases(rows) if @aliases[:values]&.any?
|
|
91
|
+
|
|
92
|
+
rows.map { |row|
|
|
93
|
+
row.each_with_index.map { |cell, i|
|
|
94
|
+
col = columns[i]
|
|
95
|
+
if col&.truncate && col.max_width && i < columns.size - 1
|
|
96
|
+
truncate_str(cell.to_s, col.max_width)
|
|
97
|
+
else
|
|
98
|
+
cell
|
|
99
|
+
end
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
80
104
|
def resolve_widths(rows)
|
|
81
105
|
all_rows = rows
|
|
82
106
|
last = columns.size - 1
|
|
@@ -95,6 +119,9 @@ module Railbow
|
|
|
95
119
|
w = [w, col.min_width].max if col.min_width
|
|
96
120
|
w = [w, col.max_width].min if col.max_width
|
|
97
121
|
end
|
|
122
|
+
# Alignment across tables raises the width; an explicit maxw caps it
|
|
123
|
+
# afterwards, so the user's cap stays authoritative.
|
|
124
|
+
w = [w, @min_widths[i].to_i].max if @min_widths && i != last && w > 0
|
|
98
125
|
w = [w, global_maxw].min if global_maxw && i != last && w > 0
|
|
99
126
|
w
|
|
100
127
|
end
|
|
@@ -429,6 +456,8 @@ module Railbow
|
|
|
429
456
|
# Build column index → value alias map
|
|
430
457
|
col_map = {}
|
|
431
458
|
columns.each_with_index do |col, i|
|
|
459
|
+
next unless col.aliased
|
|
460
|
+
|
|
432
461
|
label = col.label
|
|
433
462
|
col_map[i] = value_aliases[label] if value_aliases[label]
|
|
434
463
|
# Also look up by original name if column was renamed by alias
|