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