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.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'open3'
3
4
  require_relative 'git/provider'
4
5
  require_relative 'analytics/summary'
5
6
  require_relative 'analytics/activity'
@@ -37,7 +38,9 @@ module PrettyGit
37
38
  private
38
39
 
39
40
  def ensure_repo!(path)
40
- return if File.directory?(File.join(path, '.git'))
41
+ # Use git to reliably detect work-trees/worktrees/bare repos
42
+ stdout, _stderr, status = Open3.capture3('git', 'rev-parse', '--is-inside-work-tree', chdir: path)
43
+ return if status.success? && stdout.to_s.strip == 'true'
41
44
 
42
45
  raise ArgumentError, "Not a git repository: #{path}"
43
46
  end
@@ -47,21 +50,25 @@ module PrettyGit
47
50
  end
48
51
 
49
52
  def renderer_for(filters, io)
50
- case filters.format
51
- when 'console'
52
- use_color = !filters.no_color && filters.theme != 'mono'
53
- Render::ConsoleRenderer.new(io: io, color: use_color, theme: filters.theme)
54
- when 'csv'
55
- Render::CsvRenderer.new(io: io)
56
- when 'md'
57
- Render::MarkdownRenderer.new(io: io)
58
- when 'yaml'
59
- Render::YamlRenderer.new(io: io)
60
- when 'xml'
61
- Render::XmlRenderer.new(io: io)
62
- else
63
- Render::JsonRenderer.new(io: io)
53
+ if filters.format == 'console'
54
+ return Render::ConsoleRenderer.new(
55
+ io: io,
56
+ color: !filters.no_color && filters.theme != 'mono',
57
+ theme: filters.theme
58
+ )
64
59
  end
60
+
61
+ dispatch = {
62
+ 'csv' => Render::CsvRenderer,
63
+ 'md' => Render::MarkdownRenderer,
64
+ 'yaml' => Render::YamlRenderer,
65
+ 'xml' => Render::XmlRenderer,
66
+ 'json' => Render::JsonRenderer
67
+ }
68
+ klass = dispatch[filters.format]
69
+ raise ArgumentError, "Unknown format: #{filters.format}" unless klass
70
+
71
+ klass.new(io: io)
65
72
  end
66
73
 
67
74
  def analytics_for(report, enum, filters)
@@ -9,10 +9,7 @@ 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
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
12
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
16
13
  def self.run(argv = ARGV, out: $stdout, err: $stderr)
17
14
  options = {
18
15
  report: 'summary',
@@ -22,12 +19,13 @@ module PrettyGit
22
19
  exclude_authors: [],
23
20
  paths: [],
24
21
  exclude_paths: [],
25
- time_bucket: 'week',
22
+ time_bucket: nil,
26
23
  limit: 10,
27
24
  format: 'console',
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
  }
@@ -46,6 +44,9 @@ module PrettyGit
46
44
  return 1
47
45
  end
48
46
 
47
+ # REPO positional arg (after REPORT), if still present and not an option
48
+ options[:repo] = argv.shift if argv[0] && argv[0] !~ /^-/
49
+
49
50
  exit_code = CLIHelpers.validate_and_maybe_exit(options, parser, out, err)
50
51
  return exit_code if exit_code
51
52
 
@@ -58,6 +59,6 @@ module PrettyGit
58
59
  err.puts e.message
59
60
  2
60
61
  end
61
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
62
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
62
63
  end
63
64
  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 = %w[summary activity authors files heatmap languages hotspots churn ownership].freeze
13
- FORMATS = %w[console json csv md yaml xml].freeze
14
- METRICS = %w[bytes files loc].freeze
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 = 'Usage: pretty-git [REPORT] [options]'
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') { options[:no_color] = true }
51
- opts.on('--theme NAME', 'console color theme: basic|bright|mono') { |val| options[:theme] = val }
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,7 +124,14 @@ module PrettyGit
75
124
  code = handle_version_help(options, parser, out)
76
125
  return code unless code.nil?
77
126
 
78
- return nil if valid_report?(options[:report]) && valid_theme?(options[:theme]) && valid_metric?(options[:metric])
127
+ base_ok = valid_base?(options)
128
+ conflicts_ok = validate_conflicts(options, err)
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
79
135
 
80
136
  print_validation_errors(options, err)
81
137
  1
@@ -94,30 +150,102 @@ module PrettyGit
94
150
  end
95
151
 
96
152
  def valid_report?(report) = REPORTS.include?(report)
