Browse Source

Merge branch 'master' into master

dfeo 3 years ago
parent
commit
04dd0b8794
No account linked to committer's email
7 changed files with 176 additions and 88 deletions
  1. 2
    2
      Makefile
  2. 1
    1
      doc/AUTHOR
  3. 4
    0
      doc/LICENSE
  4. 1
    1
      doc/README
  5. 27
    1
      doc/gitstats.pod
  6. 0
    1
      doc/license
  7. 141
    82
      gitstats

+ 2
- 2
Makefile View File

@@ -3,7 +3,7 @@ BINDIR=$(PREFIX)/bin
3 3
 RESOURCEDIR=$(PREFIX)/share/gitstats
4 4
 RESOURCES=gitstats.css sortable.js *.gif
5 5
 BINARIES=gitstats
6
-VERSION=$(shell git describe 2>/dev/null || git rev-parse --short HEAD)
6
+VERSION=$(shell git describe 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || date +%Y-%m-%d)
7 7
 SEDVERSION=perl -pi -e 's/VERSION = 0/VERSION = "$(VERSION)"/' --
8 8
 
9 9
 all: help
@@ -29,6 +29,6 @@ release:
29 29
 	@$(RM) gitstats.tmp
30 30
 
31 31
 man:
32
-	pod2man --center "User Commands" -r $(shell git rev-parse --short HEAD) doc/gitstats.pod > doc/gitstats.1
32
+	pod2man --center "User Commands" -r $(VERSION) doc/gitstats.pod > doc/gitstats.1
33 33
 
34 34
 .PHONY: all help install release

doc/author.txt → doc/AUTHOR View File

@@ -2,7 +2,7 @@ Author can be reached by sending e-mail to <hoxu@users.sf.net>.
2 2
 Include "gitstats" in the subject or prepare to battle the spam filters.
3 3
 
4 4
 See the following command for list of authors who have contributed:
5
-  $ git-shortlog HEAD
5
+  $ git shortlog HEAD
6 6
 
7 7
 Also thanks to the following people:
8 8
 Alexander Botero-Lowry

+ 4
- 0
doc/LICENSE View File

@@ -0,0 +1,4 @@
1
+gitstats license is GPLv2/GPLv3, see doc/GPLv2 and doc/GPLv3 respectively.
2
+
3
+sortable.js, contained in gitstats, is licensed under the MIT license. See the
4
+file itself for details.

+ 1
- 1
doc/README View File

@@ -5,7 +5,7 @@ Currently it produces only HTML output with tables and graphs.
5 5
 
6 6
 Requirements
7 7
 ============
8
-- Python (>= 2.4.4)
8
+- Python (>= 2.6.0)
9 9
 - Git (>= 1.5.2.4)
10 10
 - Gnuplot (>= 4.0.0)
11 11
 - a git repository (bare clone will work as well)

+ 27
- 1
doc/gitstats.pod View File

@@ -53,10 +53,18 @@ How many domains to show in domains by commits.
53 53
 
54 54
 Maximum file extension length.
55 55
 
56
+=item processes
57
+
58
+Number of concurrent processes to use when extracting git repository data.
59
+
56 60
 =item project_name
57 61
 
58 62
 Project name to show on the generated pages. Default is to use basename of the repository directory.
59 63
 
64
+=item start_date
65
+
66
+Specify a starting date to pass with --since to git.
67
+
60 68
 =item style
61 69
 
62 70
 CSS stylesheet to use.
@@ -73,11 +81,29 @@ Q: I have files in my git repository that I would like to exclude from the stati
73 81
 
74 82
 A: At the moment the only way is to use L<git-filter-branch(1)> to create a temporary repository and generate the statistics from that.
75 83
 
84
+Q: How do I merge author information when the same author has made commits using different names or emails ?
85
+
86
+A: Use git .mailmap feature described in B<MAPPING AUTHORS> of L<git-shortlog(1)>.
87
+
88
+=head1 EXAMPLES
89
+
90
+=over
91
+
92
+=item Generates statistics from a git repository in C<foo> and outputs the result in a directory C<foo_stats>:
93
+
94
+  gitstats foo foo_stats
95
+
96
+=item As above, but only analyzes the last 10 commits:
97
+
98
+  gitstats -c commit_begin='HEAD~10' foo foo_stats
99
+
100
+=back
101
+
76 102
 =head1 AUTHORS
77 103
 
78 104
 B<gitstats> was written by Heikki Hokkanen and others.
79 105
 
