PageRenderTime 56ms CodeModel.GetById 32ms RepoModel.GetById 0ms app.codeStats 1ms

/scripts/commits-to-changelog.py

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