#!/usr/bin/env node var Promise = require('bluebird'); var git = require('nodegit'); var program = require('commander'); var _ = require('lodash'); var moment = require('moment'); var exec = Promise.promisify(require('child_process').exec); var DATE_FORMAT = 'YYYY-MM-DD'; var config = { // Maximum time diff between 2 subsequent commits in minutes which are // counted to be in the same coding "session" maxCommitDiffInMinutes: 2 * 60, // How many minutes should be added for the first commit of coding session firstCommitAdditionInMinutes: 2 * 60, // Include commits since time x since: 'always' }; function main() { parseArgs(); config = mergeDefaultsWithArgs(config); config.since = parseSinceDate(config.since); getCommits('.').then(function(commits) { var commitsByEmail = _.groupBy(commits, function(commit) { return commit.author.email || 'unknown'; }); var authorWorks = _.map(commitsByEmail, function(authorCommits, authorEmail) { return { email: authorEmail, name: authorCommits[0].author.name, hours: estimateHours(_.pluck(authorCommits, 'date')), commits: authorCommits.length }; }); // XXX: This relies on the implementation detail that json is printed // in the same order as the keys were added. This is anyway just for // making the output easier to read, so it doesn't matter if it // isn't sorted in some cases. var sortedWork = {}; _.each(_.sortBy(authorWorks, 'hours'), function(authorWork) { sortedWork[authorWork.email] = _.omit(authorWork, 'email'); }); var totalHours = _.reduce(sortedWork, function(sum, authorWork) { return sum + authorWork.hours; }, 0); sortedWork.total = { hours: totalHours, commits: commits.length }; console.log(JSON.stringify(sortedWork, undefined, 2)); }).catch(function(e) { console.error(e.stack); }); } function parseArgs() { function int(val) { return parseInt(val, 10); } program .version(require('../package.json').version) .usage('[options]') .option( '-d, --max-commit-diff [max-commit-diff]', 'maximum difference in minutes between commits counted to one' + ' session. Default: ' + config.maxCommitDiffInMinutes, int ) .option( '-a, --first-commit-add [first-commit-add]', 'how many minutes first commit of session should add to total.' + ' Default: ' + config.firstCommitAdditionInMinutes, int ) .option( '-s, --since [since-certain-date]', 'Analyze data since certain date.' + ' [always|yesterday|today|lastweek|thisweek|yyyy-mm-dd] Default: ' + config.since, String ); program.on('--help', function() { console.log(' Examples:'); console.log(''); console.log(' - Estimate hours of project'); console.log(''); console.log(' $ git hours'); console.log(''); console.log(' - Estimate hours in repository where developers commit' + ' more seldom: they might have 4h(240min) pause between commits'); console.log(''); console.log(' $ git hours --max-commit-diff 240'); console.log(''); console.log(' - Estimate hours in repository where developer works 5' + ' hours before first commit in day'); console.log(''); console.log(' $ git hours --first-commit-add 300'); console.log(''); console.log(' - Estimate hours work in repository since yesterday'); console.log(''); console.log(' $ git hours --since yesterday'); console.log(''); console.log(' - Estimate hours work in repository since 2015-01-31'); console.log(''); console.log(' $ git hours --since 2015-01-31'); console.log(''); console.log(' For more details, visit https://github.com/kimmobrunfeldt/git-hours'); console.log(''); }); program.parse(process.argv); } function parseSinceDate(since) { switch (since) { case 'today': return moment().startOf('day'); case 'yesterday': return moment().startOf('day').subtract(1, 'day'); case 'thisweek': return moment().startOf('week'); case 'lastweek': return moment().startOf('week').subtract(1, 'week'); case 'always': return 'always'; default: // XXX: Moment tries to parse anything, results might be weird return moment(since, DATE_FORMAT); } } function mergeDefaultsWithArgs(conf) { return { range: program.range, maxCommitDiffInMinutes: program.maxCommitDiff || conf.maxCommitDiffInMinutes, firstCommitAdditionInMinutes: program.firstCommitAdd || conf.firstCommitAdditionInMinutes, since: program.since || conf.since }; } // Estimates spent working hours based on commit dates function estimateHours(dates) { if (dates.length < 2) { return 0; } // Oldest commit first, newest last var sortedDates = dates.sort(function(a, b) { return a - b; }); var allButLast = _.take(sortedDates, sortedDates.length - 1); var totalHours = _.reduce(allButLast, function(hours, date, index) { var nextDate = sortedDates[index + 1]; var diffInMinutes = (nextDate - date) / 1000 / 60; // Check if commits are counted to be in same coding session if (diffInMinutes < config.maxCommitDiffInMinutes) { return hours + diffInMinutes / 60; } // The date difference is too big to be inside single coding session // The work of first commit of a session cannot be seen in git history, // so we make a blunt estimate of it return hours + config.firstCommitAdditionInMinutes / 60; }, 0); return Math.round(totalHours); } // Promisify nodegit's API of getting all commits in repository function getCommits(gitPath) { return git.Repository.open(gitPath) .then(function(repo) { var branchNames = getBranchNames(gitPath); return Promise.map(branchNames, function(branchName) { return getBranchLatestCommit(repo, branchName); }) .map(function(branchLatestCommit) { return getBranchCommits(branchLatestCommit); }) .reduce(function(allCommits, branchCommits) { _.each(branchCommits, function(commit) { allCommits.push(commit); }); return allCommits; }, []) .then(function(commits) { // Multiple branches might share commits, so take unique var uniqueCommits = _.uniq(commits, function(item, key, a) { return item.sha; }); return uniqueCommits; }); }); } function getBranchNames(gitPath) { var cmd = "git branch --no-color | awk -F ' +' '! /\\(no branch\\)/ {print $2}'"; return new Promise(function(resolve, reject) { exec(cmd, {cwd: gitPath}, function(err, stdout, stderr) { if (err) { reject(err); } resolve(stdout .split('\n') .filter(function(e) { return e; }) // Remove empty .map(function(str) { return str.trim(); }) // Trim whitespace ); }); }); } function getBranchLatestCommit(repo, branchName) { return repo.getBranch(branchName).then(function(reference) { return repo.getBranchCommit(reference.name()); }); } function getBranchCommits(branchLatestCommit) { return new Promise(function(resolve, reject) { var history = branchLatestCommit.history(); var commits = []; history.on('commit', function(commit) { var author = null; if (!_.isNull(commit.author())) { author = { name: commit.author().name(), email: commit.author().email() }; } var commitData = { sha: commit.sha(), date: commit.date(), message: commit.message(), author: author }; var sinceAlways = config.since === 'always' || !config.since; if (sinceAlways || moment(commitData.date.toISOString()).isAfter(config.since)) { commits.push(commitData); } }); history.on('end', function() { resolve(commits); }); history.on('error', function(err) { reject(err); }); // Start emitting events. history.start(); }); } main();