git_fame 2.5.3 → 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: 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