Blob Blame History Raw
#!/usr/bin/env python

# Copyright (C) 2004 Andrea Arcangeli <andrea@suse.de> SUSE
# $Id: mkpatch.py,v 1.20 2005/06/07 17:22:58 andrea Exp $

# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

# You can copy or symlink this script into ~/bin and
# your ~/.signedoffby file should contain a string like this:
# "Signed-off-by: Andrea Arcangeli <andrea@suse.de>"

# Usage is intuitive like this:
#	./mkpatch.py # without parameter search the backup in current dir
#	./mkpatch.py dir2 # this search the backups in dir2
#	./mkpatch.py dir1 dir2
#	./mkpatch.py dir2 destination-patchfile
#	./mkpatch.py dir1 dir2 destination-patchfile
#	./mkpatch.py destination-patchfile # this will only parse patchfile

# There are three options: -n, -s, -a (alias respectively to
# --no-signoff, --signoff and --acked). If you're only rediffing
# the patch you can use '-n' to avoid altering the signoff list.
# If you're instead only reviewing the patch you can use '-a'
# to add an Acked-by, instead of a Signed-off-by. You can use
# bash alias with bash 'alias mkpatch.py=mkpatch.py -a' if you
# only review patches, or you can use -n instead if you only
# regenerate patches without even reviewing them. You can always
# force a signoff by using -a or -s. The last option mode overrides
# any previous signoff mode. The default is '-s' (aka '--signoff').

# If you miss the ~/.signedoffby file, '-n' (aka '--no-signoff')
# behaviour will be forced.

import sys, os, re, readline, getopt
from rfc822 import Message

TAGS = (
	'From',
	'Subject',
	'Patch-mainline',
	'References',
	)

DIFF_CMD = 'diff -urNp --exclude CVS --exclude BitKeeper --exclude {arch} --exclude .arch-ids --exclude .svn --exclude .git --exclude .hg'
SIGNOFF_FILE = '~/.signedoffby'

class signoff_mode_class(object):
	signedoffby = 'Signed-off-by: '
	ackedby = 'Acked-by: '

	def __init__(self):
		self.mode = 0
		self.my_signoff = None
	def signoff(self):
		self.mode = 0
	def no_signoff(self):
		self.mode = 1
	def acked(self):
		self.mode = 2
	def is_acked(self):
		return self.mode == 2
	def is_signingoff(self):
		return self.mode == 0
	def is_enabled(self):
		return self.is_signingoff() or self.is_acked()
	def change_prefix(self, prefix, signoff):
		if self.my_signoff is None:
			return prefix
		if self.is_signingoff():
			if signoff == self.my_signoff:
				return self.signedoffby
		elif self.is_acked():
			if signoff == self.my_signoff:
				return self.ackedby
		return prefix

class tag_class(object):
	def __init__(self, name):
		self.name = name
		self.regexp = re.compile(name + r': (.*)', re.I)
		self.value = ''

	def parse(self, line, message):
		header = message.isheader(line)
		if header and header.lower() == self.name.lower():
			header = message.getheader(header)
			if header:
				self.value = header
				return len(self.value.split('\n'))

	def ask_value(self):
		self.value = ''
		first = 1
		while 1:
			this_value = raw_input('%s: ' % self.name)
			if not this_value:
				break
			if not first:
				self.value += '\n '
			first = 0
			self.value += this_value

class patch_class(object):
	def __init__(self, patchfile, signoff_mode):
		self.patchfile = patchfile
		try:
			self.message = Message(file(patchfile))
		except IOError:
			self.message = None
		self.signoff_mode = signoff_mode
		self.prepare()
		self.read()

	def prepare(self):
		readline.add_history(os.path.basename(self.patchfile))
		readline.add_history('yes'); readline.add_history('no')

		my_signoff = None
		self.re_signoff = re.compile(self.signoff_mode.signedoffby + r'(.*@.*)', re.I)
		self.re_ackedby = re.compile(self.signoff_mode.ackedby + r'(.*@.*)', re.I)
		try:
			signoff = file(os.path.expanduser(SIGNOFF_FILE)).readline()
		except IOError:
			pass
		else:
			m = self.re_signoff.search(signoff)
			if m:
				my_signoff = m.group(1)
				readline.add_history(my_signoff)

		if not my_signoff:
			self.signoff_mode.no_signoff()
		else:
			self.signoff_mode.my_signoff = my_signoff

		self.tags = []
		for tag in TAGS:
			self.tags.append(tag_class(tag))

		self.signedoffby = self.signoff_mode.signedoffby
		self.ackedby = self.signoff_mode.ackedby

	def parse_metadata(self, line):
		# grab bk metadata and convert into valid header
		m = self.re_signoff.search(line)
		prefix = self.signedoffby
		if not m:
			prefix = self.ackedby
			m = self.re_ackedby.search(line)
		if m:
			this_signoff = m.group(1)
			if this_signoff not in self.signoff:
				self.signoff[this_signoff] = prefix
				self.signoff_order.append(this_signoff)
			return

		if self.message:
			for tag in self.tags:
				ret = tag.parse(line, self.message)
				if ret:
					return ret

	def read(self):
		self.metadata = ''
		self.signoff = {}
		self.signoff_order = []
		self.__payload = ''

		try:
			patch = file(self.patchfile, 'r')
		except IOError:
			pass
		else:
			re_index = re.compile(r'Index: .*')
			re_bk = re.compile(r'=====.*vs.*=====')
			re_diff = re.compile(r'diff .*')
			re_plus = re.compile(r'--- .*')
			re_empty = re.compile(r'^\s*$')

			re_signoff = self.re_signoff
			re_ackedby = self.re_ackedby

			emptylines = ''
			headers = 1
			state = 'is_metadata'
			while 1:
				line = patch.readline()
				if not line:
					break

				if re_diff.match(line) or re_plus.match(line) or \
				       re_index.match(line) or re_bk.match(line):
					state = 'is_payload'
				elif state == 'is_metadata' and (re_signoff.match(line) or
								 re_ackedby.match(line)):
					state = 'is_signoff'

				if state == 'is_metadata':
					if re_empty.search(line):
						emptylines += '\n'
					else:
						nr_lines = self.parse_metadata(line)
						if type(nr_lines) == int:
							for i in xrange(1, nr_lines):
								patch.readline()
						if not headers or not nr_lines:
							if headers:
								emptylines = ''
								headers = 0
							self.metadata += emptylines + line
							emptylines = ''
				elif state == 'is_signoff':
					m = self.re_signoff.match(line)
					prefix = self.signedoffby
					if not m:
						prefix = self.ackedby
						m = self.re_ackedby.match(line)
					if m:
						this_signoff = m.group(1)
						if this_signoff not in self.signoff:
							self.signoff[this_signoff] = prefix
							self.signoff_order.append(this_signoff)
				elif state == 'is_payload':
					self.__payload += line
				else:
					raise 'unknown state'

	def ask_empty_tags(self):
		for tag in self.tags:
			if not tag.value:
				tag.ask_value()

	def get_tags(self):
		ret = ''
		for tag in self.tags:
			if tag.value:
				ret += tag.name + ': ' + tag.value + '\n'
		return ret

	def get_signoff(self):
		ret = ''
		for signoff in self.signoff_order:
			prefix = self.signoff[signoff]
			prefix = self.signoff_mode.change_prefix(prefix, signoff)
			ret += prefix + signoff + '\n'
		my_signoff = self.signoff_mode.my_signoff
		if self.signoff_mode.is_enabled() and \
		       my_signoff and my_signoff not in self.signoff:
			prefix = self.signoff_mode.change_prefix(None, my_signoff)
			ret += prefix + my_signoff + '\n'
		return ret

	def write(self):
		tags = self.get_tags()
		if tags:
			tags += '\n'
		metadata = self.metadata
		if metadata:
			metadata += '\n'
		signoff = self.get_signoff()
		if signoff:
			signoff += '\n'
		payload = self.payload
		try:
			os.unlink(self.patchfile) # handle links
		except OSError:
			pass
		file(self.patchfile, 'w').write(tags + metadata + signoff + payload)

	def get_payload(self):
		return self.__payload

	def set_payload(self, value):
		if value is not None:
			self.__payload = cleanup_patch(value)

	payload = property(get_payload, set_payload)

def cleanup_patch(patch):
	diffline = re.compile(DIFF_CMD + r'.*')
	ret = ''
	for line in re.split('\n', patch):
		if line and not diffline.match(line):
			ret += line + '\n'
	stdin, stdout = os.popen2('diffstat')
	print >>stdin, ret,
	stdin.close()
	diffstat = stdout.read()
	if not diffstat:
		raise 'no diffstat'
	return diffstat + '\n' + ret

def replace_diff(diff, patchfile, signoff_mode):
	patch = patch_class(patchfile, signoff_mode)
	patch.payload = diff
	patch.ask_empty_tags()
	patch.write()

def mkpatch(*args):
	# parse opts
	try:
		opts, args = getopt.getopt(args, 'nas', ( 'no-signoff', 'acked', 'signoff', ))
	except getopt.GetoptError:
		raise 'EINVAL'
	signoff_mode = signoff_mode_class()
	for opt, arg in opts:
		if opt in ('-n', '--no-signoff', ):
			signoff_mode.no_signoff()
		elif opt in ('-a', '--acked', ):
			signoff_mode.acked()
		elif opt in ('-s', '--signoff', ):
			signoff_mode.signoff()

	# parse args
	nr_args = len(args)
	def cleanup_path(args):
		return map(os.path.normpath, map(os.path.expanduser, args))
	if nr_args > 3:
		raise 'EINVAL'
	elif nr_args == 0:
		olddir = None
		newdir = '.'
		patchfile = None
	elif nr_args == 1:
		olddir = None
		newdir, = cleanup_path(args)
		patchfile = None
	elif nr_args == 2:
		olddir = None
		newdir, patchfile = cleanup_path(args)
	elif nr_args == 3:
		olddir, newdir, patchfile = cleanup_path(args)

	#print olddir, newdir, patchfile
	if olddir and not os.path.isdir(olddir):
		print >>sys.stderr, 'olddir must be a directory'
		raise 'EINVAL'
	elif not os.path.isdir(newdir):
		if not os.path.isfile(newdir):
			print >>sys.stderr, 'newdir must be a directory or a file'
			raise 'EINVAL'
		olddir, newdir, patchfile = (None, None, newdir, )
	elif patchfile and os.path.isdir(patchfile):
		olddir = newdir
		newdir = patchfile
		patchfile = None
	#print olddir, newdir, patchfile

	diff = None
	if not olddir and newdir:
		# use backup files
		print >>sys.stderr, 'Searching backup files in %s ...' % newdir,
		find = os.popen('find %s -type f \( -name \*~ -or -name \*.orig \) 2>/dev/null' % newdir, 'r')
		files = find.readlines()
		if files:
			print >>sys.stderr, 'done.'
		else:
			print >>sys.stderr, 'none found.'

		diff = ''
		already_diffed = {}
		for backup_f in files:
			new_f = None
			backup_f = backup_f[:-1]
			backup_f = os.path.normpath(backup_f)
			if backup_f[-5:] == '.orig':
				new_f = backup_f[:-5]
			elif backup_f[-4:] == '.~1~':
				new_f = backup_f[:-4]
			elif backup_f[-1:] == '~':
				new_f = backup_f[:-1]

			if new_f:
				if not os.path.isfile(new_f):
					continue
				if new_f in already_diffed:
					continue
				already_diffed[new_f] = 0
				print >>sys.stderr, 'Diffing %s...' % new_f,
				this_diff = os.popen(DIFF_CMD + ' %s %s' % (backup_f, new_f) + ' 2>/dev/null').read()
				if this_diff:
					diff += 'Index: ' + new_f + '\n' + this_diff
				if this_diff:
					print >>sys.stderr, 'done.'
				else:
					print >>sys.stderr, 'unchanged.'
	elif olddir and newdir:
		# use two directories
		print >>sys.stderr, 'Creating diff between %s and %s ...' % (olddir, newdir),
		diff = os.popen(DIFF_CMD + ' %s %s' % (olddir, newdir) + ' 2>/dev/null', 'r').read()
		print >>sys.stderr, 'done.'

	if patchfile:
		replace_diff(diff, patchfile, signoff_mode)
		os.execvp('vi', ('vi', '-c', 'set tw=72', patchfile, ))
	else:
		if diff:
			print cleanup_patch(diff),

if __name__ == '__main__':
	try:
		mkpatch(*sys.argv[1:])
	except 'EINVAL':
		print >>sys.stderr, 'Usage:', sys.argv[0], \
		      '[-a|--acked] [-n|--no-signoff] [-s|--signoff] [olddir] [newdir] [patch]'