├── .gitignore ├── INSTALL ├── LICENSE ├── Makefile.PL ├── README.md ├── git-cal └── screenshots ├── img1.png └── img2.png /.gitignore: -------------------------------------------------------------------------------- 1 | blib/ 2 | .build/ 3 | _build/ 4 | cover_db/ 5 | inc/ 6 | Build 7 | !Build/ 8 | Build.bat 9 | .last_cover_stats 10 | Makefile 11 | Makefile.old 12 | MANIFEST.bak 13 | META.yml 14 | MYMETA.yml 15 | MYMETA.json 16 | nytprof.out 17 | pm_to_blib 18 | -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | With root access: 5 | 6 | perl Makefile.PL 7 | make 8 | sudo make install 9 | 10 | Without root access: 11 | 12 | perl Makefile.PL PREFIX=~/.local 13 | make 14 | make install 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Karthik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile.PL: -------------------------------------------------------------------------------- 1 | use 5.006; 2 | use strict; 3 | use warnings; 4 | use ExtUtils::MakeMaker; 5 | 6 | WriteMakefile( 7 | NAME => 'git-cal', 8 | ( eval ($ExtUtils::MakeMaker::VERSION) >= 6.3002 ? ( 'LICENSE' => 'mit' ) : () ), 9 | EXE_FILES => [ 'git-cal', ], 10 | PL_FILES => {}, 11 | PREREQ_PM => {}, 12 | dist => { COMPRESS => 'gzip -9f', SUFFIX => 'gz', }, 13 | ); 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | git-cal 2 | ======= 3 | 4 | ### Description 5 | ![screenshot with black theme](https://raw.github.com/k4rthik/git-cal/master/screenshots/img1.png) 6 | ![screenshot with white theme](https://raw.github.com/k4rthik/git-cal/master/screenshots/img2.png) 7 | on your terminal 8 | 9 | * git-cal is a simple script to view commits calendar (similar to github contributions calendar) on command line 10 | * Each block in the graph corresponds to a day and is shaded with one 11 | of the 5 possible colors, each representing relative number of commits on that day. 12 | * Option to choose --ascii or --unicode to denote the same instead of the ANSI colors. 13 | * Option to use git config to set options. 14 | 15 | ### Install 16 | 17 | - with root access: 18 | ``` 19 | perl Makefile.PL 20 | make 21 | sudo make install 22 | ``` 23 | 24 | - without root access: 25 | ``` 26 | perl Makefile.PL PREFIX=~/.local 27 | make 28 | make install 29 | ``` 30 | 31 | - with Homebrew 32 | ``` 33 | brew install git-cal 34 | ``` 35 | 36 | -------------------------------------------------------------------------------- /git-cal: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use utf8; 7 | use Getopt::Long; 8 | use Pod::Usage; 9 | use Time::Local; 10 | use Data::Dumper; 11 | 12 | binmode(STDOUT, ":utf8"); 13 | #command line options 14 | my ( $help, $period, $use_ascii, $use_ansi, $use_unicode, $format, $author, $filepath, $all_branches ); 15 | 16 | GetOptions( 17 | 'help|?' => \$help, 18 | 'period|p=n' => \$period, 19 | 'ascii' => \$use_ascii, 20 | 'ansi' => \$use_ansi, 21 | 'unicode' => \$use_unicode, 22 | 'author=s' => \$author, 23 | 'all' => \$all_branches 24 | ) or pod2usage(2); 25 | 26 | pod2usage(1) if $help; 27 | 28 | $filepath = shift @ARGV; 29 | 30 | # also tried to use unicode chars instead of colors, the exp did not go well 31 | #qw(⬚ ⬜ ▤ ▣ ⬛) 32 | #qw(⬚ ▢ ▤ ▣ ⬛) 33 | 34 | my @unicode = qw(⬚ ▢ ▤ ▣ ⬛); 35 | my @colors = ( 237, 139, 40, 190, 1 ); 36 | my @ascii = ( " ", ".", "o", "O", "0" ); 37 | my @months = qw (Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec); 38 | 39 | configure(\$period, \$use_ascii, \$use_ansi, \$use_unicode, \$author); 40 | 41 | process(); 42 | 43 | # 53 X 7 grid 44 | # consists of 0 - 370 blocks 45 | my ( @grid, @timeline, %pos_month, %month_pos, $jan1, $cur_year, $max_epoch, $min_epoch, $max_commits, $q1, $q2, $q3 ); 46 | my ( $first_block, $last_block, $start_block, $end_block, $row_start, $row_end ); 47 | #loads of global variables 48 | 49 | sub process { 50 | $format 51 | = $use_ansi ? 'ansi' 52 | : $use_ascii ? 'ascii' 53 | : $use_unicode ? 'unicode' 54 | : undef; 55 | 56 | #if the user decided not to choose the format, let's pick some environmentally smart format 57 | if ( !defined $format ) { 58 | $format 59 | = $ENV{EMACS} ? 'unicode' 60 | : $ENV{TERM} eq 'dumb' ? 'ascii' 61 | : 'ansi'; 62 | } 63 | init_cal_stuff(); 64 | process_current_repo(); 65 | my %stats = compute_stats(); 66 | print_grid(%stats); 67 | } 68 | 69 | sub process_current_repo { 70 | my $git_command = git_command(); 71 | my @epochs = qx/$git_command/; 72 | 73 | if ($?) { 74 | print "fatal: git-cal failed to get the git log\n"; 75 | exit(2); 76 | } 77 | 78 | if ( !@epochs ) { 79 | print "git-cal: got empty log, nothing to do\n"; 80 | exit(1); 81 | } 82 | my $status; 83 | foreach (@epochs) { 84 | $status = add_epoch($_); 85 | last if !$status; 86 | } 87 | } 88 | 89 | sub git_command { 90 | my $command = qq{git log --no-merges --pretty=format:"%at" --since="13 months"}; 91 | $command .= qq{ --author="$author"} if $author; 92 | $command .= qq{ --all } if $all_branches; 93 | if ($filepath) { 94 | if ( -e $filepath ) { 95 | $command .= qq{ -- $filepath}; 96 | } 97 | else { 98 | print "fatal: $filepath: no such file or directory\n"; 99 | exit(2); 100 | } 101 | } 102 | return $command; 103 | } 104 | 105 | sub init_cal_stuff { 106 | my ( $wday, $yday, $month, $year ) = ( localtime(time) )[ 6, 7, 4, 5 ]; 107 | $cur_year = $year; 108 | $jan1 = 370 - ( $yday + 6 - $wday ); 109 | $last_block = $jan1 + $yday + 1; 110 | $first_block = $last_block - 365; 111 | $max_commits = 0; 112 | push @timeline, $jan1; 113 | $month_pos{0} = $jan1; 114 | my $cur = $jan1; 115 | 116 | foreach ( 0 .. $month - 1 ) { 117 | $cur += number_of_days( $_, $year ); 118 | push @timeline, $cur; 119 | $month_pos{ $_ + 1 } = $cur; 120 | } 121 | $cur = $jan1; 122 | for ( my $m = 11; $m > $month; $m-- ) { 123 | $cur -= number_of_days( $m, $year - 1 ); 124 | unshift @timeline, $cur; 125 | $month_pos{$m} = $cur; 126 | } 127 | 128 | $pos_month{ $month_pos{$_} } = $months[$_] foreach keys %month_pos; 129 | 130 | die "period can only be between -11 to 0 and 1 to 12" if ( defined $period && ( $period < -11 || $period > 12 ) ); 131 | if ( !defined $period ) { 132 | $start_block = $first_block; 133 | $end_block = $last_block; 134 | } 135 | elsif ( $period > 0 ) { 136 | $start_block = $month_pos{ $period - 1 }; 137 | $end_block = $month_pos{ $period % 12 }; 138 | $end_block = $last_block if $start_block > $end_block; 139 | } 140 | else { 141 | $start_block = $timeline[ 11 + $period ]; 142 | $start_block = $first_block if $period == -12; 143 | $end_block = $last_block; 144 | } 145 | $row_start = int $start_block / 7; 146 | $row_end = int $end_block / 7; 147 | $max_epoch = time - 86400 * ( $last_block - $end_block ); 148 | $min_epoch = time - 86400 * ( $last_block - $start_block ); 149 | 150 | } 151 | 152 | 153 | sub add_epoch { 154 | my ($epoch, $count) = @_; 155 | if ( $epoch > $max_epoch || $epoch < $min_epoch ) { 156 | return 1; 157 | } 158 | my ( $month, $year, $wday, $yday ) = ( localtime($epoch) )[ 4, 5, 6, 7 ]; 159 | my $pos; 160 | if ( $year == $cur_year ) { 161 | $pos = ( $jan1 + $yday ); 162 | } 163 | else { 164 | my $total = ( $year % 4 ) ? 365 : 366; 165 | $pos = ( $jan1 - ( $total - $yday ) ); 166 | } 167 | return 0 if $pos < 0; #just in case 168 | add_to_grid( $pos, $epoch, $count ); 169 | return 1; 170 | } 171 | 172 | sub add_to_grid { 173 | my ( $pos, $epoch, $count ) = @_; 174 | $count ||= 1; 175 | my $r = int $pos / 7; 176 | my $c = $pos % 7; 177 | $grid[$r][$c]->{commits}+=$count; 178 | $grid[$r][$c]->{epoch} = $epoch; 179 | $max_commits = $grid[$r][$c]->{commits} if $grid[$r][$c]->{commits} > $max_commits; 180 | } 181 | 182 | 183 | sub compute_stats { 184 | my %commit_counts; 185 | 186 | my ( 187 | $total_commits, 188 | $cur_streak, 189 | $cur_start, 190 | $max_streak, 191 | $max_start, 192 | $max_end, 193 | $cur_streak_weekdays, 194 | $cur_weekdays_start, 195 | $max_streak_weekdays, 196 | $max_weekdays_start, 197 | $max_weekdays_end, 198 | $q1, 199 | $q2, 200 | $q3, 201 | ) = (0) x 14; 202 | 203 | foreach my $r ( $row_start .. $row_end ) { 204 | foreach my $c ( 0 .. 6 ) { 205 | my $cur_block = ( $r * 7 ) + $c; 206 | if ( $cur_block >= $start_block && $cur_block < $end_block ) { 207 | my $count = $grid[$r][$c]->{commits} || 0; 208 | $total_commits += $count; 209 | if ($count) { 210 | $commit_counts{$count} = 1; 211 | $cur_streak++; 212 | $cur_start ||= $grid[$r][$c]->{epoch}; 213 | if ( $cur_streak > $max_streak ) { 214 | $max_streak = $cur_streak; 215 | $max_start = $cur_start; 216 | $max_end = $grid[$r][$c]->{epoch}; 217 | } 218 | 219 | #count++ if you work on weekends and streak will not be broken otherwise :) 220 | $cur_streak_weekdays++; 221 | $cur_weekdays_start ||= $grid[$r][$c]->{epoch}; 222 | if ( $cur_streak_weekdays > $max_streak_weekdays ) { 223 | $max_streak_weekdays = $cur_streak_weekdays; 224 | $max_weekdays_start = $cur_weekdays_start; 225 | $max_weekdays_end = $grid[$r][$c]->{epoch}; 226 | } 227 | } 228 | else { 229 | $cur_streak = 0; 230 | $cur_start = 0; 231 | if ( $c > 0 && $c < 6 ) { 232 | $cur_streak_weekdays = 0; 233 | $cur_weekdays_start = 0; 234 | } 235 | } 236 | } 237 | } 238 | } 239 | 240 | #now compute quartiles 241 | my @commit_counts = sort { $a <=> $b } ( keys %commit_counts ); 242 | $q1 = $commit_counts[ int( scalar @commit_counts ) / 4 ]; 243 | $q2 = $commit_counts[ int( scalar @commit_counts ) / 2 ]; 244 | $q3 = $commit_counts[ int( 3 * ( scalar @commit_counts ) / 4 ) ]; 245 | 246 | #print "commit counts: " . (scalar @commit_counts) . " - " . (join ",",@commit_counts) . "\n\n"; 247 | #print "quartiles: $q1 $q2 $q3\n"; 248 | 249 | #die Dumper \%stat; 250 | 251 | my %stat = ( 252 | total_commits => $total_commits, 253 | cur_streak => $cur_streak , 254 | cur_start => $cur_start , 255 | max_streak => $max_streak , 256 | max_start => $max_start , 257 | max_end => $max_end , 258 | cur_streak_weekdays => $cur_streak_weekdays, 259 | cur_weekdays_start => $cur_weekdays_start, 260 | max_streak_weekdays => $max_streak_weekdays, 261 | max_weekdays_start => $max_weekdays_start, 262 | max_weekdays_end => $max_weekdays_end , 263 | q1 => $q1, 264 | q2 => $q2, 265 | q3 => $q3, 266 | ); 267 | 268 | 269 | return %stat; 270 | } 271 | 272 | sub print_grid { 273 | my %stat = @_; 274 | 275 | my $space = 6; 276 | print_month_names($space); 277 | foreach my $c ( 0 .. 6 ) { 278 | printf "\n%" . ( $space - 2 ) . "s", ""; 279 | 280 | print $c == 1 ? "M " 281 | : $c == 3 ? "W " 282 | : $c == 5 ? "F " 283 | : " "; 284 | 285 | foreach my $r ( $row_start .. $row_end ) { 286 | my $cur_block = ( $r * 7 ) + $c; 287 | if ( $cur_block >= $start_block && $cur_block < $end_block ) { 288 | my $val = $grid[$r][$c]->{commits} || 0; 289 | 290 | my $index = $val == 0 ? 0 291 | : $val <= $stat{q1} ? 1 292 | : $val <= $stat{q2} ? 2 293 | : $val <= $stat{q3} ? 3 294 | : 4; 295 | 296 | print_block($index); 297 | } 298 | else { 299 | print " "; 300 | } 301 | } 302 | } 303 | print "\n\n"; 304 | printf "%" . ( 2 * ( $row_end - $row_start ) + $space - 15 ) . "s", "Less "; #such that the right borders align 305 | print_block($_) foreach ( 0 .. 4 ); 306 | print " More\n"; 307 | 308 | printf "%4d: Total commits\n", $stat{total_commits}; 309 | print_message( $stat{max_streak_weekdays}, $stat{max_weekdays_start}, $stat{max_weekdays_end}, "Longest streak excluding weekends" ); 310 | print_message( $stat{max_streak}, $stat{max_start}, $stat{max_end}, "Longest streak including weekends" ); 311 | print_message( $stat{cur_streak_weekdays}, $stat{cur_weekdays_start}, time, "Current streak" ); 312 | } 313 | 314 | 315 | sub print_block { 316 | my $index = shift; 317 | $index = 4 if $index > 4; 318 | $_ 319 | = ( $format eq "ascii" ) ? "${ascii[$index]} " 320 | : ( $format eq "unicode" ) ? "${unicode[$index]} " 321 | : "\e[38;5;$colors[$index]m\x{25fc} \e[0m"; 322 | print; 323 | } 324 | 325 | 326 | sub print_month_names { 327 | #print month labels, printing current month in the right position is tricky 328 | my $space = shift; 329 | if ( defined $period && $period > 0 ) { 330 | printf "%" . $space . "s %3s", "", $months[ $period - 1 ]; 331 | return; 332 | } 333 | my $label_printer = 0; 334 | my $timeline_iter = defined $period ? 11 + $period : 0; 335 | if ( $start_block == $first_block && $timeline[0] != 0 ) { 336 | my $first_pos = int $timeline[0] / 7; 337 | if ( $first_pos == 0 ) { 338 | printf "%" . ( $space - 2 ) . "s", ""; 339 | print $pos_month{ $timeline[-1] } . " "; 340 | print $pos_month{ $timeline[0] } . " "; 341 | $timeline_iter++; 342 | } 343 | elsif ( $first_pos == 1 ) { 344 | printf "%" . ( $space - 2 ) . "s", ""; 345 | print $pos_month{ $timeline[-1] } . " "; 346 | } 347 | else { 348 | printf "%" . $space . "s", ""; 349 | printf "%-" . ( 2 * $first_pos ) . "s", $pos_month{ $timeline[-1] }; 350 | } 351 | $label_printer = $first_pos; 352 | } 353 | else { 354 | printf "%" . $space . "s", ""; 355 | $label_printer += ( int $start_block / 7 ); 356 | } 357 | 358 | while ( $label_printer < $end_block / 7 && $timeline_iter <= $#timeline ) { 359 | while ( ( int $timeline[$timeline_iter] / 7 ) != $label_printer ) { print " "; $label_printer++; } 360 | print " " . $pos_month{ $timeline[$timeline_iter] } . " "; 361 | $label_printer += 3; 362 | $timeline_iter++; 363 | } 364 | } 365 | 366 | sub print_message { 367 | my ( $days, $start_epoch, $end_epoch, $message ) = @_; 368 | if ($days) { 369 | my @range; 370 | foreach my $epoch ( $start_epoch, $end_epoch ) { 371 | my ( $mday, $mon, $year ) = ( localtime($epoch) )[ 3, 4, 5 ]; 372 | my $s = sprintf( "%3s %2d %4d", $months[$mon], $mday, ( 1900 + $year ) ); 373 | push @range, $s; 374 | } 375 | printf "%4d: Days ( %-25s ) - %-40s\n", $days, ( join " - ", @range ), $message; 376 | } 377 | else { 378 | printf "%4d: Days - %-40s\n", $days, $message; 379 | } 380 | } 381 | 382 | sub number_of_days { 383 | my ( $month, $year ) = @_; 384 | return 30 if $month == 3 || $month == 5 || $month == 8 || $month == 10; 385 | return 31 if $month != 1; 386 | return 28 if $year % 4; 387 | return 29; 388 | } 389 | 390 | sub configure { 391 | my ($period, $ascii, $ansi, $unicode, $author) = @_; 392 | my @wanted; 393 | push @wanted, 'format' if (not grep { defined $$_ } ($ascii, $ansi, $unicode)); 394 | push @wanted, 'period' if (not defined $$period); 395 | push @wanted, 'author' if (not defined $$author); 396 | if (@wanted) { 397 | my $git_command = "git config --get-regexp 'calendar\.*'"; 398 | my @parts = split(/\s/, qx/$git_command/); 399 | if(@parts) { 400 | my %config; 401 | while(my ($key, $value) = splice(@parts, 0, 2)) { 402 | $key =~ s/calendar\.//; 403 | $config{$key} = $value; 404 | } 405 | local @ARGV = (map { ( "-$_" => $config{$_} ) } 406 | grep { exists $config{$_} } @wanted); 407 | GetOptions( 408 | 'period=n' => $period, 409 | 'format=s' => sub { 410 | if ($_[1] eq 'ascii') { $$ascii ||= 1; } 411 | elsif ($_[1] eq 'ansi') { $$ansi ||= 1; } 412 | elsif ($_[1] eq 'unicode') { $$unicode ||= 1; } 413 | }, 414 | 'author=s' => $author 415 | ); 416 | } 417 | } 418 | } 419 | 420 | __END__ 421 | 422 | =head1 NAME 423 | 424 | git-cal - A simple tool to view commits calendar (similar to github contributions calendar) on command line 425 | 426 | =head1 SYNOPSIS 427 | 428 | "git-cal" is a tool to visualize the git commit history in github's contribution calendar style. 429 | The calendar shows how frequently the commits are made over the past year or some choosen period 430 | Activity can be displayed using ascii, ansi or unicode characters, default is choosen based on ENV 431 | 432 | git-cal 433 | 434 | git-cal --period=<1..12, -11..0> 435 | 436 | git-cal --author= 437 | 438 | git-cal --ascii 439 | 440 | git-cal --ansi 441 | 442 | git-cal --unicode 443 | 444 | git-cal 445 | 446 | =head2 OPTIONS 447 | 448 | =over 449 | 450 | =item [--period|-p]= 451 | 452 | Do not show the entire year: 453 | 454 | =over 455 | 456 | =item n = 1 to 12 457 | 458 | Shows only one month (1=Jan .. 12=Dec) 459 | 460 | =item n = -11 to 0 461 | 462 | Shows the previous -n months (and the current month) 463 | 464 | =back 465 | 466 | =item --author= 467 | 468 | View commits of a particular author. 469 | 470 | =item --all 471 | 472 | View stats from all branches. 473 | 474 | =item --ascii 475 | 476 | Display activity using ASCII characters instead of ANSI colors. 477 | 478 | =item --ansi 479 | 480 | Display activity using ANSI colors 481 | 482 | =item --unicode 483 | 484 | Display activity using unicode characters 485 | 486 | =item --help|-? 487 | 488 | Print this message. 489 | 490 | =back 491 | 492 | =head2 ADDITIONAL OPTIONS 493 | 494 | to view the logs of a particular file or directory 495 | 496 | =head2 USING GIT CONFIG 497 | 498 | git-cal uses the git config tool to store configuration on disk. Similar keys are used to 499 | those listed above with the notable exception being the bundling of ascii, ansi and unicode 500 | into a "format" key. Examples of the three supported keys are below. 501 | 502 | git config --global calendar.format ascii 503 | 504 | git config --global calendar.period 5 505 | 506 | git config --global calendar.author karthik 507 | 508 | A command line supplied option will override the matching option set using this method. 509 | 510 | =head1 AUTHOR 511 | 512 | Karthik katooru 513 | 514 | =head1 COPYRIGHT AND LICENSE 515 | 516 | This program is free software; you can redistribute it and/or modify it under the MIT License 517 | -------------------------------------------------------------------------------- /screenshots/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k4rthik/git-cal/b3bb376e46ebf3d09820b95b3426e9c58ad6cdc9/screenshots/img1.png -------------------------------------------------------------------------------- /screenshots/img2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k4rthik/git-cal/b3bb376e46ebf3d09820b95b3426e9c58ad6cdc9/screenshots/img2.png --------------------------------------------------------------------------------