railbow 0.6.2 → 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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "date"
4
4
  require_relative "calendar"
5
+ require_relative "clock"
5
6
  require_relative "config"
6
7
  require_relative "name_formatter"
7
8
  require_relative "name_collision_resolver"
@@ -10,10 +11,11 @@ module Railbow
10
11
  module Params
11
12
  module_function
12
13
 
13
- # Parse compound ENV value: "author:me,diff,base:develop"
14
- # → { "author" => "me", "diff" => true, "base" => "develop" }
15
- # Repeated keys collect into arrays: "hide:date,hide:author"
16
- # → { "hide" => ["date", "author"] }
14
+ # Parse a compound ENV value; repeated keys collect into arrays.
15
+ # parse_compound("author:me,diff,base:develop")
16
+ # # => { "author" => "me", "diff" => true, "base" => "develop" }
17
+ # parse_compound("hide:date,hide:author")
18
+ # # => { "hide" => ["date", "author"] }
17
19
  def parse_compound(value)
18
20
  return {} if value.nil? || value.strip.empty?
19
21
 
@@ -54,11 +56,12 @@ module Railbow
54
56
  amount = match[1].to_i
55
57
  unit = match[2].downcase
56
58
 
59
+ today = Clock.today
57
60
  case unit
58
- when "d" then Date.today - amount
59
- when "w" then Date.today - (amount * 7)
60
- when "mo", "m" then Date.today.prev_month(amount)
61
- when "y" then Date.today.prev_year(amount)
61
+ when "d" then today - amount
62
+ when "w" then today - (amount * 7)
63
+ when "mo", "m" then today.prev_month(amount)
64
+ when "y" then today.prev_year(amount)
62
65
  end
63
66
  end
64
67
 
@@ -80,12 +83,9 @@ module Railbow
80
83
  (ENV["RBW_SINCE"] || Config.load["since"] || "all").strip.downcase
81
84
  end
82
85
 
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.
86
+ # Floor on how many migrations survive the SINCE window: when it leaves
87
+ # fewer rows, the oldest are pulled back in. 0 disables the floor. A knob of
88
+ # its own, not a RBW_SINCE token, so an ad-hoc `RBW_SINCE=2mo` keeps it.
89
89
  def since_min
90
90
  value = ENV["RBW_SINCE_MIN"] || Config.load["since_min"] || 0
91
91
  [value.to_i, 0].max
@@ -161,15 +161,14 @@ module Railbow
161
161
  ENV["RBW_AUTHOR_FORMAT"] || Config.load["author_format"] || "short"
162
162
  end
163
163
 
164
- # Format an author name using NameFormatter.
165
- # Accepts preset names ("initials", "first_name", etc.) or
166
- # custom patterns ("FF L", "FFFF L.", "LLLL, FFFF").
164
+ # author_format is a preset ("initials", "first_name", ...) or a custom
165
+ # pattern ("FF L", "FFFF L.", "LLLL, FFFF").
167
166
  def format_author(name)
168
167
  NameFormatter.format(name, author_format)
169
168
  end
170
169
 
171
- # Format a batch of author names with collision resolution.
172
- # Returns Hash{String => String} mapping raw names to disambiguated formatted names.
170
+ # Formats a batch of names so two different authors never render the same.
171
+ # Returns Hash{String => String} of raw name to formatted name.
173
172
  def format_authors(names)
174
173
  NameCollisionResolver.resolve(names, author_format)
175
174
  end
@@ -188,12 +187,11 @@ module Railbow
188
187
  val.is_a?(String) ? val : ""
189
188
  end
190
189
 
191
- # Built-in regex for extracting ticket/task identifiers from branch names.
192
- # Matches patterns like: ps-123, 123, abc123, ab-123-456, feat/ps-1234, feat/123-some-feature
190
+ # Ticket ids in branch names: ps-123, 123, abc123, ab-123-456, feat/ps-1234,
191
+ # feat/123-some-feature.
193
192
  TICKET_RE = /(?:^|\/)([a-z]{1,5}-?\d+(?:-\d+)*|\d+(?:-\d+)*)/i
194
193
 
