pretty-git 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +413 -0
- data/bin/pretty-git +7 -0
- data/lib/pretty_git/analytics/activity.rb +82 -0
- data/lib/pretty_git/analytics/authors.rb +64 -0
- data/lib/pretty_git/analytics/files.rb +73 -0
- data/lib/pretty_git/analytics/heatmap.rb +51 -0
- data/lib/pretty_git/analytics/languages.rb +155 -0
- data/lib/pretty_git/analytics/summary.rb +94 -0
- data/lib/pretty_git/app.rb +83 -0
- data/lib/pretty_git/cli.rb +63 -0
- data/lib/pretty_git/cli_helpers.rb +130 -0
- data/lib/pretty_git/filters.rb +43 -0
- data/lib/pretty_git/git/provider.rb +134 -0
- data/lib/pretty_git/render/console_renderer.rb +280 -0
- data/lib/pretty_git/render/csv_renderer.rb +44 -0
- data/lib/pretty_git/render/json_renderer.rb +18 -0
- data/lib/pretty_git/render/languages_section.rb +47 -0
- data/lib/pretty_git/render/markdown_renderer.rb +68 -0
- data/lib/pretty_git/render/terminal_width.rb +38 -0
- data/lib/pretty_git/render/xml_renderer.rb +43 -0
- data/lib/pretty_git/render/yaml_renderer.rb +34 -0
- data/lib/pretty_git/types.rb +14 -0
- data/lib/pretty_git/version.rb +5 -0
- data/lib/pretty_git.rb +7 -0
- metadata +99 -0
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'open3'
|
4
|
+
require 'time'
|
5
|
+
require_relative '../types'
|
6
|
+
|
7
|
+
module PrettyGit
|
8
|
+
module Git
|
9
|
+
# Streams commits from git CLI using `git log --numstat` and parses them.
|
10
|
+
class Provider
|
11
|
+
SEP_RECORD = "\x1E" # record separator
|
12
|
+
SEP_FIELD = "\x1F" # unit separator
|
13
|
+
|
14
|
+
def initialize(filters)
|
15
|
+
@filters = filters
|
16
|
+
end
|
17
|
+
|
18
|
+
# Returns Enumerator of PrettyGit::Types::Commit
|
19
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
20
|
+
def each_commit
|
21
|
+
Enumerator.new do |yld|
|
22
|
+
cmd = build_git_command
|
23
|
+
Open3.popen3(*cmd, chdir: @filters.repo_path) do |_stdin, stdout, stderr, wait_thr|
|
24
|
+
current = nil
|
25
|
+
stdout.each_line do |line|
|
26
|
+
line = line.chomp
|
27
|
+
if record_separator?(line)
|
28
|
+
emit_current(yld, current)
|
29
|
+
current = nil
|
30
|
+
next
|
31
|
+
end
|
32
|
+
|
33
|
+
if current.nil?
|
34
|
+
current = start_commit_from_header(line)
|
35
|
+
next if current
|
36
|
+
end
|
37
|
+
|
38
|
+
next if line.empty?
|
39
|
+
|
40
|
+
append_numstat_line(current, line)
|
41
|
+
end
|
42
|
+
|
43
|
+
emit_current(yld, current)
|
44
|
+
|
45
|
+
status = wait_thr.value
|
46
|
+
unless status.success?
|
47
|
+
err = stderr.read
|
48
|
+
raise StandardError, (err && !err.empty? ? err : "git log failed with status #{status.exitstatus}")
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def emit_current(yld, current)
|
58
|
+
return unless current
|
59
|
+
|
60
|
+
additions = current[:files].sum(&:additions)
|
61
|
+
deletions = current[:files].sum(&:deletions)
|
62
|
+
yld << Types::Commit.new(
|
63
|
+
sha: current[:sha],
|
64
|
+
author_name: current[:author_name],
|
65
|
+
author_email: current[:author_email],
|
66
|
+
authored_at: current[:authored_at],
|
67
|
+
message: current[:message],
|
68
|
+
additions: additions,
|
69
|
+
deletions: deletions,
|
70
|
+
files: current[:files]
|
71
|
+
)
|
72
|
+
end
|
73
|
+
|
74
|
+
def record_separator?(line)
|
75
|
+
line == SEP_RECORD
|
76
|
+
end
|
77
|
+
|
78
|
+
def start_commit_from_header(line)
|
79
|
+
sha, author_name, author_email, authored_at, subject = line.split(SEP_FIELD, 5)
|
80
|
+
return nil unless subject
|
81
|
+
|
82
|
+
{
|
83
|
+
sha: sha,
|
84
|
+
author_name: author_name,
|
85
|
+
author_email: author_email,
|
86
|
+
authored_at: Time.parse(authored_at).utc.iso8601,
|
87
|
+
message: subject,
|
88
|
+
files: []
|
89
|
+
}
|
90
|
+
end
|
91
|
+
|
92
|
+
def append_numstat_line(current, line)
|
93
|
+
add_s, del_s, path = line.split("\t", 3)
|
94
|
+
return unless path
|
95
|
+
|
96
|
+
additions = add_s == '-' ? 0 : add_s.to_i
|
97
|
+
deletions = del_s == '-' ? 0 : del_s.to_i
|
98
|
+
current[:files] << Types::FileStat.new(path: path, additions: additions, deletions: deletions)
|
99
|
+
end
|
100
|
+
|
101
|
+
def build_git_command
|
102
|
+
args = ['git', 'log', '--no-merges', '--date=iso-strict', pretty_format_string, '--numstat']
|
103
|
+
add_time_filters(args)
|
104
|
+
add_author_and_branch_filters(args)
|
105
|
+
add_path_filters(args)
|
106
|
+
args
|
107
|
+
end
|
108
|
+
|
109
|
+
def pretty_format_string
|
110
|
+
"--pretty=format:%H#{SEP_FIELD}%an#{SEP_FIELD}%ae#{SEP_FIELD}%ad#{SEP_FIELD}%s#{SEP_RECORD}"
|
111
|
+
end
|
112
|
+
|
113
|
+
def add_time_filters(args)
|
114
|
+
s = @filters.since_iso8601
|
115
|
+
u = @filters.until_iso8601
|
116
|
+
args << "--since=#{s}" if s
|
117
|
+
args << "--until=#{u}" if u
|
118
|
+
end
|
119
|
+
|
120
|
+
def add_author_and_branch_filters(args)
|
121
|
+
@filters.authors&.each { |a| args << "--author=#{a}" }
|
122
|
+
@filters.branches&.each { |b| args << "--branches=#{b}" }
|
123
|
+
end
|
124
|
+
|
125
|
+
def add_path_filters(args)
|
126
|
+
path_args = Array(@filters.paths).compact
|
127
|
+
return if path_args.empty?
|
128
|
+
|
129
|
+
args << '--'
|
130
|
+
args.concat(path_args)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,280 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'terminal_width'
|
4
|
+
require_relative 'languages_section'
|
5
|
+
|
6
|
+
module PrettyGit
|
7
|
+
module Render
|
8
|
+
# Simple color helpers used by console components.
|
9
|
+
module Colors
|
10
|
+
module_function
|
11
|
+
|
12
|
+
def apply(code, text, enabled)
|
13
|
+
return text unless enabled
|
14
|
+
|
15
|
+
"\e[#{code}m#{text}\e[0m"
|
16
|
+
end
|
17
|
+
|
18
|
+
def title(text, enabled, theme = 'basic')
|
19
|
+
code = theme == 'bright' ? '1;35' : '1;36'
|
20
|
+
apply(code, text, enabled)
|
21
|
+
end
|
22
|
+
|
23
|
+
def header(text, enabled, theme = 'basic')
|
24
|
+
code = theme == 'bright' ? '1;36' : '1;34'
|
25
|
+
apply(code, text, enabled)
|
26
|
+
end
|
27
|
+
|
28
|
+
def dim(text, enabled)
|
29
|
+
apply('2;37', text, enabled)
|
30
|
+
end
|
31
|
+
|
32
|
+
def green(text, enabled)
|
33
|
+
apply('32', text, enabled)
|
34
|
+
end
|
35
|
+
|
36
|
+
def red(text, enabled)
|
37
|
+
apply('31', text, enabled)
|
38
|
+
end
|
39
|
+
|
40
|
+
def yellow(text, enabled)
|
41
|
+
apply('33', text, enabled)
|
42
|
+
end
|
43
|
+
|
44
|
+
def bold(text, enabled)
|
45
|
+
apply('1', text, enabled)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Prints aligned ASCII tables with optional colored headers.
|
50
|
+
class TablePrinter
|
51
|
+
def initialize(io, color: true, theme: 'basic')
|
52
|
+
@io = io
|
53
|
+
@color = color
|
54
|
+
@theme = theme
|
55
|
+
end
|
56
|
+
|
57
|
+
def print(headers, rows, highlight_max: true, first_col_colorizer: nil)
|
58
|
+
widths = compute_widths(headers, rows)
|
59
|
+
if (term_cols = TerminalWidth.detect_terminal_columns(@io))
|
60
|
+
widths = TerminalWidth.fit_to_terminal(widths, term_cols)
|
61
|
+
end
|
62
|
+
|
63
|
+
print_header(headers, widths)
|
64
|
+
print_rows(headers, rows, widths, highlight_max, first_col_colorizer)
|
65
|
+
@io.puts 'No data' if rows.empty?
|
66
|
+
end
|
67
|
+
|
68
|
+
def compute_widths(headers, rows)
|
69
|
+
widths = headers.map(&:length)
|
70
|
+
rows.each do |row|
|
71
|
+
widths = widths.each_with_index.map do |w, i|
|
72
|
+
[w, row[headers[i].to_sym].to_s.length].max
|
73
|
+
end
|
74
|
+
end
|
75
|
+
widths
|
76
|
+
end
|
77
|
+
|
78
|
+
# rubocop:disable Metrics/AbcSize
|
79
|
+
def print_header(headers, widths)
|
80
|
+
header_line = headers.map.with_index do |h, i|
|
81
|
+
text = i.zero? ? truncate(h, widths[i]) : h
|
82
|
+
i.zero? ? text.ljust(widths[i]) : text.rjust(widths[i])
|
83
|
+
end.join(' ')
|
84
|
+
sep_line = widths.map { |w| '-' * w }.join(' ')
|
85
|
+
|
86
|
+
@io.puts Colors.header(header_line, @color, @theme)
|
87
|
+
@io.puts Colors.dim(sep_line, @color)
|
88
|
+
end
|
89
|
+
# rubocop:enable Metrics/AbcSize
|
90
|
+
|
91
|
+
def print_rows(headers, rows, widths, highlight_max, first_col_colorizer)
|
92
|
+
max_map = highlight_max ? compute_max_map(headers, rows) : {}
|
93
|
+
eps = 1e-9
|
94
|
+
rows.each do |row|
|
95
|
+
cells = []
|
96
|
+
headers.each_with_index do |h, i|
|
97
|
+
val = row[h.to_sym]
|
98
|
+
color_code = i.zero? && first_col_colorizer ? first_col_colorizer.call(row) : nil
|
99
|
+
cells << cell_for(
|
100
|
+
val,
|
101
|
+
widths[i],
|
102
|
+
first_col: i.zero?,
|
103
|
+
is_max: max_cell?(val, i, max_map, highlight_max, eps),
|
104
|
+
color_code: color_code
|
105
|
+
)
|
106
|
+
end
|
107
|
+
@io.puts cells.join(' ')
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def cell_for(value, width, first_col:, is_max: false, color_code: nil)
|
112
|
+
raw = value.to_s
|
113
|
+
raw = truncate(raw, width) if first_col
|
114
|
+
padded = first_col ? raw.ljust(width) : raw.rjust(width)
|
115
|
+
return Colors.bold(padded, @color) if is_max
|
116
|
+
return Colors.apply(color_code, padded, @color) if first_col && color_code
|
117
|
+
|
118
|
+
padded
|
119
|
+
end
|
120
|
+
|
121
|
+
def max_cell?(val, idx, max_map, highlight_max, eps)
|
122
|
+
return false unless highlight_max && max_map[idx]
|
123
|
+
return false unless numeric?(val)
|
124
|
+
|
125
|
+
(val.to_f - max_map[idx]).abs < eps
|
126
|
+
end
|
127
|
+
|
128
|
+
private
|
129
|
+
|
130
|
+
# Terminal width helpers moved to PrettyGit::Render::TerminalWidth
|
131
|
+
|
132
|
+
def truncate(text, max)
|
133
|
+
return text if text.length <= max
|
134
|
+
return text[0, max] if max <= 1
|
135
|
+
|
136
|
+
"#{text[0, max - 1]}…"
|
137
|
+
end
|
138
|
+
|
139
|
+
def numeric?(val)
|
140
|
+
val.is_a?(Numeric) || val.to_s.match?(/\A-?\d+(\.\d+)?\z/)
|
141
|
+
end
|
142
|
+
|
143
|
+
def compute_max_map(headers, rows)
|
144
|
+
map = {}
|
145
|
+
headers.each_with_index do |_h, i|
|
146
|
+
next if i.zero?
|
147
|
+
|
148
|
+
nums = rows.map { |r| r[headers[i].to_sym] }.select { |v| numeric?(v) }.map(&:to_f)
|
149
|
+
map[i] = nums.max if nums.any?
|
150
|
+
end
|
151
|
+
map
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# Renders human-friendly console output with optional colors.
|
156
|
+
class ConsoleRenderer
|
157
|
+
def initialize(io: $stdout, color: true, theme: 'basic')
|
158
|
+
@io = io
|
159
|
+
@color = color
|
160
|
+
@theme = theme
|
161
|
+
@table = TablePrinter.new(@io, color: @color, theme: @theme)
|
162
|
+
end
|
163
|
+
|
164
|
+
def call(report, result, _filters)
|
165
|
+
case report
|
166
|
+
when 'summary'
|
167
|
+
render_summary(result)
|
168
|
+
when 'activity'
|
169
|
+
render_activity(result)
|
170
|
+
when 'authors'
|
171
|
+
render_authors(result)
|
172
|
+
when 'files'
|
173
|
+
render_files(result)
|
174
|
+
when 'heatmap'
|
175
|
+
render_heatmap(result)
|
176
|
+
when 'languages'
|
177
|
+
LanguagesSection.render(@io, @table, result, color: @color)
|
178
|
+
else
|
179
|
+
@io.puts result.inspect
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
private
|
184
|
+
|
185
|
+
# rubocop:disable Metrics/AbcSize
|
186
|
+
def render_summary(data)
|
187
|
+
title "Summary for #{data[:repo_path]}"
|
188
|
+
period = "Period: #{data.dig(:period, :since)} .. #{data.dig(:period, :until)}"
|
189
|
+
line period
|
190
|
+
t = data[:totals]
|
191
|
+
commits_s = "commits=#{Colors.yellow(t[:commits], @color)}"
|
192
|
+
authors_s = "authors=#{t[:authors]}"
|
193
|
+
adds_s = "+#{Colors.green(t[:additions], @color)}"
|
194
|
+
dels_s = "-#{Colors.red(t[:deletions], @color)}"
|
195
|
+
line "Totals: #{commits_s} #{authors_s} #{adds_s} #{dels_s}"
|
196
|
+
|
197
|
+
@io.puts
|
198
|
+
title 'Top Authors'
|
199
|
+
@table.print(%w[author commits additions deletions avg_commit_size], data[:top_authors])
|
200
|
+
|
201
|
+
@io.puts
|
202
|
+
title 'Top Files'
|
203
|
+
@table.print(%w[path commits additions deletions changes], data[:top_files])
|
204
|
+
|
205
|
+
@io.puts
|
206
|
+
line "Generated at: #{data[:generated_at]}"
|
207
|
+
end
|
208
|
+
# rubocop:enable Metrics/AbcSize
|
209
|
+
|
210
|
+
def render_activity(data)
|
211
|
+
title "Activity for #{data[:repo_path]}"
|
212
|
+
line "Period: #{data.dig(:period, :since)} .. #{data.dig(:period, :until)}"
|
213
|
+
line "Bucket: #{data[:bucket]}"
|
214
|
+
|
215
|
+
@io.puts
|
216
|
+
title 'Activity'
|
217
|
+
@table.print(%w[bucket timestamp commits additions deletions], data[:items])
|
218
|
+
|
219
|
+
@io.puts
|
220
|
+
line "Generated at: #{data[:generated_at]}"
|
221
|
+
end
|
222
|
+
|
223
|
+
# rubocop:disable Metrics/AbcSize
|
224
|
+
def render_authors(data)
|
225
|
+
title "Authors for #{data[:repo_path]}"
|
226
|
+
line "Period: #{data.dig(:period, :since)} .. #{data.dig(:period, :until)}"
|
227
|
+
t = data[:totals]
|
228
|
+
commits_s = "commits=#{Colors.yellow(t[:commits], @color)}"
|
229
|
+
authors_s = "authors=#{t[:authors]}"
|
230
|
+
adds_s = "+#{Colors.green(t[:additions], @color)}"
|
231
|
+
dels_s = "-#{Colors.red(t[:deletions], @color)}"
|
232
|
+
line "Totals: #{authors_s} #{commits_s} #{adds_s} #{dels_s}"
|
233
|
+
|
234
|
+
@io.puts
|
235
|
+
title 'Authors'
|
236
|
+
@table.print(%w[author author_email commits additions deletions avg_commit_size], data[:items])
|
237
|
+
|
238
|
+
@io.puts
|
239
|
+
line "Generated at: #{data[:generated_at]}"
|
240
|
+
end
|
241
|
+
# rubocop:enable Metrics/AbcSize
|
242
|
+
|
243
|
+
def render_files(data)
|
244
|
+
title "Files for #{data[:repo_path]}"
|
245
|
+
line "Period: #{data.dig(:period, :since)} .. #{data.dig(:period, :until)}"
|
246
|
+
|
247
|
+
@io.puts
|
248
|
+
title 'Files'
|
249
|
+
@table.print(%w[path commits additions deletions changes], data[:items])
|
250
|
+
|
251
|
+
@io.puts
|
252
|
+
line "Generated at: #{data[:generated_at]}"
|
253
|
+
end
|
254
|
+
|
255
|
+
def render_heatmap(data)
|
256
|
+
title "Heatmap for #{data[:repo_path]}"
|
257
|
+
line "Period: #{data.dig(:period, :since)} .. #{data.dig(:period, :until)}"
|
258
|
+
|
259
|
+
@io.puts
|
260
|
+
title 'Heatmap'
|
261
|
+
@table.print(%w[dow hour commits], data[:items])
|
262
|
+
|
263
|
+
@io.puts
|
264
|
+
line "Generated at: #{data[:generated_at]}"
|
265
|
+
end
|
266
|
+
|
267
|
+
# Languages rendering moved to PrettyGit::Render::LanguagesSection
|
268
|
+
|
269
|
+
def title(text)
|
270
|
+
@io.puts Colors.title(text, @color, @theme)
|
271
|
+
end
|
272
|
+
|
273
|
+
def line(text)
|
274
|
+
@io.puts text
|
275
|
+
end
|
276
|
+
|
277
|
+
# table is handled by @table
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'csv'
|
4
|
+
|
5
|
+
module PrettyGit
|
6
|
+
module Render
|
7
|
+
# Renders CSV according to specs/output_formats.md and DR-001
|
8
|
+
class CsvRenderer
|
9
|
+
def initialize(io: $stdout)
|
10
|
+
@io = io
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(report, result, _filters)
|
14
|
+
case report
|
15
|
+
when 'activity'
|
16
|
+
write_csv(%w[bucket timestamp commits additions deletions], result[:items])
|
17
|
+
when 'authors'
|
18
|
+
write_csv(%w[author author_email commits additions deletions avg_commit_size], result[:items])
|
19
|
+
when 'files'
|
20
|
+
write_csv(%w[path commits additions deletions changes], result[:items])
|
21
|
+
when 'heatmap'
|
22
|
+
write_csv(%w[dow hour commits], result[:items])
|
23
|
+
when 'languages'
|
24
|
+
write_csv(%w[language bytes percent], result[:items])
|
25
|
+
else
|
26
|
+
raise ArgumentError, "CSV output for report '#{report}' is not supported yet"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def write_csv(headers, rows)
|
33
|
+
csv = CSV.generate(force_quotes: false) do |out|
|
34
|
+
out << headers
|
35
|
+
rows.each do |row|
|
36
|
+
out << headers.map { |h| row[h.to_sym] }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
# Ensure UTF-8 per DR-001
|
40
|
+
@io.write(csv.encode('UTF-8'))
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module PrettyGit
|
6
|
+
module Render
|
7
|
+
# Renders report result as pretty-formatted JSON to the provided IO.
|
8
|
+
class JsonRenderer
|
9
|
+
def initialize(io: $stdout)
|
10
|
+
@io = io
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(_report, result, _filters)
|
14
|
+
@io.puts JSON.pretty_generate(result)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PrettyGit
|
4
|
+
module Render
|
5
|
+
# Renders the Languages report table with colorized language names.
|
6
|
+
module LanguagesSection
|
7
|
+
LANG_ANSI_COLOR_CODES = {
|
8
|
+
'Ruby' => '31',
|
9
|
+
'JavaScript' => '33', 'TypeScript' => '34',
|
10
|
+
'JSX' => '33', 'TSX' => '34',
|
11
|
+
'Python' => '34', 'Go' => '36', 'Rust' => '33', 'Java' => '31',
|
12
|
+
'C' => '37', 'C++' => '35', 'C#' => '32', 'Objective-C' => '36', 'Swift' => '35',
|
13
|
+
'Kotlin' => '35', 'Scala' => '35', 'Groovy' => '32', 'Dart' => '36',
|
14
|
+
'PHP' => '35', 'Perl' => '35', 'R' => '35', 'Lua' => '34', 'Haskell' => '35',
|
15
|
+
'Elixir' => '35', 'Erlang' => '31',
|
16
|
+
'Shell' => '32', 'PowerShell' => '34', 'Batchfile' => '33',
|
17
|
+
'HTML' => '31', 'CSS' => '35', 'SCSS' => '35',
|
18
|
+
'JSON' => '37', 'YAML' => '31', 'TOML' => '33', 'INI' => '33', 'XML' => '36',
|
19
|
+
'Markdown' => '34', 'Makefile' => '33', 'Dockerfile' => '36',
|
20
|
+
'SQL' => '36', 'GraphQL' => '35', 'Proto' => '33',
|
21
|
+
'Svelte' => '31', 'Vue' => '32'
|
22
|
+
}.freeze
|
23
|
+
|
24
|
+
module_function
|
25
|
+
|
26
|
+
def render(io, table, data, color: true)
|
27
|
+
title(io, data, color)
|
28
|
+
io.puts
|
29
|
+
table_rows = rows(data[:items])
|
30
|
+
colorizer = ->(row) { LANG_ANSI_COLOR_CODES[row[:language]] }
|
31
|
+
table.print(%w[language bytes percent], table_rows, highlight_max: false, first_col_colorizer: colorizer)
|
32
|
+
io.puts
|
33
|
+
io.puts "Generated at: #{data[:generated_at]}"
|
34
|
+
end
|
35
|
+
|
36
|
+
def title(io, data, color)
|
37
|
+
io.puts Colors.title("Languages for #{data[:repo_path]}", color)
|
38
|
+
end
|
39
|
+
|
40
|
+
def rows(items)
|
41
|
+
items.map do |item|
|
42
|
+
{ language: item[:language], bytes: item[:bytes], percent: format('%.1f', item[:percent]) }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PrettyGit
|
4
|
+
module Render
|
5
|
+
# Renders Markdown tables and sections per specs/output_formats.md
|
6
|
+
class MarkdownRenderer
|
7
|
+
def initialize(io: $stdout)
|
8
|
+
@io = io
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(report, result, _filters)
|
12
|
+
case report
|
13
|
+
when 'summary'
|
14
|
+
render_summary(result)
|
15
|
+
when 'activity'
|
16
|
+
render_table('Activity', %w[bucket timestamp commits additions deletions], result[:items])
|
17
|
+
when 'authors'
|
18
|
+
render_table('Authors', %w[author author_email commits additions deletions avg_commit_size], result[:items])
|
19
|
+
when 'files'
|
20
|
+
render_table('Top Files', %w[path commits additions deletions changes], result[:items])
|
21
|
+
when 'heatmap'
|
22
|
+
render_table('Heatmap', %w[dow hour commits], result[:items])
|
23
|
+
when 'languages'
|
24
|
+
render_table('Languages', %w[language bytes percent], result[:items])
|
25
|
+
else
|
26
|
+
@io.puts result.inspect
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def render_summary(data)
|
33
|
+
header_summary(data)
|
34
|
+
print_totals(data[:totals])
|
35
|
+
@io.puts
|
36
|
+
render_table('Top Authors', %w[author commits additions deletions avg_commit_size], data[:top_authors])
|
37
|
+
@io.puts
|
38
|
+
render_table('Top Files', %w[path commits additions deletions changes], data[:top_files])
|
39
|
+
@io.puts
|
40
|
+
@io.puts "Generated at: #{data[:generated_at]}"
|
41
|
+
end
|
42
|
+
|
43
|
+
def header_summary(data)
|
44
|
+
@io.puts "# Summary for #{data[:repo_path]}"
|
45
|
+
@io.puts
|
46
|
+
@io.puts "Period: #{data.dig(:period, :since)} .. #{data.dig(:period, :until)}"
|
47
|
+
end
|
48
|
+
|
49
|
+
def print_totals(totals)
|
50
|
+
@io.puts(
|
51
|
+
"Totals: commits=#{totals[:commits]} authors=#{totals[:authors]} " \
|
52
|
+
"+#{totals[:additions]} -#{totals[:deletions]}"
|
53
|
+
)
|
54
|
+
end
|
55
|
+
|
56
|
+
def render_table(title, headers, rows)
|
57
|
+
@io.puts "# #{title}"
|
58
|
+
@io.puts
|
59
|
+
@io.puts "| #{headers.join(' | ')} |"
|
60
|
+
@io.puts "|#{headers.map { '---' }.join('|')}|"
|
61
|
+
rows.each do |r|
|
62
|
+
@io.puts "| #{headers.map { |h| r[h.to_sym] }.join(' | ')} |"
|
63
|
+
end
|
64
|
+
@io.puts 'No data' if rows.empty?
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PrettyGit
|
4
|
+
module Render
|
5
|
+
# Terminal width utility functions used by console renderers/printers.
|
6
|
+
module TerminalWidth
|
7
|
+
module_function
|
8
|
+
|
9
|
+
def detect_terminal_columns(io)
|
10
|
+
return unless io.respond_to?(:tty?) && io.tty?
|
11
|
+
|
12
|
+
cols = io_columns(io) || env_columns
|
13
|
+
cols if cols&.positive?
|
14
|
+
end
|
15
|
+
|
16
|
+
def io_columns(io)
|
17
|
+
return unless io.respond_to?(:winsize)
|
18
|
+
|
19
|
+
io.winsize&.last
|
20
|
+
end
|
21
|
+
|
22
|
+
def env_columns
|
23
|
+
ENV['COLUMNS']&.to_i
|
24
|
+
end
|
25
|
+
|
26
|
+
def fit_to_terminal(widths, cols)
|
27
|
+
total = widths.sum + (widths.size - 1)
|
28
|
+
return widths if total <= cols
|
29
|
+
|
30
|
+
other = widths[1..].sum + (widths.size - 1)
|
31
|
+
min_first = 8
|
32
|
+
new_first = [cols - other, min_first].max
|
33
|
+
widths[0] = new_first
|
34
|
+
widths
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rexml/document'
|
4
|
+
|
5
|
+
module PrettyGit
|
6
|
+
module Render
|
7
|
+
# Renders result structure as XML
|
8
|
+
class XmlRenderer
|
9
|
+
def initialize(io: $stdout)
|
10
|
+
@io = io
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(_report, result, _filters)
|
14
|
+
doc = REXML::Document.new
|
15
|
+
doc << REXML::XMLDecl.new('1.0', 'UTF-8')
|
16
|
+
root = doc.add_element('report')
|
17
|
+
hash_to_xml(root, result)
|
18
|
+
formatter = REXML::Formatters::Pretty.new(2)
|
19
|
+
formatter.compact = true
|
20
|
+
formatter.write(doc, @io)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def hash_to_xml(parent, obj)
|
26
|
+
case obj
|
27
|
+
when Hash
|
28
|
+
obj.each do |k, v|
|
29
|
+
el = parent.add_element(k.to_s)
|
30
|
+
hash_to_xml(el, v)
|
31
|
+
end
|
32
|
+
when Array
|
33
|
+
obj.each do |item|
|
34
|
+
el = parent.add_element('item')
|
35
|
+
hash_to_xml(el, item)
|
36
|
+
end
|
37
|
+
else
|
38
|
+
parent.text = obj.nil? ? '' : obj.to_s
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|