pretty-git 0.1.2 → 0.1.4
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 +34 -1
- data/README.md +144 -39
- data/README.ru.md +144 -39
- data/lib/pretty_git/analytics/churn.rb +75 -0
- data/lib/pretty_git/analytics/hotspots.rb +78 -0
- data/lib/pretty_git/analytics/ownership.rb +90 -0
- data/lib/pretty_git/app.rb +18 -16
- data/lib/pretty_git/cli.rb +7 -4
- data/lib/pretty_git/cli_helpers.rb +16 -3
- data/lib/pretty_git/filters.rb +11 -2
- data/lib/pretty_git/git/provider.rb +35 -3
- data/lib/pretty_git/render/console_renderer.rb +53 -16
- data/lib/pretty_git/render/csv_renderer.rb +20 -17
- data/lib/pretty_git/render/markdown_renderer.rb +85 -21
- data/lib/pretty_git/render/xml_renderer.rb +71 -3
- data/lib/pretty_git/render/yaml_renderer.rb +58 -2
- data/lib/pretty_git/version.rb +1 -1
- metadata +6 -3
@@ -9,14 +9,14 @@ module PrettyGit
|
|
9
9
|
# and RuboCop-compliant. Provides parser configuration and execution utilities.
|
10
10
|
# rubocop:disable Metrics/ModuleLength
|
11
11
|
module CLIHelpers
|
12
|
-
REPORTS = %w[summary activity authors files heatmap languages].freeze
|
12
|
+
REPORTS = %w[summary activity authors files heatmap languages hotspots churn ownership].freeze
|
13
13
|
FORMATS = %w[console json csv md yaml xml].freeze
|
14
14
|
METRICS = %w[bytes files loc].freeze
|
15
15
|
|
16
16
|
module_function
|
17
17
|
|
18
18
|
def configure_parser(opts, options)
|
19
|
-
opts.banner = 'Usage: pretty-git [REPORT] [options]'
|
19
|
+
opts.banner = 'Usage: pretty-git [REPORT] [REPO] [options]'
|
20
20
|
add_repo_options(opts, options)
|
21
21
|
add_time_author_options(opts, options)
|
22
22
|
add_path_limit_options(opts, options)
|
@@ -75,7 +75,9 @@ module PrettyGit
|
|
75
75
|
code = handle_version_help(options, parser, out)
|
76
76
|
return code unless code.nil?
|
77
77
|
|
78
|
-
|
78
|
+
base_ok = valid_report?(options[:report]) && valid_theme?(options[:theme]) && valid_metric?(options[:metric])
|
79
|
+
conflicts_ok = validate_conflicts(options, err)
|
80
|
+
return nil if base_ok && conflicts_ok
|
79
81
|
|
80
82
|
print_validation_errors(options, err)
|
81
83
|
1
|
@@ -112,6 +114,17 @@ module PrettyGit
|
|
112
114
|
err.puts "Unknown metric: #{options[:metric]}. Supported: #{METRICS.join(', ')}"
|
113
115
|
end
|
114
116
|
|
117
|
+
# Returns true when flags are consistent; otherwise prints errors and returns false
|
118
|
+
def validate_conflicts(options, err)
|
119
|
+
ok = true
|
120
|
+
if options[:metric] && options[:report] != 'languages'
|
121
|
+
err.puts "--metric is only supported for 'languages' report"
|
122
|
+
ok = false
|
123
|
+
end
|
124
|
+
# time_bucket is accepted by multiple reports historically; do not enforce here.
|
125
|
+
ok
|
126
|
+
end
|
127
|
+
|
115
128
|
def build_filters(options)
|
116
129
|
Filters.new(
|
117
130
|
repo_path: options[:repo],
|
data/lib/pretty_git/filters.rb
CHANGED
@@ -31,14 +31,23 @@ module PrettyGit
|
|
31
31
|
|
32
32
|
private
|
33
33
|
|
34
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
34
35
|
def time_to_iso8601(val)
|
35
36
|
return nil if val.nil? || val.to_s.strip.empty?
|
36
37
|
|
37
|
-
|
38
|
-
|
38
|
+
# If value is a date without time, interpret as UTC midnight to avoid
|
39
|
+
# timezone-dependent shifts across environments.
|
40
|
+
if val.is_a?(String) && val.match?(/^\d{4}-\d{2}-\d{2}$/)
|
41
|
+
y, m, d = val.split('-').map(&:to_i)
|
42
|
+
t = Time.new(y, m, d, 0, 0, 0, '+00:00')
|
43
|
+
else
|
44
|
+
# Otherwise parse normally and normalize to UTC.
|
45
|
+
t = val.is_a?(Time) ? val : Time.parse(val.to_s)
|
46
|
+
end
|
39
47
|
t.utc.iso8601
|
40
48
|
rescue ArgumentError
|
41
49
|
raise ArgumentError, "Invalid datetime: #{val} (expected ISO8601 or YYYY-MM-DD)"
|
42
50
|
end
|
51
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
43
52
|
end
|
44
53
|
end
|
@@ -7,6 +7,7 @@ require_relative '../types'
|
|
7
7
|
module PrettyGit
|
8
8
|
module Git
|
9
9
|
# Streams commits from git CLI using `git log --numstat` and parses them.
|
10
|
+
# rubocop:disable Metrics/ClassLength
|
10
11
|
class Provider
|
11
12
|
SEP_RECORD = "\x1E" # record separator
|
12
13
|
SEP_FIELD = "\x1F" # unit separator
|
@@ -57,7 +58,7 @@ module PrettyGit
|
|
57
58
|
|
58
59
|
additions = current[:files].sum(&:additions)
|
59
60
|
deletions = current[:files].sum(&:deletions)
|
60
|
-
|
61
|
+
commit = Types::Commit.new(
|
61
62
|
sha: current[:sha],
|
62
63
|
author_name: current[:author_name],
|
63
64
|
author_email: current[:author_email],
|
@@ -67,6 +68,9 @@ module PrettyGit
|
|
67
68
|
deletions: deletions,
|
68
69
|
files: current[:files]
|
69
70
|
)
|
71
|
+
return if exclude_author?(commit.author_name, commit.author_email)
|
72
|
+
|
73
|
+
yld << commit
|
70
74
|
end
|
71
75
|
|
72
76
|
def record_separator?(line)
|
@@ -120,13 +124,41 @@ module PrettyGit
|
|
120
124
|
@filters.branches&.each { |b| args << "--branches=#{b}" }
|
121
125
|
end
|
122
126
|
|
127
|
+
# rubocop:disable Metrics/AbcSize
|
123
128
|
def add_path_filters(args)
|
124
129
|
path_args = Array(@filters.paths).compact
|
125
|
-
|
130
|
+
exclude_args = Array(@filters.exclude_paths).compact
|
131
|
+
|
132
|
+
# Nothing to filter by
|
133
|
+
return if path_args.empty? && exclude_args.empty?
|
126
134
|
|
127
135
|
args << '--'
|
128
|
-
|
136
|
+
|
137
|
+
# If only excludes provided, include all paths first
|
138
|
+
args << '.' if path_args.empty? && !exclude_args.empty?
|
139
|
+
|
140
|
+
# Include patterns as-is
|
141
|
+
args.concat(path_args) unless path_args.empty?
|
142
|
+
|
143
|
+
# Exclude patterns via git pathspec magic with glob
|
144
|
+
exclude_args.each do |pat|
|
145
|
+
args << ":(exclude,glob)#{pat}"
|
146
|
+
end
|
147
|
+
end
|
148
|
+
# rubocop:enable Metrics/AbcSize
|
149
|
+
|
150
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
151
|
+
def exclude_author?(name, email)
|
152
|
+
patterns = Array(@filters.exclude_authors).compact
|
153
|
+
return false if patterns.empty?
|
154
|
+
|
155
|
+
patterns.any? do |pat|
|
156
|
+
pn = pat.to_s
|
157
|
+
name&.downcase&.include?(pn.downcase) || email&.downcase&.include?(pn.downcase)
|
158
|
+
end
|
129
159
|
end
|
160
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
130
161
|
end
|
131
162
|
end
|
132
163
|
end
|
164
|
+
# rubocop:enable Metrics/ClassLength
|
@@ -153,6 +153,7 @@ module PrettyGit
|
|
153
153
|
end
|
154
154
|
|
155
155
|
# Renders human-friendly console output with optional colors.
|
156
|
+
# rubocop:disable Metrics/ClassLength
|
156
157
|
class ConsoleRenderer
|
157
158
|
def initialize(io: $stdout, color: true, theme: 'basic')
|
158
159
|
@io = io
|
@@ -162,22 +163,21 @@ module PrettyGit
|
|
162
163
|
end
|
163
164
|
|
164
165
|
def call(report, result, _filters)
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
end
|
166
|
+
handlers = {
|
167
|
+
'summary' => method(:render_summary),
|
168
|
+
'activity' => method(:render_activity),
|
169
|
+
'authors' => method(:render_authors),
|
170
|
+
'files' => method(:render_files),
|
171
|
+
'heatmap' => method(:render_heatmap),
|
172
|
+
'languages' => ->(data) { LanguagesSection.render(@io, @table, data, color: @color) },
|
173
|
+
'hotspots' => method(:render_hotspots),
|
174
|
+
'churn' => method(:render_churn),
|
175
|
+
'ownership' => method(:render_ownership)
|
176
|
+
}
|
177
|
+
handler = handlers[report]
|
178
|
+
return @io.puts(result.inspect) unless handler
|
179
|
+
|
180
|
+
handler.call(result)
|
181
181
|
end
|
182
182
|
|
183
183
|
private
|
@@ -266,6 +266,42 @@ module PrettyGit
|
|
266
266
|
|
267
267
|
# Languages rendering moved to PrettyGit::Render::LanguagesSection
|
268
268
|
|
269
|
+
def render_hotspots(data)
|
270
|
+
title "Hotspots for #{data[:repo_path]}"
|
271
|
+
line "Period: #{data.dig(:period, :since)} .. #{data.dig(:period, :until)}"
|
272
|
+
|
273
|
+
@io.puts
|
274
|
+
title 'Hotspots'
|
275
|
+
@table.print(%w[path score commits additions deletions], data[:items])
|
276
|
+
|
277
|
+
@io.puts
|
278
|
+
line "Generated at: #{data[:generated_at]}"
|
279
|
+
end
|
280
|
+
|
281
|
+
def render_churn(data)
|
282
|
+
title "Churn for #{data[:repo_path]}"
|
283
|
+
line "Period: #{data.dig(:period, :since)} .. #{data.dig(:period, :until)}"
|
284
|
+
|
285
|
+
@io.puts
|
286
|
+
title 'Churn'
|
287
|
+
@table.print(%w[path churn commits], data[:items])
|
288
|
+
|
289
|
+
@io.puts
|
290
|
+
line "Generated at: #{data[:generated_at]}"
|
291
|
+
end
|
292
|
+
|
293
|
+
def render_ownership(data)
|
294
|
+
title "Ownership for #{data[:repo_path]}"
|
295
|
+
line "Period: #{data.dig(:period, :since)} .. #{data.dig(:period, :until)}"
|
296
|
+
|
297
|
+
@io.puts
|
298
|
+
title 'Ownership'
|
299
|
+
@table.print(%w[path owner owner_share authors], data[:items])
|
300
|
+
|
301
|
+
@io.puts
|
302
|
+
line "Generated at: #{data[:generated_at]}"
|
303
|
+
end
|
304
|
+
|
269
305
|
def title(text)
|
270
306
|
@io.puts Colors.title(text, @color, @theme)
|
271
307
|
end
|
@@ -276,5 +312,6 @@ module PrettyGit
|
|
276
312
|
|
277
313
|
# table is handled by @table
|
278
314
|
end
|
315
|
+
# rubocop:enable Metrics/ClassLength
|
279
316
|
end
|
280
317
|
end
|
@@ -4,33 +4,36 @@ require 'csv'
|
|
4
4
|
|
5
5
|
module PrettyGit
|
6
6
|
module Render
|
7
|
-
# Renders CSV according to
|
7
|
+
# Renders CSV according to docs/output_formats.md and DR-001
|
8
8
|
class CsvRenderer
|
9
|
+
HEADERS = {
|
10
|
+
'activity' => %w[bucket timestamp commits additions deletions],
|
11
|
+
'authors' => %w[author author_email commits additions deletions avg_commit_size],
|
12
|
+
'files' => %w[path commits additions deletions changes],
|
13
|
+
'heatmap' => %w[dow hour commits],
|
14
|
+
'hotspots' => %w[path score commits additions deletions changes],
|
15
|
+
'churn' => %w[path churn commits additions deletions],
|
16
|
+
'ownership' => %w[path owner owner_share authors]
|
17
|
+
}.freeze
|
9
18
|
def initialize(io: $stdout)
|
10
19
|
@io = io
|
11
20
|
end
|
12
21
|
|
13
22
|
def call(report, result, _filters)
|
14
|
-
|
15
|
-
|
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
|
-
metric = (result[:metric] || 'bytes').to_s
|
25
|
-
headers = ['language', metric, 'percent', 'color']
|
26
|
-
write_csv(headers, result[:items])
|
27
|
-
else
|
28
|
-
raise ArgumentError, "CSV output for report '#{report}' is not supported yet"
|
29
|
-
end
|
23
|
+
headers = headers_for(report, result)
|
24
|
+
write_csv(headers, result[:items])
|
30
25
|
end
|
31
26
|
|
32
27
|
private
|
33
28
|
|
29
|
+
def headers_for(report, result)
|
30
|
+
return ['language', (result[:metric] || 'bytes').to_s, 'percent', 'color'] if report == 'languages'
|
31
|
+
|
32
|
+
HEADERS.fetch(report) do
|
33
|
+
raise ArgumentError, "CSV output for report '#{report}' is not supported yet"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
34
37
|
def write_csv(headers, rows)
|
35
38
|
csv = CSV.generate(force_quotes: false) do |out|
|
36
39
|
out << headers
|
@@ -2,37 +2,54 @@
|
|
2
2
|
|
3
3
|
module PrettyGit
|
4
4
|
module Render
|
5
|
-
# Renders Markdown tables and sections per
|
5
|
+
# Renders Markdown tables and sections per docs/output_formats.md
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
6
7
|
class MarkdownRenderer
|
8
|
+
TITLES = {
|
9
|
+
'activity' => 'Activity',
|
10
|
+
'authors' => 'Authors',
|
11
|
+
'files' => 'Top Files',
|
12
|
+
'heatmap' => 'Heatmap',
|
13
|
+
'languages' => 'Languages',
|
14
|
+
'hotspots' => 'Hotspots',
|
15
|
+
'churn' => 'Churn',
|
16
|
+
'ownership' => 'Ownership'
|
17
|
+
}.freeze
|
18
|
+
|
19
|
+
HEADERS = {
|
20
|
+
'activity' => %w[bucket timestamp commits additions deletions],
|
21
|
+
'authors' => %w[author author_email commits additions deletions avg_commit_size],
|
22
|
+
'files' => %w[path commits additions deletions changes],
|
23
|
+
'heatmap' => %w[dow hour commits],
|
24
|
+
'hotspots' => %w[path score commits additions deletions changes],
|
25
|
+
'churn' => %w[path churn commits additions deletions],
|
26
|
+
'ownership' => %w[path owner owner_share authors]
|
27
|
+
}.freeze
|
7
28
|
def initialize(io: $stdout)
|
8
29
|
@io = io
|
9
30
|
end
|
10
31
|
|
11
|
-
# rubocop:disable Metrics/CyclomaticComplexity
|
12
32
|
def call(report, result, _filters)
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
render_table('Authors', %w[author author_email commits additions deletions avg_commit_size], result[:items])
|
20
|
-
when 'files'
|
21
|
-
render_table('Top Files', %w[path commits additions deletions changes], result[:items])
|
22
|
-
when 'heatmap'
|
23
|
-
render_table('Heatmap', %w[dow hour commits], result[:items])
|
24
|
-
when 'languages'
|
25
|
-
metric = (result[:metric] || 'bytes').to_s
|
26
|
-
headers = ['language', metric, 'percent', 'color']
|
27
|
-
render_table('Languages', headers, result[:items])
|
28
|
-
else
|
29
|
-
@io.puts result.inspect
|
30
|
-
end
|
33
|
+
return render_summary(result) if report == 'summary'
|
34
|
+
|
35
|
+
headers = headers_for(report, result)
|
36
|
+
title = title_for(report)
|
37
|
+
rows = sort_rows(report, result[:items], result)
|
38
|
+
render_table(title, headers, rows)
|
31
39
|
end
|
32
|
-
# rubocop:enable Metrics/CyclomaticComplexity
|
33
40
|
|
34
41
|
private
|
35
42
|
|
43
|
+
def headers_for(report, result)
|
44
|
+
return ['language', (result[:metric] || 'bytes').to_s, 'percent', 'color'] if report == 'languages'
|
45
|
+
|
46
|
+
HEADERS.fetch(report, [])
|
47
|
+
end
|
48
|
+
|
49
|
+
def title_for(report)
|
50
|
+
TITLES.fetch(report, report.to_s.capitalize)
|
51
|
+
end
|
52
|
+
|
36
53
|
def render_summary(data)
|
37
54
|
header_summary(data)
|
38
55
|
print_totals(data[:totals])
|
@@ -67,6 +84,53 @@ module PrettyGit
|
|
67
84
|
end
|
68
85
|
@io.puts 'No data' if rows.empty?
|
69
86
|
end
|
87
|
+
|
88
|
+
# Deterministic ordering per docs/determinism.md
|
89
|
+
# NOTE: High branching is intentional to keep tie-breakers explicit and stable across formats.
|
90
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
91
|
+
def sort_rows(report, rows, ctx = nil)
|
92
|
+
return rows unless rows.is_a?(Array)
|
93
|
+
|
94
|
+
case report
|
95
|
+
when 'hotspots'
|
96
|
+
rows.sort_by { |r| [-to_f(r[:score]), -to_i(r[:commits]), -to_i(r[:changes]), to_s(r[:path])] }
|
97
|
+
when 'churn'
|
98
|
+
rows.sort_by { |r| [-to_i(r[:churn]), -to_i(r[:commits]), to_s(r[:path])] }
|
99
|
+
when 'ownership'
|
100
|
+
rows.sort_by { |r| [-to_f(r[:owner_share]), -to_i(r[:authors]), to_s(r[:path])] }
|
101
|
+
when 'files'
|
102
|
+
rows.sort_by { |r| [-to_i(r[:changes]), -to_i(r[:commits]), to_s(r[:path])] }
|
103
|
+
when 'authors'
|
104
|
+
rows.sort_by { |r| [-to_i(r[:commits]), -to_i(r[:additions]), -to_i(r[:deletions]), to_s(r[:author_email])] }
|
105
|
+
when 'languages'
|
106
|
+
metric = ctx && ctx[:metric] ? ctx[:metric].to_sym : :bytes
|
107
|
+
rows.sort_by { |r| [-to_i(r[metric]), to_s(r[:language])] }
|
108
|
+
when 'activity'
|
109
|
+
rows.sort_by { |r| [to_s(r[:timestamp])] }
|
110
|
+
when 'heatmap'
|
111
|
+
rows.sort_by { |r| [to_i(r[:dow] || r[:day] || r[:weekday]), to_i(r[:hour])] }
|
112
|
+
else
|
113
|
+
rows
|
114
|
+
end
|
115
|
+
end
|
116
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
117
|
+
|
118
|
+
def to_i(val)
|
119
|
+
Integer(val || 0)
|
120
|
+
rescue StandardError
|
121
|
+
0
|
122
|
+
end
|
123
|
+
|
124
|
+
def to_f(val)
|
125
|
+
Float(val || 0.0)
|
126
|
+
rescue StandardError
|
127
|
+
0.0
|
128
|
+
end
|
129
|
+
|
130
|
+
def to_s(val)
|
131
|
+
(val || '').to_s
|
132
|
+
end
|
70
133
|
end
|
134
|
+
# rubocop:enable Metrics/ClassLength
|
71
135
|
end
|
72
136
|
end
|
@@ -10,15 +10,29 @@ module PrettyGit
|
|
10
10
|
@io = io
|
11
11
|
end
|
12
12
|
|
13
|
-
|
13
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
14
|
+
def call(report, result, _filters)
|
15
|
+
ordered = apply_order(report, result)
|
14
16
|
doc = REXML::Document.new
|
15
17
|
doc << REXML::XMLDecl.new('1.0', 'UTF-8')
|
16
|
-
|
17
|
-
|
18
|
+
root_name = case report
|
19
|
+
when 'hotspots' then 'hotspotsReport'
|
20
|
+
when 'churn' then 'churnReport'
|
21
|
+
when 'ownership' then 'ownershipReport'
|
22
|
+
when 'languages' then 'languagesReport'
|
23
|
+
when 'files' then 'filesReport'
|
24
|
+
when 'authors' then 'authorsReport'
|
25
|
+
when 'activity' then 'activityReport'
|
26
|
+
when 'heatmap' then 'heatmapReport'
|
27
|
+
else 'report'
|
28
|
+
end
|
29
|
+
root = doc.add_element(root_name)
|
30
|
+
hash_to_xml(root, ordered)
|
18
31
|
formatter = REXML::Formatters::Pretty.new(2)
|
19
32
|
formatter.compact = true
|
20
33
|
formatter.write(doc, @io)
|
21
34
|
end
|
35
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
22
36
|
|
23
37
|
private
|
24
38
|
|
@@ -38,6 +52,60 @@ module PrettyGit
|
|
38
52
|
parent.text = obj.nil? ? '' : obj.to_s
|
39
53
|
end
|
40
54
|
end
|
55
|
+
|
56
|
+
# Deterministic ordering per docs/determinism.md
|
57
|
+
# NOTE: High branching is intentional to keep tie-breakers explicit and stable across formats.
|
58
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
59
|
+
def apply_order(report, result)
|
60
|
+
dup = Marshal.load(Marshal.dump(result))
|
61
|
+
items = dup[:items]
|
62
|
+
return dup unless items.is_a?(Array)
|
63
|
+
|
64
|
+
dup[:items] =
|
65
|
+
case report
|
66
|
+
when 'hotspots'
|
67
|
+
items.sort_by do |r|
|
68
|
+
[-to_f(r[:score]), -to_i(r[:commits]), -to_i(r[:changes]), to_s(r[:path])]
|
69
|
+
end
|
70
|
+
when 'churn'
|
71
|
+
items.sort_by { |r| [-to_i(r[:churn]), -to_i(r[:commits]), to_s(r[:path])] }
|
72
|
+
when 'ownership'
|
73
|
+
items.sort_by { |r| [-to_f(r[:owner_share]), -to_i(r[:authors]), to_s(r[:path])] }
|
74
|
+
when 'files'
|
75
|
+
items.sort_by { |r| [-to_i(r[:changes]), -to_i(r[:commits]), to_s(r[:path])] }
|
76
|
+
when 'authors'
|
77
|
+
items.sort_by do |r|
|
78
|
+
[-to_i(r[:commits]), -to_i(r[:additions]), -to_i(r[:deletions]), to_s(r[:author_email])]
|
79
|
+
end
|
80
|
+
when 'languages'
|
81
|
+
metric = (dup[:metric] || 'bytes').to_sym
|
82
|
+
items.sort_by { |r| [-to_i(r[metric]), to_s(r[:language])] }
|
83
|
+
when 'activity'
|
84
|
+
items.sort_by { |r| [to_s(r[:timestamp])] }
|
85
|
+
when 'heatmap'
|
86
|
+
items.sort_by { |r| [to_i(r[:dow] || r[:day] || r[:weekday]), to_i(r[:hour])] }
|
87
|
+
else
|
88
|
+
items
|
89
|
+
end
|
90
|
+
dup
|
91
|
+
end
|
92
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
93
|
+
|
94
|
+
def to_i(val)
|
95
|
+
Integer(val || 0)
|
96
|
+
rescue StandardError
|
97
|
+
0
|
98
|
+
end
|
99
|
+
|
100
|
+
def to_f(val)
|
101
|
+
Float(val || 0.0)
|
102
|
+
rescue StandardError
|
103
|
+
0.0
|
104
|
+
end
|
105
|
+
|
106
|
+
def to_s(val)
|
107
|
+
(val || '').to_s
|
108
|
+
end
|
41
109
|
end
|
42
110
|
end
|
43
111
|
end
|
@@ -10,9 +10,11 @@ module PrettyGit
|
|
10
10
|
@io = io
|
11
11
|
end
|
12
12
|
|
13
|
-
def call(
|
13
|
+
def call(report, result, _filters)
|
14
|
+
# Apply deterministic ordering for items where applicable
|
15
|
+
ordered = apply_order(report, result)
|
14
16
|
# Dump the entire result structure to YAML with string keys for safe parsing
|
15
|
-
@io.write(stringify_keys(
|
17
|
+
@io.write(stringify_keys(ordered).to_yaml)
|
16
18
|
end
|
17
19
|
|
18
20
|
private
|
@@ -29,6 +31,60 @@ module PrettyGit
|
|
29
31
|
obj
|
30
32
|
end
|
31
33
|
end
|
34
|
+
|
35
|
+
# Deterministic ordering per docs/determinism.md
|
36
|
+
# NOTE: High branching is intentional to keep tie-breakers explicit and stable across formats.
|
37
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
38
|
+
def apply_order(report, result)
|
39
|
+
dup = Marshal.load(Marshal.dump(result)) # deep dup
|
40
|
+
items = dup[:items]
|
41
|
+
return dup unless items.is_a?(Array)
|
42
|
+
|
43
|
+
dup[:items] =
|
44
|
+
case report
|
45
|
+
when 'hotspots'
|
46
|
+
items.sort_by do |r|
|
47
|
+
[-to_f(r[:score]), -to_i(r[:commits]), -to_i(r[:changes]), to_s(r[:path])]
|
48
|
+
end
|
49
|
+
when 'churn'
|
50
|
+
items.sort_by { |r| [-to_i(r[:churn]), -to_i(r[:commits]), to_s(r[:path])] }
|
51
|
+
when 'ownership'
|
52
|
+
items.sort_by { |r| [-to_f(r[:owner_share]), -to_i(r[:authors]), to_s(r[:path])] }
|
53
|
+
when 'files'
|
54
|
+
items.sort_by { |r| [-to_i(r[:changes]), -to_i(r[:commits]), to_s(r[:path])] }
|
55
|
+
when 'authors'
|
56
|
+
items.sort_by do |r|
|
57
|
+
[-to_i(r[:commits]), -to_i(r[:additions]), -to_i(r[:deletions]), to_s(r[:author_email])]
|
58
|
+
end
|
59
|
+
when 'languages'
|
60
|
+
metric = (dup[:metric] || 'bytes').to_sym
|
61
|
+
items.sort_by { |r| [-to_i(r[metric]), to_s(r[:language])] }
|
62
|
+
when 'activity'
|
63
|
+
items.sort_by { |r| [to_s(r[:timestamp])] }
|
64
|
+
when 'heatmap'
|
65
|
+
items.sort_by { |r| [to_i(r[:dow] || r[:day] || r[:weekday]), to_i(r[:hour])] }
|
66
|
+
else
|
67
|
+
items
|
68
|
+
end
|
69
|
+
dup
|
70
|
+
end
|
71
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
72
|
+
|
73
|
+
def to_i(val)
|
74
|
+
Integer(val || 0)
|
75
|
+
rescue StandardError
|
76
|
+
0
|
77
|
+
end
|
78
|
+
|
79
|
+
def to_f(val)
|
80
|
+
Float(val || 0.0)
|
81
|
+
rescue StandardError
|
82
|
+
0.0
|
83
|
+
end
|
84
|
+
|
85
|
+
def to_s(val)
|
86
|
+
(val || '').to_s
|
87
|
+
end
|
32
88
|
end
|
33
89
|
end
|
34
90
|
end
|
data/lib/pretty_git/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: pretty-git
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Pretty Git Authors
|
@@ -51,8 +51,6 @@ dependencies:
|
|
51
51
|
version: '4.0'
|
52
52
|
description: Generates structured analytics from local Git repositories with multiple
|
53
53
|
export formats.
|
54
|
-
email:
|
55
|
-
- ''
|
56
54
|
executables:
|
57
55
|
- pretty-git
|
58
56
|
extensions: []
|
@@ -66,9 +64,12 @@ files:
|
|
66
64
|
- lib/pretty_git.rb
|
67
65
|
- lib/pretty_git/analytics/activity.rb
|
68
66
|
- lib/pretty_git/analytics/authors.rb
|
67
|
+
- lib/pretty_git/analytics/churn.rb
|
69
68
|
- lib/pretty_git/analytics/files.rb
|
70
69
|
- lib/pretty_git/analytics/heatmap.rb
|
70
|
+
- lib/pretty_git/analytics/hotspots.rb
|
71
71
|
- lib/pretty_git/analytics/languages.rb
|
72
|
+
- lib/pretty_git/analytics/ownership.rb
|
72
73
|
- lib/pretty_git/analytics/summary.rb
|
73
74
|
- lib/pretty_git/app.rb
|
74
75
|
- lib/pretty_git/cli.rb
|
@@ -89,9 +90,11 @@ homepage: https://github.com/MikoMikocchi/pretty-git
|
|
89
90
|
licenses:
|
90
91
|
- MIT
|
91
92
|
metadata:
|
93
|
+
homepage_uri: https://github.com/MikoMikocchi/pretty-git
|
92
94
|
source_code_uri: https://github.com/MikoMikocchi/pretty-git
|
93
95
|
changelog_uri: https://github.com/MikoMikocchi/pretty-git/blob/main/CHANGELOG.md
|
94
96
|
bug_tracker_uri: https://github.com/MikoMikocchi/pretty-git/issues
|
97
|
+
documentation_uri: https://github.com/MikoMikocchi/pretty-git#readme
|
95
98
|
rubygems_mfa_required: 'true'
|
96
99
|
rdoc_options: []
|
97
100
|
require_paths:
|