소스 검색

add code lines stats, json report

MFTECH 1 년 전
부모
커밋
011810bdef
3개의 변경된 파일470개의 추가작업 그리고 74개의 파일을 삭제
  1. 2
    0
      .gitignore
  2. 15
    0
      README.md
  3. 453
    74
      gitstats

+ 2
- 0
.gitignore 파일 보기

@@ -0,0 +1,2 @@
1
+
2
+.DS_Store

+ 15
- 0
README.md 파일 보기

@@ -0,0 +1,15 @@
1
+# Gitstats - meaningful data for your git repository. 
2
+
3
+## How to run
4
+1. Clone this repo and `cd` into it
5
+2. Create a output directory for your output
6
+3. `./gitstats [path_to_git_repo] [path_to_output_folder]`
7
+4. Open the `index.html` within your `ouptut_folder`
8
+
9
+## Dependencies
10
+
11
+* Gnuplot
12
+  * MacOS: `brew install gnuplot`
13
+  * Ubuntu: `sudo apt-get install gnuplot`
14
+* Git
15
+* Python >= 3.0

+ 453
- 74
gitstats 파일 보기

@@ -4,6 +4,7 @@
4 4
 import datetime
5 5
 import getopt
6 6
 import glob
7
+import json
7 8
 import os
8 9
 import pickle
9 10
 import platform
@@ -13,6 +14,7 @@ import subprocess
13 14
 import sys
14 15
 import time
15 16
 import zlib
17
+import multiprocessing
16 18
 
17 19
 if sys.version_info < (2, 6):
18 20
 	print >> sys.stderr, "Python 2.6 or higher is required for gitstats"
@@ -25,6 +27,7 @@ os.environ['LC_ALL'] = 'C'
25 27
 GNUPLOT_COMMON = 'set terminal png transparent size 640,240\nset size 1.0,1.0\n'
26 28
 ON_LINUX = (platform.system() == 'Linux')
27 29
 WEEKDAYS = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
30
+JSONFILE = 'gitstats.json'
28 31
 
29 32
 exectime_internal = 0.0
30 33
 exectime_external = 0.0
@@ -46,8 +49,11 @@ conf = {
46 49
 	'commit_end': 'HEAD',
47 50
 	'linear_linestats': 1,
48 51
 	'project_name': '',
49
-	'processes': 8,
50
-	'start_date': ''
52
+	'processes': multiprocessing.cpu_count(),
53
+	'start_date': '',
54
+	'end_date': '',
55
+	'excluded_authors': [],
56
+	'excluded_prefixes': []
51 57
 }
52 58
 
53 59
 def getpipeoutput(cmds, quiet = False):
@@ -74,8 +80,15 @@ def getpipeoutput(cmds, quiet = False):
74 80
 
75 81
 def getlogrange(defaultrange = 'HEAD', end_only = True):
76 82
 	commit_range = getcommitrange(defaultrange, end_only)
83
+	datesel = ''
77 84
 	if len(conf['start_date']) > 0:
78
-		return '--since="%s" "%s"' % (conf['start_date'], commit_range)
85
+		datesel = '--since="%s" %s' % (conf['start_date'], datesel)
86
+	if len(conf['end_date']) > 0:
87
+		datesel = '--until="%s" %s' % (conf['end_date'], datesel)
88
+    
89
+	if (len(datesel) > 0):
90
+		commit_range = '%s "%s"' % (datesel, commit_range)
91
+
79 92
 	return commit_range
80 93
 
81 94
 def getcommitrange(defaultrange = 'HEAD', end_only = False):
@@ -147,6 +160,14 @@ class DataCollector:
147 160
 		self.activity_by_hour_of_week_busiest = 0
148 161
 		self.activity_by_year_week = {} # yy_wNN -> commits
149 162
 		self.activity_by_year_week_peak = 0
163
+		self.lineactivity_by_hour_of_day = {} # hour -> commits
164
+		self.lineactivity_by_day_of_week = {} # day -> commits
165
+		self.lineactivity_by_month_of_year = {} # month [1-12] -> commits
166
+		self.lineactivity_by_hour_of_week = {} # weekday -> hour -> commits
167
+		self.lineactivity_by_hour_of_day_busiest = 0
168
+		self.lineactivity_by_hour_of_week_busiest = 0
169
+		self.lineactivity_by_year_week = {} # yy_wNN -> commits
170
+		self.lineactivity_by_year_week_peak = 0
150 171
 
151 172
 		self.authors = {} # name -> {commits, first_commit_stamp, last_commit_stamp, last_active_day, active_days, lines_added, lines_removed}
152 173
 
@@ -207,7 +228,7 @@ class DataCollector:
207 228
 	def loadCache(self, cachefile):
208 229
 		if not os.path.exists(cachefile):
209 230
 			return
210
-		print( 'Loading cache...')
231
+		print('Loading cache...')
211 232
 		f = open(cachefile, 'rb')
212 233
 		try:
213 234
 			self.cache = pickle.loads(zlib.decompress(f.read()))
@@ -232,6 +253,12 @@ class DataCollector:
232 253
 
233 254
 	def getActivityByHourOfDay(self):
234 255
 		return {}
256
+	
257
+	def getLineActivityByDayOfWeek(self):
258
+		return {}
259
+
260
+	def getLineActivityByHourOfDay(self):
261
+		return {}
235 262
 
236 263
 	# : get a dictionary of domains
237 264
 	def getDomainInfo(self, domain):
@@ -263,13 +290,16 @@ class DataCollector:
263 290
 	def getTotalFiles(self):
264 291
 		return -1
265 292
 	
293
+	def getTotalLines(self):
294
+		return -1
295
+	
266 296
 	def getTotalLOC(self):