195
- # Extract a ticket identifier from a branch name.
196
- # Returns the matched identifier or the original branch name if no match.
194
+ # The branch's ticket id, or the whole branch name when it has none.
197
195
  def extract_branch_ticket(branch)
198
196
  m = branch.match(TICKET_RE)
199
197
  m ? m[1] : branch
@@ -208,6 +206,15 @@ module Railbow
208
206
  val.to_s.strip
209
207
  end
210
208
 
209
+ # --- RBW_TZ ---
210
+
211
+ def tz
212
+ val = ENV["RBW_TZ"] || Config.load["tz"]
213
+ return "local" if val.nil? || val.to_s.strip.empty?
214
+
215
+ val.to_s.strip
216
+ end
217
+
211
218
  # --- Compound: RBW_VIEW ---
212
219
 
213
220
  def view
@@ -244,10 +251,9 @@ module Railbow
244
251
  db["full"] == true
245
252
  end
246
253
 
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.
254
+ # Expand only the first database, summarizing the rest in a line each. A
255
+ # database with pending migrations is always drawn in full, since that is
256
+ # what you may need to act on. RBW_DB=full overrides it; inline ignores it.
251
257
  def db_focus?
252
258
  db["focus"] == true
253
259
  end
@@ -4,37 +4,39 @@ require "rails/railtie"
4
4
 
5
5
  module Railbow
6
6
  class Railtie < Rails::Railtie
7
- # Initialize after ActiveRecord is loaded
7
+ # on_load, so booting the app does not force ActiveRecord to load early.
8
8
  initializer "railbow.enhance_migrations" do
9
9
  ActiveSupport.on_load(:active_record) do
10
10
  require_relative "migration_formatter"
11
11
 
12
- # Prepend our formatter module to ActiveRecord::Migration
13
12
  ActiveRecord::Migration.prepend(Railbow::MigrationFormatter)
14
13
  end
15
14
  end
16
15
 
17
- # Enhance routes output with colors
18
16
  initializer "railbow.enhance_routes", after: :load_config_initializers do
19
17
  require "action_dispatch/routing/inspector"
20
18
  require_relative "routes_formatter"
21
19
  ActionDispatch::Routing::ConsoleFormatter::Sheet.prepend(Railbow::RoutesFormatter)
22
20
  end
23
21
 
24
- # Enhance `rails about` output
25
22
  initializer "railbow.enhance_about", after: :load_config_initializers do
26
23
  require_relative "about_formatter"
27
24
  Rails::Info.singleton_class.prepend(Railbow::AboutFormatter)
28
25
  end
29
26
 
30
- # Enhance `rails notes` output
31
27
  initializer "railbow.enhance_notes", after: :load_config_initializers do
32
28
  require "rails/source_annotation_extractor"
33
29
  require_relative "notes_formatter"
34
30
  Rails::SourceAnnotationExtractor.prepend(Railbow::NotesFormatter)
35
31
  end
36
32
 
37
- # Load custom Rake tasks
33
+ # Since Rails 8.0 `rails stats` is a Rails command that boots the app but
34
+ # never loads rake tasks, so the .rake file alone cannot install this.
35
+ initializer "railbow.enhance_stats", after: :load_config_initializers do
36
+ require_relative "stats_formatter"
37
+ Railbow::StatsFormatter.install!
38
+ end
39
+
38
40
  rake_tasks do
39
41
  load "railbow/tasks/migrate_status.rake"
40
42
  load "railbow/tasks/stats.rake"
@@ -62,12 +62,22 @@ module Railbow
62
62
  return
63
63
  end
64
64
 
65
- strip_format = Railbow::Params.compact_strip_format?
66
- prepared = routes.map { |r| prepare_route(r, strip_format) }
67
- prepared = filter_by_verb(prepared)
65
+ # A failure drops whatever this section already buffered, then renders
66
+ # it the Rails way, header included since railbow normally hides it.
67
+ mark = @buffer.size
68
+ plain = lambda do
69
+ @buffer.slice!(mark..)
70
+ header(routes)
71
+ super
72
+ end
73
+ Railbow.guard(plain) do
74
+ strip_format = Railbow::Params.compact_strip_format?
75
+ prepared = routes.map { |r| prepare_route(r, strip_format) }
76
+ prepared = filter_by_verb(prepared)
68
77
 