80
-See the git repository at http://repo.or.cz/w/gitstats.git for an up-to-date full list of contributors.
106
+See the git repository at https://github.com/hoxu/gitstats for an up-to-date full list of contributors.
81 107
 
82 108
 =head1 WWW
83 109
 

+ 0
- 1
doc/license View File

@@ -1 +0,0 @@
1
-License is GPLv2/GPLv3, see doc/GPLv2 and doc/GPLv3 respectively.

+ 141
- 82
gitstats View File

@@ -1,5 +1,5 @@
1
-#!/usr/bin/env python
2
-# Copyright (c) 2007-2012 Heikki Hokkanen <hoxu@users.sf.net> & others (see doc/author.txt)
1
+#!/usr/bin/env python2
2
+# Copyright (c) 2007-2014 Heikki Hokkanen <hoxu@users.sf.net> & others (see doc/AUTHOR)
3 3
 # GPLv2 / GPLv3
4 4
 import datetime
5 5
 import getopt
@@ -14,6 +14,12 @@ import sys
14 14
 import time
15 15
 import zlib
16 16
 
17
+if sys.version_info < (2, 6):
18
+	print >> sys.stderr, "Python 2.6 or higher is required for gitstats"
19
+	sys.exit(1)
20
+
21
+from multiprocessing import Pool
22
+
17 23
 os.environ['LC_ALL'] = 'C'
18 24
 
19 25
 GNUPLOT_COMMON = 'set terminal png transparent size 640,240\nset size 1.0,1.0\n'
@@ -40,7 +46,8 @@ conf = {
40 46
 	'commit_end': 'HEAD',
41 47
 	'linear_linestats': 1,
42 48
 	'project_name': '',
43
-	'merge_authors': {}
49
+	'processes': 8,
50
+	'start_date': ''
44 51
 }
45 52
 
46 53
 def getpipeoutput(cmds, quiet = False):
@@ -49,12 +56,14 @@ def getpipeoutput(cmds, quiet = False):
49 56
 	if not quiet and ON_LINUX and os.isatty(1):
50 57
 		print '>> ' + ' | '.join(cmds),
51 58
 		sys.stdout.flush()
52
-	p0 = subprocess.Popen(cmds[0], stdout = subprocess.PIPE, shell = True)
53
-	p = p0
59
+	p = subprocess.Popen(cmds[0], stdout = subprocess.PIPE, shell = True)
60
+	processes=[p]
54 61
 	for x in cmds[1:]:
55
-		p = subprocess.Popen(x, stdin = p0.stdout, stdout = subprocess.PIPE, shell = True)
56
-		p0 = p
62
+		p = subprocess.Popen(x, stdin = p.stdout, stdout = subprocess.PIPE, shell = True)
63
+		processes.append(p)
57 64
 	output = p.communicate()[0]
65
+	for p in processes:
66
+		p.wait()
58 67
 	end = time.time()
59 68
 	if not quiet:
60 69
 		if ON_LINUX and os.isatty(1):
@@ -63,6 +72,12 @@ def getpipeoutput(cmds, quiet = False):
63 72
 	exectime_external += (end - start)
64 73
 	return output.rstrip('\n')
65 74
 
75
+def getlogrange(defaultrange = 'HEAD', end_only = True):
76
+	commit_range = getcommitrange(defaultrange, end_only)
77
+	if len(conf['start_date']) > 0:
78
+		return '--since="%s" "%s"' % (conf['start_date'], commit_range)
79
+	return commit_range
80
+
66 81
 def getcommitrange(defaultrange = 'HEAD', end_only = False):
67 82
 	if len(conf['commit_end']) > 0:
68 83
 		if end_only or len(conf['commit_begin']) == 0:
@@ -104,6 +119,20 @@ def getgitversion():
104 119
 def getgnuplotversion():
105 120
 	return getpipeoutput(['%s --version' % gnuplot_cmd]).split('\n')[0]
106 121
 
122
+def getnumoffilesfromrev(time_rev):
123
+	"""
124
+	Get number of files changed in commit
125
+	"""
126
+	time, rev = time_rev
127
+	return (int(time), rev, int(getpipeoutput(['git ls-tree -r --name-only "%s"' % rev, 'wc -l']).split('\n')[0]))
128
+
129
+def getnumoflinesinblob(ext_blob):
130
+	"""
131
+	Get number of lines in blob
132
+	"""
133
+	ext, blob_id = ext_blob
134
+	return (ext, blob_id, int(getpipeoutput(['git cat-file blob %s' % blob_id, 'wc -l']).split()[0]))
135
+
107 136
 class DataCollector:
