├── .nvmrc
├── .prettierrc
├── test
├── .eslintrc
├── libs
│ ├── global-setup.js
│ └── global-teardown.js
└── utils
│ └── date.test.js
├── img
└── screenshot.png
├── bin
└── git-contrib-calendar.js
├── .npmignore
├── jest.config.js
├── .editorconfig
├── .eslintrc
├── src
├── utils
│ ├── errors.js
│ ├── cli.js
│ └── date.js
├── index.js
├── components
│ ├── RepoInfo.js
│ ├── CalendarDay.js
│ ├── App.js
│ └── Calendar.js
└── services
│ └── git.js
├── .github
└── workflows
│ └── ci.yml
├── README.md
├── LICENSE
├── .gitignore
└── package.json
/.nvmrc:
--------------------------------------------------------------------------------
1 | 24.12.0
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
4 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/img/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/giannisp/git-contrib-calendar/HEAD/img/screenshot.png
--------------------------------------------------------------------------------
/test/libs/global-setup.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Global setup for all tests.
3 | */
4 |
5 | module.exports = () => {};
6 |
--------------------------------------------------------------------------------
/test/libs/global-teardown.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Global teardown for all tests.
3 | */
4 |
5 | module.exports = () => {};
6 |
--------------------------------------------------------------------------------
/bin/git-contrib-calendar.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const importJsx = require('import-jsx');
4 |
5 | importJsx('../src');
6 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .github
2 | .vscode
3 | img
4 | .editorconfig
5 | .eslintrc
6 | .nvmrc
7 | .prettierrc
8 | jest.config.js
9 | test
10 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Jest configuration file.
3 | */
4 |
5 | module.exports = {
6 | testEnvironment: 'node',
7 | globalSetup: './test/libs/global-setup.js',
8 | globalTeardown: './test/libs/global-teardown.js',
9 | roots: ['./test'],
10 | };
11 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 | indent_size = 4
14 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["airbnb", "plugin:prettier/recommended", "prettier"],
3 | "env": {
4 | "node": true
5 | },
6 | "rules": {
7 | "react/jsx-filename-extension": 0,
8 | "react/require-default-props": 0,
9 | "react/function-component-definition": [
10 | 2,
11 | {
12 | "namedComponents": "arrow-function",
13 | "unnamedComponents": "arrow-function"
14 | }
15 | ]
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/utils/errors.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file App defined errors.
3 | */
4 |
5 | /* eslint-disable max-classes-per-file */
6 |
7 | /**
8 | * Generic app error handler extending base Error.
9 | */
10 | class AppError extends Error {
11 | constructor(message) {
12 | super(message);
13 |
14 | Error.captureStackTrace(this, this.constructor);
15 | this.name = this.constructor.name;
16 | }
17 | }
18 |
19 | /**
20 | * Error for invalid path.
21 | */
22 | class InvalidPathError extends AppError {
23 | constructor(path) {
24 | super('Invalid path');
25 | this.data = { path };
26 | }
27 | }
28 |
29 | module.exports = {
30 | AppError,
31 | InvalidPathError,
32 | };
33 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file App entrypoint.
3 | */
4 |
5 | /* eslint-disable no-console */
6 |
7 | const fs = require('fs');
8 | const React = require('react');
9 | const { render } = require('ink');
10 | const importJsx = require('import-jsx');
11 |
12 | const { initCli, getCliOptions } = require('./utils/cli');
13 |
14 | initCli();
15 |
16 | const { repoPath, year, dateRange, author } = getCliOptions();
17 |
18 | if (!fs.existsSync(repoPath)) {
19 | console.log(`Invalid path: ${repoPath}`);
20 |
21 | process.exit();
22 | }
23 |
24 | const App = importJsx('./components/App');
25 |
26 | render(
27 | ,
34 | );
35 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | name: Test
8 |
9 | runs-on: ubuntu-latest
10 |
11 | strategy:
12 | matrix:
13 | node-version: [24]
14 |
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 |
19 | - name: Setup NodeJS ${{ matrix.node-version }}
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version: ${{ matrix.node-version }}
23 |
24 | - name: Setup cache
25 | uses: actions/cache@v4
26 | with:
27 | path: ~/.npm
28 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
29 | restore-keys: |
30 | ${{ runner.os }}-node-
31 |
32 | - name: Install
33 | run: npm ci
34 |
35 | - name: ESLint
36 | run: npm run eslint
37 |
38 | - name: Test
39 | run: npm run test
40 |
--------------------------------------------------------------------------------
/src/components/RepoInfo.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file RepoInfo component.
3 | */
4 |
5 | const React = require('react');
6 | const PropTypes = require('prop-types');
7 | const { Text, Newline } = require('ink');
8 |
9 | const { getBranch } = require('../services/git');
10 |
11 | const RepoInfo = ({ repoPath }) => {
12 | const [branch, setBranch] = React.useState(null);
13 |
14 | React.useEffect(() => {
15 | const fetchBranch = async () => {
16 | const branchData = await getBranch(repoPath);
17 |
18 | setBranch(branchData);
19 | };
20 |
21 | fetchBranch();
22 | }, []);
23 |
24 | if (!branch) {
25 | return null;
26 | }
27 |
28 | return (
29 |
30 | Repository: {repoPath}
31 |
32 | Branch: {branch.current}
33 |
34 | );
35 | };
36 |
37 | RepoInfo.propTypes = {
38 | repoPath: PropTypes.string.isRequired,
39 | };
40 |
41 | module.exports = RepoInfo;
42 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # git-contrib-calendar
2 |
3 | 
4 |
5 | This is an experimental git contributions calendar running on the terminal for any local repository.
6 |
7 | The interface is inspired by the famous GitHub profile contributions graph, where each block represents a single day, and the color shade depends on the number of commits for that particular day.
8 |
9 | ## Install
10 |
11 | ```
12 | npm i -g git-contrib-calendar
13 | ```
14 |
15 | ## Run for git repository on the current path
16 |
17 | ```
18 | git-contrib-calendar
19 | ```
20 |
21 | ## CLI options
22 |
23 | ```
24 | -p, --path Path to any local git repository (example: -p /path/to/repo)
25 | -a, --author Filter git commits by author (example: -a John)
26 | -y, --year Display git commits for a specific year (example: -y 2018)
27 | -v, --version Display the app version
28 | -h, --help Display help
29 | ```
30 |
31 | ## Under the hood
32 |
33 | - Node
34 | - Ink
35 | - React
36 | - ESLint
37 | - Prettier
38 | - Commander CLI
39 |
--------------------------------------------------------------------------------
/src/components/CalendarDay.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file CalendarDay component.
3 | */
4 |
5 | const React = require('react');
6 | const PropTypes = require('prop-types');
7 | const { Box, Text } = require('ink');
8 |
9 | /**
10 | * Return the background color for the calendar day box.
11 | *
12 | * @param {Number} commitsCount The commits count.
13 | *
14 | * @return {String} The background color.
15 | */
16 | const getBackgroundColor = (commitsCount) => {
17 | if (commitsCount === 0) {
18 | return '#d0d0d0';
19 | }
20 |
21 | if (commitsCount > 0 && commitsCount <= 10) {
22 | return '#00d700';
23 | }
24 |
25 | if (commitsCount > 10 && commitsCount <= 30) {
26 | return '#00af00';
27 | }
28 |
29 | return '#005f00';
30 | };
31 |
32 | const CalendarDay = ({ commitsCount }) => (
33 |
34 | {' '}
35 |
36 | );
37 |
38 | CalendarDay.propTypes = {
39 | commitsCount: PropTypes.number.isRequired,
40 | };
41 |
42 | module.exports = CalendarDay;
43 |
--------------------------------------------------------------------------------
/test/utils/date.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Date util tests.
3 | */
4 |
5 | const { getCalendarDays, getDateRange } = require('../../src/utils/date');
6 |
7 | describe('Date util', () => {
8 | it('Should return calendar days for a date range', () => {
9 | const calendarDays = getCalendarDays('2022-01-01', '2022-01-03');
10 | expect(calendarDays).toHaveLength(3);
11 | expect(calendarDays[0]).toEqual({
12 | dayIndex: 6,
13 | monthIndex: 0,
14 | year: 2022,
15 | date: '2022-01-01',
16 | });
17 | expect(calendarDays[1]).toEqual({
18 | dayIndex: 0,
19 | monthIndex: 0,
20 | year: 2022,
21 | date: '2022-01-02',
22 | });
23 | expect(calendarDays[2]).toEqual({
24 | dayIndex: 1,
25 | monthIndex: 0,
26 | year: 2022,
27 | date: '2022-01-03',
28 | });
29 | });
30 |
31 | it('Should return a date range for a specific year', () => {
32 | const dateRange = getDateRange(2022);
33 | expect(dateRange.from).toEqual('2022-01-02');
34 | expect(dateRange.to).toEqual('2022-12-31');
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Ioannis Poulakas
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 |
--------------------------------------------------------------------------------
/src/utils/cli.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file CLI utils.
3 | */
4 |
5 | const path = require('path');
6 | const { program } = require('commander');
7 |
8 | const pkg = require('../../package.json');
9 | const { getDateRange } = require('./date');
10 |
11 | /**
12 | * Init command-line interface.
13 | */
14 | exports.initCli = () => {
15 | program
16 | .option(
17 | '-p, --path ',
18 | 'Path to any local git repository (example: -p /path/to/repo)',
19 | process.cwd(),
20 | )
21 | .option(
22 | '-a, --author ',
23 | 'Filter git commits by author (example: -a John)',
24 | undefined,
25 | )
26 | .option(
27 | '-y, --year ',
28 | 'Display git commits for a specific year (example: -y 2018)',
29 | undefined,
30 | );
31 |
32 | program.version(pkg.version, '-v, --version');
33 |
34 | program.parse(process.argv);
35 | };
36 |
37 | /**
38 | * Return command-line options.
39 | *
40 | * @return {Object} The options.
41 | */
42 | exports.getCliOptions = () => {
43 | const options = program.opts();
44 |
45 | const repoPath = path.resolve(options.path);
46 | const year = options.year ? parseInt(options.year, 10) : undefined;
47 | const dateRange = getDateRange(year);
48 |
49 | return { repoPath, year, dateRange, author: options.author };
50 | };
51 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # parcel-bundler cache (https://parceljs.org/)
61 | .cache
62 |
63 | # next.js build output
64 | .next
65 |
66 | # nuxt.js build output
67 | .nuxt
68 |
69 | # vuepress build output
70 | .vuepress/dist
71 |
72 | # Serverless directories
73 | .serverless
74 |
75 | # VS Code settings
76 | .vscode
77 |
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file App component.
3 | */
4 |
5 | const React = require('react');
6 | const PropTypes = require('prop-types');
7 | const { Box, Text } = require('ink');
8 | const importJsx = require('import-jsx');
9 |
10 | const { isGitRepo } = require('../services/git');
11 |
12 | const RepoInfo = importJsx('./RepoInfo');
13 | const Calendar = importJsx('./Calendar');
14 |
15 | const App = ({ repoPath, dateFrom, dateTo, year, author }) => {
16 | const [isRepo, setIsRepo] = React.useState();
17 |
18 | React.useEffect(() => {
19 | const fetchIsGitRepo = async () => {
20 | const isRepoResult = await isGitRepo(repoPath);
21 |
22 | setIsRepo(isRepoResult);
23 | };
24 |
25 | fetchIsGitRepo();
26 | }, []);
27 |
28 | if (typeof isRepo === 'undefined') {
29 | return null;
30 | }
31 |
32 | if (!isRepo) {
33 | return Not a git repository: {repoPath};
34 | }
35 |
36 | return (
37 |
38 |
39 |
40 |
41 |
42 |
49 |
50 | );
51 | };
52 |
53 | App.propTypes = {
54 | repoPath: PropTypes.string.isRequired,
55 | dateFrom: PropTypes.string.isRequired,
56 | dateTo: PropTypes.string.isRequired,
57 | year: PropTypes.number,
58 | author: PropTypes.string,
59 | };
60 |
61 | module.exports = App;
62 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "git-contrib-calendar",
3 | "version": "1.3.0",
4 | "description": "Experimental git contributions calendar for the terminal (built with NodeJS, React and Ink)",
5 | "main": "bin/git-contrib-calendar.js",
6 | "bin": {
7 | "git-contrib-calendar": "bin/git-contrib-calendar.js"
8 | },
9 | "scripts": {
10 | "start": "node bin/git-contrib-calendar.js",
11 | "eslint": "eslint src",
12 | "test": "jest"
13 | },
14 | "keywords": [
15 | "git",
16 | "contributions-calendar",
17 | "cli",
18 | "cli-app",
19 | "ink"
20 | ],
21 | "repository": {
22 | "type": "git",
23 | "url": "git+https://github.com/giannisp/git-contrib-calendar.git"
24 | },
25 | "author": "Ioannis Poulakas",
26 | "license": "MIT",
27 | "bugs": {
28 | "url": "https://github.com/giannisp/git-contrib-calendar/issues"
29 | },
30 | "homepage": "https://github.com/giannisp/git-contrib-calendar#readme",
31 | "dependencies": {
32 | "commander": "^10.0.1",
33 | "import-jsx": "^4.0.1",
34 | "ink": "^3.2.0",
35 | "lodash.groupby": "^4.6.0",
36 | "lodash.maxby": "^4.6.0",
37 | "lodash.uniqby": "^4.7.0",
38 | "luxon": "^3.7.2",
39 | "prop-types": "^15.8.1",
40 | "react": "^18.3.1",
41 | "simple-git": "^3.30.0"
42 | },
43 | "devDependencies": {
44 | "@types/jest": "^30.0.0",
45 | "eslint": "^8.56.0",
46 | "eslint-config-airbnb": "^19.0.4",
47 | "eslint-config-airbnb-base": "^15.0.0",
48 | "eslint-config-prettier": "^10.1.8",
49 | "eslint-plugin-import": "^2.32.0",
50 | "eslint-plugin-jsx-a11y": "^6.10.2",
51 | "eslint-plugin-prettier": "^5.5.4",
52 | "eslint-plugin-react": "^7.37.5",
53 | "eslint-plugin-react-hooks": "^4.6.0",
54 | "jest": "^30.2.0",
55 | "prettier": "^3.6.2"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/services/git.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Git service.
3 | */
4 |
5 | const simpleGit = require('simple-git');
6 |
7 | const { InvalidPathError } = require('../utils/errors');
8 |
9 | /**
10 | * Return a git instance.
11 | *
12 | * @param {String} repoPath The local repository path.
13 | *
14 | * @return {Object} The git instance.
15 | */
16 | const getGit = (repoPath) => {
17 | try {
18 | return simpleGit(repoPath);
19 | } catch (error) {
20 | if (error instanceof simpleGit.GitConstructError) {
21 | throw new InvalidPathError(repoPath);
22 | }
23 |
24 | throw error;
25 | }
26 | };
27 |
28 | exports.getGit = getGit;
29 |
30 | /**
31 | * Return if path is an actual git repository.
32 | *
33 | * @param {String} repoPath The local repository path.
34 | *
35 | * @return {Promise} If path is a git repository.
36 | */
37 | const isGitRepo = async (repoPath) => {
38 | const git = getGit(repoPath);
39 |
40 | return git.checkIsRepo();
41 | };
42 |
43 | exports.isGitRepo = isGitRepo;
44 |
45 | /**
46 | * Return branch data.
47 | *
48 | * @param {String} repoPath The local repository path.
49 | * @param {Object} options The branch command options.
50 | *
51 | * @return {Promise