├── README.md ├── git-punchcard ├── img ├── basic.png ├── large-width.png ├── mongodb-output.png ├── opaque.png └── tz-8.png └── test.sh /README.md: -------------------------------------------------------------------------------- 1 | # INTRODUCTION 2 | 3 | This is a small script to visualize the time when commits are committed in a 4 | git repository. The idea is stolen from Github's punchcard picture(Kudos to 5 | Github)! 6 | 7 | ## SCREENSHOTS 8 | 9 | Here's the generated picture from MongoDB project: 10 | 11 | ![MongoDB Punchcard](./img/mongodb-output.png) 12 | 13 | ## USAGE 14 | 15 | ### Basic Usage 16 | 17 | ```sh 18 | $ ./git-punchcard 19 | ``` 20 | 21 | ![basic](./img/basic.png) 22 | 23 | Provides the punchcard of the git repository at `.` 24 | 25 | ### Supply a path to the `git` repository 26 | 27 | ```sh 28 | $ ./git-punchcard path=/media/username/code/project 29 | ``` 30 | 31 | 32 | ### Supply an output file name 33 | 34 | ```sh 35 | $ ./git-punchcard file=output-1.png 36 | ``` 37 | 38 | ### Supply a width of the output file 39 | 40 | ```sh 41 | $ ./git-punchcard width=2000 42 | ``` 43 | 44 | ![img](./img/large-width.png) 45 | 46 | ### Opacity of the punch marks 47 | 48 | ```sh 49 | $ ./git-punchcard opaque=0 50 | ``` 51 | 52 | In the normal mode, the opacity of the punch marks is dependent on the size of 53 | the punch mark. If you set opaque=0, then all the punches will be the same 54 | color. The size will be the only indication of the number of commits in that 55 | particular range. 56 | 57 | ![opaque](./img/opaque.png) 58 | 59 | ### Supply output timezone 60 | 61 | ```sh 62 | $ ./git-punchcard timezone=+8.5 63 | ``` 64 | 65 | Convert all the commit times to UTC+8.5. This is useful if the project has 66 | contributors across the world and you want to find a common window of time when 67 | all contributors are working. 68 | 69 | ![timezone](./img/tz-8.png) 70 | 71 | 72 | ### Git Options 73 | 74 | You can pipe git options to the script using the flag `gitopts` 75 | 76 | Following are only examples, any valid options that can be given to log can be 77 | piped to `git-punchcard` 78 | 79 | ```sh 80 | # Show a punchcard of commits from 1st January 81 | $ ./git-punchcard gitopts="--since='1st January 2017'" 82 | 83 | # Show a punchcard of commits from the last month 84 | $ ./git-punchcard gitopts="--since='1 month ago'" 85 | 86 | # Show a punchcard of commits before a particular date 87 | $ ./git-punchcard gitopts="--before='2nd January 2016'" 88 | 89 | # Show a punch of commits before some period 90 | $ ./git-punchcard gitopts="--before='1 month ago'" 91 | ``` 92 | 93 | ## WHY IS IT INTERESTING 94 | 95 | It shows how this repository is developed in developer's time. As I see it, I 96 | can get a simple clue whether a project is a spare time project or this project 97 | is totally under a company's control, thus resulting in commits from 8AM to 98 | 6PM, Monday to Friday. 99 | 100 | ## PREREQUISITES 101 | ------------ 102 | 103 | - python (of course!) 104 | - pycairo module 105 | - git 106 | 107 | Then you're free to go! 108 | 109 | ## USAGE 110 | 111 | - `cp git-punchcard /usr/local/bin` 112 | - make sure that `/usr/local/bin` is in your `$PATH` environment variable. 113 | - invoke `git punchcard` 114 | - in the same folder, you should see a file named `output.png`, that's the generated image. 115 | 116 | If you want a different name, then simply invoke `git punchcard file=`. 117 | The default width of a picture is 1100px. If you'd like 118 | to have a higher resolution, you can run `git punchcard file= width=`. 119 | 120 | If you would like to filter by a particular author then do so as follows. (all parameters are available) 121 | `git punchcard author=` 122 | 123 | The image gets scaled automatically. 124 | 125 | LICENSE 126 | ------- 127 | 128 | This project is under public domain, you can do whatever you want ;) 129 | However, if you're improving this tool a bit, you can freely fork it and then 130 | send me back a pull request. I would be very glad to integrate it. 131 | -------------------------------------------------------------------------------- /git-punchcard: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Note: 3 | # Originally written by guanqun (https://github.com/guanqun/) Sep 29, 2011 4 | # Edited by Intrepid (https://github.com/intrepid/) Apr 12, 2012 5 | 6 | import math 7 | import cairo 8 | import sys 9 | import subprocess 10 | import os 11 | from email.utils import mktime_tz, parsedate_tz, formatdate 12 | 13 | # defaults 14 | author = '' 15 | width = 1100 16 | output_file = 'output.png' 17 | 18 | if len(sys.argv) > 1: 19 | if sys.argv[1] == "--help" or sys.argv[1] == "-h": 20 | print("SYNTAX: git punchchard") 21 | print 22 | print("EXAMPLE: git punchchard file=outputfile.png width=4000") 23 | print("This creates 'outputfile.png', a 4000px wide png image") 24 | print 25 | print("EXAMPLE: git punchcard path=/home/siddharth/code/punchcard") 26 | print("This creates a punch card of the git repository at the given path") 27 | print 28 | print("EXAMPLE: git punchcard opaque=0") 29 | print("This sets the opacity of all the circles to constant value 0") 30 | print("0 -> Black, 1 -> White (invisible)") 31 | print 32 | print("EXAMPLE: git punchcard timezone=+7.5") 33 | print("This shows the graph with all times converted to timezone UTC+7.5") 34 | print("If your contributors live in multiple timezones, this helps you") 35 | print("in getting a relative estimate of when they work.") 36 | print 37 | sys.exit(0) 38 | 39 | original_path = os.getcwd() 40 | path = os.getcwd() 41 | opaque = -1 42 | utc = 0 43 | timezone = None 44 | gitopts = "" 45 | 46 | options = dict() 47 | 48 | if len(sys.argv) > 1: 49 | options = dict(arg.split('=', 1) for arg in sys.argv[1:]) 50 | if 'width' in options: 51 | width = int(options['width']) 52 | if 'author' in options: 53 | author = options['author'] 54 | if 'file' in options: 55 | output_file = options["file"] 56 | if 'path' in options: 57 | path = options["path"] 58 | if 'opaque' in options: 59 | try: 60 | opaque = float(options["opaque"]) 61 | except ValueError as error: 62 | print("WARNING: Could not parse the opaque argument you gave. Please \ 63 | enter a value between 0 and 1") 64 | opaque = -1 65 | if 'utc' in options: 66 | utc = options['utc'] == '1' 67 | timezone = 0 68 | if 'timezone' in options: 69 | try: 70 | timezone = float(options["timezone"]) 71 | except ValueError as error: 72 | print("WARNING: COULD not parse timezone argument.") 73 | print("Please enter a decimal value. Eg: 3.0, -11.5") 74 | timezone = None 75 | 76 | if 'gitopts' in options: 77 | gitopts = options['gitopts'] 78 | 79 | height = int(round(width/2.75, 0)) 80 | 81 | # Calculate the relative distance 82 | distance = int(math.sqrt((width*height)/270.5)) 83 | 84 | # Round the distance to a number divisible by 2 85 | if distance % 2 == 1: 86 | distance -= 1 87 | 88 | max_range = (distance/2) ** 2 89 | 90 | # Good values for the relative position 91 | left = width/18 + 10 # The '+ 10' is to prevent text from overlapping 92 | top = height/20 + 10 93 | indicator_length = height/20 94 | 95 | days = ['Sat', 'Fri', 'Thu', 'Wed', 'Tue', 'Mon', 'Sun'] 96 | hours = ['12am'] + [str(x) for x in range(1, 12)] + ['12pm'] + [str(x) for x in range(1, 12)] 97 | def get_x_y_from_date(day, hour): 98 | y = top + (days.index(day) + 1) * distance 99 | x = left + (hour + 1) * distance 100 | return x, y 101 | 102 | def get_log_data(): 103 | try: 104 | os.chdir(path) 105 | print('current path: ', os.getcwd()) 106 | gitcommand = ['git', 'log', '--no-merges', '--author='+author, \ 107 | '--pretty=format: %aD'] 108 | 109 | if gitopts != "": 110 | gitcommand.append(gitopts) 111 | 112 | print('run cmd: ', ' '.join(gitcommand)) 113 | 114 | p = subprocess.Popen(gitcommand, 115 | stdin=subprocess.PIPE, 116 | stdout=subprocess.PIPE, 117 | stderr=subprocess.PIPE, 118 | universal_newlines=True) 119 | outdata, errdata = p.communicate() 120 | except OSError as e: 121 | print('Git not installed?') 122 | sys.exit(-1) 123 | if outdata == '': 124 | print('Not a git repository?') 125 | sys.exit(-1) 126 | return outdata 127 | 128 | # get day and hour 129 | temp_log = get_log_data() 130 | 131 | # CONVERT EVERYTHING TO UTC or ADD TIMEZONE OFFSET 132 | 133 | utc_offset = timezone != None and timezone * 3600 or 0 134 | 135 | fixed_stamps = temp_log.split('\n') 136 | 137 | if timezone != None: 138 | fixed_stamps = [formatdate(mktime_tz(parsedate_tz(x)) + utc_offset) \ 139 | for x in fixed_stamps] 140 | 141 | data_log = [[x.strip().split(',')[0], x.strip().split(' ')[4].split(':')[0]] \ 142 | for x in fixed_stamps] 143 | 144 | stats = {} 145 | for d in days: 146 | stats[d] = {} 147 | for h in range(0, 24): 148 | stats[d][h] = 0 149 | 150 | total = 0 151 | for line in data_log: 152 | stats[ line[0] ][ int(line[1]) ] += 1 153 | total += 1 154 | 155 | def get_length(nr): 156 | if nr == 0: 157 | return 0 158 | for i in range(1, distance//2): 159 | if i*i <= nr and nr < (i+1)*(i+1): 160 | return i 161 | if nr == max_range: 162 | return distance/2-1 163 | 164 | # normalize 165 | all_values = [] 166 | for d, hour_pair in stats.items(): 167 | for h, value in hour_pair.items(): 168 | all_values.append(value) 169 | max_value = max(all_values) 170 | final_data = [] 171 | for d, hour_pair in stats.items(): 172 | for h, value in hour_pair.items(): 173 | final_data.append( [ get_length(int( float(stats[d][h]) / max_value * max_range )), get_x_y_from_date(d, h) ] ) 174 | 175 | surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) 176 | cr = cairo.Context(surface) 177 | 178 | cr.set_line_width (1) 179 | 180 | # draw background to white 181 | cr.set_source_rgb(1, 1, 1) 182 | cr.rectangle(0, 0, width, height) 183 | cr.fill() 184 | 185 | # set black 186 | cr.set_source_rgb(0, 0, 0) 187 | 188 | # draw x-axis and y-axis 189 | cr.move_to(left, top) 190 | cr.rel_line_to(0, 8 * distance) 191 | cr.rel_line_to(25 * distance, 0) 192 | cr.stroke() 193 | 194 | # draw indicators on x-axis and y-axis 195 | x, y = left, top 196 | for i in range(8): 197 | cr.move_to(x, y) 198 | cr.rel_line_to(-indicator_length, 0) 199 | cr.stroke() 200 | y += distance 201 | 202 | x += distance 203 | for i in range(25): 204 | cr.move_to(x, y) 205 | cr.rel_line_to(0, indicator_length) 206 | cr.stroke() 207 | x += distance 208 | 209 | # select font 210 | cr.select_font_face ('sans-serif', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) 211 | 212 | # and set a appropriate font size 213 | cr.set_font_size(math.sqrt((width*height)/3055.6)) 214 | 215 | # draw Mon, Sat, ... Sun on y-axis 216 | x, y = (left - 5), (top + distance) 217 | for i in range(7): 218 | x_bearing, y_bearing, width, height, x_advance, y_advance = cr.text_extents(days[i]) 219 | cr.move_to(x - indicator_length - width, y + height/2) 220 | cr.show_text(days[i]) 221 | y += distance 222 | 223 | # draw 12am, 1, ... 11 on x-axis 224 | x, y = (left + distance), (top + (7 + 1) * distance + 5) 225 | for i in range(24): 226 | x_bearing, y_bearing, width, height, x_advance, y_advance = cr.text_extents(hours[i]) 227 | cr.move_to(x - width/2 - x_bearing, y + indicator_length + height) 228 | cr.show_text(hours[i]) 229 | x += distance 230 | 231 | # draw circles according to their frequency 232 | def draw_circle(pos, length): 233 | # find the position 234 | # max of length is half of the distance 235 | x, y = pos 236 | clr = (1 - float(length * length) / max_range ) 237 | if opaque >= 0 and opaque < 1: 238 | clr = opaque 239 | cr.set_source_rgba (clr, clr, clr) 240 | cr.move_to(x, y) 241 | cr.arc(x, y, length, 0, 2 * math.pi) 242 | cr.fill() 243 | 244 | for each in final_data: 245 | draw_circle(each[1], each[0]) 246 | 247 | # write to output 248 | os.chdir(original_path) 249 | surface.write_to_png (output_file) 250 | 251 | print("punchcard written to output file at: %s" % os.path.join(original_path, output_file)) 252 | -------------------------------------------------------------------------------- /img/basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guanqun/git-punchcard-plot/019daaa2acbc530b7cdb752454dd47df600bc0dd/img/basic.png -------------------------------------------------------------------------------- /img/large-width.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guanqun/git-punchcard-plot/019daaa2acbc530b7cdb752454dd47df600bc0dd/img/large-width.png -------------------------------------------------------------------------------- /img/mongodb-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guanqun/git-punchcard-plot/019daaa2acbc530b7cdb752454dd47df600bc0dd/img/mongodb-output.png -------------------------------------------------------------------------------- /img/opaque.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guanqun/git-punchcard-plot/019daaa2acbc530b7cdb752454dd47df600bc0dd/img/opaque.png -------------------------------------------------------------------------------- /img/tz-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guanqun/git-punchcard-plot/019daaa2acbc530b7cdb752454dd47df600bc0dd/img/tz-8.png -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | ##### TEST FILE 2 | 3 | ### Running this file will generate some graphs for this repository 4 | 5 | ## basic 6 | 7 | echo 8 | echo "BASIC: Invoking without any arguments" 9 | ./git-punchcard file=basic.png 10 | echo 11 | 12 | ## opaque 13 | 14 | echo 15 | echo "OPAQUE: Invoking with opaque=0 so that all circles are completely black" 16 | ./git-punchcard opaque=0 file=opaque.png 17 | echo 18 | 19 | ## utc 20 | 21 | echo 22 | echo "UTC: Invoking with utc=1" 23 | echo "All timestamps are converted to UTC before plotting the punchcard" 24 | ./git-punchcard utc=1 opaque=0 file=utc.png 25 | echo 26 | 27 | ## custom timezone: positive 28 | 29 | echo 30 | echo "TIMEZONE: Invoking with timezone=7.5" 31 | echo "All timestamps are converted to UTC+7.5 before plotting the punchcard" 32 | ./git-punchcard timezone=7.5 opaque=0 file=custom-tz-positive.png 33 | echo 34 | 35 | ## custom timezone: negative 36 | 37 | echo 38 | echo "TIMEZONE: Invoking with timezone=-7.5" 39 | echo "All timestamps are converted to UTC-7.5 before plotting the punchcard" 40 | ./git-punchcard timezone=-7.5 opaque=0 file=custom-tz-negative.png 41 | echo 42 | 43 | ## git options: test 1: --since option 44 | 45 | echo 46 | echo "GIT OPTIONS: Invoking with --gitopts=\"--since='1 month ago'\"" 47 | echo "All timestamps are converted to UTC+7.5 before plotting the punchcard" 48 | ./git-punchcard gitopts='--since="1 month ago"' opaque=0 file=git-options-since.png 49 | echo 50 | 51 | ## git options: test 2: --before option 52 | 53 | echo 54 | echo "GIT OPTIONS: Invoking with --gitopts=\"--before='january 2014'\"" 55 | echo "All timestamps are converted to UTC+7.5 before plotting the punchcard" 56 | ./git-punchcard gitopts='--before="january 2014"' opaque=0 file=git-options-before.png 57 | echo 58 | 59 | ## git options: test 3: --since and before option 60 | 61 | echo 62 | echo "GIT OPTIONS: Invoking with --gitopts=\"--before='september 2017' --after='january 2017'\"" 63 | echo "All timestamps are converted to UTC+7.5 before plotting the punchcard" 64 | ./git-punchcard gitopts='--since="1 month ago" --after="january 2017"' opaque=0 file=git-options-period.png 65 | echo 66 | --------------------------------------------------------------------------------