6181c751ca9c8d09b9d37024ab7ed9e1e8a48a61
[mono.git] / scripts / commits-to-changelog.py
1 #!/usr/bin/python
2
3 from __future__ import print_function
4 from optparse import OptionParser
5 import subprocess
6 import re
7 import os.path
8 import fnmatch
9 import os
10 import sys
11
12 # subtract 8 for the leading tabstop
13 fill_column = 74 - 8
14
15 path_to_root = None
16
17 all_changelogs = {}
18
19 def git (command, *args):
20     popen = subprocess.Popen (["git", command] + list (args), stdout = subprocess.PIPE)
21     output = popen.communicate () [0]
22     if popen.returncode != 0:
23         print ("Error: git failed", file=sys.stderr)
24         exit (1)
25     return output
26
27 def changelog_path (changelog):
28     global path_to_root
29     if not path_to_root:
30         path_to_root = git ("rev-parse", "--show-cdup").strip ()
31     (pathname, filename) = changelog
32     return path_to_root + "./" + pathname + "/" + filename
33
34 def changelog_for_file (filename):
35     while filename != "":
36         dirname = os.path.dirname (filename)
37         if dirname in all_changelogs:
38             return (dirname, all_changelogs [dirname])
39         filename = dirname
40     assert False
41
42 def changelogs_for_file_pattern (pattern, changed_files):
43     changelogs = set ()
44     for filename in changed_files:
45         suffix = filename
46         while suffix != "":
47             # FIXME: fnmatch doesn't support the {x,y} pattern
48             if fnmatch.fnmatch (suffix, pattern):
49                 changelogs.add (changelog_for_file (filename))
50             (_, _, suffix) = suffix.partition ("/")
51     return changelogs
52
53 def format_paragraph (paragraph):
54     lines = []
55     words = paragraph.split ()
56     if len (words) == 0:
57         return lines
58     current = words [0]
59     for word in words [1:]:
60         if len (current) + 1 + len (word) <= fill_column:
61             current += " " + word
62         else:
63             lines.append ("\t" + current)
64             current = word
65     lines.append ("\t" + current)
66     return lines
67
68 def format_changelog_paragraph (files, paragraph):
69     files_string = ""
70     for (filename, entity) in files:
71         if len (files_string) > 0:
72             files_string += ", "
73         files_string += filename
74         if entity:
75             files_string += " (" + entity + ")"
76     return format_paragraph ("* " + files_string + ": " + paragraph)
77
78 def append_paragraph (lines, paragraph):
79     if len (lines):
80         lines.append ("")
81     lines += paragraph
82
83 def format_changelog_entries (commit, changed_files, prefix, file_entries, all_paragraphs):
84     changelogs = set ()
85     for f in changed_files:
86         changelogs.add (changelog_for_file (f))
87     marked_changelogs = set ()
88
89     author_line = git ("log", "-n1", "--date=short", "--format=%ad  %an  <%ae>", commit).strip ()
90
91     paragraphs = {}
92     for changelog in changelogs:
93         paragraphs [changelog] = [author_line]
94
95     for (files, comments) in file_entries:
96         changelog_entries = {}
97         for (filename, entity) in files:
98             entry_changelogs = changelogs_for_file_pattern (filename, changed_files)
99             if len (entry_changelogs) == 0:
100                 print ("Warning: could not match file {0} in commit {1}".format (filename, commit))
101             for changelog in entry_changelogs:
102                 if changelog not in changelog_entries:
103                     changelog_entries [changelog] = []
104                 changelog_entries [changelog].append ((filename, entity))
105                 marked_changelogs.add (changelog)
106
107         for (changelog, files) in changelog_entries.items ():
108             append_paragraph (paragraphs [changelog], format_changelog_paragraph (files, comments [0]))
109             for paragraph in comments [1:]:
110                 append_paragraph (paragraphs [changelog], format_paragraph (paragraph))
111
112     unmarked_changelogs = changelogs - marked_changelogs
113     for changelog in unmarked_changelogs:
114         if len (prefix) == 0:
115             print ("Warning: empty entry in {0} for commit {1}".format (changelog_path (changelog), commit))
116             insert_paragraphs = all_paragraphs
117         else:
118             insert_paragraphs = prefix
119         for paragraph in insert_paragraphs:
120             append_paragraph (paragraphs [changelog], format_paragraph (paragraph))
121
122     return paragraphs
123
124 def debug_print_commit (commit, raw_message, prefix, file_entries, changed_files, changelog_entries):
125     print ("===================== Commit")
126     print (commit)
127     print ("--------------------- RAW")
128     print (raw_message)
129     print ("--------------------- Prefix")
130     for line in prefix:
131         print (line)
132     print ("--------------------- File entries")
133     for (files, comments) in file_entries:
134         files_str = ""
135         for (filename, entity) in files:
136             if len (files_str):
137                 files_str = files_str + ", "
138             files_str = files_str + filename
139             if entity:
140                 files_str = files_str + " (" + entity + ")"
141         print files_str
142         for line in comments:
143             print ("  " + line)
144     print ("--------------------- Files touched")
145     for f in changed_files:
146         print (f)
147     print ("--------------------- ChangeLog entries")
148     for ((dirname, filename), lines) in changelog_entries.items ():
149         print ("{0}/{1}:".format (dirname, filename))
150         for line in lines:
151             print (line)
152
153 def process_commit (commit):
154     changed_files = map (lambda l: l.split () [2], git ("diff-tree", "--numstat", commit).splitlines () [1:])
155     if len (filter (lambda f: re.search ("(^|/)Change[Ll]og$", f), changed_files)):
156         return None
157     raw_message = git ("log", "-n1", "--format=%B", commit)
158     # filter SVN migration message
159     message = re.sub ("(^|\n)svn path=[^\n]+revision=\d+(?=$|\n)", "", raw_message)
160     # filter ChangeLog headers
161     message = re.sub ("(^|\n)\d+-\d+-\d+[ \t]+((\w|[.-])+[ \t]+)+<[^\n>]+>(?=$|\n)", "", message)
162     # filter leading whitespace
163     message = re.sub ("^\s+", "", message)
164     # filter trailing whitespace
165     message = re.sub ("\s+$", "", message)
166     # paragraphize - first remove whitespace at beginnings and ends of lines
167     message = re.sub ("[ \t]*\n[ \t]*", "\n", message)
168     # paragraphize - now replace three or more consecutive newlines with two
169     message = re.sub ("\n\n\n+", "\n\n", message)
170     # paragraphize - replace single newlines with a space
171     message = re.sub ("(?<!\n)\n(?!\n)", " ", message)
172     # paragraphize - finally, replace double newlines with single ones
173     message = re.sub ("\n\n", "\n", message)
174
175     # A list of paragraphs (strings without newlines) that occur
176     # before the first file comments
177     prefix = []
178
179     # A list of tuples of the form ([(filename, entity), ...], [paragraph, ...]).
180     #
181     # Each describes a file comment, containing multiple paragraphs.
182     # Those paragraphs belong to a list of files, each with an
183     # optional entity (usually a function name).
184     file_entries = []
185
186     current_files = None
187     current_files_comments = None
188
189     message_lines = message.splitlines ()
190     for line in message_lines:
191         if re.match ("\*\s[^:]+:", line):
192             if current_files:
193                 file_entries.append ((current_files, current_files_comments))
194
195             (files, _, comments) = line.partition (":")
196
197             current_files_comments = [comments.strip ()]
198
199             current_files = []
200             for f in re.split ("\s*,\s*", files [1:].strip ()):
201                 m = re.search ("\(([^()]+)\)$", f)
202                 if m:
203                     filename = f [:m.start (0)].strip ()
204                     entity = m.group (1).strip ()
205                 else:
206                     filename = f
207                     entity = None
208                 current_files.append ((filename, entity))
209         else:
210             if current_files:
211                 current_files_comments.append (line)
212             else:
213                 prefix.append (line)
214     if current_files:
215         file_entries.append ((current_files, current_files_comments))
216
217     changelog_entries = format_changelog_entries (commit, changed_files, prefix, file_entries, message_lines)
218
219     #debug_print_commit (commit, raw_message, prefix, file_entries, changed_files, changelog_entries)
220
221     return changelog_entries
222
223 def start_changelog (changelog):
224     full_path = changelog_path (changelog)
225     old_name = full_path + ".old"
226     os.rename (full_path, old_name)
227     return open (full_path, "w")
228
229 def finish_changelog (changelog, file):
230     old_file = open (changelog_path (changelog) + ".old")
231     file.write (old_file.read ())
232     old_file.close ()
233     file.close ()
234
235 def append_lines (file, lines):
236     for line in lines:
237         file.write (line + "\n")
238     file.write ("\n")
239
240 def main ():
241     usage = "usage: %prog [options] <start-commit>"
242     parser = OptionParser (usage)
243     parser.add_option ("-r", "--root", dest = "root", help = "Root directory of the working tree to be changed")
244     (options, args) = parser.parse_args ()
245     if len (args) != 1:
246         parser.error ("incorrect number of arguments")
247     start_commit = args [0]
248
249     if options.root:
250         global path_to_root
251         path_to_root = options.root + "/"
252
253     # MonkeyWrench uses a shared git repo but sets BUILD_REVISION,
254     # if present we use it instead of HEAD
255     HEAD = "HEAD"
256     if 'BUILD_REVISION' in os.environ:
257         HEAD = os.environ['BUILD_REVISION']
258
259     #see if git supports %B in --format
260     output = git ("log", "-n1", "--format=%B", HEAD)
261     if output.startswith ("%B"):
262         print ("Error: git doesn't support %B in --format - install version 1.7.2 or newer", file=sys.stderr)
263         exit (1)
264
265     for filename in git ("ls-tree", "-r", "--name-only", HEAD).splitlines ():
266         if re.search ("(^|/)Change[Ll]og$", filename):
267             (path, name) = os.path.split (filename)
268             all_changelogs [path] = name
269
270     commits = git ("rev-list", "--no-merges", HEAD, "^{0}".format (start_commit)).splitlines ()
271
272     touched_changelogs = {}
273     for commit in commits:
274         entries = process_commit (commit)
275         if entries == None:
276             continue
277         for (changelog, lines) in entries.items ():
278             if not os.path.exists (changelog_path (changelog)):
279                 continue
280             if changelog not in touched_changelogs:
281                 touched_changelogs [changelog] = start_changelog (changelog)
282             append_lines (touched_changelogs [changelog], lines)
283     for (changelog, file) in touched_changelogs.items ():
284         finish_changelog (changelog, file)
285
286 if __name__ == "__main__":
287     main ()