sapling/chistedit.py

464 lines
15 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.
2016-04-04 22:48:01 +03:00
The current conflict detection mechanism is based on a per-file
comparison. Reordered changesets that touch the sames files are
2014-11-07 20:34:29 +03:00
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 extensions
from mercurial import node
from mercurial import scmutil
from mercurial import error
from mercurial import util
from mercurial.i18n import _
from hgext import color
import functools
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_MOVE_DOWN = ['J']
KEY_MOVE_UP = ['K']
KEY_SEL = [' ']
KEY_QUIT = ['q']
KEY_HISTEDIT = ['c', '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']
ACTION_LABELS = {
'fold': '^fold',
'roll': '^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):
# Some actions ('fold' and 'roll') combine a patch with a previous one.
# Add a marker showing which patch they apply to, and also omit the
# description for 'roll' (since it will get discarded). Example display:
#
# #10 pick 316392:06a16c25c053 add option to skip tests
# #11 ^roll 316393:71313c964cc5
# #12 pick 316394:ab31f3973b0d include mfbt for mozilla-config.h
# #13 ^fold 316395:14ce5803f4c3 fix warnings
#
# The carets point to the changeset being folded into ("roll this
# changeset into the changeset above").
action = ACTION_LABELS.get(self.action, self.action)
h = self.ctx.hex()[0:12]
r = self.ctx.rev()
desc = self.ctx.description().splitlines()[0].strip()
if self.action == 'roll':
desc = ''
return "#{0:<2} {1:<6} {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])
if state['selected']:
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
2016-04-04 22:48:01 +03:00
This takes the current state and based on the current character input from
the user we change the state.
"""
selected = state['selected']
oldpos = state['pos']
rules = state['rules']
if ch in KEY_DOWN or ch in KEY_MOVE_DOWN:
newpos = min(oldpos + 1, len(rules) - 1)
movecursor(state, oldpos, newpos)
if selected is not None or ch in KEY_MOVE_DOWN:
swap(state, oldpos, newpos)
if ch in KEY_UP or ch in KEY_MOVE_UP:
newpos = max(0, oldpos - 1)
movecursor(state, oldpos, newpos)
if selected is not None or ch in KEY_MOVE_UP:
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()
2016-02-05 21:13:51 +03:00
def helplines():
help = """
?: help, up/k: move up, down/j: move down, space: select, v: view patch
2016-02-05 21:13:51 +03:00
d: drop, e: edit, f: fold, m: mess, p: pick, r: roll
K: move current up, J: move current down, c: commit changes, q: abort
"""
2016-02-05 21:13:51 +03:00
return help.strip().splitlines()
def renderhelp(win, state):
maxy, maxx = win.getmaxyx()
2016-02-05 21:13:51 +03:00
for y, line in enumerate(helplines()):
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)
2016-02-05 21:13:51 +03:00
helplen = len(helplines())
helpwin = curses.newwin(helplen, maxx, 0, 0)
2016-02-05 21:24:50 +03:00
editwin = curses.newwin(maxy - helplen - 8, maxx, helplen, 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 error.Abort(_('history edit already in progress, try '
'--continue or --abort'))
revs.extend(freeargs)
if len(revs) != 1:
raise error.Abort(
_('histedit requires exactly one ancestor revision'))
rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs)))
if len(rr) != 1:
raise error.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 error.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 is 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