#!/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).split('-')[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")
  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:])
