git-stats/lib/index.js

660 lines
17 KiB
JavaScript
Raw Permalink Normal View History

2015-01-25 20:49:49 +01:00
// Dependencies
2015-02-01 19:05:12 +01:00
var Ul = require("ul")
, Abs = require("abs")
, ReadJson = require("r-json")
, WriteJson = require("w-json")
2015-01-26 08:56:49 +01:00
, Moment = require("moment")
2015-05-26 11:05:24 +02:00
, Gry = require("gry")
2015-05-26 11:18:04 +02:00
, IsThere = require("is-there")
2015-05-28 18:49:44 +02:00
, CliPie = require("cli-pie")
, CliGhCal = require("cli-gh-cal")
2015-07-08 10:20:13 +02:00
, GitLogParser = require("gitlog-parser").parse
2015-07-08 10:42:35 +02:00
, ChildProcess = require("child_process")
, Deffy = require("deffy")
, Typpy = require("typpy")
2015-07-08 10:42:35 +02:00
, Exec = ChildProcess.exec
, Spawn = ChildProcess.spawn
, IterateObject = require("iterate-object")
2015-01-25 20:49:49 +01:00
;
// Constants
2015-07-12 15:57:43 +02:00
const DATE_FORMAT = "MMM D, YYYY"
, DEFAULT_STORE = Abs("~/.git-stats")
, DEFAULT_DATA = {
commits: {}
}
2015-08-03 06:40:00 +02:00
, CONFIG_PATH = Abs("~/.git-stats-config.json")
2015-07-12 15:57:43 +02:00
;
2015-01-25 20:54:02 +01:00
2015-07-13 08:08:22 +02:00
/**
* GitStats
*
* @name GitStats
* @function
* @param {String} dataPath Path to the data file.
* @return {GitStats} The `GitStats` instance.
*/
2015-07-12 16:51:02 +02:00
function GitStats(dataPath) {
this.path = Abs(Deffy(dataPath, DEFAULT_STORE));
this.config = {};
}
2015-01-25 20:49:49 +01:00
2015-08-03 06:40:00 +02:00
// Defaults
GitStats.CONFIG_PATH = CONFIG_PATH
GitStats.DEFAULT_CONFIG = {
// Dark theme by default
theme: "DARK"
// This defaults in library
, path: undefined
// This defaults in cli-gh-cal
, first_day: undefined
// This defaults to *one year ago*
, since: undefined
// This defaults to *now*
, until: undefined
// Don't show authors by default
, authors: false
// No global activity by default
, global_activity: false
};
2015-09-14 16:29:40 +02:00
/**
* getConfig
* Fetches the configuration object from file (`~/.git-stats-config.json`).
*
* @name getConfig
* @function
* @param {Function} callback The callback function.
* @return {Object|Undefined} If no callback is provided, the configuration object will be returned.
*/
GitStats.prototype.getConfig = function (callback) {
if (callback) {
ReadJson(CONFIG_PATH, function (err, data) {
callback(err && err.code !== "ENOENT" ? err : null, data || {});
});
} else {
try {
return ReadJson(CONFIG_PATH);
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
return {};
}
}
};
2015-09-14 16:29:40 +02:00
/**
* initConfig
* Inits the configuration field (`this.config`).
*
* @name initConfig
* @function
* @param {Object|String} input The path to a custom git-stats configuration file or the configuration object.
* @param {Function} callback The callback function.
*/
GitStats.prototype.initConfig = function (input, callback) {
var self = this;
if (Typpy(input, Function)) {
callback = input;
input = null;
}
input = input || CONFIG_PATH;
// Handle object input
if (Typpy(input, Object)) {
this.config = Ul.deepMerge(this.config, GitStats.DEFAULT_CONFIG);
callback && callback(null, this.config);
return this.config;
}
if (callback) {
this.getConfig(function (err, data) {
if (err) { return callback(err); }
self.initConfig(data, callback);
});
} else {
this.initConfig(this.getConfig());
}
};
2015-01-26 08:56:49 +01:00
/**
* record
* Records a new commit.
*
* @name record
* @function
* @param {Object} data The commit data containing:
*
2015-01-26 09:47:13 +01:00
* - `date` (String|Date): The date object or a string in a format that can be parsed.
2015-01-26 08:56:49 +01:00
* - `url` (String): The repository remote url.
* - `hash` (String): The commit hash.
2015-09-13 16:49:10 +02:00
* - `_data` (Object): If this field is provided, it should be the content of the git-stats data file as object. It will be modified in-memory and then returned.
* - `save` (Boolean): If `false`, the result will *not* be saved in the file.
2015-01-26 08:56:49 +01:00
*
* @param {Function} callback The callback function.
2015-07-13 08:08:22 +02:00
* @return {GitStats} The `GitStats` instance.
2015-01-26 08:56:49 +01:00
*/
GitStats.prototype.record = function (data, callback) {
var self = this;
2015-01-26 08:58:03 +01:00
// Validate data
2015-01-26 08:56:49 +01:00
callback = callback || function (err) { if (err) throw err; };
data = Object(data);
2015-01-26 08:56:49 +01:00
if (typeof data.date === "string") {
2015-01-26 09:47:13 +01:00
data.date = new Moment(new Date(data.date));
2015-01-26 08:56:49 +01:00
}
if (!/^moment|date$/.test(Typpy(data.date))) {
2015-02-09 14:11:47 +01:00
callback(new Error("The date field should be a string or a date object."));
return GitStats;
} else if (Typpy(data.date, Date)) {
data.date = Moment(data.date);
2015-01-26 08:56:49 +01:00
}
if (typeof data.hash !== "string" || !data.hash) {
2015-02-09 14:11:47 +01:00
callback(new Error("Invalid hash."));
return GitStats;
2015-01-26 08:56:49 +01:00
}
2015-07-13 08:08:22 +02:00
// This is not used, but remains here just in case we need
// it in the future
2015-01-26 08:56:49 +01:00
if (typeof data.url !== "string" || !data.url) {
delete data.url;
2015-01-26 08:56:49 +01:00
}
2015-09-13 16:44:40 +02:00
function modify (err, stats) {
var commits = stats.commits
, day = data.date.format(DATE_FORMAT)
, today = commits[day] = Object(commits[day])
2015-01-26 08:56:49 +01:00
;
today[data.hash] = 1;
2015-01-26 08:56:49 +01:00
2015-09-13 16:44:40 +02:00
if (data.save === false) {
callback(null, stats);
} else {
self.save(stats, callback);
}
return stats;
}
// Check if we have input data
if (data._data) {
return modify(null, data._data);
} else {
// Get stats
self.get(modify);
}
2015-02-09 14:11:47 +01:00
return self;
2015-01-25 20:54:02 +01:00
};
/**
* removeCommit
* Deletes a specifc commit from the history.
*
* @name record
* @function
* @param {Object} data The commit data containing:
*
* - `date` (String|Date): The date object or a string in a format that can be parsed. If not provided, the hash object will be searched in all dates.
* - `hash` (String): The commit hash.
* - `_data` (Object): If this field is provided, it should be the content of the git-stats data file as object. It will be modified in-memory and then returned.
* - `save` (Boolean): If `false`, the result will *not* be saved in the file.
*
* @param {Function} callback The callback function.
* @return {GitStats} The `GitStats` instance.
*/
GitStats.prototype.removeCommit = function (data, callback) {
var self = this;
// Validate data
callback = callback || function (err) { if (err) throw err; };
data = Object(data);
if (typeof data.date === "string") {
data.date = new Moment(new Date(data.date));
}
if (!/^moment|date$/.test(Typpy(data.date))) {
data.date = null;
} else if (Typpy(data.date, Date)) {
data.date = Moment(data.date);
}
if (typeof data.hash !== "string" || !data.hash) {
callback(new Error("Invalid hash."));
return GitStats;
}
function modify (err, stats) {
if (err) { return callback(err); }
if (!data.date) {
IterateObject(stats.commits, function (todayObj) {
delete todayObj[data.hash];
});
} else {
var commits = stats.commits
, day = data.date.format(DATE_FORMAT)
, today = commits[day] = Object(commits[day])
;
delete today[data.hash];
}
if (data.save === false) {
callback(null, stats);
} else {
self.save(stats, callback);
}
return stats;
}
// Check if we have input data
if (data._data) {
return modify(null, data._data);
} else {
// Get stats
self.get(modify);
}
return self;
};
2015-01-26 09:05:46 +01:00
/**
* get
* Gets the git stats.
*
* @name get
* @function
* @param {Function} callback The callback function.
2015-07-13 08:08:22 +02:00
* @return {GitStats} The `GitStats` instance.
2015-01-26 09:05:46 +01:00
*/
GitStats.prototype.get = function (callback) {
var self = this;
ReadJson(self.path, function (err, data) {
if (err && err.code === "ENOENT") {
return self.save(DEFAULT_DATA, function (err) {
callback(err, DEFAULT_DATA);
});
}
2015-02-01 19:05:12 +01:00
if (err) { return callback(err); }
callback(null, data);
});
return self;
};
2015-02-08 19:15:25 +01:00
/**
* save
* Saves the provided stats.
*
* @name save
* @function
* @param {Object} stats The stats to be saved.
* @param {Function} callback The callback function.
2015-07-13 08:08:22 +02:00
* @return {GitStats} The `GitStats` instance.
2015-02-08 19:15:25 +01:00
*/
GitStats.prototype.save = function (stats, callback) {
2015-07-12 14:22:26 +02:00
WriteJson(this.path, stats, callback);
return this;
2015-02-08 19:15:25 +01:00
};
2015-02-01 19:05:12 +01:00
2015-02-08 19:15:25 +01:00
/**
* iterateDays
2015-02-15 19:20:49 +01:00
* Iterate through the days, calling the callback function on each day.
2015-02-08 19:15:25 +01:00
*
* @name iterateDays
* @function
* @param {Object} data An object containing the following fields:
*
* - `start` (Moment): A `Moment` date object representing the start date (default: *an year ago*).
* - `end` (Moment): A `Moment` date object representing the end date (default: *now*).
* - `format` (String): The format of the date (default: `"MMM D, YYYY"`).
*
* @param {Function} callback The callback function called with the current day formatted (type: string) and the `Moment` date object.
2015-07-13 08:08:22 +02:00
* @return {GitStats} The `GitStats` instance.
2015-02-08 19:15:25 +01:00
*/
GitStats.prototype.iterateDays = function (data, callback) {
2015-01-25 20:54:02 +01:00
if (typeof data === "function") {
callback = data;
data = undefined;
}
// Merge the defaults
2015-05-28 20:32:57 +02:00
data.end = data.end || Moment();
data.start = data.start || Moment().subtract(1, "years");
data.format = data.format || DATE_FORMAT;
var start = new Moment(data.start.format(DATE_FORMAT), DATE_FORMAT)
, end = new Moment(data.end.format(DATE_FORMAT), DATE_FORMAT)
, tomrrow = Moment(end.format(DATE_FORMAT), DATE_FORMAT).add(1, "days")
, endStr = tomrrow.format(DATE_FORMAT)
, cDay = null
;
while (start.format(DATE_FORMAT) !== endStr) {
cDay = start.format(data.format);
2015-01-27 12:07:15 +01:00
callback(cDay, start);
2015-02-08 19:16:08 +01:00
start.add(1, "days");
2015-01-25 20:54:02 +01:00
}
2015-02-09 14:11:47 +01:00
return this;
2015-01-25 20:49:49 +01:00
};
2015-01-26 10:27:48 +01:00
2015-02-08 19:15:25 +01:00
/**
* graph
* Creates an object with the stats on the provided period (default: *last year*).
*
* @name graph
* @function
* @param {Object} data The object passed to the `iterateDays` method.
* @param {Function} callback The callback function.
2015-07-13 08:08:22 +02:00
* @return {GitStats} The `GitStats` instance.
2015-02-08 19:15:25 +01:00
*/
GitStats.prototype.graph = function (data, callback) {
if (typeof data === "function") {
callback = data;
data = undefined;
}
var self = this;
// Get commits
self.get(function (err, stats) {
2015-01-26 10:27:48 +01:00
if (err) { return callback(err); }
var cDayObj = null
, year = {}
2015-01-26 10:27:48 +01:00
;
// Iterate days
self.iterateDays(data, function (cDay) {
cDayObj = Object(stats.commits[cDay]);
2015-01-26 10:27:48 +01:00
cDayObj = year[cDay] = {
_: cDayObj
, c: Object.keys(cDayObj).length
2015-01-26 10:27:48 +01:00
};
});
2015-01-26 10:27:48 +01:00
callback(null, year);
});
2015-02-09 14:11:47 +01:00
return self;
};
2015-02-08 19:15:25 +01:00
/**
* calendar
* Creates the calendar data for the provided period (default: *last year*).
*
* @name calendar
* @function
* @param {Object} data The object passed to the `graph` method.
* @param {Function} callback The callback function.
2015-07-13 08:08:22 +02:00
* @return {GitStats} The `GitStats` instance.
2015-02-08 19:15:25 +01:00
*/
GitStats.prototype.calendar = function (data, callback) {
var self = this;
self.graph(data, function (err, graph) {
if (err) { return callback(err); }
2015-02-01 14:43:45 +01:00
var cal = { total: 0, days: {}, cStreak: 0, lStreak: 0, max: 0 }
, cDay = null
, days = Object.keys(graph)
, levels = null
, cLevel = 0
;
days.forEach(function (c) {
cDay = graph[c];
cal.total += cDay.c;
2015-02-01 14:43:45 +01:00
if (cDay.c > cal.max) {
cal.max = cDay.c;
}
2015-01-27 12:25:25 +01:00
if (cDay.c > 0) {
if (++cal.cStreak > cal.lStreak) {
cal.lStreak = cal.cStreak;
}
} else {
cal.cStreak = 0;
}
});
levels = cal.max / (LEVELS.length * 2);
days.forEach(function (c) {
cDay = graph[c];
cal.days[c] = {
c: cDay.c
, level: !levels
? 0 : (cLevel = Math.round(cDay.c / levels)) >= 4
? 4 : !cLevel && cDay.c > 0 ? 1 : cLevel
};
});
callback(null, cal);
});
return self;
};
2015-02-08 19:15:25 +01:00
/**
* ansiCalendar
* Creates the ANSI contributions calendar.
*
* @name ansiCalendar
* @function
2015-10-11 11:15:15 +02:00
* @param {Object} options The object passed to the `calendar` method.
2015-02-08 19:15:25 +01:00
* @param {Function} callback The callback function.
2015-07-13 08:08:22 +02:00
* @return {GitStats} The `GitStats` instance.
2015-02-08 19:15:25 +01:00
*/
2015-10-11 11:15:15 +02:00
GitStats.prototype.ansiCalendar = function (options, callback) {
2015-10-11 11:15:15 +02:00
if (typeof options === "function") {
callback = options;
options = undefined;
}
2015-01-27 12:07:15 +01:00
var self = this;
2015-10-11 11:15:15 +02:00
self.graph(options, function (err, graph) {
var cal = []
, data = {
theme: options.theme
, start: options.start
, end: options.end
, firstDay: options.firstDay
, cal: cal
}
;
2015-10-11 11:15:15 +02:00
self.iterateDays(options, function (cDay) {
cDayObj = graph[cDay];
if (!cDayObj) { return; }
cal.push([cDay, cDayObj.c]);
});
2015-10-11 11:15:15 +02:00
callback(null, options.raw ? data : CliGhCal(cal, data));
});
2015-02-09 14:11:47 +01:00
return self;
2015-01-26 10:27:48 +01:00
};
2015-05-05 15:56:26 +02:00
2015-07-13 08:08:22 +02:00
/**
* authors
* Creates an array with the authors of a git repository.
*
* @name authors
* @function
* @param {String|Object} options The repo path or an object containing the following fields:
*
* - `repo` (String): The repository path.
*
* @param {Function} callback The callback function.
* @return {GitStats} The `GitStats` instance.
*/
GitStats.prototype.authors = function (options, callback) {
2015-07-08 08:17:58 +02:00
var repo = new Gry(options.repo);
repo.exec("shortlog -s -n --all", function (err, stdout) {
if (err) { return callback(err); }
lines = stdout.split("\n");
pieData = stdout.split("\n").map(function (c) {
var splits = c.split("\t").map(function (cc) {
return cc.trim();
});
return {
value: parseInt(splits[0])
, label: splits[1]
};
});
callback(null, pieData);
});
return this;
2015-07-08 08:17:58 +02:00
};
2015-07-13 08:08:22 +02:00
/**
* authorsPie
* Creates the authors pie.
*
* @name authorsPie
* @function
* @param {String|Object} options The repo path or an object containing the following fields:
*
* - `repo` (String): The repository path.
* - `radius` (Number): The pie radius.
* - `no_ansi` (Boolean): If `true`, the pie will not contain ansi characters.
2015-10-11 11:15:15 +02:00
* - `raw` (Boolean): If `true`, the raw JSON will be displayed.
2015-07-13 08:08:22 +02:00
*
* @param {Function} callback The callback function.
* @return {GitStats} The `GitStats` instance.
*/
GitStats.prototype.authorsPie = function (options, callback) {
2015-07-13 08:08:22 +02:00
2015-05-26 11:18:04 +02:00
if (typeof options === "string") {
options = {
repo: options
};
}
2015-05-28 18:56:32 +02:00
options = Ul.merge(options, {
radius: process.stdout.rows / 2 || 20
});
2015-07-08 09:42:23 +02:00
if (!IsThere(options.repo)) {
2015-07-08 10:20:13 +02:00
return callback(new Error("The repository folder doesn't exist."));
2015-05-26 11:18:04 +02:00
}
2015-05-05 15:56:26 +02:00
var self = this
, repo = new Gry(options.repo)
2015-05-28 18:49:44 +02:00
, pie = null
, pieData = []
;
self.authors(options, function (err, authors) {
2015-05-26 11:18:04 +02:00
if (err) { return callback(err); }
2015-07-08 08:17:58 +02:00
if (authors.length > 50) {
var others = {
value: authors.slice(50).reduce(function (a, b) {
return a + b.value;
}, 0)
, label: "Others"
2015-05-28 18:49:44 +02:00
};
2015-07-08 08:17:58 +02:00
authors = authors.slice(0, 50);
authors.push(others);
}
2015-05-28 18:49:44 +02:00
2015-10-11 11:15:15 +02:00
var data = {
2015-05-28 18:49:44 +02:00
legend: true
, flat: true
2015-05-28 18:56:32 +02:00
, no_ansi: options.no_ansi
2015-10-11 11:15:15 +02:00
, authors: authors
};
2015-05-28 18:49:44 +02:00
2015-10-11 11:15:15 +02:00
callback(null, options.raw ? data : new CliPie(options.radius, authors, data).toString());
2015-05-26 11:18:04 +02:00
});
return self;
2015-05-05 15:56:26 +02:00
};
2015-07-08 10:20:13 +02:00
2015-07-13 08:08:22 +02:00
/**
* globalActivity
* Creates the global contributions calendar (all commits made by all committers).
*
* @name globalActivity
* @function
* @param {String|Object} options The repo path or an object containing the following fields:
*
* - `repo` (String): The repository path.
* - `start` (String): The start date.
* - `end` (String): The end date.
* - `theme` (String|Object): The calendar theme.
2015-10-11 11:15:15 +02:00
* - `raw` (Boolean): If `true`, the raw JSON will be displayed.
2015-07-13 08:08:22 +02:00
*
* @param {Function} callback The callback function.
* @return {GitStats} The `GitStats` instance.
*/
GitStats.prototype.globalActivity = function (options, callback) {
2015-07-08 10:20:13 +02:00
if (typeof options === "string") {
options = {
repo: options
};
}
options.repo = Abs(options.repo);
if (!IsThere(options.repo)) {
return callback(new Error("The repository folder doesn't exist."));
}
var commits = {}
, today = null
, cal = []
;
GitLogParser(Spawn("git", ["log", "--since", options.start.format(DATE_FORMAT), "--until", options.end.format(DATE_FORMAT)], { cwd: options.repo }).stdout).on("commit", function(commit) {
2015-07-13 09:53:04 +02:00
if (!commit) { return; }
today = Moment(commit.date).format(DATE_FORMAT);
commits[today] = commits[today] || 0;
++commits[today];
}).on("error", function (err) {
callback(err);
}).on("finish", function () {
Object.keys(commits).forEach(function (c) {
cal.push([c, commits[c]])
2015-07-08 10:20:13 +02:00
});
2015-10-11 11:15:15 +02:00
var data = {
theme: options.theme
, start: options.start
, end: options.end
2015-10-11 11:15:15 +02:00
, cal: cal
};
callback(null, options.raw ? data : CliGhCal(cal, data));
2015-07-08 10:20:13 +02:00
});
return this;
2015-07-08 10:20:13 +02:00
};
module.exports = GitStats;