pretty-git 0.1.0

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.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module PrettyGit
6
+ module Analytics
7
+ # Aggregates commits into a heatmap by day-of-week (Mon=1..Sun=7) and hour (0..23)
8
+ class Heatmap
9
+ class << self
10
+ def call(enum, filters)
11
+ grid = aggregate(enum)
12
+ items = to_items(grid)
13
+
14
+ {
15
+ report: 'heatmap',
16
+ repo_path: filters.repo_path,
17
+ period: { since: filters.since, until: filters.until },
18
+ items: items,
19
+ generated_at: Time.now.utc.iso8601
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ def aggregate(enum)
26
+ grid = Hash.new { |h, k| h[k] = Hash.new(0) } # { dow => { hour => commits } }
27
+ enum.each { |commit| tick(grid, commit) }
28
+ grid
29
+ end
30
+
31
+ def tick(grid, commit)
32
+ t = Time.parse(commit.authored_at.to_s).utc
33
+ dow = wday_mon1(t)
34
+ hour = t.hour
35
+ grid[dow][hour] += 1
36
+ end
37
+
38
+ def to_items(grid)
39
+ grid.keys.sort.flat_map do |dow|
40
+ grid[dow].keys.sort.map { |hour| { dow: dow, hour: hour, commits: grid[dow][hour] } }
41
+ end
42
+ end
43
+
44
+ def wday_mon1(time)
45
+ w = time.wday # 0..6, Sun=0
46
+ w.zero? ? 7 : w # Mon=1..Sun=7
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module PrettyGit
6
+ module Analytics
7
+ # Extension → Language mapping (common set; heuristic only)
8
+ EXT_TO_LANG = {
9
+ # Web / Script
10
+ '.rb' => 'Ruby',
11
+ '.js' => 'JavaScript', '.mjs' => 'JavaScript', '.cjs' => 'JavaScript',
12
+ '.jsx' => 'JSX', '.tsx' => 'TSX',
13
+ '.ts' => 'TypeScript',
14
+ '.py' => 'Python', '.php' => 'PHP', '.phtml' => 'PHP',
15
+ '.sh' => 'Shell', '.bash' => 'Shell', '.zsh' => 'Shell',
16
+ '.ps1' => 'PowerShell', '.psm1' => 'PowerShell',
17
+ '.bat' => 'Batchfile', '.cmd' => 'Batchfile',
18
+ # Intentionally excluding JSON (usually data, not source code)
19
+ '.yml' => 'YAML', '.yaml' => 'YAML', '.toml' => 'TOML', '.ini' => 'INI', '.xml' => 'XML',
20
+ '.html' => 'HTML', '.htm' => 'HTML', '.css' => 'CSS', '.scss' => 'SCSS', '.sass' => 'SCSS',
21
+ '.md' => 'Markdown', '.markdown' => 'Markdown',
22
+ '.vue' => 'Vue', '.svelte' => 'Svelte',
23
+
24
+ # Systems / Compiled
25
+ '.go' => 'Go', '.rs' => 'Rust', '.java' => 'Java',
26
+ '.c' => 'C', '.h' => 'C',
27
+ '.cpp' => 'C++', '.cc' => 'C++', '.cxx' => 'C++', '.hpp' => 'C++', '.hh' => 'C++',
28
+ '.m' => 'Objective-C', '.mm' => 'Objective-C', '.swift' => 'Swift',
29
+ '.kt' => 'Kotlin', '.kts' => 'Kotlin', '.scala' => 'Scala', '.groovy' => 'Groovy',
30
+ '.dart' => 'Dart', '.cs' => 'C#',
31
+
32
+ # Data / Query / Spec
33
+ '.sql' => 'SQL', '.graphql' => 'GraphQL', '.gql' => 'GraphQL', '.proto' => 'Proto',
34
+
35
+ # Misc / Scripting
36
+ '.pl' => 'Perl', '.pm' => 'Perl', '.r' => 'R', '.R' => 'R', '.lua' => 'Lua', '.hs' => 'Haskell',
37
+ '.ex' => 'Elixir', '.exs' => 'Elixir', '.erl' => 'Erlang'
38
+ }.freeze
39
+
40
+ # Filename → Language (no/varied extension)
41
+ FILENAME_TO_LANG = {
42
+ 'Makefile' => 'Makefile',
43
+ 'Dockerfile' => 'Dockerfile'
44
+ }.freeze
45
+
46
+ VENDOR_DIRS = %w[
47
+ vendor node_modules .git .bundle dist build out target coverage
48
+ .venv venv env __pycache__ .mypy_cache .pytest_cache .tox .eggs .ruff_cache
49
+ .ipynb_checkpoints
50
+ ].freeze
51
+ BINARY_EXTS = %w[
52
+ .png .jpg .jpeg .gif .svg .webp .ico .bmp
53
+ .pdf .zip .tar .gz .tgz .bz2 .7z .rar
54
+ .mp3 .ogg .wav .mp4 .mov .avi .mkv
55
+ .woff .woff2 .ttf .otf .eot
56
+ .jar .class .dll .so .dylib
57
+ .exe .bin .dat
58
+ ].freeze
59
+ # Computes language distribution by summing file sizes per language.
60
+ # Similar to GitHub Linguist approach (bytes per language).
61
+ class Languages
62
+ def self.call(_enum, filters)
63
+ repo = filters.repo_path
64
+ items = calculate(repo, include_globs: filters.paths, exclude_globs: filters.exclude_paths)
65
+ total = total_bytes(items)
66
+ items = add_percents(items, total)
67
+ items = sort_and_limit(items, filters.limit)
68
+
69
+ build_result(repo, items, total)
70
+ end
71
+
72
+ def self.calculate(repo_path, include_globs:, exclude_globs:)
73
+ by_lang = Hash.new(0)
74
+ Dir.chdir(repo_path) do
75
+ each_source_file(include_globs, exclude_globs) do |abs_path|
76
+ basename = File.basename(abs_path)
77
+ ext = File.extname(abs_path).downcase
78
+ lang = FILENAME_TO_LANG[basename] || EXT_TO_LANG[ext]
79
+ next unless lang
80
+
81
+ size = begin
82
+ File.size(abs_path)
83
+ rescue StandardError
84
+ 0
85
+ end
86
+ by_lang[lang] += size
87
+ end
88
+ end
89
+ by_lang.map { |lang, bytes| { language: lang, bytes: bytes } }
90
+ end
91
+
92
+ def self.each_source_file(include_globs, exclude_globs)
93
+ # Build list of files under repo respecting includes/excludes
94
+ all = Dir.glob('**/*', File::FNM_DOTMATCH).select { |p| File.file?(p) }
95
+ files = all.reject { |p| vendor_path?(p) || binary_ext?(p) }
96
+ files = filter_includes(files, include_globs)
97
+ files = filter_excludes(files, exclude_globs)
98
+ files.each { |rel| yield File.expand_path(rel) }
99
+ end
100
+
101
+ def self.filter_includes(files, globs)
102
+ globs = Array(globs).compact
103
+ return files if globs.empty?
104
+
105
+ allowed = globs.flat_map { |g| Dir.glob(g) }
106
+ files.select { |f| allowed.include?(f) }
107
+ end
108
+
109
+ def self.filter_excludes(files, globs)
110
+ globs = Array(globs).compact
111
+ return files if globs.empty?
112
+
113
+ blocked = globs.flat_map { |g| Dir.glob(g) }
114
+ files.reject { |f| blocked.include?(f) }
115
+ end
116
+
117
+ def self.vendor_path?(path)
118
+ parts = path.split(File::SEPARATOR)
119
+ parts.any? { |seg| VENDOR_DIRS.include?(seg) }
120
+ end
121
+
122
+ def self.binary_ext?(path)
123
+ BINARY_EXTS.include?(File.extname(path).downcase)
124
+ end
125
+
126
+ def self.total_bytes(items)
127
+ items.sum { |item| item[:bytes] }
128
+ end
129
+
130
+ def self.add_percents(items, total)
131
+ return items.map { |item| item.merge(percent: 0.0) } unless total.positive?
132
+
133
+ items.map { |item| item.merge(percent: (item[:bytes] * 100.0 / total)) }
134
+ end
135
+
136
+ def self.sort_and_limit(items, limit)
137
+ sorted = items.sort_by { |item| [-item[:percent], item[:language]] }
138
+ lim = limit.to_i
139
+ return sorted if lim <= 0
140
+
141
+ sorted.first(lim)
142
+ end
143
+
144
+ def self.build_result(repo, items, total)
145
+ {
146
+ report: 'languages',
147
+ repo_path: repo,
148
+ generated_at: Time.now.utc.iso8601,
149
+ totals: { bytes: total, languages: items.size },
150
+ items: items
151
+ }
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrettyGit
4
+ module Analytics
5
+ # Summary analytics for repository activity.
6
+ # Aggregates totals, top authors, and top files based on streamed commits.
7
+ class Summary
8
+ class << self
9
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
10
+ def call(enum, filters)
11
+ totals = { commits: 0, authors: 0, additions: 0, deletions: 0 }
12
+ per_author = Hash.new { |h, k| h[k] = { commits: 0, additions: 0, deletions: 0, email: nil } }
13
+ per_file = Hash.new { |h, k| h[k] = { commits: 0, additions: 0, deletions: 0 } }
14
+
15
+ enum.each do |c|
16
+ totals[:commits] += 1
17
+ totals[:additions] += c.additions.to_i
18
+ totals[:deletions] += c.deletions.to_i
19
+
20
+ key = c.author_name.to_s
21
+ pa = per_author[key]
22
+ pa[:email] ||= c.author_email
23
+ pa[:commits] += 1
24
+ pa[:additions] += c.additions.to_i
25
+ pa[:deletions] += c.deletions.to_i
26
+
27
+ c.files&.each do |f|
28
+ pf = per_file[f.path]
29
+ pf[:commits] += 1
30
+ pf[:additions] += f.additions.to_i
31
+ pf[:deletions] += f.deletions.to_i
32
+ end
33
+ end
34
+
35
+ totals[:authors] = per_author.size
36
+
37
+ limit = normalize_limit(filters.limit)
38
+
39
+ top_authors = per_author.map do |name, v|
40
+ {
41
+ author: name,
42
+ author_email: v[:email],
43
+ commits: v[:commits],
44
+ additions: v[:additions],
45
+ deletions: v[:deletions],
46
+ avg_commit_size: v[:commits].zero? ? 0 : ((v[:additions] + v[:deletions]).to_f / v[:commits]).round
47
+ }
48
+ end
49
+ top_authors = sort_and_limit(top_authors, limit, by_path: false)
50
+
51
+ top_files = per_file.map do |path, v|
52
+ {
53
+ path: path,
54
+ commits: v[:commits],
55
+ additions: v[:additions],
56
+ deletions: v[:deletions],
57
+ changes: v[:additions] + v[:deletions]
58
+ }
59
+ end
60
+ top_files = sort_and_limit(top_files, limit, by_path: true)
61
+
62
+ {
63
+ report: 'summary',
64
+ repo_path: File.expand_path(filters.repo_path),
65
+ period: { since: filters.since_iso8601, until: filters.until_iso8601 },
66
+ totals: totals,
67
+ top_authors: top_authors,
68
+ top_files: top_files,
69
+ generated_at: Time.now.utc.iso8601
70
+ }
71
+ end
72
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
73
+
74
+ private
75
+
76
+ def normalize_limit(raw)
77
+ return nil if raw.nil?
78
+ return nil if raw == 'all'
79
+
80
+ n = raw.to_i
81
+ n <= 0 ? nil : n
82
+ end
83
+
84
+ def sort_and_limit(arr, limit, by_path: false)
85
+ sorted = arr.sort_by do |h|
86
+ primary = by_path ? h[:changes] : (h[:additions] + h[:deletions])
87
+ [-primary, -h[:commits], (by_path ? h[:path].to_s : h[:author].to_s)]
88
+ end
89
+ limit ? sorted.first(limit) : sorted
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'git/provider'
4
+ require_relative 'analytics/summary'
5
+ require_relative 'analytics/activity'
6
+ require_relative 'analytics/files'
7
+ require_relative 'analytics/authors'
8
+ require_relative 'analytics/heatmap'
9
+ require_relative 'analytics/languages'
10
+ require_relative 'render/json_renderer'
11
+ require_relative 'render/console_renderer'
12
+ require_relative 'render/csv_renderer'
13
+ require_relative 'render/markdown_renderer'
14
+ require_relative 'render/yaml_renderer'
15
+ require_relative 'render/xml_renderer'
16
+
17
+ module PrettyGit
18
+ # Orchestrates running a report using provider, analytics and renderer.
19
+ class App
20
+ def run(report, filters, out: $stdout, err: $stderr)
21
+ _err = err # unused for now, kept for future extensibility
22
+
23
+ ensure_repo!(filters.repo_path)
24
+
25
+ provider = Git::Provider.new(filters)
26
+ enum = provider.each_commit
27
+
28
+ result = analytics_for(report, enum, filters)
29
+
30
+ render(report, result, filters, out)
31
+ 0
32
+ end
33
+
34
+ private
35
+
36
+ def ensure_repo!(path)
37
+ return if File.directory?(File.join(path, '.git'))
38
+
39
+ raise ArgumentError, "Not a git repository: #{path}"
40
+ end
41
+
42
+ def render(report, result, filters, io)
43
+ renderer_for(filters, io).call(report, result, filters)
44
+ end
45
+
46
+ def renderer_for(filters, io)
47
+ case filters.format
48
+ when 'console'
49
+ use_color = !filters.no_color && filters.theme != 'mono'
50
+ Render::ConsoleRenderer.new(io: io, color: use_color, theme: filters.theme)
51
+ when 'csv'
52
+ Render::CsvRenderer.new(io: io)
53
+ when 'md'
54
+ Render::MarkdownRenderer.new(io: io)
55
+ when 'yaml'
56
+ Render::YamlRenderer.new(io: io)
57
+ when 'xml'
58
+ Render::XmlRenderer.new(io: io)
59
+ else
60
+ Render::JsonRenderer.new(io: io)
61
+ end
62
+ end
63
+
64
+ def analytics_for(report, enum, filters)
65
+ case report
66
+ when 'summary'
67
+ Analytics::Summary.call(enum, filters)
68
+ when 'activity'
69
+ Analytics::Activity.call(enum, filters)
70
+ when 'authors'
71
+ Analytics::Authors.call(enum, filters)
72
+ when 'files'
73
+ Analytics::Files.call(enum, filters)
74
+ when 'heatmap'
75
+ Analytics::Heatmap.call(enum, filters)
76
+ when 'languages'
77
+ Analytics::Languages.call(enum, filters)
78
+ else
79
+ raise ArgumentError, "Unknown report: #{report}"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative 'version'
5
+ require_relative 'filters'
6
+ require_relative 'app'
7
+ require_relative 'cli_helpers'
8
+
9
+ module PrettyGit
10
+ # Command-line interface entry point.
11
+ class CLI
12
+ SUPPORTED_REPORTS = %w[summary activity authors files heatmap].freeze
13
+ SUPPORTED_FORMATS = %w[console json csv md yaml xml].freeze
14
+
15
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
16
+ def self.run(argv = ARGV, out: $stdout, err: $stderr)
17
+ options = {
18
+ report: 'summary',
19
+ repo: '.',
20
+ branches: [],
21
+ authors: [],
22
+ exclude_authors: [],
23
+ paths: [],
24
+ exclude_paths: [],
25
+ time_bucket: 'week',
26
+ limit: 10,
27
+ format: 'console',
28
+ out: nil,
29
+ no_color: false,
30
+ theme: 'basic',
31
+ _version: false,
32
+ _help: false
33
+ }
34
+
35
+ parser = OptionParser.new
36
+ CLIHelpers.configure_parser(parser, options)
37
+
38
+ # REPORT positional arg
39
+ options[:report] = argv.shift if argv[0] && argv[0] !~ /^-/
40
+
41
+ begin
42
+ parser.parse!(argv)
43
+ rescue OptionParser::InvalidOption => e
44
+ err.puts e.message
45
+ err.puts parser
46
+ return 1
47
+ end
48
+
49
+ exit_code = CLIHelpers.validate_and_maybe_exit(options, parser, out, err)
50
+ return exit_code if exit_code
51
+
52
+ filters = CLIHelpers.build_filters(options)
53
+ CLIHelpers.execute(options[:report], filters, options, out, err)
54
+ rescue ArgumentError => e
55
+ err.puts e.message
56
+ 1
57
+ rescue StandardError => e
58
+ err.puts e.message
59
+ 2
60
+ end
61
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
62
+ end
63
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative 'filters'
5
+ require_relative 'app'
6
+
7
+ module PrettyGit
8
+ # Helpers extracted from `PrettyGit::CLI` to keep the CLI class small
9
+ # and RuboCop-compliant. Provides parser configuration and execution utilities.
10
+ module CLIHelpers
11
+ REPORTS = %w[summary activity authors files heatmap languages].freeze
12
+ FORMATS = %w[console json csv md yaml xml].freeze
13
+
14
+ module_function
15
+
16
+ def configure_parser(opts, options)
17
+ opts.banner = 'Usage: pretty-git [REPORT] [options]'
18
+ add_repo_options(opts, options)
19
+ add_time_author_options(opts, options)
20
+ add_path_limit_options(opts, options)
21
+ add_format_output_options(opts, options)
22
+ add_misc_options(opts, options)
23
+ end
24
+
25
+ def add_repo_options(opts, options)
26
+ opts.on('--repo PATH', 'Path to git repository (default: .)') { |val| options[:repo] = val }
27
+ opts.on('--branch NAME', 'Branch (repeatable)') { |val| options[:branches] << val }
28
+ end
29
+
30
+ def add_time_author_options(opts, options)
31
+ opts.on('--since DATETIME', 'Start of period (ISO8601 or YYYY-MM-DD)') { |val| options[:since] = val }
32
+ opts.on('--until DATETIME', 'End of period (inclusive)') { |val| options[:until] = val }
33
+ opts.on('--author VAL', 'Include author (repeatable)') { |val| options[:authors] << val }
34
+ opts.on('--exclude-author VAL', 'Exclude author (repeatable)') { |val| options[:exclude_authors] << val }
35
+ opts.on('--time-bucket BUCKET', 'day|week|month (for activity)') { |val| options[:time_bucket] = val }
36
+ end
37
+
38
+ def add_path_limit_options(opts, options)
39
+ opts.on('--path GLOB', 'Include path/glob (repeatable)') { |val| options[:paths] << val }
40
+ opts.on('--exclude-path GLOB', 'Exclude path/glob (repeatable)') { |val| options[:exclude_paths] << val }
41
+ opts.on('--limit N', 'Top limit (0/all = unlimited)') { |val| options[:limit] = parse_limit(val) }
42
+ end
43
+
44
+ def add_format_output_options(opts, options)
45
+ opts.on('--format FMT', 'console|json|csv|md|yaml|xml') { |val| options[:format] = val }
46
+ opts.on('--out FILE', 'Output file path') { |val| options[:out] = val }
47
+ opts.on('--no-color', 'Disable colors in console output') { options[:no_color] = true }
48
+ opts.on('--theme NAME', 'console color theme: basic|bright|mono') { |val| options[:theme] = val }
49
+ end
50
+
51
+ def add_misc_options(opts, options)
52
+ opts.on('--version', 'Show version') { options[:_version] = true }
53
+ opts.on('--help', 'Show help') { options[:_help] = true }
54
+ end
55
+
56
+ def parse_limit(str)
57
+ s = str.to_s.strip
58
+ return 0 if s.casecmp('all').zero?
59
+
60
+ Integer(s)
61
+ rescue ArgumentError
62
+ raise ArgumentError, "Invalid --limit: expected integer or 'all'"
63
+ end
64
+
65
+ def validate_and_maybe_exit(options, parser, out, err)
66
+ code = handle_version_help(options, parser, out)
67
+ return code unless code.nil?
68
+
69
+ return nil if valid_report?(options[:report]) && valid_theme?(options[:theme])
70
+
71
+ print_validation_errors(options, err)
72
+ 1
73
+ end
74
+
75
+ def handle_version_help(options, parser, out)
76
+ if options[:_version]
77
+ out.puts PrettyGit::VERSION
78
+ return 0
79
+ end
80
+ if options[:_help]
81
+ out.puts parser
82
+ return 0
83
+ end
84
+ nil
85
+ end
86
+
87
+ def valid_report?(report) = REPORTS.include?(report)
88
+ def valid_theme?(theme) = %w[basic bright mono].include?(theme)
89
+
90
+ def print_validation_errors(options, err)
91
+ supported = REPORTS.join(', ')
92
+ unless valid_report?(options[:report])
93
+ err.puts "Unknown report: #{options[:report]}."
94
+ err.puts "Supported: #{supported}"
95
+ end
96
+ return if valid_theme?(options[:theme])
97
+
98
+ err.puts "Unknown theme: #{options[:theme]}. Supported: basic, bright, mono"
99
+ end
100
+
101
+ def build_filters(options)
102
+ Filters.new(
103
+ repo_path: options[:repo],
104
+ branches: options[:branches],
105
+ since: options[:since],
106
+ until: options[:until],
107
+ authors: options[:authors],
108
+ exclude_authors: options[:exclude_authors],
109
+ paths: options[:paths],
110
+ exclude_paths: options[:exclude_paths],
111
+ time_bucket: options[:time_bucket],
112
+ limit: options[:limit],
113
+ format: options[:format],
114
+ out: options[:out],
115
+ no_color: options[:no_color],
116
+ theme: options[:theme]
117
+ )
118
+ end
119
+
120
+ def execute(report, filters, options, out, err)
121
+ if options[:out]
122
+ File.open(options[:out], 'w') do |f|
123
+ return PrettyGit::App.new.run(report, filters, out: f, err: err)
124
+ end
125
+ end
126
+
127
+ PrettyGit::App.new.run(report, filters, out: out, err: err)
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module PrettyGit
6
+ Filters = Struct.new(
7
+ :repo_path,
8
+ :branches,
9
+ :since,
10
+ :until,
11
+ :authors,
12
+ :exclude_authors,
13
+ :paths,
14
+ :exclude_paths,
15
+ :time_bucket,
16
+ :limit,
17
+ :format,
18
+ :out,
19
+ :no_color,
20
+ :theme,
21
+ keyword_init: true
22
+ ) do
23
+ def since_iso8601
24
+ time_to_iso8601(since)
25
+ end
26
+
27
+ def until_iso8601
28
+ time_to_iso8601(self[:until])
29
+ end
30
+
31
+ private
32
+
33
+ def time_to_iso8601(val)
34
+ return nil if val.nil? || val.to_s.strip.empty?
35
+
36
+ t = val.is_a?(Time) ? val : Time.parse(val.to_s)
37
+ t = t.getlocal if t.utc_offset.nil?
38
+ t.utc.iso8601
39
+ rescue ArgumentError
40
+ raise ArgumentError, "Invalid datetime: #{val} (expected ISO8601 or YYYY-MM-DD)"
41
+ end
42
+ end
43
+ end