267 297
 		return -1
268 298
 	
269 299
 	##
270 300
 	# Save cacheable data
271 301
 	def saveCache(self, cachefile):
272
-		print( 'Saving cache...')
302
+		print('Saving cache...')
273 303
 		tempfile = cachefile + '.tmp'
274 304
 		f = open(tempfile, 'wb')
275 305
 		#pickle.dump(self.cache, f)
@@ -322,6 +352,8 @@ class GitDataCollector(DataCollector):
322 352
 				parts = re.split('\s+', line, 2)
323 353
 				commits = int(parts[1])
324 354
 				author = parts[2]
355
+				if author in conf["excluded_authors"]:
356
+					continue
325 357
 				self.tags[tag]['commits'] += commits
326 358
 				self.tags[tag]['authors'][author] = commits
327 359
 
@@ -338,6 +370,8 @@ class GitDataCollector(DataCollector):
338 370
 			timezone = parts[3]
339 371
 			author, mail = parts[4].split('<', 1)
340 372
 			author = author.rstrip()
373
+			if author in conf["excluded_authors"]:
374
+				continue
341 375
 			mail = mail.rstrip('>')
342 376
 			domain = '?'
343 377
 			if mail.find('@') != -1:
@@ -434,14 +468,18 @@ class GitDataCollector(DataCollector):
434 468
 			self.commits_by_timezone[timezone] = self.commits_by_timezone.get(timezone, 0) + 1
435 469
 
436 470
 		# outputs "<stamp> <files>" for each revision
437
-		revlines = getpipeoutput(['git rev-list --pretty=format:"%%at %%T" %s' % getlogrange('HEAD'), 'grep -v ^commit']).strip().split('\n')
471
+		revlines = getpipeoutput(['git rev-list --pretty=format:"%%at %%T %%an" %s' % getlogrange('HEAD'), 'grep -v ^commit']).strip().split('\n')
438 472
 		lines = []
439 473
 		revs_to_read = []
440 474
 		time_rev_count = []
441 475
 		#Look up rev in cache and take info from cache if found
442 476
 		#If not append rev to list of rev to read from repo
443 477
 		for revline in revlines:
444
-			time, rev = revline.split(' ')
478
+			_revline = revline.split(' ')
479
+			time, rev = _revline[:2]
480
+			author = ' '.join(_revline[2:])
481
+			if author in conf["excluded_authors"]:
482
+				continue
445 483
 			#if cache empty then add time and rev to list of new rev's
446 484
 			#otherwise try to read needed info from cache
447 485
 			if 'files_in_tree' not in self.cache.keys():
@@ -489,6 +527,14 @@ class GitDataCollector(DataCollector):
489 527
 			blob_id = parts[2]
490 528
 			size = int(parts[3])
491 529
 			fullpath = parts[4]
530
+			exclude = False
531
+			for path in conf["excluded_prefixes"]:
532
+				if fullpath.startswith(path):
533
+					exclude = True
534
+					break
535
+
536
+			if exclude:
537
+				continue
492 538
 
493 539
 			self.total_size += size
494 540
 			self.total_files += 1
@@ -540,6 +586,7 @@ class GitDataCollector(DataCollector):
540 586
 		lines.reverse()
541 587
 		files = 0; inserted = 0; deleted = 0; total_lines = 0
542 588
 		author = None
589
+		last_line = ""
543 590
 		for line in lines:
544 591
 			if len(line) == 0:
545 592
 				continue
@@ -550,35 +597,72 @@ class GitDataCollector(DataCollector):
550 597
 				if pos != -1:
551 598
 					try:
552 599
 						(stamp, author) = (int(line[:pos]), line[pos+1:])
