eggs/mercurial-1.7.3-py2.6-linux-x86_64.egg/hgext/record.py
changeset 69 c6bca38c1cbf
equal deleted inserted replaced
68:5ff1fc726848 69:c6bca38c1cbf
       
     1 # record.py
       
     2 #
       
     3 # Copyright 2007 Bryan O'Sullivan <bos@serpentine.com>
       
     4 #
       
     5 # This software may be used and distributed according to the terms of the
       
     6 # GNU General Public License version 2 or any later version.
       
     7 
       
     8 '''commands to interactively select changes for commit/qrefresh'''
       
     9 
       
    10 from mercurial.i18n import gettext, _
       
    11 from mercurial import cmdutil, commands, extensions, hg, mdiff, patch
       
    12 from mercurial import util
       
    13 import copy, cStringIO, errno, os, re, tempfile
       
    14 
       
    15 lines_re = re.compile(r'@@ -(\d+),(\d+) \+(\d+),(\d+) @@\s*(.*)')
       
    16 
       
    17 def scanpatch(fp):
       
    18     """like patch.iterhunks, but yield different events
       
    19 
       
    20     - ('file',    [header_lines + fromfile + tofile])
       
    21     - ('context', [context_lines])
       
    22     - ('hunk',    [hunk_lines])
       
    23     - ('range',   (-start,len, +start,len, diffp))
       
    24     """
       
    25     lr = patch.linereader(fp)
       
    26 
       
    27     def scanwhile(first, p):
       
    28         """scan lr while predicate holds"""
       
    29         lines = [first]
       
    30         while True:
       
    31             line = lr.readline()
       
    32             if not line:
       
    33                 break
       
    34             if p(line):
       
    35                 lines.append(line)
       
    36             else:
       
    37                 lr.push(line)
       
    38                 break
       
    39         return lines
       
    40 
       
    41     while True:
       
    42         line = lr.readline()
       
    43         if not line:
       
    44             break
       
    45         if line.startswith('diff --git a/'):
       
    46             def notheader(line):
       
    47                 s = line.split(None, 1)
       
    48                 return not s or s[0] not in ('---', 'diff')
       
    49             header = scanwhile(line, notheader)
       
    50             fromfile = lr.readline()
       
    51             if fromfile.startswith('---'):
       
    52                 tofile = lr.readline()
       
    53                 header += [fromfile, tofile]
       
    54             else:
       
    55                 lr.push(fromfile)
       
    56             yield 'file', header
       
    57         elif line[0] == ' ':
       
    58             yield 'context', scanwhile(line, lambda l: l[0] in ' \\')
       
    59         elif line[0] in '-+':
       
    60             yield 'hunk', scanwhile(line, lambda l: l[0] in '-+\\')
       
    61         else:
       
    62             m = lines_re.match(line)
       
    63             if m:
       
    64                 yield 'range', m.groups()
       
    65             else:
       
    66                 raise patch.PatchError('unknown patch content: %r' % line)
       
    67 
       
    68 class header(object):
       
    69     """patch header
       
    70 
       
    71     XXX shoudn't we move this to mercurial/patch.py ?
       
    72     """
       
    73     diff_re = re.compile('diff --git a/(.*) b/(.*)$')
       
    74     allhunks_re = re.compile('(?:index|new file|deleted file) ')
       
    75     pretty_re = re.compile('(?:new file|deleted file) ')
       
    76     special_re = re.compile('(?:index|new|deleted|copy|rename) ')
       
    77 
       
    78     def __init__(self, header):
       
    79         self.header = header
       
    80         self.hunks = []
       
    81 
       
    82     def binary(self):
       
    83         for h in self.header:
       
    84             if h.startswith('index '):
       
    85                 return True
       
    86 
       
    87     def pretty(self, fp):
       
    88         for h in self.header:
       
    89             if h.startswith('index '):
       
    90                 fp.write(_('this modifies a binary file (all or nothing)\n'))
       
    91                 break
       
    92             if self.pretty_re.match(h):
       
    93                 fp.write(h)
       
    94                 if self.binary():
       
    95                     fp.write(_('this is a binary file\n'))
       
    96                 break
       
    97             if h.startswith('---'):
       
    98                 fp.write(_('%d hunks, %d lines changed\n') %
       
    99                          (len(self.hunks),
       
   100                           sum([max(h.added, h.removed) for h in self.hunks])))
       
   101                 break
       
   102             fp.write(h)
       
   103 
       
   104     def write(self, fp):
       
   105         fp.write(''.join(self.header))
       
   106 
       
   107     def allhunks(self):
       
   108         for h in self.header:
       
   109             if self.allhunks_re.match(h):
       
   110                 return True
       
   111 
       
   112     def files(self):
       
   113         fromfile, tofile = self.diff_re.match(self.header[0]).groups()
       
   114         if fromfile == tofile:
       
   115             return [fromfile]
       
   116         return [fromfile, tofile]
       
   117 
       
   118     def filename(self):
       
   119         return self.files()[-1]
       
   120 
       
   121     def __repr__(self):
       
   122         return '<header %s>' % (' '.join(map(repr, self.files())))
       
   123 
       
   124     def special(self):
       
   125         for h in self.header:
       
   126             if self.special_re.match(h):
       
   127                 return True
       
   128 
       
   129 def countchanges(hunk):
       
   130     """hunk -> (n+,n-)"""
       
   131     add = len([h for h in hunk if h[0] == '+'])
       
   132     rem = len([h for h in hunk if h[0] == '-'])
       
   133     return add, rem
       
   134 
       
   135 class hunk(object):
       
   136     """patch hunk
       
   137 
       
   138     XXX shouldn't we merge this with patch.hunk ?
       
   139     """
       
   140     maxcontext = 3
       
   141 
       
   142     def __init__(self, header, fromline, toline, proc, before, hunk, after):
       
   143         def trimcontext(number, lines):
       
   144             delta = len(lines) - self.maxcontext
       
   145             if False and delta > 0:
       
   146                 return number + delta, lines[:self.maxcontext]
       
   147             return number, lines
       
   148 
       
   149         self.header = header
       
   150         self.fromline, self.before = trimcontext(fromline, before)
       
   151         self.toline, self.after = trimcontext(toline, after)
       
   152         self.proc = proc
       
   153         self.hunk = hunk
       
   154         self.added, self.removed = countchanges(self.hunk)
       
   155 
       
   156     def write(self, fp):
       
   157         delta = len(self.before) + len(self.after)
       
   158         if self.after and self.after[-1] == '\\ No newline at end of file\n':
       
   159             delta -= 1
       
   160         fromlen = delta + self.removed
       
   161         tolen = delta + self.added
       
   162         fp.write('@@ -%d,%d +%d,%d @@%s\n' %
       
   163                  (self.fromline, fromlen, self.toline, tolen,
       
   164                   self.proc and (' ' + self.proc)))
       
   165         fp.write(''.join(self.before + self.hunk + self.after))
       
   166 
       
   167     pretty = write
       
   168 
       
   169     def filename(self):
       
   170         return self.header.filename()
       
   171 
       
   172     def __repr__(self):
       
   173         return '<hunk %r@%d>' % (self.filename(), self.fromline)
       
   174 
       
   175 def parsepatch(fp):
       
   176     """patch -> [] of hunks """
       
   177     class parser(object):
       
   178         """patch parsing state machine"""
       
   179         def __init__(self):
       
   180             self.fromline = 0
       
   181             self.toline = 0
       
   182             self.proc = ''
       
   183             self.header = None
       
   184             self.context = []
       
   185             self.before = []
       
   186             self.hunk = []
       
   187             self.stream = []
       
   188 
       
   189         def addrange(self, limits):
       
   190             fromstart, fromend, tostart, toend, proc = limits
       
   191             self.fromline = int(fromstart)
       
   192             self.toline = int(tostart)
       
   193             self.proc = proc
       
   194 
       
   195         def addcontext(self, context):
       
   196             if self.hunk:
       
   197                 h = hunk(self.header, self.fromline, self.toline, self.proc,
       
   198                          self.before, self.hunk, context)
       
   199                 self.header.hunks.append(h)
       
   200                 self.stream.append(h)
       
   201                 self.fromline += len(self.before) + h.removed
       
   202                 self.toline += len(self.before) + h.added
       
   203                 self.before = []
       
   204                 self.hunk = []
       
   205                 self.proc = ''
       
   206             self.context = context
       
   207 
       
   208         def addhunk(self, hunk):
       
   209             if self.context:
       
   210                 self.before = self.context
       
   211                 self.context = []
       
   212             self.hunk = hunk
       
   213 
       
   214         def newfile(self, hdr):
       
   215             self.addcontext([])
       
   216             h = header(hdr)
       
   217             self.stream.append(h)
       
   218             self.header = h
       
   219 
       
   220         def finished(self):
       
   221             self.addcontext([])
       
   222             return self.stream
       
   223 
       
   224         transitions = {
       
   225             'file': {'context': addcontext,
       
   226                      'file': newfile,
       
   227                      'hunk': addhunk,
       
   228                      'range': addrange},
       
   229             'context': {'file': newfile,
       
   230                         'hunk': addhunk,
       
   231                         'range': addrange},
       
   232             'hunk': {'context': addcontext,
       
   233                      'file': newfile,
       
   234                      'range': addrange},
       
   235             'range': {'context': addcontext,
       
   236                       'hunk': addhunk},
       
   237             }
       
   238 
       
   239     p = parser()
       
   240 
       
   241     state = 'context'
       
   242     for newstate, data in scanpatch(fp):
       
   243         try:
       
   244             p.transitions[state][newstate](p, data)
       
   245         except KeyError:
       
   246             raise patch.PatchError('unhandled transition: %s -> %s' %
       
   247                                    (state, newstate))
       
   248         state = newstate
       
   249     return p.finished()
       
   250 
       
   251 def filterpatch(ui, chunks):
       
   252     """Interactively filter patch chunks into applied-only chunks"""
       
   253     chunks = list(chunks)
       
   254     chunks.reverse()
       
   255     seen = set()
       
   256     def consumefile():
       
   257         """fetch next portion from chunks until a 'header' is seen
       
   258         NB: header == new-file mark
       
   259         """
       
   260         consumed = []
       
   261         while chunks:
       
   262             if isinstance(chunks[-1], header):
       
   263                 break
       
   264             else:
       
   265                 consumed.append(chunks.pop())
       
   266         return consumed
       
   267 
       
   268     resp_all = [None]   # this two are changed from inside prompt,
       
   269     resp_file = [None]  # so can't be usual variables
       
   270     applied = {}        # 'filename' -> [] of chunks
       
   271     def prompt(query):
       
   272         """prompt query, and process base inputs
       
   273 
       
   274         - y/n for the rest of file
       
   275         - y/n for the rest
       
   276         - ? (help)
       
   277         - q (quit)
       
   278 
       
   279         Returns True/False and sets reps_all and resp_file as
       
   280         appropriate.
       
   281         """
       
   282         if resp_all[0] is not None:
       
   283             return resp_all[0]
       
   284         if resp_file[0] is not None:
       
   285             return resp_file[0]
       
   286         while True:
       
   287             resps = _('[Ynsfdaq?]')
       
   288             choices = (_('&Yes, record this change'),
       
   289                     _('&No, skip this change'),
       
   290                     _('&Skip remaining changes to this file'),
       
   291                     _('Record remaining changes to this &file'),
       
   292                     _('&Done, skip remaining changes and files'),
       
   293                     _('Record &all changes to all remaining files'),
       
   294                     _('&Quit, recording no changes'),
       
   295                     _('&?'))
       
   296             r = ui.promptchoice("%s %s" % (query, resps), choices)
       
   297             ui.write("\n")
       
   298             if r == 7: # ?
       
   299                 doc = gettext(record.__doc__)
       
   300                 c = doc.find('::') + 2
       
   301                 for l in doc[c:].splitlines():
       
   302                     if l.startswith('      '):
       
   303                         ui.write(l.strip(), '\n')
       
   304                 continue
       
   305             elif r == 0: # yes
       
   306                 ret = True
       
   307             elif r == 1: # no
       
   308                 ret = False
       
   309             elif r == 2: # Skip
       
   310                 ret = resp_file[0] = False
       
   311             elif r == 3: # file (Record remaining)
       
   312                 ret = resp_file[0] = True
       
   313             elif r == 4: # done, skip remaining
       
   314                 ret = resp_all[0] = False
       
   315             elif r == 5: # all
       
   316                 ret = resp_all[0] = True
       
   317             elif r == 6: # quit
       
   318                 raise util.Abort(_('user quit'))
       
   319             return ret
       
   320     pos, total = 0, len(chunks) - 1
       
   321     while chunks:
       
   322         pos = total - len(chunks) + 1
       
   323         chunk = chunks.pop()
       
   324         if isinstance(chunk, header):
       
   325             # new-file mark
       
   326             resp_file = [None]
       
   327             fixoffset = 0
       
   328             hdr = ''.join(chunk.header)
       
   329             if hdr in seen:
       
   330                 consumefile()
       
   331                 continue
       
   332             seen.add(hdr)
       
   333             if resp_all[0] is None:
       
   334                 chunk.pretty(ui)
       
   335             r = prompt(_('examine changes to %s?') %
       
   336                        _(' and ').join(map(repr, chunk.files())))
       
   337             if r:
       
   338                 applied[chunk.filename()] = [chunk]
       
   339                 if chunk.allhunks():
       
   340                     applied[chunk.filename()] += consumefile()
       
   341             else:
       
   342                 consumefile()
       
   343         else:
       
   344             # new hunk
       
   345             if resp_file[0] is None and resp_all[0] is None:
       
   346                 chunk.pretty(ui)
       
   347             r = total == 1 and prompt(_('record this change to %r?') %
       
   348                                       chunk.filename()) \
       
   349                            or  prompt(_('record change %d/%d to %r?') %
       
   350                                       (pos, total, chunk.filename()))
       
   351             if r:
       
   352                 if fixoffset:
       
   353                     chunk = copy.copy(chunk)
       
   354                     chunk.toline += fixoffset
       
   355                 applied[chunk.filename()].append(chunk)
       
   356             else:
       
   357                 fixoffset += chunk.removed - chunk.added
       
   358     return sum([h for h in applied.itervalues()
       
   359                if h[0].special() or len(h) > 1], [])
       
   360 
       
   361 def record(ui, repo, *pats, **opts):
       
   362     '''interactively select changes to commit
       
   363 
       
   364     If a list of files is omitted, all changes reported by :hg:`status`
       
   365     will be candidates for recording.
       
   366 
       
   367     See :hg:`help dates` for a list of formats valid for -d/--date.
       
   368 
       
   369     You will be prompted for whether to record changes to each
       
   370     modified file, and for files with multiple changes, for each
       
   371     change to use. For each query, the following responses are
       
   372     possible::
       
   373 
       
   374       y - record this change
       
   375       n - skip this change
       
   376 
       
   377       s - skip remaining changes to this file
       
   378       f - record remaining changes to this file
       
   379 
       
   380       d - done, skip remaining changes and files
       
   381       a - record all changes to all remaining files
       
   382       q - quit, recording no changes
       
   383 
       
   384       ? - display help
       
   385 
       
   386     This command is not available when committing a merge.'''
       
   387 
       
   388     dorecord(ui, repo, commands.commit, *pats, **opts)
       
   389 
       
   390 
       
   391 def qrecord(ui, repo, patch, *pats, **opts):
       
   392     '''interactively record a new patch
       
   393 
       
   394     See :hg:`help qnew` & :hg:`help record` for more information and
       
   395     usage.
       
   396     '''
       
   397 
       
   398     try:
       
   399         mq = extensions.find('mq')
       
   400     except KeyError:
       
   401         raise util.Abort(_("'mq' extension not loaded"))
       
   402 
       
   403     def committomq(ui, repo, *pats, **opts):
       
   404         mq.new(ui, repo, patch, *pats, **opts)
       
   405 
       
   406     opts = opts.copy()
       
   407     opts['force'] = True    # always 'qnew -f'
       
   408     dorecord(ui, repo, committomq, *pats, **opts)
       
   409 
       
   410 
       
   411 def dorecord(ui, repo, commitfunc, *pats, **opts):
       
   412     if not ui.interactive():
       
   413         raise util.Abort(_('running non-interactively, use commit instead'))
       
   414 
       
   415     def recordfunc(ui, repo, message, match, opts):
       
   416         """This is generic record driver.
       
   417 
       
   418         Its job is to interactively filter local changes, and accordingly
       
   419         prepare working dir into a state, where the job can be delegated to
       
   420         non-interactive commit command such as 'commit' or 'qrefresh'.
       
   421 
       
   422         After the actual job is done by non-interactive command, working dir
       
   423         state is restored to original.
       
   424 
       
   425         In the end we'll record interesting changes, and everything else will be
       
   426         left in place, so the user can continue his work.
       
   427         """
       
   428 
       
   429         merge = len(repo[None].parents()) > 1
       
   430         if merge:
       
   431             raise util.Abort(_('cannot partially commit a merge '
       
   432                                '(use hg commit instead)'))
       
   433 
       
   434         changes = repo.status(match=match)[:3]
       
   435         diffopts = mdiff.diffopts(git=True, nodates=True)
       
   436         chunks = patch.diff(repo, changes=changes, opts=diffopts)
       
   437         fp = cStringIO.StringIO()
       
   438         fp.write(''.join(chunks))
       
   439         fp.seek(0)
       
   440 
       
   441         # 1. filter patch, so we have intending-to apply subset of it
       
   442         chunks = filterpatch(ui, parsepatch(fp))
       
   443         del fp
       
   444 
       
   445         contenders = set()
       
   446         for h in chunks:
       
   447             try:
       
   448                 contenders.update(set(h.files()))
       
   449             except AttributeError:
       
   450                 pass
       
   451 
       
   452         changed = changes[0] + changes[1] + changes[2]
       
   453         newfiles = [f for f in changed if f in contenders]
       
   454         if not newfiles:
       
   455             ui.status(_('no changes to record\n'))
       
   456             return 0
       
   457 
       
   458         modified = set(changes[0])
       
   459 
       
   460         # 2. backup changed files, so we can restore them in the end
       
   461         backups = {}
       
   462         backupdir = repo.join('record-backups')
       
   463         try:
       
   464             os.mkdir(backupdir)
       
   465         except OSError, err:
       
   466             if err.errno != errno.EEXIST:
       
   467                 raise
       
   468         try:
       
   469             # backup continues
       
   470             for f in newfiles:
       
   471                 if f not in modified:
       
   472                     continue
       
   473                 fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.',
       
   474                                                dir=backupdir)
       
   475                 os.close(fd)
       
   476                 ui.debug('backup %r as %r\n' % (f, tmpname))
       
   477                 util.copyfile(repo.wjoin(f), tmpname)
       
   478                 backups[f] = tmpname
       
   479 
       
   480             fp = cStringIO.StringIO()
       
   481             for c in chunks:
       
   482                 if c.filename() in backups:
       
   483                     c.write(fp)
       
   484             dopatch = fp.tell()
       
   485             fp.seek(0)
       
   486 
       
   487             # 3a. apply filtered patch to clean repo  (clean)
       
   488             if backups:
       
   489                 hg.revert(repo, repo.dirstate.parents()[0],
       
   490                           lambda key: key in backups)
       
   491 
       
   492             # 3b. (apply)
       
   493             if dopatch:
       
   494                 try:
       
   495                     ui.debug('applying patch\n')
       
   496                     ui.debug(fp.getvalue())
       
   497                     pfiles = {}
       
   498                     patch.internalpatch(fp, ui, 1, repo.root, files=pfiles,
       
   499                                         eolmode=None)
       
   500                     cmdutil.updatedir(ui, repo, pfiles)
       
   501                 except patch.PatchError, err:
       
   502                     raise util.Abort(str(err))
       
   503             del fp
       
   504 
       
   505             # 4. We prepared working directory according to filtered patch.
       
   506             #    Now is the time to delegate the job to commit/qrefresh or the like!
       
   507 
       
   508             # it is important to first chdir to repo root -- we'll call a
       
   509             # highlevel command with list of pathnames relative to repo root
       
   510             cwd = os.getcwd()
       
   511             os.chdir(repo.root)
       
   512             try:
       
   513                 commitfunc(ui, repo, *newfiles, **opts)
       
   514             finally:
       
   515                 os.chdir(cwd)
       
   516 
       
   517             return 0
       
   518         finally:
       
   519             # 5. finally restore backed-up files
       
   520             try:
       
   521                 for realname, tmpname in backups.iteritems():
       
   522                     ui.debug('restoring %r to %r\n' % (tmpname, realname))
       
   523                     util.copyfile(tmpname, repo.wjoin(realname))
       
   524                     os.unlink(tmpname)
       
   525                 os.rmdir(backupdir)
       
   526             except OSError:
       
   527                 pass
       
   528 
       
   529     # wrap ui.write so diff output can be labeled/colorized
       
   530     def wrapwrite(orig, *args, **kw):
       
   531         label = kw.pop('label', '')
       
   532         for chunk, l in patch.difflabel(lambda: args):
       
   533             orig(chunk, label=label + l)
       
   534     oldwrite = ui.write
       
   535     extensions.wrapfunction(ui, 'write', wrapwrite)
       
   536     try:
       
   537         return cmdutil.commit(ui, repo, recordfunc, pats, opts)
       
   538     finally:
       
   539         ui.write = oldwrite
       
   540 
       
   541 cmdtable = {
       
   542     "record":
       
   543         (record,
       
   544 
       
   545          # add commit options
       
   546          commands.table['^commit|ci'][1],
       
   547 
       
   548          _('hg record [OPTION]... [FILE]...')),
       
   549 }
       
   550 
       
   551 
       
   552 def uisetup(ui):
       
   553     try:
       
   554         mq = extensions.find('mq')
       
   555     except KeyError:
       
   556         return
       
   557 
       
   558     qcmdtable = {
       
   559     "qrecord":
       
   560         (qrecord,
       
   561 
       
   562          # add qnew options, except '--force'
       
   563          [opt for opt in mq.cmdtable['^qnew'][1] if opt[1] != 'force'],
       
   564 
       
   565          _('hg qrecord [OPTION]... PATCH [FILE]...')),
       
   566     }
       
   567 
       
   568     cmdtable.update(qcmdtable)
       
   569