69
- groups = group_routes(prepared)
70
- groups.each { |label, group| render_group(label, group) }
78
+ groups = group_routes(prepared)
79
+ groups.each { |label, group| render_group(label, group) }
80
+ end
71
81
  end
72
82
 
73
83
  private
@@ -97,7 +107,9 @@ module Railbow
97
107
  Table::Column.new(label: "Verb", sticky: true),
98
108
  Table::Column.new(label: "URI Pattern", sticky: true),
99
109
  Table::Column.new(label: "Controller#Action"),
100
- Table::Column.new(label: "Prefix")
110
+ # The route name is the least of the four, so a group too wide for the
111
+ # terminal gives it up rather than wrapping past the edge.
112
+ Table::Column.new(label: "Prefix", droppable: 1)
101
113
  ]
102
114
 
103
115
  rows = routes.map { |r|
@@ -6,94 +6,164 @@ require_relative "table"
6
6
 
7
7
  module Railbow
8
8
  module StatsFormatter
9
+ RESET = Formatters::Base::RESET
10
+ DIM = Formatters::Base::DIM
11
+ CODE_COLOR = "\e[38;5;76m" # green
12
+ TEST_COLOR = "\e[38;5;39m" # cyan
13
+
14
+ BAR_WIDTH = 16
15
+ BAR_EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"].freeze
16
+ FULL_BLOCK = "█"
17
+
18
+ # Rails 8 names the class Rails::CodeStatistics, Rails 7.2 a top-level
19
+ # CodeStatistics; both live in "rails/code_statistics". Prepending twice is
20
+ # a no-op, so every entry point may call this.
21
+ def self.install!
22
+ require "rails/code_statistics"
23
+ klass = defined?(Rails::CodeStatistics) ? Rails::CodeStatistics : ::CodeStatistics
24
+ klass.prepend(self)
25
+ rescue LoadError
26
+ # railties without code statistics: nothing to decorate
27
+ end
28
+
29
+ # Stock Rails prints the table from to_s and returns nil, and both the
30
+ # stats command and the rake task rely on that, so this prints too.
9
31
  def to_s
10
32
  return super if Railbow.plain?
11
33
 
12
- formatter = Formatters::Base.new
34
+ Railbow.guard(-> { super }) do
35
+ puts railbow_render
36
+ nil
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def railbow_render
43
+ f = Formatters::Base.new
44
+ tests = railbow_test_types
45
+ names = @pairs.map(&:first)
46
+ code_names = names.reject { |n| tests.include?(n) }
47
+ test_names = names.select { |n| tests.include?(n) }
48
+
49
+ total = railbow_sum(@statistics.values)
50
+ max_loc = @statistics.values.map(&:code_lines).max.to_i
13
51
 
14
- columns = [
52
+ rows = []
53
+ separators = {}
54
+ [["Code", code_names, CODE_COLOR, false], ["Tests", test_names, TEST_COLOR, true]].each do |label, group, color, test|
55
+ next if group.empty?
56
+
57
+ # A heading row rather than a renderer separator, which would put the
58
+ # label past the first column.
59
+ rows << [Table::Themes::WALLS.format_separator.call(label), *Array.new(7, "")]
60
+ group.each do |name|
61
+ rows << railbow_row(f, name, @statistics[name], total, max_loc, color, test: test)
62
+ end
63
+ end
64
+ separators[rows.size] = "" if rows.size > 1
65
+ rows << railbow_total_row(f, total)
66
+
67
+ renderer = Table::Renderer.new(
68
+ columns: railbow_columns,
69
+ theme: Table::Themes::WALLS,
70
+ compact: Railbow::Params.compact_options,
71
+ aliases: Railbow::Config.table_aliases
72
+ )
73
+
74
+ out = +"\n"
75
+ out << renderer.render(rows, separators: separators)
76
+ out << "\n\n"
77
+ out << railbow_summary(f, code_names, test_names)
78
+ out << "\n"
79
+ out
80
+ end
81
+
82
+ def railbow_columns
83
+ [
15
84
  Table::Column.new(label: "Name", sticky: true),
16
85
  Table::Column.new(label: "Lines", align: :right),
17
86
  Table::Column.new(label: "LOC", align: :right),
18
87
  Table::Column.new(label: "Classes", align: :right),
19
88
  Table::Column.new(label: "Methods", align: :right),
20
89
  Table::Column.new(label: "M/C", align: :right),
21
- Table::Column.new(label: "LOC/M", align: :right)
90
+ Table::Column.new(label: "LOC/M", align: :right),
91
+ Table::Column.new(label: "Share of LOC")
22
92
  ]
93
+ end
23
94
 
24
- rows = []
25
- total_lines = 0
26
- total_loc = 0
27
- total_classes = 0
28
- total_methods = 0
29
- code_loc = 0
30
- test_loc = 0
31
-
32
- @pairs.each do |pair|
33
- name, stats = pair
34
- lines = stats.lines
35
- loc = stats.code_lines
36
- classes = stats.classes
37
- methods = stats.methods
38
- mc = (classes > 0) ? (methods.to_f / classes).round(0).to_i : 0
39
- locm = (methods > 0) ? ((loc.to_f / methods) - 2).round(0).to_i : 0
40
-
41
- total_lines += lines
42
- total_loc += loc
43
- total_classes += classes
44
- total_methods += methods
45
-
46
- if stats.test_file?
47
- test_loc += loc
48
- else
49
- code_loc += loc
50
- end
95
+ # M/C and LOC/M say little about test code, where most blocks are not
96
+ # methods, so on test rows they step back.
97
+ def railbow_row(f, name, stats, total, max_loc, color, test:)
98
+ ratios = railbow_ratios(stats).map { |v| test ? f.dim(railbow_num(v)) : railbow_count(f, v) }
99
+ [
100
+ name,
101
+ railbow_count(f, stats.lines),
102
+ railbow_count(f, stats.code_lines),
103
+ railbow_count(f, stats.classes),
104
+ railbow_count(f, stats.methods),
105
+ *ratios,
106
+ railbow_share(f, stats.code_lines, total.code_lines, max_loc, color)
107
+ ]
108
+ end
51
109
 
52
- rows << [
53
- name,
54
- lines.to_s,
55
- loc.to_s,
56
- classes.to_s,
57
- methods.to_s,
58
- mc.to_s,
59
- locm.to_s
60
- ]
61
- end
110
+ def railbow_total_row(f, total)
111
+ cells = [total.lines, total.code_lines, total.classes, total.methods, *railbow_ratios(total)]
112
+ [f.bold("Total"), *cells.map { |v| f.bold(railbow_num(v)) }, ""]
113
+ end
62
114
 
63
- # Total row
64
- total_mc = (total_classes > 0) ? (total_methods.to_f / total_classes).round(0).to_i : 0
65
- total_locm = (total_methods > 0) ? ((total_loc.to_f / total_methods) - 2).round(0).to_i : 0
66
-
67
- rows << [
68
- formatter.bold("Total"),
69
- formatter.bold(total_lines.to_s),
70
- formatter.bold(total_loc.to_s),
71
- formatter.bold(total_classes.to_s),
72
- formatter.bold(total_methods.to_s),
73
- formatter.bold(total_mc.to_s),
74
- formatter.bold(total_locm.to_s)
75
- ]
115
+ # Integer math, as stock Rails does it, so the numbers match its table.
116
+ def railbow_ratios(stats)
117
+ m_over_c = stats.classes.zero? ? 0 : stats.methods / stats.classes
118
+ loc_over_m = stats.methods.zero? ? 0 : (stats.code_lines / stats.methods) - 2
119
+ [m_over_c, loc_over_m]
120
+ end
76
121
 
77
- renderer = Table::Renderer.new(
78
- columns: columns,
79
- theme: Table::Themes::WALLS,
80
- compact: Railbow::Params.compact_options,
81
- aliases: Railbow::Config.table_aliases
82
- )
122
+ # The bar is scaled to the biggest row so small directories stay visible;
123
+ # the percentage is of the whole codebase.
124
+ def railbow_share(f, loc, total_loc, max_loc, color)
125
+ return f.dim("-") if loc.zero? || total_loc.zero?
126
+
127
+ eighths = (BAR_WIDTH * 8 * loc.fdiv(max_loc)).round.clamp(1, BAR_WIDTH * 8)
128
+ bar = (FULL_BLOCK * (eighths / 8)) + BAR_EIGHTHS[eighths % 8]
129
+ share = 100.0 * loc / total_loc
130
+ pct = (share < 0.1) ? "<0.1%" : format("%.1f%%", share)
131
+ "#{color}#{bar.ljust(BAR_WIDTH)}#{RESET} #{f.dim(pct.rjust(5))}"
132
+ end
133
+
134
+ def railbow_summary(f, code_names, test_names)
135
+ code = code_names.sum { |n| @statistics[n].code_lines }
136
+ test = test_names.sum { |n| @statistics[n].code_lines }
83
137
 
84
- output = +"\n"
85
- output << renderer.render(rows)
86
- output << "\n"
138
+ parts = ["#{CODE_COLOR}Code#{RESET} #{f.bold(railbow_num(code))} LOC"]
139
+ parts << "#{TEST_COLOR}Tests#{RESET} #{f.bold(railbow_num(test))} LOC"
140
+ parts << "Code to Test Ratio #{f.bold(format("1:%.1f", test.fdiv(code)))}" if code.positive?
141
+ " #{parts.join(f.dim(" · "))}\n"
142
+ end
87
143
 
88
- # Code/test ratio
89
- if code_loc > 0 && test_loc > 0
90
- ratio = (test_loc.to_f / code_loc).round(1)
91
- output << "\n #{formatter.bold("Code LOC:")} #{code_loc} " \
92
- "#{formatter.bold("Test LOC:")} #{test_loc} " \
93
- "#{formatter.bold("Code to Test Ratio:")} 1:#{ratio}\n"
144
+ def railbow_sum(calculators)
145
+ total = Struct.new(:lines, :code_lines, :classes, :methods).new(0, 0, 0, 0)
146
+ calculators.each do |s|
147
+ total.lines += s.lines
148
+ total.code_lines += s.code_lines
149
+ total.classes += s.classes
150
+ total.methods += s.methods
94
151
  end
152
+ total
153
+ end
154
+
155
+ # Rails 8 keeps the list in a class attribute that register_directory
156
+ # extends; Rails 7.2 in a constant that rspec-rails appends to.
157
+ def railbow_test_types
158
+ respond_to?(:test_types, true) ? test_types : self.class::TEST_TYPES
159
+ end
160
+
161
+ def railbow_count(f, value)
162
+ value.zero? ? f.dim("0") : railbow_num(value)
163
+ end
95
164
 
96
- output
165
+ def railbow_num(value)
166
+ value.to_s.reverse.scan(/\d{1,3}/).join(",").reverse.then { |s| value.negative? ? "-#{s}" : s }
97
167
  end
98
168
  end
99
169
  end
@@ -46,8 +46,8 @@ module Railbow
46
46
 
47
47
  rows = {}
48
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.
49
+ # Absent from detect means suppressed (dismissed, or superseded with
50
+ # hide_superseded on): render as plain NO FILE, do not re-recover.
51
51
  next unless (orphan = orphans[v])
52
52
 
53
53
  row = if orphan.filename && !orphan.filename.empty?
@@ -73,9 +73,9 @@ module Railbow
73
73
  )
74
74
  end
75
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.
76
+ # Detect reads stored snapshots only, so a version listed without a
77
+ # filename falls back to live git/worktree recovery. That keeps fresh
78
+ # clones working with zero setup.
79
79
  def from_live_recovery(version, with_content)
80
80
  snapshot = begin
81
81
  Mighost::API.find_or_recover_snapshot(version)
@@ -131,7 +131,6 @@ module Railbow
131
131
  end
132
132
  end
133
133
 
134
- # The name a ghost row displays, derived from its recovered filename.
135
134
  def display_name(ghost)
136
135
  ghost.filename
137
136
  .sub(/\A\d+_/, "") # strip version prefix
@@ -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