pretty-git 0.1.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84571ab2bdd16f92dc31c1883a70433a140253ee0d0c66f1079453fc30f34419
4
- data.tar.gz: 532ee3a2e436163f12821073297dc013e874746c5ac269f89c455826566c9f27
3
+ metadata.gz: 7e190585a91de27221e54bc2cbc58b5f6d26a1428c2e5cd5189ff0c18d362e6c
4
+ data.tar.gz: f8d2bc8f5d6c6a4887a745abd611a038166bcb54e95267a15d4868ee9d530003
5
5
  SHA512:
6
- metadata.gz: a0ba7918e1179ebfe3c4771c995b8b0eda978263be1fc6484f0f913e7b2129472272660e3c6dfb24a11f9d64300c26748d0b01fc14f0594e04e9b88cf244b96c
7
- data.tar.gz: afaa201615eff920f1af0b86384964437fe7a119fc0d43d2a5ae10ee36211215b84c9607b92354646a20d691f0382f0b5d214385a214e8107596cdcf06eefcb2
6
+ metadata.gz: e11fd51d24ed1e63fd5c773f76a46510bbcedad23c02ace63b25d7d80c366479fd02fe2bfabaa58693389c222bafe4fd9e6fc0b157b7d598e96294a3f888fbef
7
+ data.tar.gz: 38fc87c6467d72ae6a1cb63a13b06c417e81f714d5b074feae961f121e061cea0733494020e0b8f562f36ea00b2d537a0d6fd5ac36eea942465680376f8eb71e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
7
7
  ## [Unreleased]
8
8
 
9
9
 
10
+ ## [0.1.4] - 2025-08-17
11
+ ### Added
12
+ - Integration tests for new reports exports: CSV/Markdown/YAML/XML for `hotspots`, `churn`, `ownership`.
13
+ - Schema validations: `rake validate:json`, `rake validate:xml` to ensure format compatibility.
14
+ - CI: expanded matrix to include macOS; smoke test for installed binary (`--help`, `--version`).
15
+
16
+ ### Changed
17
+ - Renderers (`MarkdownRenderer`, `YamlRenderer`, `XmlRenderer`): deterministic sorting for all new reports according to `docs/determinism.md`.
18
+ - XML: per-report root elements in XML exports to match XSDs (`hotspotsReport`, `churnReport`, `ownershipReport`, `languagesReport`, etc.).
19
+ - Documentation: `README.md` and `README.ru.md` updated with sections and examples for new reports and all export formats.
20
+ - CLI: keep `--time-bucket` permissive; default `time_bucket=nil`.
21
+
22
+ ### Fixed
23
+ - Time parsing: interpret date-only inputs (`YYYY-MM-DD`) as UTC midnight and normalize to UTC ISO8601.
24
+ - CLI UX: error when `--metric` is used outside `languages` report.
25
+ - Tests/specs: updated XML specs to per-report roots; added timezone edge cases; fixed Open3 `popen3` stubs (`chdir:`) and integration requires.
26
+
27
+
10
28
  ## [0.1.3] - 2025-08-14
11
29
  ### Added
12
30
  - New analytics reports: `hotspots`, `churn`, `ownership` with sorting, scoring, and limits.
@@ -30,7 +48,7 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
30
48
  ### Changed
31
49
  - Languages: JSON language reinstated in the mapping and color scheme; sorting and percent calculations are based on the selected metric; percentages rounded to two decimals.
32
50
  - Renderers: updated `csv`, `markdown`, and console renderers to work with dynamic metrics.
33
- - Internal specs updated: `specs/output_formats.md`, `specs/cli_spec.md`, `specs/languages_map.md`.
51
+ - Internal specs updated: `docs/output_formats.md`, `docs/cli_spec.md`, `docs/languages_map.md`.
34
52
 
35
53
  ### Fixed
36
54
  - Git provider: correct commit counting — emit a new commit when a header is read and remove the record separator from the subject (`lib/pretty_git/git/provider.rb`).
data/README.md CHANGED
@@ -124,6 +124,10 @@ General form:
124
124
  pretty-git <report> <repo_path> [options]
125
125
  ```
126
126
 
127
+ Notes:
128
+ * `<repo_path>` defaults to `.` if omitted.
129
+ * You can also pass the repository via `--repo PATH` as an alternative to the positional argument.
130
+
127
131
  Available reports: `summary`, `activity`, `authors`, `files`, `heatmap`, `languages`, `hotspots`, `churn`, `ownership`.
128
132
 
129
133
  Key options:
@@ -207,19 +211,31 @@ Markdown example:
207
211
  pretty-git files . --paths app,lib --format csv
208
212
  ```
209
213
  CSV columns: `path,commits,additions,deletions,changes`.
214
+
210
215
  XML example:
