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.
@@ -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 StandardError => e
61
- err.puts e.message
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 = %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] [REPO] [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,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 = valid_report?(options[:report]) && valid_theme?(options[:theme]) && valid_metric?(options[:metric])
127
+ base_ok = valid_base?(options)
79
128
  conflicts_ok = validate_conflicts(options, err)
80
- return nil if base_ok && conflicts_ok
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) = %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
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
- supported = REPORTS.join(', ')
107
- unless valid_report?(options[:report])
108
- err.puts "Unknown report: #{options[:report]}."
109
- err.puts "Supported: #{supported}"
110
- end
111
- 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)
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
- until: options[:until],
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
- File.open(options[:out], 'w') do |f|
151
- 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::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
@@ -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,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
- 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
33
57
 
34
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
35
- def time_to_iso8601(val)
36
- return nil if val.nil? || val.to_s.strip.empty?
58
+ def []=(key, value)
59
+ key = :until_at if key == :until
60
+ super
61
+ end
37
62
 
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
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
- raise StandardError, (err && !err.empty? ? err : "git log failed with status #{status.exitstatus}")
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 { |a| args << "--author=#{a}" }
124
- @filters.branches&.each { |b| args << "--branches=#{b}" }
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 = Array(@filters.paths).compact
130
- exclude_args = Array(@filters.exclude_paths).compact
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 as-is
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' ? '1;35' : '1;36'
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' ? '1;36' : '1;34'
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('2;37', text, enabled)
47
+ apply(AnsiCodes::DIM, text, enabled)
30
48
  end
31
49
 
32
50
  def green(text, enabled)
33
- apply('32', text, enabled)
51
+ apply(AnsiCodes::GREEN, text, enabled)
34
52
  end
35
53
 
36
54
  def red(text, enabled)
37
- apply('31', text, enabled)
55
+ apply(AnsiCodes::RED, text, enabled)
38
56
  end
39
57
 
40
58
  def yellow(text, enabled)
41
- apply('33', text, enabled)
59
+ apply(AnsiCodes::YELLOW, text, enabled)
42
60
  end
43
61
 
44
62
  def bold(text, enabled)
45
- apply('1', text, enabled)
63
+ apply(AnsiCodes::BOLD, text, enabled)
46
64
  end
47
65
  end
48
66