git_statistics 0.5.1 → 0.6.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.
- data/bin/git-statistics +2 -1
- data/bin/git_statistics +2 -1
- data/lib/git_statistics.rb +69 -21
- data/lib/git_statistics/branches.rb +35 -0
- data/lib/git_statistics/collector.rb +20 -45
- data/lib/git_statistics/commit_line_extractor.rb +8 -8
- data/lib/git_statistics/commits.rb +7 -4
- data/lib/git_statistics/formatters/console.rb +50 -47
- data/lib/git_statistics/initialize.rb +0 -3
- data/lib/git_statistics/log.rb +59 -0
- data/lib/git_statistics/pipe.rb +29 -0
- data/lib/git_statistics/utilities.rb +33 -23
- data/lib/git_statistics/version.rb +1 -1
- data/spec/branches_spec.rb +44 -0
- data/spec/collector_spec.rb +20 -41
- data/spec/commits_spec.rb +51 -47
- data/spec/formatters/console_spec.rb +25 -18
- data/spec/log_spec.rb +54 -0
- data/spec/pipe_spec.rb +59 -0
- data/spec/utilities_spec.rb +65 -63
- metadata +52 -9
- data/lib/git_statistics/core_ext/string.rb +0 -11
data/bin/git-statistics
CHANGED
data/bin/git_statistics
CHANGED
data/lib/git_statistics.rb
CHANGED
@@ -1,50 +1,98 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
require 'optparse'
|
1
3
|
require 'git_statistics/initialize'
|
2
4
|
|
3
5
|
module GitStatistics
|
4
6
|
class GitStatistics
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
7
|
+
attr_reader :options
|
8
|
+
def initialize
|
9
|
+
@options = OpenStruct.new(
|
10
|
+
email: false,
|
11
|
+
merges: false,
|
12
|
+
pretty: false,
|
13
|
+
update: false,
|
14
|
+
sort: "commits",
|
15
|
+
top: 0,
|
16
|
+
branch: false,
|
17
|
+
verbose: false,
|
18
|
+
debug: false,
|
19
|
+
limit: 100
|
20
|
+
)
|
21
|
+
parse_options
|
17
22
|
end
|
18
23
|
|
19
24
|
def execute
|
25
|
+
if options.debug
|
26
|
+
Log.level = Logger::DEBUG
|
27
|
+
Log.use_debug
|
28
|
+
elsif options.verbose
|
29
|
+
Log.level = Logger::INFO
|
30
|
+
end
|
31
|
+
|
20
32
|
# Collect data (incremental or fresh) based on presence of old data
|
21
|
-
if
|
33
|
+
if options.update
|
22
34
|
# Ensure commit directory is present
|
23
|
-
collector = Collector.new(
|
24
|
-
commits_directory = collector.repo_path
|
35
|
+
collector = Collector.new(options.limit, false, options.pretty)
|
36
|
+
commits_directory = File.join(collector.repo_path, ".git_statistics")
|
25
37
|
FileUtils.mkdir_p(commits_directory)
|
26
38
|
file_count = Utilities.number_of_matching_files(commits_directory, /\d+\.json/) - 1
|
27
39
|
|
28
|
-
# Only use --since if there is data present
|
29
40
|
if file_count >= 0
|
30
41
|
time = Utilities.get_modified_time(commits_directory + "#{file_count}.json")
|
31
|
-
|
42
|
+
# Only use --since if there is data present
|
43
|
+
collector.collect(options.branch, "--since=\"#{time}\"")
|
32
44
|
collected = true
|
33
45
|
end
|
34
46
|
end
|
35
47
|
|
36
48
|
# If no data was collected as there was no present data then start fresh
|
37
49
|
unless collected
|
38
|
-
collector = Collector.new(
|
39
|
-
collector.collect(
|
50
|
+
collector = Collector.new(options.limit, true, options.pretty)
|
51
|
+
collector.collect(options.branch)
|
40
52
|
end
|
41
53
|
|
42
54
|
# Calculate statistics
|
43
|
-
collector.commits.calculate_statistics(
|
55
|
+
collector.commits.calculate_statistics(options.email, options.merges)
|
44
56
|
|
45
57
|
# Print results
|
46
58
|
results = Formatters::Console.new(collector.commits)
|
47
|
-
puts results.print_summary(
|
59
|
+
puts results.print_summary(options.sort, options.email, options.top)
|
60
|
+
end
|
61
|
+
|
62
|
+
def parse_options
|
63
|
+
OptionParser.new do |opt|
|
64
|
+
opt.version = VERSION
|
65
|
+
opt.on "-e", "--email", "Use author's email instead of name" do
|
66
|
+
options.email = true
|
67
|
+
end
|
68
|
+
opt.on "-m", "--merges", "Factor in merges when calculating statistics" do
|
69
|
+
options.merges = true
|
70
|
+
end
|
71
|
+
opt.on "-p", "--pretty", "Save the commits in git_repo/.git_statistics in pretty print (larger file size)" do
|
72
|
+
options.pretty = true
|
73
|
+
end
|
74
|
+
opt.on "-u", "--update", "Update saved commits with new data" do
|
75
|
+
options.update = true
|
76
|
+
end
|
77
|
+
opt.on "-s", "--sort TYPE", "Sort authors by {commits, additions, deletions, create, delete, rename, copy, merges}" do |type|
|
78
|
+
options.sort = type
|
79
|
+
end
|
80
|
+
opt.on "-t", "--top N", Float,"Show the top N authors in results" do |value|
|
81
|
+
options.top = value
|
82
|
+
end
|
83
|
+
opt.on "-b", "--branch", "Use current branch for statistics (otherwise all branches)" do
|
84
|
+
options.branch = true
|
85
|
+
end
|
86
|
+
opt.on "-v", "--verbose", "Verbose output (shows INFO level log statements)" do
|
87
|
+
options.verbose = true
|
88
|
+
end
|
89
|
+
opt.on "-d", "--debug", "Debug output (shows DEBUG level log statements)" do
|
90
|
+
options.debug = true
|
91
|
+
end
|
92
|
+
opt.on "-l", "--limit MAX_COMMITS", Float, "The maximum limit of commits to hold in memory at a time" do |number|
|
93
|
+
options.limit = number
|
94
|
+
end
|
95
|
+
end.parse!
|
48
96
|
end
|
49
97
|
end
|
50
98
|
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module GitStatistics
|
2
|
+
class Branches
|
3
|
+
|
4
|
+
CURRENT_BRANCH = /\A\*\s/
|
5
|
+
NO_BRANCH = /no branch/i
|
6
|
+
|
7
|
+
def self.all
|
8
|
+
list.collect { |branch| branch.sub(CURRENT_BRANCH, "") }
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.current
|
12
|
+
return '(none)' if detached?
|
13
|
+
list.detect { |branch| branch =~ CURRENT_BRANCH }.sub(CURRENT_BRANCH, "")
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.detached?
|
17
|
+
stripped.grep(NO_BRANCH).any?
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def self.list
|
23
|
+
stripped.reject { |b| b =~ NO_BRANCH }
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.stripped
|
27
|
+
pipe.map(&:strip)
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.pipe
|
31
|
+
Pipe.new("git --no-pager branch --no-color")
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
end
|
@@ -1,38 +1,29 @@
|
|
1
1
|
module GitStatistics
|
2
2
|
class Collector
|
3
3
|
|
4
|
-
attr_accessor :repo, :repo_path, :commits_path, :commits
|
4
|
+
attr_accessor :repo, :repo_path, :commits_path, :commits
|
5
5
|
|
6
|
-
def initialize(
|
7
|
-
@verbose = verbose
|
6
|
+
def initialize(limit, fresh, pretty)
|
8
7
|
@repo = Utilities.get_repository
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
@repo_path = File.expand_path("..", @repo.path) + File::Separator
|
13
|
-
@commits_path = @repo_path + ".git_statistics" + File::Separator
|
8
|
+
@repo_path = File.expand_path("..", @repo.path)
|
9
|
+
@commits_path = File.join(@repo_path, ".git_statistics")
|
14
10
|
@commits = Commits.new(@commits_path, fresh, limit, pretty)
|
15
11
|
end
|
16
12
|
|
17
13
|
def collect(branch, time_since = "", time_until = "")
|
18
|
-
# Create pipe for git log to acquire branches
|
19
|
-
pipe = open("|git --no-pager branch --no-color")
|
20
|
-
|
21
14
|
# Collect branches to use for git log
|
22
|
-
branches = branch ? [
|
15
|
+
branches = branch ? [] : Branches.all
|
23
16
|
|
24
17
|
# Create pipe for the git log to acquire commits
|
25
|
-
pipe =
|
26
|
-
|
27
|
-
|
28
|
-
|
18
|
+
pipe = Pipe.new("git --no-pager log #{branches.join(' ')} --date=iso --reverse"\
|
19
|
+
" --no-color --find-copies-harder --numstat --encoding=utf-8"\
|
20
|
+
" --summary #{time_since} #{time_until}"\
|
21
|
+
" --format=\"%H,%an,%ae,%ad,%p\"")
|
29
22
|
|
30
23
|
# Use a buffer approach to queue up lines from the log for each commit
|
31
24
|
buffer = []
|
32
25
|
pipe.each do |line|
|
33
26
|
|
34
|
-
line = line.clean_for_authors
|
35
|
-
|
36
27
|
# Extract the buffer (commit) when we match ','x5 in the log format (delimeter)
|
37
28
|
if line.split(',').size == 5
|
38
29
|
|
@@ -44,7 +35,6 @@ module GitStatistics
|
|
44
35
|
|
45
36
|
# Save commits to file if size exceeds limit or forced
|
46
37
|
@commits.flush_commits
|
47
|
-
@repo = Utilities.get_repository
|
48
38
|
end
|
49
39
|
|
50
40
|
buffer << line
|
@@ -57,33 +47,18 @@ module GitStatistics
|
|
57
47
|
|
58
48
|
def fall_back_collect_commit(sha)
|
59
49
|
# Create pipe for the git log to acquire commits
|
60
|
-
pipe =
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
buffer = pipe.map { |line| line.clean_for_authors }
|
50
|
+
pipe = Pipe.new("git --no-pager show #{sha} --date=iso --reverse"\
|
51
|
+
" --no-color --find-copies-harder --numstat --encoding=utf-8 "\
|
52
|
+
"--summary --format=\"%H,%an,%ae,%ad,%p\"")
|
65
53
|
|
66
54
|
# Check that the buffer has valid information (i.e., sha was valid)
|
67
|
-
if !
|
68
|
-
|
55
|
+
if !pipe.empty? && pipe.first.split(',').first == sha
|
56
|
+
pipe.to_a
|
69
57
|
else
|
70
|
-
|
58
|
+
[]
|
71
59
|
end
|
72
60
|
end
|
73
61
|
|
74
|
-
def collect_branches(pipe)
|
75
|
-
# Acquire all available branches from repository
|
76
|
-
branches = []
|
77
|
-
pipe.each do |line|
|
78
|
-
|
79
|
-
# Remove the '*' leading the current branch
|
80
|
-
line = line[1..-1] if line[0] == '*'
|
81
|
-
branches << line.clean_for_authors
|
82
|
-
end
|
83
|
-
|
84
|
-
return branches
|
85
|
-
end
|
86
|
-
|
87
62
|
def acquire_commit_data(line)
|
88
63
|
# Split up formated line
|
89
64
|
commit_info = line.split(',')
|
@@ -109,11 +84,11 @@ module GitStatistics
|
|
109
84
|
# Acquire general commit information
|
110
85
|
commit_data = acquire_commit_data(buffer[0])
|
111
86
|
|
112
|
-
|
87
|
+
Log.info "Extracting #{commit_data[:sha]}"
|
113
88
|
|
114
89
|
# Abort if the commit sha extracted form the buffer is invalid
|
115
90
|
if commit_data[:sha].scan(/[\d|a-f]{40}/)[0].nil?
|
116
|
-
|
91
|
+
Log.warn "Invalid buffer containing commit information"
|
117
92
|
return
|
118
93
|
end
|
119
94
|
|
@@ -122,7 +97,7 @@ module GitStatistics
|
|
122
97
|
|
123
98
|
# No files were changed in this commit, abort commit
|
124
99
|
if files.nil?
|
125
|
-
|
100
|
+
Log.debug "No files were changed"
|
126
101
|
return
|
127
102
|
end
|
128
103
|
|
@@ -134,9 +109,9 @@ module GitStatistics
|
|
134
109
|
if blob.instance_of?(Grit::Blob)
|
135
110
|
process_blob(commit_data[:data], blob, file)
|
136
111
|
elsif blob.instance_of?(Grit::Submodule)
|
137
|
-
|
112
|
+
Log.debug "Ignoring submodule #{blob.name}"
|
138
113
|
else
|
139
|
-
|
114
|
+
Log.warn "Problem processing file #{file[:file]}"
|
140
115
|
end
|
141
116
|
end
|
142
117
|
return commit_data[:data]
|
@@ -17,31 +17,31 @@ module GitStatistics
|
|
17
17
|
split_file = Utilities.split_old_new_file(changes[2], changes[3])
|
18
18
|
{:additions => changes[0].to_i,
|
19
19
|
:deletions => changes[1].to_i,
|
20
|
-
:file => split_file[:new_file]
|
21
|
-
:old_file => split_file[:old_file]
|
20
|
+
:file => split_file[:new_file],
|
21
|
+
:old_file => split_file[:old_file]}
|
22
22
|
end
|
23
23
|
return modified_or_renamed unless modified_or_renamed.empty?
|
24
24
|
|
25
25
|
AdditionsOrDeletions.if_matches(line) do |changes|
|
26
26
|
{:additions => changes[0].to_i,
|
27
27
|
:deletions => changes[1].to_i,
|
28
|
-
:file => changes[2]
|
28
|
+
:file => changes[2]}
|
29
29
|
end
|
30
30
|
end
|
31
31
|
|
32
32
|
def created_or_deleted
|
33
33
|
CreatedOrDeleted.if_matches(line) do |changes|
|
34
|
-
{:status => changes[0]
|
35
|
-
:file => changes[1]
|
34
|
+
{:status => changes[0],
|
35
|
+
:file => changes[1]}
|
36
36
|
end
|
37
37
|
end
|
38
38
|
|
39
39
|
def renamed_or_copied
|
40
40
|
RenamedOrCopied.if_matches(line) do |changes|
|
41
41
|
split_file = Utilities.split_old_new_file(changes[1], changes[2])
|
42
|
-
{:status => changes[0]
|
43
|
-
:old_file => split_file[:old_file]
|
44
|
-
:new_file => split_file[:new_file]
|
42
|
+
{:status => changes[0],
|
43
|
+
:old_file => split_file[:old_file],
|
44
|
+
:new_file => split_file[:new_file],
|
45
45
|
:similar => changes[3].to_i}
|
46
46
|
end
|
47
47
|
end
|
@@ -14,12 +14,11 @@ module GitStatistics
|
|
14
14
|
|
15
15
|
def clean
|
16
16
|
# Ensure the path exists
|
17
|
-
FileUtils.mkdir_p(
|
17
|
+
FileUtils.mkdir_p(path)
|
18
18
|
|
19
19
|
# Remove all files within path if saving
|
20
|
-
if
|
21
|
-
|
22
|
-
next if %w[. ..].include? file
|
20
|
+
if fresh
|
21
|
+
files_in_path.each do |file|
|
23
22
|
File.delete(File.join(path, file))
|
24
23
|
end
|
25
24
|
end
|
@@ -30,6 +29,10 @@ module GitStatistics
|
|
30
29
|
@totals[:languages] = {}
|
31
30
|
end
|
32
31
|
|
32
|
+
def files_in_path
|
33
|
+
Dir.entries(path).reject { |file| %w[. ..].include?(file) }
|
34
|
+
end
|
35
|
+
|
33
36
|
def flush_commits(force = false)
|
34
37
|
if size >= limit || force
|
35
38
|
file_count = Utilities.number_of_matching_files(path, /\d+\.json/)
|
@@ -2,7 +2,7 @@ module GitStatistics
|
|
2
2
|
module Formatters
|
3
3
|
class Console
|
4
4
|
|
5
|
-
attr_accessor :commits
|
5
|
+
attr_accessor :commits, :config
|
6
6
|
|
7
7
|
def initialize(commits)
|
8
8
|
@commits = commits
|
@@ -17,7 +17,7 @@ module GitStatistics
|
|
17
17
|
raise "Parameter for --sort is not valid" if data.nil?
|
18
18
|
|
19
19
|
# Create config
|
20
|
-
config = {:data => data,
|
20
|
+
@config = {:data => data,
|
21
21
|
:author_length => Utilities.max_length_in_list(data.keys, 17),
|
22
22
|
:language_length => Utilities.max_length_in_list(@commits.totals[:languages].keys, 8),
|
23
23
|
:sort => sort,
|
@@ -25,70 +25,73 @@ module GitStatistics
|
|
25
25
|
:top_n => top_n}
|
26
26
|
|
27
27
|
# Acquire formatting pattern for output
|
28
|
-
pattern = "%-#{config[:author_length]}s | %-#{config[:language_length]}s | %7s | %9s | %9s | %7s | %7s | %7s | %6s | %6s |"
|
29
|
-
config
|
30
|
-
return config
|
28
|
+
@pattern = "| %-#{config[:author_length]}s | %-#{config[:language_length]}s | %7s | %9s | %9s | %7s | %7s | %7s | %6s | %6s |"
|
29
|
+
config
|
31
30
|
end
|
32
31
|
|
33
32
|
def print_summary(sort, email, top_n = 0)
|
34
33
|
# Prepare and determine the config for the result summary based on parameters
|
34
|
+
commit_totals = @commits.totals
|
35
35
|
config = prepare_result_summary(sort, email, top_n)
|
36
36
|
|
37
37
|
# Print query/header information
|
38
|
-
output = print_header
|
38
|
+
output = print_header
|
39
39
|
|
40
40
|
# Print per author information
|
41
|
-
config[:data].each do |
|
42
|
-
output
|
43
|
-
|
44
|
-
value[:rename], value[:copy], value[:merges]]
|
45
|
-
output += "\n"
|
46
|
-
output += print_language_data(config[:pattern], value)
|
41
|
+
config[:data].each do |name, commit_data|
|
42
|
+
output << print_row(name, commit_data)
|
43
|
+
output << print_language_data(commit_data)
|
47
44
|
end
|
48
|
-
|
49
|
-
|
50
|
-
output
|
51
|
-
output
|
52
|
-
data = @commits.totals
|
53
|
-
output += config[:pattern] % ["Repository Totals", "", data[:commits],
|
54
|
-
data[:additions], data[:deletions], data[:create],
|
55
|
-
data[:delete], data[:rename], data[:copy], data[:merges]]
|
56
|
-
output += "\n"
|
57
|
-
output += print_language_data(config[:pattern], data)
|
58
|
-
return output
|
45
|
+
output << separator
|
46
|
+
output << print_row("Repository Totals", commit_totals)
|
47
|
+
output << print_language_data(commit_totals)
|
48
|
+
output.flatten.join("\n")
|
59
49
|
end
|
60
50
|
|
61
|
-
def print_language_data(
|
62
|
-
output =
|
51
|
+
def print_language_data(data)
|
52
|
+
output = []
|
63
53
|
# Print information of each language for the data
|
64
|
-
data[:languages].each do |
|
65
|
-
output
|
66
|
-
value[:create], value[:delete], value[:rename],
|
67
|
-
value[:copy], value[:merges]]
|
68
|
-
output += "\n"
|
54
|
+
data[:languages].each do |language, commit_data|
|
55
|
+
output << print_row("", commit_data, language)
|
69
56
|
end
|
70
|
-
|
57
|
+
output
|
58
|
+
end
|
59
|
+
|
60
|
+
def print_row(name, commit_info, language = '')
|
61
|
+
format_for_row(name, language, commit_info[:commits],
|
62
|
+
commit_info[:additions], commit_info[:deletions], commit_info[:create],
|
63
|
+
commit_info[:delete], commit_info[:rename], commit_info[:copy], commit_info[:merges])
|
64
|
+
end
|
65
|
+
|
66
|
+
def print_header
|
67
|
+
output = []
|
68
|
+
output << get_author_info(@commits.stats.size)
|
69
|
+
output << get_header_info
|
70
|
+
output
|
71
71
|
end
|
72
72
|
|
73
|
-
def
|
74
|
-
|
73
|
+
def get_header_info
|
74
|
+
headers = []
|
75
|
+
headers << separator
|
76
|
+
headers << format_for_row('Name/Email', 'Language', 'Commits', 'Additions', 'Deletions', 'Creates', 'Deletes', 'Renames', 'Copies', 'Merges')
|
77
|
+
headers << separator
|
78
|
+
headers
|
79
|
+
end
|
80
|
+
|
81
|
+
def format_for_row(*columns)
|
82
|
+
@pattern % columns
|
83
|
+
end
|
84
|
+
|
85
|
+
def separator
|
86
|
+
"-" * 89 + "-"*config[:author_length] + "-"*config[:language_length]
|
87
|
+
end
|
75
88
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
output += "Top #{config[:top_n]} authors(#{total_authors}) sorted by #{config[:sort]}\n"
|
80
|
-
else
|
81
|
-
output += "All authors(#{total_authors}) sorted by #{config[:sort]}\n"
|
89
|
+
def get_author_info(total_authors)
|
90
|
+
if config[:top_n] > 0 && config[:top_n] < total_authors
|
91
|
+
return "Top #{config[:top_n]} authors(#{total_authors}) sorted by #{config[:sort]}"
|
82
92
|
end
|
83
93
|
|
84
|
-
#
|
85
|
-
output += "-"*87 + "-"*config[:author_length] + "-"*config[:language_length]
|
86
|
-
output += "\n"
|
87
|
-
output += config[:pattern] % ['Name/Email', 'Language', 'Commits', 'Additions', 'Deletions', 'Creates', 'Deletes', 'Renames', 'Copies', 'Merges']
|
88
|
-
output += "\n"
|
89
|
-
output += "-"*87 + "-"*config[:author_length] + "-"*config[:language_length]
|
90
|
-
output += "\n"
|
91
|
-
return output
|
94
|
+
"All authors(#{total_authors}) sorted by #{config[:sort]}"
|
92
95
|
end
|
93
96
|
end
|
94
97
|
end
|