git_fame 2.5.2 → 3.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: d193069cf48839443661210c94fd812dd1490568
4
- data.tar.gz: e3fe0a34dacc2719ccb40cd641fbf3ec5024cfc1
2
+ SHA256:
3
+ metadata.gz: f11f7379034aee11f1a9e2366b40c4bcda4740a35065196754af87244ba2de4d
4
+ data.tar.gz: 39aec5d29bec44e8df6979c51e7ea836931ec0d7754909ba9edda45b5e86d9c5
5
5
  SHA512:
6
- metadata.gz: 75453bb3d6694ccb3d934a3cb234278c6dfecbcd9f9f2550f5edce1a10c776d345d50a077cc4c33b94b5aba2c7d8ebd59c874fd2f80b642e07062daa3393bece
7
- data.tar.gz: 645e9ccb6e9bf43b8fc7d39a3ab953bc16de8c927579f7647f24db4a755ccc02613a3fc945cf9db9753e64e8cef1caf5c9d0d584c98d37db87d5619976cd1be9
6
+ metadata.gz: fe5e3b02353c46cedd4225416f91af8d3d89f66bfb8e9610af9ff40c431842b26f522e2aa5905b35f6d5c55861935ebd2b0a0bfaa6661504261d9d97c90cc9ea
7
+ data.tar.gz: f9ca8af1b5eea4c1520390915b6d4b73c1c9436d719ac2a8e8dbf2e9d63017089133e1b4eb4a404655a347ce8379b7614f06502295d9475794292cbd89f4c463
data/exe/git-fame ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env -S ruby -W0
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path("lib", __dir__)
5
+ $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
6
+
7
+ require "git_fame"
8
+
9
+ GitFame::Command.call
@@ -1,78 +1,8 @@
1
- module GitFame
2
- class Author
3
- include GitFame::Helper
4
- attr_accessor :name, :raw_files, :raw_commits,
5
- :raw_loc, :files_list, :file_type_counts
6
-
7
- FIELDS = [:loc, :commits, :files]
8
-
9
- #
10
- # @args Hash
11
- #
12
- def initialize(args = {})
13
- @raw_loc = 0
14
- @raw_commits = 0
15
- @raw_files = 0
16
- @file_type_counts = Hash.new(0)
17
- args.keys.each do |name|
18
- instance_variable_set "@" + name.to_s, args[name]
19
- end
20
- end
21
-
22
- def merge(author)
23
- tap do
24
- FIELDS.each do |field|
25
- inc(field, author.raw(field))
26
- end
27
- end
28
- end
29
-
30
- #
31
- # @format loc / commits / files
32
- # @return String Distribution (in %) between users
33
- #
34
- def distribution
35
- "%s / %s / %s" % FIELDS.map do |field|
36
- ("%.1f" % (percent_for_field(field) * 100)).rjust(4, " ")
37
- end
38
- end
39
- alias_method :"distribution (%)", :distribution
40
-
41
- FIELDS.each do |method|
42
- define_method(method) do
43
- number_with_delimiter(raw(method))
44
- end
45
- end
46
-
47
- def update(params)
48
- params.keys.each do |key|
49
- send("#{key}=", params[key])
50
- end
51
- end
1
+ # frozen_string_literal: true
52
2
 
53
- #
54
- # Intended to catch file type counts
55
- #
56
- def method_missing(m, *args, &block)
57
- file_type_counts[m.to_s]
58
- end
59
-
60
- def raw(method)
61
- unless FIELDS.include?(method.to_sym)
62
- raise "can't access raw '#{method}' on author"
63
- end
64
-
65
- send("raw_#{method}")
66
- end
67
-
68
- def inc(method, amount)
69
- send("raw_#{method}=", raw(method) + amount)
70
- end
71
-
72
- private
73
-
74
- def percent_for_field(field)
75
- raw(field) / @parent.send(field).to_f
76
- end
3
+ module GitFame
4
+ class Author < Base
5
+ attribute :name, Types::String
6
+ attribute :email, Types::String
77
7
  end