108 137
 	"""Manages data collection from a revision control repository."""
109 138
 	def __init__(self):
@@ -257,7 +286,7 @@ class GitDataCollector(DataCollector):
257 286
 	def collect(self, dir):
258 287
 		DataCollector.collect(self, dir)
259 288
 
260
-		self.total_authors += int(getpipeoutput(['git shortlog -s %s' % getcommitrange(), 'wc -l']))
289
+		self.total_authors += int(getpipeoutput(['git shortlog -s %s' % getlogrange(), 'wc -l']))
261 290
 		#self.total_lines = int(getoutput('git-ls-files -z |xargs -0 cat |wc -l'))
262 291
 
263 292
 		# tags
@@ -293,14 +322,12 @@ class GitDataCollector(DataCollector):
293 322
 				parts = re.split('\s+', line, 2)
294 323
 				commits = int(parts[1])
295 324
 				author = parts[2]
296
-				if author in conf['merge_authors']:
297
-					author = conf['merge_authors'][author]
298 325
 				self.tags[tag]['commits'] += commits
299 326
 				self.tags[tag]['authors'][author] = commits
300 327
 
301 328
 		# Collect revision statistics
302 329
 		# Outputs "<stamp> <date> <time> <timezone> <author> '<' <mail> '>'"
303
-		lines = getpipeoutput(['git rev-list --pretty=format:"%%at %%ai %%aN <%%aE>" %s' % getcommitrange('HEAD'), 'grep -v ^commit']).split('\n')
330
+		lines = getpipeoutput(['git rev-list --pretty=format:"%%at %%ai %%aN <%%aE>" %s' % getlogrange('HEAD'), 'grep -v ^commit']).split('\n')
304 331
 		for line in lines:
305 332
 			parts = line.split(' ', 4)
306 333
 			author = ''
@@ -311,8 +338,6 @@ class GitDataCollector(DataCollector):
311 338
 			timezone = parts[3]
312 339
 			author, mail = parts[4].split('<', 1)
313 340
 			author = author.rstrip()
314
-			if author in conf['merge_authors']:
315
-				author = conf['merge_authors'][author]
316 341
 			mail = mail.rstrip('>')
317 342
 			domain = '?'
318 343
 			if mail.find('@') != -1:
@@ -408,14 +433,37 @@ class GitDataCollector(DataCollector):
408 433
 			# timezone
409 434
 			self.commits_by_timezone[timezone] = self.commits_by_timezone.get(timezone, 0) + 1
410 435
 
411
-		# TODO Optimize this, it's the worst bottleneck
412 436
 		# outputs "<stamp> <files>" for each revision
413
-		revlines = getpipeoutput(['git rev-list --pretty=format:"%%at %%T" %s' % getcommitrange('HEAD'), 'grep -v ^commit']).strip().split('\n')
437
+		revlines = getpipeoutput(['git rev-list --pretty=format:"%%at %%T" %s' % getlogrange('HEAD'), 'grep -v ^commit']).strip().split('\n')
414 438
 		lines = []
439
+		revs_to_read = []
440
+		time_rev_count = []
441
+		#Look up rev in cache and take info from cache if found
442
+		#If not append rev to list of rev to read from repo
415 443
 		for revline in revlines:
416 444
 			time, rev = revline.split(' ')
417
-			linecount = self.getFilesInCommit(rev)
418
-			lines.append('%d %d' % (int(time), linecount))
445
+			#if cache empty then add time and rev to list of new rev's
446
+			#otherwise try to read needed info from cache
447
+			if 'files_in_tree' not in self.cache.keys():
448
+				revs_to_read.append((time,rev))
449
+				continue
450
+			if rev in self.cache['files_in_tree'].keys():
451
+				lines.append('%d %d' % (int(time), self.cache['files_in_tree'][rev]))
452
+			else:
453
+				revs_to_read.append((time,rev))
454
+
455
+		#Read revisions from repo
456
+		pool = Pool(processes=conf['processes'])
457
+		time_rev_count = pool.map(getnumoffilesfromrev, revs_to_read)
458
+		pool.terminate()
459
+		pool.join()
460
+
461
+		#Update cache with new revisions and append then to general list
462
+		for (time, rev, count) in time_rev_count:
463
+			if 'files_in_tree' not in self.cache:
464
+				self.cache['files_in_tree'] = {}
465
+			self.cache['files_in_tree'][rev] = count
466
+			lines.append('%d %d' % (int(time), count))
419 467
 
420 468
 		self.total_commits += len(lines)
421 469
 		for line in lines:
