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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "zlib"
4
4
  require "date"
5
+ require_relative "clock"
5
6
  require_relative "git_utils"
6
7
  require_relative "formatters/base"
7
8
  require_relative "logo"
@@ -30,9 +31,18 @@ module Railbow
30
31
 
31
32
  PALETTE = Formatters::Base::TABLE_PALETTE
32
33
 
34
+ # git blame's author-mail for lines not committed yet
35
+ UNCOMMITTED_EMAIL = "not.committed.yet"
36
+
33
37
  def display(results, options = {})
34
38
  return super if Railbow.plain?
35
39
 
40
+ Railbow.guard(-> { super }) { railbow_display(results, options) }
41
+ end
42
+
43
+ private
44
+
45
+ def railbow_display(results, options = {})
36
46
  if Railbow::Params.help?
37
47
  print_help
38
48
  return
@@ -45,7 +55,6 @@ module Railbow
45
55
  since_date = Railbow::Params.parse_since(since_val, context: "annotations")
46
56
  git_needed = %w[all me].include?(author_mode) || since_date || sort_mode == "date"
47
57
 
48
- # Build blame cache for all files if git features are needed
49
58
  blame_cache = {}
50
59
  if git_needed
51
60
  results.each_key do |source_file|
@@ -53,7 +62,7 @@ module Railbow
53
62
  end
54
63
  end
55
64
 
56
- # Flatten annotations into a list for filtering/sorting
65
+ # Flattened so filtering and sorting can cross file boundaries
57
66
  entries = []
58
67
  results.each do |source_file, annotations|
59
68
  annotations.each do |annotation|
@@ -66,7 +75,6 @@ module Railbow
66
75
  end
67
76
  end
68
77
 
69
- # Filter by SINCE
70
78
  skipped = 0
71
79
  if since_date
72
80
  before = entries.size
@@ -76,7 +84,7 @@ module Railbow
76
84
  skipped = before - entries.size
77
85
  end
78
86
 
79
- # Sort by date (newest first) when requested
87
+ # Newest first; entries with no blame date sink to the end
80
88
  if sort_mode == "date"
81
89
  entries.sort_by! do |entry|
82
90
  d = entry.dig(:blame, :date)
@@ -84,21 +92,23 @@ module Railbow
84
92
  end
85
93
  end
86
94
 
87
- # Determine "me" identity
88
95
  git_email = nil
89
96
  if author_mode == "me"
90
97
  git_email = current_git_email
91
98
  end
92
99
 
93
- # Determine max location width for alignment when showing metadata
100
+ # Locations pad to one width so the author and date line up
94
101
  show_date = git_needed
95
102
  max_loc_width = 0
96
103
  author_colors = nil
97
104
  author_display = {}
98
105
  if author_mode == "all"
99
- author_names = entries.filter_map { |e| e.dig(:blame, :author) }.uniq
106
+ author_names = entries.reject { |e| uncommitted?(e[:blame]) }
107
+ .filter_map { |e| e.dig(:blame, :author) }.uniq
100
108
  author_colors = ColorAssigner.new(author_names)
101
109
  author_display = Railbow::Params.format_authors(author_names)
110
+ # Uncommitted notes have no author; dashes fill the author column instead
111
+ no_author = "-" * (author_display.values.map(&:length).max || 4)
102
112
  end
103
113
  if show_date || author_mode == "all"
104
114
  entries.each do |entry|
@@ -107,11 +117,10 @@ module Railbow
107
117
  end
108
118
  end
109
119
 
110
- # Group entries back by file for bar coloring
120
+ # A file with several notes gets its own bar color; a lone note's bar is dim
111
121
  file_counts = Hash.new(0)
112
122
  entries.each { |e| file_counts[e[:file]] += 1 }
113
123
 
114
- # Display
115
124
  entries.each do |entry|
116
125
  source_file = entry[:file]
117
126
  annotation = entry[:annotation]
@@ -127,7 +136,6 @@ module Railbow
127
136
  DIM
128
137
  end
129
138
  colored_bar = "#{bar_color}#{BAR}#{RESET}"
130
- # When highlighting "me", use bright white bar
131
139
  colored_bar = "#{WHITE}#{BAR}#{RESET}" if is_mine
