Implement git statistics on git trees (directories)

The current statistics refer to the complete git repository.  With this
commit it is possible to generate statistics on a given tree.
This commit is contained in:
Israel Revert 2014-03-30 08:01:39 +02:00
parent 47788e1012
commit 1cc3198955
20 changed files with 399 additions and 20 deletions

3
.gitmodules vendored
View File

@ -1,3 +1,6 @@
[submodule "spec/integration/test_repo"]
path = spec/integration/test_repo
url = git://github.com/tomgi/git_stats_test_repo.git
[submodule "spec/integration/test_repo_tree"]
path = spec/integration/test_repo_tree
url = git://github.com/irevert/git_stats_test_repo_tree

View File

@ -1,6 +1,7 @@
en:
project_name: Project name
project_version: Project version
tree_path: Tree path
generated_at: Generated at
generator: Generator
report_period: Report period
@ -57,4 +58,4 @@ en:
details: Details
insertions_by_date: Lines added by date
deletions_by_date: Lines deleted by date
changed_lines_by_date: Changed lines by date
changed_lines_by_date: Changed lines by date

View File

@ -18,6 +18,7 @@ require 'git_stats/git_data/command_runner'
require 'git_stats/git_data/commit'
require 'git_stats/git_data/repo'
require 'git_stats/git_data/short_stat'
require 'git_stats/git_data/tree'
require 'git_stats/stats_view/template'
require 'git_stats/stats_view/view'

View File