@@ -430,14 +478,15 @@ class GitDataCollector(DataCollector):
430 478
 
431 479
 		# extensions and size of files
432 480
 		lines = getpipeoutput(['git ls-tree -r -l -z %s' % getcommitrange('HEAD', end_only = True)]).split('\000')
481
+		blobs_to_read = []
433 482
 		for line in lines:
434 483
 			if len(line) == 0:
435 484
 				continue
436
-			parts = re.split('\s+', line, 5)
485
+			parts = re.split('\s+', line, 4)
437 486
 			if parts[0] == '160000' and parts[3] == '-':
438 487
 				# skip submodules
439 488
 				continue
440
-			sha1 = parts[2]
489
+			blob_id = parts[2]
441 490
 			size = int(parts[3])
442 491
 			fullpath = parts[4]
443 492
 
@@ -451,15 +500,31 @@ class GitDataCollector(DataCollector):
451 500
 				ext = filename[(filename.rfind('.') + 1):]
452 501
 			if len(ext) > conf['max_ext_length']:
453 502
 				ext = ''
454
-
455 503
 			if ext not in self.extensions:
456 504
 				self.extensions[ext] = {'files': 0, 'lines': 0}
457
-
458 505
 			self.extensions[ext]['files'] += 1
459
-			try:
460
-				self.extensions[ext]['lines'] += self.getLinesInBlob(sha1)
461
-			except:
462
-				print 'Warning: Could not count lines for file "%s"' % line
506
+			#if cache empty then add ext and blob id to list of new blob's
507
+			#otherwise try to read needed info from cache
508
+			if 'lines_in_blob' not in self.cache.keys():
509
+				blobs_to_read.append((ext,blob_id))
510
+				continue
511
+			if blob_id in self.cache['lines_in_blob'].keys():
512
+				self.extensions[ext]['lines'] += self.cache['lines_in_blob'][blob_id]
513
+			else:
514
+				blobs_to_read.append((ext,blob_id))
515
+
516
+		#Get info abount line count for new blob's that wasn't found in cache
517
+		pool = Pool(processes=conf['processes'])
518
+		ext_blob_linecount = pool.map(getnumoflinesinblob, blobs_to_read)
519
+		pool.terminate()
520
+		pool.join()
521
+
522
+		#Update cache and write down info about number of number of lines
523
+		for (ext, blob_id, linecount) in ext_blob_linecount:
524
+			if 'lines_in_blob' not in self.cache:
525
+				self.cache['lines_in_blob'] = {}
526
+			self.cache['lines_in_blob'][blob_id] = linecount
527
+			self.extensions[ext]['lines'] += self.cache['lines_in_blob'][blob_id]
463 528
 
464 529
 		# line statistics
465 530
 		# outputs:
@@ -471,7 +536,7 @@ class GitDataCollector(DataCollector):
471 536
 		extra = ''
472 537
 		if conf['linear_linestats']:
473 538
 			extra = '--first-parent -m'
474
-		lines = getpipeoutput(['git log --shortstat %s --pretty=format:"%%at %%aN" %s' % (extra, getcommitrange('HEAD'))]).split('\n')
539
+		lines = getpipeoutput(['git log --shortstat %s --pretty=format:"%%at %%aN" %s' % (extra, getlogrange('HEAD'))]).split('\n')
475 540
 		lines.reverse()
476 541
 		files = 0; inserted = 0; deleted = 0; total_lines = 0
477 542
 		author = None
@@ -485,8 +550,6 @@ class GitDataCollector(DataCollector):
485 550
 				if pos != -1:
486 551
 					try:
487 552
 						(stamp, author) = (int(line[:pos]), line[pos+1:])
488
-						if author in conf['merge_authors']:
489
-							author = conf['merge_authors'][author]
490 553
 						self.changes_by_date[stamp] = { 'files': files, 'ins': inserted, 'del': deleted, 'lines': total_lines }
491 554
 
492 555
 						date = datetime.datetime.fromtimestamp(stamp)
@@ -517,7 +580,7 @@ class GitDataCollector(DataCollector):
517 580
 					print 'Warning: failed to handle line "%s"' % line
518 581
 					(files, inserted, deleted) = (0, 0, 0)
519 582
 				#self.changes_by_date[stamp] = { 'files': files, 'ins': inserted, 'del': deleted }
520
-		self.total_lines = total_lines
583
+		self.total_lines += total_lines
521 584
 
522 585
 		# Per-author statistics
523 586
 
@@ -527,7 +590,7 @@ class GitDataCollector(DataCollector):
527 590
 		# Similar to the above, but never use --first-parent
