git_fame 2.5.0 → 3.0.0

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: 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