@ -9,11 +9,12 @@ class GitStats::CLI < Thor
option :from, :aliases => :f, :desc => 'Commit from where statistics should start.'
option :to, :aliases => :t, :default => 'HEAD', :desc => 'Commit where statistics should stop.'
option :silent, :aliases => :s, :type => :boolean, :desc => 'Silent mode. Don\'t output anything.'
option :tree, :aliases => :d, :default => '.', :desc => 'Tree where statistics should be generated.'
desc 'generate', 'Generates the statistics of a repository'
def generate
I18n.locale = options[:language]
GitStats::Generator.new(options[:path], options[:output], options[:from], options[:to]) { |g|
GitStats::Generator.new(options[:path], options[:output], options[:from], options[:to], options[:tree]) { |g|
g.add_command_observer { |command, result| puts "#{command}" } unless options[:silent]
}.render_all
end

View File

@ -4,10 +4,10 @@ module GitStats
delegate :add_command_observer, to: :@repo
delegate :render_all, to: :@view
def initialize(repo_path, out_path, first_commit_sha = nil, last_commit_sha = "HEAD")
def initialize(repo_path, out_path, first_commit_sha = nil, last_commit_sha = "HEAD", tree_path = ".")
validate_repo_path(repo_path)
@repo = GitData::Repo.new(path: repo_path, first_commit_sha: first_commit_sha, last_commit_sha: last_commit_sha)
@repo = GitData::Repo.new(path: repo_path, first_commit_sha: first_commit_sha, last_commit_sha: last_commit_sha, tree_path: tree_path)
view_data = StatsView::ViewData.new(@repo)
@view = StatsView::View.new(view_data, out_path)

View File

@ -9,7 +9,7 @@ module GitStats
attr_reader :repo, :sha, :stamp, :date, :author
def files
@files ||= repo.run_and_parse("git ls-tree -r #{self.sha}").map do |file|
@files ||= repo.run_and_parse("git ls-tree -r #{self.sha} -- #{repo.tree_path}").map do |file|
Blob.new(repo: repo, filename: file[:filename], sha: file[:sha])
end.extend(ByFieldFinder)
end
@ -37,11 +37,11 @@ module GitStats
end
def files_count
@files_count ||= repo.run("git ls-tree -r --name-only #{self.sha} | wc -l").to_i
@files_count ||= repo.run("git ls-tree -r --name-only #{self.sha} -- #{repo.tree_path}| wc -l").to_i
end
def lines_count
@lines_count ||= repo.run("git diff --shortstat `git hash-object -t tree /dev/null` #{self.sha}").lines.map do |line|
@lines_count ||= repo.run("git diff --shortstat `git hash-object -t tree /dev/null` #{self.sha} -- #{repo.tree_path}").lines.map do |line|
line[/(\d+) insertions?/, 1].to_i
end.sum
end

View File

@ -6,7 +6,7 @@ module GitStats
class Repo
include HashInitializable
attr_reader :path, :first_commit_sha, :last_commit_sha
attr_reader :path, :first_commit_sha, :last_commit_sha, :tree_path
delegate :files, :files_by_extension, :files_by_extension_count, :lines_by_extension,
:files_count, :binary_files, :text_files, :lines_count, to: :last_commit
@ -14,16 +14,17 @@ module GitStats
def initialize(params)
super(params)
@path = File.expand_path(@path)
@tree_path ||= "."
end
def authors
@authors ||= run_and_parse("git shortlog -se #{commit_range}").map do |author|
@authors ||= run_and_parse("git shortlog -se #{commit_range} #{tree_path}").map do |author|
Author.new(repo: self, name: author[:name], email: author[:email])
end.extend(ByFieldFinder)
end
def commits
@commits ||= run_and_parse("git rev-list --pretty=format:'%h|%at|%ai|%aE' #{commit_range} | grep -v commit").map do |commit_line|
@commits ||= run_and_parse("git rev-list --pretty=format:'%h|%at|%ai|%aE' #{commit_range} #{tree_path} | grep -v commit").map do |commit_line|
Commit.new(
repo: self,
sha: commit_line[:sha],
@ -87,8 +88,13 @@ module GitStats
@project_version ||= run("git rev-parse --short #{commit_range}").strip
end
def tree
@tree ||= Tree.new(repo: self, relative_path: @tree_path)
end
def project_name
@project_name ||= File.basename(path)
# @project_name ||= File.basename(path)
@project_name ||= (File.expand_path(File.join(path, tree_path)).sub(File.dirname(File.expand_path(path))+File::SEPARATOR,"") || File.basename(path))
end
def run(command)
@ -132,6 +138,7 @@ module GitStats
command_observers.each { |o| o.call(command, result) }
end
end
end
end

View File

@ -19,7 +19,7 @@ module GitStats
private
def calculate_stat
stat_line = commit.repo.run("git show --shortstat --oneline #{commit.sha}").lines.to_a[1]
stat_line = commit.repo.run("git show --shortstat --oneline #{commit.sha} -- #{commit.repo.tree_path}").lines.to_a[1]
if stat_line.blank?
@files_changed = @insertions = @deletions = 0
else

View File

@ -0,0 +1,25 @@
# -*- encoding : utf-8 -*-
require 'git_stats/hash_initializable'
module GitStats
module GitData
class Tree
include HashInitializable
attr_reader :repo, :relative_path
def initialize(params)
super(params)
end
def authors
@authors ||= run_and_parse("git shortlog -se #{commit_range}").map do |author|
Author.new(repo: self, name: author[:name], email: author[:email])
end.extend(ByFieldFinder)
end
def ==(other)
((self.repo == other.repo) && (self.relative_path == other.relative_path))
end
end
end
end

View File

@ -7,6 +7,9 @@ FactoryGirl.define do
factory :test_repo do
path 'spec/integration/test_repo'
end
factory :test_repo_tree do
path 'spec/integration/test_repo_tree'
end
end
factory :author, class: GitStats::GitData::Author do
@ -22,4 +25,9 @@ FactoryGirl.define do
association :repo, strategy: :build
association :author, strategy: :build
end
factory :tree, class: GitStats::GitData::Tree do
association :repo, strategy: :build
end
end

View File

@ -28,12 +28,12 @@ describe GitStats::GitData::Repo do
let(:repo) { build(:repo, first_commit_sha: 'abc', last_commit_sha: 'def') }
it 'should affect authors command' do
repo.should_receive(:run).with('git shortlog -se abc..def').and_return("")
repo.should_receive(:run).with('git shortlog -se abc..def .').and_return("")
repo.authors
end
it 'should affect commits command' do
repo.should_receive(:run).with("git rev-list --pretty=format:'%h|%at|%ai|%aE' abc..def | grep -v commit").and_return("")
repo.should_receive(:run).with("git rev-list --pretty=format:'%h|%at|%ai|%aE' abc..def . | grep -v commit").and_return("")
repo.commits
end

View File

@ -7,7 +7,7 @@ describe GitStats::GitData::Commit do
describe 'git output parsing' do
context 'parsing git ls-tree output' do
before {
commit.repo.should_receive(:run).with('git ls-tree -r abc').and_return("100644 blob 5ade7ad51a75ee7db4eb06cecd3918d38134087d lib/git_stats/git_data/commit.rb
commit.repo.should_receive(:run).with('git ls-tree -r abc -- .').and_return("100644 blob 5ade7ad51a75ee7db4eb06cecd3918d38134087d lib/git_stats/git_data/commit.rb
100644 blob db01e94677a8f72289848e507a52a43de2ea109a lib/git_stats/git_data/repo.rb
100644 blob 1463eacb3ac9f95f21f360f1eb935a84a9ee0895 templates/index.haml
100644 blob 31d8b960a67f195bdedaaf9e7aa70b2389f3f1a8 templates/assets/bootstrap/css/bootstrap.min.css

View File

@ -15,7 +15,7 @@ describe GitStats::Generator do
it 'should pass command observer to repo' do
repo = double('repo')
GitStats::GitData::Repo.should_receive(:new).with(path: repo_path, first_commit_sha: nil, last_commit_sha: "HEAD").and_return(repo)
GitStats::GitData::Repo.should_receive(:new).with(path: repo_path, first_commit_sha: nil, last_commit_sha: "HEAD", tree_path: ".").and_return(repo)
generator = GitStats::Generator.new(repo_path, out_path)
@ -27,7 +27,7 @@ describe GitStats::Generator do
it 'should render all templates with view data for this repo' do
repo = double('repo')
GitStats::GitData::Repo.should_receive(:new).with(path: repo_path, first_commit_sha: nil, last_commit_sha: "HEAD").and_return(repo)
GitStats::GitData::Repo.should_receive(:new).with(path: repo_path, first_commit_sha: nil, last_commit_sha: "HEAD", tree_path: ".").and_return(repo)
view_data = double('view_data')
GitStats::StatsView::ViewData.should_receive(:new).with(repo).and_return(view_data)

View File

@ -7,7 +7,7 @@ describe GitStats::GitData::Repo do
describe 'git output parsing' do
context 'invoking authors command' do
before do
repo.should_receive(:run).with('git shortlog -se HEAD').and_return(" 156 John Doe <john.doe@gmail.com>
repo.should_receive(:run).with('git shortlog -se HEAD .').and_return(" 156 John Doe <john.doe@gmail.com>
53 Joe Doe <joe.doe@gmail.com>
")
end
@ -19,7 +19,7 @@ describe GitStats::GitData::Repo do
end
it 'should parse git revlist output to date sorted commits array' do
repo.should_receive(:run).with("git rev-list --pretty=format:'%h|%at|%ai|%aE' HEAD | grep -v commit").and_return(
repo.should_receive(:run).with("git rev-list --pretty=format:'%h|%at|%ai|%aE' HEAD . | grep -v commit").and_return(
"e4412c3|1348603824|2012-09-25 22:10:24 +0200|john.doe@gmail.com
ce34874|1347482927|2012-09-12 22:48:47 +0200|joe.doe@gmail.com
5eab339|1345835073|2012-08-24 21:04:33 +0200|john.doe@gmail.com

View File

@ -14,7 +14,7 @@ describe GitStats::GitData::ShortStat do
{content: '', expect: [0, 0, 0]},
].each do |test|
it "#{test[:content]} parsing" do
commit.repo.should_receive(:run).with("git show --shortstat --oneline abc").and_return("abc some commit\n#{test[:content]}")
commit.repo.should_receive(:run).with("git show --shortstat --oneline abc -- .").and_return("abc some commit\n#{test[:content]}")
commit.short_stat.should be_a(GitStats::GitData::ShortStat)

View File

@ -0,0 +1,41 @@
# -*- encoding : utf-8 -*-
require 'spec_helper'
describe GitStats::GitData::Tree do
let(:repo) { build(:test_repo_tree, tree_path: '.') }
let(:repo_tree) { build(:test_repo_tree, tree_path: './subdir_with_1_commit') }
let(:tree) { build(:tree, repo: repo_tree, relative_path: './subdir_with_1_commit') }
describe 'tree git output parsing' do
it 'should return . by default' do
repo.tree.should == GitStats::GitData::Tree.new(repo: repo, relative_path: '.')
end
it 'should return relative_path given by parameter' do
repo_tree.tree.should == GitStats::GitData::Tree.new(repo: repo, relative_path: './subdir_with_1_commit')
repo_tree.tree.relative_path.should == './subdir_with_1_commit'
tree.relative_path.should == './subdir_with_1_commit'
end
context 'invoking authors command' do
before do
repo_tree.should_receive(:run).with('git shortlog -se HEAD ./subdir_with_1_commit').and_return(" 3 Israel Revert <israelrevert@gmail.com>
")
end
it 'should parse git shortlog output to authors hash' do
repo_tree.authors.should == [ build(:author, repo: repo_tree, name: "Israel Revert", email:"israelrevert@gmail.com") ]
end
it 'should parse git revlist output to date sorted commits array' do
repo_tree.should_receive(:run).
with("git rev-list --pretty=format:'%h|%at|%ai|%aE' HEAD ./subdir_with_1_commit | grep -v commit").
and_return("10d1814|1395407506|2014-03-21 14:11:46 +0100|israelrevert@gmail.com")
repo_tree.commits.should ==
[ GitStats::GitData::Commit.new( repo: repo, sha: "10d1814", stamp: "1395407506",
date: DateTime.parse("2014-03-21 14:11:46 +0100"),
author: repo.authors.by_email("israelrevert@gmail.com"))]
end
end
end
end

View File

@ -44,3 +44,94 @@ shared_context "shared" do
build(:author, repo: repo, name: "John Doe", email: "john.doe@gmail.com"),
] }
end
shared_context "tree_subdir_with_1_commit" do
let(:repo) { build(:test_repo_tree, last_commit_sha: 'HEAD', tree_path: './subdir_with_1_commit') }
let(:commit_dates) { [
DateTime.parse('2014-03-21 14:11:46 +0100'),
DateTime.parse('2014-03-21 14:12:23 +0100'),
DateTime.parse('2014-03-21 14:12:47 +0100'),
] }
let(:commit_dates_with_empty) {[
Date.new(2014, 03, 21),
Date.new(2014, 03, 21),
Date.new(2014, 03, 21),
]}
let(:tg_commit_dates) { [
DateTime.parse('2014-03-21 14:11:46 +0100'),
DateTime.parse('2014-03-21 14:12:23 +0100'),
DateTime.parse('2014-03-21 14:12:47 +0100'),
] }
let(:jd_commit_dates) { [
DateTime.parse('2014-03-21 14:11:46 +0100'),
DateTime.parse('2014-03-21 14:12:23 +0100'),
DateTime.parse('2014-03-21 14:12:47 +0100'),
] }
let(:expected_authors) { [
build(:author, repo: repo, name: "Israel Revert", email: "israelrevert@gmail.com"),
] }
end
shared_context "tree_subdir_with_2_commit" do
let(:repo) { build(:test_repo_tree, last_commit_sha: 'HEAD', tree_path: './subdir_with_2_commits') }
let(:commit_dates) { [
DateTime.parse('2014-03-21 14:11:46 +0100'),
DateTime.parse('2014-03-21 14:12:23 +0100'),
DateTime.parse('2014-03-21 14:12:47 +0100'),
] }
let(:commit_dates_with_empty) {[
Date.new(2014, 03, 21),
Date.new(2014, 03, 21),
Date.new(2014, 03, 21),
]}
let(:tg_commit_dates) { [
DateTime.parse('2014-03-21 14:11:46 +0100'),
DateTime.parse('2014-03-21 14:12:23 +0100'),
DateTime.parse('2014-03-21 14:12:47 +0100'),
] }
let(:jd_commit_dates) { [
DateTime.parse('2014-03-21 14:11:46 +0100'),
DateTime.parse('2014-03-21 14:12:23 +0100'),
DateTime.parse('2014-03-21 14:12:47 +0100'),
] }
let(:expected_authors) { [
build(:author, repo: repo, name: "Israel Revert", email: "israelrevert@gmail.com"),
] }
end
# 5fd0f5e|1395407567|2014-03-21 14:12:47 +0100|israelrevert@gmail.com
# 435e0ef|1395407543|2014-03-21 14:12:23 +0100|israelrevert@gmail.com
# 10d1814|1395407506|2014-03-21 14:11:46 +0100|israelrevert@gmail.com
shared_context "tree" do
let(:repo) { build(:test_repo_tree, last_commit_sha: 'HEAD') }
let(:commit_dates) { [
DateTime.parse('2014-03-21 14:11:46 +0100'),
DateTime.parse('2014-03-21 14:12:23 +0100'),
DateTime.parse('2014-03-21 14:12:47 +0100'),
] }
let(:commit_dates_with_empty) {[
Date.new(2014, 03, 21),
Date.new(2014, 03, 21),
Date.new(2014, 03, 21),
]}
let(:tg_commit_dates) { [
DateTime.parse('2014-03-21 14:11:46 +0100'),
DateTime.parse('2014-03-21 14:12:23 +0100'),
DateTime.parse('2014-03-21 14:12:47 +0100'),
] }
let(:jd_commit_dates) { [
DateTime.parse('2014-03-21 14:11:46 +0100'),
DateTime.parse('2014-03-21 14:12:23 +0100'),
DateTime.parse('2014-03-21 14:12:47 +0100'),
] }
let(:expected_authors) { [
build(:author, repo: repo, name: "Israel Revert", email: "israelrevert@gmail.com"),
] }
end

@ -0,0 +1 @@
Subproject commit 5fd0f5ea90e0ef34a0214ec9c170728913525ff4

View File

@ -0,0 +1,197 @@
# -*- encoding : utf-8 -*-
require 'integration/shared'
describe GitStats::GitData::Tree do
include_context "tree"
it 'should gather all authors' do
repo.authors.should =~ expected_authors
end
it 'should calculate correct commits period' do
repo.commits_period.should == [DateTime.parse('2014-03-21 14:11:46 +0100'),
DateTime.parse('2014-03-21 14:12:47 +0100')]
end
it 'should gather all commits sorted by date' do
repo.commits.map(&:sha).should =~ %w(10d1814 435e0ef 5fd0f5e)
end
it 'should return project name from dir' do
repo.project_name.should == 'test_repo_tree'
end
it 'should return project version as last commit hash' do
repo.project_version.should == '5fd0f5e'
end
it 'should count files in repo' do
repo.files_count.should == 4
end
it 'should count files by date' do
repo.files_count_by_date.keys == Hash[commit_dates_with_empty.zip [2, 3, 4]]
end
it 'should count lines by date' do
repo.files_count_by_date.values == Hash[commit_dates_with_empty.zip [1, 2, 2]]
end
it 'should count all lines in repo' do
repo.lines_count.should == 0
end
it 'should count files by extension in repo' do
repo.files_by_extension_count.should == {'' => 4}
end
it 'should count lines by extension in repo' do
repo.lines_by_extension.should == {}
end
it 'should count commits_count_by_author' do
repo.commits_count_by_author.keys.should == expected_authors
repo.commits_count_by_author.values.should == [3]
end
it 'should count lines_added_by_author' do
repo.insertions_by_author.keys.should == expected_authors
repo.insertions_by_author.values.should == [0]
end
it 'should count lines_deleted_by_author' do
repo.deletions_by_author.keys.should == expected_authors
repo.deletions_by_author.values.should == [0]
end
end
describe GitStats::GitData::Tree do
include_context "tree_subdir_with_1_commit"
it 'should gather all authors' do
repo.authors.should =~ expected_authors
end
it 'should calculate correct commits period' do
repo.commits_period.should == [DateTime.parse('2014-03-21 14:11:46 +0100'),
DateTime.parse('2014-03-21 14:11:46 +0100')]
end
it 'should gather all commits sorted by date' do
repo.commits.map(&:sha).should =~ %w(10d1814)
end
it 'should return project name from dir' do
repo.project_name.should == 'test_repo_tree/subdir_with_1_commit'
end
it 'should return project version as last commit hash' do
repo.project_version.should == '5fd0f5e'
end
it 'should count files in repo' do
repo.files_count.should == 2
end
it 'should count files by date' do
repo.files_count_by_date.keys == Hash[commit_dates_with_empty.zip [2]]
end
it 'should count lines by date' do
repo.files_count_by_date.values == Hash[commit_dates_with_empty.zip [1]]
end
it 'should count all lines in repo' do
repo.lines_count.should == 0
end
it 'should count files by extension in repo' do
repo.files_by_extension_count.should == {'' => 2}
end
it 'should count lines by extension in repo' do
repo.lines_by_extension.should == {}
end
it 'should count commits_count_by_author' do
repo.commits_count_by_author.keys.should == expected_authors
repo.commits_count_by_author.values.should == [1]
end
it 'should count lines_added_by_author' do
repo.insertions_by_author.keys.should == expected_authors
repo.insertions_by_author.values.should == [0]
end
it 'should count lines_deleted_by_author' do
repo.deletions_by_author.keys.should == expected_authors
repo.deletions_by_author.values.should == [0]
end
end
describe GitStats::GitData::Tree do
include_context "tree_subdir_with_2_commit"
it 'should gather all authors' do
repo.authors.should =~ expected_authors
end
it 'should calculate correct commits period' do
repo.commits_period.should == [DateTime.parse('2014-03-21 14:12:23 +0100'),
DateTime.parse('2014-03-21 14:12:47 +0100')]
end
it 'should gather all commits sorted by date' do
repo.commits.map(&:sha).should =~ %w(435e0ef 5fd0f5e)
end
it 'should return project name from dir' do
repo.project_name.should == 'test_repo_tree/subdir_with_2_commits'
end
it 'should return project version as last commit hash' do
repo.project_version.should == '5fd0f5e'
end
it 'should count files in repo' do
repo.files_count.should == 2
end
it 'should count files by date' do
repo.files_count_by_date.keys == Hash[commit_dates_with_empty.zip [1, 2]]
end
it 'should count lines by date' do
repo.files_count_by_date.values == Hash[commit_dates_with_empty.zip [2, 2]]
end
it 'should count all lines in repo' do
repo.lines_count.should == 0
end
it 'should count files by extension in repo' do
repo.files_by_extension_count.should == {'' => 2}
end
it 'should count lines by extension in repo' do
repo.lines_by_extension.should == {}
end
it 'should count commits_count_by_author' do
repo.commits_count_by_author.keys.should == expected_authors
repo.commits_count_by_author.values.should == [2]
end
it 'should count lines_added_by_author' do
repo.insertions_by_author.keys.should == expected_authors
repo.insertions_by_author.values.should == [0]
end
it 'should count lines_deleted_by_author' do
repo.deletions_by_author.keys.should == expected_authors
repo.deletions_by_author.values.should == [0]
end
end

View File

@ -5,6 +5,9 @@
%tr
%td= :project_version.t
%td= repo.project_version
%tr
%td= :tree_path.t
%td= repo.tree_path
%tr
%td= :generated_at.t
%td= I18n.localize(DateTime.now, format: :long)