528 591
 		# (we need to walk through every commit to know who
529 592
 		# committed what, not just through mainline)
530
-		lines = getpipeoutput(['git log --shortstat --date-order --pretty=format:"%%at %%aN" %s' % (getcommitrange('HEAD'))]).split('\n')
593
+		lines = getpipeoutput(['git log --shortstat --date-order --pretty=format:"%%at %%aN" %s' % (getlogrange('HEAD'))]).split('\n')
531 594
 		lines.reverse()
532 595
 		files = 0; inserted = 0; deleted = 0
533 596
 		author = None
@@ -543,8 +606,6 @@ class GitDataCollector(DataCollector):
543 606
 					try:
544 607
 						oldstamp = stamp
545 608
 						(stamp, author) = (int(line[:pos]), line[pos+1:])
546
-						if author in conf['merge_authors']:
547
-							author = conf['merge_authors'][author]
548 609
 						if oldstamp > stamp:
549 610
 							# clock skew, keep old timestamp to avoid having ugly graph
550 611
 							stamp = oldstamp
@@ -619,33 +680,12 @@ class GitDataCollector(DataCollector):
619 680
 	def getDomains(self):
620 681
 		return self.domains.keys()
621 682
 	
622
-	def getFilesInCommit(self, rev):
623
-		try:
624
-			res = self.cache['files_in_tree'][rev]
625
-		except:
626
-			res = int(getpipeoutput(['git ls-tree -r --name-only "%s"' % rev, 'wc -l']).split('\n')[0])
627
-			if 'files_in_tree' not in self.cache:
628
-				self.cache['files_in_tree'] = {}
629
-			self.cache['files_in_tree'][rev] = res
630
-
631
-		return res
632
-
633 683
 	def getFirstCommitDate(self):
634 684
 		return datetime.datetime.fromtimestamp(self.first_commit_stamp)
635 685
 	
636 686
 	def getLastCommitDate(self):
637 687
 		return datetime.datetime.fromtimestamp(self.last_commit_stamp)
638 688
 	
639
-	def getLinesInBlob(self, sha1):
640
-		try:
641
-			res = self.cache['lines_in_blob'][sha1]
642
-		except:
643
-			res = int(getpipeoutput(['git cat-file blob %s' % sha1, 'wc -l']).split()[0])
644
-			if 'lines_in_blob' not in self.cache:
645
-				self.cache['lines_in_blob'] = {}
646
-			self.cache['lines_in_blob'][sha1] = res
647
-		return res
648
-
649 689
 	def getTags(self):
650 690
 		lines = getpipeoutput(['git show-ref --tags', 'cut -d/ -f3'])
651 691
 		return lines.split('\n')
@@ -686,7 +726,7 @@ def html_linkify(text):
686 726
 
687 727
 def html_header(level, text):
688 728
 	name = html_linkify(text)
689
-	return '\n<h%d><a href="#%s" name="%s">%s</a></h%d>\n\n' % (level, name, name, text, level)
729
+	return '\n<h%d id="%s"><a href="#%s">%s</a></h%d>\n\n' % (level, name, name, text, level)
690 730
 
691 731
 class HTMLReportCreator(ReportCreator):
692 732
 	def create(self, data, path):
@@ -827,7 +867,7 @@ class HTMLReportCreator(ReportCreator):
827 867
 				f.write('<td>0</td>')
828 868
 			f.write('</tr>')
829 869
 		f.write('</table></div>')
830
-		f.write('<img src="day_of_week.png" alt="Day of Week" />')
870
+		f.write('<img src="day_of_week.png" alt="Day of Week">')
831 871
 		fp.close()
832 872
 
833 873
 		# Hour of Week
@@ -870,7 +910,7 @@ class HTMLReportCreator(ReportCreator):
870 910
 			fp.write('%d %d\n' % (mm, commits))
871 911
 		fp.close()
872 912
 		f.write('</table></div>')
873
-		f.write('<img src="month_of_year.png" alt="Month of Year" />')
913
+		f.write('<img src="month_of_year.png" alt="Month of Year">')
874 914
 
875 915
 		# Commits by year/month
876 916
 		f.write(html_header(2, 'Commits by year/month'))
@@ -878,7 +918,7 @@ class HTMLReportCreator(ReportCreator):
878 918
 		for yymm in reversed(sorted(data.commits_by_month.keys())):
