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