553
-						self.changes_by_date[stamp] = { 'files': files, 'ins': inserted, 'del': deleted, 'lines': total_lines }
554
-
555
-						date = datetime.datetime.fromtimestamp(stamp)
556
-						yymm = date.strftime('%Y-%m')
557
-						self.lines_added_by_month[yymm] = self.lines_added_by_month.get(yymm, 0) + inserted
558
-						self.lines_removed_by_month[yymm] = self.lines_removed_by_month.get(yymm, 0) + deleted
559
-
560
-						yy = date.year
561
-						self.lines_added_by_year[yy] = self.lines_added_by_year.get(yy,0) + inserted
562
-						self.lines_removed_by_year[yy] = self.lines_removed_by_year.get(yy, 0) + deleted
563
-
564
-						files, inserted, deleted = 0, 0, 0
600
+						if author not in conf["excluded_authors"]:
601
+							self.changes_by_date[stamp] = { 'files': files, 'ins': inserted, 'del': deleted, 'lines': total_lines }
602
+
603
+							date = datetime.datetime.fromtimestamp(stamp)
604
+							yymm = date.strftime('%Y-%m')
605
+							self.lines_added_by_month[yymm] = self.lines_added_by_month.get(yymm, 0) + inserted
606
+							self.lines_removed_by_month[yymm] = self.lines_removed_by_month.get(yymm, 0) + deleted
607
+
608
+							yy = date.year
609
+							self.lines_added_by_year[yy] = self.lines_added_by_year.get(yy,0) + inserted
610
+							self.lines_removed_by_year[yy] = self.lines_removed_by_year.get(yy, 0) + deleted
611
+
612
+							# lineactivity
613
+							# hour
614
+							hour = date.hour
615
+							self.lineactivity_by_hour_of_day[hour] = self.lineactivity_by_hour_of_day.get(hour, 0) + inserted + deleted
616
+							# most active hour?
617
+							if self.lineactivity_by_hour_of_day[hour] > self.lineactivity_by_hour_of_day_busiest:
618
+								self.lineactivity_by_hour_of_day_busiest = self.lineactivity_by_hour_of_day[hour]
619
+
620
+							# day of week
621
+							day = date.weekday()
622
+							self.lineactivity_by_day_of_week[day] = self.lineactivity_by_day_of_week.get(day, 0) + inserted + deleted
623
+
624
+							# domain stats
625
+							#if domain not in self.domains:
626
+								#self.domains[domain] = {}
627
+							# lines
628
+							#self.domains[domain]['lines'] = self.domains[domain].get('lines', 0) + 1
629
+
630
+							# hour of week
631
+							if day not in self.lineactivity_by_hour_of_week:
632
+								self.lineactivity_by_hour_of_week[day] = {}
633
+							self.lineactivity_by_hour_of_week[day][hour] = self.lineactivity_by_hour_of_week[day].get(hour, 0) + inserted + deleted
634
+							# most active hour?
635
+							if self.lineactivity_by_hour_of_week[day][hour] > self.lineactivity_by_hour_of_week_busiest:
636
+								self.lineactivity_by_hour_of_week_busiest = self.lineactivity_by_hour_of_week[day][hour]
637
+
638
+							# month of year
639
+							month = date.month
640
+							self.lineactivity_by_month_of_year[month] = self.lineactivity_by_month_of_year.get(month, 0) + inserted + deleted
641
+
642
+							# yearly/weekly activity
643
+							yyw = date.strftime('%Y-%W')
644
+							self.lineactivity_by_year_week[yyw] = self.lineactivity_by_year_week.get(yyw, 0) + inserted + deleted
645
+							if self.lineactivity_by_year_week_peak < self.lineactivity_by_year_week[yyw]:
646
+								self.lineactivity_by_year_week_peak = self.lineactivity_by_year_week[yyw]
647
+
648
+							files, inserted, deleted = 0, 0, 0
649
+
650
+							numbers = getstatsummarycounts(last_line)
651
+							if len(numbers) == 3:
652
+								(files, inserted, deleted) = map(lambda el : int(el), numbers)
653
+								total_lines += inserted
654
+								total_lines -= deleted
655
+								self.total_lines_added += inserted
656
+								self.total_lines_removed += deleted
657
+							else:
658
+								print('Warning: failed to handle line "%s"' % line)
659
+								(files, inserted, deleted) = (0, 0, 0)
565 660
 					except ValueError:
566 661
 						print('Warning: unexpected line "%s"' % line)
567 662
 				else:
568 663
 					print('Warning: unexpected line "%s"' % line)
569 664
 			else:
570
-				numbers = getstatsummarycounts(line)
571
-
572
-				if len(numbers) == 3:
573
-					(files, inserted, deleted) = list(map(lambda el : int(el), numbers))
574
-					total_lines += inserted
575
-					total_lines -= deleted
576
-					self.total_lines_added += inserted
577
-					self.total_lines_removed += deleted
578
-
579
-				else:
580
-					print( 'Warning: failed to handle line "%s"' % line)
581
-					(files, inserted, deleted) = (0, 0, 0)
665
+				last_line = line
582 666
 				#self.changes_by_date[stamp] = { 'files': files, 'ins': inserted, 'del': deleted }
583 667
 		self.total_lines += total_lines
584 668
 
@@ -606,32 +690,33 @@ class GitDataCollector(DataCollector):
606 690
 					try:
607 691
 						oldstamp = stamp
608 692
 						(stamp, author) = (int(line[:pos]), line[pos+1:])
609
-						if oldstamp > stamp:
610
-							# clock skew, keep old timestamp to avoid having ugly graph
611
-							stamp = oldstamp
612
-						if author not in self.authors:
613
-							self.authors[author] = { 'lines_added' : 0, 'lines_removed' : 0, 'commits' : 0}
614
-						self.authors[author]['commits'] = self.authors[author].get('commits', 0) + 1
615
-						self.authors[author]['lines_added'] = self.authors[author].get('lines_added', 0) + inserted
616
-						self.authors[author]['lines_removed'] = self.authors[author].get('lines_removed', 0) + deleted
617
-						if stamp not in self.changes_by_date_by_author:
618
-							self.changes_by_date_by_author[stamp] = {}
619
-						if author not in self.changes_by_date_by_author[stamp]:
620
-							self.changes_by_date_by_author[stamp][author] = {}
621
-						self.changes_by_date_by_author[stamp][author]['lines_added'] = self.authors[author]['lines_added']
622
-						self.changes_by_date_by_author[stamp][author]['commits'] = self.authors[author]['commits']
623
-						files, inserted, deleted = 0, 0, 0
693
+						if author not in conf["excluded_authors"]:
694
+							if oldstamp > stamp:
695
+								# clock skew, keep old timestamp to avoid having ugly graph
696
+								stamp = oldstamp
697
+							if author not in self.authors:
698
+								self.authors[author] = { 'lines_added' : 0, 'lines_removed' : 0, 'commits' : 0}
699
+							self.authors[author]['commits'] = self.authors[author].get('commits', 0) + 1
700
+							self.authors[author]['lines_added'] = self.authors[author].get('lines_added', 0) + inserted
701
+							self.authors[author]['lines_removed'] = self.authors[author].get('lines_removed', 0) + deleted
702
+							if stamp not in self.changes_by_date_by_author:
703
+								self.changes_by_date_by_author[stamp] = {}
704
+							if author not in self.changes_by_date_by_author[stamp]:
705
+								self.changes_by_date_by_author[stamp][author] = {}
706
+							self.changes_by_date_by_author[stamp][author]['lines_added'] = self.authors[author]['lines_added']
707
+							self.changes_by_date_by_author[stamp][author]['commits'] = self.authors[author]['commits']
708
+							files, inserted, deleted = 0, 0, 0
624 709
 					except ValueError:
625
-						print( 'Warning: unexpected line "%s"' % line)
710
+						print('Warning: unexpected line "%s"' % line)
626 711
 				else:
627
-					print( 'Warning: unexpected line "%s"' % line)
712
+					print('Warning: unexpected line "%s"' % line)
628 713
 			else:
629 714
 				numbers = getstatsummarycounts(line);
630 715
 
631 716
 				if len(numbers) == 3:
632 717
 					(files, inserted, deleted) = list(map(lambda el : int(el), numbers))
633 718
 				else:
634
-					print( 'Warning: failed to handle line "%s"' % line)
719
+					print('Warning: failed to handle line "%s"' % line)
635 720
 					(files, inserted, deleted) = (0, 0, 0)
636 721
 	
637 722
 	def refine(self):
@@ -662,6 +747,12 @@ class GitDataCollector(DataCollector):
662 747
 
663 748
 	def getActivityByHourOfDay(self):
664 749
 		return self.activity_by_hour_of_day
750
+	
751
+	def getLineActivityByDayOfWeek(self):
752
+		return self.lineactivity_by_day_of_week
753
+
754
+	def getLineActivityByHourOfDay(self):
755
+		return self.lineactivity_by_hour_of_day
665 756
 
666 757
 	def getAuthorInfo(self, author):
667 758
 		return self.authors[author]
@@ -704,6 +795,9 @@ class GitDataCollector(DataCollector):
704 795
 	
705 796
 	def getTotalLOC(self):
706 797
 		return self.total_lines
798
+	
799
+	def getTotalLines(self):
800
+		return self.total_lines_added + self.total_lines_removed
707 801
 
708 802
 	def getTotalSize(self):
709 803
 		return self.total_size
@@ -728,6 +822,24 @@ def html_header(level, text):
728 822
 	name = html_linkify(text)
729 823
 	return '\n<h%d id="%s"><a href="#%s">%s</a></h%d>\n\n' % (level, name, name, text, level)
730 824
 
825
+
826
+class GitDataCollectorJSONEncoder(json.JSONEncoder):
827
+    def default(self, obj):
828
+        if isinstance(obj, set):
829
+            return list(obj)
830
+        if isinstance(obj, datetime.timedelta):
831
+            return str(obj)
832
+        if isinstance(obj, GitDataCollector):
833
+            return obj.__dict__
834
+        # Let the base class default method raise the TypeError
835
+        return json.JSONEncoder.default(self, obj)
836
+
837
+class JSONReportCreator(ReportCreator):
838
+    def create(self, data, filename):
839
+        f = open(filename, 'w')
840
+        json.dump(data, f, indent=True,
841
+                cls=GitDataCollectorJSONEncoder)
842
+        f.close()
731 843
 class HTMLReportCreator(ReportCreator):
732 844
 	def create(self, data, path):
733 845
 		ReportCreator.create(self, data, path)
@@ -744,7 +856,7 @@ class HTMLReportCreator(ReportCreator):
744 856
 					shutil.copyfile(src, path + '/' + file)
745 857
 					break
746 858
 			else:
747
-				print( 'Warning: "%s" not found, so not copied (searched: %s)' % (file, basedirs))
859
+				print('Warning: "%s" not found, so not copied (searched: %s)' % (file, basedirs))
748 860
 
749 861
 		f = open(path + "/index.html", 'w')
750 862
 		format = '%Y-%m-%d %H:%M:%S'
@@ -754,7 +866,7 @@ class HTMLReportCreator(ReportCreator):
754 866
 
755 867
 		self.printNav(f)
756 868
 
757
-		f.write('<dl>')
869
+		f.write('<div><div class="card-body"><dl>')
758 870
 		f.write('<dt>Project name</dt><dd>%s</dd>' % (data.projectname))
759 871
 		f.write('<dt>Generated</dt><dd>%s (in %d seconds)</dd>' % (datetime.datetime.now().strftime(format), time.time() - data.getStampCreated()))
760 872
 		f.write('<dt>Generator</dt><dd><a href="http://gitstats.sourceforge.net/">GitStats</a> (version %s), %s, %s</dd>' % (getversion(), getgitversion(), getgnuplotversion()))
@@ -764,7 +876,7 @@ class HTMLReportCreator(ReportCreator):
764 876
 		f.write('<dt>Total Lines of Code</dt><dd>%s (%d added, %d removed)</dd>' % (data.getTotalLOC(), data.total_lines_added, data.total_lines_removed))
765 877
 		f.write('<dt>Total Commits</dt><dd>%s (average %.1f commits per active day, %.1f per all days)</dd>' % (data.getTotalCommits(), float(data.getTotalCommits()) / len(data.getActiveDays()), float(data.getTotalCommits()) / data.getCommitDeltaDays()))
766 878
 		f.write('<dt>Authors</dt><dd>%s (average %.1f commits per author)</dd>' % (data.getTotalAuthors(), (1.0 * data.getTotalCommits()) / data.getTotalAuthors()))
767
-		f.write('</dl>')
879
+		f.write('</dl></div></div>')
768 880
 
769 881
 		f.write('</body>\n</html>')
770 882
 		f.close()
@@ -795,7 +907,7 @@ class HTMLReportCreator(ReportCreator):
795 907
 			stampcur -= deltaweek
796 908
 
797 909
 		# top row: commits & bar
798
-		f.write('<table class="noborders"><tr>')
910
+		f.write('<div><div class="card-body"><table class="noborders"><tr>')
799 911
 		for i in range(0, WEEKS):