879 919
 			f.write('<tr><td>%s</td><td>%d</td><td>%d</td><td>%d</td></tr>' % (yymm, data.commits_by_month.get(yymm,0), data.lines_added_by_month.get(yymm,0), data.lines_removed_by_month.get(yymm,0)))
880 920
 		f.write('</table></div>')
881
-		f.write('<img src="commits_by_year_month.png" alt="Commits by year/month" />')
921
+		f.write('<img src="commits_by_year_month.png" alt="Commits by year/month">')
882 922
 		fg = open(path + '/commits_by_year_month.dat', 'w')
883 923
 		for yymm in sorted(data.commits_by_month.keys()):
884 924
 			fg.write('%s %s\n' % (yymm, data.commits_by_month[yymm]))
@@ -890,7 +930,7 @@ class HTMLReportCreator(ReportCreator):
890 930
 		for yy in reversed(sorted(data.commits_by_year.keys())):
891 931
 			f.write('<tr><td>%s</td><td>%d (%.2f%%)</td><td>%d</td><td>%d</td></tr>' % (yy, data.commits_by_year.get(yy,0), (100.0 * data.commits_by_year.get(yy,0)) / data.getTotalCommits(), data.lines_added_by_year.get(yy,0), data.lines_removed_by_year.get(yy,0)))
892 932
 		f.write('</table></div>')
893
-		f.write('<img src="commits_by_year.png" alt="Commits by Year" />')
933
+		f.write('<img src="commits_by_year.png" alt="Commits by Year">')
894 934
 		fg = open(path + '/commits_by_year.dat', 'w')
895 935
 		for yy in sorted(data.commits_by_year.keys()):
896 936
 			fg.write('%d %d\n' % (yy, data.commits_by_year[yy]))
@@ -900,13 +940,13 @@ class HTMLReportCreator(ReportCreator):
900 940
 		f.write(html_header(2, 'Commits by Timezone'))
901 941
 		f.write('<div><div class="card-body"><table><tr>')
902 942
 		f.write('<th>Timezone</th><th>Commits</th>')
943
+		f.write('</tr>')
903 944
 		max_commits_on_tz = max(data.commits_by_timezone.values())
904 945
 		for i in sorted(data.commits_by_timezone.keys(), key = lambda n : int(n)):
905 946
 			commits = data.commits_by_timezone[i]
906 947
 			r = 127 + int((float(commits) / max_commits_on_tz) * 128)
907 948
 			f.write('<tr><th>%s</th><td style="background-color: rgb(%d, 0, 0)">%d</td></tr>' % (i, r, commits))
908 949
 		f.write('</tr></table></div>')
909
-
910 950
 		f.write('</body></html>')
911 951
 		f.close()
912 952
 
@@ -934,12 +974,12 @@ class HTMLReportCreator(ReportCreator):
934 974
 			f.write('<p class="moreauthors">These didn\'t make it to the top: %s</p>' % ', '.join(rest))
935 975
 
936 976
 		f.write(html_header(2, 'Cumulated Added Lines of Code per Author'))
937
-		f.write('<img src="lines_of_code_by_author.png" alt="Lines of code per Author" />')
977
+		f.write('<img src="lines_of_code_by_author.png" alt="Lines of code per Author">')
938 978
 		if len(allauthors) > conf['max_authors']:
939 979
 			f.write('<p class="moreauthors">Only top %d authors shown</p>' % conf['max_authors'])
940 980
 
941 981
 		f.write(html_header(2, 'Commits per Author'))
942
-		f.write('<img src="commits_by_author.png" alt="Commits per Author" />')
982
+		f.write('<img src="commits_by_author.png" alt="Commits per Author">')
943 983
 		if len(allauthors) > conf['max_authors']:
944 984
 			f.write('<p class="moreauthors">Only top %d authors shown</p>' % conf['max_authors'])
945 985
 
@@ -1017,7 +1057,7 @@ class HTMLReportCreator(ReportCreator):
1017 1057
 			fp.write('%s %d %d\n' % (domain, n , info['commits']))
1018 1058
 			f.write('<tr><th>%s</th><td>%d (%.2f%%)</td></tr>' % (domain, info['commits'], (100.0 * info['commits'] / totalcommits)))
1019 1059
 		f.write('</table></div>')
1020
-		f.write('<img src="domains.png" alt="Commits by Domains" />')
1060
+		f.write('<img src="domains.png" alt="Commits by Domains">')
1021 1061
 		fp.close()
1022 1062
 
1023 1063
 		f.write('</body></html>')
@@ -1054,7 +1094,7 @@ class HTMLReportCreator(ReportCreator):
1054 1094
 		#	fg.write('%s %d\n' % (datetime.datetime.fromtimestamp(stamp).strftime('%Y-%m-%d'), data.files_by_stamp[stamp]))
