Add an introductory comment.
[git-dm.git] / gitdm
blobc2b20cd983ff1fe1a677091ca81b449b7e67ece5
1 #!/usr/bin/pypy
2 #-*- coding:utf-8 -*-
6 # This code is part of the LWN git data miner.
8 # Copyright 2007-13 Eklektix, Inc.
9 # Copyright 2007-13 Jonathan Corbet <corbet@lwn.net>
10 # Copyright 2011 Germán Póo-Caamaño <gpoo@gnome.org>
12 # This file may be distributed under the terms of the GNU General
13 # Public License, version 2.
16 import database, csvdump, ConfigFile, reports
17 import getopt, datetime
18 import os, re, sys, rfc822, string, os.path
19 import logparser
20 from patterns import patterns
22 Today = datetime.date.today()
25 # Remember author names we have griped about.
27 GripedAuthorNames = [ ]
30 # Control options.
32 MapUnknown = 0
33 DevReports = 1
34 DateStats = 0
35 AuthorSOBs = 1
36 FileFilter = None
37 CSVFile = None
38 CSVPrefix = None
39 AkpmOverLt = 0
40 DumpDB = 0
41 CFName = 'gitdm.config'
42 DirName = ''
43 Aggregate = 'month'
44 Numstat = 0
45 ReportByFileType = 0
46 ReportUnknowns = False
47 CompanyFilter = None
48 FileReport = None
50 # Options:
52 # -a            Andrew Morton's signoffs shadow Linus's
53 # -b dir        Specify the base directory to fetch the configuration files
54 # -c cfile      Specify a configuration file
55 # -C company    Only consider patches from <company>
56 # -d            Output individual developer stats
57 # -D            Output date statistics
58 # -f file       Write touched-files report to <file>
59 # -h hfile      HTML output to hfile
60 # -l count      Maximum length for output lists
61 # -n        Use numstats instead of generated patch from git log
62 # -o file       File for text output
63 # -p prefix Prefix for CSV output
64 # -r pattern    Restrict to files matching pattern
65 # -s            Ignore author SOB lines
66 # -u            Map unknown employers to '(Unknown)'
67 # -U            Dump unknown hackers in report
68 # -x file.csv   Export raw statistics as CSV
69 # -w        Aggregrate the raw statistics by weeks instead of months
70 # -y            Aggregrate the raw statistics by years instead of months
71 # -z            Dump out the hacker database at completion
73 def ParseOpts():
74     global MapUnknown, DevReports
75     global DateStats, AuthorSOBs, FileFilter, AkpmOverLt, DumpDB
76     global CFName, CSVFile, CSVPrefix,DirName, Aggregate, Numstat
77     global ReportByFileType, ReportUnknowns, CompanyFilter, FileReport
79     opts, rest = getopt.getopt(sys.argv[1:], 'ab:dC:c:Df:h:l:no:p:r:stUuwx:yz')
80     for opt in opts:
81         if opt[0] == '-a':
82             AkpmOverLt = 1
83         elif opt[0] == '-b':
84             DirName = opt[1]
85         elif opt[0] == '-C':
86             CompanyFilter = opt[1]
87         elif opt[0] == '-c':
88             CFName = opt[1]
89         elif opt[0] == '-d':
90             DevReports = 0
91         elif opt[0] == '-D':
92             DateStats = 1
93         elif opt[0] == '-f':
94             FileReport = opt[1]
95         elif opt[0] == '-h':
96             reports.SetHTMLOutput(open(opt[1], 'w'))
97         elif opt[0] == '-l':
98             reports.SetMaxList(int(opt[1]))
99         elif opt[0] == '-n':
100             Numstat = 1
101         elif opt[0] == '-o':
102             reports.SetOutput(open(opt[1], 'w'))
103         elif opt[0] == '-p':
104             CSVPrefix = opt[1]
105         elif opt[0] == '-r':
106             print 'Filter on "%s"' % (opt[1])
107             FileFilter = re.compile(opt[1])
108         elif opt[0] == '-s':
109             AuthorSOBs = 0
110         elif opt[0] == '-t':
111             ReportByFileType = 1
112         elif opt[0] == '-u':
113             MapUnknown = 1
114         elif opt[0] == '-U':
115             ReportUnknowns = True
116         elif opt[0] == '-x':
117             CSVFile = open(opt[1], 'w')
118             print "open output file " + opt[1] + "\n"
119         elif opt [0] == '-w':
120             Aggregate = 'week'
121         elif opt [0] == '-y':
122             Aggregate = 'year'
123         elif opt[0] == '-z':
124             DumpDB = 1
125         
127 # Tracking for file accesses.
129 FileAccesses = { }
131 def AddAccess(path):
132     try:
133         FileAccesses[path] += 1
134     except KeyError:
135         FileAccesses[path] = 1
137 def NoteFileAccess(paths):
138     #
139     # Keep separate track of what we've noted in this set so that each level
140     # of the tree only gets a single note from one patch.
141     #
142     noted = [ ]
143     for path in paths:
144         if path.startswith('a/') or path.startswith('b/'):
145             path = path[2:]
146         AddAccess(path)
147         noted.append(path)
148         path, last = os.path.split(path)
149         while path and path not in ['a', 'b', '/']:
150             if path in noted:
151                 break
152             noted.append(path)
153             AddAccess(path)
154             path, last = os.path.split(path)
157 # Local version still, for now
159 def LookupStoreHacker(name, email):
160     return database.LookupStoreHacker(name, email, MapUnknown)
163 # Date tracking.
166 DateMap = { }
168 def AddDateLines(date, lines):
169     if lines > 1000000:
170         print 'Skip big patch (%d)' % lines
171         return
172     try:
173         DateMap[date] += lines
174     except KeyError:
175         DateMap[date] = lines
177 def PrintDateStats():
178     dates = DateMap.keys()
179     dates.sort()
180     total = 0
181     datef = open('datelc.csv', 'w')
182     datef.write('Date,Changed,Total Changed\n')
183     for date in dates:
184         total += DateMap[date]
185         datef.write('%d/%02d/%02d,%d,%d\n' % (date.year, date.month, date.day,
186                                     DateMap[date], total))
190 # Let's slowly try to move some smarts into this class.
192 class patch:
193     (ADDED, REMOVED) = range(2)
195     def __init__(self, commit):
196         self.commit = commit
197         self.merge = self.added = self.removed = 0
198         self.author = LookupStoreHacker('Unknown hacker', 'unknown@hacker.net')
199         self.email = 'unknown@hacker.net'
200         self.sobs = [ ]
201         self.reviews = [ ]
202         self.testers = [ ]
203         self.reports = [ ]
204         self.filetypes = {}
205         self.files = [ ]
207     def addreviewer(self, reviewer):
208         self.reviews.append(reviewer)
210     def addtester(self, tester):
211         self.testers.append(tester)
213     def addreporter(self, reporter):
214         self.reports.append(reporter)
216     def addfiletype(self, filetype, added, removed):
217         if self.filetypes.has_key(filetype):
218             self.filetypes[filetype][self.ADDED] += added
219             self.filetypes[filetype][self.REMOVED] += removed
220         else:
221             self.filetypes[filetype] = [added, removed]
223     def addfile(self, name):
224         self.files.append(name)
227 def parse_numstat(line, file_filter):
228     """
229         Receive a line of text, determine if fits a numstat line and
230         parse the added and removed lines as well as the file type.
231     """
232     m = patterns['numstat'].match(line)
233     if m:
234         filename = m.group(3)
235         # If we have a file filter, check for file lines.
236         if file_filter and not file_filter.search(filename):
237             return None, None, None, None
239         try:
240             added = int(m.group(1))
241             removed = int(m.group(2))
242         except ValueError:
243             # A binary file (image, etc.) is marked with '-'
244             added = removed = 0
246         m = patterns['rename'].match(filename)
247         if m:
248             filename = '%s%s%s' % (m.group(1), m.group(3), m.group(4))
250         filetype = database.FileTypes.guess_file_type(os.path.basename(filename))
251         return filename, filetype, added, removed
252     else:
253         return None, None, None, None
256 # The core hack for grabbing the information about a changeset.
258 def grabpatch(logpatch):
259     m = patterns['commit'].match(logpatch[0])
260     if not m:
261         return None
263     p = patch(m.group(1))
264     ignore = (FileFilter is not None)
265     need_bline = False
266     for Line in logpatch[1:]:
267         #
268         # Maybe it's an author line?
269         #
270         m = patterns['author'].match(Line)
271         if m:
272             p.email = database.RemapEmail(m.group(2))
273             p.author = LookupStoreHacker(m.group(1), p.email)
274             continue
275         #
276         # Could be a signed-off-by:
277         #
278         m = patterns['signed-off-by'].match(Line)
279         if m:
280             email = database.RemapEmail(m.group(2))
281             sobber = LookupStoreHacker(m.group(1), email)
282             if sobber != p.author or AuthorSOBs:
283                 p.sobs.append((email, LookupStoreHacker(m.group(1), m.group(2))))
284             continue
285         #
286         # Various other tags of interest.
287         #
288         m = patterns['reviewed-by'].match(Line)
289         if m:
290             email = database.RemapEmail(m.group(2))
291             p.addreviewer(LookupStoreHacker(m.group(1), email))
292             continue
293         m = patterns['tested-by'].match(Line)
294         if m:
295             email = database.RemapEmail(m.group(2))
296             p.addtester(LookupStoreHacker(m.group(1), email))
297             p.author.testcredit(patch)
298             continue
299         # Reported-by:
300         m = patterns['reported-by'].match(Line)
301         if m:
302             email = database.RemapEmail(m.group(2))
303             p.addreporter(LookupStoreHacker(m.group(1), email))
304             p.author.reportcredit(patch)
305             continue
306         # Reported-and-tested-by:
307         m = patterns['reported-and-tested-by'].match(Line)
308         if m:
309             email = database.RemapEmail(m.group(2))
310             h = LookupStoreHacker(m.group(1), email)
311             p.addreporter(h)
312             p.addtester(h)
313             p.author.reportcredit(patch)
314             p.author.testcredit(patch)
315             continue
316         #
317         # If this one is a merge, make note of the fact.
318         #
319         m = patterns['merge'].match(Line)
320         if m:
321             p.merge = 1
322             continue
323         #
324         # See if it's the date.
325         #
326         m = patterns['date'].match(Line)
327         if m:
328             dt = rfc822.parsedate(m.group(2))
329             p.date = datetime.date(dt[0], dt[1], dt[2])
330             if p.date > Today:
331                 sys.stderr.write('Funky date: %s\n' % p.date)
332                 p.date = Today
333             continue
334         if not Numstat:
335             #
336             # If we have a file filter, check for file lines.
337             #
338             if FileFilter:
339                 ignore = ApplyFileFilter(Line, ignore)
340             #
341             # If we are tracking files touched, look for a relevant line here.
342             #
343             if FileReport and not ignore:
344                 m = patterns['filea'].match(Line)
345                 if m:
346                     file = m.group(1)
347                     if file == '/dev/null':
348                         need_bline = True
349                         continue
350                     p.addfile(m.group(1))
351                     continue
352                 elif need_bline:
353                     m = patterns['fileb'].match(Line)
354                     if m:
355                         p.addfile(m.group(1))
356                     need_bline = False
357                     continue
358             #
359             # OK, maybe it's part of the diff itself.
360             #
361             if not ignore:
362                 if patterns['add'].match(Line):
363                     p.added += 1
364                     continue
365                 if patterns['rem'].match(Line):
366                     p.removed += 1
367         else:
368             #
369             # Grab data in the numstat format.
370             #
371             (filename, filetype, added, removed) = parse_numstat(Line, FileFilter)
372             if filename:
373                 p.added += added
374                 p.removed += removed
375                 p.addfiletype(filetype, added, removed)
376                 p.addfile(filename)
378     if '@' in p.author.name:
379         GripeAboutAuthorName(p.author.name)
381     return p
383 def GripeAboutAuthorName(name):
384     if name in GripedAuthorNames:
385         return
386     GripedAuthorNames.append(name)
387     print '%s is an author name, probably not what you want' % (name)
389 def ApplyFileFilter(line, ignore):
390     #
391     # If this is the first file line (--- a/), set ignore one way
392     # or the other.
393     #
394     m = patterns['filea'].match(line)
395     if m:
396         file = m.group(1)
397         if FileFilter.search(file):
398             return 0
399         return 1
400     #
401     # For the second line, we can turn ignore off, but not on
402     #
403     m = patterns['fileb'].match(line)
404     if m:
405         file = m.group(1)
406         if FileFilter.search(file):
407             return 0
408     return ignore
410 def is_svntag(logpatch):
411     """
412         This is a workaround for a bug on the migration to Git
413         from Subversion found in GNOME.  It may happen in other
414         repositories as well.
415     """
417     for Line in logpatch:
418         m = patterns['svn-tag'].match(Line.strip())
419         if m:
420             sys.stderr.write('(W) detected a commit on a svn tag: %s\n' %
421                               (m.group(0),))
422             return True
424     return False
427 # If this patch is signed off by both Andrew Morton and Linus Torvalds,
428 # remove the (redundant) Linus signoff.
430 def TrimLTSOBs(p):
431     if AkpmOverLt == 1 and Linus in p.sobs and Akpm in p.sobs:
432         p.sobs.remove(Linus)
436 # Here starts the real program.
438 ParseOpts()
441 # Read the config files.
443 ConfigFile.ConfigFile(CFName, DirName)
446 # Let's pre-seed the database with a couple of hackers
447 # we want to remember.
449 if AkpmOverLt == 1:
450     Linus = ('torvalds@linux-foundation.org',
451          LookupStoreHacker('Linus Torvalds', 'torvalds@linux-foundation.org'))
452     Akpm = ('akpm@linux-foundation.org',
453         LookupStoreHacker('Andrew Morton', 'akpm@linux-foundation.org'))
455 TotalChanged = TotalAdded = TotalRemoved = 0
458 # Snarf changesets.
460 print >> sys.stderr, 'Grabbing changesets...\r',
462 patches = logparser.LogPatchSplitter(sys.stdin)
463 printcount = CSCount = 0
465 for logpatch in patches:
466     if (printcount % 50) == 0:
467         print >> sys.stderr, 'Grabbing changesets...%d\r' % printcount,
468     printcount += 1
470     # We want to ignore commits on svn tags since in Subversion
471     # thats mean a copy of the whole repository, which leads to
472     # wrong results.  Some migrations from Subversion to Git does
473     # not catch all this tags/copy and import them just as a new
474     # big changeset.
475     if is_svntag(logpatch):
476         continue
478     p = grabpatch(logpatch)
479     if not p:
480         break
481 #    if p.added > 100000 or p.removed > 100000:
482 #        print 'Skipping massive add', p.commit
483 #        continue
484     if FileFilter and p.added == 0 and p.removed == 0:
485         continue
486     #
487     # Apply the company filter if it exists.
488     #
489     empl = p.author.emailemployer(p.email, p.date)
490     if CompanyFilter and empl.name != CompanyFilter:
491         continue
492     #
493     # Now note the file accesses if need be.
494     #
495     if FileReport:
496         NoteFileAccess(p.files)
497     #
498     # Record some global information - but only if this patch had
499     # stuff which wasn't ignored.
500     #
501     if ((p.added + p.removed) > 0 or not FileFilter) and not p.merge:
502         TotalAdded += p.added
503         TotalRemoved += p.removed
504         TotalChanged += max(p.added, p.removed)
505         AddDateLines(p.date, max(p.added, p.removed))
506         empl.AddCSet(p)
507         if AkpmOverLt:
508             TrimLTSOBs(p)
509         for sobemail, sobber in p.sobs:
510             empl = sobber.emailemployer(sobemail, p.date)
511             empl.AddSOB()
513     if not p.merge:
514         p.author.addpatch(p)
515         for sobemail, sob in p.sobs:
516             sob.addsob(p)
517         for hacker in p.reviews:
518             hacker.addreview(p)
519         for hacker in p.testers:
520             hacker.addtested(p)
521         for hacker in p.reports:
522             hacker.addreport(p)
523         CSCount += 1
524     csvdump.AccumulatePatch(p, Aggregate)
525     csvdump.store_patch(p)
526 print >> sys.stderr, 'Grabbing changesets...done       '
528 if DumpDB:
529     database.DumpDB()
530 database.MixVirtuals()
533 # Say something
535 hlist = database.AllHackers()
536 elist = database.AllEmployers()
537 ndev = nempl = 0
538 for h in hlist:
539     if len(h.patches) > 0:
540         ndev += 1
541 for e in elist:
542     if e.count > 0:
543         nempl += 1
544 reports.Write('Processed %d csets from %d developers\n' % (CSCount,
545                                                             ndev))
546 reports.Write('%d employers found\n' % (nempl))
547 reports.Write('A total of %d lines added, %d removed (delta %d)\n' %
548               (TotalAdded, TotalRemoved, TotalAdded - TotalRemoved))
549 if TotalChanged == 0:
550     TotalChanged = 1 # HACK to avoid div by zero
551 if DateStats:
552     PrintDateStats()
554 if CSVPrefix:
555     csvdump.save_csv(CSVPrefix)
557 if CSVFile:
558     csvdump.OutputCSV(CSVFile)
559     CSVFile.close()
561 if DevReports:
562     reports.DevReports(hlist, TotalChanged, CSCount, TotalRemoved)
563 if ReportUnknowns:
564     reports.ReportUnknowns(hlist, CSCount)
565 reports.EmplReports(elist, TotalChanged, CSCount)
567 if ReportByFileType and Numstat:
568     reports.ReportByFileType(hlist)
570 if FileReport:
571     reports.FileAccessReport(FileReport, FileAccesses, CSCount)