record.py
557 lines
| 18.4 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 | ||
|
r10263 | # GNU General Public License version 2 or any later version. | ||
|
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 | ||
|
r13099 | import copy, cStringIO, errno, os, re, shutil, 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 | ||||
|
r13157 | if line.startswith('diff --git a/') or line.startswith('diff -r '): | ||
|
r5037 | 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 | """ | ||
|
r13157 | diffgit_re = re.compile('diff --git a/(.*) b/(.*)$') | ||
diff_re = re.compile('diff -r .* (.*)$') | ||||
|
r5037 | 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): | ||||
|
r13294 | return util.any(h.startswith('index ') for h in self.header) | ||
|
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), | ||||
|
r11728 | sum([max(h.added, h.removed) for h in self.hunks]))) | ||
|
r5037 | break | ||
fp.write(h) | ||||
def write(self, fp): | ||||
fp.write(''.join(self.header)) | ||||
def allhunks(self): | ||||
|
r13294 | return util.any(self.allhunks_re.match(h) for h in self.header) | ||
|
r5037 | |||
def files(self): | ||||
|
r13157 | match = self.diffgit_re.match(self.header[0]) | ||
if match: | ||||
fromfile, tofile = match.groups() | ||||
if fromfile == tofile: | ||||
return [fromfile] | ||||
return [fromfile, tofile] | ||||
else: | ||||
return self.diff_re.match(self.header[0]).groups() | ||||
|
r5037 | |||
def filename(self): | ||||
return self.files()[-1] | ||||
def __repr__(self): | ||||
return '<header %s>' % (' '.join(map(repr, self.files()))) | ||||
def special(self): | ||||
|
r13294 | return util.any(self.special_re.match(h) for h in self.header) | ||
|
r5037 | |||
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): | ||||
|
r13293 | """patch -> [] of headers -> [] 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 = [] | ||||
|
r13293 | self.headers = [] | ||
|
r5037 | |||
|
r11499 | def addrange(self, limits): | ||
fromstart, fromend, tostart, toend, proc = limits | ||||
|
r5037 | 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.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) | ||||
|
r13293 | self.headers.append(h) | ||
|
r5037 | self.header = h | ||
def finished(self): | ||||
self.addcontext([]) | ||||
|
r13293 | return self.headers | ||
|
r5037 | |||
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() | ||||
|
r13293 | def filterpatch(ui, headers): | ||
|
r5826 | """Interactively filter patch chunks into applied-only chunks""" | ||
|
r13291 | def prompt(skipfile, skipall, 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) | ||||
|
r13291 | Return True/False and possibly updated skipfile and skipall. | ||
|
r5826 | """ | ||
|
r13291 | if skipall is not None: | ||
return skipall, skipfile, skipall | ||||
if skipfile is not None: | ||||
return skipfile, skipfile, skipall | ||||
|
r5154 | 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) | ||
|
r10694 | ui.write("\n") | ||
|
r9048 | if r == 7: # ? | ||
|
r7015 | doc = gettext(record.__doc__) | ||
|
r11236 | c = doc.find('::') + 2 | ||
|
r7015 | for l in doc[c:].splitlines(): | ||
|
r11236 | if l.startswith(' '): | ||
|
r10282 | ui.write(l.strip(), '\n') | ||
|
r5154 | continue | ||
|
r9048 | elif r == 0: # yes | ||
|
r9837 | ret = True | ||
|
r9048 | elif r == 1: # no | ||
|
r9837 | ret = False | ||
|
r9048 | elif r == 2: # Skip | ||
|
r13291 | ret = skipfile = False | ||
|
r9048 | elif r == 3: # file (Record remaining) | ||
|
r13291 | ret = skipfile = True | ||
|
r9048 | elif r == 4: # done, skip remaining | ||
|
r13291 | ret = skipall = False | ||
|
r9048 | elif r == 5: # all | ||
|
r13291 | ret = skipall = True | ||
|
r9048 | elif r == 6: # quit | ||
|
r5154 | raise util.Abort(_('user quit')) | ||
|
r13291 | return ret, skipfile, skipall | ||
seen = set() | ||||
applied = {} # 'filename' -> [] of chunks | ||||
skipfile, skipall = None, None | ||||
|
r13295 | pos, total = 1, sum(len(h.hunks) for h in headers) | ||
|
r13293 | for h in headers: | ||
|
r13295 | pos += len(h.hunks) | ||
|
r13293 | skipfile = None | ||
fixoffset = 0 | ||||
hdr = ''.join(h.header) | ||||
if hdr in seen: | ||||
continue | ||||
seen.add(hdr) | ||||
if skipall is None: | ||||
h.pretty(ui) | ||||
msg = (_('examine changes to %s?') % | ||||
_(' and ').join(map(repr, h.files()))) | ||||
r, skipfile, skipall = prompt(skipfile, skipall, msg) | ||||
if not r: | ||||
continue | ||||
applied[h.filename()] = [h] | ||||
if h.allhunks(): | ||||
applied[h.filename()] += h.hunks | ||||
continue | ||||
for i, chunk in enumerate(h.hunks): | ||||
|
r13291 | if skipfile is None and skipall is None: | ||
|
r5037 | chunk.pretty(ui) | ||
|
r13773 | if total == 1: | ||
msg = _('record this change to %r?') % chunk.filename() | ||||
else: | ||||
idx = pos - len(h.hunks) + i | ||||
msg = _('record change %d/%d to %r?') % (idx, total, | ||||
chunk.filename()) | ||||
|
r13291 | r, skipfile, skipall = prompt(skipfile, skipall, msg) | ||
|
r9837 | if r: | ||
|
r5037 | if fixoffset: | ||
chunk = copy.copy(chunk) | ||||
chunk.toline += fixoffset | ||||
applied[chunk.filename()].append(chunk) | ||||
else: | ||||
fixoffset += chunk.removed - chunk.added | ||||
|
r11500 | return sum([h for h in applied.itervalues() | ||
if h[0].special() or len(h) > 1], []) | ||||
|
r5037 | |||
def record(ui, repo, *pats, **opts): | ||||
|
r5154 | '''interactively select changes to commit | ||
|
r10973 | If a list of files is omitted, all changes reported by :hg:`status` | ||
|
r9272 | will be candidates for recording. | ||
|
r5154 | |||
|
r10973 | See :hg:`help dates` for a list of formats valid for -d/--date. | ||
|
r6163 | |||
|
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 | |||
|
r11237 | ? - display help | ||
This command is not available when committing a merge.''' | ||||
|
r5037 | |||
|
r10323 | dorecord(ui, repo, commands.commit, *pats, **opts) | ||
|
r5830 | |||
|
r5932 | def qrecord(ui, repo, patch, *pats, **opts): | ||
'''interactively record a new patch | ||||
|
r5830 | |||
|
r10973 | See :hg:`help qnew` & :hg:`help record` for more information and | ||
|
r9272 | usage. | ||
|
r5830 | ''' | ||
try: | ||||
mq = extensions.find('mq') | ||||
except KeyError: | ||||
raise util.Abort(_("'mq' extension not loaded")) | ||||
|
r10323 | def committomq(ui, repo, *pats, **opts): | ||
|
r5932 | mq.new(ui, repo, patch, *pats, **opts) | ||
|
r5830 | |||
|
r10323 | dorecord(ui, repo, committomq, *pats, **opts) | ||
|
r5827 | |||
|
r10323 | def dorecord(ui, repo, commitfunc, *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. | ||
|
r13195 | Its job is to interactively filter local changes, and | ||
accordingly prepare working directory into a state in which the | ||||
job can be delegated to a non-interactive commit command such as | ||||
'commit' or 'qrefresh'. | ||||
|
r5827 | |||
|
r13195 | After the actual job is done by non-interactive command, the | ||
working directory is restored to its original state. | ||||
|
r5827 | |||
|
r13195 | In the end we'll record interesting changes, and everything else | ||
will be left in place, so the user can continue working. | ||||
|
r5827 | """ | ||
|
r7754 | |||
|
r11237 | merge = len(repo[None].parents()) > 1 | ||
if merge: | ||||
raise util.Abort(_('cannot partially commit a merge ' | ||||
|
r13023 | '(use "hg commit" instead)')) | ||
|
r11237 | |||
|
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: | ||
|
r10282 | try: | ||
contenders.update(set(h.files())) | ||||
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) | ||
|
r13099 | shutil.copystat(repo.wjoin(f), tmpname) | ||
|
r5037 | 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: | ||
|
r13878 | hg.revert(repo, repo.dirstate.p1(), | ||
|
r11564 | lambda key: key in backups) | ||
|
r5037 | |||
|
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) | ||||
|
r12266 | cmdutil.updatedir(ui, repo, pfiles) | ||
|
r6950 | except patch.PatchError, err: | ||
|
r12674 | raise util.Abort(str(err)) | ||
|
r5037 | del fp | ||
|
r13195 | # 4. We prepared working directory according to filtered | ||
# patch. Now is the time to delegate the job to | ||||
# commit/qrefresh or the like! | ||||
|
r5827 | |||
|
r13195 | # it is important to first chdir to repo root -- we'll call | ||
# a highlevel command with list of pathnames relative to | ||||
# repo root | ||||
|
r5827 | cwd = os.getcwd() | ||
os.chdir(repo.root) | ||||
try: | ||||
|
r10323 | commitfunc(ui, repo, *newfiles, **opts) | ||
|
r5827 | 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)) | ||
|
r13099 | # Our calls to copystat() here and above are a | ||
# hack to trick any editors that have f open that | ||||
# we haven't modified them. | ||||
# | ||||
# Also note that this racy as an editor could | ||||
# notice the file's mtime before we've finished | ||||
# writing it. | ||||
shutil.copystat(tmpname, repo.wjoin(realname)) | ||||
|
r5037 | os.unlink(tmpname) | ||
os.rmdir(backupdir) | ||||
except OSError: | ||||
pass | ||||
|
r10825 | |||
# wrap ui.write so diff output can be labeled/colorized | ||||
def wrapwrite(orig, *args, **kw): | ||||
label = kw.pop('label', '') | ||||
for chunk, l in patch.difflabel(lambda: args): | ||||
orig(chunk, label=label + l) | ||||
oldwrite = ui.write | ||||
extensions.wrapfunction(ui, 'write', wrapwrite) | ||||
try: | ||||
return cmdutil.commit(ui, repo, recordfunc, pats, opts) | ||||
finally: | ||||
ui.write = oldwrite | ||||
|
r5037 | |||
cmdtable = { | ||||
|
r5040 | "record": | ||
|
r13196 | (record, commands.table['^commit|ci'][1], # same options as commit | ||
|
r5040 | _('hg record [OPTION]... [FILE]...')), | ||
|
r13936 | "qrecord": | ||
(qrecord, {}, # placeholder until mq is available | ||||
_('hg qrecord [OPTION]... PATCH [FILE]...')), | ||||
|
r5040 | } | ||
|
r5830 | |||
|
r9710 | def uisetup(ui): | ||
|
r5830 | try: | ||
mq = extensions.find('mq') | ||||
except KeyError: | ||||
return | ||||
qcmdtable = { | ||||
"qrecord": | ||||
|
r13196 | (qrecord, mq.cmdtable['^qnew'][1], # same options as qnew | ||
|
r5932 | _('hg qrecord [OPTION]... PATCH [FILE]...')), | ||
|
r5830 | } | ||
cmdtable.update(qcmdtable) | ||||