git_fame 2.5.3 → 3.0.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,159 @@
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 :help do
96
+ desc "Print usage"
97
+ long "--help"
98
+ short "-h"
99
+ end
100
+
101
+ def self.call(argv = ARGV)
102
+ cmd = new
103
+ cmd.parse(argv, raise_on_parse_error: true)
104
+ cmd.run
105
+ rescue TTY::Option::Error => e
106
+ abort e.message
107
+ end
108
+
109
+ def run
110
+ if params[:help]
111
+ abort help
112
+ end
113
+
114
+ thread = spinner.run do
115
+ Render.new(result: result, **options(:branch))
116
+ end
117
+
118
+ thread.value.call
119
+ rescue Dry::Struct::Error => e
120
+ abort e.message
121
+ rescue Interrupt
122
+ exit
123
+ end
124
+
125
+ private
126
+
127
+ def filter
128
+ Filter.new(**params.to_h.compact_blank.except(:branch))
129
+ end
130
+
131
+ def spinner
132
+ @spinner ||= TTY::Spinner.new("[:spinner] git-fame is crunching the numbers, hold on ...", interval: 1)
133
+ end
134
+
135
+ def repo
136
+ Rugged::Repository.discover(params[:path])
137
+ end
138
+
139
+ def collector
140
+ Collector.new(filter: filter, diff: diff, **options)
141
+ end
142
+
143
+ def diff
144
+ Diff.new(commit: commit, **options)
145
+ end
146
+
147
+ def options(*args)
148
+ params.to_h.only(*args, :log_level).compact_blank
149
+ end
150
+
151
+ def commit
152
+ repo.rev_parse(params[:branch])
153
+ end
154
+
155
+ def result
156
+ collector.call
157
+ end
158
+ end
159
+ 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.3"
4
+ VERSION = "3.0.0"
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