git-export-patches 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  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. args = [
  10. 'git',
  11. '-C',
  12. repo,
  13. 'describe',
  14. '--tags',
  15. ]
  16. return subprocess.check_output(args).split('-')[0:2]
  17. def format_patch(repo, since):
  18. args = [
  19. 'git',
  20. '-C',
  21. repo,
  22. 'format-patch',
  23. '--keep-subject',
  24. '--no-stat',
  25. '--stdout',
  26. # Per RFC 3676 the signature is separated from the body by a line with
  27. # '-- ' on it. If the signature option is omitted the signature defaults
  28. # to the Git version number.
  29. '--no-signature',
  30. # The name of the parent commit object isn't useful information in this
  31. # context, so zero it out to avoid needless patch-file churn.
  32. '--zero-commit',
  33. # Some versions of git print out different numbers of characters in the
  34. # 'index' line of patches, so pass --full-index to get consistent
  35. # behaviour.
  36. '--full-index',
  37. since
  38. ]
  39. return subprocess.check_output(args)
  40. def split_patches(patch_data):
  41. """Split a concatenated series of patches into N separate patches"""
  42. patches = []
  43. patch_start = re.compile('^From [0-9a-f]+ ')
  44. for line in patch_data.splitlines():
  45. if patch_start.match(line):
  46. patches.append([])
  47. patches[-1].append(line)
  48. return patches
  49. def munge_subject_to_filename(subject):
  50. """Derive a suitable filename from a commit's subject"""
  51. if subject.endswith('.patch'):
  52. subject = subject[:-6]
  53. return re.sub(r'[^A-Za-z0-9-]+', '_', subject).strip('_').lower() + '.patch'
  54. def get_file_name(patch):
  55. """Return the name of the file to which the patch should be written"""
  56. for line in patch:
  57. if line.startswith('Patch-Filename: '):
  58. return line[len('Patch-Filename: '):]
  59. # If no patch-filename header, munge the subject.
  60. for line in patch:
  61. if line.startswith('Subject: '):
  62. return munge_subject_to_filename(line[len('Subject: '):])
  63. def remove_patch_filename(patch):
  64. """Strip out the Patch-Filename trailer from a patch's message body"""
  65. force_keep_next_line = False
  66. for i, l in enumerate(patch):
  67. is_patchfilename = l.startswith('Patch-Filename: ')
  68. next_is_patchfilename = i < len(patch) - 1 and patch[i+1].startswith('Patch-Filename: ')
  69. if not force_keep_next_line and (is_patchfilename or (next_is_patchfilename and len(l.rstrip()) == 0)):
  70. pass # drop this line
  71. else:
  72. yield l
  73. force_keep_next_line = l.startswith('Subject: ')
  74. def main(argv):
  75. parser = argparse.ArgumentParser()
  76. parser.add_argument("-o", "--output",
  77. help="directory into which exported patches will be written")
  78. parser.add_argument("patch_range",
  79. nargs='?',
  80. help="range of patches to export. Defaults to all commits since the "
  81. "most recent tag or remote branch.")
  82. args = parser.parse_args(argv)
  83. repo = '.'
  84. patch_range = args.patch_range
  85. if patch_range is None:
  86. patch_range, num_patches = guess_base_commit(repo)
  87. sys.stderr.write("Exporting {} patches since {}\n".format(num_patches, patch_range))
  88. patch_data = format_patch(repo, patch_range)
  89. patches = split_patches(patch_data)
  90. out_dir = args.output
  91. try:
  92. os.mkdir(out_dir)
  93. except OSError:
  94. pass
  95. # remove old patches, so that deleted commits are correctly reflected in the
  96. # patch files (as a removed file)
  97. for p in os.listdir(out_dir):
  98. if p.endswith('.patch'):
  99. os.remove(os.path.join(out_dir, p))
  100. with open(os.path.join(out_dir, '.patches'), 'w') as pl:
  101. for patch in patches:
  102. filename = get_file_name(patch)
  103. with open(os.path.join(out_dir, filename), 'w') as f:
  104. f.write('\n'.join(remove_patch_filename(patch)).rstrip('\n') + '\n')
  105. pl.write(filename + '\n')
  106. if __name__ == '__main__':
  107. main(sys.argv[1:])