97
- def valid_theme?(theme) = %w[basic bright mono].include?(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
98
162
 
99
163
  def valid_metric?(metric)
100
164
  metric.nil? || METRICS.include?(metric)
101
165
  end
102
166
 
103
167
  def print_validation_errors(options, err)
104
- supported = REPORTS.join(', ')
105
- unless valid_report?(options[:report])
106
- err.puts "Unknown report: #{options[:report]}."
107
- err.puts "Supported: #{supported}"
108
- end
109
- err.puts "Unknown theme: #{options[:theme]}. Supported: basic, bright, mono" unless valid_theme?(options[:theme])
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)
110
194
  return if valid_metric?(options[:metric])
111
195
 
112
196
  err.puts "Unknown metric: #{options[:metric]}. Supported: #{METRICS.join(', ')}"
113
197
  end
114
198
 
199
+ # Returns true when flags are consistent; otherwise prints errors and returns false
200
+ def validate_conflicts(options, err)
201
+ ok = true
202
+ if options[:metric] && options[:report] != 'languages'
203
+ err.puts "--metric is only supported for 'languages' report"
204
+ ok = false
205
+ end
206
+ # time_bucket is accepted by multiple reports historically; do not enforce here.
207
+ ok
208
+ end
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
+
115
243
  def build_filters(options)
116
244
  Filters.new(
117
245
  repo_path: options[:repo],
118
246
  branches: options[:branches],
119
247
  since: options[:since],
120
- until: options[:until],
248
+ until_at: options[:until],
121
249
  authors: options[:authors],
122
250
  exclude_authors: options[:exclude_authors],
123
251
  paths: options[:paths],
@@ -128,14 +256,25 @@ module PrettyGit
128
256
  format: options[:format],
129
257
  out: options[:out],
130
258
  no_color: options[:no_color],
131
- theme: options[:theme]
259
+ theme: options[:theme],
260
+ verbose: options[:_verbose]
132
261
  )
133
262
  end
134
263
 
135
264
  def execute(report, filters, options, out, err)
136
265
  if options[:out]
137
- File.open(options[:out], 'w') do |f|
138
- return PrettyGit::App.new.run(report, filters, out: f, err: err)
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::EACCES
273
+ err.puts "Cannot write to: #{options[:out]} (permission denied)"
274
+ return 2
275
+ rescue Errno::ENOENT
276
+ err.puts "Cannot write to: #{options[:out]} (directory not found)"
277
+ return 2
139
278
  end
140
279
  end
141
280
 
@@ -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
@@ -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
- :until,
11
+ :until_at,
11
12
  :authors,
12
13
  :exclude_authors,
13
14
  :paths,
@@ -19,26 +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
- def since_iso8601
25
- time_to_iso8601(since)
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
- def until_iso8601
29
- time_to_iso8601(self[:until])
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
- private
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
57
+
58
+ def []=(key, value)
59
+ key = :until_at if key == :until
60
+ super
61
+ end
33
62
 
34
- def time_to_iso8601(val)
35
- return nil if val.nil? || val.to_s.strip.empty?
63
+ def since_iso8601
64
+ PrettyGit::Utils::TimeUtils.to_utc_iso8601(since)
65
+ end
36
66
 
37
- t = val.is_a?(Time) ? val : Time.parse(val.to_s)
38
- t = t.getlocal if t.utc_offset.nil?
39
- t.utc.iso8601
40
- rescue ArgumentError
41
- raise ArgumentError, "Invalid datetime: #{val} (expected ISO8601 or YYYY-MM-DD)"
67
+ # Keep method name for backwards compatibility across the codebase
68
+ def until_iso8601
69
+ PrettyGit::Utils::TimeUtils.to_utc_iso8601(self[:until_at])
42
70
  end
43
71
  end
44
72
  end
@@ -2,11 +2,15 @@
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
9
12
  # Streams commits from git CLI using `git log --numstat` and parses them.
13
+ # rubocop:disable Metrics/ClassLength
10
14
  class Provider
11
15
  SEP_RECORD = "\x1E" # record separator
12
16
  SEP_FIELD = "\x1F" # unit separator
@@ -16,10 +20,18 @@ module PrettyGit
16
20
  end
17
21
 
18
22
  # Returns Enumerator of PrettyGit::Types::Commit
19
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
23
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
20
24
  def each_commit
21
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
22
30
  cmd = build_git_command
31
+ PrettyGit::Logger.verbose(
32
+ "[pretty-git] git cmd: #{cmd.join(' ')} (cwd=#{@filters.repo_path})",
33
+ @filters.verbose
34
+ )
23
35
  Open3.popen3(*cmd, chdir: @filters.repo_path) do |_stdin, stdout, stderr, wait_thr|
