record.py
551 lines
| 17.7 KiB
| text/x-python
|
PythonLexer
/ hgext / record.py
|
r5037 | # record.py | ||
# | ||||
# Copyright 2007 Bryan O'Sullivan <bos@serpentine.com> | ||||
# | ||||
|
r8225 | # This software may be used and distributed according to the terms of the | ||
# GNU General Public License version 2, incorporated herein by reference. | ||||
|
r5037 | |||
|
r8934 | '''commands to interactively select changes for commit/qrefresh''' | ||
|
r5037 | |||
|
r7015 | from mercurial.i18n import gettext, _ | ||
|
r6212 | from mercurial import cmdutil, commands, extensions, hg, mdiff, patch | ||
|
r5037 | from mercurial import util | ||
|
r6212 | import copy, cStringIO, errno, operator, os, re, tempfile | ||
|
r5037 | |||
lines_re = re.compile(r'@@ -(\d+),(\d+) \+(\d+),(\d+) @@\s*(.*)') | ||||
def scanpatch(fp): | ||||
|
r5826 | """like patch.iterhunks, but yield different events | ||
- ('file', [header_lines + fromfile + tofile]) | ||||
- ('context', [context_lines]) | ||||
- ('hunk', [hunk_lines]) | ||||
- ('range', (-start,len, +start,len, diffp)) | ||||
""" | ||||
|
r5037 | lr = patch.linereader(fp) | ||
def scanwhile(first, p): | ||||
|
r5826 | """scan lr while predicate holds""" | ||
|
r5037 | lines = [first] | ||
while True: | ||||
line = lr.readline() | ||||
if not line: | ||||
break | ||||
if p(line): | ||||
lines.append(line) | ||||
else: | ||||
lr.push(line) | ||||
break | ||||
return lines | ||||
while True: | ||||
line = lr.readline() | ||||
if not line: | ||||
break | ||||
if line.startswith('diff --git a/'): | ||||
def notheader(line): | ||||
s = line.split(None, 1) | ||||
return not s or s[0] not in ('---', 'diff') | ||||
header = scanwhile(line, notheader) | ||||
fromfile = lr.readline() | ||||
if fromfile.startswith('---'): | ||||
tofile = lr.readline() | ||||
header += [fromfile, tofile] | ||||
else: | ||||
lr.push(fromfile) | ||||
yield 'file', header | ||||
elif line[0] == ' ': | ||||
yield 'context', scanwhile(line, lambda l: l[0] in ' \\') | ||||
elif line[0] in '-+': | ||||
yield 'hunk', scanwhile(line, lambda l: l[0] in '-+\\') | ||||
else: | ||||
m = lines_re.match(line) | ||||
if m: | ||||
yield 'range', m.groups() | ||||
else: | ||||
raise patch.PatchError('unknown patch content: %r' % line) | ||||
class header(object): | ||||
|
r5826 | """patch header | ||
|
r6210 | |||
XXX shoudn't we move this to mercurial/patch.py ? | ||||
|
r5826 | """ | ||
|
r5037 | diff_re = re.compile('diff --git a/(.*) b/(.*)$') | ||
allhunks_re = re.compile('(?:index|new file|deleted file) ') | ||||
pretty_re = re.compile('(?:new file|deleted file) ') | ||||
special_re = re.compile('(?:index|new|deleted|copy|rename) ') | ||||
def __init__(self, header): | ||||
self.header = header | ||||
self.hunks = [] | ||||
def binary(self): | ||||
for h in self.header: | ||||
if h.startswith('index '): | ||||
return True | ||||
|
r5143 | |||
|
r5037 | def pretty(self, fp): | ||
for h in self.header: | ||||
if h.startswith('index '): | ||||
fp.write(_('this modifies a binary file (all or nothing)\n')) | ||||
break | ||||
if self.pretty_re.match(h): | ||||
fp.write(h) | ||||
if self.binary(): | ||||
fp.write(_('this is a binary file\n')) | ||||
break | ||||
if h.startswith('---'): | ||||
fp.write(_('%d hunks, %d lines changed\n') % | ||||
(len(self.hunks), | ||||
sum([h.added + h.removed for h in self.hunks]))) | ||||
break | ||||
fp.write(h) | ||||
def write(self, fp): | ||||
fp.write(''.join(self.header)) | ||||
def allhunks(self): | ||||
for h in self.header: | ||||
if self.allhunks_re.match(h): | ||||
return True | ||||
def files(self): | ||||
fromfile, tofile = self.diff_re.match(self.header[0]).groups() | ||||
if fromfile == tofile: | ||||
return [fromfile] | ||||
return [fromfile, tofile] | ||||
def filename(self): | ||||
return self.files()[-1] | ||||
def __repr__(self): | ||||
return '<header %s>' % (' '.join(map(repr, self.files()))) | ||||
def special(self): | ||||
for h in self.header: | ||||
if self.special_re.match(h): | ||||
return True | ||||
def countchanges(hunk): | ||||
|
r5826 | """hunk -> (n+,n-)""" | ||
|
r5037 | add = len([h for h in hunk if h[0] == '+']) | ||
rem = len([h for h in hunk if h[0] == '-']) | ||||
return add, rem | ||||
class hunk(object): | ||||
|
r5826 | """patch hunk | ||
|
r6210 | |||
|
r5826 | XXX shouldn't we merge this with patch.hunk ? | ||
""" | ||||
|
r5037 | maxcontext = 3 | ||
def __init__(self, header, fromline, toline, proc, before, hunk, after): | ||||
def trimcontext(number, lines): | ||||
delta = len(lines) - self.maxcontext | ||||
if False and delta > 0: | ||||
return number + delta, lines[:self.maxcontext] | ||||
return number, lines | ||||
self.header = header | ||||
self.fromline, self.before = trimcontext(fromline, before) | ||||
self.toline, self.after = trimcontext(toline, after) | ||||
self.proc = proc | ||||
self.hunk = hunk | ||||
self.added, self.removed = countchanges(self.hunk) | ||||
def write(self, fp): | ||||
delta = len(self.before) + len(self.after) | ||||
|
r6949 | if self.after and self.after[-1] == '\\ No newline at end of file\n': | ||
delta -= 1 | ||||
|
r5037 | fromlen = delta + self.removed | ||
tolen = delta + self.added | ||||
fp.write('@@ -%d,%d +%d,%d @@%s\n' % | ||||
(self.fromline, fromlen, self.toline, tolen, | ||||
self.proc and (' ' + self.proc))) | ||||
fp.write(''.join(self.before + self.hunk + self.after)) | ||||
pretty = write | ||||
def filename(self): | ||||
return self.header.filename() | ||||
def __repr__(self): | ||||
return '<hunk %r@%d>' % (self.filename(), self.fromline) | ||||
def parsepatch(fp): | ||||
|
r5826 | """patch -> [] of hunks """ | ||
|
r5037 | class parser(object): | ||
|
r5826 | """patch parsing state machine""" | ||
|
r5037 | def __init__(self): | ||
self.fromline = 0 | ||||
self.toline = 0 | ||||
self.proc = '' | ||||
self.header = None | ||||
self.context = [] | ||||
self.before = [] | ||||
self.hunk = [] | ||||
self.stream = [] | ||||
def addrange(self, (fromstart, fromend, tostart, toend, proc)): | ||||
self.fromline = int(fromstart) | ||||
self.toline = int(tostart) | ||||
self.proc = proc | ||||
def addcontext(self, context): | ||||
if self.hunk: | ||||
h = hunk(self.header, self.fromline, self.toline, self.proc, | ||||
self.before, self.hunk, context) | ||||
self.header.hunks.append(h) | ||||
self.stream.append(h) | ||||
self.fromline += len(self.before) + h.removed | ||||
self.toline += len(self.before) + h.added | ||||
self.before = [] | ||||
self.hunk = [] | ||||
self.proc = '' | ||||
self.context = context | ||||
def addhunk(self, hunk): | ||||
if self.context: | ||||
self.before = self.context | ||||
self.context = [] | ||||
|
r6949 | self.hunk = hunk | ||
|
r5037 | |||
def newfile(self, hdr): | ||||
self.addcontext([]) | ||||
h = header(hdr) | ||||
self.stream.append(h) | ||||
self.header = h | ||||
def finished(self): | ||||
self.addcontext([]) | ||||
return self.stream | ||||
transitions = { | ||||
'file': {'context': addcontext, | ||||
'file': newfile, | ||||
'hunk': addhunk, | ||||
'range': addrange}, | ||||
'context': {'file': newfile, | ||||
'hunk': addhunk, | ||||
'range': addrange}, | ||||
'hunk': {'context': addcontext, | ||||
'file': newfile, | ||||
'range': addrange}, | ||||
'range': {'context': addcontext, | ||||
'hunk': addhunk}, | ||||
} | ||||
|
r5143 | |||
|
r5037 | p = parser() | ||
state = 'context' | ||||
for newstate, data in scanpatch(fp): | ||||
try: | ||||
p.transitions[state][newstate](p, data) | ||||
except KeyError: | ||||
raise patch.PatchError('unhandled transition: %s -> %s' % | ||||
(state, newstate)) | ||||
state = newstate | ||||
return p.finished() | ||||
def filterpatch(ui, chunks): | ||||
|
r5826 | """Interactively filter patch chunks into applied-only chunks""" | ||
|
r5037 | chunks = list(chunks) | ||
chunks.reverse() | ||||
|
r8460 | seen = set() | ||
|
r5037 | def consumefile(): | ||
|
r5826 | """fetch next portion from chunks until a 'header' is seen | ||
NB: header == new-file mark | ||||
""" | ||||
|
r5037 | consumed = [] | ||
while chunks: | ||||
if isinstance(chunks[-1], header): | ||||
break | ||||
else: | ||||
consumed.append(chunks.pop()) | ||||
return consumed | ||||
|
r5826 | |||
resp_all = [None] # this two are changed from inside prompt, | ||||
resp_file = [None] # so can't be usual variables | ||||
applied = {} # 'filename' -> [] of chunks | ||||
|
r5154 | def prompt(query): | ||
|
r5826 | """prompt query, and process base inputs | ||
|
r6210 | |||
|
r5826 | - y/n for the rest of file | ||
- y/n for the rest | ||||
- ? (help) | ||||
- q (quit) | ||||
else, input is returned to the caller. | ||||
""" | ||||
|
r5154 | if resp_all[0] is not None: | ||
return resp_all[0] | ||||
if resp_file[0] is not None: | ||||
return resp_file[0] | ||||
while True: | ||||
|
r8259 | resps = _('[Ynsfdaq?]') | ||
choices = (_('&Yes, record this change'), | ||||
_('&No, skip this change'), | ||||
_('&Skip remaining changes to this file'), | ||||
_('Record remaining changes to this &file'), | ||||
_('&Done, skip remaining changes and files'), | ||||
_('Record &all changes to all remaining files'), | ||||
_('&Quit, recording no changes'), | ||||
_('&?')) | ||||
|
r9461 | r = ui.promptchoice("%s %s" % (query, resps), choices) | ||
|
r9048 | if r == 7: # ? | ||
|
r7015 | doc = gettext(record.__doc__) | ||
c = doc.find(_('y - record this change')) | ||||
for l in doc[c:].splitlines(): | ||||
if l: ui.write(l.strip(), '\n') | ||||
|
r5154 | continue | ||
|
r9048 | elif r == 0: # yes | ||
ret = 'y' | ||||
elif r == 1: # no | ||||
ret = 'n' | ||||
elif r == 2: # Skip | ||||
ret = resp_file[0] = 'n' | ||||
elif r == 3: # file (Record remaining) | ||||
ret = resp_file[0] = 'y' | ||||
elif r == 4: # done, skip remaining | ||||
ret = resp_all[0] = 'n' | ||||
elif r == 5: # all | ||||
ret = resp_all[0] = 'y' | ||||
elif r == 6: # quit | ||||
|
r5154 | raise util.Abort(_('user quit')) | ||
|
r9048 | return ret | ||
|
r7910 | pos, total = 0, len(chunks) - 1 | ||
|
r5037 | while chunks: | ||
chunk = chunks.pop() | ||||
if isinstance(chunk, header): | ||||
|
r5826 | # new-file mark | ||
|
r5154 | resp_file = [None] | ||
|
r5037 | fixoffset = 0 | ||
hdr = ''.join(chunk.header) | ||||
if hdr in seen: | ||||
consumefile() | ||||
continue | ||||
|
r8460 | seen.add(hdr) | ||
|
r5154 | if resp_all[0] is None: | ||
|
r5037 | chunk.pretty(ui) | ||
|
r5285 | r = prompt(_('examine changes to %s?') % | ||
|
r5154 | _(' and ').join(map(repr, chunk.files()))) | ||
|
r7015 | if r == _('y'): | ||
|
r5037 | applied[chunk.filename()] = [chunk] | ||
if chunk.allhunks(): | ||||
applied[chunk.filename()] += consumefile() | ||||
else: | ||||
consumefile() | ||||
else: | ||||
|
r5826 | # new hunk | ||
|
r5154 | if resp_file[0] is None and resp_all[0] is None: | ||
|
r5037 | chunk.pretty(ui) | ||
|
r7910 | r = total == 1 and prompt(_('record this change to %r?') % | ||
|
r7911 | chunk.filename()) \ | ||
or prompt(_('record change %d/%d to %r?') % | ||||
(pos, total, chunk.filename())) | ||||
|
r7015 | if r == _('y'): | ||
|
r5037 | if fixoffset: | ||
chunk = copy.copy(chunk) | ||||
chunk.toline += fixoffset | ||||
applied[chunk.filename()].append(chunk) | ||||
else: | ||||
fixoffset += chunk.removed - chunk.added | ||||
|
r7910 | pos = pos + 1 | ||
|
r5037 | return reduce(operator.add, [h for h in applied.itervalues() | ||
if h[0].special() or len(h) > 1], []) | ||||
def record(ui, repo, *pats, **opts): | ||||
|
r5154 | '''interactively select changes to commit | ||
|
r9272 | If a list of files is omitted, all changes reported by "hg status" | ||
will be candidates for recording. | ||||
|
r5154 | |||
|
r6163 | See 'hg help dates' for a list of formats valid for -d/--date. | ||
|
r9272 | You will be prompted for whether to record changes to each | ||
modified file, and for files with multiple changes, for each | ||||
change to use. For each query, the following responses are | ||||
possible:: | ||||
|
r5154 | |||
|
r9157 | y - record this change | ||
n - skip this change | ||||
|
r5154 | |||
|
r9157 | s - skip remaining changes to this file | ||
f - record remaining changes to this file | ||||
|
r5154 | |||
|
r9157 | d - done, skip remaining changes and files | ||
a - record all changes to all remaining files | ||||
q - quit, recording no changes | ||||
|
r5154 | |||
|
r9157 | ? - display help''' | ||
|
r5037 | |||
|
r5830 | def record_committer(ui, repo, pats, opts): | ||
|
r5827 | commands.commit(ui, repo, *pats, **opts) | ||
|
r5830 | dorecord(ui, repo, record_committer, *pats, **opts) | ||
|
r5932 | def qrecord(ui, repo, patch, *pats, **opts): | ||
'''interactively record a new patch | ||||
|
r5830 | |||
|
r9272 | See 'hg help qnew' & 'hg help record' for more information and | ||
usage. | ||||
|
r5830 | ''' | ||
try: | ||||
mq = extensions.find('mq') | ||||
except KeyError: | ||||
raise util.Abort(_("'mq' extension not loaded")) | ||||
def qrecord_committer(ui, repo, pats, opts): | ||||
|
r5932 | mq.new(ui, repo, patch, *pats, **opts) | ||
|
r5830 | |||
|
r5932 | opts = opts.copy() | ||
opts['force'] = True # always 'qnew -f' | ||||
|
r5830 | dorecord(ui, repo, qrecord_committer, *pats, **opts) | ||
|
r5827 | |||
def dorecord(ui, repo, committer, *pats, **opts): | ||||
|
r8208 | if not ui.interactive(): | ||
|
r5037 | raise util.Abort(_('running non-interactively, use commit instead')) | ||
|
r6600 | def recordfunc(ui, repo, message, match, opts): | ||
|
r5827 | """This is generic record driver. | ||
|
r9087 | Its job is to interactively filter local changes, and accordingly | ||
|
r5827 | prepare working dir into a state, where the job can be delegated to | ||
non-interactive commit command such as 'commit' or 'qrefresh'. | ||||
After the actual job is done by non-interactive command, working dir | ||||
state is restored to original. | ||||
In the end we'll record intresting changes, and everything else will be | ||||
left in place, so the user can continue his work. | ||||
""" | ||||
|
r7754 | |||
changes = repo.status(match=match)[:3] | ||||
|
r5037 | diffopts = mdiff.diffopts(git=True, nodates=True) | ||
|
r7754 | chunks = patch.diff(repo, changes=changes, opts=diffopts) | ||
|
r5037 | fp = cStringIO.StringIO() | ||
|
r7308 | fp.write(''.join(chunks)) | ||
|
r5037 | fp.seek(0) | ||
|
r5827 | # 1. filter patch, so we have intending-to apply subset of it | ||
|
r5037 | chunks = filterpatch(ui, parsepatch(fp)) | ||
del fp | ||||
|
r8152 | contenders = set() | ||
|
r5037 | for h in chunks: | ||
|
r8152 | try: contenders.update(set(h.files())) | ||
|
r5037 | except AttributeError: pass | ||
|
r5143 | |||
|
r7754 | changed = changes[0] + changes[1] + changes[2] | ||
newfiles = [f for f in changed if f in contenders] | ||||
|
r5037 | if not newfiles: | ||
ui.status(_('no changes to record\n')) | ||||
return 0 | ||||
|
r8152 | modified = set(changes[0]) | ||
|
r5037 | |||
|
r5827 | # 2. backup changed files, so we can restore them in the end | ||
|
r5037 | backups = {} | ||
backupdir = repo.join('record-backups') | ||||
try: | ||||
os.mkdir(backupdir) | ||||
except OSError, err: | ||||
|
r5129 | if err.errno != errno.EEXIST: | ||
raise | ||||
|
r5037 | try: | ||
|
r5827 | # backup continues | ||
|
r5037 | for f in newfiles: | ||
if f not in modified: | ||||
continue | ||||
fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.', | ||||
dir=backupdir) | ||||
os.close(fd) | ||||
|
r9467 | ui.debug('backup %r as %r\n' % (f, tmpname)) | ||
|
r5037 | util.copyfile(repo.wjoin(f), tmpname) | ||
backups[f] = tmpname | ||||
fp = cStringIO.StringIO() | ||||
for c in chunks: | ||||
if c.filename() in backups: | ||||
c.write(fp) | ||||
dopatch = fp.tell() | ||||
fp.seek(0) | ||||
|
r5827 | # 3a. apply filtered patch to clean repo (clean) | ||
|
r5037 | if backups: | ||
hg.revert(repo, repo.dirstate.parents()[0], backups.has_key) | ||||
|
r5827 | # 3b. (apply) | ||
|
r5037 | if dopatch: | ||
|
r6950 | try: | ||
|
r9467 | ui.debug('applying patch\n') | ||
|
r6950 | ui.debug(fp.getvalue()) | ||
|
r7718 | pfiles = {} | ||
|
r8811 | patch.internalpatch(fp, ui, 1, repo.root, files=pfiles, | ||
eolmode=None) | ||||
|
r7718 | patch.updatedir(ui, repo, pfiles) | ||
|
r6950 | except patch.PatchError, err: | ||
s = str(err) | ||||
if s: | ||||
raise util.Abort(s) | ||||
else: | ||||
raise util.Abort(_('patch failed to apply')) | ||||
|
r5037 | del fp | ||
|
r5827 | # 4. We prepared working directory according to filtered patch. | ||
# Now is the time to delegate the job to commit/qrefresh or the like! | ||||
# it is important to first chdir to repo root -- we'll call a | ||||
# highlevel command with list of pathnames relative to repo root | ||||
cwd = os.getcwd() | ||||
os.chdir(repo.root) | ||||
try: | ||||
committer(ui, repo, newfiles, opts) | ||||
finally: | ||||
os.chdir(cwd) | ||||
|
r5037 | return 0 | ||
finally: | ||||
|
r5827 | # 5. finally restore backed-up files | ||
|
r5037 | try: | ||
for realname, tmpname in backups.iteritems(): | ||||
|
r9467 | ui.debug('restoring %r to %r\n' % (tmpname, realname)) | ||
|
r5128 | util.copyfile(tmpname, repo.wjoin(realname)) | ||
|
r5037 | os.unlink(tmpname) | ||
os.rmdir(backupdir) | ||||
except OSError: | ||||
pass | ||||
return cmdutil.commit(ui, repo, recordfunc, pats, opts) | ||||
cmdtable = { | ||||
|
r5040 | "record": | ||
(record, | ||||
|
r5830 | |||
# add commit options | ||||
commands.table['^commit|ci'][1], | ||||
|
r5040 | _('hg record [OPTION]... [FILE]...')), | ||
} | ||||
|
r5830 | |||
def extsetup(): | ||||
try: | ||||
mq = extensions.find('mq') | ||||
except KeyError: | ||||
return | ||||
qcmdtable = { | ||||
"qrecord": | ||||
(qrecord, | ||||
|
r5932 | # add qnew options, except '--force' | ||
[opt for opt in mq.cmdtable['qnew'][1] if opt[1] != 'force'], | ||||
|
r5830 | |||
|
r5932 | _('hg qrecord [OPTION]... PATCH [FILE]...')), | ||
|
r5830 | } | ||
cmdtable.update(qcmdtable) | ||||