211
216
  ```xml
212
- <files>
213
- <item path="app/models/user.rb" commits="42" additions="2100" deletions="1400" changes="3500" />
214
- <item path="app/services/auth.rb" commits="35" additions="1500" deletions="900" changes="2400" />
217
+ <?xml version="1.0" encoding="UTF-8"?>
218
+ <report>
219
+ <report>files</report>
215
220
  <generated_at>2025-01-31T00:00:00Z</generated_at>
216
221
  <repo_path>/abs/path/to/repo</repo_path>
217
- <report>files</report>
218
- <period>
219
- <since/>
220
- <until/>
221
- </period>
222
- </files>
222
+ <items>
223
+ <item>
224
+ <path>app/models/user.rb</path>
225
+ <commits>42</commits>
226
+ <additions>2100</additions>
227
+ <deletions>1400</deletions>
228
+ <changes>3500</changes>
229
+ </item>
230
+ <item>
231
+ <path>app/services/auth.rb</path>
232
+ <commits>35</commits>
233
+ <additions>1500</additions>
234
+ <deletions>900</deletions>
235
+ <changes>2400</changes>
236
+ </item>
237
+ </items>
238
+ </report>
223
239
  ```
224
240
 
225
241
  ### 🔥 heatmap — commit heatmap
@@ -337,15 +353,20 @@ Notes:
337
353
 
338
354
  XML example:
339
355
  ```xml
340
- <ownership>
356
+ <?xml version="1.0" encoding="UTF-8"?>
357
+ <report>
341
358
  <report>ownership</report>
342
359
  <generated_at>2025-01-31T00:00:00Z</generated_at>
343
360
  <repo_path>.</repo_path>
344
361
  <items>
345
- <item path="lib/a.rb" owner="Alice <a@example.com>" owner_share="82.5" authors="2"/>
362
+ <item>
363
+ <path>lib/a.rb</path>
364
+ <owner>Alice &lt;a@example.com&gt;</owner>
365
+ <owner_share>82.5</owner_share>
366
+ <authors>2</authors>
367
+ </item>
346
368
  </items>
347
-
348
- </ownership>
369
+ </report>
349
370
  ```
350
371
 
351
372
  ## 📤 Exports
data/README.ru.md CHANGED
@@ -124,6 +124,10 @@ bundle exec bin/pretty-git activity . --time-bucket week --since 2025-01-01 \
124
124
  pretty-git <report> <repo_path> [options]
125
125
  ```
126
126
 
127
+ Примечания:
128
+ * `<repo_path>` по умолчанию — `.` (если опущен).
129
+ * Репозиторий можно указать и через флаг `--repo PATH` как альтернативу позиционному аргументу.
130
+
127
131
  Доступные отчёты: `summary`, `activity`, `authors`, `files`, `heatmap`, `languages`, `hotspots`, `churn`, `ownership`.
128
132
 
129
133
  Ключевые опции:
@@ -207,19 +211,31 @@ CSV-колонки: `author,author_email,commits,additions,deletions,avg_commit_
207
211
  pretty-git files . --paths app,lib --format csv
208
212
  ```
209
213
  CSV-колонки: `path,commits,additions,deletions,changes`.
214
+
210
215
  Пример XML:
211
216
  ```xml
212
- <files>
213
- <item path="app/models/user.rb" commits="42" additions="2100" deletions="1400" changes="3500" />
214
- <item path="app/services/auth.rb" commits="35" additions="1500" deletions="900" changes="2400" />
217
+ <?xml version="1.0" encoding="UTF-8"?>
218
+ <report>
219
+ <report>files</report>
215
220
  <generated_at>2025-01-31T00:00:00Z</generated_at>
216
221
  <repo_path>/abs/path/to/repo</repo_path>
217
- <report>files</report>
218
- <period>
219
- <since/>
220
- <until/>
221
- </period>
222
- </files>
222
+ <items>
223
+ <item>
224
+ <path>app/models/user.rb</path>
225
+ <commits>42</commits>
226
+ <additions>2100</additions>
227
+ <deletions>1400</deletions>
228
+ <changes>3500</changes>
229
+ </item>
230
+ <item>
231
+ <path>app/services/auth.rb</path>
232
+ <commits>35</commits>
233
+ <additions>1500</additions>
234
+ <deletions>900</deletions>
235
+ <changes>2400</changes>
236
+ </item>
237
+ </items>
238
+ </report>
223
239
  ```
224
240
 
225
241
  ### 🔥 heatmap — тепловая карта
@@ -335,15 +351,20 @@ CSV‑колонки: `path,owner,owner_share,authors`.
335
351
 
336
352
  Пример XML:
337
353
  ```xml
338
- <ownership>
354
+ <?xml version="1.0" encoding="UTF-8"?>
355
+ <report>
339
356
  <report>ownership</report>
340
357
  <generated_at>2025-01-31T00:00:00Z</generated_at>
341
358
  <repo_path>.</repo_path>
342
359
  <items>
