git-author-report 1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 53e61550adce28323504c93920a1c75d8e45d6f85e7afb20235a31a4cc0d4016
4
+ data.tar.gz: 41918e48a7553b4134efde8a642e97ff035c253d1f5a4e96bdf04b742e3c63c1
5
+ SHA512:
6
+ metadata.gz: d473d8b1f9adbc2b6ac34265c0e5981780fd87e3870099eef5c23323dc59f836023616e23d2a0d40c7ca6a67e3bb06c32dec9e12368cb04c32a12f8bb7df4c8b
7
+ data.tar.gz: c4740e7a739f9521a5afc000052b9957226d5381a11ff7e7b059384c4726f149d5fd4f7780687e637769400739450585c22a5b051101a170267eff92a140bcff
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.0.0
data/bin/git-report ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+
4
+ # git-report runs on whatever Ruby is on PATH -- including the stock macOS
5
+ # system Ruby -- so users don't have to install Ruby. It has no gem
6
+ # dependencies: everything it needs comes from the Ruby standard library, so
7
+ # there is nothing to install and no first-run vendoring step.
8
+
9
+ script_dir = File.expand_path(File.dirname(__FILE__))
10
+
11
+ require File.join(script_dir, '../lib/version.rb')
12
+
13
+ if ['-v', '--version'].include?(ARGV[0])
14
+ puts "git-report #{Git::Report::VERSION}"
15
+ exit 0
16
+ end
17
+
18
+ require File.join(script_dir, '../lib/git_report.rb')
19
+
20
+ git_report = Git::Report.new.retrieve_stats
21
+ puts git_report.stats
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+
3
+ pushd `dirname $0` > /dev/null
4
+ BIN_DIR=`pwd`
5
+ popd > /dev/null
6
+
7
+ git config --global alias.report "!exec \"$BIN_DIR/git-report\""
8
+ echo '#################################'
9
+ echo '# "git report" has been added #'
10
+ echo '# Run: git report #'
11
+ echo '#################################'
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+
3
+ git config --global --unset alias.report
4
+ echo '###################################'
5
+ echo '# "git report" has been removed #'
6
+ echo '###################################'
@@ -0,0 +1,4 @@
1
+ all:
2
+ true
3
+ install:
4
+ true
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mkmf'
4
+
5
+ find_executable('bash')
6
+ find_executable('git')
7
+ find_executable('make')
8
+
9
+ # Trick Rubygems into thinking the generated artifact was compiled
10
+ compile = File.join(Dir.pwd, "git_report.#{RbConfig::CONFIG['DLEXT']}")
11
+ File.write(compile, '')
12
+
13
+ # Install "git report"
14
+ puts `../../bin/git_add_alias_report`
15
+
16
+ # Trick Rubygems into thinking the Makefile was executed
17
+ $makefile_created = true
data/lib/git/author.rb ADDED
@@ -0,0 +1,77 @@
1
+ # keys of report are the authors, the values contains the report data:
2
+ # * commits, +LOC, -LOC, LOC,
3
+ require 'shellwords'
4
+
5
+ module Git
6
+ # Author class - represents a git contributor with their statistics
7
+ class Author
8
+ # name: String - the name of the author
9
+ # emails: Set<String> - a list of email addresses
10
+ # commits: Fixnum - number of commits by this author
11
+ # loc_added: Fixnum - number of lines this author added over time
12
+ # loc_deleted: Fixnum - number of lines this author deleted over time
13
+ # loc: Fixnum - number of lines in the current codebase this author added
14
+ # files: Fixnum - number of files
15
+ attr_reader :name, :emails, :commits, :loc_added, :loc_deleted, :loc, :files
16
+
17
+ #
18
+ def initialize(name:, emails: '', commits: 0)
19
+ @name = name
20
+ @emails = Set.new Array(emails)
21
+ @commits = commits.to_i
22
+ @loc = 0
23
+ @loc_added = 0
24
+ @loc_deleted = 0
25
+ @files = 0
26
+ end
27
+
28
+ def merge(other)
29
+ return unless mergable?(other)
30
+ @emails.merge other.emails
31
+ @commits += other.commits
32
+ self
33
+ end
34
+
35
+ def mergable?(other)
36
+ name == other.name
37
+ end
38
+
39
+ def retrieve_loc_stats
40
+ # Run one git log per email in parallel, returning [added, deleted] per
41
+ # email. The instance-variable accumulation happens afterwards on this
42
+ # single thread, so the parallel blocks never race on @loc_added/@loc_deleted.
43
+ Git::Parallel.pmap(emails) do |email|
44
+ # Escape email to prevent command injection
45
+ escaped_email = Shellwords.escape(email)
46
+ numstat = `git log --author=#{escaped_email} \
47
+ --pretty=tformat: --numstat --no-merges`
48
+
49
+ # Skip if no commits found for this email
50
+ next if numstat.empty?
51
+
52
+ loc = numstat.split(/\n/).map do |numstat_line|
53
+ parts = numstat_line.scan(/\A(.*)\t(.*)\t/)[0]
54
+ parts ? parts.map(&:to_i) : nil
55
+ end.compact
56
+
57
+ # Skip if no valid data
58
+ next if loc.empty?
59
+
60
+ loc.transpose.map { |add_del_loc| add_del_loc.inject(&:+) }
61
+ end.each do |added_deleted|
62
+ next unless added_deleted
63
+ added, deleted = added_deleted
64
+ @loc_added += added if added
65
+ @loc_deleted += deleted if deleted
66
+ end
67
+ end
68
+
69
+ def loc=(loc)
70
+ @loc = loc.to_i
71
+ end
72
+
73
+ def files=(files)
74
+ @files = files.to_i
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,67 @@
1
+ module Git
2
+ # Minimal parallel helpers built on the Ruby standard library, replacing the
3
+ # former `pmap` gem. The work these drive is I/O-bound -- every block shells
4
+ # out to git -- so threads give real concurrency even under MRI's GVL: a
5
+ # subprocess spawned by backticks runs outside the lock while other threads
6
+ # proceed. Keeping this in-tree is what lets git-report run on stock Ruby
7
+ # with no gems to install.
8
+ module Parallel
9
+ # Upper bound on worker threads, and therefore on how many git subprocesses
10
+ # run at once. This cap is the whole point: a repository with thousands of
11
+ # authors must NOT spawn a thread (and a `git log`) per author -- that would
12
+ # swamp the machine. 64 matches the historical default of the pmap gem this
13
+ # replaced, so throughput on large repositories is unchanged.
14
+ MAX_THREADS = 64
15
+
16
+ module_function
17
+
18
+ # Like Enumerable#map, but runs the block for each element across a bounded
19
+ # pool of threads and returns the results in the original order.
20
+ def pmap(enum, &block)
21
+ results = []
22
+ mutex = Mutex.new
23
+ process(enum) do |item, index|
24
+ value = block.call(item)
25
+ mutex.synchronize { results[index] = value }
26
+ end
27
+ results
28
+ end
29
+
30
+ # Like Enumerable#each, but runs the block for each element across a bounded
31
+ # pool of threads. Returns the original enumerable once all work is done.
32
+ def peach(enum, &block)
33
+ process(enum) { |item, _index| block.call(item) }
34
+ enum
35
+ end
36
+
37
+ # Drains every [item, index] pair through at most MAX_THREADS workers. All
38
+ # jobs are enqueued up front, so each worker simply pops until the queue is
39
+ # empty and then exits -- no poison pills needed. Joining via Thread#value
40
+ # propagates the first exception raised in any worker to the caller.
41
+ def process(enum)
42
+ items = enum.to_a
43
+ queue = Queue.new
44
+ items.each_with_index { |item, index| queue << [item, index] }
45
+
46
+ worker_count = [items.size, MAX_THREADS].min
47
+ workers = Array.new(worker_count) do
48
+ Thread.new do
49
+ # We re-raise from Thread#value below, so silence Ruby's automatic
50
+ # "terminated with exception" dump to stderr -- it would otherwise
51
+ # print the same error twice.
52
+ Thread.current.report_on_exception = false
53
+ loop do
54
+ begin
55
+ item, index = queue.pop(true)
56
+ rescue ThreadError
57
+ break # queue drained
58
+ end
59
+ yield item, index
60
+ end
61
+ end
62
+ end
63
+ workers.each(&:value)
64
+ end
65
+ private_class_method :process
66
+ end
67
+ end
data/lib/git/report.rb ADDED
@@ -0,0 +1,169 @@
1
+ # Git report module for analyzing repository statistics
2
+ require 'shellwords'
3
+
4
+ module Git
5
+ # Report class - generates statistics about git repository contributors
6
+ # Analyzes commits, lines of code, and file changes per author
7
+ class Report
8
+ attr_reader :authors
9
+
10
+ NAME = /\A[^(]*\((.*)\s*\d{4}-\d\d-\d\d \d\d:\d\d:\d\d [+-]\d{4}\s+\d+\)/
11
+ COMMITS_NAME_EMAIL = /\A\s*(\d*)\t(.*) <(.*)>/
12
+ HEADER = {
13
+ name: 'Name',
14
+ loc: 'LOC',
15
+ commits: 'Commits',
16
+ files: 'files',
17
+ loc_add: '+LOC',
18
+ loc_del: '-LOC'
19
+ }.freeze
20
+
21
+ def initialize
22
+ @authors = []
23
+ end
24
+
25
+ def retrieve_stats
26
+ verify_git_repository
27
+ parse_shortlog
28
+ parse_blame
29
+ self
30
+ end
31
+
32
+
33
+
34
+ def parse_shortlog
35
+ # First pass: get all authors (including those with only merges).
36
+ # HEAD is required: without an explicit revision and with a non-tty
37
+ # stdin (as when run via the `git report` alias) git shortlog reads the
38
+ # commit list from stdin and would otherwise return nothing.
39
+ `git shortlog -se HEAD`.split(/\n/).map do |shortlog_line|
40
+ add shortlog_line, commits: 0
41
+ end
42
+
43
+ # Second pass: get actual commit counts (excluding merges)
44
+ `git shortlog -se --no-merges HEAD`.split(/\n/).map do |shortlog_line|
45
+ add shortlog_line
46
+ end
47
+
48
+ Git::Parallel.peach(authors, &:retrieve_loc_stats)
49
+ end
50
+
51
+ def parse_blame
52
+ ls_files = `git ls-files`.split(/\n/)
53
+ ignore_files = `git status --porcelain`.split(/\n/)
54
+ ignore_files.map! { |file| file[3..-1] }
55
+ ls_files -= ignore_files
56
+
57
+ file_lists = ls_files.each_slice((ls_files.count**0.5).ceil).to_a
58
+ names_loc_count = Hash.new(0)
59
+ names_files = {}
60
+
61
+ Git::Parallel.peach(file_lists) do |files|
62
+ files.each do |file|
63
+ escaped_file = Shellwords.escape(file)
64
+ blame = `git blame -w #{escaped_file} 2> /dev/null`
65
+ next if blame.empty?
66
+ blame.unpack('C*').pack('C*').split(/\n/).map do |line|
67
+ name = line.match(NAME)[1].strip.force_encoding('UTF-8')
68
+ names_loc_count[name] += 1
69
+ names_files[name] ||= Set.new
70
+ names_files[name] << file
71
+ end
72
+ end
73
+ end
74
+ # find_author can be nil for lines git attributes to someone outside the
75
+ # shortlog history (e.g. "Not Committed Yet" for staged-but-uncommitted
76
+ # changes), so skip those rather than crash.
77
+ names_loc_count.each { |name, loc| find_author(name)&.loc = loc }
78
+ names_files.each { |name, files| find_author(name)&.files = files.size }
79
+ end
80
+
81
+ def add(line, opts = {})
82
+ shortlog_line = format_shortlog_line(*line.scan(COMMITS_NAME_EMAIL)[0])
83
+ shortlog_line[:commits] = opts[:commits] if opts[:commits]
84
+ candidate = Author.new(**shortlog_line)
85
+ @authors << candidate unless add_author candidate
86
+ end
87
+
88
+ def add_author(candidate)
89
+ merged = false
90
+ @authors.each do |author|
91
+ if author.mergable?(candidate)
92
+ author.merge(candidate)
93
+ merged = true
94
+ end
95
+ end
96
+ merged
97
+ end
98
+
99
+ def find_author(name)
100
+ @authors.find { |author| author.name == name }
101
+ end
102
+
103
+ def stats
104
+ authors.sort! { |this, other| other.name <=> this.name }
105
+ authors.sort! { |this, other| other.files <=> this.files }
106
+ authors.sort! { |this, other| other.loc <=> this.loc }
107
+
108
+ name_length = authors.map { |auth| auth.name.length }.max
109
+ loc_length = authors.map { |auth| auth.loc.to_s.length }.max
110
+ commits_length = authors.map { |auth| auth.commits.to_s.length }.max
111
+ files_length = authors.map { |auth| auth.files.to_s.length }.max
112
+ loc_add_length = authors.map { |auth| auth.loc_added.to_s.length }.max
113
+ loc_del_length = authors.map { |auth| auth.loc_deleted.to_s.length }.max
114
+
115
+ name_length = [name_length, HEADER[:name].length].max
116
+ loc_length = [loc_length, HEADER[:loc].length].max
117
+ commits_length = [commits_length, HEADER[:commits].length].max
118
+ files_length = [files_length, HEADER[:files].length].max
119
+ loc_add_length = [loc_add_length, HEADER[:loc_add].length].max
120
+ loc_del_length = [loc_del_length, HEADER[:loc_del].length].max
121
+
122
+ lines = []
123
+ hr = '+' + ('-' * name_length) + '--'
124
+ hr += '+' + ('-' * loc_length) + '--'
125
+ hr += '+' + ('-' * commits_length) + '--'
126
+ hr += '+' + ('-' * files_length) + '--'
127
+ hr += '+' + ('-' * loc_add_length) + '--'
128
+ hr += '+' + ('-' * loc_del_length) + '--+'
129
+
130
+ head = "| #{HEADER[:name].ljust(name_length)} "
131
+ head += "| #{HEADER[:loc].rjust(loc_length)} "
132
+ head += "| #{HEADER[:commits].rjust(commits_length)} "
133
+ head += "| #{HEADER[:files].rjust(files_length)} "
134
+ head += "| #{HEADER[:loc_add].rjust(loc_add_length)} "
135
+ head += "| #{HEADER[:loc_del].to_s.rjust(loc_del_length)} |"
136
+
137
+ lines << hr
138
+ lines << head
139
+ lines << hr
140
+ authors.each do |author|
141
+ # Skip authors with no contributions
142
+ next if [author.loc, author.commits, author.files, author.loc_added,
143
+ author.loc_deleted].all?(&:zero?)
144
+
145
+ line = "| #{author.name.unicode_normalize.ljust(name_length)} "
146
+ line += "| #{author.loc.to_s.rjust(loc_length)} "
147
+ line += "| #{author.commits.to_s.rjust(commits_length)} "
148
+ line += "| #{author.files.to_s.rjust(files_length)} "
149
+ line += "| #{author.loc_added.to_s.rjust(loc_add_length)} "
150
+ line += "| #{author.loc_deleted.to_s.rjust(loc_del_length)} |"
151
+ lines << line
152
+ end
153
+ lines << hr
154
+ lines
155
+ end
156
+
157
+ private
158
+
159
+ def verify_git_repository
160
+ unless system('git rev-parse --git-dir > /dev/null 2>&1')
161
+ raise "Not a git repository (or any of the parent directories)"
162
+ end
163
+ end
164
+
165
+ def format_shortlog_line(commits, name, email)
166
+ { name: name, emails: email, commits: commits.to_i }
167
+ end
168
+ end
169
+ end
data/lib/git_report.rb ADDED
@@ -0,0 +1,8 @@
1
+ require 'set'
2
+
3
+ # Git module - contains classes for analyzing git repository statistics
4
+ module Git
5
+ # Auto-load all classes in the git subdirectory (sorted for deterministic
6
+ # load order across platforms)
7
+ Dir[File.join(File.dirname(__FILE__), 'git', '**', '*.rb')].sort.each(&method(:require))
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem.pre_uninstall do |uninstaller|
4
+ bin_dir = uninstaller.spec.bin_dir
5
+ git_remove_alias_report = File.join(bin_dir, 'git_remove_alias_report')
6
+ puts `#{git_remove_alias_report}`
7
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ # Report carries the gem version, read from the VERSION file at the repo root.
5
+ class Report
6
+ VERSION_FILE = File.expand_path('../VERSION', __dir__)
7
+ VERSION = File.exist?(VERSION_FILE) ? File.read(VERSION_FILE).strip : 'unknown'
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: git-author-report
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Wolfgang Teuber
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: git-author-report is a command line tool, run as `git report`, that prints
13
+ a per-author breakdown of code contribution (surviving LOC, lifetime +/-LOC, commits,
14
+ files) as an ASCII table.
15
+ email: knugie@gmx.net
16
+ executables:
17
+ - git-report
18
+ extensions:
19
+ - ext/git_report/extconf.rb
20
+ extra_rdoc_files: []
21
+ files:
22
+ - VERSION
23
+ - bin/git-report
24
+ - bin/git_add_alias_report
25
+ - bin/git_remove_alias_report
26
+ - ext/git_report/Makefile
27
+ - ext/git_report/extconf.rb
28
+ - lib/git/author.rb
29
+ - lib/git/parallel.rb
30
+ - lib/git/report.rb
31
+ - lib/git_report.rb
32
+ - lib/rubygems_plugin.rb
33
+ - lib/version.rb
34
+ homepage: https://github.com/wteuber/git-author-report
35
+ licenses:
36
+ - MIT
37
+ metadata:
38
+ source_code_uri: https://github.com/wteuber/git-author-report
39
+ rubygems_mfa_required: 'true'
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.6'
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 4.0.10
55
+ specification_version: 4
56
+ summary: Per-author contribution report for any git repository
57
+ test_files: []