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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +413 -0
- data/bin/pretty-git +7 -0
- data/lib/pretty_git/analytics/activity.rb +82 -0
- data/lib/pretty_git/analytics/authors.rb +64 -0
- data/lib/pretty_git/analytics/files.rb +73 -0
- data/lib/pretty_git/analytics/heatmap.rb +51 -0
- data/lib/pretty_git/analytics/languages.rb +155 -0
- data/lib/pretty_git/analytics/summary.rb +94 -0
- data/lib/pretty_git/app.rb +83 -0
- data/lib/pretty_git/cli.rb +63 -0
- data/lib/pretty_git/cli_helpers.rb +130 -0
- data/lib/pretty_git/filters.rb +43 -0
- data/lib/pretty_git/git/provider.rb +134 -0
- data/lib/pretty_git/render/console_renderer.rb +280 -0
- data/lib/pretty_git/render/csv_renderer.rb +44 -0
- data/lib/pretty_git/render/json_renderer.rb +18 -0
- data/lib/pretty_git/render/languages_section.rb +47 -0
- data/lib/pretty_git/render/markdown_renderer.rb +68 -0
- data/lib/pretty_git/render/terminal_width.rb +38 -0
- data/lib/pretty_git/render/xml_renderer.rb +43 -0
- data/lib/pretty_git/render/yaml_renderer.rb +34 -0
- data/lib/pretty_git/types.rb +14 -0
- data/lib/pretty_git/version.rb +5 -0
- data/lib/pretty_git.rb +7 -0
- metadata +99 -0
@@ -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
|