From: Catalin Marinas Date: Fri, 14 Sep 2007 17:51:57 +0000 (+0100) Subject: Add patch editing command X-Git-Tag: v0.14.3~121 X-Git-Url: https://git.distorted.org.uk/~mdw/stgit/commitdiff_plain/ed60fdae02d9317475eb64328ec683e1148c3f7b Add patch editing command This patch adds the 'edit' command which takes most of the similar functionality out of 'refresh'. In addition, it allows the direct patch diff editing with the '--diff' option. The patch/e-mail parsing functions were moved from stgit.commands.imprt to stgit.commands.common as they are now used by both 'import' and 'edit'. Signed-off-by: Catalin Marinas --- diff --git a/contrib/stgit-completion.bash b/contrib/stgit-completion.bash index 7ae9e6a..b1d2730 100644 --- a/contrib/stgit-completion.bash +++ b/contrib/stgit-completion.bash @@ -21,6 +21,7 @@ _stg_commands=" clone commit cp + edit export files float @@ -247,6 +248,7 @@ _stg () unhide) _stg_patches $command _hidden_patches ;; # patch commands delete) _stg_patches $command _all_patches ;; + edit) _stg_patches $command _applied_patches ;; export) _stg_patches $command _applied_patches ;; files) _stg_patches $command _all_patches ;; log) _stg_patches $command _all_patches ;; diff --git a/stgit/commands/common.py b/stgit/commands/common.py index f3fa89d..d81de26 100644 --- a/stgit/commands/common.py +++ b/stgit/commands/common.py @@ -348,3 +348,133 @@ def post_rebase(applied, nopush, merged): # push the patches back if not nopush: push_patches(applied, merged) + +# +# Patch description/e-mail/diff parsing +# +def __end_descr(line): + return re.match('---\s*$', line) or re.match('diff -', line) or \ + re.match('Index: ', line) + +def __split_descr_diff(string): + """Return the description and the diff from the given string + """ + descr = diff = '' + top = True + + for line in string.split('\n'): + if top: + if not __end_descr(line): + descr += line + '\n' + continue + else: + top = False + diff += line + '\n' + + return (descr.rstrip(), diff) + +def __parse_description(descr): + """Parse the patch description and return the new description and + author information (if any). + """ + subject = body = '' + authname = authemail = authdate = None + + descr_lines = [line.rstrip() for line in descr.split('\n')] + if not descr_lines: + raise CmdException, "Empty patch description" + + lasthdr = 0 + end = len(descr_lines) + + # Parse the patch header + for pos in range(0, end): + if not descr_lines[pos]: + continue + # check for a "From|Author:" line + if re.match('\s*(?:from|author):\s+', descr_lines[pos], re.I): + auth = re.findall('^.*?:\s+(.*)$', descr_lines[pos])[0] + authname, authemail = name_email(auth) + lasthdr = pos + 1 + continue + # check for a "Date:" line + if re.match('\s*date:\s+', descr_lines[pos], re.I): + authdate = re.findall('^.*?:\s+(.*)$', descr_lines[pos])[0] + lasthdr = pos + 1 + continue + if subject: + break + # get the subject + subject = descr_lines[pos] + lasthdr = pos + 1 + + # get the body + if lasthdr < end: + body = reduce(lambda x, y: x + '\n' + y, descr_lines[lasthdr:], '') + + return (subject + body, authname, authemail, authdate) + +def parse_mail(msg): + """Parse the message object and return (description, authname, + authemail, authdate, diff) + """ + from email.Header import decode_header, make_header + + def __decode_header(header): + """Decode a qp-encoded e-mail header as per rfc2047""" + try: + words_enc = decode_header(header) + hobj = make_header(words_enc) + except Exception, ex: + raise CmdException, 'header decoding error: %s' % str(ex) + return unicode(hobj).encode('utf-8') + + # parse the headers + if msg.has_key('from'): + authname, authemail = name_email(__decode_header(msg['from'])) + else: + authname = authemail = None + + # '\n\t' can be found on multi-line headers + descr = __decode_header(msg['subject']).replace('\n\t', ' ') + authdate = msg['date'] + + # remove the '[*PATCH*]' expression in the subject + if descr: + descr = re.findall('^(\[.*?[Pp][Aa][Tt][Cc][Hh].*?\])?\s*(.*)$', + descr)[0][1] + else: + raise CmdException, 'Subject: line not found' + + # the rest of the message + msg_text = '' + for part in msg.walk(): + if part.get_content_type() == 'text/plain': + msg_text += part.get_payload(decode = True) + + rem_descr, diff = __split_descr_diff(msg_text) + if rem_descr: + descr += '\n\n' + rem_descr + + # parse the description for author information + descr, descr_authname, descr_authemail, descr_authdate = \ + __parse_description(descr) + if descr_authname: + authname = descr_authname + if descr_authemail: + authemail = descr_authemail + if descr_authdate: + authdate = descr_authdate + + return (descr, authname, authemail, authdate, diff) + +def parse_patch(fobj): + """Parse the input file and return (description, authname, + authemail, authdate, diff) + """ + descr, diff = __split_descr_diff(fobj.read()) + descr, authname, authemail, authdate = __parse_description(descr) + + # we don't yet have an agreed place for the creation date. + # Just return None + return (descr, authname, authemail, authdate, diff) diff --git a/stgit/commands/edit.py b/stgit/commands/edit.py new file mode 100644 index 0000000..1a7b1b3 --- /dev/null +++ b/stgit/commands/edit.py @@ -0,0 +1,244 @@ +"""Patch editing command +""" + +__copyright__ = """ +Copyright (C) 2007, Catalin Marinas + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2 as +published by the Free Software Foundation. + +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 +""" + +from optparse import OptionParser, make_option +from email.Utils import formatdate + +from stgit.commands.common import * +from stgit.utils import * +from stgit.out import * +from stgit import stack, git + + +help = 'edit a patch description or diff' +usage = """%prog [options] [] + +Edit the given patch (defaulting to the current one) description, +author information or its diff (if the '--diff' option is +passed). Without any other option, the command invokes the editor with +the patch description and diff in the form below: + + Subject line + + From: author information + Date: creation date + + Patch description + + Signed-off-by: author + +Command-line options can be used to modify specific information +without invoking the editor. + +If the patch diff is edited but the patch application fails, the +rejected patch is stored in the .stgit-failed.patch file (and also in +.stgit-edit.{diff,txt}). The edited patch can be replaced with one of +these files using the '--file' and '--diff' options. +""" + +options = [make_option('-d', '--diff', + help = 'allow the editing of the patch diff', + action = 'store_true'), + make_option('-f', '--file', + help = 'use FILE instead of invoking the editor'), + make_option('-O', '--diff-opts', + help = 'options to pass to git-diff'), + make_option('--undo', + help = 'revert the commit generated by the last edit', + action = 'store_true'), + make_option('-a', '--annotate', metavar = 'NOTE', + help = 'annotate the patch log entry'), + make_option('-m', '--message', + help = 'replace the patch description with MESSAGE'), + make_option('--author', metavar = '"NAME "', + help = 'replae the author details with "NAME "'), + make_option('--authname', + help = 'replace the author name with AUTHNAME'), + make_option('--authemail', + help = 'replace the author e-mail with AUTHEMAIL'), + make_option('--authdate', + help = 'replace the author date with AUTHDATE'), + make_option('--commname', + help = 'replace the committer name with COMMNAME'), + make_option('--commemail', + help = 'replace the committer e-mail with COMMEMAIL') + ] + make_sign_options() + +def __update_patch(pname, fname, options): + """Update the current patch from the given file. + """ + patch = crt_series.get_patch(pname) + + bottom = patch.get_bottom() + top = patch.get_top() + + f = open(fname) + message, author_name, author_email, author_date, diff = parse_patch(f) + f.close() + + if options.diff: + git.switch(bottom) + try: + git.apply_patch(fname) + except: + # avoid inconsistent repository state + git.switch(top) + raise + + out.start('Updating patch "%s"' % pname) + crt_series.refresh_patch(message = message, + author_name = author_name, + author_email = author_email, + author_date = author_date, + backup = True, log = 'edit') + if crt_series.empty_patch(pname): + out.done('empty patch') + else: + out.done() + +def __edit_update_patch(pname, options): + """Edit the given patch interactively. + """ + patch = crt_series.get_patch(pname) + + if options.diff_opts: + if not options.diff: + raise CmdException, '--diff-opts only available with --diff' + diff_flags = options.diff_opts.split() + else: + diff_flags = [] + + # generate the file to be edited + descr = patch.get_description().strip() + descr_lines = descr.split('\n') + authdate = patch.get_authdate() + + short_descr = descr_lines[0].rstrip() + long_descr = reduce(lambda x, y: x + '\n' + y, + descr_lines[1:], '').strip() + + tmpl = '%(shortdescr)s\n\n' \ + 'From: %(authname)s <%(authemail)s>\n' + if authdate: + tmpl += 'Date: %(authdate)s\n' + tmpl += '\n%(longdescr)s\n' + + tmpl_dict = { + 'shortdescr': short_descr, + 'longdescr': long_descr, + 'authname': patch.get_authname(), + 'authemail': patch.get_authemail(), + 'authdate': patch.get_authdate() + } + + if options.diff: + # add the patch diff to the edited file + bottom = patch.get_bottom() + top = patch.get_top() + + tmpl += '---\n\n' \ + '%(diffstat)s\n' \ + '%(diff)s' + + tmpl_dict['diffstat'] = git.diffstat(rev1 = bottom, rev2 = top) + tmpl_dict['diff'] = git.diff(rev1 = bottom, rev2 = top, + diff_flags = diff_flags) + + for key in tmpl_dict: + # make empty strings if key is not available + if tmpl_dict[key] is None: + tmpl_dict[key] = '' + + text = tmpl % tmpl_dict + + if options.diff: + fname = '.stgit-edit.diff' + else: + fname = '.stgit-edit.txt' + + # write the file to be edited + f = open(fname, 'w+') + f.write(text) + f.close() + + # invoke the editor + call_editor(fname) + + __update_patch(pname, fname, options) + +def func(parser, options, args): + """Edit the given patch or the current one. + """ + crt_pname = crt_series.get_current() + + if not args: + pname = crt_pname + if not pname: + raise CmdException, 'No patches applied' + elif len(args) == 1: + pname = args[0] + if crt_series.patch_unapplied(pname) or crt_series.patch_hidden(pname): + raise CmdException, 'Cannot edit unapplied or hidden patches' + elif not crt_series.patch_applied(pname): + raise CmdException, 'Unknown patch "%s"' % pname + else: + parser.error('incorrect number of arguments') + + check_local_changes() + check_conflicts() + check_head_top_equal() + + if pname != crt_pname: + # Go to the patch to be edited + applied = crt_series.get_applied() + between = applied[:applied.index(pname):-1] + pop_patches(between) + + if options.author: + options.authname, options.authemail = name_email(options.author) + + if options.undo: + out.start('Undoing the editing of "%s"' % pname) + crt_series.undo_refresh() + out.done() + elif options.message or options.authname or options.authemail \ + or options.authdate or options.commname or options.commemail \ + or options.sign_str: + # just refresh the patch with the given information + out.start('Updating patch "%s"' % pname) + crt_series.refresh_patch(message = options.message, + author_name = options.authname, + author_email = options.authemail, + author_date = options.authdate, + committer_name = options.commname, + committer_email = options.commemail, + backup = True, sign_str = options.sign_str, + log = 'edit', + notes = options.annotate) + out.done() + elif options.file: + __update_patch(pname, options.file, options) + else: + __edit_update_patch(pname, options) + + if pname != crt_pname: + # Push the patches back + between.reverse() + push_patches(between) diff --git a/stgit/commands/imprt.py b/stgit/commands/imprt.py index fad5136..717f373 100644 --- a/stgit/commands/imprt.py +++ b/stgit/commands/imprt.py @@ -16,7 +16,6 @@ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA """ import sys, os, re, email -from email.Header import decode_header, make_header from mailbox import UnixMailbox from StringIO import StringIO from optparse import OptionParser, make_option @@ -91,10 +90,6 @@ options = [make_option('-m', '--mail', ] + make_sign_options() -def __end_descr(line): - return re.match('---\s*$', line) or re.match('diff -', line) or \ - re.match('Index: ', line) - def __strip_patch_name(name): stripped = re.sub('^[0-9]+-(.*)$', '\g<1>', name) stripped = re.sub('^(.*)\.(diff|patch)$', '\g<1>', stripped) @@ -106,127 +101,6 @@ def __replace_slashes_with_dashes(name): return stripped -def __split_descr_diff(string): - """Return the description and the diff from the given string - """ - descr = diff = '' - top = True - - for line in string.split('\n'): - if top: - if not __end_descr(line): - descr += line + '\n' - continue - else: - top = False - diff += line + '\n' - - return (descr.rstrip(), diff) - -def __parse_description(descr): - """Parse the patch description and return the new description and - author information (if any). - """ - subject = body = '' - authname = authemail = authdate = None - - descr_lines = [line.rstrip() for line in descr.split('\n')] - if not descr_lines: - raise CmdException, "Empty patch description" - - lasthdr = 0 - end = len(descr_lines) - - # Parse the patch header - for pos in range(0, end): - if not descr_lines[pos]: - continue - # check for a "From|Author:" line - if re.match('\s*(?:from|author):\s+', descr_lines[pos], re.I): - auth = re.findall('^.*?:\s+(.*)$', descr_lines[pos])[0] - authname, authemail = name_email(auth) - lasthdr = pos + 1 - continue - # check for a "Date:" line - if re.match('\s*date:\s+', descr_lines[pos], re.I): - authdate = re.findall('^.*?:\s+(.*)$', descr_lines[pos])[0] - lasthdr = pos + 1 - continue - if subject: - break - # get the subject - subject = descr_lines[pos] - lasthdr = pos + 1 - - # get the body - if lasthdr < end: - body = reduce(lambda x, y: x + '\n' + y, descr_lines[lasthdr:], '') - - return (subject + body, authname, authemail, authdate) - -def __parse_mail(msg): - """Parse the message object and return (description, authname, - authemail, authdate, diff) - """ - def __decode_header(header): - """Decode a qp-encoded e-mail header as per rfc2047""" - try: - words_enc = decode_header(header) - hobj = make_header(words_enc) - except Exception, ex: - raise CmdException, 'header decoding error: %s' % str(ex) - return unicode(hobj).encode('utf-8') - - # parse the headers - if msg.has_key('from'): - authname, authemail = name_email(__decode_header(msg['from'])) - else: - authname = authemail = None - - # '\n\t' can be found on multi-line headers - descr = __decode_header(msg['subject']).replace('\n\t', ' ') - authdate = msg['date'] - - # remove the '[*PATCH*]' expression in the subject - if descr: - descr = re.findall('^(\[.*?[Pp][Aa][Tt][Cc][Hh].*?\])?\s*(.*)$', - descr)[0][1] - else: - raise CmdException, 'Subject: line not found' - - # the rest of the message - msg_text = '' - for part in msg.walk(): - if part.get_content_type() == 'text/plain': - msg_text += part.get_payload(decode = True) - - rem_descr, diff = __split_descr_diff(msg_text) - if rem_descr: - descr += '\n\n' + rem_descr - - # parse the description for author information - descr, descr_authname, descr_authemail, descr_authdate = \ - __parse_description(descr) - if descr_authname: - authname = descr_authname - if descr_authemail: - authemail = descr_authemail - if descr_authdate: - authdate = descr_authdate - - return (descr, authname, authemail, authdate, diff) - -def __parse_patch(fobj): - """Parse the input file and return (description, authname, - authemail, authdate, diff) - """ - descr, diff = __split_descr_diff(fobj.read()) - descr, authname, authemail, authdate = __parse_description(descr) - - # we don't yet have an agreed place for the creation date. - # Just return None - return (descr, authname, authemail, authdate, diff) - def __create_patch(filename, message, author_name, author_email, author_date, diff, options): """Create a new patch on the stack @@ -312,10 +186,10 @@ def __import_file(filename, options, patch = None): except Exception, ex: raise CmdException, 'error parsing the e-mail file: %s' % str(ex) message, author_name, author_email, author_date, diff = \ - __parse_mail(msg) + parse_mail(msg) else: message, author_name, author_email, author_date, diff = \ - __parse_patch(f) + parse_patch(f) if filename: f.close() @@ -367,7 +241,7 @@ def __import_mbox(filename, options): for msg in mbox: message, author_name, author_email, author_date, diff = \ - __parse_mail(msg) + parse_mail(msg) __create_patch(None, message, author_name, author_email, author_date, diff, options) diff --git a/stgit/commands/refresh.py b/stgit/commands/refresh.py index f70d808..241f065 100644 --- a/stgit/commands/refresh.py +++ b/stgit/commands/refresh.py @@ -41,40 +41,17 @@ options = [make_option('-f', '--force', help = 'force the refresh even if HEAD and '\ 'top differ', action = 'store_true'), - make_option('-e', '--edit', - help = 'invoke an editor for the patch '\ - 'description', - action = 'store_true'), - make_option('-s', '--showpatch', - help = 'show the patch content in the editor buffer', - action = 'store_true'), make_option('--update', help = 'only update the current patch files', action = 'store_true'), make_option('--undo', help = 'revert the commit generated by the last refresh', action = 'store_true'), - make_option('-m', '--message', - help = 'use MESSAGE as the patch ' \ - 'description'), make_option('-a', '--annotate', metavar = 'NOTE', help = 'annotate the patch log entry'), - make_option('--author', metavar = '"NAME "', - help = 'use "NAME " as the author details'), - make_option('--authname', - help = 'use AUTHNAME as the author name'), - make_option('--authemail', - help = 'use AUTHEMAIL as the author e-mail'), - make_option('--authdate', - help = 'use AUTHDATE as the author date'), - make_option('--commname', - help = 'use COMMNAME as the committer name'), - make_option('--commemail', - help = 'use COMMEMAIL as the committer ' \ - 'e-mail'), make_option('-p', '--patch', help = 'refresh (applied) PATCH instead of the top one') - ] + make_sign_options() + ] def func(parser, options, args): autoresolved = config.get('stgit.autoresolved') @@ -103,18 +80,11 @@ def func(parser, options, args): out.done() return - if options.author: - options.authname, options.authemail = name_email(options.author) - files = [path for (stat,path) in git.tree_status(verbose = True)] if args: files = [f for f in files if f in args] - if files or not crt_series.head_top_equal() \ - or options.edit or options.message \ - or options.authname or options.authemail or options.authdate \ - or options.commname or options.commemail or options.sign_str: - + if files or not crt_series.head_top_equal(): if options.patch: applied = crt_series.get_applied() between = applied[:applied.index(patch):-1] @@ -133,16 +103,7 @@ def func(parser, options, args): if autoresolved == 'yes': resolved_all() crt_series.refresh_patch(files = files, - message = options.message, - edit = options.edit, - show_patch = options.showpatch, - author_name = options.authname, - author_email = options.authemail, - author_date = options.authdate, - committer_name = options.commname, - committer_email = options.commemail, - backup = True, sign_str = options.sign_str, - notes = options.annotate) + backup = True, notes = options.annotate) if crt_series.empty_patch(patch): out.done('empty patch') diff --git a/stgit/git.py b/stgit/git.py index 539d699..6c55127 100644 --- a/stgit/git.py +++ b/stgit/git.py @@ -762,6 +762,8 @@ def diff(files = None, rev1 = 'HEAD', rev2 = None, diff_flags = []): else: return '' +# TODO: take another parameter representing a diff string as we +# usually invoke git.diff() form the calling functions def diffstat(files = None, rev1 = 'HEAD', rev2 = None): """Return the diffstat between rev1 and rev2.""" return GRun('git-apply', '--stat', '--summary' diff --git a/stgit/main.py b/stgit/main.py index 5b9d7c4..a49513d 100644 --- a/stgit/main.py +++ b/stgit/main.py @@ -68,6 +68,7 @@ commands = Commands({ 'clone': 'clone', 'commit': 'commit', 'cp': 'copy', + 'edit': 'edit', 'export': 'export', 'files': 'files', 'float': 'float', diff --git a/stgit/stack.py b/stgit/stack.py index 906e6b1..d6f6a6e 100644 --- a/stgit/stack.py +++ b/stgit/stack.py @@ -19,10 +19,10 @@ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA """ import sys, os, re +from email.Utils import formatdate from stgit.utils import * from stgit.out import * -from stgit.run import * from stgit import git, basedir, templates from stgit.config import config from shutil import copyfile @@ -67,6 +67,8 @@ def __clean_comments(f): f.seek(0); f.truncate() f.writelines(lines) +# TODO: move this out of the stgit.stack module, it is really for +# higher level commands to handle the user interaction def edit_file(series, line, comment, show_patch = True): fname = '.stgitmsg.txt' tmpl = templates.get_template('patchdescr.tmpl') @@ -252,7 +254,16 @@ class Patch(StgitObject): self._set_field('authemail', email or git.author().email) def get_authdate(self): - return self._get_field('authdate') + date = self._get_field('authdate') + if not date: + return date + + if re.match('[0-9]+\s+[+-][0-9]+', date): + # Unix time (seconds) + time zone + secs_tz = date.split() + date = formatdate(int(secs_tz[0]))[:-5] + secs_tz[1] + + return date def set_authdate(self, date): self._set_field('authdate', date or git.author().date) @@ -764,6 +775,8 @@ class Series(PatchSet): elif message: descr = message + # TODO: move this out of the stgit.stack module, it is really + # for higher level commands to handle the user interaction if not message and edit: descr = edit_file(self, descr.rstrip(), \ 'Please edit the description for patch "%s" ' \ @@ -842,6 +855,8 @@ class Series(PatchSet): if self.patch_exists(name): raise StackException, 'Patch "%s" already exists' % name + # TODO: move this out of the stgit.stack module, it is really + # for higher level commands to handle the user interaction if not message and can_edit: descr = edit_file( self, None, diff --git a/t/t1400-patch-history.sh b/t/t1400-patch-history.sh index b0602ff..5b842d0 100755 --- a/t/t1400-patch-history.sh +++ b/t/t1400-patch-history.sh @@ -64,7 +64,7 @@ test_expect_success \ 'Check the "push(f)" log' \ ' stg pop && - stg refresh -m "Foo2 Patch" && + stg edit -m "Foo2 Patch" && stg push && stg log --full | grep -q -e "^push(f) " '