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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -0
- data/README.md +40 -16
- data/lib/railbow/about_formatter.rb +57 -21
- data/lib/railbow/calendar.rb +2 -4
- data/lib/railbow/clock.rb +72 -0
- data/lib/railbow/color_assigner.rb +6 -12
- data/lib/railbow/config.rb +1 -1
- data/lib/railbow/demo/fixtures.rb +4 -4
- data/lib/railbow/demo/status_demo.rb +5 -11
- data/lib/railbow/formatters/base.rb +34 -26
- data/lib/railbow/init.rb +6 -2
- data/lib/railbow/migration_formatter.rb +41 -25
- data/lib/railbow/migration_parser.rb +12 -19
- data/lib/railbow/multi_db.rb +15 -1
- data/lib/railbow/name_collision_resolver.rb +14 -27
- data/lib/railbow/name_formatter.rb +15 -16
- data/lib/railbow/notes_formatter.rb +42 -19
- data/lib/railbow/params.rb +33 -27
- data/lib/railbow/railtie.rb +8 -6
- data/lib/railbow/routes_formatter.rb +18 -6
- data/lib/railbow/stats_formatter.rb +139 -69
- data/lib/railbow/status/ghosts.rb +5 -6
- data/lib/railbow/status/git_data.rb +11 -18
- data/lib/railbow/status/help.rb +8 -2
- data/lib/railbow/status/printer.rb +20 -34
- data/lib/railbow/status/section.rb +29 -44
- data/lib/railbow/table/renderer.rb +32 -36
- data/lib/railbow/tasks/migrate_status.rake +33 -17
- data/lib/railbow/tasks/stats.rake +11 -75
- data/lib/railbow/version.rb +1 -1
- data/lib/railbow.rb +28 -4
- metadata +2 -2
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
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 =
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
#
|
|
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
|
data/lib/railbow/params.rb
CHANGED
|
@@ -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
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
|
59
|
-
when "w" then
|
|
60
|
-
when "mo", "m" then
|
|
61
|
-
when "y" then
|
|
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
|
|
84
|
-
#
|
|
85
|
-
#
|
|
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
|
-
#
|
|
165
|
-
#
|
|
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
|
-
#
|
|
172
|
-
# Returns Hash{String => String}
|
|
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
|
-
#
|
|
192
|
-
#
|
|
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
|
-
#
|
|
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
|
|
248
|
-
#
|
|
249
|
-
#
|
|
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
|
data/lib/railbow/railtie.rb
CHANGED
|
@@ -4,37 +4,39 @@ require "rails/railtie"
|
|
|
4
4
|
|
|
5
5
|
module Railbow
|
|
6
6
|
class Railtie < Rails::Railtie
|
|
7
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
50
|
-
#
|
|
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
|
|
77
|
-
#
|
|
78
|
-
#
|
|
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
|