git_fame 2.5.2 → 3.0.2

Sign up to get free protection for your applications and to get access to all the features.
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