78
8
  end
data/lib/git_fame/base.rb CHANGED
@@ -1,532 +1,19 @@
1
- require "csv"
2
- require "time"
3
- require "open3"
4
- require "hirb"
5
- require "memoist"
6
- require "timeout"
7
-
8
- # String#scrib is build in to Ruby 2.1+
9
- if RUBY_VERSION.to_f < 2.1
10
- require "scrub_rb"
11
- end
12
-
13
- require "git_fame/helper"
14
- require "git_fame/author"
15
- require "git_fame/silent_progressbar"
16
- require "git_fame/blame_parser"
17
- require "git_fame/result"
18
- require "git_fame/file"
19
- require "git_fame/errors"
20
- require "git_fame/commit_range"
1
+ # frozen_string_literal: true
21
2
 
22
3
  module GitFame
23
- SORT = ["name", "commits", "loc", "files"]
24
- CMD_TIMEOUT = 10
25
-
26
- class Base
27
- include GitFame::Helper
28
- extend Memoist
29
-
30
- #
31
- # @args[:repository] String Absolute path to git repository
32
- # @args[:sort] String What should #authors be sorted by?
33
- # @args[:by_type] Boolean Should counts be grouped by file extension?
34
- # @args[:exclude] String Comma-separated list of paths in the repo
35
- # which should be excluded
36
- # @args[:branch] String Branch to run from
37
- # @args[:after] date after
38
- # @args[:before] date before
39
- #
40
- def initialize(args)
41
- @default_settings = {
42
- branch: "master",
43
- sorting: "loc",
44
- ignore_types: ["image", "binary"]
45
- }
46
- @progressbar = args.fetch(:progressbar, false)
47
- @file_authors = Hash.new { |h,k| h[k] = {} }
48
- # Create array out of comma separated list
49
- @exclude = args.fetch(:exclude, "").split(",").
50
- map{ |path| path.strip.sub(/\A\//, "") }
51
- @extensions = args.fetch(:extensions, "").split(",")
52
- # Default sorting option is by loc
53
- @include = args.fetch(:include, "").split(",")
54
- @sort = args.fetch(:sort, @default_settings.fetch(:sorting))
55
- @repository = File.expand_path(args.fetch(:repository))
56
- @by_type = args.fetch(:by_type, false)
57
- @branch = args.fetch(:branch, nil)
58
- @everything = args.fetch(:everything, false)
59
- @timeout = args.fetch(:timeout, CMD_TIMEOUT)
60
- @git_dir = File.join(@repository, ".git")
61
-
62
- # Figure out what branch the caller is using
63
- if present?(@branch = args[:branch])
64
- unless branch_exists?(@branch)
65
- raise Error, "Branch '#{@branch}' does not exist"
66
- end
67
- else
68
- @branch = default_branch
69
- end
70
-
71
- @after = args.fetch(:after, nil)
72
- @before = args.fetch(:before, nil)
73
- [@after, @before].each do |date|
74
- if date and not valid_date?(date)
75
- raise Error, "#{date} is not a valid date"
76
- end
77
- end
78
-
79
- # Fields that should be visible in the final table
80
- # Used by #csv_puts, #to_csv and #pretty_puts
81
- # Format: [ [ :method_on_author, "custom column name" ] ]
82
- @visible_fields = [
83
- :name,
84
- :loc,
85
- :commits,
86
- :files,
87
- [:distribution, "distribution (%)"]
88
- ]
89
- @wopt = args.fetch(:whitespace, false) ? "-w" : ""
90
- @authors = {}
91
- @verbose = args.fetch(:verbose, false)
92
- populate
93
- end
94
-
95
- #
96
- # Generates pretty output
97
- #
98
- def pretty_puts
99
- extend Hirb::Console
100
- Hirb.enable({ pager: false })
101
- puts "\nStatistics based on #{commit_range.to_s(true)}"
102
- puts "Active files: #{number_with_delimiter(files)}"
103
- puts "Active lines: #{number_with_delimiter(loc)}"
104
- puts "Total commits: #{number_with_delimiter(commits)}\n"
105
- unless @everything
106
- puts "\nNote: Files matching MIME type #{ignore_types.join(", ")} has been ignored\n\n"
107
- end
108
- table(authors, fields: printable_fields)
109
- end
110
-
111
- #
112
- # Prints CSV
113
- #
114
- def csv_puts
115
- puts to_csv
116
- end
117
-
118
- #
119
- # Generate csv output
120
- #
121
- def to_csv
122
- CSV.generate do |csv|
123
- csv << fields
124
- authors.each do |author|
125
- csv << fields.map do |f|
126
- author.send(f)
127
- end
128
- end
129
- end
130
- end
4
+ class Base < Dry::Struct
5
+ schema schema.strict(true)
131
6
 
132
- #
133
- # @return Fixnum Total number of files
134
- # TODO: Rename this
135
- #
136
- def files
137
- used_files.count
138
- end
7
+ attribute? :log_level, Types::Coercible::Symbol.default(:info).enum(:debug, :info, :warn, :error, :fatal, :unknown)
139
8
 
140
- #
141
- # @return Array list of repo files processed
142
- #
143
- # TODO: Rename
144
- def file_list; used_files; end
9
+ private
145
10
 
146
- #
147
- # @return Fixnum Total number of commits
148
- #
149
- def commits
150
- authors.inject(0) { |result, author| author.raw(:commits) + result }
11
+ def say(template, *args)
12
+ logger.debug(template % args)
151
13
  end
152
14
 
153
- #
154
- # @return Fixnum Total number of lines
155
- #
156
- def loc
157
- authors.inject(0) { |result, author| author.raw(:loc) + result }
15
+ def logger
16
+ @logger ||= Logger.new($stdout, level: log_level, progname: self.class.name)
158
17
  end
159
-
160
- #
161
- # @return Array<Author> A list of authors
162
- #
163
- def authors
164
- unique_authors.sort_by do |author|
165
- @sort == "name" ? author.send(@sort) : -1 * author.raw(@sort)
166
- end
167
- end
168
-
169
- protected
170
-
171
- # Populates @authors and with data
172
- # Block is called on every call to populate, but
173
- # the data is only calculated once
174
- def populate
175
- # Display progressbar with the number of files as countdown
176
- progressbar = init_progressbar(current_files.count)
177
-
178
- # Extract the blame history from all checked in files
179
- current_files.each do |file|
180
- progressbar.increment
181
-
182
- # Skip this file if non wanted type
183
- next unless check_file?(file)
184
-
185
- # -w ignore whitespaces (defined in @wopt)
186
- # -M detect moved or copied lines.
187
- # -p procelain mode (parsed by BlameParser)
188
- execute("git #{git_directory_params} blame #{encoding_opt} -p -M #{default_params} #{commit_range.to_s} #{@wopt} -- '#{file}'") do |result|
189
- BlameParser.new(result.to_s).parse.each do |row|
190
- next if row[:boundary]
191
-
192
- email = get(row, :author, :mail)
193
- name = get(row, :author, :name)
194
-
195
- # Create or find user
196
- author = author_by_email(email, name)
197
-
198
- # Get author by name and increase the number of loc by 1
199
- author.inc(:loc, get(row, :num_lines))
200
-
201
- # Store the files and authors together
202
- associate_file_with_author(author, file)
203
- end
204
- end
205
- end
206
-
207
- # Get repository summery and update each author accordingly
208
- execute("git #{git_directory_params} shortlog #{encoding_opt} #{default_params} -se #{commit_range.to_s}") do |result|
209
- result.to_s.split("\n").map do |line|
210
- _, commits, name, email = line.match(/(\d+)\s+(.+)\s+<(.+?)>/).to_a
211
- author = author_by_email(email)
212
-
213
- author.name = name
214
-
215
- author.update({
216
- raw_commits: commits.to_i,
217
- raw_files: files_from_author(author).count,
218
- files_list: files_from_author(author)
219
- })
220
- end
221
- end
222
-
223
- progressbar.finish
224
- end
225
-
226
- # Ignore mime types found in {ignore_types}
227
- def check_file?(file)
228
- return true if @everything
229
- type = mime_type_for_file(file)
230
- ! ignore_types.any? { |ignored| type.include?(ignored) }
231
- end
232
-
233
- # Return mime type for file (form: x/y)
234
- def mime_type_for_file(file)
235
- execute("git #{git_directory_params} show #{commit_range.range.last}:'#{file}' | LC_ALL=C file --mime-type -").to_s.
236
- match(/.+: (.+?)$/).to_a[1]
237
- end
238
-
239
- def get(hash, *keys)
240
- keys.inject(hash) { |h, key| h.fetch(key) }
241
- end
242
-
243
- def ignore_types
244
- @default_settings.fetch(:ignore_types)
245
- end
246
-
247
- def unique_authors
248
- # Merges duplicate users (users with the same name)
249
- # Object#dup prevents the original to be changed
250
- @authors.values.dup.each_with_object({}) do |author, result|
251
- if ex_author = result[author.name]
252
- result[author.name] = ex_author.dup.merge(author)
253
- else
254
- result[author.name] = author
255
- end
256
- end.values
257
- end
258
-
259
- # Uses the more printable names in @visible_fields
260
- def printable_fields
261
- raw_fields.map do |field|
262
- field.is_a?(Array) ? field.last : field
263
- end
264
- end
265
-
266
- def associate_file_with_author(author, file)
267
- if @by_type
268
- author.file_type_counts[file.extname] += 1
269
- end
270
- @file_authors[author][file] ||= 1
271
- end
272
-
273
- def used_files
274
- @file_authors.values.map(&:keys).flatten.uniq
275
- end
276
-
277
- def file_extensions
278
- used_files.map(&:extname)
279
- end
280
-
281
- # Check to see if a string is empty (nil or "")
282
- def blank?(value)
283
- value.nil? or value.empty?
284
- end
285
-
286
- def files_from_author(author)
287
- @file_authors[author].keys
288
- end
289
-
290
- def present?(value)
291
- not blank?(value)
292
- end
293
-
294
- def valid_date?(date)
295
- !! date.match(/\d{4}-\d{2}-\d{2}/)
296
- end
297
-
298
- # Includes fields from file extensions
299
- def raw_fields
300
- return @visible_fields unless @by_type
301
- (@visible_fields + file_extensions).uniq
302
- end
303
-
304
- # Method fields used by #to_csv and #pretty_puts
305
- def fields
306
- raw_fields.map do |field|
307
- field.is_a?(Array) ? field.first : field
308
- end
309
- end
310
-
311
- # Command to be executed at @repository
312
- # @silent = true wont raise an error on exit code =! 0
313
- def execute(command, silent = false, &block)
314
- result = run_with_timeout(command)
315
- if result.success? or silent
316
- warn command if @verbose
317
- return result unless block
318
- return block.call(result)
319
- end
320
- raise Error, cmd_error_message(command, result.data)
321
- rescue Errno::ENOENT
322
- raise Error, cmd_error_message(command, $!.message)
323
- end
324
-
325
- def run_with_timeout(command)
326
- if @timeout != -1
327
- Timeout.timeout(CMD_TIMEOUT) { run_no_timeout(command) }
328
- else
329
- run_no_timeout(command)
330
- end
331
- end
332
-
333
- def run_no_timeout(command)
334
- out, err, status = Open3.capture3(command)
335
- ok = status.success?
336
- output = ok ? out : err
337
- Result.new(output.scrub.strip, ok)
338
- end
339
-
340
- def cmd_error_message(command, message)
341
- "Could not run '#{command}' => #{message}"
342
- end
343
-
344
- # Does @branch exist in the current git repo?
345
- def branch_exists?(branch)
346
- execute("git #{git_directory_params} show-ref '#{branch}'", true) do |result|
347
- result.success?
348
- end
349
- end
350
-
351
- # In those cases the users havent defined a branch
352
- # We try to define it for him/her by
353
- # 1. check if { @default_settings.fetch(:branch) } exists
354
- # 1. look at .git/HEAD (basically)
355
- def default_branch
356
- if branch_exists?(@default_settings.fetch(:branch))
357
- return @default_settings.fetch(:branch)
358
- end
359
-
360
- execute("git #{git_directory_params} rev-parse HEAD | head -1") do |result|
361
- return result.data.split(" ")[0] if result.success?
362
- end
363
- raise Error, "No branch found. Define one using --branch=<branch>"
364
- end
365
-
366
- def author_by_email(email, name = nil)
367
- @authors[(email || "").strip] ||= Author.new({ parent: self, name: name })
368
- end
369
-
370
- # Lists the paths to contained git submodules
371
- def current_submodules
372
- execute("git config --file .gitmodules --get-regexp path | awk '{ print $2 }'") do |result|
373
- result.to_s.split(/\n/)
374
- end
375
- end
376
-
377
- # List all files in current git directory, excluding
378
- # extensions in @extensions defined by the user
379
- def current_files
380
- if commit_range.is_range?
381
- execute("git #{git_directory_params} -c diff.renames=0 -c diff.renameLimit=1000 diff -M -C -c --name-only --ignore-submodules=all --diff-filter=AM #{encoding_opt} #{default_params} #{commit_range.to_s}") do |result|
382
- filter_files(result.to_s.split(/\n/))
383
- end
384
- else
385
- submodules = current_submodules
386
- execute("git #{git_directory_params} ls-tree -r #{commit_range.to_s} --name-only") do |result|
387
- filter_files(result.to_s.split(/\n/).select { |f| !submodules.index(f) })
388
- end
389
- end
390
- end
391
-
392
- def default_params
393
- "--date=local"
394
- end
395
-
396
- def git_directory_params
397
- "--git-dir='#{@git_dir}' --work-tree='#{@repository}'"
398
- end
399
-
400
- def encoding_opt
401
- "--encoding=UTF-8"
402
- end
403
-
404
- def filter_files(raw_files)
405
- files = remove_excluded_files(raw_files)
406
- files = keep_included_files(files)
407
- files = files.map { |file| GitFame::FileUnit.new(file) }
408
- return files if @extensions.empty?
409
- files.select { |file| @extensions.include?(file.extname) }
410
- end
411
-
412
- def commit_range
413
- CommitRange.new(current_range, @branch)
414
- end
415
-
416
- def current_range
417
- return @branch if blank?(@after) and blank?(@before)
418
-
419
- if present?(@after) and present?(@before)
420
- if end_date < start_date
421
- raise Error, "after=#{@after} can't be greater then before=#{@before}"
422
- end
423
-
424
- if end_date > end_commit_date and start_date > end_commit_date
425
- raise Error, "after=#{@after} and before=#{@before} is set too high, higest is #{end_commit_date}"
426
- end
427
-
428
- if end_date < start_commit_date and start_date < start_commit_date
429
- raise Error, "after=#{@after} and before=#{@before} is set too low, lowest is #{start_commit_date}"
430
- end
431
- elsif present?(@after)
432
- if start_date > end_commit_date
433
- raise Error, "after=#{@after} is set too high, highest is #{end_commit_date}"
434
- end
435
- elsif present?(@before)
436
- if end_date < start_commit_date
437
- raise Error, "before=#{@before} is set too low, lowest is #{start_commit_date}"
438
- end
439
- end
440
-
441
- if present?(@before)
442
- if end_date > end_commit_date
443
- commit2 = @branch
444
- else
445
- # Try finding a commit that day
446
- commit2 = execute("git #{git_directory_params} rev-list --before='#{@before} 23:59:59' --after='#{@before} 00:00:01' #{default_params} '#{@branch}' | head -1").to_s
447
-
448
- # Otherwise, look for the closest commit
449
- if blank?(commit2)
450
- commit2 = execute("git #{git_directory_params} rev-list --before='#{@before}' #{default_params} '#{@branch}' | head -1").to_s
451
- end
452
- end
453
- end
454
-
455
- if present?(@after)
456
- if start_date < start_commit_date
457
- return present?(commit2) ? commit2 : @branch
458
- end
459
-
460
- commit1 = execute("git #{git_directory_params} rev-list --before='#{end_of_yesterday(@after)}' #{default_params} '#{@branch}' | head -1").to_s
461
-
462
- # No commit found this early
463
- # If NO end date is choosen, just use current branch
464
- # Otherwise use specified (@before) as end date
465
- if blank?(commit1)
466
- return @branch unless @before
467
- return commit2
468
- end
469
- end
470
-
471
- if @after and @before
472
- # Nothing found in date span
473
- if commit1 == commit2
474
- raise Error, "There are no commits between #{@before} and #{@after}"
475
- end
476
- return [commit1, commit2]
477
- end
478
-
479
- return commit2 if @before
480
- [commit1, @branch]
481
- end
482
-
483
- def end_of_yesterday(time)
484
- (Time.parse(time) - 86400).strftime("%F 23:59:59")
485
- end
486
-
487
- def start_commit_date
488
- Time.parse(execute("git #{git_directory_params} log #{encoding_opt} --pretty=format:'%cd' #{default_params} #{@branch} | tail -1").to_s)
489
- end
490
-
491
- def end_commit_date
492
- Time.parse(execute("git #{git_directory_params} log #{encoding_opt} --pretty=format:'%cd' #{default_params} #{@branch} | head -1").to_s)
493
- end
494
-
495
- def end_date
496
- Time.parse("#{@before} 23:59:59")
497
- end
498
-
499
- def start_date
500
- Time.parse("#{@after} 00:00:01")
501
- end
502
-
503
- # Removes files excluded by the user
504
- # Defined using --exclude
505
- def remove_excluded_files(files)
506
- return files if @exclude.empty?
507
- files.reject do |file|
508
- @exclude.any? { |exclude| File.fnmatch(exclude, file) }
509
- end
510
- end
511
-
512
- def keep_included_files(files)
513
- return files if @include.empty?
514
- files.select do |file|
515
- @include.any? { |include| File.fnmatch(include, file) }
516
- end
517
- end
518
-
519
- def init_progressbar(files_count)
520
- SilentProgressbar.new("Git Fame", files_count, (@progressbar and not @verbose))
521
- end
522
-
523
- # TODO: Are all these needed?
524
- memoize :populate, :run_with_timeout
525
- memoize :current_range, :current_files
526
- memoize :printable_fields, :files_from_author
527
- memoize :raw_fields, :fields, :file_list
528
- memoize :end_commit_date, :loc, :commits
529
- memoize :start_commit_date, :files, :authors
530
- memoize :file_extensions, :used_files
531
18
  end
532
- end
19
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFame
4
+ class Collector
5
+ extend Dry::Initializer
6
+
7
+ option :filter, type: Filter
8
+ option :diff, type: Types::Any
9
+
10
+ # @return [Collector]
11
+ def call
12
+ Result.new(contributions: contributions)
13
+ end
14
+
15
+ private
16
+
17
+ def contributions
18
+ commits = Hash.new { |h, k| h[k] = Set.new }
19
+ files = Hash.new { |h, k| h[k] = Set.new }
20
+ lines = Hash.new(0)
21
+ names = {}
22
+
23
+ diff.each do |change|
24
+ filter.call(change) do |loc, file, oid, name, email|
25
+ commits[email].add(oid)
26
+ files[email].add(file)
27
+ names[email] = name
28
+ lines[email] += loc
29
+ end
30
+ end
31
+
32
+ lines.each_key.map do |email|
33
+ Contribution.new({
34
+ lines: lines[email],
35
+ commits: commits[email],
36
+ files: files[email],
37
+ author: {
38
+ name: names[email],
39
+ email: email
40
+ }
41
+ })
42
+ end
43
+ end
44
+ end
45
+ end