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.
@@ -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