pretty-git 0.1.4 → 0.1.6
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 +84 -4
- data/README.md +78 -6
- data/README.ru.md +76 -3
- data/lib/pretty_git/analytics/languages.rb +61 -19
- data/lib/pretty_git/app.rb +22 -15
- data/lib/pretty_git/cli.rb +17 -6
- data/lib/pretty_git/cli_helpers.rb +148 -19
- data/lib/pretty_git/constants.rb +15 -0
- data/lib/pretty_git/filters.rb +41 -22
- data/lib/pretty_git/git/provider.rb +78 -15
- data/lib/pretty_git/logger.rb +20 -0
- data/lib/pretty_git/render/console_renderer.rb +25 -7
- data/lib/pretty_git/render/csv_renderer.rb +4 -15
- data/lib/pretty_git/render/markdown_renderer.rb +5 -15
- data/lib/pretty_git/render/report_schema.rb +39 -0
- data/lib/pretty_git/utils/path_utils.rb +30 -0
- data/lib/pretty_git/utils/time_utils.rb +39 -0
- data/lib/pretty_git/version.rb +1 -1
- metadata +6 -1
data/lib/pretty_git/cli.rb
CHANGED
@@ -9,9 +9,6 @@ require_relative 'cli_helpers'
|
|
9
9
|
module PrettyGit
|
10
10
|
# Command-line interface entry point.
|
11
11
|
class CLI
|
12
|
-
SUPPORTED_REPORTS = %w[summary activity authors files heatmap languages hotspots churn ownership].freeze
|
13
|
-
SUPPORTED_FORMATS = %w[console json csv md yaml xml].freeze
|
14
|
-
|
15
12
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
|
16
13
|
def self.run(argv = ARGV, out: $stdout, err: $stderr)
|
17
14
|
options = {
|
@@ -28,6 +25,7 @@ module PrettyGit
|
|
28
25
|
out: nil,
|
29
26
|
no_color: false,
|
30
27
|
theme: 'basic',
|
28
|
+
_verbose: false,
|
31
29
|
_version: false,
|
32
30
|
_help: false
|
33
31
|
}
|
@@ -55,11 +53,24 @@ module PrettyGit
|
|
55
53
|
filters = CLIHelpers.build_filters(options)
|
56
54
|
CLIHelpers.execute(options[:report], filters, options, out, err)
|
57
55
|
rescue ArgumentError => e
|
58
|
-
err.puts e.message
|
56
|
+
err.puts "Error: #{e.message}"
|
59
57
|
1
|
60
|
-
rescue
|
61
|
-
|
58
|
+
rescue Errno::ENOENT => e
|
59
|
+
# Repository not found is user input error (exit 1), file write error is system error (exit 2)
|
60
|
+
if e.message.include?('chdir') || options[:repo]
|
61
|
+
err.puts "Not a git repository: #{e.message}"
|
62
|
+
1
|
63
|
+
else
|
64
|
+
err.puts "File system error: #{e.message}"
|
65
|
+
2
|
66
|
+
end
|
67
|
+
rescue Errno::EACCES => e
|
68
|
+
err.puts "Permission denied: #{e.message}"
|
62
69
|
2
|
70
|
+
rescue StandardError => e
|
71
|
+
err.puts "Unexpected error: #{e.message}"
|
72
|
+
err.puts e.backtrace.first(5).join("\n") if options[:_verbose]
|
73
|
+
99
|
63
74
|
end
|
64
75
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
|
65
76
|
end
|
@@ -1,29 +1,71 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'optparse'
|
4
|
+
require 'fileutils'
|
4
5
|
require_relative 'filters'
|
5
6
|
require_relative 'app'
|
7
|
+
require_relative 'constants'
|
6
8
|
|
7
9
|
module PrettyGit
|
8
10
|
# Helpers extracted from `PrettyGit::CLI` to keep the CLI class small
|
9
11
|
# and RuboCop-compliant. Provides parser configuration and execution utilities.
|
10
12
|
# rubocop:disable Metrics/ModuleLength
|
11
13
|
module CLIHelpers
|
12
|
-
REPORTS =
|
13
|
-
FORMATS =
|
14
|
-
METRICS =
|
14
|
+
REPORTS = PrettyGit::Constants::REPORTS
|
15
|
+
FORMATS = PrettyGit::Constants::FORMATS
|
16
|
+
METRICS = PrettyGit::Constants::METRICS
|
17
|
+
THEMES = PrettyGit::Constants::THEMES
|
15
18
|
|
16
19
|
module_function
|
17
20
|
|
21
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
18
22
|
def configure_parser(opts, options)
|
19
|
-
opts.banner =
|
23
|
+
opts.banner = <<~BANNER
|
24
|
+
Usage: pretty-git [REPORT] [REPO] [options]
|
25
|
+
|
26
|
+
Reports: #{REPORTS.join(', ')}
|
27
|
+
Formats: #{FORMATS.join(', ')}
|
28
|
+
Themes: #{THEMES.join(', ')}
|
29
|
+
BANNER
|
30
|
+
opts.separator('')
|
31
|
+
opts.separator('Repository and branch:')
|
20
32
|
add_repo_options(opts, options)
|
33
|
+
opts.separator('')
|
34
|
+
opts.separator('Time, authors, and bucketing:')
|
21
35
|
add_time_author_options(opts, options)
|
36
|
+
opts.separator('')
|
37
|
+
opts.separator('Paths and limits:')
|
22
38
|
add_path_limit_options(opts, options)
|
39
|
+
opts.separator('')
|
40
|
+
opts.separator('Format and output:')
|
23
41
|
add_format_output_options(opts, options)
|
42
|
+
opts.separator('')
|
43
|
+
opts.separator('Metrics (languages report only):')
|
24
44
|
add_metric_options(opts, options)
|
45
|
+
opts.separator('')
|
46
|
+
opts.separator('Other:')
|
25
47
|
add_misc_options(opts, options)
|
48
|
+
opts.separator('')
|
49
|
+
opts.separator('Examples:')
|
50
|
+
opts.separator(' # Summary in JSON to stdout')
|
51
|
+
opts.separator(' $ pretty-git summary . --format json')
|
52
|
+
opts.separator('')
|
53
|
+
opts.separator(' # Authors since a date with a limit')
|
54
|
+
opts.separator(' $ pretty-git authors ~/repo --since 2025-01-01 --limit 20')
|
55
|
+
opts.separator('')
|
56
|
+
opts.separator(' # Files with includes/excludes (globs)')
|
57
|
+
opts.separator(' $ pretty-git files . --path app/**/*.rb --exclude-path spec/**')
|
58
|
+
opts.separator('')
|
59
|
+
opts.separator(' # Activity bucketed by week to Markdown file')
|
60
|
+
opts.separator(' $ pretty-git activity . --time-bucket week --format md --out out/report.md')
|
61
|
+
opts.separator('')
|
62
|
+
opts.separator(' # Languages by LOC to CSV (redirect to file)')
|
63
|
+
opts.separator(' $ pretty-git languages . --metric loc --format csv > langs.csv')
|
64
|
+
opts.separator('')
|
65
|
+
opts.separator(' # Hotspots on main with a higher limit to YAML')
|
66
|
+
opts.separator(' $ pretty-git hotspots . --branch main --limit 50 --format yaml')
|
26
67
|
end
|
68
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
27
69
|
|
28
70
|
def add_repo_options(opts, options)
|
29
71
|
opts.on('--repo PATH', 'Path to git repository (default: .)') { |val| options[:repo] = val }
|
@@ -47,8 +89,14 @@ module PrettyGit
|
|
47
89
|
def add_format_output_options(opts, options)
|
48
90
|
opts.on('--format FMT', 'console|json|csv|md|yaml|xml') { |val| options[:format] = val }
|
49
91
|
opts.on('--out FILE', 'Output file path') { |val| options[:out] = val }
|
50
|
-
opts.on('--no-color', 'Disable colors in console output')
|
51
|
-
|
92
|
+
opts.on('--no-color', 'Disable colors in console output') do
|
93
|
+
options[:no_color] = true
|
94
|
+
options[:_no_color_provided] = true
|
95
|
+
end
|
96
|
+
opts.on('--theme NAME', 'console color theme: basic|bright|mono') do |val|
|
97
|
+
options[:theme] = val
|
98
|
+
options[:_theme_provided] = true
|
99
|
+
end
|
52
100
|
end
|
53
101
|
|
54
102
|
def add_metric_options(opts, options)
|
@@ -60,6 +108,7 @@ module PrettyGit
|
|
60
108
|
def add_misc_options(opts, options)
|
61
109
|
opts.on('--version', 'Show version') { options[:_version] = true }
|
62
110
|
opts.on('--help', 'Show help') { options[:_help] = true }
|
111
|
+
opts.on('--verbose', 'Verbose output (debug)') { options[:_verbose] = true }
|
63
112
|
end
|
64
113
|
|
65
114
|
def parse_limit(str)
|
@@ -75,9 +124,14 @@ module PrettyGit
|
|
75
124
|
code = handle_version_help(options, parser, out)
|
76
125
|
return code unless code.nil?
|
77
126
|
|
78
|
-
base_ok =
|
127
|
+
base_ok = valid_base?(options)
|
79
128
|
conflicts_ok = validate_conflicts(options, err)
|
80
|
-
|
129
|
+
if base_ok && conflicts_ok
|
130
|
+
early = warn_ignores(options, err)
|
131
|
+
return 0 if early == :early_exit
|
132
|
+
|
133
|
+
return nil
|
134
|
+
end
|
81
135
|
|
82
136
|
print_validation_errors(options, err)
|
83
137
|
1
|
@@ -96,19 +150,47 @@ module PrettyGit
|
|
96
150
|
end
|
97
151
|
|
98
152
|
def valid_report?(report) = REPORTS.include?(report)
|
99
|
-
def valid_theme?(theme) =
|
153
|
+
def valid_theme?(theme) = THEMES.include?(theme)
|
154
|
+
def valid_format?(fmt) = FORMATS.include?(fmt)
|
155
|
+
|
156
|
+
def valid_base?(options)
|
157
|
+
valid_report?(options[:report]) &&
|
158
|
+
valid_theme?(options[:theme]) &&
|
159
|
+
valid_metric?(options[:metric]) &&
|
160
|
+
valid_format?(options[:format])
|
161
|
+
end
|
100
162
|
|
101
163
|
def valid_metric?(metric)
|
102
164
|
metric.nil? || METRICS.include?(metric)
|
103
165
|
end
|
104
166
|
|
105
167
|
def print_validation_errors(options, err)
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
168
|
+
print_report_error(options, err)
|
169
|
+
print_theme_error(options, err)
|
170
|
+
print_format_error(options, err)
|
171
|
+
print_metric_error(options, err)
|
172
|
+
end
|
173
|
+
|
174
|
+
def print_report_error(options, err)
|
175
|
+
return if valid_report?(options[:report])
|
176
|
+
|
177
|
+
err.puts "Unknown report: #{options[:report]}."
|
178
|
+
err.puts "Supported: #{REPORTS.join(', ')}"
|
179
|
+
end
|
180
|
+
|
181
|
+
def print_theme_error(options, err)
|
182
|
+
return if valid_theme?(options[:theme])
|
183
|
+
|
184
|
+
err.puts "Unknown theme: #{options[:theme]}. Supported: #{THEMES.join(', ')}"
|
185
|
+
end
|
186
|
+
|
187
|
+
def print_format_error(options, err)
|
188
|
+
return if valid_format?(options[:format])
|
189
|
+
|
190
|
+
err.puts "Unknown format: #{options[:format]}. Supported: #{FORMATS.join(', ')}"
|
191
|
+
end
|
192
|
+
|
193
|
+
def print_metric_error(options, err)
|
112
194
|
return if valid_metric?(options[:metric])
|
113
195
|
|
114
196
|
err.puts "Unknown metric: #{options[:metric]}. Supported: #{METRICS.join(', ')}"
|
@@ -125,12 +207,45 @@ module PrettyGit
|
|
125
207
|
ok
|
126
208
|
end
|
127
209
|
|
210
|
+
# Print non-fatal warnings for flags that won't have effect with current options
|
211
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
212
|
+
def warn_ignores(options, err)
|
213
|
+
return unless err
|
214
|
+
|
215
|
+
fmt = options[:format]
|
216
|
+
return unless fmt && fmt != 'console'
|
217
|
+
|
218
|
+
err.puts "Warning: --theme has no effect when --format=#{fmt}" if options[:theme]
|
219
|
+
err.puts "Warning: --no-color has no effect when --format=#{fmt}" if options[:no_color]
|
220
|
+
|
221
|
+
# Exit early only if user explicitly provided these console-only flags
|
222
|
+
# AND no other significant options that warrant running the app were provided.
|
223
|
+
return unless options[:_theme_provided] || options[:_no_color_provided]
|
224
|
+
|
225
|
+
significant = significant_options?(options)
|
226
|
+
:early_exit unless significant
|
227
|
+
end
|
228
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
229
|
+
|
230
|
+
def significant_options?(options)
|
231
|
+
return true if options[:out]
|
232
|
+
return true if options[:metric]
|
233
|
+
return true if options[:time_bucket]
|
234
|
+
return true if options[:limit] && options[:limit] != 10
|
235
|
+
|
236
|
+
any_collection_present?(options, %i[branches authors exclude_authors paths exclude_paths])
|
237
|
+
end
|
238
|
+
|
239
|
+
def any_collection_present?(options, keys)
|
240
|
+
keys.any? { |k| options[k]&.any? }
|
241
|
+
end
|
242
|
+
|
128
243
|
def build_filters(options)
|
129
244
|
Filters.new(
|
130
245
|
repo_path: options[:repo],
|
131
246
|
branches: options[:branches],
|
132
247
|
since: options[:since],
|
133
|
-
|
248
|
+
until_at: options[:until],
|
134
249
|
authors: options[:authors],
|
135
250
|
exclude_authors: options[:exclude_authors],
|
136
251
|
paths: options[:paths],
|
@@ -141,14 +256,28 @@ module PrettyGit
|
|
141
256
|
format: options[:format],
|
142
257
|
out: options[:out],
|
143
258
|
no_color: options[:no_color],
|
144
|
-
theme: options[:theme]
|
259
|
+
theme: options[:theme],
|
260
|
+
verbose: options[:_verbose]
|
145
261
|
)
|
146
262
|
end
|
147
263
|
|
148
264
|
def execute(report, filters, options, out, err)
|
149
265
|
if options[:out]
|
150
|
-
|
151
|
-
|
266
|
+
begin
|
267
|
+
dir = File.dirname(options[:out])
|
268
|
+
FileUtils.mkdir_p(dir) unless dir.nil? || dir == '.'
|
269
|
+
File.open(options[:out], 'w') do |f|
|
270
|
+
return PrettyGit::App.new.run(report, filters, out: f, err: err)
|
271
|
+
end
|
272
|
+
rescue Errno::EISDIR
|
273
|
+
err.puts "Cannot write to: #{options[:out]} (is a directory)"
|
274
|
+
return 2
|
275
|
+
rescue Errno::EACCES
|
276
|
+
err.puts "Cannot write to: #{options[:out]} (permission denied)"
|
277
|
+
return 2
|
278
|
+
rescue Errno::ENOENT
|
279
|
+
err.puts "Cannot write to: #{options[:out]} (directory not found)"
|
280
|
+
return 2
|
152
281
|
end
|
153
282
|
end
|
154
283
|
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PrettyGit
|
4
|
+
module Constants
|
5
|
+
REPORTS = %w[
|
6
|
+
summary activity authors files heatmap languages hotspots churn ownership
|
7
|
+
].freeze
|
8
|
+
|
9
|
+
FORMATS = %w[console json csv md yaml xml].freeze
|
10
|
+
|
11
|
+
METRICS = %w[bytes files loc].freeze
|
12
|
+
|
13
|
+
THEMES = %w[basic bright mono].freeze
|
14
|
+
end
|
15
|
+
end
|
data/lib/pretty_git/filters.rb
CHANGED
@@ -1,13 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'time'
|
4
|
+
require_relative 'utils/time_utils'
|
4
5
|
|
5
6
|
module PrettyGit
|
6
7
|
Filters = Struct.new(
|
7
8
|
:repo_path,
|
8
9
|
:branches,
|
9
10
|
:since,
|
10
|
-
:
|
11
|
+
:until_at,
|
11
12
|
:authors,
|
12
13
|
:exclude_authors,
|
13
14
|
:paths,
|
@@ -19,35 +20,53 @@ module PrettyGit
|
|
19
20
|
:out,
|
20
21
|
:no_color,
|
21
22
|
:theme,
|
23
|
+
:verbose,
|
22
24
|
keyword_init: true
|
23
25
|
) do
|
24
|
-
|
25
|
-
|
26
|
+
# Backward-compat: allow initializing with `until:` keyword by remapping to :until_at
|
27
|
+
# Preserve Struct keyword_init behavior by overriding initialize instead of .new
|
28
|
+
def initialize(*args, **kwargs)
|
29
|
+
# Accept a single Hash positional argument for backward compatibility
|
30
|
+
kwargs = args.first if (kwargs.nil? || kwargs.empty?) && args.length == 1 && args.first.is_a?(Hash)
|
31
|
+
kwargs ||= {}
|
32
|
+
|
33
|
+
if kwargs.key?(:until)
|
34
|
+
Kernel.warn('[pretty-git] DEPRECATION: Filters initialized with :until. Use :until_at instead.')
|
35
|
+
kwargs = kwargs.dup
|
36
|
+
kwargs[:until_at] = kwargs.delete(:until)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Keyword-init struct: prefer keyword form consistently to keep initialize simple
|
40
|
+
super(**kwargs)
|
26
41
|
end
|
27
42
|
|
28
|
-
|
29
|
-
|
43
|
+
# Backward-compat: support filters.until and filters.until=
|
44
|
+
def until
|
45
|
+
self[:until_at]
|
46
|
+
end
|
47
|
+
|
48
|
+
def until=(val)
|
49
|
+
self[:until_at] = val
|
30
50
|
end
|
31
51
|
|
32
|
-
|
52
|
+
# Backward-compat for hash-style access used in older specs
|
53
|
+
def [](key)
|
54
|
+
key = :until_at if key == :until
|
55
|
+
super
|
56
|
+
end
|
33
57
|
|
34
|
-
|
35
|
-
|
36
|
-
|
58
|
+
def []=(key, value)
|
59
|
+
key = :until_at if key == :until
|
60
|
+
super
|
61
|
+
end
|
37
62
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
t = val.is_a?(Time) ? val : Time.parse(val.to_s)
|
46
|
-
end
|
47
|
-
t.utc.iso8601
|
48
|
-
rescue ArgumentError
|
49
|
-
raise ArgumentError, "Invalid datetime: #{val} (expected ISO8601 or YYYY-MM-DD)"
|
63
|
+
def since_iso8601
|
64
|
+
PrettyGit::Utils::TimeUtils.to_utc_iso8601(since)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Keep method name for backwards compatibility across the codebase
|
68
|
+
def until_iso8601
|
69
|
+
PrettyGit::Utils::TimeUtils.to_utc_iso8601(self[:until_at])
|
50
70
|
end
|
51
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
52
71
|
end
|
53
72
|
end
|
@@ -2,7 +2,10 @@
|
|
2
2
|
|
3
3
|
require 'open3'
|
4
4
|
require 'time'
|
5
|
+
require 'json'
|
5
6
|
require_relative '../types'
|
7
|
+
require_relative '../logger'
|
8
|
+
require_relative '../utils/path_utils'
|
6
9
|
|
7
10
|
module PrettyGit
|
8
11
|
module Git
|
@@ -17,10 +20,18 @@ module PrettyGit
|
|
17
20
|
end
|
18
21
|
|
19
22
|
# Returns Enumerator of PrettyGit::Types::Commit
|
20
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
23
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
21
24
|
def each_commit
|
22
25
|
Enumerator.new do |yld|
|
26
|
+
prof = ENV['PG_PROF'] == '1'
|
27
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
28
|
+
headers = 0
|
29
|
+
numstat_lines = 0
|
23
30
|
cmd = build_git_command
|
31
|
+
PrettyGit::Logger.verbose(
|
32
|
+
"[pretty-git] git cmd: #{cmd.join(' ')} (cwd=#{@filters.repo_path})",
|
33
|
+
@filters.verbose
|
34
|
+
)
|
24
35
|
Open3.popen3(*cmd, chdir: @filters.repo_path) do |_stdin, stdout, stderr, wait_thr|
|
25
36
|
current = nil
|
26
37
|
stdout.each_line do |line|
|
@@ -28,6 +39,7 @@ module PrettyGit
|
|
28
39
|
# Try to start a new commit from header on any line
|
29
40
|
header = start_commit_from_header(line)
|
30
41
|
if header
|
42
|
+
headers += 1 if prof
|
31
43
|
# emit previous commit if any
|
32
44
|
emit_current(yld, current)
|
33
45
|
current = header
|
@@ -36,6 +48,7 @@ module PrettyGit
|
|
36
48
|
|
37
49
|
next if line.empty?
|
38
50
|
|
51
|
+
numstat_lines += 1 if prof
|
39
52
|
append_numstat_line(current, line)
|
40
53
|
end
|
41
54
|
|
@@ -44,12 +57,36 @@ module PrettyGit
|
|
44
57
|
status = wait_thr.value
|
45
58
|
unless status.success?
|
46
59
|
err = stderr.read
|
47
|
-
|
60
|
+
error_msg = err && !err.empty? ? err : "git log failed with status #{status.exitstatus}"
|
61
|
+
# Repository errors are ArgumentError (invalid input), other git errors are StandardError
|
62
|
+
if error_msg.include?('not a git repository') || error_msg.include?('Not a git repository')
|
63
|
+
raise ArgumentError, error_msg
|
64
|
+
end
|
65
|
+
|
66
|
+
raise StandardError, error_msg
|
67
|
+
|
48
68
|
end
|
49
69
|
end
|
70
|
+
|
71
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
72
|
+
if prof
|
73
|
+
# Emit a compact profile to stderr
|
74
|
+
elapsed = (t1 - t0)
|
75
|
+
warn format(
|
76
|
+
'[pg_prof] git_provider: time=%<sec>.3fs headers=%<headers>d numstat_lines=%<num>d',
|
77
|
+
{ sec: elapsed, headers: headers, num: numstat_lines }
|
78
|
+
)
|
79
|
+
summary = {
|
80
|
+
component: 'git_provider',
|
81
|
+
time_sec: elapsed,
|
82
|
+
headers: headers,
|
83
|
+
numstat_lines: numstat_lines
|
84
|
+
}
|
85
|
+
warn("[pg_prof_json] #{summary.to_json}")
|
86
|
+
end
|
50
87
|
end
|
51
88
|
end
|
52
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
89
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
53
90
|
|
54
91
|
private
|
55
92
|
|
@@ -73,10 +110,6 @@ module PrettyGit
|
|
73
110
|
yld << commit
|
74
111
|
end
|
75
112
|
|
76
|
-
def record_separator?(line)
|
77
|
-
line == SEP_RECORD
|
78
|
-
end
|
79
|
-
|
80
113
|
def start_commit_from_header(line)
|
81
114
|
sha, author_name, author_email, authored_at, subject = line.split(SEP_FIELD, 5)
|
82
115
|
return nil unless subject
|
@@ -120,14 +153,20 @@ module PrettyGit
|
|
120
153
|
end
|
121
154
|
|
122
155
|
def add_author_and_branch_filters(args)
|
123
|
-
@filters.authors&.each
|
124
|
-
|
156
|
+
@filters.authors&.each do |a|
|
157
|
+
validate_safe_string(a, 'author')
|
158
|
+
args << "--author=#{a}"
|
159
|
+
end
|
160
|
+
# Treat branches as explicit revisions to include
|
161
|
+
@filters.branches&.each do |b|
|
162
|
+
validate_git_ref(b)
|
163
|
+
args << b
|
164
|
+
end
|
125
165
|
end
|
126
166
|
|
127
|
-
# rubocop:disable Metrics/AbcSize
|
128
167
|
def add_path_filters(args)
|
129
|
-
path_args =
|
130
|
-
exclude_args =
|
168
|
+
path_args = PrettyGit::Utils::PathUtils.normalize_globs(@filters.paths)
|
169
|
+
exclude_args = PrettyGit::Utils::PathUtils.normalize_globs(@filters.exclude_paths)
|
131
170
|
|
132
171
|
# Nothing to filter by
|
133
172
|
return if path_args.empty? && exclude_args.empty?
|
@@ -137,15 +176,14 @@ module PrettyGit
|
|
137
176
|
# If only excludes provided, include all paths first
|
138
177
|
args << '.' if path_args.empty? && !exclude_args.empty?
|
139
178
|
|
140
|
-
# Include patterns
|
179
|
+
# Include patterns (normalized)
|
141
180
|
args.concat(path_args) unless path_args.empty?
|
142
181
|
|
143
|
-
# Exclude patterns via git pathspec magic with glob
|
182
|
+
# Exclude patterns via git pathspec magic with glob (normalized)
|
144
183
|
exclude_args.each do |pat|
|
145
184
|
args << ":(exclude,glob)#{pat}"
|
146
185
|
end
|
147
186
|
end
|
148
|
-
# rubocop:enable Metrics/AbcSize
|
149
187
|
|
150
188
|
# rubocop:disable Metrics/CyclomaticComplexity
|
151
189
|
def exclude_author?(name, email)
|
@@ -158,6 +196,31 @@ module PrettyGit
|
|
158
196
|
end
|
159
197
|
end
|
160
198
|
# rubocop:enable Metrics/CyclomaticComplexity
|
199
|
+
|
200
|
+
# Validates that a string doesn't contain shell metacharacters that could be exploited
|
201
|
+
def validate_safe_string(value, name)
|
202
|
+
return if value.nil? || value.empty?
|
203
|
+
|
204
|
+
# Check for dangerous shell metacharacters
|
205
|
+
return unless value.to_s =~ /[;&|`$()<>]/
|
206
|
+
|
207
|
+
raise ArgumentError, "Invalid #{name}: contains shell metacharacters"
|
208
|
+
end
|
209
|
+
|
210
|
+
# Validates git references (branch names, tags, commit SHAs)
|
211
|
+
# Allows alphanumeric, dash, underscore, slash, dot, @, ^, ~, and :
|
212
|
+
def validate_git_ref(ref)
|
213
|
+
return if ref.nil? || ref.empty?
|
214
|
+
|
215
|
+
# Git ref naming rules are complex, but we allow a safe subset
|
216
|
+
# See: https://git-scm.com/docs/git-check-ref-format
|
217
|
+
raise ArgumentError, "Invalid git ref: #{ref}" unless ref.to_s =~ %r{\A[a-zA-Z0-9/_.\-@^~:]+\z}
|
218
|
+
|
219
|
+
# Additional safety: reject refs that look like options
|
220
|
+
return unless ref.to_s.start_with?('-')
|
221
|
+
|
222
|
+
raise ArgumentError, "Invalid git ref: #{ref} (looks like an option)"
|
223
|
+
end
|
161
224
|
end
|
162
225
|
end
|
163
226
|
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PrettyGit
|
4
|
+
# Minimal centralized logger for PrettyGit.
|
5
|
+
# Writes to stderr by default; can accept custom IO via keyword.
|
6
|
+
module Logger
|
7
|
+
module_function
|
8
|
+
|
9
|
+
def warn(msg, err: $stderr)
|
10
|
+
err.puts(msg)
|
11
|
+
end
|
12
|
+
|
13
|
+
# Convenience: only emit when enabled is truthy
|
14
|
+
def verbose(msg, enabled, err: $stderr)
|
15
|
+
return unless enabled
|
16
|
+
|
17
|
+
warn(msg, err: err)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -7,6 +7,24 @@ module PrettyGit
|
|
7
7
|
module Render
|
8
8
|
# Simple color helpers used by console components.
|
9
9
|
module Colors
|
10
|
+
# ANSI color codes for different themes
|
11
|
+
module AnsiCodes
|
12
|
+
# Title colors
|
13
|
+
BRIGHT_TITLE = '1;35' # Bright magenta
|
14
|
+
BASIC_TITLE = '1;36' # Cyan
|
15
|
+
|
16
|
+
# Header colors
|
17
|
+
BRIGHT_HEADER = '1;36' # Bright cyan
|
18
|
+
BASIC_HEADER = '1;34' # Blue
|
19
|
+
|
20
|
+
# Text styles
|
21
|
+
DIM = '2;37' # Dim white
|
22
|
+
GREEN = '32' # Green
|
23
|
+
RED = '31' # Red
|
24
|
+
YELLOW = '33' # Yellow
|
25
|
+
BOLD = '1' # Bold
|
26
|
+
end
|
27
|
+
|
10
28
|
module_function
|
11
29
|
|
12
30
|
def apply(code, text, enabled)
|
@@ -16,33 +34,33 @@ module PrettyGit
|
|
16
34
|
end
|
17
35
|
|
18
36
|
def title(text, enabled, theme = 'basic')
|
19
|
-
code = theme == 'bright' ?
|
37
|
+
code = theme == 'bright' ? AnsiCodes::BRIGHT_TITLE : AnsiCodes::BASIC_TITLE
|
20
38
|
apply(code, text, enabled)
|
21
39
|
end
|
22
40
|
|
23
41
|
def header(text, enabled, theme = 'basic')
|
24
|
-
code = theme == 'bright' ?
|
42
|
+
code = theme == 'bright' ? AnsiCodes::BRIGHT_HEADER : AnsiCodes::BASIC_HEADER
|
25
43
|
apply(code, text, enabled)
|
26
44
|
end
|
27
45
|
|
28
46
|
def dim(text, enabled)
|
29
|
-
apply(
|
47
|
+
apply(AnsiCodes::DIM, text, enabled)
|
30
48
|
end
|
31
49
|
|
32
50
|
def green(text, enabled)
|
33
|
-
apply(
|
51
|
+
apply(AnsiCodes::GREEN, text, enabled)
|
34
52
|
end
|
35
53
|
|
36
54
|
def red(text, enabled)
|
37
|
-
apply(
|
55
|
+
apply(AnsiCodes::RED, text, enabled)
|
38
56
|
end
|
39
57
|
|
40
58
|
def yellow(text, enabled)
|
41
|
-
apply(
|
59
|
+
apply(AnsiCodes::YELLOW, text, enabled)
|
42
60
|
end
|
43
61
|
|
44
62
|
def bold(text, enabled)
|
45
|
-
apply(
|
63
|
+
apply(AnsiCodes::BOLD, text, enabled)
|
46
64
|
end
|
47
65
|
end
|
48
66
|
|