1055 1095
 		fg.close()
1056 1096
 			
1057
-		f.write('<img src="files_by_date.png" alt="Files by Date" />')
1097
+		f.write('<img src="files_by_date.png" alt="Files by Date">')
1058 1098
 
1059 1099
 		#f.write('<h2>Average file size by date</h2>')
1060 1100
 
@@ -1086,7 +1126,7 @@ class HTMLReportCreator(ReportCreator):
1086 1126
 		f.write('</dl></div>\n')
1087 1127
 
1088 1128
 		f.write(html_header(2, 'Lines of Code'))
1089
-		f.write('<img src="lines_of_code.png" />')
1129
+		f.write('<img src="lines_of_code.png" alt="Lines of Code">')
1090 1130
 
1091 1131
 		fg = open(path + '/lines_of_code.dat', 'w')
1092 1132
 		for stamp in sorted(data.changes_by_date.keys()):
@@ -1137,6 +1177,7 @@ class HTMLReportCreator(ReportCreator):
1137 1177
 set output 'hour_of_day.png'
1138 1178
 unset key
1139 1179
 set xrange [0.5:24.5]
1180
+set yrange [0:]
1140 1181
 set xtics 4
1141 1182
 set grid y
1142 1183
 set ylabel "Commits"
@@ -1152,6 +1193,7 @@ plot 'hour_of_day.dat' using 1:2:(0.5) w boxes fs solid
1152 1193
 set output 'day_of_week.png'
1153 1194
 unset key
1154 1195
 set xrange [0.5:7.5]
1196
+set yrange [0:]
1155 1197
 set xtics 1
1156 1198
 set grid y
1157 1199
 set ylabel "Commits"
@@ -1182,6 +1224,7 @@ plot 'domains.dat' using 2:3:(0.5) with boxes fs solid, '' using 2:3:1 with labe
1182 1224
 set output 'month_of_year.png'
1183 1225
 unset key
1184 1226
 set xrange [0.5:12.5]
1227
+set yrange [0:]
1185 1228
 set xtics 1
1186 1229
 set grid y
1187 1230
 set ylabel "Commits"
@@ -1196,6 +1239,7 @@ plot 'month_of_year.dat' using 1:2:(0.5) w boxes fs solid
1196 1239
 """
1197 1240
 set output 'commits_by_year_month.png'
1198 1241
 unset key
1242
+set yrange [0:]
1199 1243
 set xdata time
1200 1244
 set timefmt "%Y-%m"
1201 1245
 set format x "%Y-%m"
@@ -1214,6 +1258,7 @@ plot 'commits_by_year_month.dat' using 1:2:(0.5) w boxes fs solid
1214 1258
 """
1215 1259
 set output 'commits_by_year.png'
1216 1260
 unset key
1261
+set yrange [0:]
1217 1262
 set xtics 1 rotate
1218 1263
 set grid y
1219 1264
 set ylabel "Commits"
@@ -1229,6 +1274,7 @@ plot 'commits_by_year.dat' using 1:2:(0.5) w boxes fs solid
1229 1274
 """
1230 1275
 set output 'files_by_date.png'
1231 1276
 unset key
1277
+set yrange [0:]
1232 1278
 set xdata time
1233 1279
 set timefmt "%Y-%m-%d"
1234 1280
 set format x "%Y-%m-%d"
@@ -1248,6 +1294,7 @@ plot 'files_by_date.dat' using 1:2 w steps
1248 1294
 """
1249 1295
 set output 'lines_of_code.png'
1250 1296
 unset key
1297
+set yrange [0:]
1251 1298
 set xdata time
1252 1299
 set timefmt "%s"
1253 1300
 set format x "%Y-%m-%d"
@@ -1267,6 +1314,7 @@ plot 'lines_of_code.dat' using 1:2 w lines
1267 1314
 set terminal png transparent size 640,480
1268 1315
 set output 'lines_of_code_by_author.png'
1269 1316
 set key left top
1317
+set yrange [0:]
1270 1318
 set xdata time
1271 1319
 set timefmt "%s"
1272 1320
 set format x "%Y-%m-%d"