800 912
 			commits = 0
801 913
 			if weeks[i] in data.activity_by_year_week:
@@ -811,12 +923,12 @@ class HTMLReportCreator(ReportCreator):
811 923
 		f.write('</tr><tr>')
812 924
 		for i in range(0, WEEKS):
813 925
 			f.write('<td>%s</td>' % (WEEKS - i))
814
-		f.write('</tr></table>')
926
+		f.write('</tr></table></div></div>')
815 927
 
816 928
 		# Hour of Day
817 929
 		f.write(html_header(2, 'Hour of Day'))
818 930
 		hour_of_day = data.getActivityByHourOfDay()
819
-		f.write('<table><tr><th>Hour</th>')
931
+		f.write('<div><div class="card-body"><table><tr><th>Hour</th>')
820 932
 		for i in range(0, 24):
821 933
 			f.write('<th>%d</th>' % i)
822 934
 		f.write('</tr>\n<tr><th>Commits</th>')
@@ -895,7 +1007,7 @@ class HTMLReportCreator(ReportCreator):
895 1007
 					f.write('<td></td>')
896 1008
 			f.write('</tr>')
897 1009
 
898
-		f.write('</table>')
1010
+		f.write('</table></div></div>')
899 1011
 
900 1012
 		# Month of Year
901 1013
 		f.write(html_header(2, 'Month of Year'))
@@ -938,7 +1050,7 @@ class HTMLReportCreator(ReportCreator):
938 1050
 
939 1051
 		# Commits by timezone
940 1052
 		f.write(html_header(2, 'Commits by Timezone'))
941
-		f.write('<table><tr>')
1053
+		f.write('<div><div class="card-body"><table><tr>')
942 1054
 		f.write('<th>Timezone</th><th>Commits</th>')
943 1055
 		f.write('</tr>')
944 1056
 		max_commits_on_tz = max(data.commits_by_timezone.values())
@@ -946,7 +1058,7 @@ class HTMLReportCreator(ReportCreator):
946 1058
 			commits = data.commits_by_timezone[i]
947 1059
 			r = 127 + int((float(commits) / max_commits_on_tz) * 128)
948 1060
 			f.write('<tr><th>%s</th><td style="background-color: rgb(%d, 0, 0)">%d</td></tr>' % (i, r, commits))
949
-		f.write('</table>')
1061
+		f.write('</table></div>')
950 1062
 
951 1063
 		f.write('</body></html>')
952 1064
 		f.close()
@@ -962,12 +1074,12 @@ class HTMLReportCreator(ReportCreator):
962 1074
 		# Authors :: List of authors
963 1075
 		f.write(html_header(2, 'List of Authors'))
964 1076
 
965
-		f.write('<table class="authors sortable" id="authors">')
1077
+		f.write('<div><div class="card-body"><table class="authors sortable" id="authors">')
966 1078
 		f.write('<tr><th>Author</th><th>Commits (%)</th><th>+ lines</th><th>- lines</th><th>First commit</th><th>Last commit</th><th class="unsortable">Age</th><th>Active days</th><th># by commits</th></tr>')
967 1079
 		for author in data.getAuthors(conf['max_authors']):
968 1080
 			info = data.getAuthorInfo(author)
969 1081
 			f.write('<tr><td>%s</td><td>%d (%.2f%%)</td><td>%d</td><td>%d</td><td>%s</td><td>%s</td><td>%s</td><td>%d</td><td>%d</td></tr>' % (author, info['commits'], info['commits_frac'], info['lines_added'], info['lines_removed'], info['date_first'], info['date_last'], info['timedelta'], len(info['active_days']), info['place_by_commits']))
970
-		f.write('</table>')
1082
+		f.write('</table></div></div>')
971 1083
 
972 1084
 		allauthors = data.getAuthors()
973 1085
 		if len(allauthors) > conf['max_authors']:
@@ -1134,6 +1246,173 @@ class HTMLReportCreator(ReportCreator):
1134 1246
 			fg.write('%d %d\n' % (stamp, data.changes_by_date[stamp]['lines']))
1135 1247
 		fg.close()
1136 1248
 
