Skip to content

Instantly share code, notes, and snippets.

@leandropls
Last active March 27, 2017 11:56
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save leandropls/6db26df3939b094dd321 to your computer and use it in GitHub Desktop.
Save leandropls/6db26df3939b094dd321 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
#
# Copyright (c) 2015 Leandro Pereira de Lima e Silva
#
# 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.
#
##
# Tip: consider putting this script on your path with the name git-hours; this
# way, you'll be able to invoke it as a git command (git hours)
##
import os, subprocess, codecs
from datetime import datetime, timedelta
from collections import namedtuple
from argparse import ArgumentParser
Commit = namedtuple('Commit', ['hash', 'merge', 'author', 'date', 'message',
'files', 'changes', 'insertions', 'deletions'])
def gitlog(author = None, since = None, until = None, files = []):
'''Reads and parses git log yielding commits'''
cmdline = ['/usr/bin/env', 'git', 'log', '--date=iso', '--shortstat']
if author is not None:
cmdline.append('--author=%s' % author)
if since is not None:
cmdline.append('--since=%s' % since.strftime('%Y-%m-%d %H:%M:%S %z'))
if until is not None:
cmdline.append('--until=%s' % until.strftime('%Y-%m-%d %H:%M:%S %z'))
if len(files) > 0:
cmdline.extend(files)
reader = codecs.getreader('utf-8')
gitlog = subprocess.Popen(cmdline, stdout = subprocess.PIPE)
chash = None
for line in reader(gitlog.stdout):
line = line.rstrip()
if line == '':
continue
# Detect message
if line[:4] == ' ':
message += line[4:]
continue
# Detect other fields
splitline = line.split(' ', maxsplit = 1)
if splitline[0] == 'commit':
if chash is not None:
yield Commit(chash, merge, author, date, message, files,
changes, insertions, deletions)
chash = splitline[1]
merge, author, date, message = False, None, None, ''
files, changes, insertions, deletions = 0, 0, 0, 0
continue
if splitline[0] == 'Author:':
author = splitline[1].strip()
continue
if splitline[0] == 'Date:':
date = datetime.strptime(splitline[1].strip(), '%Y-%m-%d %H:%M:%S %z')
continue
if splitline[0] == 'Merge:':
merge = True
continue
# Detect change statistics
filesplit = line.lstrip().split(' ')
if len(filesplit) >= 3 and filesplit[2] == 'changed,' and filesplit[1] in ('file', 'files'):
files = int(filesplit[0], 10)
if len(filesplit) == 5:
if filesplit[4] in ('insertions(+)', 'insertion(+)'):
insertions = int(filesplit[3], 10)
changes = insertions
elif filesplit[4] in ('deletions(-)', 'deletion(-)'):
deletions = int(filesplit[3], 10)
changes = deletions
else:
print(filesplit[4])
elif len(filesplit) == 7:
insertions = int(filesplit[3], 10)
deletions = int(filesplit[5], 10)
changes = insertions + deletions
if chash is not None:
yield Commit(chash, merge, author, date, message, files,
changes, insertions, deletions)
def logpairs(options):
'''Yields pairs of commits'''
logiter = gitlog(**options)
b = next(logiter)
while True:
try:
a, b = b, next(logiter)
yield a, b
except StopIteration:
return
def workinghours(maxidle = 2, **options):
'''Estimate number of hours worked on a git repo'''
maxidlehours = timedelta(hours = maxidle)
days = set()
known_hours = 0
known_commits = 0
known_changes = 0
total_changes = 0
commits = 1
for a, b in logpairs(options):
if a.merge or b.merge:
continue
days.add(a.date.date())
commits += 1
total_changes += a.changes
if a.author != b.author:
continue
if b.date > a.date:
continue
timediff = a.date - b.date
if timediff > maxidlehours:
continue
known_commits += 1
known_hours += timediff.total_seconds() / 3600
known_changes += a.changes
try:
hours = (known_hours / known_changes) * total_changes
except ZeroDivisionError:
return None
return total_changes, commits, hours, len(days)
def parse_date(datestr):
'''Parse dates accepted by --since and --until'''
formats = ['%Y-%m-%d %H:%M:%S %z', '%Y-%m-%d %z',
'%Y-%m-%d %H:%M:%S', '%Y-%m-%d']
for f in formats:
try:
return datetime.strptime(datestr, f)
except ValueError:
continue
return None
def parse_options():
'''Parse command line options'''
parser = ArgumentParser()
parser.add_argument('-a', '--author', dest = 'author', metavar = 'name',
help = 'Limit the computation to commits with author/committer header '
'lines that match the specified pattern (regular expression).')
parser.add_argument('-f', '--files',
dest = 'files', metavar = 'file', nargs ='+', default = [],
help = 'Limit the computation to commits that affected this '
'file(s) or folder(s).')
parser.add_argument('-i', '--maxidle',
dest = 'maxidle', metavar = 'hours', type = float, default = 2,
help = 'Maximum number of hours between commits before assuming '
'that the programmer took a break (default: 2 hours).')
parser.add_argument('-s', '--since', dest = 'since', metavar = 'date',
help = 'Limit the computation to commits more recent than a specific '
'date. Format: YYYY-MM-DD [HH:MM:SS] [[+-]ZZZZ].')
parser.add_argument('-u', '--until', dest = 'until', metavar = 'date',
help = 'Limit the computation to commits older than a specific date. '
'Format: YYYY-MM-DD [HH:MM:SS] [[+-]ZZZZ].')
args = parser.parse_args()
ret = {}
if args.author is not None:
ret['author'] = args.author
if args.since is not None:
since = parse_date(args.since)
if since is None:
parser.error('Wrong date format for -s, --since.')
ret['since'] = since
if args.until is not None:
until = parse_date(args.until)
if until is None:
parser.error('Wrong date format for -u, --until.')
if len(args.until) < 11:
until += timedelta(seconds = 86399)
ret['until'] = until
if len(args.files) > 0:
for filename in args.files:
try:
os.stat(filename)
except FileNotFoundError:
parser.error('File or folder "%s" does not exist.' % filename)
ret['files'] = args.files
if args.maxidle is not None:
ret['maxidle'] = args.maxidle
return ret
if __name__ == '__main__':
options = parse_options()
gitstats = workinghours(**options)
if gitstats is not None:
print('Changes: %d / Commits: %s / Hours: %.0f / Days: %d' % gitstats)
else:
print('Not enough information to estimate working hours.')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment