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