343
- <item path="lib/a.rb" owner="Alice &lt;a@example.com&gt;" owner_share="82.5" authors="2"/>
360
+ <item>
361
+ <path>lib/a.rb</path>
362
+ <owner>Alice &lt;a@example.com&gt;</owner>
363
+ <owner_share>82.5</owner_share>
364
+ <authors>2</authors>
365
+ </item>
344
366
  </items>
345
-
346
- </ownership>
367
+ </report>
347
368
  ```
348
369
 
349
370
  ## 🚫 Игнорируемые директории и файлы
@@ -12,7 +12,7 @@ module PrettyGit
12
12
  SUPPORTED_REPORTS = %w[summary activity authors files heatmap languages hotspots churn ownership].freeze
13
13
  SUPPORTED_FORMATS = %w[console json csv md yaml xml].freeze
14
14
 
15
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
15
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
16
16
  def self.run(argv = ARGV, out: $stdout, err: $stderr)
17
17
  options = {
18
18
  report: 'summary',
@@ -22,7 +22,7 @@ module PrettyGit
22
22
  exclude_authors: [],
23
23
  paths: [],
24
24
  exclude_paths: [],
25
- time_bucket: 'week',
25
+ time_bucket: nil,
26
26
  limit: 10,
27
27
  format: 'console',
28
28
  out: nil,
@@ -46,6 +46,9 @@ module PrettyGit
46
46
  return 1
47
47
  end
48
48
 
49
+ # REPO positional arg (after REPORT), if still present and not an option
50
+ options[:repo] = argv.shift if argv[0] && argv[0] !~ /^-/
51
+
49
52
  exit_code = CLIHelpers.validate_and_maybe_exit(options, parser, out, err)
50
53
  return exit_code if exit_code
51
54
 
@@ -58,6 +61,6 @@ module PrettyGit
58
61
  err.puts e.message
59
62
  2
60
63
  end
61
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
64
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
62
65
  end
63
66
  end
@@ -16,7 +16,7 @@ module PrettyGit
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
- return nil if valid_report?(options[:report]) && valid_theme?(options[:theme]) && valid_metric?(options[:metric])
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],
@@ -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
- t = val.is_a?(Time) ? val : Time.parse(val.to_s)
38
- t = t.getlocal if t.utc_offset.nil?
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
- yld << Types::Commit.new(
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
- return if path_args.empty?
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
- args.concat(path_args)
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
@@ -4,7 +4,7 @@ require 'csv'
4
4
 
5
5
  module PrettyGit
6
6
  module Render
7
- # Renders CSV according to specs/output_formats.md and DR-001
7
+ # Renders CSV according to docs/output_formats.md and DR-001
8
8
  class CsvRenderer
9
9
  HEADERS = {
10
10
  'activity' => %w[bucket timestamp commits additions deletions],
@@ -2,7 +2,8 @@
2
2
 
3
3
  module PrettyGit
4
4
  module Render
5
- # Renders Markdown tables and sections per specs/output_formats.md
5
+ # Renders Markdown tables and sections per docs/output_formats.md
6
+ # rubocop:disable Metrics/ClassLength
6
7
  class MarkdownRenderer
7
8
  TITLES = {
8
9
  'activity' => 'Activity',
@@ -33,7 +34,8 @@ module PrettyGit
33
34
 
34
35
  headers = headers_for(report, result)
35
36
  title = title_for(report)
36
- render_table(title, headers, result[:items])
37
+ rows = sort_rows(report, result[:items], result)
38
+ render_table(title, headers, rows)
37
39
  end
38
40
 
39
41
  private
@@ -82,6 +84,53 @@ module PrettyGit
82
84
  end
83
85
  @io.puts 'No data' if rows.empty?
84
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
85
133
  end
134
+ # rubocop:enable Metrics/ClassLength
86
135
  end
87
136
  end
@@ -10,15 +10,29 @@ module PrettyGit
10
10
  @io = io
11
11
  end
12
12
 
13
- def call(_report, result, _filters)
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
- root = doc.add_element('report')
17
- hash_to_xml(root, result)
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(_report, result, _filters)
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(result).to_yaml)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PrettyGit
4
- VERSION = '0.1.3'
4
+ VERSION = '0.1.4'
5
5
  end
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.3
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: []
@@ -92,9 +90,11 @@ homepage: https://github.com/MikoMikocchi/pretty-git
92
90
  licenses:
93
91
  - MIT
94
92
  metadata:
93
+ homepage_uri: https://github.com/MikoMikocchi/pretty-git
95
94
  source_code_uri: https://github.com/MikoMikocchi/pretty-git
96
95
  changelog_uri: https://github.com/MikoMikocchi/pretty-git/blob/main/CHANGELOG.md
97
96
  bug_tracker_uri: https://github.com/MikoMikocchi/pretty-git/issues
97
+ documentation_uri: https://github.com/MikoMikocchi/pretty-git#readme
98
98
  rubygems_mfa_required: 'true'
99
99
  rdoc_options: []
100
100
  require_paths: