Merge branch 'release/v0.2.0'
This commit is contained in:
commit
60ba644fb3
22 changed files with 755 additions and 364 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
/.bundle/
|
||||||
|
/.yardoc
|
||||||
|
/Gemfile.lock
|
||||||
|
/_yardoc/
|
||||||
|
/coverage/
|
||||||
|
/doc/
|
||||||
|
/pkg/
|
||||||
|
/spec/reports/
|
||||||
|
/tmp/
|
||||||
|
*.bundle
|
||||||
|
*.so
|
||||||
|
*.o
|
||||||
|
*.a
|
||||||
|
mkmf.log
|
5
Gemfile
Normal file
5
Gemfile
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# A sample Gemfile
|
||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
# Specify your gem's dependencies in timecost.gemspec
|
||||||
|
gemspec
|
19
Gemfile.lock
Normal file
19
Gemfile.lock
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
PATH
|
||||||
|
remote: .
|
||||||
|
specs:
|
||||||
|
timecost (0.2.0)
|
||||||
|
|
||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
minitest (4.7.5)
|
||||||
|
rake (10.1.0)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
ruby
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
bundler (~> 1.6)
|
||||||
|
minitest (~> 4.7.5)
|
||||||
|
rake (~> 10.0)
|
||||||
|
timecost!
|
22
LICENSE.txt
Normal file
22
LICENSE.txt
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
Copyright (c) 2014 Glenn Y. Rolland
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of this software and associated documentation files (the
|
||||||
|
"Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||||
|
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
26
README.md
26
README.md
|
@ -1,14 +1,14 @@
|
||||||
TimeTrack-Log for Git
|
TimeCost for Git
|
||||||
=====================
|
================
|
||||||
|
|
||||||
Use git logs to give an estimation of spent time on your projects.
|
|
||||||
|
|
||||||
|
Use git logs to give an estimation of spent time & costs of your projects.
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
------------
|
------------
|
||||||
|
|
||||||
* Clone the project somewhere
|
Install the project with:
|
||||||
* Copy the ''bin/git-timetrack-log'' file in ''/usr/local/bin''
|
|
||||||
|
$ gem install timecost
|
||||||
|
|
||||||
Usage
|
Usage
|
||||||
-----
|
-----
|
||||||
|
@ -16,7 +16,7 @@ Usage
|
||||||
To get the total time spent on your git project
|
To get the total time spent on your git project
|
||||||
|
|
||||||
```
|
```
|
||||||
$ git timetrack-log
|
$ git timecost
|
||||||
|
|
||||||
[...]
|
[...]
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ TOTAL: 3.36 hours
|
||||||
To get the time spent on your project since a given date
|
To get the time spent on your project since a given date
|
||||||
|
|
||||||
```
|
```
|
||||||
$ git timetrack-log -d 2013-03-01
|
$ git timecost -d 2013-03-01
|
||||||
set date filter to 2013-03-01
|
set date filter to 2013-03-01
|
||||||
(1.0) 2013-09-23T13:02:39+02:00 - 2013-09-23T14:02:39+02:00
|
(1.0) 2013-09-23T13:02:39+02:00 - 2013-09-23T14:02:39+02:00
|
||||||
* Glenn Y. Rolland <glenux@glenux.net>
|
* Glenn Y. Rolland <glenux@glenux.net>
|
||||||
|
@ -43,6 +43,14 @@ TOTAL: 1.00 hours
|
||||||
|
|
||||||
For other possibilities
|
For other possibilities
|
||||||
```
|
```
|
||||||
$ git timetrack-log -h
|
$ git timecost -h
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Contributing
|
||||||
|
------------
|
||||||
|
|
||||||
|
1. Fork it ( https://github.com/[my-github-username]/timecost/fork )
|
||||||
|
2. Create your feature branch (`git checkout -b my-new-feature`)
|
||||||
|
3. Commit your changes (`git commit -am 'Add some feature'`)
|
||||||
|
4. Push to the branch (`git push origin my-new-feature`)
|
||||||
|
5. Create a new Pull Request
|
||||||
|
|
12
Rakefile
Normal file
12
Rakefile
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
|
||||||
|
require 'rake'
|
||||||
|
require "bundler/gem_tasks"
|
||||||
|
require 'rake/testtask'
|
||||||
|
|
||||||
|
Rake::TestTask.new do |t|
|
||||||
|
#t.warning = true
|
||||||
|
#t.verbose = true
|
||||||
|
t.libs << "spec"
|
||||||
|
t.test_files = FileList['spec/**/*_spec.rb']
|
||||||
|
end
|
||||||
|
task :default => :test
|
19
TODO.md
Normal file
19
TODO.md
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
TODO
|
||||||
|
====
|
||||||
|
|
||||||
|
Fixes and ideas for the future
|
||||||
|
|
||||||
|
## Per user scotch
|
||||||
|
|
||||||
|
Different users have a different commit style & frequency.
|
||||||
|
We should be able to define a per-user scotch.
|
||||||
|
|
||||||
|
|
||||||
|
## Automatic scotch : Use median time between consecutive commits, per user
|
||||||
|
|
||||||
|
def median(array)
|
||||||
|
sorted = array.sort
|
||||||
|
len = sorted.length
|
||||||
|
return (sorted[(len - 1) / 2] + sorted[len / 2]) / 2.0
|
||||||
|
end
|
||||||
|
|
20
bin/git-timecost
Executable file
20
bin/git-timecost
Executable file
|
@ -0,0 +1,20 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
# vim: set syntax=ruby ts=4 sw=4 noet :
|
||||||
|
|
||||||
|
require 'pp'
|
||||||
|
require 'date'
|
||||||
|
require 'optparse'
|
||||||
|
require 'yaml'
|
||||||
|
|
||||||
|
require 'timecost'
|
||||||
|
|
||||||
|
app = TimeCost::CLI.new
|
||||||
|
app.parse_cmdline ARGV
|
||||||
|
app.analyze
|
||||||
|
app.export
|
||||||
|
app.report
|
||||||
|
#app.report_ranges
|
||||||
|
#app.report_users
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
|
|
@ -1,355 +0,0 @@
|
||||||
#!/usr/bin/env ruby
|
|
||||||
# vim: set syntax=ruby ts=4 sw=4 noet :
|
|
||||||
|
|
||||||
require 'pp'
|
|
||||||
require 'date'
|
|
||||||
require 'optparse'
|
|
||||||
require 'yaml'
|
|
||||||
|
|
||||||
class GitExtractor
|
|
||||||
|
|
||||||
class Commit
|
|
||||||
attr_accessor :author, :commit, :date, :note
|
|
||||||
def initialize commit
|
|
||||||
@commit = commit
|
|
||||||
@note = nil
|
|
||||||
@author = nil
|
|
||||||
@date = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
class Range
|
|
||||||
attr_accessor :time_start, :time_stop, :commits
|
|
||||||
|
|
||||||
def initialize config, commit
|
|
||||||
@config = config
|
|
||||||
@time_stop = DateTime.parse(commit.date)
|
|
||||||
@time_start = @time_stop - (@config[:range_granularity] * 3 / 24.0)
|
|
||||||
@commits = [commit]
|
|
||||||
self
|
|
||||||
end
|
|
||||||
|
|
||||||
def merge range
|
|
||||||
# B -----[----]----
|
|
||||||
# A --[----]------
|
|
||||||
# = ---[------]----
|
|
||||||
|
|
||||||
# minimum of both
|
|
||||||
new_start = if range.time_start < @time_start then range.time_start
|
|
||||||
else @time_start
|
|
||||||
end
|
|
||||||
|
|
||||||
new_end = if range.time_stop >= @time_stop then range.time_stop
|
|
||||||
else @time_stop
|
|
||||||
end
|
|
||||||
|
|
||||||
@time_start = new_start
|
|
||||||
@time_stop = new_end
|
|
||||||
@commits.concat range.commits
|
|
||||||
end
|
|
||||||
|
|
||||||
def overlap? range
|
|
||||||
result = false
|
|
||||||
|
|
||||||
# Ref ----[----]-----
|
|
||||||
# overlapping :
|
|
||||||
# A -[----]--------
|
|
||||||
# B -------[----]--
|
|
||||||
# C -[----------]--
|
|
||||||
# D ------[]-------
|
|
||||||
# non-overlapping :
|
|
||||||
# E -[]------------
|
|
||||||
# F -----------[]--
|
|
||||||
|
|
||||||
start_before_start = (range.time_start < @time_start)
|
|
||||||
start_after_start = (range.time_start >= @time_start)
|
|
||||||
start_after_stop = (range.time_start >= @time_stop)
|
|
||||||
start_before_stop = (range.time_start < @time_stop)
|
|
||||||
|
|
||||||
stop_before_stop = (range.time_stop < @time_stop)
|
|
||||||
stop_after_stop = (range.time_stop >= @time_stop)
|
|
||||||
stop_before_start = (range.time_stop < @time_start)
|
|
||||||
stop_after_start = (range.time_stop >= @time_start)
|
|
||||||
|
|
||||||
|
|
||||||
# A case
|
|
||||||
if start_before_start and start_before_stop and
|
|
||||||
stop_after_start and stop_before_stop then
|
|
||||||
result = true
|
|
||||||
end
|
|
||||||
|
|
||||||
# B case
|
|
||||||
if start_after_start and start_before_stop and
|
|
||||||
stop_after_start and stop_after_stop then
|
|
||||||
result = true
|
|
||||||
end
|
|
||||||
|
|
||||||
# C case
|
|
||||||
if start_before_start and start_before_stop and
|
|
||||||
stop_after_start and stop_after_stop then
|
|
||||||
result = true
|
|
||||||
end
|
|
||||||
|
|
||||||
# D case
|
|
||||||
if start_after_start and start_before_stop and
|
|
||||||
stop_after_start and stop_before_stop then
|
|
||||||
result = true
|
|
||||||
end
|
|
||||||
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
def fixed_start
|
|
||||||
return @time_start + (@config[:range_granularity]/24.0)
|
|
||||||
end
|
|
||||||
|
|
||||||
def diff
|
|
||||||
return ("%.2f" % ((@time_stop - fixed_start).to_f * 24)).to_f
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_s
|
|
||||||
val = "(%s) %s - %s\n" % [diff, fixed_start, @time_stop]
|
|
||||||
@commits.each do |commit|
|
|
||||||
lines = []
|
|
||||||
unless @config[:author_filter_enable] then
|
|
||||||
lines.push commit.author
|
|
||||||
end
|
|
||||||
lines.concat commit.note.split(/\n/)
|
|
||||||
r = lines.map{ |s| "\t %s" % s }.join "\n"
|
|
||||||
r[1] = '*'
|
|
||||||
val += r + "\n"
|
|
||||||
end
|
|
||||||
return val
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class RangeList
|
|
||||||
def initialize
|
|
||||||
@ranges = []
|
|
||||||
end
|
|
||||||
|
|
||||||
def add range
|
|
||||||
merged = false
|
|
||||||
merged_range = nil
|
|
||||||
|
|
||||||
# merge
|
|
||||||
@ranges.each do |old|
|
|
||||||
#pp old
|
|
||||||
if old.overlap? range then
|
|
||||||
old.merge range
|
|
||||||
merged_range = old
|
|
||||||
merged = true
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# add if needed
|
|
||||||
if merged then
|
|
||||||
@ranges.delete merged_range
|
|
||||||
self.add merged_range
|
|
||||||
else
|
|
||||||
@ranges.push range
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def each
|
|
||||||
@ranges.each do |r|
|
|
||||||
yield r
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sum
|
|
||||||
result = 0
|
|
||||||
@ranges.each do |r|
|
|
||||||
result += r.diff
|
|
||||||
end
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
# FIXME: accept multiple authors
|
|
||||||
@config = {
|
|
||||||
:author_filter_enable => false,
|
|
||||||
:author_filter => ".*?",
|
|
||||||
|
|
||||||
:date_filter_enable => false,
|
|
||||||
:date_filter => ".*?",
|
|
||||||
|
|
||||||
:input_dump => [],
|
|
||||||
:output_dump => nil,
|
|
||||||
|
|
||||||
:range_granularity => 0.5, # in decimal hours
|
|
||||||
|
|
||||||
:verbose => false
|
|
||||||
}
|
|
||||||
@rangelist = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_cmdline args
|
|
||||||
opts = OptionParser.new do |opts|
|
|
||||||
opts.banner = "Usage: #{File.basename $0} [options]"
|
|
||||||
|
|
||||||
opts.on_tail("-v","--verbose", "Run verbosely") do |v|
|
|
||||||
@config[:verbose] = true
|
|
||||||
end
|
|
||||||
|
|
||||||
opts.on_tail("-h","--help", "Show this help") do
|
|
||||||
puts opts
|
|
||||||
exit 0
|
|
||||||
end
|
|
||||||
|
|
||||||
opts.on("-i","--input FILE", "Set input dump file") do |file|
|
|
||||||
@config[:input_dump] << file
|
|
||||||
end
|
|
||||||
|
|
||||||
opts.on("-o","--output FILE", "Set output dump file") do |file|
|
|
||||||
@config[:output_dump] = file
|
|
||||||
end
|
|
||||||
|
|
||||||
opts.on("-d","--date DATE", "Keep only commits since DATE") do |date|
|
|
||||||
puts "set date filter to #{date}"
|
|
||||||
@config[:date_filter] = DateTime.parse(date);
|
|
||||||
@config[:date_filter_enable] = true
|
|
||||||
end
|
|
||||||
|
|
||||||
opts.on("-t","--time TIME", "Keep only commits on last TIME datys") do |time|
|
|
||||||
puts "set time filter to latest #{time} days"
|
|
||||||
@config[:date_filter] = DateTime.now - time.to_f;
|
|
||||||
puts "set date filter to date = #{@config[:date_filter]}"
|
|
||||||
@config[:date_filter_enable] = true
|
|
||||||
end
|
|
||||||
|
|
||||||
opts.on("-a","--author AUTHOR", "Keep only commits by AUTHOR") do |author|
|
|
||||||
puts "set author filter to #{author}"
|
|
||||||
@config[:author_filter] = author
|
|
||||||
@config[:author_filter_enable] = true
|
|
||||||
end
|
|
||||||
|
|
||||||
# overlap :
|
|
||||||
#
|
|
||||||
opts.on("-s","--scotch GRANULARITY", "Use GRANULARITY (decimal hours) to merge ranges") do |granularity|
|
|
||||||
puts "set scotch to #{granularity}"
|
|
||||||
@config[:range_granularity] = granularity.to_f
|
|
||||||
end
|
|
||||||
end
|
|
||||||
opts.parse! args
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_git
|
|
||||||
# git log
|
|
||||||
# foreach, create time range (before) + logs
|
|
||||||
process = IO.popen ["git", "log",
|
|
||||||
"--date=iso",
|
|
||||||
"--no-patch",
|
|
||||||
"--","."]
|
|
||||||
|
|
||||||
|
|
||||||
@rangelist = RangeList.new
|
|
||||||
commit = nil
|
|
||||||
loop do
|
|
||||||
line = process.gets
|
|
||||||
break if line.nil?
|
|
||||||
# utf-8 fix ?
|
|
||||||
# line.encode!( line.encoding, "binary", :invalid => :replace, :undef => :replace)
|
|
||||||
line.strip!
|
|
||||||
|
|
||||||
case line
|
|
||||||
when /^commit (.*)$/ then
|
|
||||||
id = $1
|
|
||||||
# merge ranges & push
|
|
||||||
unless commit.nil? then
|
|
||||||
range = Range.new @config, commit
|
|
||||||
@rangelist.add range
|
|
||||||
end
|
|
||||||
commit = Commit.new id
|
|
||||||
# puts "commit #{id}"
|
|
||||||
|
|
||||||
when /^Author:\s*(.*?)\s*$/ then
|
|
||||||
unless commit.nil? then
|
|
||||||
commit.author = $1
|
|
||||||
|
|
||||||
if @config[:author_filter_enable] and
|
|
||||||
(not commit.author =~ /#{@config[:author_filter]}/) then
|
|
||||||
commit = nil
|
|
||||||
# reject
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
when /^Date:\s*(.*?)\s*$/ then
|
|
||||||
unless commit.nil? then
|
|
||||||
commit.date = $1
|
|
||||||
|
|
||||||
if @config[:date_filter_enable] and
|
|
||||||
(DateTime.parse(commit.date) < @config[:date_filter]) then
|
|
||||||
commit = nil
|
|
||||||
# reject
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
when /^\s*$/ then
|
|
||||||
# skip
|
|
||||||
|
|
||||||
else
|
|
||||||
# add as note
|
|
||||||
unless commit.nil? then
|
|
||||||
commit.note = if commit.note.nil? then line
|
|
||||||
else commit.note + "\n" + line
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
def analyze_dumps
|
|
||||||
#read ranges
|
|
||||||
@rangelist = RangeList.new
|
|
||||||
|
|
||||||
@config[:input_dump].each do |filename|
|
|
||||||
rangelist = YAML::load(File.open(filename,"r"))
|
|
||||||
rangelist.each do |range|
|
|
||||||
@rangelist.add range
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def analyze
|
|
||||||
if @config[:input_dump].empty? then
|
|
||||||
analyze_git
|
|
||||||
else
|
|
||||||
analyze_dumps
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def export
|
|
||||||
return if @config[:output_dump].nil?
|
|
||||||
puts "Exporting to %s" % @config[:output_dump]
|
|
||||||
File.open(@config[:output_dump], "w") do |file|
|
|
||||||
file.puts YAML::dump(@rangelist)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def report
|
|
||||||
return if not @config[:output_dump].nil?
|
|
||||||
|
|
||||||
@rangelist.each do |r|
|
|
||||||
puts r.to_s + "\n"
|
|
||||||
end
|
|
||||||
puts "TOTAL: %.2f hours" % @rangelist.sum
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
app = GitExtractor.new
|
|
||||||
app.parse_cmdline ARGV
|
|
||||||
app.analyze
|
|
||||||
app.export
|
|
||||||
app.report
|
|
||||||
exit 0
|
|
||||||
|
|
7
lib/timecost.rb
Normal file
7
lib/timecost.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
|
||||||
|
require 'timecost/commit'
|
||||||
|
require 'timecost/range'
|
||||||
|
require 'timecost/author_list'
|
||||||
|
require 'timecost/range_list'
|
||||||
|
require 'timecost/cli'
|
||||||
|
|
38
lib/timecost/author_list.rb
Normal file
38
lib/timecost/author_list.rb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
|
||||||
|
require 'pp'
|
||||||
|
|
||||||
|
module TimeCost
|
||||||
|
class AuthorList
|
||||||
|
class UnknownAuthor < RuntimeError ; end
|
||||||
|
|
||||||
|
# Prepare an empty index (local)
|
||||||
|
def initialize
|
||||||
|
@count = 0
|
||||||
|
@author_to_id = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def add author
|
||||||
|
if @author_to_id.include? author then
|
||||||
|
result = @author_to_id[author]
|
||||||
|
else
|
||||||
|
@author_to_id[author] = @count
|
||||||
|
result = @count
|
||||||
|
@count += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def alias author_ref, author_new
|
||||||
|
raise UnknownAuthor unless @author_to_id.include? author_ref
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return local user id for git user
|
||||||
|
# FIXME: should handle multiple names for same user
|
||||||
|
def parse author
|
||||||
|
return @author_to_id[author]
|
||||||
|
end
|
||||||
|
|
||||||
|
def size
|
||||||
|
return @author_to_id.keys.size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
220
lib/timecost/cli.rb
Normal file
220
lib/timecost/cli.rb
Normal file
|
@ -0,0 +1,220 @@
|
||||||
|
module TimeCost
|
||||||
|
class CLI
|
||||||
|
def initialize
|
||||||
|
# FIXME: accept multiple authors
|
||||||
|
@config = {
|
||||||
|
:author_filter_enable => false,
|
||||||
|
:author_filter => ".*?",
|
||||||
|
|
||||||
|
:date_filter_enable => false,
|
||||||
|
:date_filter => [],
|
||||||
|
|
||||||
|
:branches_filter_enable => true,
|
||||||
|
|
||||||
|
:input_dump => [],
|
||||||
|
:output_dump => nil,
|
||||||
|
|
||||||
|
:range_granularity => 0.5, # in decimal hours
|
||||||
|
|
||||||
|
:verbose => false
|
||||||
|
}
|
||||||
|
@rangelist = {}
|
||||||
|
@authorlist = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_cmdline args
|
||||||
|
options = OptionParser.new do |opts|
|
||||||
|
opts.banner = "Usage: #{File.basename $0} [options]"
|
||||||
|
|
||||||
|
opts.on_tail("-v","--verbose", "Run verbosely") do |v|
|
||||||
|
@config[:verbose] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on_tail("-h","--help", "Show this help") do
|
||||||
|
puts opts
|
||||||
|
exit 0
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
opts.on("-i","--input FILE", "Set input dump file") do |file|
|
||||||
|
@config[:input_dump] << file
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("-o","--output FILE", "Set output dump file") do |file|
|
||||||
|
@config[:output_dump] = file
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("--before DATE", "Keep only commits before DATE") do |date|
|
||||||
|
puts "set date filter to <= #{date}"
|
||||||
|
@config[:date_filter] << lambda { |other|
|
||||||
|
return (other <= DateTime.parse(date))
|
||||||
|
}
|
||||||
|
@config[:date_filter_enable] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("--after DATE", "Keep only commits after DATE") do |date|
|
||||||
|
puts "set date filter to >= #{date}"
|
||||||
|
@config[:date_filter] << lambda { |other|
|
||||||
|
return (other >= DateTime.parse(date))
|
||||||
|
}
|
||||||
|
@config[:date_filter_enable] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("-t","--time TIME", "Keep only commits on last TIME days") do |time|
|
||||||
|
puts "set time filter to latest #{time} days"
|
||||||
|
@config[:date_filter] = DateTime.now - time.to_f;
|
||||||
|
puts "set date filter to date = #{@config[:date_filter]}"
|
||||||
|
@config[:date_filter_enable] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("-a","--author AUTHOR", "Keep only commits by AUTHOR") do |author|
|
||||||
|
puts "set author filter to #{author}"
|
||||||
|
@config[:author_filter] = author
|
||||||
|
@config[:author_filter_enable] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on_tail("--all", "Collect from all branches and refs") do
|
||||||
|
@config[:branches_filter_enable] = false
|
||||||
|
end
|
||||||
|
|
||||||
|
# overlap :
|
||||||
|
#
|
||||||
|
opts.on("-s","--scotch GRANULARITY", "Use GRANULARITY (decimal hours) to merge ranges") do |granularity|
|
||||||
|
puts "set scotch to #{granularity}"
|
||||||
|
@config[:range_granularity] = granularity.to_f
|
||||||
|
end
|
||||||
|
end
|
||||||
|
options.parse! args
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_git
|
||||||
|
# git log
|
||||||
|
# foreach, create time range (before) + logs
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"git", "log",
|
||||||
|
"--date=iso",
|
||||||
|
"--no-patch"
|
||||||
|
]
|
||||||
|
if not @config[:branches_filter_enable] then
|
||||||
|
cmd << "--all"
|
||||||
|
end
|
||||||
|
cmd.concat ["--", "."]
|
||||||
|
process = IO.popen cmd
|
||||||
|
|
||||||
|
@rangelist = {}
|
||||||
|
commit = nil
|
||||||
|
loop do
|
||||||
|
line = process.gets
|
||||||
|
break if line.nil?
|
||||||
|
# utf-8 fix ?
|
||||||
|
# line.encode!( line.encoding, "binary", :invalid => :replace, :undef => :replace)
|
||||||
|
line.strip!
|
||||||
|
|
||||||
|
case line
|
||||||
|
when /^commit (.*)$/ then
|
||||||
|
id = $1
|
||||||
|
# merge ranges & push
|
||||||
|
unless commit.nil? then
|
||||||
|
range = Range.new commit, granularity: @config[:range_granularity]
|
||||||
|
|
||||||
|
if not @rangelist.include? commit.author then
|
||||||
|
@rangelist[commit.author] = RangeList.new
|
||||||
|
end
|
||||||
|
@rangelist[commit.author].add range
|
||||||
|
end
|
||||||
|
commit = Commit.new id
|
||||||
|
# puts "commit #{id}"
|
||||||
|
|
||||||
|
when /^Author:\s*(.*?)\s*$/ then
|
||||||
|
unless commit.nil? then
|
||||||
|
commit.author = $1
|
||||||
|
|
||||||
|
if @config[:author_filter_enable] and
|
||||||
|
(not commit.author =~ /#{@config[:author_filter]}/) then
|
||||||
|
commit = nil
|
||||||
|
# reject
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
when /^Date:\s*(.*?)\s*$/ then
|
||||||
|
unless commit.nil? then
|
||||||
|
commit.date = $1
|
||||||
|
|
||||||
|
# reject if a some filter does not validate date
|
||||||
|
filter_keep = true
|
||||||
|
filters = @config[:date_filter]
|
||||||
|
filters.each do |f|
|
||||||
|
filter_keep &= f.call(DateTime.parse(commit.date))
|
||||||
|
end
|
||||||
|
|
||||||
|
if not filter_keep then
|
||||||
|
commit = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
when /^\s*$/ then
|
||||||
|
# skip
|
||||||
|
|
||||||
|
else
|
||||||
|
# add as note
|
||||||
|
unless commit.nil? then
|
||||||
|
commit.note = if commit.note.nil? then line
|
||||||
|
else commit.note + "\n" + line
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
def analyze_dumps
|
||||||
|
#read ranges
|
||||||
|
@rangelist = RangeList.new
|
||||||
|
|
||||||
|
@config[:input_dump].each do |filename|
|
||||||
|
rangelist = YAML::load(File.open(filename,"r"))
|
||||||
|
rangelist.each do |range|
|
||||||
|
@rangelist.add range
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def analyze
|
||||||
|
if @config[:input_dump].empty? then
|
||||||
|
analyze_git
|
||||||
|
else
|
||||||
|
analyze_dumps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def export
|
||||||
|
return if @config[:output_dump].nil?
|
||||||
|
puts "Exporting to %s" % @config[:output_dump]
|
||||||
|
File.open(@config[:output_dump], "w") do |file|
|
||||||
|
file.puts YAML::dump(@rangelist)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def report
|
||||||
|
return if not @config[:output_dump].nil?
|
||||||
|
|
||||||
|
@rangelist.each do |author,rangelist|
|
||||||
|
rangelist.each do |range|
|
||||||
|
puts range.to_s(!@config[:author_filter_enable]) + "\n"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
total = 0
|
||||||
|
@rangelist.each do |author,rangelist|
|
||||||
|
puts "SUB-TOTAL for %s: %.2f hours\n" % [author, rangelist.sum]
|
||||||
|
total += rangelist.sum
|
||||||
|
end
|
||||||
|
puts "TOTAL: %.2f hours" % total
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
14
lib/timecost/commit.rb
Normal file
14
lib/timecost/commit.rb
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
|
||||||
|
module TimeCost
|
||||||
|
|
||||||
|
class Commit
|
||||||
|
attr_accessor :author, :commit, :date, :note
|
||||||
|
def initialize commit
|
||||||
|
@commit = commit
|
||||||
|
@note = nil
|
||||||
|
@author = nil
|
||||||
|
@date = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
117
lib/timecost/range.rb
Normal file
117
lib/timecost/range.rb
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
|
||||||
|
module TimeCost
|
||||||
|
class Range
|
||||||
|
attr_accessor :time_start, :time_stop, :commits, :author
|
||||||
|
|
||||||
|
GRANULARITY_DEFAULT = 0.5
|
||||||
|
|
||||||
|
def initialize commit, options = {}
|
||||||
|
@granularity = options[:granularity] || GRANULARITY_DEFAULT
|
||||||
|
|
||||||
|
# FIXME: First approximation for users
|
||||||
|
# later, we'll replace with @user = User.parse(commit.author)
|
||||||
|
@author = commit.author
|
||||||
|
|
||||||
|
@time_stop = DateTime.parse(commit.date)
|
||||||
|
@time_start = @time_stop - (@granularity * 3 / 24.0)
|
||||||
|
@commits = [commit]
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def merge range
|
||||||
|
# B -----[----]----
|
||||||
|
# A --[----]------
|
||||||
|
# = ---[------]----
|
||||||
|
|
||||||
|
# minimum of both
|
||||||
|
new_start = if range.time_start < @time_start then range.time_start
|
||||||
|
else @time_start
|
||||||
|
end
|
||||||
|
|
||||||
|
new_end = if range.time_stop >= @time_stop then range.time_stop
|
||||||
|
else @time_stop
|
||||||
|
end
|
||||||
|
|
||||||
|
@time_start = new_start
|
||||||
|
@time_stop = new_end
|
||||||
|
@commits.concat range.commits
|
||||||
|
end
|
||||||
|
|
||||||
|
def overlap? range
|
||||||
|
result = false
|
||||||
|
|
||||||
|
# return early result if ranges come from different authors
|
||||||
|
return false if (@author != range.author)
|
||||||
|
|
||||||
|
# Ref ----[----]-----
|
||||||
|
# overlapping :
|
||||||
|
# A -[----]--------
|
||||||
|
# B -------[----]--
|
||||||
|
# C -[----------]--
|
||||||
|
# D ------[]-------
|
||||||
|
# non-overlapping :
|
||||||
|
# E -[]------------
|
||||||
|
# F -----------[]--
|
||||||
|
|
||||||
|
start_before_start = (range.time_start < @time_start)
|
||||||
|
start_after_start = (range.time_start >= @time_start)
|
||||||
|
start_after_stop = (range.time_start >= @time_stop)
|
||||||
|
start_before_stop = (range.time_start < @time_stop)
|
||||||
|
|
||||||
|
stop_before_stop = (range.time_stop < @time_stop)
|
||||||
|
stop_after_stop = (range.time_stop >= @time_stop)
|
||||||
|
stop_before_start = (range.time_stop < @time_start)
|
||||||
|
stop_after_start = (range.time_stop >= @time_start)
|
||||||
|
|
||||||
|
# A case
|
||||||
|
if start_before_start and start_before_stop and
|
||||||
|
stop_after_start and stop_before_stop then
|
||||||
|
result = true
|
||||||
|
end
|
||||||
|
|
||||||
|
# B case
|
||||||
|
if start_after_start and start_before_stop and
|
||||||
|
stop_after_start and stop_after_stop then
|
||||||
|
result = true
|
||||||
|
end
|
||||||
|
|
||||||
|
# C case
|
||||||
|
if start_before_start and start_before_stop and
|
||||||
|
stop_after_start and stop_after_stop then
|
||||||
|
result = true
|
||||||
|
end
|
||||||
|
|
||||||
|
# D case
|
||||||
|
if start_after_start and start_before_stop and
|
||||||
|
stop_after_start and stop_before_stop then
|
||||||
|
result = true
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
def fixed_start
|
||||||
|
return @time_start + (@granularity/24.0)
|
||||||
|
end
|
||||||
|
|
||||||
|
def diff
|
||||||
|
return ("%.2f" % ((@time_stop - fixed_start).to_f * 24)).to_f
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s show_authors = true
|
||||||
|
val = "(%s)\t%s - %s\n" % [diff, fixed_start, @time_stop]
|
||||||
|
if show_authors then
|
||||||
|
val += "\tby %s\n" % @commits.first.author
|
||||||
|
end
|
||||||
|
@commits.each do |commit|
|
||||||
|
lines = []
|
||||||
|
lines.concat commit.note.split(/\n/)
|
||||||
|
r = lines.map{ |s| "\t %s" % s }.join "\n"
|
||||||
|
r[1] = '*'
|
||||||
|
val += r + "\n"
|
||||||
|
end
|
||||||
|
return val
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
46
lib/timecost/range_list.rb
Normal file
46
lib/timecost/range_list.rb
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
|
||||||
|
module TimeCost
|
||||||
|
class RangeList
|
||||||
|
def initialize
|
||||||
|
@ranges = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def add range
|
||||||
|
merged = false
|
||||||
|
merged_range = nil
|
||||||
|
|
||||||
|
# merge
|
||||||
|
@ranges.each do |old|
|
||||||
|
#pp old
|
||||||
|
if old.overlap? range then
|
||||||
|
old.merge range
|
||||||
|
merged_range = old
|
||||||
|
merged = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# add if needed
|
||||||
|
if merged then
|
||||||
|
@ranges.delete merged_range
|
||||||
|
self.add merged_range
|
||||||
|
else
|
||||||
|
@ranges.push range
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def each
|
||||||
|
@ranges.each do |r|
|
||||||
|
yield r
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sum
|
||||||
|
result = 0
|
||||||
|
@ranges.each do |r|
|
||||||
|
result += r.diff
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
3
lib/timecost/version.rb
Normal file
3
lib/timecost/version.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module Timecost
|
||||||
|
VERSION = "0.2.0"
|
||||||
|
end
|
65
spec/author_list_spec.rb
Normal file
65
spec/author_list_spec.rb
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
|
||||||
|
|
||||||
|
require_relative 'spec_helper'
|
||||||
|
|
||||||
|
require 'timecost/author_list'
|
||||||
|
|
||||||
|
describe TimeCost::AuthorList do
|
||||||
|
let(:list) { TimeCost::AuthorList.new }
|
||||||
|
let(:first) { "Foo <foo@example.com>" }
|
||||||
|
let(:second) { "Bar <bar@example.com>" }
|
||||||
|
|
||||||
|
describe '.new' do
|
||||||
|
it "can be created without arguments" do
|
||||||
|
assert_instance_of TimeCost::AuthorList, list
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.add' do
|
||||||
|
it "must accept adding authors" do
|
||||||
|
assert_respond_to list, :add
|
||||||
|
|
||||||
|
list.add first
|
||||||
|
list.add second
|
||||||
|
end
|
||||||
|
|
||||||
|
it "must assign a different id to different authors" do
|
||||||
|
list.add first
|
||||||
|
list.add second
|
||||||
|
id_foo = list.parse first
|
||||||
|
id_bar = list.parse second
|
||||||
|
refute_equal id_foo, id_bar
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.size' do
|
||||||
|
it "must be zero in the beginning" do
|
||||||
|
assert_equal list.size, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
it "must grow while adding authors" do
|
||||||
|
list.add first
|
||||||
|
assert_equal list.size, 1
|
||||||
|
list.add second
|
||||||
|
assert_equal list.size, 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.alias' do
|
||||||
|
it "must accept aliases for authors" do
|
||||||
|
assert_respond_to list, :alias
|
||||||
|
|
||||||
|
list.add first
|
||||||
|
list.alias first, second
|
||||||
|
end
|
||||||
|
|
||||||
|
it "must assign the same id to aliases authors" do
|
||||||
|
list.add first
|
||||||
|
list.alias first, second
|
||||||
|
|
||||||
|
id_foo = list.parse first
|
||||||
|
id_bar = list.parse second
|
||||||
|
refute_equal id_foo, id_bar
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
16
spec/cli_spec.rb
Normal file
16
spec/cli_spec.rb
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
|
||||||
|
|
||||||
|
require_relative 'spec_helper'
|
||||||
|
|
||||||
|
require 'timecost/cli'
|
||||||
|
|
||||||
|
describe TimeCost::CLI do
|
||||||
|
let(:cli) { TimeCost::CLI.new }
|
||||||
|
|
||||||
|
describe '.new' do
|
||||||
|
it "can be created without arguments" do
|
||||||
|
assert_instance_of TimeCost::CLI, cli
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
26
spec/range_list_spec.rb
Normal file
26
spec/range_list_spec.rb
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
|
||||||
|
require_relative 'spec_helper'
|
||||||
|
|
||||||
|
require 'timecost/range_list'
|
||||||
|
|
||||||
|
describe TimeCost::RangeList do
|
||||||
|
let(:list) { TimeCost::RangeList.new }
|
||||||
|
|
||||||
|
describe '.new' do
|
||||||
|
it "can be created without arguments" do
|
||||||
|
assert_instance_of TimeCost::RangeList, list
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is empty at start" do
|
||||||
|
end
|
||||||
|
|
||||||
|
it "can insert ranges" do
|
||||||
|
end
|
||||||
|
|
||||||
|
it "can merge overlapping ranges" do
|
||||||
|
end
|
||||||
|
|
||||||
|
it "cumulates non-overlapping ranges" do
|
||||||
|
end
|
||||||
|
end
|
35
spec/range_spec.rb
Normal file
35
spec/range_spec.rb
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
|
||||||
|
require_relative 'spec_helper'
|
||||||
|
|
||||||
|
require 'timecost/range'
|
||||||
|
|
||||||
|
describe TimeCost::Range do
|
||||||
|
let(:config) do { granularity: 0.5 } end
|
||||||
|
|
||||||
|
let(:commitA) { nil }
|
||||||
|
let(:commitB) { nil }
|
||||||
|
let(:commitC) { nil }
|
||||||
|
let(:commitD) { nil }
|
||||||
|
|
||||||
|
let(:rangeA) {
|
||||||
|
TimeCost::Range.new commitA, config
|
||||||
|
}
|
||||||
|
|
||||||
|
describe '.new' do
|
||||||
|
it "can be created from " do
|
||||||
|
assert_instance_of TimeCost::Range, rangeA
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.overlap?' do
|
||||||
|
it "must respond to .overlap?" do
|
||||||
|
end
|
||||||
|
|
||||||
|
it "must return false when ranges are not overlapping" do
|
||||||
|
end
|
||||||
|
|
||||||
|
it "must return true when ranges are overlapping" do
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
16
spec/spec_helper.rb
Executable file
16
spec/spec_helper.rb
Executable file
|
@ -0,0 +1,16 @@
|
||||||
|
|
||||||
|
#require 'mark'
|
||||||
|
#
|
||||||
|
require 'minitest/unit'
|
||||||
|
require 'minitest/autorun'
|
||||||
|
require 'minitest/spec'
|
||||||
|
require 'minitest/pride'
|
||||||
|
|
||||||
|
$LOAD_PATH.unshift('../lib')
|
||||||
|
|
||||||
|
#if __FILE__ == $0
|
||||||
|
# $LOAD_PATH.unshift('lib', 'spec')
|
||||||
|
# Dir.glob('./spec/**/*_spec.rb') { |f| require f }
|
||||||
|
#end
|
||||||
|
|
||||||
|
|
24
timecost.gemspec
Normal file
24
timecost.gemspec
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# coding: utf-8
|
||||||
|
lib = File.expand_path('../lib', __FILE__)
|
||||||
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
||||||
|
require 'timecost/version'
|
||||||
|
|
||||||
|
Gem::Specification.new do |spec|
|
||||||
|
spec.name = "timecost"
|
||||||
|
spec.version = Timecost::VERSION
|
||||||
|
spec.authors = ["Glenn Y. Rolland"]
|
||||||
|
spec.email = ["glenux@glenux.net"]
|
||||||
|
spec.summary = %q{Use GIT logs to give an estimation of spent time & costs of your projects.}
|
||||||
|
spec.description = %q{Use GIT logs to give an estimation of spent time & costs of your projects.}
|
||||||
|
spec.homepage = "https://github.com/glenux/git-timecost"
|
||||||
|
spec.license = "MIT"
|
||||||
|
|
||||||
|
spec.files = `git ls-files -z`.split("\x0")
|
||||||
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
||||||
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
||||||
|
spec.require_paths = ["lib"]
|
||||||
|
|
||||||
|
spec.add_development_dependency "bundler", "~> 1.6"
|
||||||
|
spec.add_development_dependency "rake", "~> 10.0"
|
||||||
|
spec.add_development_dependency "minitest", "~> 4.7.5"
|
||||||
|
end
|
Loading…
Reference in a new issue