#!/usr/bin/env python import argparse import os import re import subprocess import sys def guess_base_commit(repo): """Guess which commit the patches might be based on""" args = [ 'git', '-C', repo, 'describe', '--tags', ] return subprocess.check_output(args).rsplit('-', 2)[0:2] def format_patch(repo, since): args = [ 'git', '-C', repo, 'format-patch', '--keep-subject', '--no-stat', '--stdout', # Per RFC 3676 the signature is separated from the body by a line with # '-- ' on it. If the signature option is omitted the signature defaults # to the Git version number. '--no-signature', # The name of the parent commit object isn't useful information in this # context, so zero it out to avoid needless patch-file churn. '--zero-commit', # Some versions of git print out different numbers of characters in the # 'index' line of patches, so pass --full-index to get consistent # behaviour. '--full-index', since ] return subprocess.check_output(args) def split_patches(patch_data): """Split a concatenated series of patches into N separate patches""" patches = [] patch_start = re.compile('^From [0-9a-f]+ ') for line in patch_data.splitlines(): if patch_start.match(line): patches.append([]) patches[-1].append(line) return patches def munge_subject_to_filename(subject): """Derive a suitable filename from a commit's subject""" if subject.endswith('.patch'): subject = subject[:-6] return re.sub(r'[^A-Za-z0-9-]+', '_', subject).strip('_').lower() + '.patch' def get_file_name(patch): """Return the name of the file to which the patch should be written""" for line in patch: if line.startswith('Patch-Filename: '): return line[len('Patch-Filename: '):] # If no patch-filename header, munge the subject. for line in patch: if line.startswith('Subject: '): return munge_subject_to_filename(line[len('Subject: '):]) def remove_patch_filename(patch): """Strip out the Patch-Filename trailer from a patch's message body""" force_keep_next_line = False for i, l in enumerate(patch): is_patchfilename = l.startswith('Patch-Filename: ') next_is_patchfilename = i < len(patch) - 1 and patch[i+1].startswith('Patch-Filename: ') if not force_keep_next_line and (is_patchfilename or (next_is_patchfilename and len(l.rstrip()) == 0)): pass # drop this line else: yield l force_keep_next_line = l.startswith('Subject: ') def main(argv): parser = argparse.ArgumentParser() parser.add_argument("-o", "--output", help="directory into which exported patches will be written", required=True) parser.add_argument("patch_range", nargs='?', help="range of patches to export. Defaults to all commits since the " "most recent tag or remote branch.") args = parser.parse_args(argv) repo = '.' patch_range = args.patch_range if patch_range is None: patch_range, num_patches = guess_base_commit(repo) sys.stderr.write("Exporting {} patches since {}\n".format(num_patches, patch_range)) patch_data = format_patch(repo, patch_range) patches = split_patches(patch_data) out_dir = args.output try: os.mkdir(out_dir) except OSError: pass # remove old patches, so that deleted commits are correctly reflected in the # patch files (as a removed file) for p in os.listdir(out_dir): if p.endswith('.patch'): os.remove(os.path.join(out_dir, p)) with open(os.path.join(out_dir, '.patches'), 'w') as pl: for patch in patches: filename = get_file_name(patch) with open(os.path.join(out_dir, filename), 'w') as f: f.write('\n'.join(remove_patch_filename(patch)).rstrip('\n') + '\n') pl.write(filename + '\n') if __name__ == '__main__': main(sys.argv[1:])