24
36
  current = nil
25
37
  stdout.each_line do |line|
@@ -27,6 +39,7 @@ module PrettyGit
27
39
  # Try to start a new commit from header on any line
28
40
  header = start_commit_from_header(line)
29
41
  if header
42
+ headers += 1 if prof
30
43
  # emit previous commit if any
31
44
  emit_current(yld, current)
32
45
  current = header
@@ -35,6 +48,7 @@ module PrettyGit
35
48
 
36
49
  next if line.empty?
37
50
 
51
+ numstat_lines += 1 if prof
38
52
  append_numstat_line(current, line)
39
53
  end
40
54
 
@@ -46,9 +60,26 @@ module PrettyGit
46
60
  raise StandardError, (err && !err.empty? ? err : "git log failed with status #{status.exitstatus}")
47
61
  end
48
62
  end
63
+
64
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+ if prof
66
+ # Emit a compact profile to stderr
67
+ elapsed = (t1 - t0)
68
+ warn format(
69
+ '[pg_prof] git_provider: time=%<sec>.3fs headers=%<headers>d numstat_lines=%<num>d',
70
+ { sec: elapsed, headers: headers, num: numstat_lines }
71
+ )
72
+ summary = {
73
+ component: 'git_provider',
74
+ time_sec: elapsed,
75
+ headers: headers,
76
+ numstat_lines: numstat_lines
77
+ }
78
+ warn("[pg_prof_json] #{summary.to_json}")
79
+ end
49
80
  end
50
81
  end
51
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
82
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
52
83
 
53
84
  private
54
85
 
@@ -57,7 +88,7 @@ module PrettyGit
57
88
 
58
89
  additions = current[:files].sum(&:additions)
59
90
  deletions = current[:files].sum(&:deletions)
60
- yld << Types::Commit.new(
91
+ commit = Types::Commit.new(
61
92
  sha: current[:sha],
62
93
  author_name: current[:author_name],
63
94
  author_email: current[:author_email],
@@ -67,10 +98,9 @@ module PrettyGit
67
98
  deletions: deletions,
68
99
  files: current[:files]
69
100
  )
70
- end
101
+ return if exclude_author?(commit.author_name, commit.author_email)
71
102
 
72
- def record_separator?(line)
73
- line == SEP_RECORD
103
+ yld << commit
74
104
  end
75
105
 
76
106
  def start_commit_from_header(line)
@@ -117,16 +147,43 @@ module PrettyGit
117
147
 
118
148
  def add_author_and_branch_filters(args)
119
149
  @filters.authors&.each { |a| args << "--author=#{a}" }
120
- @filters.branches&.each { |b| args << "--branches=#{b}" }
150
+ # Treat branches as explicit revisions to include
151
+ @filters.branches&.each { |b| args << b }
121
152
  end
122
153
 
123
154
  def add_path_filters(args)
124
- path_args = Array(@filters.paths).compact
125
- return if path_args.empty?
155
+ path_args = PrettyGit::Utils::PathUtils.normalize_globs(@filters.paths)
156
+ exclude_args = PrettyGit::Utils::PathUtils.normalize_globs(@filters.exclude_paths)
157
+
158
+ # Nothing to filter by
159
+ return if path_args.empty? && exclude_args.empty?
126
160
 
127
161
  args << '--'
128
- args.concat(path_args)
162
+
163
+ # If only excludes provided, include all paths first
164
+ args << '.' if path_args.empty? && !exclude_args.empty?
165
+
166
+ # Include patterns (normalized)
167
+ args.concat(path_args) unless path_args.empty?
168
+
169
+ # Exclude patterns via git pathspec magic with glob (normalized)
170
+ exclude_args.each do |pat|
171
+ args << ":(exclude,glob)#{pat}"
172
+ end
173
+ end
174
+
175
+ # rubocop:disable Metrics/CyclomaticComplexity
176
+ def exclude_author?(name, email)
177
+ patterns = Array(@filters.exclude_authors).compact
178
+ return false if patterns.empty?
179
+
180
+ patterns.any? do |pat|
181
+ pn = pat.to_s
182
+ name&.downcase&.include?(pn.downcase) || email&.downcase&.include?(pn.downcase)
183
+ end
129
184
  end
185
+ # rubocop:enable Metrics/CyclomaticComplexity
130
186
  end
131
187
  end
132
188
  end
189
+ # rubocop:enable Metrics/ClassLength
@@ -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
@@ -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],