@@ -1280,7 +1328,8 @@ plot """
1280 1328
 		plots = []
1281 1329
 		for a in self.authors_to_plot:
1282 1330
 			i = i + 1
1283
-			plots.append("""'lines_of_code_by_author.dat' using 1:%d title "%s" w lines""" % (i, a.replace("\"", "\\\"")))
1331
+			author = a.replace("\"", "\\\"").replace("`", "")
1332
+			plots.append("""'lines_of_code_by_author.dat' using 1:%d title "%s" w lines""" % (i, author))
1284 1333
 		f.write(", ".join(plots))
1285 1334
 		f.write('\n')
1286 1335
 
@@ -1294,6 +1343,7 @@ plot """
1294 1343
 set terminal png transparent size 640,480
1295 1344
 set output 'commits_by_author.png'
1296 1345
 set key left top
1346
+set yrange [0:]
1297 1347
 set xdata time
1298 1348
 set timefmt "%s"
1299 1349
 set format x "%Y-%m-%d"
@@ -1307,7 +1357,8 @@ plot """
1307 1357
 		plots = []
1308 1358
 		for a in self.authors_to_plot:
1309 1359
 			i = i + 1
1310
-			plots.append("""'commits_by_author.dat' using 1:%d title "%s" w lines""" % (i, a.replace("\"", "\\\"")))
1360
+			author = a.replace("\"", "\\\"").replace("`", "")
1361
+			plots.append("""'commits_by_author.dat' using 1:%d title "%s" w lines""" % (i, author))
1311 1362
 		f.write(", ".join(plots))
1312 1363
 		f.write('\n')
1313 1364
 
@@ -1322,10 +1373,10 @@ plot """
1322 1373
 
1323 1374
 	def printHeader(self, f, title = ''):
1324 1375
 		f.write(
1325
-"""<?xml version="1.0" encoding="UTF-8"?>
1326
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
1327
-<html xmlns="http://www.w3.org/1999/xhtml">
1376
+"""<!DOCTYPE html>
1377
+<html>
1328 1378
 <head>
1379
+	<meta charset="UTF-8">
1329 1380
 	<title>GitStats - %s</title>
1330 1381
 	<link rel="stylesheet" href="%s" type="text/css" />
1331 1382
 	<link rel="stylesheet" href="bootstrap.css" type="text/css" />
@@ -1350,10 +1401,23 @@ plot """
1350 1401
 </nav>
1351 1402
 """)
1352 1403
 		
1404
+def usage():
1405
+	print """
1406
+Usage: gitstats [options] <gitpath..> <outputpath>
1407
+
1408
+Options:
1409
+-c key=value     Override configuration value
1410
+
1411
+Default config values:
1412
+%s
1413
+
1414
+Please see the manual page for more details.
1415
+""" % conf
1416
+
1353 1417
 
1354 1418
 class GitStats:
1355 1419
 	def run(self, args_orig):
1356
-		optlist, args = getopt.getopt(args_orig, 'c:')
1420
+		optlist, args = getopt.getopt(args_orig, 'hc:', ["help"])
1357 1421
 		for o,v in optlist:
1358 1422
 			if o == '-c':
1359 1423
 				key, value = v.split('=', 1)
@@ -1361,22 +1425,14 @@ class GitStats:
1361 1425
 					raise KeyError('no such key "%s" in config' % key)
1362 1426
 				if isinstance(conf[key], int):
1363 1427
 					conf[key] = int(value)
1364
-				elif isinstance(conf[key], dict):
1365
-					kk,vv = value.split(',', 1)
1366
-					conf[key][kk] = vv
1367 1428
 				else:
1368 1429
 					conf[key] = value
1430
+			elif o in ('-h', '--help'):
1431
+				usage()
1432
+				sys.exit()
1369 1433
 
1370 1434
 		if len(args) < 2:
1371
-			print """
1372
-Usage: gitstats [options] <gitpath..> <outputpath>
1373
-
1374
-Options:
1375
--c key=value     Override configuration value
1376
-
1377
-Default config values:
1378
-%s
1379
-""" % conf
1435
+			usage()
1380 1436
 			sys.exit(0)
1381 1437
 
1382 1438
 		outputpath = os.path.abspath(args[-1])
@@ -1403,11 +1459,14 @@ Default config values:
1403 1459
 		for gitpath in args[0:-1]:
1404 1460
 			print 'Git path: %s' % gitpath
1405 1461
 
1462
+			prevdir = os.getcwd()
1406 1463
 			os.chdir(gitpath)
1407 1464
 
1408 1465
 			print 'Collecting data...'
1409 1466
 			data.collect(gitpath)
1410 1467
 
1468
+			os.chdir(prevdir)
1469
+
1411 1470
 		print 'Refining data...'
1412 1471
 		data.saveCache(cachefile)
1413 1472
 		data.refine()