"make dist" updates the ChangeLogs automatically.
[mono-project/dkf.git] / scripts / commits-to-changelog.py
blob38de6db57a4889812bf826ff28c1c4b86996ca21
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
10 # subtract 8 for the leading tabstop
11 fill_column = 74 - 8
13 path_to_root = None
15 all_changelogs = {}
17 def git (command, *args):
18 return subprocess.Popen (["git", command] + list (args), stdout = subprocess.PIPE).communicate () [0]
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
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
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
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
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)
69 def append_paragraph (lines, paragraph):
70 if len (lines):
71 lines.append ("")
72 lines += paragraph
74 def format_changelog_entries (commit, changed_files, prefix, file_entries):
75 changelogs = set ()
76 for f in changed_files:
77 changelogs.add (changelog_for_file (f))
78 marked_changelogs = set ()
80 author_line = git ("log", "-n1", "--date=short", "--format=%ad %an <%ae>", commit).strip ()
82 paragraphs = {}
83 for changelog in changelogs:
84 paragraphs [changelog] = [author_line]
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)
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))
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 append_paragraph (paragraphs [changelog], format_paragraph ("FIXME: empty entry!"))
108 for paragraph in prefix:
109 append_paragraph (paragraphs [changelog], format_paragraph (paragraph))
111 return paragraphs
113 def debug_print_commit (commit, raw_message, prefix, file_entries, changed_files, changelog_entries):
114 print "===================== Commit"
115 print commit
116 print "--------------------- RAW"
117 print raw_message
118 print "--------------------- Prefix"
119 for line in prefix:
120 print line
121 print "--------------------- File entries"
122 for (files, comments) in file_entries:
123 files_str = ""
124 for (filename, entity) in files:
125 if len (files_str):
126 files_str = files_str + ", "
127 files_str = files_str + filename
128 if entity:
129 files_str = files_str + " (" + entity + ")"
130 print files_str
131 for line in comments:
132 print " " + line
133 print "--------------------- Files touched"
134 for f in changed_files:
135 print f
136 print "--------------------- ChangeLog entries"
137 for ((dirname, filename), lines) in changelog_entries.items ():
138 print "%s/%s:" % (dirname, filename)
139 for line in lines:
140 print line
142 def process_commit (commit):
143 changed_files = map (lambda l: l.split () [2], git ("diff-tree", "--numstat", commit).splitlines () [1:])
144 if len (filter (lambda f: re.search ("(^|/)Change[Ll]og$", f), changed_files)):
145 return
146 raw_message = git ("log", "-n1", "--format=%B", commit)
147 # filter SVN migration message
148 message = re.sub ("(^|\n)svn path=[^\n]+revision=\d+(?=$|\n)", "", raw_message)
149 # filter ChangeLog headers
150 message = re.sub ("(^|\n)\d+-\d+-\d+[ \t]+((\w|[.-])+[ \t]+)+<[^\n>]+>(?=$|\n)", "", message)
151 # filter leading whitespace
152 message = re.sub ("^\s+", "", message)
153 # filter trailing whitespace
154 message = re.sub ("\s+$", "", message)
155 # paragraphize - first remove whitespace at beginnings and ends of lines
156 message = re.sub ("[ \t]*\n[ \t]*", "\n", message)
157 # paragraphize - now replace three or more consecutive newlines with two
158 message = re.sub ("\n\n\n+", "\n\n", message)
159 # paragraphize - replace single newlines with a space
160 message = re.sub ("(?<!\n)\n(?!\n)", " ", message)
161 # paragraphize - finally, replace double newlines with single ones
162 message = re.sub ("\n\n", "\n", message)
164 # A list of paragraphs (strings without newlines) that occur
165 # before the first file comments
166 prefix = []
168 # A list of tuples of the form ([(filename, entity), ...], [paragraph, ...]).
170 # Each describes a file comment, containing multiple paragraphs.
171 # Those paragraphs belong to a list of files, each with an
172 # optional entity (usually a function name).
173 file_entries = []
175 current_files = None
176 current_files_comments = None
178 for line in message.splitlines ():
179 if re.match ("\*\s[^:]+:", line):
180 if current_files:
181 file_entries.append ((current_files, current_files_comments))
183 (files, _, comments) = line.partition (":")
185 current_files_comments = [comments.strip ()]
187 current_files = []
188 for f in re.split ("\s*,\s*", files [1:].strip ()):
189 m = re.search ("\(([^()]+)\)$", f)
190 if m:
191 filename = f [:m.start (0)].strip ()
192 entity = m.group (1).strip ()
193 else:
194 filename = f
195 entity = None
196 current_files.append ((filename, entity))
197 else:
198 if current_files:
199 current_files_comments.append (line)
200 else:
201 prefix.append (line)
202 if current_files:
203 file_entries.append ((current_files, current_files_comments))
205 changelog_entries = format_changelog_entries (commit, changed_files, prefix, file_entries)
207 #debug_print_commit (commit, raw_message, prefix, file_entries, changed_files, changelog_entries)
209 return changelog_entries
211 def start_changelog (changelog):
212 full_path = changelog_path (changelog)
213 old_name = full_path + ".old"
214 os.rename (full_path, old_name)
215 return open (full_path, "w")
217 def finish_changelog (changelog, file):
218 old_file = open (changelog_path (changelog) + ".old")
219 file.write (old_file.read ())
220 old_file.close ()
221 file.close ()
223 def append_lines (file, lines):
224 for line in lines:
225 file.write (line + "\n")
226 file.write ("\n")
228 def main ():
229 usage = "usage: %prog [options] <start-commit>"
230 parser = OptionParser (usage)
231 parser.add_option ("-r", "--root", dest = "root", help = "Root directory of the working tree to be changed")
232 (options, args) = parser.parse_args ()
233 if len (args) != 1:
234 parser.error ("incorrect number of arguments")
235 start_commit = args [0]
237 if options.root:
238 global path_to_root
239 path_to_root = options.root + "/"
241 for filename in git ("ls-tree", "-r", "--name-only", "HEAD").splitlines ():
242 if re.search ("(^|/)Change[Ll]og$", filename):
243 (path, name) = os.path.split (filename)
244 all_changelogs [path] = name
246 commits = git ("rev-list", "--no-merges", "HEAD", "^%s" % start_commit).splitlines ()
248 touched_changelogs = {}
249 for commit in commits:
250 entries = process_commit (commit)
251 for (changelog, lines) in entries.items ():
252 if changelog not in touched_changelogs:
253 touched_changelogs [changelog] = start_changelog (changelog)
254 append_lines (touched_changelogs [changelog], lines)
255 for (changelog, file) in touched_changelogs.items ():
256 finish_changelog (changelog, file)
258 if __name__ == "__main__":
259 main ()