132
140
 
133
141
  tag = annotation.tag
@@ -136,7 +144,6 @@ module Railbow
136
144
  colored_tag = "#{tag_color}#{BOLD}#{tag}#{RESET}"
137
145
  note = annotation.text.to_s.encode("UTF-8", invalid: :replace, undef: :replace)
138
146
 
139
- # Build the file:line row with optional author+date
140
147
  loc_line = " #{colored_bar} #{location}"
141
148
 
142
149
  if author_mode == "all" && blame_info
@@ -144,7 +151,11 @@ module Railbow
144
151
  author_name = author_display[raw_author] || Railbow::Params.format_author(raw_author)
145
152
  blame_date = blame_info[:date] ? blame_info[:date].strftime("%Y-%m-%d") : ""
146
153
  padding = " " * [(max_loc_width - location.length + 2), 2].max
147
- colored_author = "#{author_colors.color_for(raw_author)}#{author_name}#{RESET}"
154
+ colored_author = if uncommitted?(blame_info)
155
+ "#{DIM}#{no_author}#{RESET}"
156
+ else
157
+ "#{author_colors.color_for(raw_author)}#{author_name}#{RESET}"
158
+ end
148
159
  meta = "#{colored_author} #{DIM}#{blame_date}#{RESET}"
149
160
  loc_line = " #{colored_bar} #{location}#{padding}#{meta}"
150
161
  elsif author_mode == "me" && is_mine && blame_info
@@ -164,7 +175,6 @@ module Railbow
164
175
  indent_width = 1 + 1 + 1 + 3 + tag.length + 1 # " " + BAR + " " + " " + TAG + " "
165
176
  continuation_prefix = " #{colored_bar} #{" " * (tag.length + 1)}"
166
177
 
167
- # Apply "me" highlighting to note text
168
178
  if is_mine
169
179
  tag_str = "#{WHITE}#{tag}#{RESET} "
170
180
  end
@@ -198,13 +208,14 @@ module Railbow
198
208
  puts
199
209
  end
200
210
 
201
- private
202
-
203
211
  def blame_file(path)
204
212
  return {} unless File.exist?(path)
205
213
 
206
- output, status = Railbow::GitUtils.capture2("blame", "--porcelain", path)
207
- return {} unless status.success?
214
+ # stderr is captured so "no such path in HEAD" never reaches the terminal
215
+ output, _err, status = Railbow::GitUtils.capture3("blame", "--porcelain", path)
216
+ # Untracked or ignored files (e.g. app/assets/builds) count as uncommitted,
217
+ # the way git blame dates uncommitted lines, so RBW_SINCE keeps them
218
+ return Hash.new(uncommitted_blame) unless status.success?
208
219
 
209
220
  result = {}
210
221
  current_line = nil
@@ -227,8 +238,8 @@ module Railbow
227
238
  elsif line.start_with?("author-time ")
228
239
  current_time = line.sub("author-time ", "").to_i
229
240
  elsif line.start_with?("\t") && current_line
230
- # End of this blame entry — store it
231
- date = current_time ? Time.at(current_time).to_date : nil
241
+ # The tab-prefixed source line closes a porcelain blame entry
242
+ date = current_time ? Railbow::Clock.in_zone(Time.at(current_time)).to_date : nil
232
243
  result[current_line] = {
233
244
  author: current_author,
234
245
  email: current_email,
@@ -240,6 +251,18 @@ module Railbow
240
251
  result
241
252
  end
242
253
 
254
+ def uncommitted_blame
255
+ {
256
+ author: "Not Committed Yet",
257
+ email: UNCOMMITTED_EMAIL,
258
+ date: Railbow::Clock.in_zone(Time.now).to_date
259
+ }.freeze
260
+ end
261
+
262
+ def uncommitted?(blame_info)
263
+ blame_info&.dig(:email) == UNCOMMITTED_EMAIL
264
+ end
265
+
243
266
  def current_git_email
244
267
  output, _status = Railbow::GitUtils.capture2("config", "user.email")
245
268
  output.strip.downcase
@@ -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: ws-123, 123, abc123, ab-123-456, feat/ws-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