sapling/chistedit.py

441 lines
14 KiB
Python
Raw Normal View History

# chistedit.py
#
# Copyright 2014 Facebook, Inc.
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
"""
An interactive ncurses interface to histedit
This extensions allows you to interactively move around changesets
or change the action to perform while keeping track of possible
conflicts.
Use up/down or j/k to move up and down. Select a commit via space and move
it around. You can use d/e/f/m/r to change the action of a commit. You
can cycle through available commands with left/h or right/l.
The diff for the current commit can be viewed by pressing v. To apply
the commands press C, which will call histedit.
2014-11-07 20:34:29 +03:00
The current conflict deteciton mechanism is based on a per-file
comparision. Reordered changesets that touch the sames files are
considered a "potential conflict". Please note that Mercurial's merge
algorithm might still be able to merge these files without conflict.
"""
from __future__ import print_function
from hgext import histedit
from mercurial import cmdutil
from mercurial import context
from mercurial import extensions
from mercurial import localrepo
from mercurial import node
from mercurial import scmutil
from mercurial import util
from mercurial.i18n import _
from hgext import color
import functools
import itertools
import os
import sys
try:
import curses
except ImportError:
print("Python curses library required", file=sys.stderr)
KEY_NEXT_ACTION = ['h', 'KEY_RIGHT']
KEY_PREV_ACTION = ['l', 'KEY_LEFT']
KEY_DOWN = ['j', 'KEY_DOWN']
KEY_UP = ['k', 'KEY_UP']
KEY_SEL = [' ']
KEY_QUIT = ['q']
KEY_HISTEDIT = ['C']
KEY_SHOWPATCH = ['v']
KEY_HELP = ['?']
KEY_ACTION = {
'd': 'drop',
'e': 'edit',
'f': 'fold',
'm': 'mess',
'p': 'pick',
'r': 'roll',
}
KEY_LIST = ['pick', 'edit', 'fold', 'drop', 'mess', 'roll']
COLOR_HELP, COLOR_SELECTED, COLOR_OK, COLOR_WARN = 1, 2, 3, 4
E_QUIT, E_HISTEDIT = 1, 2
MODE_PATCH, MODE_RULES, MODE_HELP = 1, 2, 3
class histeditrule(object):
def __init__(self, ctx, pos, action='pick'):
self.ctx = ctx
self.action = action
self.origpos = pos
self.pos = pos
self.conflicts = []
def __str__(self):
action = self.action
h = self.ctx.hex()[0:12]
r = self.ctx.rev()
desc = self.ctx.description().splitlines()[0].strip()
return "#{0:<2} {1} {2}:{3} {4}".format(
self.origpos, action, r, h, desc)
def checkconflicts(self, other):
if other.pos > self.pos and other.origpos <= self.origpos:
if set(other.ctx.files()) & set(self.ctx.files()) != set():
self.conflicts.append(other)
return self.conflicts
if other in self.conflicts:
self.conflicts.remove(other)
return self.conflicts
# ============ EVENTS ===============
def movecursor(state, oldpos, newpos):
state['pos'] = newpos
def makeselection(state, pos):
state['selected'] = pos
def swap(state, oldpos, newpos):
"""Swap two positions and calculate necessary conflicts in
O(|newpos-oldpos|) time"""
rules = state['rules']
assert 0 <= oldpos < len(rules) and 0 <= newpos < len(rules)
rules[oldpos], rules[newpos] = rules[newpos], rules[oldpos]
# TODO: swap should not know about histeditrule's internals
rules[newpos].pos = newpos
rules[oldpos].pos = oldpos
start = min(oldpos, newpos)
end = max(oldpos, newpos)
for r in xrange(start, end+1):
rules[newpos].checkconflicts(rules[r])
rules[oldpos].checkconflicts(rules[r])
makeselection(state, newpos)
def changeaction(state, pos, action):
"""Change the action state on the given position to the new action"""
rules = state['rules']
assert 0 <= pos < len(rules)
rules[pos].action = action
def cycleaction(state, pos, next=False):
"""Changes the action state the next or the previous action from
the action list"""
rules = state['rules']
assert 0 <= pos < len(rules)
current = rules[pos].action
assert current in KEY_ACTION.values()
assert current in KEY_LIST
index = KEY_LIST.index(current)
if next:
index+=1
else:
index-=1
changeaction(state, pos, KEY_LIST[index % len(KEY_LIST)])
def event(state, ch):
"""Change state based on the current character input
This takes the current state and based on the current charcter input from
the user we change the state.
"""
selected = state['selected']
oldpos = state['pos']
rules = state['rules']
if ch in KEY_DOWN:
newpos = min(oldpos+1, len(rules)-1)
movecursor(state, oldpos, newpos)
if selected is not None:
swap(state, oldpos, newpos)
if ch in KEY_UP:
newpos = max(0, oldpos-1)
movecursor(state, oldpos, newpos)
if selected is not None:
swap(state, oldpos, newpos)
if ch in KEY_NEXT_ACTION:
cycleaction(state, oldpos, next=True)
if ch in KEY_PREV_ACTION:
cycleaction(state, oldpos, next=False)
if ch in KEY_SEL:
selected = oldpos if selected is None else None
makeselection(state, selected)
if '0' <= ch <= '9' and int(ch) < len(rules):
newrule = next((r for r in rules if r.origpos == int(ch)))
movecursor(state, oldpos, newrule.pos)
if selected is not None:
swap(state, oldpos, newrule.pos)
if ch in KEY_ACTION:
changeaction(state, oldpos, KEY_ACTION[ch])
if ch in KEY_SHOWPATCH:
cur, prev = state['mode']
if cur == MODE_PATCH:
state['mode'] = (MODE_RULES, cur)
else:
state['mode'] = (MODE_PATCH, cur)
if ch in KEY_HELP:
cur, prev = state['mode']
if cur == MODE_HELP:
state['mode'] = (MODE_RULES, cur)
else:
state['mode'] = (MODE_HELP, cur)
if ch in KEY_QUIT:
return E_QUIT
if ch in KEY_HISTEDIT:
return E_HISTEDIT
def makecommands(rules):
"""Returns a list of commands consumable by histedit --commands based on
our list of rules"""
commands = []
for rules in rules:
commands.append("{0} {1}\n".format(rules.action, rules.ctx))
return commands
def addln(win, y, x, line, color=None):
"""Add a line to the given window left padding but 100% filled with
whitespace characters, so that the color appears on the whole line"""
maxy, maxx = win.getmaxyx()
length = maxx-1-x
line = ("{0:<%d}" % length).format(str(line).strip())[:length]
if y < 0:
y = maxy + y
if x < 0:
x = maxx + x
if color:
win.addstr(y, x, line, color)
else:
win.addstr(y, x, line)
def main(repo, rules, stdscr):
# initialize color pattern
curses.init_pair(COLOR_HELP, curses.COLOR_WHITE, curses.COLOR_BLUE)
curses.init_pair(COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(COLOR_WARN, curses.COLOR_BLACK, curses.COLOR_YELLOW)
curses.init_pair(COLOR_OK, curses.COLOR_BLACK, curses.COLOR_GREEN)
# don't display the cursor
try:
curses.curs_set(0)
except curses.error:
pass
def rendercommit(win, state):
"""Renders the commit window that shows the log of the current selected
commit"""
pos = state['pos']
rules = state['rules']
rule = rules[pos]
ctx = rule.ctx
win.box(0, 0)
maxy, maxx = win.getmaxyx()
length = maxx-3
line = "changeset: {0}:{1:<12}".format(ctx.rev(), ctx)
win.addstr(1, 1, line[:length])
line = "user: {0}".format(util.shortuser(ctx.user()))
win.addstr(2, 1, line[:length])
bms = repo.nodebookmarks(ctx.node())
line = "bookmark: {0}".format(' '.join(bms))
win.addstr(3, 1, line[:length])
line = "files: {0}".format(','.join(ctx.files()))
win.addstr(4, 1, line[:length])
line = "summary: {0}".format(ctx.description().splitlines()[0])
win.addstr(5, 1, line[:length])
conflicts = rule.conflicts
if len(conflicts) > 0:
conflictstr = ','.join(map(lambda r: str(r.ctx), conflicts))
conflictstr = "changed files overlap with {0}".format(conflictstr)
else:
conflictstr = 'no overlap'
win.addstr(6, 1, conflictstr[:length])
win.noutrefresh()
def renderhelp(win, state):
help = """
?: help, up/k: move up, down/j: move down, space: select, v: view patch
d/e/f/m/p/r: change action, C: invoke histedit, q: abort
"""
maxy, maxx = win.getmaxyx()
for y, line in enumerate(help.splitlines()[1:3]):
if y > maxy:
break
addln(win, y, 0, line, curses.color_pair(COLOR_HELP))
win.noutrefresh()
def renderrules(rulesscr, state):
rules = state['rules']
pos = state['pos']
selected = state['selected']
conflicts = [r.ctx for r in rules if r.conflicts]
if len(conflicts) > 0:
line = "potential conflict in %s" % ','.join(map(str, conflicts))
addln(rulesscr, -1, 0, line, curses.color_pair(COLOR_WARN))
for y, rule in enumerate(rules):
if len(rule.conflicts) > 0:
rulesscr.addstr(y, 0, " ", curses.color_pair(COLOR_WARN))
else:
rulesscr.addstr(y, 0, " ", curses.COLOR_BLACK)
if y == selected:
addln(rulesscr, y, 2, rule, curses.color_pair(COLOR_SELECTED))
elif y == pos:
addln(rulesscr, y, 2, rule, curses.A_BOLD)
else:
addln(rulesscr, y, 2, rule)
rulesscr.noutrefresh()
def renderstring(win, state, output):
maxy, maxx = win.getmaxyx()
length = min(maxy, len(output))
for y in range(0, length):
win.addstr(y, 0, output[y])
win.noutrefresh()
def renderpatch(win, state):
pos = state['pos']
rules = state['rules']
rule = rules[pos]
displayer = cmdutil.show_changeset(repo.ui, repo, {
'patch': True, 'verbose': True},
buffered=True)
displayer.show(rule.ctx)
displayer.close()
output = displayer.hunk[rule.ctx.rev()].splitlines()
renderstring(win, state, output)
state = {
'pos': 0,
'rules': rules,
'selected': None,
'mode': (MODE_RULES, MODE_RULES),
}
# eventloop
ch = None
stdscr.clear()
stdscr.refresh()
while True:
try:
e = event(state, ch)
if e == E_QUIT:
return False
if e == E_HISTEDIT:
return state['rules']
else:
maxy, maxx = stdscr.getmaxyx()
commitwin = curses.newwin(8, maxx, maxy-8, 0)
helpwin = curses.newwin(2, maxx, 0, 0)
editwin = curses.newwin(maxy-2-8, maxx, 2, 0)
# start rendering
commitwin.erase()
helpwin.erase()
editwin.erase()
curmode, _ = state['mode']
if curmode == MODE_PATCH:
renderpatch(editwin, state)
elif curmode == MODE_HELP:
renderstring(editwin, state, __doc__.strip().splitlines())
else:
renderrules(editwin, state)
rendercommit(commitwin, state)
renderhelp(helpwin, state)
curses.doupdate()
# done rendering
ch = stdscr.getkey()
except curses.error:
pass
cmdtable = {}
command = cmdutil.command(cmdtable)
testedwith = 'internal'
@command('chistedit', [
('k', 'keep', False,
_("don't strip old nodes after edit is complete")),
('r', 'rev', [], _('first revision to be edited'))],
_("ANCESTOR"))
def chistedit(ui, repo, *freeargs, **opts):
"""Provides a ncurses interface to histedit. Press ? in chistedit mode
to see an extensive help. Requires python-curses to be installed."""
def nocolor(orig, text, effects):
return text
# disable coloring only if we call histedit
import hgext.color
extensions.wrapfunction(hgext.color, 'render_effects', nocolor)
color.render_effect = lambda text, effects: text
try:
keep = opts.get('keep')
revs = opts.get('rev', [])[:]
cmdutil.checkunfinished(repo)
cmdutil.bailifchanged(repo)
if os.path.exists(os.path.join(repo.path, 'histedit-state')):
raise util.Abort(_('history edit already in progress, try '
'--continue or --abort'))
revs.extend(freeargs)
if len(revs) != 1:
raise util.Abort(
_('histedit requires exactly one ancestor revision'))
rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs)))
if len(rr) != 1:
raise util.Abort(_('The specified revisions must have '
'exactly one common root'))
root = rr[0].node()
topmost, empty = repo.dirstate.parents()
revs = histedit.between(repo, root, topmost, keep)
if not revs:
raise util.Abort(_('%s is not an ancestor of working directory') %
node.short(root))
ctxs = []
for i, r in enumerate(revs):
ctxs.append(histeditrule(repo[r], i))
rc = curses.wrapper(functools.partial(main, repo, ctxs))
curses.echo()
curses.endwin()
if rc == False:
ui.write("chistedit aborted\n")
return 0
if type(rc) is list:
ui.status("running histedit\n")
rules = makecommands(rc)
filename = repo.join('chistedit')
with open(filename, 'w+') as fp:
for r in rules:
fp.write(r)
opts['commands'] = filename
return histedit.histedit(ui, repo, *freeargs, **opts)
except KeyboardInterrupt:
pass
return -1