1249
+		# Weekly activity
1250
+		WEEKS = 32
1251
+		f.write(html_header(2, 'Weekly activity'))
1252
+		f.write('<p>Last %d weeks</p>' % WEEKS)
1253
+
1254
+		# generate weeks to show (previous N weeks from now)
1255
+		now = datetime.datetime.now()
1256
+		deltaweek = datetime.timedelta(7)
1257
+		weeks = []
1258
+		stampcur = now
1259
+		for i in range(0, WEEKS):
1260
+			weeks.insert(0, stampcur.strftime('%Y-%W'))
1261
+			stampcur -= deltaweek
1262
+
1263
+		# top row: commits & bar
1264
+		f.write('<table class="noborders"><tr>')
1265
+		for i in range(0, WEEKS):
1266
+			commits = 0
1267
+			if weeks[i] in data.lineactivity_by_year_week:
1268
+				commits = data.lineactivity_by_year_week[weeks[i]]
1269
+
1270
+			percentage = 0
1271
+			if weeks[i] in data.lineactivity_by_year_week:
1272
+				percentage = float(data.lineactivity_by_year_week[weeks[i]]) / data.lineactivity_by_year_week_peak
1273
+			height = max(1, int(200 * percentage))
1274
+			f.write('<td style="text-align: center; vertical-align: bottom">%d<div style="display: block; background-color: red; width: 20px; height: %dpx"></div></td>' % (commits, height))
1275
+
1276
+		# bottom row: year/week
1277
+		f.write('</tr><tr>')
1278
+		for i in range(0, WEEKS):
1279
+			f.write('<td>%s</td>' % (WEEKS - i))
1280
+		f.write('</tr></table>')
1281
+
1282
+		# Hour of Day
1283
+		f.write(html_header(2, 'Hour of Day'))
1284
+		hour_of_day = data.getLineActivityByHourOfDay()
1285
+		f.write('<table><tr><th>Hour</th>')
1286
+		for i in range(0, 24):
1287
+			f.write('<th>%d</th>' % i)
1288
+		f.write('</tr>\n<tr><th>Lines</th>')
1289
+		fp = open(path + '/line_hour_of_day.dat', 'w')
1290
+		for i in range(0, 24):
1291
+			if i in hour_of_day:
1292
+				r = 127 + int((float(hour_of_day[i]) / data.lineactivity_by_hour_of_day_busiest) * 128)
1293
+				f.write('<td style="background-color: rgb(%d, 0, 0)">%d</td>' % (r, hour_of_day[i]))
1294
+				fp.write('%d %d\n' % (i, hour_of_day[i]))
1295
+			else:
1296
+				f.write('<td>0</td>')
1297
+				fp.write('%d 0\n' % i)
1298
+		fp.close()
1299
+		f.write('</tr>\n<tr><th>%</th>')
1300
+		totallines = data.getTotalLines()
1301
+		for i in range(0, 24):
1302
+			if i in hour_of_day:
1303
+				r = 127 + int((float(hour_of_day[i]) / data.lineactivity_by_hour_of_day_busiest) * 128)
1304
+				f.write('<td style="background-color: rgb(%d, 0, 0)">%.2f</td>' % (r, (100.0 * hour_of_day[i]) / totallines))
1305
+			else:
1306
+				f.write('<td>0.00</td>')
1307
+		f.write('</tr></table>')
1308
+		f.write('<img src="line_hour_of_day.png" alt="Hour of Day" />')
1309
+		fg = open(path + '/line_hour_of_day.dat', 'w')
1310
+		for i in range(0, 24):
1311
+			if i in hour_of_day:
1312
+				fg.write('%d %d\n' % (i + 1, hour_of_day[i]))
1313
+			else:
1314
+				fg.write('%d 0\n' % (i + 1))
1315
+		fg.close()
1316
+
1317
+		# Day of Week
1318
+		f.write(html_header(2, 'Day of Week'))
1319
+		day_of_week = data.getLineActivityByDayOfWeek()
1320
+		f.write('<div class="vtable"><table>')
1321
+		f.write('<tr><th>Day</th><th>Total (%)</th></tr>')
1322
+		fp = open(path + '/line_day_of_week.dat', 'w')
1323
+		for d in range(0, 7):
1324
+			commits = 0
1325
+			if d in day_of_week:
1326
+				commits = day_of_week[d]
1327
+			fp.write('%d %s %d\n' % (d + 1, WEEKDAYS[d], commits))
1328
+			f.write('<tr>')
1329
+			f.write('<th>%s</th>' % (WEEKDAYS[d]))
1330
+			if d in day_of_week:
1331
+				f.write('<td>%d (%.2f%%)</td>' % (day_of_week[d], (100.0 * day_of_week[d]) / totallines))
1332
+			else:
1333
+				f.write('<td>0</td>')
1334
+			f.write('</tr>')
1335
+		f.write('</table></div>')
1336
+		f.write('<img src="line_day_of_week.png" alt="Day of Week" />')
1337
+		fp.close()
1338
+
1339
+		# Hour of Week
1340
+		f.write(html_header(2, 'Hour of Week'))
1341
+		f.write('<table>')
1342
+
1343
+		f.write('<tr><th>Weekday</th>')
1344
+		for hour in range(0, 24):
1345
+			f.write('<th>%d</th>' % (hour))
1346
+		f.write('</tr>')
1347
+
1348
+		for weekday in range(0, 7):
1349
+			f.write('<tr><th>%s</th>' % (WEEKDAYS[weekday]))
1350
+			for hour in range(0, 24):
1351
+				try:
1352
+					commits = data.lineactivity_by_hour_of_week[weekday][hour]
1353
+				except KeyError:
1354
+					commits = 0
1355
+				if commits != 0:
1356
+					f.write('<td')
1357
+					r = 127 + int((float(commits) / data.lineactivity_by_hour_of_week_busiest) * 128)
1358
+					f.write(' style="background-color: rgb(%d, 0, 0)"' % r)
1359
+					f.write('>%d</td>' % commits)
1360
+				else:
1361
+					f.write('<td></td>')
1362
+			f.write('</tr>')
1363
+
1364
+		f.write('</table>')
1365
+
1366
+		# Month of Year
1367
+		f.write(html_header(2, 'Month of Year'))
1368
+		f.write('<div class="vtable"><table>')
1369
+		f.write('<tr><th>Month</th><th>Lines (%)</th></tr>')
1370
+		fp = open (path + '/line_month_of_year.dat', 'w')
1371
+		for mm in range(1, 13):
1372
+			commits = 0
1373
+			if mm in data.lineactivity_by_month_of_year:
1374
+				commits = data.lineactivity_by_month_of_year[mm]
1375
+			f.write('<tr><td>%d</td><td>%d (%.2f %%)</td></tr>' % (mm, commits, (100.0 * commits) / data.getTotalLines()))
1376
+			fp.write('%d %d\n' % (mm, commits))
1377
+		fp.close()
1378
+		f.write('</table></div>')
1379
+		f.write('<img src="line_month_of_year.png" alt="Month of Year" />')
1380
+
1381
+		# Lines by year/month
1382
+		f.write(html_header(2, 'Lines by year/month'))
1383
+		f.write('<div class="vtable"><table><tr><th>Month</th><th>Commits</th><th>Lines added</th><th>Lines removed</th></tr>')
1384
+		for yymm in reversed(sorted(data.commits_by_month.keys())):
1385
+			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)))
1386
+		f.write('</table></div>')
1387
+		f.write('<img src="line_commits_by_year_month.png" alt="Commits by year/month" />')
1388
+		fg = open(path + '/line_commits_by_year_month.dat', 'w')
1389
+		for yymm in sorted(data.commits_by_month.keys()):
1390
+			fg.write('%s %s\n' % (yymm, data.lines_added_by_month.get(yymm, 0) + data.lines_removed_by_month.get(yymm, 0)))
1391
+		fg.close()
1392
+
1393
+		# Lines by year
1394
+		f.write(html_header(2, 'Lines by Year'))
1395
+		f.write('<div class="vtable"><table><tr><th>Year</th><th>Commits (% of all)</th><th>Lines added</th><th>Lines removed</th></tr>')
1396
+		for yy in reversed(sorted(data.commits_by_year.keys())):
1397
+			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)))
1398
+		f.write('</table></div>')
1399
+		f.write('<img src="line_commits_by_year.png" alt="Commits by Year" />')
1400
+		fg = open(path + '/line_commits_by_year.dat', 'w')
1401
+		for yy in sorted(data.commits_by_year.keys()):
1402
+			fg.write('%d %d\n' % (yy, data.lines_added_by_year.get(yy,0) + data.lines_removed_by_year.get(yy,0)))
1403
+		fg.close()
1404
+
1405
+		# Commits by timezone
1406
+		f.write(html_header(2, 'Commits by Timezone'))
1407
+		f.write('<table><tr>')
1408
+		f.write('<th>Timezone</th><th>Commits</th>')
1409
+		max_commits_on_tz = max(data.commits_by_timezone.values())
1410
+		for i in sorted(data.commits_by_timezone.keys(), key = lambda n : int(n)):
1411
+			commits = data.commits_by_timezone[i]
1412
+			r = 127 + int((float(commits) / max_commits_on_tz) * 128)
1413
+			f.write('<tr><th>%s</th><td style="background-color: rgb(%d, 0, 0)">%d</td></tr>' % (i, r, commits))
1414
+		f.write('</tr></table>')
1415
+
1137 1416
 		f.write('</body></html>')
