├── .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 | [![Status badge](https://github.com/kimmobrunfeldt/git-hours/actions/workflows/test.yml/badge.svg?branch=master)](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 | ![](docs/step0.png) 44 | 45 | *Go through all commits and compare the difference between 46 | them in time.* 47 | 48 |


49 | 50 | ![](docs/step1.png) 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 | ![](docs/step2.png) 58 | 59 | *If the difference is bigger than a given threshold, the coding session is finished.* 60 | 61 |


62 | 63 | ![](docs/step3.png) 64 | 65 | *To compensate the first commit whose work is unknown, we add extra hours to the coding session.* 66 | 67 |


68 | 69 | ![](docs/step4.png) 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 | --------------------------------------------------------------------------------