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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: b116811a8adcab134d77f4a932abe46b4fd90e04
4
- data.tar.gz: c90accdd8d6be58ab2e321f1799e0f7cb6125644
2
+ SHA256:
3
+ metadata.gz: 8df295a66aac19b5e17698fc1cdc0ec965f42a19bd7cbe588c5ae36f32dd56a2
4
+ data.tar.gz: 11c9a291204496ff78616f0b26303ca25d54b5983ed00ac6b1406f61760cbe2e
5
5
  SHA512:
6
- metadata.gz: afc303473aee3149fed387971235d58086f957eeef8794283df6e497710a45b48f75ab90c6c211ce3add9c013632e0bfce99a411eb6272dc6157553f68da4695
7
- data.tar.gz: f249b2dff4b90b1cd15b4af050fb372db9ae2648912b5e8b435393655bd7181c340e2b5a6655d1de035e9209bcca636ba2b4ae3e9a7ed9ac408f197288496a32
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,533 +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
131
-
132
- #
133
- # @return Fixnum Total number of files
134
- # TODO: Rename this
135
- #
136
- def files
137
- used_files.count
138
- end
139
-
140
- #
141
- # @return Array list of repo files processed
142
- #
143
- # TODO: Rename
144
- def file_list; used_files; end
145
-
146
- #
147
- # @return Fixnum Total number of commits
148
- #
149
- def commits
150
- authors.inject(0) { |result, author| author.raw(:commits) + result }
151
- end
152
-
153
- #
154
- # @return Fixnum Total number of lines
155
- #
156
- def loc
157
- authors.inject(0) { |result, author| author.raw(:loc) + result }
158
- 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
4
+ class Base < Dry::Struct
5
+ schema schema.strict(true)
289
6
 
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
- return true if branch == "HEAD"
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
- # Lists the paths to contained git submodules
372
- def current_submodules
373
- execute("git config --file .gitmodules --get-regexp path | awk '{ print $2 }'") do |result|
374
- result.to_s.split(/\n/)
375
- end
376
- end
377
-
378
- # List all files in current git directory, excluding
379
- # extensions in @extensions defined by the user
380
- def current_files
381
- if commit_range.is_range?
382
- 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|
383
- filter_files(result.to_s.split(/\n/))
384
- end
385
- else
386
- submodules = current_submodules
387
- execute("git #{git_directory_params} ls-tree -r #{commit_range.to_s} --name-only") do |result|
388
- filter_files(result.to_s.split(/\n/).select { |f| !submodules.index(f) })
389
- end
390
- end
391
- end
392
-
393
- def default_params
394
- "--date=local"
395
- end
396
-
397
- def git_directory_params
398
- "--git-dir='#{@git_dir}' --work-tree='#{@repository}'"
399
- end
7
+ attribute? :log_level, Types::Coercible::Symbol.default(:info).enum(:debug, :info, :warn, :error, :fatal, :unknown)
400
8
 