1138 1417
 		f.close()
1139 1418
 
@@ -1168,7 +1447,7 @@ class HTMLReportCreator(ReportCreator):
1168 1447
 		self.createGraphs(path)
1169 1448
 	
1170 1449
 	def createGraphs(self, path):
1171
-		print( 'Generating graphs...')
1450
+		print('Generating graphs...')
1172 1451
 
1173 1452
 		# hour of day
1174 1453
 		f = open(path + '/hour_of_day.plot', 'w')
@@ -1336,6 +1615,99 @@ plot """
1336 1615
 
1337 1616
 		f.close()
1338 1617
 
1618
+		# hour of day
1619
+		f = open(path + '/line_hour_of_day.plot', 'w')
1620
+		f.write(GNUPLOT_COMMON)
1621
+		f.write(
1622
+"""
1623
+set output 'line_hour_of_day.png'
1624
+unset key
1625
+set xrange [0.5:24.5]
1626
+set xtics 4
1627
+set grid y
1628
+set ylabel "Lines"
1629
+plot 'line_hour_of_day.dat' using 1:2:(0.5) w boxes fs solid
1630
+""")
1631
+		f.close()
1632
+
1633
+		# day of week
1634
+		f = open(path + '/line_day_of_week.plot', 'w')
1635
+		f.write(GNUPLOT_COMMON)
1636
+		f.write(
1637
+"""
1638
+set output 'line_day_of_week.png'
1639
+unset key
1640
+set xrange [0.5:7.5]
1641
+set xtics 1
1642
+set grid y
1643
+set ylabel "Lines"
1644
+plot 'line_day_of_week.dat' using 1:3:(0.5):xtic(2) w boxes fs solid
1645
+""")
1646
+		f.close()
1647
+
1648
+		# Domains
1649
+#		f = open(path + '/domains.plot', 'w')
1650
+#		f.write(GNUPLOT_COMMON)
1651
+#		f.write(
1652
+#"""
1653
+#set output 'domains.png'
1654
+#unset key
1655
+#unset xtics
1656
+#set yrange [0:]
1657
+#set grid y
1658
+#set ylabel "Commits"
1659
+#plot 'domains.dat' using 2:3:(0.5) with boxes fs solid, '' using 2:3:1 with labels rotate by 45 offset 0,1
1660
+#""")
1661
+#		f.close()
1662
+
1663
+		# Month of Year
1664
+		f = open(path + '/line_month_of_year.plot', 'w')
1665
+		f.write(GNUPLOT_COMMON)
1666
+		f.write(
1667
+"""
1668
+set output 'line_month_of_year.png'
1669
+unset key
1670
+set xrange [0.5:12.5]
1671
+set xtics 1
1672
+set grid y
1673
+set ylabel "Lines"
1674
+plot 'line_month_of_year.dat' using 1:2:(0.5) w boxes fs solid
1675
+""")
1676
+		f.close()
1677
+
1678
+		# commits_by_year_month
1679
+		f = open(path + '/line_commits_by_year_month.plot', 'w')
1680
+		f.write(GNUPLOT_COMMON)
1681
+		f.write(
1682
+"""
1683
+set output 'line_commits_by_year_month.png'
1684
+unset key
1685
+set xdata time
1686
+set timefmt "%Y-%m"
1687
+set format x "%Y-%m"
1688
+set xtics rotate
1689
+set bmargin 5
1690
+set grid y
1691
+set ylabel "Lines"
1692
+plot 'line_commits_by_year_month.dat' using 1:2:(0.5) w boxes fs solid
1693
+""")
1694
+		f.close()
1695
+
1696
+		# commits_by_year
1697
+		f = open(path + '/line_commits_by_year.plot', 'w')
1698
+		f.write(GNUPLOT_COMMON)
1699
+		f.write(
1700
+"""
1701
+set output 'line_commits_by_year.png'
1702
+unset key
1703
+set xtics 1 rotate
1704
+set grid y
1705
+set ylabel "Lines"
1706
+set yrange [0:]
1707
+plot 'line_commits_by_year.dat' using 1:2:(0.5) w boxes fs solid
1708
+""")
1709
+		f.close()
1710
+
1339 1711
 		# Commits per author
