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