401
- def encoding_opt
402
- "--encoding=UTF-8"
403
- end
404
-
405
- def filter_files(raw_files)
406
- files = remove_excluded_files(raw_files)
407
- files = keep_included_files(files)
408
- files = files.map { |file| GitFame::FileUnit.new(file) }
409
- return files if @extensions.empty?
410
- files.select { |file| @extensions.include?(file.extname) }
411
- end
412
-
413
- def commit_range
414
- CommitRange.new(current_range, @branch)
415
- end
416
-
417
- def current_range
418
- return @branch if blank?(@after) and blank?(@before)
419
-
420
- if present?(@after) and present?(@before)
421
- if end_date < start_date
422
- raise Error, "after=#{@after} can't be greater then before=#{@before}"
423
- end
424
-
425
- if end_date > end_commit_date and start_date > end_commit_date
426
- raise Error, "after=#{@after} and before=#{@before} is set too high, higest is #{end_commit_date}"
427
- end
428
-
429
- if end_date < start_commit_date and start_date < start_commit_date
430
- raise Error, "after=#{@after} and before=#{@before} is set too low, lowest is #{start_commit_date}"
431
- end
432
- elsif present?(@after)
433
- if start_date > end_commit_date
434
- raise Error, "after=#{@after} is set too high, highest is #{end_commit_date}"
435
- end
436
- elsif present?(@before)
437
- if end_date < start_commit_date
438
- raise Error, "before=#{@before} is set too low, lowest is #{start_commit_date}"
439
- end
440
- end
441
-
442
- if present?(@before)
443
- if end_date > end_commit_date
444
- commit2 = @branch
445
- else
446
- # Try finding a commit that day
447
- 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
448
-
449
- # Otherwise, look for the closest commit
450
- if blank?(commit2)
451
- commit2 = execute("git #{git_directory_params} rev-list --before='#{@before}' #{default_params} '#{@branch}' | head -1").to_s
452
- end
453
- end
454
- end
455
-
456
- if present?(@after)
457
- if start_date < start_commit_date
458
- return present?(commit2) ? commit2 : @branch
459
- end
460
-
461
- commit1 = execute("git #{git_directory_params} rev-list --before='#{end_of_yesterday(@after)}' #{default_params} '#{@branch}' | head -1").to_s
462
-
463
- # No commit found this early
464
- # If NO end date is choosen, just use current branch
465
- # Otherwise use specified (@before) as end date
466
- if blank?(commit1)
467
- return @branch unless @before
468
- return commit2
469
- end
470
- end
471
-
472
- if @after and @before
473
- # Nothing found in date span
474
- if commit1 == commit2
475
- raise Error, "There are no commits between #{@before} and #{@after}"
476
- end
477
- return [commit1, commit2]
478
- end
9
+ private
479
10
 
480
- return commit2 if @before
481
- [commit1, @branch]
11
+ def say(template, *args)
12
+ logger.debug(template % args)
482
13
  end
483
14
 
484
- def end_of_yesterday(time)
485
- (Time.parse(time) - 86400).strftime("%F 23:59:59")
15
+ def logger
16
+ @logger ||= Logger.new($stdout, level: log_level, progname: self.class.name)
486
17
  end
487
-
488
- def start_commit_date
489
- Time.parse(execute("git #{git_directory_params} log #{encoding_opt} --pretty=format:'%cd' #{default_params} #{@branch} | tail -1").to_s)
490
- end
491
-
492
- def end_commit_date
493
- Time.parse(execute("git #{git_directory_params} log #{encoding_opt} --pretty=format:'%cd' #{default_params} #{@branch} | head -1").to_s)
494
- end
495
-
496
- def end_date
497
- Time.parse("#{@before} 23:59:59")
498
- end
499
-
500
- def start_date
501
- Time.parse("#{@after} 00:00:01")
502
- end
503
-
504
- # Removes files excluded by the user
505
- # Defined using --exclude
506
- def remove_excluded_files(files)
507
- return files if @exclude.empty?
508
- files.reject do |file|
509
- @exclude.any? { |exclude| File.fnmatch(exclude, file) }
510
- end
511
- end
512
-
513
- def keep_included_files(files)
514
- return files if @include.empty?
515
- files.select do |file|
516
- @include.any? { |include| File.fnmatch(include, file) }
517
- end
518
- end
519
-
520
- def init_progressbar(files_count)
521
- SilentProgressbar.new("Git Fame", files_count, (@progressbar and not @verbose))
522
- end
523
-
524
- # TODO: Are all these needed?
525
- memoize :populate, :run_with_timeout
526
- memoize :current_range, :current_files
527
- memoize :printable_fields, :files_from_author
528
- memoize :raw_fields, :fields, :file_list
529
- memoize :end_commit_date, :loc, :commits
530
- memoize :start_commit_date, :files, :authors
531
- memoize :file_extensions, :used_files
532
18
  end
533
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