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