├── README.md └── git-score /README.md: -------------------------------------------------------------------------------- 1 | # git-score 2 | 3 | *git-score* is a script that computes 'scores' for authors who have contributed 4 | to a git repository. It displays aggregate stats in a concise format for human 5 | consumption and provides a rough estimation of developer involvement in a 6 | project. 7 | 8 | ![git-score screenshot](http://farm8.staticflickr.com/7180/6900456317_0d237aba81.jpg) 9 | 10 | ## Installation 11 | 12 | Copy *git-score* to somewhere in your `PATH`. A good solution is to place a 13 | git-score symlink in *~/bin* that points to your git-score repository. 14 | 15 | ## Usage 16 | 17 | Inside a git repository, just run 18 | 19 | % git-score 20 | 21 | It takes the following arguments directly: 22 | 23 | -e, --exclude= 24 | Tallies scores per-file, and if matches the filename. 25 | This is useful to exclude directories or particular file extensions. 26 | Warning: may run much slower. Still untested on very large repos. 27 | 28 | -i, --include= 29 | Same as exclude, but the reverse ( must match). 30 | Include and exclude can be used in conjunction. 31 | 32 | -o, --order=[+-] 33 | Sorts by chosen . Prepend [+,-] for [ascending, descending] 34 | must be one of: author, files, commits, delta, added, removed 35 | All fields (except author) default to descending. 36 | 37 | -r, --repo= 38 | Specify a path to the git repository. Can be specified multiple times to 39 | aggregate stats across multiple repositories. 40 | 41 | -h, --help 42 | Shows available options. 43 | 44 | All other arguments not beginning with a hypen ('-') will be passed to `git 45 | log`. This way, you can specify revision ranges to narrow scoring to a 46 | particular set of commits. 47 | 48 | % git-score 61bf126e..HEAD 49 | 50 | Note: The hyphen ('-') is optional: `git score` works too on recent versions of 51 | git. 52 | 53 | ## Examples 54 | 55 | Note: Emails obscured here. 56 | 57 | % git score 58 | author commits files delta (+) (-) 59 | Matt Sparks 13 2 143 184 41 60 | jbenet 8 2 76 110 34 61 | 62 | % git score b210b0..HEAD 63 | author commits files delta (+) (-) 64 | jbenet 7 2 46 77 31 65 | Matt Sparks 6 2 14 31 17 66 | 67 | % git score --include=.*\.[hm]$ 68 | author commits files delta (+) (-) 69 | jbenet 267 363 24664 47848 23184 70 | chris <-------------------> 46 126 12908 17156 4248 71 | Rico <-----------------------> 39 119 8659 10880 2221 72 | 73 | % git score --include=.*/src/.*$ --exclude=.*\.m$ 74 | author commits files delta (+) (-) 75 | jbenet 267 124 1884 3568 1684 76 | Rico <-----------------------> 39 46 738 918 180 77 | chris <-------------------> 46 44 696 936 240 78 | 79 | % git score --order=author 80 | author commits files delta (+) (-) 81 | jbenet 12 2 136 183 47 82 | Matt Sparks 13 2 143 184 41 83 | 84 | % git score --order=commits 85 | author commits files delta (+) (-) 86 | Matt Sparks 13 2 143 184 41 87 | jbenet 12 2 136 183 47 88 | 89 | % git score --order=-delta 90 | author commits files delta (+) (-) 91 | jbenet 13 2 151 198 47 92 | Matt Sparks 13 2 143 184 41 93 | 94 | % git score --order=+added 95 | author commits files delta (+) (-) 96 | Matt Sparks 13 2 143 184 41 97 | jbenet 13 2 151 198 47 98 | -------------------------------------------------------------------------------- /git-score: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # 3 | # git-score -- aggregate git commit statistics 4 | # 5 | # Author: Juan Batiz-Benet 6 | # Author: Matt Sparks 7 | import operator 8 | import optparse 9 | import os 10 | import re 11 | import subprocess 12 | import sys 13 | 14 | 15 | STAT_PATTERN = (r'^\s*(\d+) files changed, (\d+) insertions\(\+\), ' 16 | r'(\d+) deletions\(\-\)$') 17 | 18 | FILE_PATTERN = r'^\s*(\d+)\t(\d+)\t(.*)$' 19 | 20 | # Truncate long author names to this size. 21 | MAX_NAME_LENGTH = 45 22 | 23 | 24 | class CommitStat(object): 25 | '''Represents the statistics of one commit.''' 26 | def __init__(self, author, added, removed, files): 27 | self._author = author 28 | self._added = added 29 | self._removed = removed 30 | self._files = files 31 | 32 | def __str__(self): 33 | '''Return string representation.''' 34 | values = {'author': self._author, 35 | 'added': self._added, 36 | 'removed': self._removed, 37 | 'delta': self.delta(), 38 | 'files': str(list(self._files))} 39 | return '%(author)s: d%(delta)d, +%(added)i, -%(removed)d %(files)s' % values 40 | 41 | def author(self): 42 | '''Returns author name.''' 43 | return self._author 44 | 45 | def added(self): 46 | '''Returns number of lines added in this commit.''' 47 | return self._added 48 | 49 | def removed(self): 50 | '''Returns number of lines removed in this commit.''' 51 | return self._removed 52 | 53 | def delta(self): 54 | '''Returns delta of lines added and removed in this commit.''' 55 | return self._added - self._removed 56 | 57 | def numFilesChanged(self): 58 | '''Returns the number of files changed.''' 59 | return len(self._files) 60 | 61 | 62 | class CommitStatList(object): 63 | '''An aggregated group of CommitStat.''' 64 | def __init__(self, name=""): 65 | self._name = name 66 | self._added = 0 67 | self._removed = 0 68 | self._commits = list() 69 | self._files = set() 70 | 71 | def name(self): 72 | '''Returns the name of this CommitStatList.''' 73 | return self._name 74 | 75 | def added(self): 76 | '''Returns number of lines added over all commits.''' 77 | return self._added 78 | 79 | def removed(self): 80 | '''Returns number of lines removed over all commits.''' 81 | return self._removed 82 | 83 | def delta(self): 84 | '''Returns delta of lines added and removed.''' 85 | return self._added - self._removed 86 | 87 | def numFilesChanged(self): 88 | '''Returns the number of files changed''' 89 | return len(self._files) 90 | 91 | def commits(self): 92 | '''Returns number of total commits.''' 93 | return len(self._commits) 94 | 95 | def add(self, commitstat): 96 | '''Adds a CommitStat, aggregating counts.''' 97 | if not isinstance(commitstat, CommitStat): 98 | raise TypeError('commitstat is not of type CommitStat') 99 | 100 | self._added += commitstat._added 101 | self._removed += commitstat._removed 102 | self._files.update(commitstat._files) 103 | self._commits.append(commitstat) 104 | 105 | def __add__(self, other): 106 | '''Adds this and another CommitStatList, creating a new CommitStatList.''' 107 | csl = CommitStatList(self._name) 108 | csl._added = self._added + other._added 109 | csl._removed = self._removed + other._removed 110 | csl._files = self._files.union(other._files) 111 | csl._commits = self._commits + other._commits 112 | return csl 113 | 114 | 115 | class AuthorMap(object): 116 | '''The representation of a collection of authors and their CommitStats.''' 117 | def __init__(self): 118 | self._stats = {} 119 | self._orderfield = 'delta' # default sorting field 120 | self._ascending = False # default sorting direction 121 | 122 | def __getitem__(self, author): 123 | '''Returns the CommitStatList for a particular author.''' 124 | if author not in self._stats: 125 | self._stats[author] = CommitStatList(author) 126 | return self._stats[author] 127 | 128 | def __iter__(self): 129 | '''Returns iterator for the sorted keys of the author map.''' 130 | def statkey(stat): 131 | '''Returns the value to sort a CommitStatList by.''' 132 | if self._orderfield == 'author': 133 | return stat.name().lower() # named the commit lists by author name 134 | if self._orderfield == 'files': 135 | return stat.numFilesChanged() 136 | return getattr(stat, self._orderfield)() 137 | 138 | stats = self._stats.values() 139 | stats.sort(key=statkey) 140 | stats = map(lambda statset: statset.name(), stats) # want keys, not values 141 | 142 | if not self._ascending: 143 | stats.reverse() 144 | return iter(stats) 145 | 146 | def __len__(self): 147 | return len(self._stats.keys()) 148 | 149 | def __add__(self, other): 150 | '''Returns a new AuthorMap representing the sum this and other stats.''' 151 | am = AuthorMap() 152 | for author in set(self._stats.keys() + other._stats.keys()): 153 | am._stats[author] = self[author] + other[author] 154 | return am 155 | 156 | def __str__(self): 157 | return 'AuthorMap(stats=%s, orderfield=%s, ascending=%s)' % (self._stats, 158 | self._orderfield, self._ascending) 159 | 160 | def order(self, order): 161 | '''Defines the sort order for this AuthorMap. 162 | 163 | Args: 164 | order: the direction and field to sort by. Accepted orders: 165 | [+-]?(author|files|commits|added|removed|delta]) 166 | ''' 167 | if order[0] in ('-', '+'): # direction was specified 168 | self._orderfield = order[1:] 169 | self._ascending = order[0] == '+' 170 | else: 171 | self._orderfield = order 172 | self._ascending = order == 'author' # descending default, except authors 173 | 174 | 175 | class GitColor(object): 176 | '''Gets and uses current git settings to color output.''' 177 | _colorIsOn = None 178 | 179 | @classmethod 180 | def gitColorIsOn(cls): 181 | '''Returns whether git config color.ui is on. Only queries once.''' 182 | if cls._colorIsOn is not None: 183 | return cls._colorIsOn 184 | 185 | cmd = ['git', 'config', 'color.ui'] 186 | proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) 187 | next = proc.stdout.readline().strip() 188 | next = 'auto' if next == '' else next 189 | cls._colorIsOn = next in ['true', 'always', 'auto'] 190 | return cls._colorIsOn 191 | 192 | @classmethod 193 | def red(cls, text): 194 | '''Returns text, in red if color is on.''' 195 | if cls.gitColorIsOn(): 196 | return "\033[31m%s\033[0m" % text 197 | return text 198 | 199 | @classmethod 200 | def green(cls, text): 201 | '''Returns text, in green if color is on.''' 202 | if cls.gitColorIsOn(): 203 | return '\033[32m%s\033[0m' % text 204 | return text 205 | 206 | 207 | def parseGitLogData(stream, options, path): 208 | class ParseCommitData(object): 209 | '''A utility class to keep track of commit stats currently being parsed.''' 210 | author = None 211 | added = 0 212 | removed = 0 213 | files = None 214 | 215 | @classmethod 216 | def reset(cls, author): 217 | cls.author = author 218 | cls.added = 0 219 | cls.removed = 0 220 | cls.files = set() 221 | 222 | @classmethod 223 | def commitStat(cls): 224 | return CommitStat(cls.author, cls.added, cls.removed, cls.files) 225 | 226 | stat_pattern = re.compile(STAT_PATTERN) 227 | file_pattern = re.compile(FILE_PATTERN) 228 | exclude_pattern = re.compile(options.exclude) if options.exclude else None 229 | include_pattern = re.compile(options.include) if options.include else None 230 | 231 | authorstats = AuthorMap() 232 | 233 | for line in stream: 234 | line = line.strip() 235 | if line == '': 236 | continue # skip blank lines 237 | 238 | # Author line, aggregate last commit (if any) and reset counts 239 | if line.startswith('+'): 240 | if ParseCommitData.author != None and len(ParseCommitData.files) > 0: 241 | authorstats[ParseCommitData.author].add(ParseCommitData.commitStat()) 242 | ParseCommitData.reset(line[1:]) # remove leading '+' 243 | 244 | # File line, record per-file changes. 245 | else: 246 | match = file_pattern.match(line) 247 | if not match: 248 | continue 249 | 250 | added, removed, filename = match.group(1, 2, 3) 251 | exclude_file = options.exclude and exclude_pattern.match(filename) 252 | include_file = not options.include or include_pattern.match(filename) 253 | if include_file and not exclude_file: 254 | ParseCommitData.added += int(added) 255 | ParseCommitData.removed += int(removed) 256 | if path: 257 | filename = os.path.join(path, filename) 258 | ParseCommitData.files.add(filename) 259 | 260 | # Process last commit. 261 | if ParseCommitData.author != None and len(ParseCommitData.files) > 0: 262 | authorstats[ParseCommitData.author].add(ParseCommitData.commitStat()) 263 | 264 | return authorstats 265 | 266 | 267 | def gitStats(log_args, options, path=None): 268 | if path: 269 | old_path = os.getcwd() 270 | os.chdir(path) 271 | cmd = ['git', 'log', '--numstat', '--pretty=format:+%an <%ae>'] 272 | cmd.extend(log_args) 273 | proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) 274 | if path: 275 | os.chdir(old_path) 276 | return parseGitLogData(proc.stdout, options, path) 277 | 278 | 279 | def gitStatsAggregated(log_args, options): 280 | if options.repos: 281 | return reduce(operator.add, 282 | [gitStats(log_args, options, r) for r in options.repos]) 283 | else: 284 | return gitStats(log_args, options) 285 | 286 | 287 | def parseArgs(): 288 | parser = optparse.OptionParser() 289 | parser.add_option('-e', '--exclude', dest='exclude', metavar='PATTERN', 290 | help='exclude files matching PATTERN') 291 | parser.add_option('-i', '--include', dest='include', metavar='PATTERN', 292 | help='include files matching PATTERN') 293 | parser.add_option('-o', '--order', dest='order', metavar='ORDER', 294 | help='sorting order (e.g., +delta)') 295 | parser.add_option('-r', '--repo', dest='repos', metavar='REPO', 296 | action='append', 297 | help='repository path, can be specified multiple times') 298 | options, args = parser.parse_args() 299 | 300 | # Validate sorting field. 301 | valid_fields = ('author', 'files', 'commits', 'delta', 'added', 'removed') 302 | if options.order: 303 | has_prefix = options.order.startswith('+') or options.order.startswith('-') 304 | if (len(options.order) < 2 or 305 | (has_prefix and options.order[1:] not in valid_fields) or 306 | (not has_prefix and options.order not in valid_fields)): 307 | parser.error('invalid sorting order: %s' % options.order) 308 | 309 | return options, args 310 | 311 | 312 | def main(): 313 | options, log_args = parseArgs() 314 | stats = gitStatsAggregated(log_args, options) 315 | if options.order: 316 | stats.order(options.order) 317 | 318 | if len(stats) == 0: 319 | print 'no commits' 320 | sys.exit(0) 321 | 322 | # Find length of longest author name. 323 | author_length = max([len(a) for a in stats]) 324 | author_length = min(author_length, MAX_NAME_LENGTH) 325 | 326 | print 'author%s\tcommits\tfiles\tdelta\t(+)\t(-)' % (' ' * (author_length-6)) 327 | for author in stats: 328 | name_field = author[0:author_length] 329 | name_field = '%s%s' % (name_field, ' ' * (author_length - len(name_field))) 330 | values = {'name': name_field, 331 | 'commits': stats[author].commits(), 332 | 'files': stats[author].numFilesChanged(), 333 | 'delta': stats[author].delta(), 334 | 'added': GitColor.green(stats[author].added()), 335 | 'removed': GitColor.red(stats[author].removed()) } 336 | 337 | print '%(name)s\t%(commits)d\t%(files)d' % values, 338 | print '\t%(delta)d\t%(added)s\t%(removed)s' % values 339 | 340 | 341 | if __name__ == '__main__': 342 | main() 343 | --------------------------------------------------------------------------------