├── .editorconfig
├── .eslintignore
├── .eslintrc
├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── LICENSE
├── README.md
├── docs
├── screenshot.png
├── step0.png
├── step1.png
├── step2.png
├── step3.png
└── step4.png
├── package-lock.json
├── package.json
├── src
└── index.js
└── test
└── test-functional.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | indent_style = tab
13 | indent_size = 2
14 | trim_trailing_whitespace = false
15 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | tmp
2 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "commonjs": true,
4 | "es6": true,
5 | "mocha": true,
6 | "node": true
7 | },
8 | "extends": "airbnb-base",
9 | "globals": {
10 | "Atomics": "readonly",
11 | "SharedArrayBuffer": "readonly"
12 | },
13 | "parserOptions": {
14 | "ecmaVersion": 2019
15 | },
16 | "rules": {
17 | "global-require": 0,
18 | "indent": [2, 2, { "SwitchCase": 1 }],
19 | "linebreak-style": [ 2, "unix" ],
20 | "no-console": 0, // this is a cli-app
21 | "no-multiple-empty-lines": [2, { "max": 2, "maxBOF": 2, "maxEOF": 2 }],
22 | "quotes": [ 2, "single" ],
23 | "semi": [ 2, "always" ]
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | run:
11 | name: Node ${{ matrix.node }} on ${{ matrix.os }}
12 | runs-on: ${{ matrix.os }}
13 |
14 | strategy:
15 | fail-fast: false
16 | matrix:
17 | node: [14]
18 | os: [ubuntu-latest]
19 |
20 | steps:
21 | - name: Set git to use LF
22 | run: |
23 | git config --global core.autocrlf false
24 | git config --global core.eol lf
25 |
26 | - uses: actions/checkout@v2
27 | - name: Use Node.js ${{ matrix.node }}
28 | uses: actions/setup-node@v2
29 | with:
30 | node-version: ${{ matrix.node }}
31 | - run: node --version && npm --version
32 | - run: npm ci
33 | - run: npm run eslint
34 | - run: npm test
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 |
5 | # Runtime data
6 | pids
7 | *.pid
8 | *.seed
9 |
10 | # Directory for instrumented libs generated by jscoverage/JSCover
11 | lib-cov
12 |
13 | # Coverage directory used by tools like istanbul
14 | coverage
15 |
16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
17 | .grunt
18 |
19 | # Compiled binary addons (http://nodejs.org/api/addons.html)
20 | build/Release
21 |
22 | # Dependency directory
23 | # Deployed apps should consider commenting this line out:
24 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
25 | node_modules
26 |
27 | # OS X
28 | .DS_Store
29 |
30 | # Vagrant directory
31 | .vagrant
32 |
33 | # Vim
34 | *.swp
35 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v14
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Kimmo Brunfeldt
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/kimmobrunfeldt/git-hours/actions?query=branch%3Amaster)
2 |
3 | # git-hours
4 |
5 | Estimate time spent on a git repository.
6 |
7 | **For example time spent on [Twitter's Bootstrap](https://github.com/twbs/bootstrap)**
8 |
9 | ```javascript
10 | ➜ bootstrap git:(master) git-hours
11 | {
12 |
13 | ...
14 |
15 | "total": {
16 | "hours": 9959,
17 | "commits": 11470
18 | }
19 | }
20 | ```
21 |
22 | From a person working 8 hours per day, it would take more than 3 years to build Bootstrap.
23 |
24 | *Please note that the information might not be accurate enough to be used in billing.*
25 |
26 | ## Install
27 |
28 | $ npm install -g git-hours
29 |
30 | **NOTE:** If for some reason `git-hours` won't work, try to `npm install -g nodegit`.
31 |
32 | `git-hours` depends on [nodegit](https://github.com/nodegit/nodegit).
33 | It might be a bit tricky to install. If installing git-hours fails for some
34 | reason, probably it was because nodegit couldn't be installed.
35 | Check [their documentation](https://github.com/nodegit/nodegit#getting-started) for troubleshooting.
36 |
37 | ## How it works
38 |
39 | The algorithm for estimating hours is quite simple. For each author in the commit history, do the following:
40 |
41 |
42 |
43 | 
44 |
45 | *Go through all commits and compare the difference between
46 | them in time.*
47 |
48 |
49 |
50 | 
51 |
52 | *If the difference is smaller or equal then a given threshold, group the commits
53 | to a same coding session.*
54 |
55 |
56 |
57 | 
58 |
59 | *If the difference is bigger than a given threshold, the coding session is finished.*
60 |
61 |
62 |
63 | 
64 |
65 | *To compensate the first commit whose work is unknown, we add extra hours to the coding session.*
66 |
67 |
68 |
69 | 
70 |
71 | *Continue until we have determined all coding sessions and sum the hours
72 | made by individual authors.*
73 |
74 |
75 |
76 | The algorithm in [~30 lines of code](https://github.com/kimmobrunfeldt/git-hours/blob/8aaeee237cb9d9028e7a2592a25ad8468b1f45e4/index.js#L114-L143).
77 |
78 | ## Usage
79 |
80 | In root of a git repository run:
81 |
82 | $ git-hours
83 |
84 | **Note: repository is not detected if you are not in the root of repository!**
85 |
86 | Help
87 |
88 | Usage: git-hours [options]
89 |
90 | Options:
91 |
92 | -h, --help output usage information
93 | -V, --version output the version number
94 | -d, --max-commit-diff [max-commit-diff] maximum difference in minutes between commits counted to one session. Default: 120
95 | -a, --first-commit-add [first-commit-add] how many minutes first commit of session should add to total. Default: 120
96 | -s, --since [since-certain-date] Analyze data since certain date. [always|yesterday|tonight|lastweek|yyyy-mm-dd] Default: always'
97 | -e, --email [emailOther=emailMain] Group person by email address. Default: none
98 | -u, --until [until-certain-date] Analyze data until certain date. [always|yesterday|today|lastweek|thisweek|yyyy-mm-dd] Default: always
99 | -m, --merge-request [false|true] Include merge requests into calculation. Default: true
100 | -p, --path [git-repo] Git repository to analyze. Default: .
101 | -b, --branch [branch-name] Analyze only data on the specified branch. Default: all branches
102 |
103 | Examples:
104 |
105 | - Estimate hours of project
106 |
107 | $ git-hours
108 |
109 | - Estimate hours in repository where developers commit more seldom: they might have 4h(240min) pause between commits
110 |
111 | $ git-hours --max-commit-diff 240
112 |
113 | - Estimate hours in repository where developer works 5 hours before first commit in day
114 |
115 | $ git-hours --first-commit-add 300
116 |
117 | - Estimate hours work in repository since yesterday
118 |
119 | $ git-hours --since yesterday
120 |
121 | - Estimate hours work in repository since 2015-01-31
122 |
123 | $ git-hours --since 2015-01-31
124 |
125 | - Estimate hours work in repository on the "master" branch
126 |
127 | $ git-hours --branch master
128 |
129 | For more details, visit https://github.com/kimmobrunfeldt/git-hours
130 |
131 |
--------------------------------------------------------------------------------
/docs/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kimmobrunfeldt/git-hours/c589eea5174ee6439b68b4b437e443e7b6e4bf7b/docs/screenshot.png
--------------------------------------------------------------------------------
/docs/step0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kimmobrunfeldt/git-hours/c589eea5174ee6439b68b4b437e443e7b6e4bf7b/docs/step0.png
--------------------------------------------------------------------------------
/docs/step1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kimmobrunfeldt/git-hours/c589eea5174ee6439b68b4b437e443e7b6e4bf7b/docs/step1.png
--------------------------------------------------------------------------------
/docs/step2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kimmobrunfeldt/git-hours/c589eea5174ee6439b68b4b437e443e7b6e4bf7b/docs/step2.png
--------------------------------------------------------------------------------
/docs/step3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kimmobrunfeldt/git-hours/c589eea5174ee6439b68b4b437e443e7b6e4bf7b/docs/step3.png
--------------------------------------------------------------------------------
/docs/step4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kimmobrunfeldt/git-hours/c589eea5174ee6439b68b4b437e443e7b6e4bf7b/docs/step4.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "git-hours",
3 | "version": "1.5.0",
4 | "description": "Estimate time spent on a git repository",
5 | "main": "./src/index.js",
6 | "bin": {
7 | "git-hours": "./src/index.js"
8 | },
9 | "files": [
10 | "src/**/*"
11 | ],
12 | "repository": {
13 | "type": "git",
14 | "url": "https://github.com/kimmobrunfeldt/git-hours.git"
15 | },
16 | "keywords": [
17 | "git",
18 | "time",
19 | "spent",
20 | "tracking",
21 | "clock",
22 | "hours"
23 | ],
24 | "author": "Kimmo Brunfeldt",
25 | "license": "MIT",
26 | "bugs": {
27 | "url": "https://github.com/kimmobrunfeldt/git-hours/issues"
28 | },
29 | "homepage": "https://github.com/kimmobrunfeldt/git-hours",
30 | "dependencies": {
31 | "bluebird": "^3.7.2",
32 | "commander": "^8.0.0",
33 | "lodash": "^4.17.21",
34 | "moment": "^2.10.6",
35 | "nodegit": "^0.27.0"
36 | },
37 | "devDependencies": {
38 | "eslint": "^7.30.0",
39 | "eslint-config-airbnb-base": "^14.2.1",
40 | "eslint-plugin-import": "^2.23.4",
41 | "mocha": "^9.0.2",
42 | "nodemon": "^2.0.9",
43 | "np": "^7.5.0"
44 | },
45 | "scripts": {
46 | "dev": "npx nodemon --exec 'npm run test && npm run lint'",
47 | "test": "npx mocha -R spec",
48 | "lint": "npx eslint --ext js ."
49 | },
50 | "engines": {
51 | "node": "^14.x"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const Promise = require('bluebird');
4 | const _ = require('lodash');
5 | const fs = require('fs');
6 | const git = require('nodegit');
7 | const moment = require('moment');
8 | const program = require('commander');
9 |
10 | const DATE_FORMAT = 'YYYY-MM-DD';
11 |
12 | let config = {
13 | // Maximum time diff between 2 subsequent commits in minutes which are
14 | // counted to be in the same coding "session"
15 | maxCommitDiffInMinutes: 2 * 60,
16 |
17 | // How many minutes should be added for the first commit of coding session
18 | firstCommitAdditionInMinutes: 2 * 60,
19 |
20 | // Include commits since time x
21 | since: 'always',
22 | until: 'always',
23 |
24 | // Include merge requests
25 | mergeRequest: true,
26 |
27 | // Git repo
28 | gitPath: '.',
29 |
30 | // Aliases of emails for grouping the same activity as one person
31 | emailAliases: {
32 | 'linus@torvalds.com': 'linus@linux.com',
33 | },
34 | branch: null,
35 | };
36 |
37 | // Estimates spent working hours based on commit dates
38 | function estimateHours(dates) {
39 | if (dates.length < 2) {
40 | return 0;
41 | }
42 |
43 | // Oldest commit first, newest last
44 | const sortedDates = dates.sort((a, b) => a - b);
45 | const allButLast = _.take(sortedDates, sortedDates.length - 1);
46 |
47 | const totalHours = _.reduce(allButLast, (hours, date, index) => {
48 | const nextDate = sortedDates[index + 1];
49 | const diffInMinutes = (nextDate - date) / 1000 / 60;
50 |
51 | // Check if commits are counted to be in same coding session
52 | if (diffInMinutes < config.maxCommitDiffInMinutes) {
53 | return hours + diffInMinutes / 60;
54 | }
55 |
56 | // The date difference is too big to be inside single coding session
57 | // The work of first commit of a session cannot be seen in git history,
58 | // so we make a blunt estimate of it
59 | return hours + config.firstCommitAdditionInMinutes / 60;
60 | }, 0);
61 |
62 | return Math.round(totalHours);
63 | }
64 |
65 | function getBranchCommits(branchLatestCommit) {
66 | return new Promise((resolve, reject) => {
67 | const history = branchLatestCommit.history();
68 | const commits = [];
69 |
70 | history.on('commit', (commit) => {
71 | let author = null;
72 | if (!_.isNull(commit.author())) {
73 | author = {
74 | name: commit.author().name(),
75 | email: commit.author().email(),
76 | };
77 | }
78 |
79 | const commitData = {
80 | sha: commit.sha(),
81 | date: commit.date(),
82 | message: commit.message(),
83 | author,
84 | };
85 |
86 | let isValidSince = true;
87 | const sinceAlways = config.since === 'always' || !config.since;
88 | if (sinceAlways || moment(commitData.date.toISOString()).isAfter(config.since)) {
89 | isValidSince = true;
90 | } else {
91 | isValidSince = false;
92 | }
93 |
94 | let isValidUntil = true;
95 | const untilAlways = config.until === 'always' || !config.until;
96 | if (untilAlways || moment(commitData.date.toISOString()).isBefore(config.until)) {
97 | isValidUntil = true;
98 | } else {
99 | isValidUntil = false;
100 | }
101 |
102 | if (isValidSince && isValidUntil) {
103 | commits.push(commitData);
104 | }
105 | });
106 | history.on('end', () => resolve(commits));
107 | history.on('error', reject);
108 |
109 | // Start emitting events.
110 | history.start();
111 | });
112 | }
113 |
114 | function getBranchLatestCommit(repo, branchName) {
115 | return repo.getBranch(branchName).then((reference) => repo.getBranchCommit(reference.name()));
116 | }
117 |
118 | function getAllReferences(repo) {
119 | return repo.getReferenceNames(git.Reference.TYPE.ALL);
120 | }
121 |
122 | // Promisify nodegit's API of getting all commits in repository
123 | function getCommits(gitPath, branch) {
124 | return git.Repository.open(gitPath)
125 | .then((repo) => {
126 | const allReferences = getAllReferences(repo);
127 | let filterPromise;
128 |
129 | if (branch) {
130 | filterPromise = Promise.filter(allReferences, (reference) => (reference === `refs/heads/${branch}`));
131 | } else {
132 | filterPromise = Promise.filter(allReferences, (reference) => reference.match(/refs\/heads\/.*/));
133 | }
134 |
135 | return filterPromise.map((branchName) => getBranchLatestCommit(repo, branchName))
136 | .map((branchLatestCommit) => getBranchCommits(branchLatestCommit))
137 | .reduce((allCommits, branchCommits) => {
138 | _.each(branchCommits, (commit) => {
139 | allCommits.push(commit);
140 | });
141 |
142 | return allCommits;
143 | }, [])
144 | .then((commits) => {
145 | // Multiple branches might share commits, so take unique
146 | const uniqueCommits = _.uniq(commits, (item) => item.sha);
147 |
148 | return uniqueCommits.filter((commit) => {
149 | // Exclude all commits starting with "Merge ..."
150 | if (!config.mergeRequest && commit.message.startsWith('Merge ')) {
151 | return false;
152 | }
153 | return true;
154 | });
155 | });
156 | });
157 | }
158 |
159 | function parseEmailAlias(value) {
160 | if (value.indexOf('=') > 0) {
161 | const email = value.substring(0, value.indexOf('=')).trim();
162 | const alias = value.substring(value.indexOf('=') + 1).trim();
163 | if (config.emailAliases === undefined) {
164 | config.emailAliases = {};
165 | }
166 | config.emailAliases[email] = alias;
167 | } else {
168 | console.error(`ERROR: Invalid alias: ${value}`);
169 | }
170 | }
171 |
172 | function mergeDefaultsWithArgs(conf) {
173 |
174 | const options = program.opts();
175 | return {
176 | range: options.range,
177 | maxCommitDiffInMinutes: options.maxCommitDiff || conf.maxCommitDiffInMinutes,
178 | firstCommitAdditionInMinutes: options.firstCommitAdd || conf.firstCommitAdditionInMinutes,
179 | since: options.since || conf.since,
180 | until: options.until || conf.until,
181 | gitPath: options.path || conf.gitPath,
182 | mergeRequest: options.mergeRequest !== undefined ? (options.mergeRequest === 'true') : conf.mergeRequest,
183 | branch: options.branch || conf.branch,
184 | };
185 | }
186 |
187 | function parseInputDate(inputDate) {
188 | switch (inputDate) {
189 | case 'today':
190 | return moment().startOf('day');
191 | case 'yesterday':
192 | return moment().startOf('day').subtract(1, 'day');
193 | case 'thisweek':
194 | return moment().startOf('week');
195 | case 'lastweek':
196 | return moment().startOf('week').subtract(1, 'week');
197 | case 'always':
198 | return 'always';
199 | default:
200 | // XXX: Moment tries to parse anything, results might be weird
201 | return moment(inputDate, DATE_FORMAT);
202 | }
203 | }
204 |
205 | function parseSinceDate(since) {
206 | return parseInputDate(since);
207 | }
208 |
209 | function parseUntilDate(until) {
210 | return parseInputDate(until);
211 | }
212 |
213 | function parseArgs() {
214 | function int(val) {
215 | return parseInt(val, 10);
216 | }
217 |
218 | program
219 | .version(require('../package.json').version)
220 | .usage('[options]')
221 | .option(
222 | '-d, --max-commit-diff [max-commit-diff]',
223 | `maximum difference in minutes between commits counted to one session. Default: ${config.maxCommitDiffInMinutes}`,
224 | int,
225 | )
226 | .option(
227 | '-a, --first-commit-add [first-commit-add]',
228 | `how many minutes first commit of session should add to total. Default: ${config.firstCommitAdditionInMinutes}`,
229 | int,
230 | )
231 | .option(
232 | '-s, --since [since-certain-date]',
233 | `Analyze data since certain date. [always|yesterday|today|lastweek|thisweek|yyyy-mm-dd] Default: ${config.since}`,
234 | String,
235 | )
236 | .option(
237 | '-e, --email [emailOther=emailMain]',
238 | 'Group person by email address. Default: none',
239 | String,
240 | )
241 | .option(
242 | '-u, --until [until-certain-date]',
243 | `Analyze data until certain date. [always|yesterday|today|lastweek|thisweek|yyyy-mm-dd] Default: ${config.until}`,
244 | String,
245 | )
246 | .option(
247 | '-m, --merge-request [false|true]',
248 | `Include merge requests into calculation. Default: ${config.mergeRequest}`,
249 | String,
250 | )
251 | .option(
252 | '-p, --path [git-repo]',
253 | `Git repository to analyze. Default: ${config.gitPath}`,
254 | String,
255 | )
256 | .option(
257 | '-b, --branch [branch-name]',
258 | `Analyze only data on the specified branch. Default: ${config.branch}`,
259 | String,
260 | );
261 |
262 | program.on('--help', () => {
263 | console.log([
264 | ' Examples:',
265 | ' - Estimate hours of project',
266 | ' $ git-hours',
267 | ' - Estimate hours in repository where developers commit more seldom: they might have 4h(240min) pause between commits',
268 | ' $ git-hours --max-commit-diff 240',
269 | ' - Estimate hours in repository where developer works 5 hours before first commit in day',
270 | ' $ git-hours --first-commit-add 300',
271 | ' - Estimate hours work in repository since yesterday',
272 | ' $ git-hours --since yesterday',
273 | ' - Estimate hours work in repository since 2015-01-31',
274 | ' $ git-hours --since 2015-01-31',
275 | ' - Estimate hours work in repository on the "master" branch',
276 | ' $ git-hours --branch master',
277 | ' For more details, visit https://github.com/kimmobrunfeldt/git-hours',
278 | ].join('\n\n'));
279 | });
280 |
281 | program.parse(process.argv);
282 | }
283 |
284 | function exitIfShallow() {
285 | if (fs.existsSync('.git/shallow')) {
286 | console.log('Cannot analyze shallow copies!');
287 | console.log('Please run git fetch --unshallow before continuing!');
288 | process.exit(1);
289 | }
290 | }
291 |
292 | function main() {
293 | exitIfShallow();
294 |
295 | parseArgs();
296 | config = mergeDefaultsWithArgs(config);
297 | config.since = parseSinceDate(config.since);
298 | config.until = parseUntilDate(config.until);
299 |
300 | // Poor man`s multiple args support
301 | // https://github.com/tj/commander.js/issues/531
302 | for (let i = 0; i < process.argv.length; i += 1) {
303 | const k = process.argv[i];
304 | let n = i <= process.argv.length - 1 ? process.argv[i + 1] : undefined;
305 | if (k === '-e' || k === '--email') {
306 | parseEmailAlias(n);
307 | } else if (k.startsWith('--email=')) {
308 | n = k.substring(k.indexOf('=') + 1);
309 | parseEmailAlias(n);
310 | }
311 | }
312 |
313 | getCommits(config.gitPath, config.branch).then((commits) => {
314 | const commitsByEmail = _.groupBy(commits, (commit) => {
315 | let email = commit.author.email || 'unknown';
316 | if (config.emailAliases !== undefined && config.emailAliases[email] !== undefined) {
317 | email = config.emailAliases[email];
318 | }
319 | return email;
320 | });
321 |
322 | const authorWorks = _.map(commitsByEmail, (authorCommits, authorEmail) => ({
323 | email: authorEmail,
324 | name: authorCommits[0].author.name,
325 | hours: estimateHours(_.map(authorCommits, 'date')),
326 | commits: authorCommits.length,
327 | }));
328 |
329 | // XXX: This relies on the implementation detail that json is printed
330 | // in the same order as the keys were added. This is anyway just for
331 | // making the output easier to read, so it doesn't matter if it
332 | // isn't sorted in some cases.
333 | const sortedWork = {};
334 |
335 | _.each(_.sortBy(authorWorks, 'hours'), (authorWork) => {
336 | sortedWork[authorWork.email] = _.omit(authorWork, 'email');
337 | });
338 |
339 | const totalHours = _.reduce(sortedWork, (sum, authorWork) => sum + authorWork.hours, 0);
340 |
341 | sortedWork.total = {
342 | hours: totalHours,
343 | commits: commits.length,
344 | };
345 |
346 | console.log(JSON.stringify(sortedWork, undefined, 2));
347 | }).catch((e) => {
348 | console.error(e.stack);
349 | });
350 | }
351 |
352 | main();
353 |
--------------------------------------------------------------------------------
/test/test-functional.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const { exec } = require('child_process');
3 |
4 | let totalHoursCount;
5 |
6 | describe('git-hours', () => {
7 | it('should output json', (done) => {
8 | exec('node ./src/index.js', (err, stdout, stderr) => {
9 | if (err !== null) {
10 | throw new Error(stderr);
11 | }
12 | const work = JSON.parse(stdout);
13 | assert.notEqual(work.total.hours.length, 0);
14 | assert.notEqual(work.total.commits.length, 0);
15 | totalHoursCount = work.total.hours;
16 | done();
17 | });
18 | });
19 |
20 | it('Should analyse since today', (done) => {
21 | exec('node ./src/index.js --since today', (err, stdout) => {
22 | assert.ifError(err);
23 | const work = JSON.parse(stdout);
24 | assert.strictEqual(typeof work.total.hours, 'number');
25 | done();
26 | });
27 | });
28 |
29 | it('Should analyse since yesterday', (done) => {
30 | exec('node ./src/index.js --since yesterday', (err, stdout) => {
31 | assert.ifError(err);
32 | const work = JSON.parse(stdout);
33 | assert.strictEqual(typeof work.total.hours, 'number');
34 | done();
35 | });
36 | });
37 |
38 | it('Should analyse since last week', (done) => {
39 | exec('node ./src/index.js --since lastweek', (err, stdout) => {
40 | assert.ifError(err);
41 | const work = JSON.parse(stdout);
42 | assert.strictEqual(typeof work.total.hours, 'number');
43 | done();
44 | });
45 | });
46 |
47 | it('Should analyse since a specific date', (done) => {
48 | exec('node ./src/index.js --since 2015-01-01', (err, stdout) => {
49 | assert.ifError(err);
50 | const work = JSON.parse(stdout);
51 | assert.notEqual(work.total.hours, 0);
52 | done();
53 | });
54 | });
55 |
56 | it('Should analyse as without param', (done) => {
57 | exec('node ./src/index.js --since always', (err, stdout) => {
58 | assert.ifError(err);
59 | const work = JSON.parse(stdout);
60 | assert.equal(work.total.hours, totalHoursCount);
61 | done();
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------