Merge pull request #1100 from BrzVlad/master
[mono-project.git] / scripts / commits-to-changelog.py
blob6181c751ca9c8d09b9d37024ab7ed9e1e8a48a61
1 #!/usr/bin/python
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
12 # subtract 8 for the leading tabstop
13 fill_column = 74 - 8
15 path_to_root = None
17 all_changelogs = {}
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
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
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
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
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
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)
78 def append_paragraph (lines, paragraph):
79 if len (lines):
80 lines.append ("")
81 lines += paragraph
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 ()
89 author_line = git ("log", "-n1", "--date=short", "--format=%ad %an <%ae>", commit).strip ()
91 paragraphs = {}
92 for changelog in changelogs:
93 paragraphs [changelog] = [author_line]
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)
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))
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))
122 return paragraphs
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)
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)
175 # A list of paragraphs (strings without newlines) that occur
176 # before the first file comments
177 prefix = []
179 # A list of tuples of the form ([(filename, entity), ...], [paragraph, ...]).
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 = []
186 current_files = None
187 current_files_comments = None
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))
195 (files, _, comments) = line.partition (":")
197 current_files_comments = [comments.strip ()]
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))
217 changelog_entries = format_changelog_entries (commit, changed_files, prefix, file_entries, message_lines)
219 #debug_print_commit (commit, raw_message, prefix, file_entries, changed_files, changelog_entries)
221 return changelog_entries
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")
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 ()
235 def append_lines (file, lines):
236 for line in lines:
237 file.write (line + "\n")
238 file.write ("\n")
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]
249 if options.root:
250 global path_to_root
251 path_to_root = options.root + "/"
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']
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)
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
270 commits = git ("rev-list", "--no-merges", HEAD, "^{0}".format (start_commit)).splitlines ()
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)
286 if __name__ == "__main__":
287 main ()