mirror of
https://github.com/facebook/sapling.git
synced 2024-10-11 09:17:30 +03:00
31b8b83011
This makes the default behavior consistent between "histedit" and "chistedit" in picking the ancestor revision to use when none is specified.
605 lines
20 KiB
Python
605 lines
20 KiB
Python
# 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 changeset via space and move
|
|
it around. You can use d/e/f/m/r to change the action of a changeset. You
|
|
can cycle through available commands with left/h or right/l.
|
|
|
|
The diff for the current changeset can be viewed by pressing v. To apply
|
|
the commands press C, which will call histedit.
|
|
|
|
The current conflict detection mechanism is based on a per-file
|
|
comparison. 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,
|
|
destutil,
|
|
node,
|
|
scmutil,
|
|
error,
|
|
util,
|
|
)
|
|
from mercurial.i18n import _
|
|
|
|
import functools
|
|
import os
|
|
import sys
|
|
|
|
try:
|
|
import curses
|
|
except ImportError:
|
|
print("Python curses library required", file=sys.stderr)
|
|
|
|
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
|
|
E_PAGEDOWN, E_PAGEUP, E_LINEUP, E_LINEDOWN, E_RESIZE = 3, 4, 5, 6, 7
|
|
MODE_INIT, MODE_PATCH, MODE_RULES, MODE_HELP = 0, 1, 2, 3
|
|
|
|
KEYTABLE = {
|
|
'global': {
|
|
'h': 'next-action',
|
|
'KEY_RIGHT': 'next-action',
|
|
'l': 'prev-action',
|
|
'KEY_LEFT': 'prev-action',
|
|
'q': 'quit',
|
|
'c': 'histedit',
|
|
'C': 'histedit',
|
|
'v': 'showpatch',
|
|
'?': 'help',
|
|
},
|
|
MODE_RULES: {
|
|
'd': 'action-drop',
|
|
'e': 'action-edit',
|
|
'f': 'action-fold',
|
|
'm': 'action-mess',
|
|
'p': 'action-pick',
|
|
'r': 'action-roll',
|
|
' ': 'select',
|
|
'j': 'down',
|
|
'k': 'up',
|
|
'KEY_DOWN': 'down',
|
|
'KEY_UP': 'up',
|
|
'J': 'move-down',
|
|
'K': 'move-up',
|
|
'KEY_NPAGE': 'move-down',
|
|
'KEY_PPAGE': 'move-up',
|
|
'0': 'goto', # Used for 0..9
|
|
},
|
|
MODE_PATCH: {
|
|
' ': 'page-down',
|
|
'KEY_NPAGE': 'page-down',
|
|
'KEY_PPAGE': 'page-up',
|
|
'j': 'line-down',
|
|
'k': 'line-up',
|
|
'KEY_DOWN': 'line-down',
|
|
'KEY_UP': 'line-up',
|
|
'J': 'down',
|
|
'K': 'up',
|
|
},
|
|
MODE_HELP: {
|
|
},
|
|
}
|
|
|
|
def screen_size():
|
|
import termios
|
|
from fcntl import ioctl
|
|
from struct import unpack
|
|
return unpack('hh', ioctl(1, termios.TIOCGWINSZ, ' '))
|
|
|
|
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):
|
|
'''Change the rule/changeset that the cursor is pointing to, regardless of
|
|
current mode (you can switch between patches from the view patch window).'''
|
|
state['pos'] = newpos
|
|
|
|
mode, _ = state['mode']
|
|
if mode == MODE_RULES:
|
|
# Scroll through the list by updating the view for MODE_RULES, so that
|
|
# even if we are not currently viewing the rules, switching back will
|
|
# result in the cursor's rule being visible.
|
|
modestate = state['modes'][MODE_RULES]
|
|
if newpos < modestate['line_offset']:
|
|
modestate['line_offset'] = newpos
|
|
elif newpos > modestate['line_offset'] + state['page_height'] - 1:
|
|
modestate['line_offset'] = newpos - state['page_height'] + 1
|
|
|
|
# Reset the patch view region to the top of the new patch.
|
|
state['modes'][MODE_PATCH]['line_offset'] = 0
|
|
|
|
def changemode(state, mode):
|
|
curmode, _ = state['mode']
|
|
state['mode'] = (mode, curmode)
|
|
|
|
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_LIST
|
|
|
|
index = KEY_LIST.index(current)
|
|
if next:
|
|
index += 1
|
|
else:
|
|
index -= 1
|
|
changeaction(state, pos, KEY_LIST[index % len(KEY_LIST)])
|
|
|
|
def changeview(state, delta, unit):
|
|
'''Change the region of whatever is being viewed (a patch or the list of
|
|
changesets). 'delta' is an amount (+/- 1) and 'unit' is 'page' or 'line'.'''
|
|
mode, _ = state['mode']
|
|
if mode != MODE_PATCH:
|
|
return
|
|
mode_state = state['modes'][mode]
|
|
num_lines = len(patchcontents(state))
|
|
page_height = state['page_height']
|
|
unit = page_height if unit == 'page' else 1
|
|
num_pages = 1 + (num_lines - 1) / page_height
|
|
max_offset = (num_pages - 1) * page_height
|
|
newline = mode_state['line_offset'] + delta * unit
|
|
mode_state['line_offset'] = max(0, min(max_offset, newline))
|
|
|
|
def event(state, ch):
|
|
"""Change state based on the current character input
|
|
|
|
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 (curses.KEY_RESIZE, "KEY_RESIZE"):
|
|
return E_RESIZE
|
|
|
|
lookup_ch = ch
|
|
if '0' <= ch <= '9':
|
|
lookup_ch = '0'
|
|
|
|
curmode, prevmode = state['mode']
|
|
action = KEYTABLE[curmode].get(lookup_ch, KEYTABLE['global'].get(lookup_ch))
|
|
if action is None:
|
|
return
|
|
if action in ('down', 'move-down'):
|
|
newpos = min(oldpos + 1, len(rules) - 1)
|
|
movecursor(state, oldpos, newpos)
|
|
if selected is not None or action == 'move-down':
|
|
swap(state, oldpos, newpos)
|
|
elif action in ('up', 'move-up'):
|
|
newpos = max(0, oldpos - 1)
|
|
movecursor(state, oldpos, newpos)
|
|
if selected is not None or action == 'move-up':
|
|
swap(state, oldpos, newpos)
|
|
elif action == 'next-action':
|
|
cycleaction(state, oldpos, next=True)
|
|
elif action == 'prev-action':
|
|
cycleaction(state, oldpos, next=False)
|
|
elif action == 'select':
|
|
selected = oldpos if selected is None else None
|
|
makeselection(state, selected)
|
|
elif action == 'goto' and int(ch) < len(rules) and len(rules) <= 10:
|
|
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)
|
|
elif action.startswith('action-'):
|
|
changeaction(state, oldpos, action[7:])
|
|
elif action == 'showpatch':
|
|
changemode(state, MODE_PATCH if curmode != MODE_PATCH else prevmode)
|
|
elif action == 'help':
|
|
changemode(state, MODE_HELP if curmode != MODE_HELP else prevmode)
|
|
elif action == 'quit':
|
|
return E_QUIT
|
|
elif action == 'histedit':
|
|
return E_HISTEDIT
|
|
elif action == 'page-down':
|
|
return E_PAGEDOWN
|
|
elif action == 'page-up':
|
|
return E_PAGEUP
|
|
elif action == 'line-down':
|
|
return E_LINEDOWN
|
|
elif action == 'line-up':
|
|
return E_LINEUP
|
|
|
|
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 patchcontents(state):
|
|
repo = state['repo']
|
|
rule = state['rules'][state['pos']]
|
|
displayer = cmdutil.show_changeset(repo.ui, repo, {
|
|
'patch': True, 'verbose': True
|
|
}, buffered=True)
|
|
displayer.show(rule.ctx)
|
|
displayer.close()
|
|
return displayer.hunk[rule.ctx.rev()].splitlines()
|
|
|
|
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()
|
|
|
|
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 helplines(mode):
|
|
if mode == MODE_PATCH:
|
|
help = """\
|
|
?: help, k/up: line up, j/down: line down, v: stop viewing patch
|
|
pgup: prev page, space/pgdn: next page, c: commit, q: abort
|
|
"""
|
|
else:
|
|
help = """\
|
|
?: help, k/up: move up, j/down: move down, space: select, v: view patch
|
|
d: drop, e: edit, f: fold, m: mess, p: pick, r: roll
|
|
pgup/K: move patch up, pgdn/J: move patch down, c: commit, q: abort
|
|
"""
|
|
return help.splitlines()
|
|
|
|
def renderhelp(win, state):
|
|
maxy, maxx = win.getmaxyx()
|
|
mode, _ = state['mode']
|
|
for y, line in enumerate(helplines(mode)):
|
|
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']
|
|
start = state['modes'][MODE_RULES]['line_offset']
|
|
|
|
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[start:]):
|
|
if y >= state['page_height']:
|
|
break
|
|
if len(rule.conflicts) > 0:
|
|
rulesscr.addstr(y, 0, " ", curses.color_pair(COLOR_WARN))
|
|
else:
|
|
rulesscr.addstr(y, 0, " ", curses.COLOR_BLACK)
|
|
if y + start == selected:
|
|
addln(rulesscr, y, 2, rule, curses.color_pair(COLOR_SELECTED))
|
|
elif y + start == 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 - 1, len(output))
|
|
for y in range(0, length):
|
|
win.addstr(y, 0, output[y])
|
|
win.noutrefresh()
|
|
|
|
def renderpatch(win, state):
|
|
start = state['modes'][MODE_PATCH]['line_offset']
|
|
renderstring(win, state, patchcontents(state)[start:])
|
|
|
|
def layout(mode):
|
|
maxy, maxx = stdscr.getmaxyx()
|
|
helplen = len(helplines(mode))
|
|
return {
|
|
'commit': (8, maxx),
|
|
'help': (helplen, maxx),
|
|
'main': (maxy - helplen - 8, maxx),
|
|
}
|
|
|
|
def drawvertwin(size, y, x):
|
|
win = curses.newwin(size[0], size[1], y, x)
|
|
y += size[0]
|
|
return win, y, x
|
|
|
|
state = {
|
|
'pos': 0,
|
|
'rules': rules,
|
|
'selected': None,
|
|
'mode': (MODE_INIT, MODE_INIT),
|
|
'page_height': None,
|
|
'modes': {
|
|
MODE_RULES: {
|
|
'line_offset': 0,
|
|
},
|
|
MODE_PATCH: {
|
|
'line_offset': 0,
|
|
}
|
|
},
|
|
'repo': repo,
|
|
}
|
|
|
|
# eventloop
|
|
ch = None
|
|
stdscr.clear()
|
|
stdscr.refresh()
|
|
while True:
|
|
try:
|
|
oldmode, _ = state['mode']
|
|
if oldmode == MODE_INIT:
|
|
changemode(state, MODE_RULES)
|
|
e = event(state, ch)
|
|
|
|
if e == E_QUIT:
|
|
return False
|
|
if e == E_HISTEDIT:
|
|
return state['rules']
|
|
else:
|
|
if e == E_RESIZE:
|
|
size = screen_size()
|
|
if size != stdscr.getmaxyx():
|
|
curses.resizeterm(*size)
|
|
|
|
curmode, _ = state['mode']
|
|
sizes = layout(curmode)
|
|
if curmode != oldmode:
|
|
state['page_height'] = sizes['main'][0]
|
|
# Adjust the view to fit the current screen size.
|
|
movecursor(state, state['pos'], state['pos'])
|
|
|
|
# Pack the windows against the top, each pane spread across the
|
|
# full width of the screen.
|
|
y, x = (0, 0)
|
|
helpwin, y, x = drawvertwin(sizes['help'], y, x)
|
|
mainwin, y, x = drawvertwin(sizes['main'], y, x)
|
|
commitwin, y, x = drawvertwin(sizes['commit'], y, x)
|
|
|
|
if e in (E_PAGEDOWN, E_PAGEUP, E_LINEDOWN, E_LINEUP):
|
|
if e == E_PAGEDOWN:
|
|
changeview(state, +1, 'page')
|
|
elif e == E_PAGEUP:
|
|
changeview(state, -1, 'page')
|
|
elif e == E_LINEDOWN:
|
|
changeview(state, +1, 'line')
|
|
elif e == E_LINEUP:
|
|
changeview(state, -1, 'line')
|
|
|
|
# start rendering
|
|
commitwin.erase()
|
|
helpwin.erase()
|
|
mainwin.erase()
|
|
if curmode == MODE_PATCH:
|
|
renderpatch(mainwin, state)
|
|
elif curmode == MODE_HELP:
|
|
renderstring(mainwin, state, __doc__.strip().splitlines())
|
|
else:
|
|
renderrules(mainwin, state)
|
|
rendercommit(commitwin, state)
|
|
renderhelp(helpwin, state)
|
|
curses.doupdate()
|
|
# done rendering
|
|
ch = stdscr.getkey()
|
|
except curses.error:
|
|
pass
|
|
|
|
cmdtable = {}
|
|
command = cmdutil.command(cmdtable)
|
|
|
|
testedwith = 'ships-with-fb-hgext'
|
|
|
|
@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."""
|
|
|
|
# disable color
|
|
ui._colormode = None
|
|
|
|
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 not revs:
|
|
defaultrev = destutil.desthistedit(ui, repo)
|
|
if defaultrev is not None:
|
|
revs.append(defaultrev)
|
|
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.vfs.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
|