pretty-git 0.1.3 → 0.1.5

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.
@@ -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
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrettyGit
4
+ module Utils
5
+ # Utilities for path/glob normalization and handling cross-platform quirks.
6
+ module PathUtils
7
+ module_function
8
+
9
+ # Normalize a string path or glob to Unicode NFC form.
10
+ # Returns nil if input is nil.
11
+ def normalize_nfc(str)
12
+ return nil if str.nil?
13
+
14
+ s = str.to_s
15
+ # Only normalize if supported in this Ruby build; otherwise return as-is
16
+ if s.respond_to?(:unicode_normalize)
17
+ s.unicode_normalize(:nfc)
18
+ else
19
+ s
20
+ end
21
+ end
22
+
23
+ # Normalize each entry in an Array-like collection to NFC and compact
24
+ # nils. Returns an Array.
25
+ def normalize_globs(collection)
26
+ Array(collection).compact.map { |p| normalize_nfc(p) }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module PrettyGit
6
+ module Utils
7
+ # Utilities for time parsing and ISO8601 normalization used by filters.
8
+ module TimeUtils
9
+ module_function
10
+
11
+ # Converts various time inputs to ISO8601 in UTC.
12
+ # Accepts Time, String(ISO8601), or String(YYYY-MM-DD) treated as UTC midnight.
13
+ # Returns nil for nil/blank input. Raises ArgumentError for invalid values.
14
+ def to_utc_iso8601(val)
15
+ return nil if val.nil? || val.to_s.strip.empty?
16
+
17
+ parse_to_time(val).utc.iso8601
18
+ rescue ArgumentError
19
+ raise ArgumentError, "Invalid datetime: #{val} (expected ISO8601 or YYYY-MM-DD)"
20
+ end
21
+
22
+ def parse_to_time(val)
23
+ return val if val.is_a?(Time)
24
+ return parse_date_only(val) if val.is_a?(String) && date_only?(val)
25
+
26
+ Time.parse(val.to_s)
27
+ end
28
+
29
+ def parse_date_only(str)
30
+ y, m, d = str.split('-').map(&:to_i)
31
+ Time.new(y, m, d, 0, 0, 0, '+00:00')
32
+ end
33
+
34
+ def date_only?(str)
35
+ !!(str =~ /^\d{4}-\d{2}-\d{2}$/)
36
+ end
37
+ end
38
+ end
39
+ 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.5'
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.5
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: []
@@ -76,8 +74,10 @@ files:
76
74
  - lib/pretty_git/app.rb
77
75
  - lib/pretty_git/cli.rb
78
76
  - lib/pretty_git/cli_helpers.rb
77
+ - lib/pretty_git/constants.rb
79
78
  - lib/pretty_git/filters.rb
80
79
  - lib/pretty_git/git/provider.rb
80
+ - lib/pretty_git/logger.rb
81
81
  - lib/pretty_git/render/console_renderer.rb
82
82
  - lib/pretty_git/render/csv_renderer.rb
83
83
  - lib/pretty_git/render/json_renderer.rb
@@ -87,14 +87,18 @@ files:
87
87
  - lib/pretty_git/render/xml_renderer.rb
88
88
  - lib/pretty_git/render/yaml_renderer.rb
89
89
  - lib/pretty_git/types.rb
90
+ - lib/pretty_git/utils/path_utils.rb
91
+ - lib/pretty_git/utils/time_utils.rb
90
92
  - lib/pretty_git/version.rb
91
93
  homepage: https://github.com/MikoMikocchi/pretty-git
92
94
  licenses:
93
95
  - MIT
94
96
  metadata:
97
+ homepage_uri: https://github.com/MikoMikocchi/pretty-git
95
98
  source_code_uri: https://github.com/MikoMikocchi/pretty-git
96
99
  changelog_uri: https://github.com/MikoMikocchi/pretty-git/blob/main/CHANGELOG.md
97
100
  bug_tracker_uri: https://github.com/MikoMikocchi/pretty-git/issues
101
+ documentation_uri: https://github.com/MikoMikocchi/pretty-git#readme
98
102
  rubygems_mfa_required: 'true'
99
103
  rdoc_options: []
100
104
  require_paths: