Apply changes using custom rebase editor

Create a custom rebase-editor script that can be used to alter the
contents of the rebase 'todo' file by being used as the editor. Use a
wrapper object that sets and removes the environmental variables
required for to enable use of the custom editor with git-rebase.

Add an interactive mode to the import-upstream subcommand to allow the
user to request that an editor be spawned after the rebase instructions
file has been modified.

Include a special debug interactive mode, where an editor will be
launched by the script of the final todo instructions file just before
it is pared by rebase.

Should rely on the main editor launch in general to avoid not being able
to process the stdout/stderr result from 'git rebase', because the debug
mode means that we cannot capture I/O without hanging most editors.

Change-Id: Idf5670abe7b9aca23d94cd52e9a7327bd575814d
JIRA: CICD-733
This commit is contained in:
Darragh Bailey
2013-08-01 08:36:41 +01:00
parent e770c47595
commit 36c75ffd11
3 changed files with 342 additions and 3 deletions

View File

@@ -11,6 +11,7 @@
from ghp.errors import HpgitError
from ghp.log import LogDedentMixin
from ghp.lib.utils import GitMixin
from ghp.lib.rebaseeditor import RebaseEditor
from ghp import subcommand, log
from ghp.lib.searchers import UpstreamMergeBaseSearcher
@@ -206,7 +207,7 @@ class ImportUpstream(LogDedentMixin, GitMixin):
self.git.checkout(base)
self.git.merge(*self.extra_branches)
def start(self, strategy):
def apply(self, strategy, interactive=False):
"""Apply list of commits given onto latest import of upstream"""
self.log.debug(
@@ -214,7 +215,41 @@ class ImportUpstream(LogDedentMixin, GitMixin):
Should apply the following list of commits
%s
""", "\n ".join([c.id for c in strategy.filtered_iter()]))
raise NotImplementedError("start() is not yet implemented")
base = self.import_branch + "-base"
first = next(strategy.filtered_iter())
self._set_branch(self.import_branch, self.branch, force=True)
rebase = RebaseEditor(interactive, repo=self.repo)
self.log.info(
"""\
Rebase changes, dropping merges through editor:
git rebase --onto %s \\
%s %s
""", base, first.parents[0].id, self.import_branch)
status, out, err = rebase.run(strategy.filtered_iter(),
first.parents[0].id, self.import_branch,
onto=base)
if status:
if err and err.startswith("Nothing to do"):
# cancelled by user
self.log.notice("Cancelled by user")
return
self.log.error("Rebase failed, will need user intervention to "
"resolve.")
if out:
self.log.notice(out)
if err:
self.log.notice(err)
# once we support resuming/finishing add a message here to tell the
# user to rerun this tool with the appropriate options to complete
return
self.log.notice("Successfully applied all locally carried changes")
def resume(self, args):
"""Resume previous partial import"""
@@ -313,6 +348,8 @@ class LocateChangesWalk(LocateChangesStrategy):
self.filters.append(ReverseCommitFilter())
@subcommand.arg('-i', '--interactive', action='store_true', default=False,
help='Let the user edit the list of commits before applying.')
@subcommand.arg('-f', '--force', dest='force', required=False, action='store_true',
default=False,
help='Force overwrite of existing import branch if it exists.')
@@ -382,7 +419,7 @@ def do_import_upstream(args):
importupstream.create_import(force=args.force)
logger.notice("Successfully created import branch")
importupstream.start(strategy)
importupstream.apply(strategy, args.interactive)

187
ghp/lib/rebaseeditor.py Normal file
View File

@@ -0,0 +1,187 @@
#
# Copyright (c) 2012, 2013 Hewlett-Packard Development Company, L.P.
#
# Confidential computer software. Valid license from HP required for
# possession, use or copying. Consistent with FAR 12.211 and 12.212,
# Commercial Computer Software, Computer Software Documentation, and
# Technical Data for Commercial Items are licensed to the U.S. Government
# under vendor's standard commercial license.
#
from ghp.lib.utils import GitMixin
from ghp.log import LogDedentMixin
import ghp
from subprocess import call
import os
REBASE_EDITOR_SCRIPT = os.path.abspath(
os.path.join(os.path.dirname(ghp.__file__), "scripts", "rebase-editor.py"))
# insure name of file will match any naming filters used by editors to
# enable syntax highlighting
REBASE_EDITOR_TODO = "hpgit/git-rebase-todo"
TODO_EPILOGUE = """\
# Rebase %(shortrevisions)s onto %(shortonto)s
#
# All commands from normal rebase instructions files are supported
#
# If you remove a line, that commit will be dropped.
# Removing all commits will abort the rebase.
#
"""
class RebaseEditor(GitMixin, LogDedentMixin):
def __init__(self, interactive=False, *args, **kwargs):
self._interactive = interactive
super(RebaseEditor, self).__init__()
self._editor = REBASE_EDITOR_SCRIPT
# interactive switch here determines if the script that is given
# to git-rebase to run as it's editor, will in turn exec an editor
# for the user to look through the instructions before rebase
# applies them
if interactive == 'debug':
self.log.debug("Enabling interactive mode for rebase")
self._editor = "%s --interactive" % self.editor
@property
def editor(self):
return self._editor
def _write_todo(self, commits, *args, **kwargs):
todo_file = os.path.join(self.repo.path, REBASE_EDITOR_TODO)
if os.path.exists(todo_file):
os.remove(todo_file)
if not os.path.exists(os.path.dirname(todo_file)):
os.mkdir(os.path.dirname(todo_file))
# see if onto is set in the args or kwargs
onto = kwargs.get('onto', None)
for idx, arg in enumerate(args):
if arg.startswith("--onto"):
# either onto is after the option in this arg, or it's the
# next arg, or not providing it is an exception
onto = arg[7:] or args[idx + 1]
break
root = None
with open(todo_file, "w") as todo:
for commit in commits:
if not root:
root = commit.parents[0].id
subject = commit.message.splitlines()[0]
todo.write("pick %s %s\n" % (commit.id[:7], subject))
# if root isn't set at this point, then there were no commits
if not root:
todo.write("noop\n")
todo.write(TODO_EPILOGUE %
{'shortrevisions': self._short_revisions(root,
commit.id),
'shortonto': self._short_onto(onto or root)})
return todo_file
def _short_revisions(self, root, commit):
if not root:
return "<none>"
return "%s..%s" % (root[:7], commit[:7])
def _short_onto(self, onto):
if not onto:
return "<none>"
return self.git.rev_parse(onto)[:7]
def _set_editor(self, editor):
if self.git_sequence_editor:
self._saveeditor = self.git_sequence_editor
if self._interactive == 'debug':
os.environ['HPGIT_GIT_SEQUENCE_EDITOR'] = self._saveeditor
os.environ['GIT_SEQUENCE_EDITOR'] = editor
else:
self._saveeditor = self.git_editor
if self._interactive == 'debug':
os.environ['HPGIT_GIT_EDITOR'] = self._saveeditor
os.environ['GIT_EDITOR'] = editor
def _unset_editor(self):
for var in ['GIT_SEQUENCE_EDITOR', 'GIT_EDITOR']:
# HPGIT_* variations should only be set if script was in a debug
# mode.
if os.environ.get('HPGIT_' + var, None):
del os.environ['HPGIT_' + var]
# Restore previous editor only if the environment var is set. This
# isn't perfect since we should probably unset the env var if it
# wasn't previously set, but this shouldn't cause any problems.
if os.environ.get(var, None):
os.environ[var] = self._saveeditor
break
def run(self, commits, *args, **kwargs):
"""
Reads the list of commits given, and constructions the instuctions
file to be used by rebase.
Will spawn an editor if the constructor was told to be interactive.
Additional arguments *args and **kwargs are to be passed to 'git
rebase'.
"""
todo_file = self._write_todo(commits, *args, **kwargs)
if self._interactive:
# spawn the editor
user_editor = self.git_sequence_editor or self.git_editor
status = call([user_editor, todo_file])
if status:
return (status, None, "Editor returned non-zero exit code")
editor = "%s %s" % (self.editor, todo_file)
self._set_editor(editor)
try:
if self._interactive == 'debug':
# In general it's not recommended to run rebase in direct
# interactive mode because it's not possible to capture the
# stdout/stderr, but sometimes it's useful to allow it for
# debugging to check the final result.
#
# It is not safe to redirect I/O channels as most editors will
# be expecting that I/O is from/to proper terminal. YMMV
cmd = ['git', 'rebase', '--interactive']
cmd.extend(self.git.transform_kwargs(**kwargs))
cmd.extend(args)
return (call(cmd), None, None)
else:
return self.git.rebase(interactive=True, with_exceptions=False,
with_extended_output=True, *args, **kwargs)
finally:
os.remove(todo_file)
# make sure to remove the environment tweaks added so as not to
# impact any subsequent use of git commands using editors
self._unset_editor()
@property
def git_sequence_editor(self):
return os.environ.get('GIT_SEQUENCE_EDITOR',
self.git.config("sequence.editor",
with_exceptions=False))
@property
def git_editor(self):
return os.environ.get("GIT_EDITOR", self.git.var("GIT_EDITOR"))

115
ghp/scripts/rebase-editor.py Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python
#
# Copyright (c) 2012, 2013 Hewlett-Packard Development Company, L.P.
#
# Confidential computer software. Valid license from HP required for
# possession, use or copying. Consistent with FAR 12.211 and 12.212,
# Commercial Computer Software, Computer Software Documentation, and
# Technical Data for Commercial Items are licensed to the U.S. Government
# under vendor's standard commercial license.
#
"""
Command line editor for modifying git rebase instructions file through use
of the interactive mode. Will in turn launch an editor in turn if the user
wished to use the interactive mode of git-rebase.
Script will replace all occurances of 'pick' or any other instruction entry
with a list of instructions read from the given input file.
Avoid use of stdin for passing such information as many editors have problems
if exec'ed and stdin is a pipe.
"""
from argparse import ArgumentParser
import fileinput
import os
import sys
def rebase_replace_insn(path, istream):
"""
Function replaces the current instructions listed in the rebase
instructions (insn) file with those read from the given istream.
"""
echo_out = False
for line in fileinput.input(path, inplace=True):
stripped = line.strip()
# first blank line indicates end of rebase instructions
if not stripped:
if not echo_out:
while True:
replacement = istream.readline().strip()
if not replacement:
break
if not replacement.startswith("#"):
print replacement
print ""
echo_out = True
continue
if echo_out:
print stripped
if __name__ == '__main__':
parser = ArgumentParser(
description=__doc__.strip(),
)
parser.add_argument('-v', '--verbose', action='store_true', default=False,
help='Enable verbose mode')
parser.add_argument('-i', '--interactive', action='store_true',
help='Enable interactive mode, where the user can edit '
'the list of commits before being applied')
parser.add_argument('ifile', metavar='<new-list>',
help='File containing the new list of instructions to '
'be placed into the rebase instructions file.')
parser.add_argument('extra_args', metavar='<args>', nargs='?', default=[],
help='Additional arguments to be passed to the '
'subsequent editor')
parser.add_argument('ofile', metavar='<todo-list>',
help='Filename containing the list of instructions to'
'be edited.')
args = parser.parse_args()
VERBOSE = args.verbose
# don't attempt to use stdin to pass the information between the parent
# process through 'git-rebase' and this script, as many editors will
# have problems if stdin is a pipe.
if VERBOSE:
print "rebase-editor: Replacing contents of rebase instructions file"
rebase_replace_insn(args.ofile, open(args.ifile, 'r'))
# if interactive mode, attempt to exec the editor defined by the user
# for use with git
if not args.interactive:
if VERBOSE:
print "rebase-editor: Interactive mode not enabled"
sys.exit(0)
# calling code should only override one of the two editor variables,
# starting with the one with the highest precedence
env = os.environ
for var in ['GIT_SEQUENCE_EDITOR', 'GIT_EDITOR']:
editor = env.get('HPGIT_' + var, None)
if editor:
del env['HPGIT_' + var]
env[var] = editor
break
if editor:
editor_args = [editor]
editor_args.extend(args.extra_args)
editor_args.append(args.ofile)
sys.stdin.flush()
sys.stdout.flush()
sys.stderr.flush()
#os.dup2(sys.stdin.fileno(), 0)
#os.dup2(sys.stdout.fileno(), 1)
#os.dup2(sys.stderr.fileno(), 2)
os.execvpe(editor, editor_args, env=env)
sys.stderr.write("rebase-editor: No git EDITOR variables defined in "
"environment to call as requested by the "
"--interactive option.\n")
sys.exit(2)