git-export-patches 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. #!/usr/bin/env python
  2. import argparse
  3. import os
  4. import re
  5. import subprocess
  6. import sys
  7. def guess_base_commit(repo):
  8. """Guess which commit the patches might be based on"""
  9. try:
  10. args = [
  11. 'git',
  12. '-C',
  13. repo,
  14. 'rev-parse',
  15. '--verify',
  16. 'refs/patches/upstream-head',
  17. ]
  18. upstream_head = subprocess.check_output(args).strip()
  19. args = [
  20. 'git',
  21. '-C',
  22. repo,
  23. 'rev-list',
  24. '--count',
  25. upstream_head + '..',
  26. ]
  27. num_commits = subprocess.check_output(args).strip()
  28. return [upstream_head, num_commits]
  29. except subprocess.CalledProcessError:
  30. args = [
  31. 'git',
  32. '-C',
  33. repo,
  34. 'describe',
  35. '--tags',
  36. ]
  37. return subprocess.check_output(args).rsplit('-', 2)[0:2]
  38. def format_patch(repo, since):
  39. args = [
  40. 'git',
  41. '-C',
  42. repo,
  43. '-c',
  44. 'core.attributesfile=' + os.path.join(os.path.dirname(os.path.realpath(__file__)), '.electron.attributes'),
  45. # Ensure it is not possible to match anything
  46. # Disabled for now as we have consistent chunk headers
  47. # '-c',
  48. # 'diff.electron.xfuncname=$^',
  49. 'format-patch',
  50. '--keep-subject',
  51. '--no-stat',
  52. '--stdout',
  53. # Per RFC 3676 the signature is separated from the body by a line with
  54. # '-- ' on it. If the signature option is omitted the signature defaults
  55. # to the Git version number.
  56. '--no-signature',
  57. # The name of the parent commit object isn't useful information in this
  58. # context, so zero it out to avoid needless patch-file churn.
  59. '--zero-commit',
  60. # Some versions of git print out different numbers of characters in the
  61. # 'index' line of patches, so pass --full-index to get consistent
  62. # behaviour.
  63. '--full-index',
  64. since
  65. ]
  66. return subprocess.check_output(args)
  67. def split_patches(patch_data):
  68. """Split a concatenated series of patches into N separate patches"""
  69. patches = []
  70. patch_start = re.compile('^From [0-9a-f]+ ')
  71. for line in patch_data.splitlines():
  72. if patch_start.match(line):
  73. patches.append([])
  74. patches[-1].append(line)
  75. return patches
  76. def munge_subject_to_filename(subject):
  77. """Derive a suitable filename from a commit's subject"""
  78. if subject.endswith('.patch'):
  79. subject = subject[:-6]
  80. return re.sub(r'[^A-Za-z0-9-]+', '_', subject).strip('_').lower() + '.patch'
  81. def get_file_name(patch):
  82. """Return the name of the file to which the patch should be written"""
  83. for line in patch:
  84. if line.startswith('Patch-Filename: '):
  85. return line[len('Patch-Filename: '):]
  86. # If no patch-filename header, munge the subject.
  87. for line in patch:
  88. if line.startswith('Subject: '):
  89. return munge_subject_to_filename(line[len('Subject: '):])
  90. def remove_patch_filename(patch):
  91. """Strip out the Patch-Filename trailer from a patch's message body"""
  92. force_keep_next_line = False
  93. for i, l in enumerate(patch):
  94. is_patchfilename = l.startswith('Patch-Filename: ')
  95. next_is_patchfilename = i < len(patch) - 1 and patch[i+1].startswith('Patch-Filename: ')
  96. if not force_keep_next_line and (is_patchfilename or (next_is_patchfilename and len(l.rstrip()) == 0)):
  97. pass # drop this line
  98. else:
  99. yield l
  100. force_keep_next_line = l.startswith('Subject: ')
  101. def main(argv):
  102. parser = argparse.ArgumentParser()
  103. parser.add_argument("-o", "--output",
  104. help="directory into which exported patches will be written",
  105. required=True)
  106. parser.add_argument("patch_range",
  107. nargs='?',
  108. help="range of patches to export. Defaults to all commits since the "
  109. "most recent tag or remote branch.")
  110. args = parser.parse_args(argv)
  111. repo = '.'
  112. patch_range = args.patch_range
  113. if patch_range is None:
  114. patch_range, num_patches = guess_base_commit(repo)
  115. sys.stderr.write("Exporting {} patches since {}\n".format(num_patches, patch_range))
  116. patch_data = format_patch(repo, patch_range)
  117. patches = split_patches(patch_data)
  118. out_dir = args.output
  119. try:
  120. os.mkdir(out_dir)
  121. except OSError:
  122. pass
  123. # remove old patches, so that deleted commits are correctly reflected in the
  124. # patch files (as a removed file)
  125. for p in os.listdir(out_dir):
  126. if p.endswith('.patch'):
  127. os.remove(os.path.join(out_dir, p))
  128. with open(os.path.join(out_dir, '.patches'), 'w') as pl:
  129. for patch in patches:
  130. filename = get_file_name(patch)
  131. with open(os.path.join(out_dir, filename), 'w') as f:
  132. f.write('\n'.join(remove_patch_filename(patch)).rstrip('\n') + '\n')
  133. pl.write(filename + '\n')
  134. if __name__ == '__main__':
  135. main(sys.argv[1:])