git_fame 2.5.1 → 3.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/enumerable"
4
+ require "tty-option"
5
+ require "tty-spinner"
6
+
7
+ module GitFame
8
+ class Command
9
+ include TTY::Option
10
+ using Extension
11
+
12
+ usage do
13
+ program "git"
14
+ command "fame"
15
+ desc "GitFame is a tool to generate a contributor list from git history"
16
+ example "Include commits made since 2010", "git fame --after 2010-01-01"
17
+ example "Include commits made before 2015", "git fame --before 2015-01-01"
18
+ example "Include commits made since 2010 and before 2015", "git fame --after 2010-01-01 --before 2015-01-01"
19
+ example "Only changes made to the main branch", "git fame --branch main"
20
+ example "Only ruby and javascript files", "git fame --extensions .rb .js"
21
+ example "Exclude spec files and the README", "git fame --exclude */**/*_spec.rb README.md"
22
+ example "Only spec files and markdown files", "git fame --include */**/*_spec.rb */**/*.md"
23
+ example "A parent directory of the current directory", "git fame ../other/git/repo"
24
+ end
25
+
26
+ option :log_level do
27
+ permit ["debug", "info", "warn", "error", "fatal"]
28
+ long "--log-level [LEVEL]"
29
+ desc "Log level"
30
+ end
31
+
32
+ option :exclude do
33
+ desc "Exclude files matching the given glob pattern"
34
+ long "--exclude [GLOB]"
35
+ arity zero_or_more
36
+ short "-E [BLOB]"
37
+ convert :list
38
+ end
39
+
40
+ option :include do
41
+ desc "Include files matching the given glob pattern"
42
+ long "--include [GLOB]"
43
+ arity zero_or_more
44
+ short "-I [BLOB]"
45
+ convert :list
46
+ end
47
+
48
+ option :extensions do
49
+ desc "File extensions to be included starting with a period"
50
+ arity zero_or_more
51
+ long "--extensions [EXT]"
52
+ short "-ex [EXT]"
53
+ convert :list
54
+
55
+ validate -> input do
56
+ input.match(/\.\w+/)
57
+ end
58
+ end
59
+
60
+ option :before do
61
+ desc "Only changes made after this date"
62
+ long "--before [DATE]"
63
+ short "-B [DATE]"
64
+ validate -> input do
65
+ Types::Params::DateTime.valid?(input)
66
+ end
67
+ end
68
+
69
+ option :after do
70
+ desc "Only changes made before this date"
71
+ long "--after [DATE]"
72
+ short "-A [DATE]"
73
+
74
+ validate -> input do
75
+ Types::Params::DateTime.valid?(input)
76
+ end
77
+ end
78
+
79
+ argument :path do
80
+ desc "Path or sub path to the git repository"
81
+ default { Dir.pwd }
82
+ optional
83
+
84
+ validate -> path do
85
+ File.directory?(path)
86
+ end
87
+ end
88
+
89
+ option :branch do
90
+ desc "Branch to be used as starting point"
91
+ long "--branch [NAME]"
92
+ default "HEAD"
93
+ end
94
+
95
+ flag :version do
96
+ desc "Current version"
97
+ long "--version"
98
+ short "-v"
99
+ end
100
+
101
+ flag :help do
102
+ desc "Print usage"
103
+ long "--help"
104
+ short "-h"
105
+ end
106
+
107
+ def self.call(argv = ARGV)
108
+ cmd = new
109
+ cmd.parse(argv, raise_on_parse_error: true)
110
+ cmd.run
111
+ rescue TTY::Option::InvalidParameter, TTY::Option::InvalidArgument => e
112
+ abort e.message
113
+ end
114
+
115
+ def run
116
+ if params[:help]
117
+ puts help
118
+ exit
119
+ end
120
+
121
+ if params[:version]
122
+ puts "git-fame v#{GitFame::VERSION}"
123
+ exit
124
+ end
125
+
126
+ thread = spinner.run do
127
+ Render.new(result: result, **options(:branch))
128
+ end
129
+
130
+ thread.value.call
131
+ rescue Dry::Struct::Error => e
132
+ abort e.message
133
+ rescue Interrupt
134
+ exit
135
+ end
136
+
137
+ private
138
+
139
+ def filter
140
+ Filter.new(**params.to_h.compact_blank.except(:branch))
141
+ end
142
+
143
+ def spinner
144
+ @spinner ||= TTY::Spinner.new("[:spinner] git-fame is crunching the numbers, hold on ...", interval: 1)
145
+ end
146
+
147
+ def repo
148
+ Rugged::Repository.discover(params[:path])
149
+ end
150
+
151
+ def collector
152
+ Collector.new(filter: filter, diff: diff, **options)
153
+ end
154
+
155
+ def diff
156
+ Diff.new(commit: commit, **options)
157
+ end
158
+
159
+ def options(*args)
160
+ params.to_h.only(*args, :log_level).compact_blank
161
+ end
162
+
163
+ def commit
164
+ repo.rev_parse(params[:branch])
165
+ end
166
+
167
+ def result
168
+ collector.call
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFame
4
+ class Contribution < Base
5
+ attribute :lines, Types::Integer
6
+ attribute :commits, Types::Set
7
+ attribute :files, Types::Set
8
+ attribute :author, Author
9
+
10
+ delegate :name, :email, to: :author
11
+ end
12
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFame
4
+ class Diff < Base
5
+ include Enumerable
6
+
7
+ attribute :commit, Types::Any
8
+ delegate :tree, to: :commit
9
+ delegate :repo, to: :tree
10
+
11
+ # @yield [Hash]
12
+ #
13
+ # @return [void]
14
+ def each(&block)
15
+ tree.walk(:preorder).each do |root, entry|
16
+ case entry
17
+ in { type: :blob, name: file, oid: }
18
+ Rugged::Blame.new(repo, root + file, newest_commit: commit).each(&block)
19
+ in { type: type, name: file }
20
+ say("Ignore type [%s] in for %s", type, root + file)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFame
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFame
4
+ module Extension
5
+ refine Hash do
6
+ # Exclude keys from a Hash
7
+ #
8
+ # @param [Array<Symbol>] keys
9
+ #
10
+ # @return [Hash]
11
+ def only(...)
12
+ dup.extract!(...)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFame
4
+ class Filter < Base
5
+ OPT = File::FNM_EXTGLOB | File::FNM_DOTMATCH | File::FNM_CASEFOLD | File::FNM_PATHNAME
6
+
7
+ attribute? :before, Types::JSON::DateTime
8
+ attribute? :after, Types::JSON::DateTime
9
+ attribute? :extensions, Types::Set
10
+ attribute? :include, Types::Set
11
+ attribute? :exclude, Types::Set
12
+
13
+ schema schema.strict(false)
14
+
15
+ # Invokes block if hunk is valid
16
+ #
17
+ # @param hunk [Hash]
18
+ #
19
+ # @yieldparam lines [Integer]
20
+ # @yieldparam orig_path [Pathname]
21
+ # @yieldparam oid [String]
22
+ # @yieldparam name [String]
23
+ # @yieldparam email [String]
24
+ #
25
+ # @return [void]
26
+ def call(hunk, &block)
27
+ case [hunk, attributes]
28
+ in [{ orig_path: path, final_signature: { time: created_at } }, { after: }] unless created_at > after
29
+ say("File %s ignored due to [created > after] (%p > %p)", path, created_at, after)
30
+ in [{ orig_path: path, final_signature: { time: created_at } }, { before: }] unless created_at < before
31
+ say("File %s ignored due to [created < before] (%p < %p)", path, created_at, before)
32
+ in [{ orig_path: path}, { exclude: excluded }] if excluded.any? { File.fnmatch?(_1, path, OPT) }
33
+ say("File %s excluded by [exclude] (%p)", path, excluded)
34
+ in [{ orig_path: path }, { include: included }] unless included.any? { File.fnmatch?(_1, path, OPT) }
35
+ say("File %s excluded by [include] (%p)", path, included)
36
+ in [{ orig_path: path }, { extensions: }] unless extensions.any? { File.extname(path) == _1 }
37
+ say("File %s excluded by [extensions] (%p)", path, extensions)
38
+ in [{final_signature: { name:, email:}, final_commit_id: oid, lines_in_hunk: lines, orig_path: path}, Hash]
39
+ block[lines, path, oid, name, email]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/dependencies/autoload"
4
+ require "active_support/number_helper"
5
+
6
+ module GitFame
7
+ class Render
8
+ module Extension
9
+ refine Integer do
10
+ def f
11
+ ActiveSupport::NumberHelper.number_to_delimited(self, delimiter: " ")
12
+ end
13
+ end
14
+
15
+ refine Contribution do
16
+ def dist(result)
17
+ l = lines.to_f / result.lines
18
+ c = commits.count.to_f / result.commits.count
19
+ f = files.count.to_f / result.files.count
20
+
21
+ "%0.1f%% / %0.1f%% / %0.1f%%" % [l * 100, c * 100, f * 100]
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-screen"
4
+ require "tty-table"
5
+ require "tty-box"
6
+ require "erb"
7
+
8
+ module GitFame
9
+ class Render < Base
10
+ FIELDS = [:name, :email, :lines, :commits, :files, :dist].map(&:to_s).freeze
11
+
12
+ attribute :branch, Types::String
13
+ attribute :result, Result
14
+ delegate_missing_to :result
15
+
16
+ using Extension
17
+
18
+ # Renders to stdout
19
+ #
20
+ # @return [void]
21
+ def call
22
+ table = TTY::Table.new(header: FIELDS)
23
+ width = TTY::Screen.width
24
+
25
+ contributions.reverse_each do |c|
26
+ table << [c.name, c.email, c.lines.f, c.commits.count.f, c.files.count.f, c.dist(self)]
27
+ end
28
+
29
+ print table.render(:unicode, width: width, resize: true, alignment: [:center])
30
+ end
31
+
32
+ private
33
+
34
+ def contributions
35
+ result.contributions.sort_by(&:lines)
36
+ end
37
+ end
38
+ end
@@ -1,6 +1,31 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module GitFame
2
- class Result < Struct.new(:data, :success)
3
- def to_s; data; end
4
- def success?; success; end
4
+ class Result < Base
5
+ attribute :contributions, Types.Array(Contribution)
6
+
7
+ # @return [Array<Author>]
8
+ def authors
9
+ contributions.map(&:author)
10
+ end
11
+
12
+ # @return [Array<String>]
13
+ def commits
14
+ contributions.flat_map do |c|
15
+ c.commits.to_a
16
+ end
17
+ end
18
+
19
+ # @return [Array<String>]
20
+ def files
21
+ contributions.flat_map do |c|
22
+ c.files.to_a
23
+ end
24
+ end
25
+
26
+ # @return [Integer]
27
+ def lines
28
+ contributions.sum(&:lines)
29
+ end
5
30
  end
6
- end
31
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module GitFame
6
+ module Types
7
+ include Dry::Types()
8
+
9
+ Set = Instance(Set).constructor(&:to_set)
10
+ end
11
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module GitFame
2
- VERSION = "2.5.1"
4
+ VERSION = "3.0.1"
3
5
  end
data/lib/git_fame.rb CHANGED
@@ -1,4 +1,17 @@
1
- $-v = false
1
+ # frozen_string_literal: true
2
2
 
3
- require "git_fame/version"
4
- require "git_fame/base"
3
+ require "active_support/core_ext/module/delegation"
4
+ require "active_support/isolated_execution_state"
5
+ require "active_support/core_ext/numeric/time"
6
+ require "dry/core/memoizable"
7
+ require "dry/initializer"
8
+ require "dry/struct"
9
+ require "dry/types"
10
+ require "neatjson"
11
+ require "zeitwerk"
12
+ require "pathname"
13
+ require "rugged"
14
+
15
+ module GitFame
16
+ Zeitwerk::Loader.for_gem.tap(&:setup)
17
+ end