# HG changeset patch # User Bryan O'Sullivan # Date 1185924485 25200 # Node ID b2607267236dfacfc24f5f62115c8df99c8f6592 # Parent ca0d02222d6a4075c03bf30d231fb28d1cb999ae Add record extension, giving darcs-like interactive hunk picking diff --git a/hgext/record.py b/hgext/record.py new file mode 100644 --- /dev/null +++ b/hgext/record.py @@ -0,0 +1,381 @@ +# record.py +# +# Copyright 2007 Bryan O'Sullivan +# +# This software may be used and distributed according to the terms of +# the GNU General Public License, incorporated herein by reference. + +'''interactive change selection during commit''' + +from mercurial.i18n import _ +from mercurial import cmdutil, commands, cmdutil, hg, mdiff, patch, revlog +from mercurial import util +import copy, cStringIO, errno, operator, os, re, shutil, tempfile + +lines_re = re.compile(r'@@ -(\d+),(\d+) \+(\d+),(\d+) @@\s*(.*)') + +def scanpatch(fp): + lr = patch.linereader(fp) + + def scanwhile(first, p): + 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): + 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 + + 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 '
' % (' '.join(map(repr, self.files()))) + + def special(self): + for h in self.header: + if self.special_re.match(h): + return True + +def countchanges(hunk): + 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): + 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) + 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 '' % (self.filename(), self.fromline) + +def parsepatch(fp): + class parser(object): + 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 = [] + self.hunk = data + + 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}, + } + + 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): + chunks = list(chunks) + chunks.reverse() + seen = {} + def consumefile(): + consumed = [] + while chunks: + if isinstance(chunks[-1], header): + break + else: + consumed.append(chunks.pop()) + return consumed + resp = None + applied = {} + while chunks: + chunk = chunks.pop() + if isinstance(chunk, header): + fixoffset = 0 + hdr = ''.join(chunk.header) + if hdr in seen: + consumefile() + continue + seen[hdr] = True + if not resp: + chunk.pretty(ui) + r = resp or ui.prompt(_('record changes to %s? [y]es [n]o') % + _(' and ').join(map(repr, chunk.files())), + '(?:|[yYnNqQaA])$') or 'y' + if r in 'aA': + r = 'y' + resp = 'y' + if r in 'qQ': + raise util.Abort(_('user quit')) + if r in 'yY': + applied[chunk.filename()] = [chunk] + if chunk.allhunks(): + applied[chunk.filename()] += consumefile() + else: + consumefile() + else: + if not resp: + chunk.pretty(ui) + r = resp or ui.prompt(_('record this change to %r? [y]es [n]o') % + chunk.filename(), '(?:|[yYnNqQaA])$') or 'y' + if r in 'aA': + r = 'y' + resp = 'y' + if r in 'qQ': + raise util.Abort(_('user quit')) + if r in 'yY': + if fixoffset: + chunk = copy.copy(chunk) + chunk.toline += fixoffset + applied[chunk.filename()].append(chunk) + else: + fixoffset += chunk.removed - chunk.added + return reduce(operator.add, [h for h in applied.itervalues() + if h[0].special() or len(h) > 1], []) + +def record(ui, repo, *pats, **opts): + '''interactively select changes to commit''' + + if not ui.interactive: + raise util.Abort(_('running non-interactively, use commit instead')) + + def recordfunc(ui, repo, files, message, match, opts): + if files: + changes = None + else: + changes = repo.status(files=files, match=match)[:5] + modified, added, removed = changes[:3] + files = modified + added + removed + diffopts = mdiff.diffopts(git=True, nodates=True) + fp = cStringIO.StringIO() + patch.diff(repo, repo.dirstate.parents()[0], files=files, + match=match, changes=changes, opts=diffopts, fp=fp) + fp.seek(0) + + chunks = filterpatch(ui, parsepatch(fp)) + del fp + + contenders = {} + for h in chunks: + try: contenders.update(dict.fromkeys(h.files())) + except AttributeError: pass + + newfiles = [f for f in files if f in contenders] + + if not newfiles: + ui.status(_('no changes to record\n')) + return 0 + + if changes is None: + changes = repo.status(files=newfiles, match=match)[:5] + modified = dict.fromkeys(changes[0]) + + backups = {} + backupdir = repo.join('record-backups') + try: + os.mkdir(backupdir) + except OSError, err: + if err.errno == errno.EEXIST: + pass + try: + for f in newfiles: + if f not in modified: + continue + fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.', + dir=backupdir) + os.close(fd) + ui.debug('backup %r as %r\n' % (f, tmpname)) + 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) + + if backups: + hg.revert(repo, repo.dirstate.parents()[0], backups.has_key) + + if dopatch: + ui.debug('applying patch\n') + ui.debug(fp.getvalue()) + patch.internalpatch(fp, ui, 1, repo.root) + del fp + + repo.commit(newfiles, message, opts['user'], opts['date'], match, + force_editor=opts.get('force_editor')) + return 0 + finally: + try: + for realname, tmpname in backups.iteritems(): + ui.debug('restoring %r to %r\n' % (tmpname, realname)) + util.copyfile(tmpname, realname) + os.unlink(tmpname) + os.rmdir(backupdir) + except OSError: + pass + return cmdutil.commit(ui, repo, recordfunc, pats, opts) + +cmdtable = { + 'record': + (record, [('A', 'addremove', None, + _('mark new/missing files as added/removed before committing')), + ('d', 'date', '', _('record datecode as commit date')), + ('u', 'user', '', _('record user as commiter')), + ] + commands.walkopts + commands.commitopts, + _('hg record [FILE]...')), + } diff --git a/tests/test-record b/tests/test-record new file mode 100755 --- /dev/null +++ b/tests/test-record @@ -0,0 +1,204 @@ +#!/bin/sh + +echo "[ui]" >> $HGRCPATH +echo "interactive=true" >> $HGRCPATH +echo "[extensions]" >> $HGRCPATH +echo "record=" >> $HGRCPATH + +echo % help + +hg help record + +hg init a +cd a + +echo % select no files + +touch empty-rw +hg add empty-rw +hg record empty-rw<> plain +done + +hg add plain +hg record -d '7 0' -m plain plain<> plain +hg record -d '8 0' -m end plain <> plain +hg record -d '9 0' -m noeol plain <> plain +hg record -d '10 0' -m eol plain <> plain +done + +hg record -d '10 0' -m begin-and-end plain <> plain +done + +echo % record end + +hg record -d '11 0' -m end-only plain <> plain +done + +echo % record end + +hg record --traceback -d '13 0' -m end-again plain<> plain +done + +echo % record beginning, middle + +hg record -d '14 0' -m middle-only plain < as commit message + -l --logfile read commit message from + +use "hg -v help record" to show global options +% select no files +diff --git a/empty-rw b/empty-rw +new file mode 100644 +record changes to 'empty-rw'? [y]es [n]o no changes to record + +changeset: -1:000000000000 +tag: tip +user: +date: Thu Jan 01 00:00:00 1970 +0000 + + +% select files but no hunks +diff --git a/empty-rw b/empty-rw +new file mode 100644 +record changes to 'empty-rw'? [y]es [n]o transaction abort! +rollback completed + +changeset: -1:000000000000 +tag: tip +user: +date: Thu Jan 01 00:00:00 1970 +0000 + + +% record empty file +diff --git a/empty-rw b/empty-rw +new file mode 100644 +record changes to 'empty-rw'? [y]es [n]o +changeset: 0:c0708cf4e46e +tag: tip +user: test +date: Thu Jan 01 00:00:00 1970 +0000 +summary: empty + + +% rename empty file +diff --git a/empty-rw b/empty-rename +rename from empty-rw +rename to empty-rename +record changes to 'empty-rw' and 'empty-rename'? [y]es [n]o +changeset: 1:df251d174da3 +tag: tip +user: test +date: Thu Jan 01 00:00:01 1970 +0000 +summary: rename + + +% copy empty file +diff --git a/empty-rename b/empty-copy +copy from empty-rename +copy to empty-copy +record changes to 'empty-rename' and 'empty-copy'? [y]es [n]o +changeset: 2:b63ea3939f8d +tag: tip +user: test +date: Thu Jan 01 00:00:02 1970 +0000 +summary: copy + + +% delete empty file +diff --git a/empty-copy b/empty-copy +deleted file mode 100644 +record changes to 'empty-copy'? [y]es [n]o +changeset: 3:a2546574bce9 +tag: tip +user: test +date: Thu Jan 01 00:00:03 1970 +0000 +summary: delete + + +% add binary file +diff --git a/tip.bundle b/tip.bundle +new file mode 100644 +this is a binary file +record changes to 'tip.bundle'? [y]es [n]o +changeset: 4:9e998a545a8b +tag: tip +user: test +date: Thu Jan 01 00:00:04 1970 +0000 +summary: binary + +diff -r a2546574bce9 -r 9e998a545a8b tip.bundle +Binary file tip.bundle has changed + +% change binary file +diff --git a/tip.bundle b/tip.bundle +this modifies a binary file (all or nothing) +record changes to 'tip.bundle'? [y]es [n]o +changeset: 5:93d05561507d +tag: tip +user: test +date: Thu Jan 01 00:00:05 1970 +0000 +summary: binary-change + +diff -r 9e998a545a8b -r 93d05561507d tip.bundle +Binary file tip.bundle has changed + +% rename and change binary file +diff --git a/tip.bundle b/top.bundle +rename from tip.bundle +rename to top.bundle +this modifies a binary file (all or nothing) +record changes to 'tip.bundle' and 'top.bundle'? [y]es [n]o +changeset: 6:699cc1bea9aa +tag: tip +user: test +date: Thu Jan 01 00:00:06 1970 +0000 +summary: binary-change-rename + +diff -r 93d05561507d -r 699cc1bea9aa tip.bundle +Binary file tip.bundle has changed +diff -r 93d05561507d -r 699cc1bea9aa top.bundle +Binary file top.bundle has changed + +% add plain file +diff --git a/plain b/plain +new file mode 100644 +record changes to 'plain'? [y]es [n]o +changeset: 7:118ed744216b +tag: tip +user: test +date: Thu Jan 01 00:00:07 1970 +0000 +summary: plain + +diff -r 699cc1bea9aa -r 118ed744216b plain +--- /dev/null Thu Jan 01 00:00:00 1970 +0000 ++++ b/plain Thu Jan 01 00:00:07 1970 +0000 +@@ -0,0 +1,10 @@ ++1 ++2 ++3 ++4 ++5 ++6 ++7 ++8 ++9 ++10 + +% modify end of plain file +diff --git a/plain b/plain +1 hunks, 1 lines changed +record changes to 'plain'? [y]es [n]o @@ -8,3 +8,4 @@ 8 + 8 + 9 + 10 ++11 +record this change to 'plain'? [y]es [n]o % modify end of plain file, no EOL +diff --git a/plain b/plain +1 hunks, 1 lines changed +record changes to 'plain'? [y]es [n]o @@ -9,3 +9,4 @@ 9 + 9 + 10 + 11 ++cf81a2760718a74d44c0c2eecb72f659e63a69c5 +\ No newline at end of file +record this change to 'plain'? [y]es [n]o % modify end of plain file, add EOL +diff --git a/plain b/plain +1 hunks, 2 lines changed +record changes to 'plain'? [y]es [n]o @@ -9,4 +9,4 @@ 9 + 9 + 10 + 11 +-cf81a2760718a74d44c0c2eecb72f659e63a69c5 +\ No newline at end of file ++cf81a2760718a74d44c0c2eecb72f659e63a69c5 +record this change to 'plain'? [y]es [n]o % modify beginning, trim end, record both +diff --git a/plain b/plain +2 hunks, 4 lines changed +record changes to 'plain'? [y]es [n]o @@ -1,4 +1,4 @@ 1 +-1 ++2 + 2 + 3 + 4 +record this change to 'plain'? [y]es [n]o @@ -8,5 +8,3 @@ 8 + 8 + 9 + 10 +-11 +-cf81a2760718a74d44c0c2eecb72f659e63a69c5 +record this change to 'plain'? [y]es [n]o +changeset: 11:d09ab1967dab +tag: tip +user: test +date: Thu Jan 01 00:00:10 1970 +0000 +summary: begin-and-end + +diff -r e2ecd9b0b78d -r d09ab1967dab plain +--- a/plain Thu Jan 01 00:00:10 1970 +0000 ++++ b/plain Thu Jan 01 00:00:10 1970 +0000 +@@ -1,4 +1,4 @@ 1 +-1 ++2 + 2 + 3 + 4 +@@ -8,5 +8,3 @@ 8 + 8 + 9 + 10 +-11 +-cf81a2760718a74d44c0c2eecb72f659e63a69c5 + +% trim beginning, modify end +% record end +diff --git a/plain b/plain +2 hunks, 5 lines changed +record changes to 'plain'? [y]es [n]o @@ -1,9 +1,6 @@ 2 +-2 +-2 +-3 + 4 + 5 + 6 + 7 + 8 + 9 +record this change to 'plain'? [y]es [n]o @@ -4,7 +1,7 @@ + 4 + 5 + 6 + 7 + 8 + 9 +-10 ++10.new +record this change to 'plain'? [y]es [n]o +changeset: 12:44516c9708ae +tag: tip +user: test +date: Thu Jan 01 00:00:11 1970 +0000 +summary: end-only + +diff -r d09ab1967dab -r 44516c9708ae plain +--- a/plain Thu Jan 01 00:00:10 1970 +0000 ++++ b/plain Thu Jan 01 00:00:11 1970 +0000 +@@ -7,4 +7,4 @@ 7 + 7 + 8 + 9 +-10 ++10.new + +% record beginning +diff --git a/plain b/plain +1 hunks, 3 lines changed +record changes to 'plain'? [y]es [n]o @@ -1,6 +1,3 @@ 2 +-2 +-2 +-3 + 4 + 5 + 6 +record this change to 'plain'? [y]es [n]o +changeset: 13:3ebbace64a8d +tag: tip +user: test +date: Thu Jan 01 00:00:12 1970 +0000 +summary: begin-only + +diff -r 44516c9708ae -r 3ebbace64a8d plain +--- a/plain Thu Jan 01 00:00:11 1970 +0000 ++++ b/plain Thu Jan 01 00:00:12 1970 +0000 +@@ -1,6 +1,3 @@ 2 +-2 +-2 +-3 + 4 + 5 + 6 + +% add to beginning, trim from end +% record end +diff --git a/plain b/plain +2 hunks, 4 lines changed +record changes to 'plain'? [y]es [n]o @@ -1,6 +1,9 @@ 4 ++1 ++2 ++3 + 4 + 5 + 6 + 7 + 8 + 9 +record this change to 'plain'? [y]es [n]o @@ -1,7 +4,6 @@ + 4 + 5 + 6 + 7 + 8 + 9 +-10.new +record this change to 'plain'? [y]es [n]o % add to beginning, middle, end +% record beginning, middle +diff --git a/plain b/plain +3 hunks, 7 lines changed +record changes to 'plain'? [y]es [n]o @@ -1,2 +1,5 @@ 4 ++1 ++2 ++3 + 4 + 5 +record this change to 'plain'? [y]es [n]o @@ -1,6 +4,8 @@ + 4 + 5 ++5.new ++5.reallynew + 6 + 7 + 8 + 9 +record this change to 'plain'? [y]es [n]o @@ -3,4 +8,6 @@ + 6 + 7 + 8 + 9 ++10 ++11 +record this change to 'plain'? [y]es [n]o +changeset: 15:c1c639d8b268 +tag: tip +user: test +date: Thu Jan 01 00:00:14 1970 +0000 +summary: middle-only + +diff -r efc0dad7bd9f -r c1c639d8b268 plain +--- a/plain Thu Jan 01 00:00:13 1970 +0000 ++++ b/plain Thu Jan 01 00:00:14 1970 +0000 +@@ -1,5 +1,10 @@ 4 ++1 ++2 ++3 + 4 + 5 ++5.new ++5.reallynew + 6 + 7 + 8 + +% record end +diff --git a/plain b/plain +1 hunks, 2 lines changed +record changes to 'plain'? [y]es [n]o @@ -9,3 +9,5 @@ 7 + 7 + 8 + 9 ++10 ++11 +record this change to 'plain'? [y]es [n]o +changeset: 16:80b74bbc7808 +tag: tip +user: test +date: Thu Jan 01 00:00:15 1970 +0000 +summary: end-only + +diff -r c1c639d8b268 -r 80b74bbc7808 plain +--- a/plain Thu Jan 01 00:00:14 1970 +0000 ++++ b/plain Thu Jan 01 00:00:15 1970 +0000 +@@ -9,3 +9,5 @@ 7 + 7 + 8 + 9 ++10 ++11 +