1340 1712
 		f = open(path + '/commits_by_author.plot', 'w')
1341 1713
 		f.write(GNUPLOT_COMMON)
@@ -1370,7 +1742,7 @@ plot """
1370 1742
 		for f in files:
1371 1743
 			out = getpipeoutput([gnuplot_cmd + ' "%s"' % f])
1372 1744
 			if len(out) > 0:
1373
-				print( out)
1745
+				print(out)
1374 1746
 
1375 1747
 	def printHeader(self, f, title = ''):
1376 1748
 		f.write(
@@ -1379,8 +1751,8 @@ plot """
1379 1751
 <head>
1380 1752
 	<meta charset="UTF-8">
1381 1753
 	<title>GitStats - %s</title>
1382
-	<link rel="stylesheet" href="%s" type="text/css">
1383
-	<meta name="generator" content="GitStats %s">
1754
+	<link rel="stylesheet" href="%s" type="text/css" />
1755
+	<meta name="generator" content="GitStats %s" />
1384 1756
 	<script type="text/javascript" src="sortable.js"></script>
1385 1757
 </head>
1386 1758
 <body>
@@ -1425,6 +1797,8 @@ class GitStats:
1425 1797
 					raise KeyError('no such key "%s" in config' % key)
1426 1798
 				if isinstance(conf[key], int):
1427 1799
 					conf[key] = int(value)
1800
+				elif isinstance(conf[key], list):
1801
+					conf[key].append(value)
1428 1802
 				else:
1429 1803
 					conf[key] = value
1430 1804
 			elif o in ('-h', '--help'):
@@ -1443,47 +1817,52 @@ class GitStats:
1443 1817
 		except OSError:
1444 1818
 			pass
1445 1819
 		if not os.path.isdir(outputpath):
1446
-			print( 'FATAL: Output path is not a directory or does not exist')
1820
+			print('FATAL: Output path is not a directory or does not exist')
1447 1821
 			sys.exit(1)
1448 1822
 
1449 1823
 		if not getgnuplotversion():
1450
-			print( 'gnuplot not found')
1824
+			print('gnuplot not found')
1451 1825
 			sys.exit(1)
1452 1826
 
1453
-		print( 'Output path: %s' % outputpath)
1827
+		print('Output path: %s' % outputpath)
1454 1828
 		cachefile = os.path.join(outputpath, 'gitstats.cache')
1455 1829
 
1456 1830
 		data = GitDataCollector()
1457 1831
 		data.loadCache(cachefile)
1458 1832
 
1459 1833
 		for gitpath in args[0:-1]:
1460
-			print( 'Git path: %s' % gitpath)
1834
+			print('Git path: %s' % gitpath)
1461 1835
 
1462 1836
 			prevdir = os.getcwd()
1463 1837
 			os.chdir(gitpath)
1464 1838
 
1465
-			print( 'Collecting data...')
1839
+			print('Collecting data...')
1466 1840
 			data.collect(gitpath)
1467 1841
 
1468 1842
 			os.chdir(prevdir)
1469 1843
 
1470
-		print( 'Refining data...')
1844
+		print('Refining data...')
1471 1845
 		data.saveCache(cachefile)
1472 1846
 		data.refine()
1473 1847
 
1474 1848
 		os.chdir(rundir)
1475 1849
 
1476
-		print( 'Generating report...')
1850
+		print('Generating HTML report...')
1477 1851
 		report = HTMLReportCreator()
1478 1852
 		report.create(data, outputpath)
1479 1853
 
1854
+		print('Generating JSON report...')
1855
+		report = JSONReportCreator()
1856
+		report.create(data, os.path.join(outputpath, JSONFILE))
1857
+
1480 1858
 		time_end = time.time()
1481 1859
 		exectime_internal = time_end - time_start
1482
-		print( 'Execution time %.5f secs, %.5f secs (%.2f %%) in external commands)' % (exectime_internal, exectime_external, (100.0 * exectime_external) / exectime_internal))
1860
+		print('Execution time %.5f secs, %.5f secs (%.2f %%) in external commands)' % (exectime_internal, exectime_external, (100.0 * exectime_external) / exectime_internal))
1483 1861
 		if sys.stdin.isatty():
1484
-			print( 'You may now run:')
1862
+			print('You may now run:')
1485 1863
 			print()
1486
-			print( '   sensible-browser \'%s\'' % os.path.join(outputpath, 'index.html').replace("'", "'\\''"))
1864
+			print('   sensible-browser \'%s\'' % os.path.join(outputpath, 'index.html').replace("'", "'\\''"))
1865
+			print('   sensible-notepad \'%s\'' % os.path.join(outputpath, JSONFILE).replace("'", "'\\''"))
1487 1866
 			print()
1488 1867
 
1489 1868
 if __name__=='__main__':