hgext/transplant.py
changeset 3714 198173f3957c
child 3723 c828fca6f38a
equal deleted inserted replaced
3713:8ae88ed2a3b6 3714:198173f3957c
       
     1 # Patch transplanting extension for Mercurial
       
     2 #
       
     3 # Copyright 2006 Brendan Cully <brendan@kublai.com>
       
     4 #
       
     5 # This software may be used and distributed according to the terms
       
     6 # of the GNU General Public License, incorporated herein by reference.
       
     7 
       
     8 from mercurial.demandload import *
       
     9 from mercurial.i18n import gettext as _
       
    10 demandload(globals(), 'os tempfile')
       
    11 demandload(globals(), 'mercurial:bundlerepo,cmdutil,commands,hg,merge,patch')
       
    12 demandload(globals(), 'mercurial:revlog,util')
       
    13 
       
    14 '''patch transplanting tool
       
    15 
       
    16 This extension allows you to transplant patches from another branch.
       
    17 
       
    18 Transplanted patches are recorded in .hg/transplant/transplants, as a map
       
    19 from a changeset hash to its hash in the source repository.
       
    20 '''
       
    21 
       
    22 class transplantentry:
       
    23     def __init__(self, lnode, rnode):
       
    24         self.lnode = lnode
       
    25         self.rnode = rnode
       
    26 
       
    27 class transplants:
       
    28     def __init__(self, path=None, transplantfile=None, opener=None):
       
    29         self.path = path
       
    30         self.transplantfile = transplantfile
       
    31         self.opener = opener
       
    32 
       
    33         if not opener:
       
    34             self.opener = util.opener(self.path)
       
    35         self.transplants = []
       
    36         self.dirty = False
       
    37         self.read()
       
    38 
       
    39     def read(self):
       
    40         abspath = os.path.join(self.path, self.transplantfile)
       
    41         if self.transplantfile and os.path.exists(abspath):
       
    42             for line in self.opener(self.transplantfile).read().splitlines():
       
    43                 lnode, rnode = map(revlog.bin, line.split(':'))
       
    44                 self.transplants.append(transplantentry(lnode, rnode))
       
    45 
       
    46     def write(self):
       
    47         if self.dirty and self.transplantfile:
       
    48             if not os.path.isdir(self.path):
       
    49                 os.mkdir(self.path)
       
    50             fp = self.opener(self.transplantfile, 'w')
       
    51             for c in self.transplants:
       
    52                 l, r = map(revlog.hex, (c.lnode, c.rnode))
       
    53                 fp.write(l + ':' + r + '\n')
       
    54             fp.close()
       
    55         self.dirty = False
       
    56 
       
    57     def get(self, rnode):
       
    58         return [t for t in self.transplants if t.rnode == rnode]
       
    59 
       
    60     def set(self, lnode, rnode):
       
    61         self.transplants.append(transplantentry(lnode, rnode))
       
    62         self.dirty = True
       
    63 
       
    64     def remove(self, transplant):
       
    65         del self.transplants[self.transplants.index(transplant)]
       
    66         self.dirty = True
       
    67 
       
    68 class transplanter:
       
    69     def __init__(self, ui, repo):
       
    70         self.ui = ui
       
    71         self.path = repo.join('transplant')
       
    72         self.opener = util.opener(self.path)
       
    73         self.transplants = transplants(self.path, 'transplants', opener=self.opener)
       
    74 
       
    75     def applied(self, repo, node, parent):
       
    76         '''returns True if a node is already an ancestor of parent
       
    77         or has already been transplanted'''
       
    78         if hasnode(repo, node):
       
    79             if node in repo.changelog.reachable(parent, stop=node):
       
    80                 return True
       
    81         for t in self.transplants.get(node):
       
    82             # it might have been stripped
       
    83             if not hasnode(repo, t.lnode):
       
    84                 self.transplants.remove(t)
       
    85                 return False
       
    86             if t.lnode in repo.changelog.reachable(parent, stop=t.lnode):
       
    87                 return True
       
    88         return False
       
    89 
       
    90     def apply(self, repo, source, revmap, merges, opts={}):
       
    91         '''apply the revisions in revmap one by one in revision order'''
       
    92         revs = revmap.keys()
       
    93         revs.sort()
       
    94 
       
    95         p1, p2 = repo.dirstate.parents()
       
    96         pulls = []
       
    97         diffopts = patch.diffopts(self.ui, opts)
       
    98         diffopts.git = True
       
    99 
       
   100         lock = repo.lock()
       
   101         wlock = repo.wlock()
       
   102         try:
       
   103             for rev in revs:
       
   104                 node = revmap[rev]
       
   105                 revstr = '%s:%s' % (rev, revlog.short(node))
       
   106 
       
   107                 if self.applied(repo, node, p1):
       
   108                     self.ui.warn(_('skipping already applied revision %s\n') %
       
   109                                  revstr)
       
   110                     continue
       
   111 
       
   112                 parents = source.changelog.parents(node)
       
   113                 if not opts.get('filter'):
       
   114                     # If the changeset parent is the same as the wdir's parent,
       
   115                     # just pull it.
       
   116                     if parents[0] == p1:
       
   117                         pulls.append(node)
       
   118                         p1 = node
       
   119                         continue
       
   120                     if pulls:
       
   121                         if source != repo:
       
   122                             repo.pull(source, heads=pulls, lock=lock)
       
   123                         merge.update(repo, pulls[-1], wlock=wlock)
       
   124                         p1, p2 = repo.dirstate.parents()
       
   125                         pulls = []
       
   126 
       
   127                 domerge = False
       
   128                 if node in merges:
       
   129                     # pulling all the merge revs at once would mean we couldn't
       
   130                     # transplant after the latest even if transplants before them
       
   131                     # fail.
       
   132                     domerge = True
       
   133                     if not hasnode(repo, node):
       
   134                         repo.pull(source, heads=[node], lock=lock)
       
   135 
       
   136                 if parents[1] != revlog.nullid:
       
   137                     self.ui.note(_('skipping merge changeset %s:%s\n')
       
   138                                  % (rev, revlog.short(node)))
       
   139                     patchfile = None
       
   140                 else:
       
   141                     fd, patchfile = tempfile.mkstemp(prefix='hg-transplant-')
       
   142                     fp = os.fdopen(fd, 'w')
       
   143                     patch.export(source, [node], fp=fp, opts=diffopts)
       
   144                     fp.close()
       
   145 
       
   146                 del revmap[rev]
       
   147                 if patchfile or domerge:
       
   148                     try:
       
   149                         n = self.applyone(repo, node, source.changelog.read(node),
       
   150                                           patchfile, merge=domerge,
       
   151                                           log=opts.get('log'),
       
   152                                           filter=opts.get('filter'),
       
   153                                           lock=lock, wlock=wlock)
       
   154                         if domerge:
       
   155                             self.ui.status(_('%s merged at %s\n') % (revstr,
       
   156                                       revlog.short(n)))
       
   157                         else:
       
   158                             self.ui.status(_('%s transplanted to %s\n') % (revlog.short(node),
       
   159                                                                        revlog.short(n)))
       
   160                     finally:
       
   161                         if patchfile:
       
   162                             os.unlink(patchfile)
       
   163             if pulls:
       
   164                 repo.pull(source, heads=pulls, lock=lock)
       
   165                 merge.update(repo, pulls[-1], wlock=wlock)
       
   166         finally:
       
   167             self.saveseries(revmap, merges)
       
   168             self.transplants.write()
       
   169 
       
   170     def filter(self, filter, changelog, patchfile):
       
   171         '''arbitrarily rewrite changeset before applying it'''
       
   172 
       
   173         self.ui.status('filtering %s' % patchfile)
       
   174         util.system('%s %s' % (filter, util.shellquote(patchfile)),
       
   175                     environ={'HGUSER': changelog[1]},
       
   176                     onerr=util.Abort, errprefix=_('filter failed'))
       
   177 
       
   178     def applyone(self, repo, node, cl, patchfile, merge=False, log=False,
       
   179                  filter=None, lock=None, wlock=None):
       
   180         '''apply the patch in patchfile to the repository as a transplant'''
       
   181         (manifest, user, (time, timezone), files, message) = cl[:5]
       
   182         date = "%d %d" % (time, timezone)
       
   183         extra = {'transplant_source': node}
       
   184         if filter:
       
   185             self.filter(filter, cl, patchfile)
       
   186             patchfile, message, user, date = patch.extract(self.ui, file(patchfile))
       
   187 
       
   188         if log:
       
   189             message += '\n(transplanted from %s)' % revlog.hex(node)
       
   190             cl = list(cl)
       
   191             cl[4] = message
       
   192 
       
   193         self.ui.status(_('applying %s\n') % revlog.short(node))
       
   194         self.ui.note('%s %s\n%s\n' % (user, date, message))
       
   195 
       
   196         if not patchfile and not merge:
       
   197             raise util.Abort(_('can only omit patchfile if merging'))
       
   198         if patchfile:
       
   199             try:
       
   200                 files = {}
       
   201                 fuzz = patch.patch(patchfile, self.ui, cwd=repo.root,
       
   202                                    files=files)
       
   203                 if not files:
       
   204                     self.ui.warn(_('%s: empty changeset') % revlog.hex(node))
       
   205                     return
       
   206                 files = patch.updatedir(self.ui, repo, files, wlock=wlock)
       
   207                 if filter:
       
   208                     os.unlink(patchfile)
       
   209             except Exception, inst:
       
   210                 if filter:
       
   211                     os.unlink(patchfile)
       
   212                 p1 = repo.dirstate.parents()[0]
       
   213                 p2 = node
       
   214                 self.log(cl, p1, p2, merge=merge)
       
   215                 self.ui.write(str(inst) + '\n')
       
   216                 raise util.Abort(_('Fix up the merge and run hg transplant --continue'))
       
   217         else:
       
   218             files = None
       
   219         if merge:
       
   220             p1, p2 = repo.dirstate.parents()
       
   221             repo.dirstate.setparents(p1, node)
       
   222 
       
   223         n = repo.commit(files, message, user, date, lock=lock, wlock=wlock,
       
   224                         extra=extra)
       
   225         if not merge:
       
   226             self.transplants.set(n, node)
       
   227 
       
   228         return n
       
   229 
       
   230     def resume(self, repo, source, opts=None):
       
   231         '''recover last transaction and apply remaining changesets'''
       
   232         if os.path.exists(os.path.join(self.path, 'journal')):
       
   233             n, node = self.recover(repo)
       
   234         self.ui.status(_('%s transplanted as %s\n') % (revlog.short(node),
       
   235                                                        revlog.short(n)))
       
   236         seriespath = os.path.join(self.path, 'series')
       
   237         if not os.path.exists(seriespath):
       
   238             return
       
   239         nodes, merges = self.readseries()
       
   240         revmap = {}
       
   241         for n in nodes:
       
   242             revmap[source.changelog.rev(n)] = n
       
   243         os.unlink(seriespath)
       
   244 
       
   245         self.apply(repo, source, revmap, merges, opts)
       
   246 
       
   247     def recover(self, repo):
       
   248         '''commit working directory using journal metadata'''
       
   249         node, user, date, message, parents = self.readlog()
       
   250         merge = len(parents) == 2
       
   251 
       
   252         if not user or not date or not message or not parents[0]:
       
   253             raise util.Abort(_('transplant log file is corrupt'))
       
   254 
       
   255         wlock = repo.wlock()
       
   256         p1, p2 = repo.dirstate.parents()
       
   257         if p1 != parents[0]:
       
   258             raise util.Abort(_('working dir not at transplant parent %s') %
       
   259                              revlog.hex(parents[0]))
       
   260         if merge:
       
   261             repo.dirstate.setparents(p1, parents[1])
       
   262         n = repo.commit(None, message, user, date, wlock=wlock)
       
   263         if not n:
       
   264             raise util.Abort(_('commit failed'))
       
   265         if not merge:
       
   266             self.transplants.set(n, node)
       
   267         self.unlog()
       
   268 
       
   269         return n, node
       
   270 
       
   271     def readseries(self):
       
   272         nodes = []
       
   273         merges = []
       
   274         cur = nodes
       
   275         for line in self.opener('series').read().splitlines():
       
   276             if line.startswith('# Merges'):
       
   277                 cur = merges
       
   278                 continue
       
   279             cur.append(revlog.bin(line))
       
   280 
       
   281         return (nodes, merges)
       
   282 
       
   283     def saveseries(self, revmap, merges):
       
   284         if not revmap:
       
   285             return
       
   286 
       
   287         if not os.path.isdir(self.path):
       
   288             os.mkdir(self.path)
       
   289         series = self.opener('series', 'w')
       
   290         revs = revmap.keys()
       
   291         revs.sort()
       
   292         for rev in revs:
       
   293             series.write(revlog.hex(revmap[rev]) + '\n')
       
   294         if merges:
       
   295             series.write('# Merges\n')
       
   296             for m in merges:
       
   297                 series.write(revlog.hex(m) + '\n')
       
   298         series.close()
       
   299 
       
   300     def log(self, changelog, p1, p2, merge=False):
       
   301         '''journal changelog metadata for later recover'''
       
   302 
       
   303         if not os.path.isdir(self.path):
       
   304             os.mkdir(self.path)
       
   305         fp = self.opener('journal', 'w')
       
   306         fp.write('# User %s\n' % changelog[1])
       
   307         fp.write('# Date %d %d\n' % changelog[2])
       
   308         fp.write('# Node ID %s\n' % revlog.hex(p2))
       
   309         fp.write('# Parent ' + revlog.hex(p1) + '\n')
       
   310         if merge:
       
   311             fp.write('# Parent ' + revlog.hex(p2) + '\n')
       
   312         fp.write(changelog[4].rstrip() + '\n')
       
   313         fp.close()
       
   314 
       
   315     def readlog(self):
       
   316         parents = []
       
   317         message = []
       
   318         for line in self.opener('journal').read().splitlines():
       
   319             if line.startswith('# User '):
       
   320                 user = line[7:]
       
   321             elif line.startswith('# Date '):
       
   322                 date = line[7:]
       
   323             elif line.startswith('# Node ID '):
       
   324                 node = revlog.bin(line[10:])
       
   325             elif line.startswith('# Parent '):
       
   326                 parents.append(revlog.bin(line[9:]))
       
   327             else:
       
   328                 message.append(line)
       
   329         return (node, user, date, '\n'.join(message), parents)
       
   330 
       
   331     def unlog(self):
       
   332         '''remove changelog journal'''
       
   333         absdst = os.path.join(self.path, 'journal')
       
   334         if os.path.exists(absdst):
       
   335             os.unlink(absdst)
       
   336 
       
   337     def transplantfilter(self, repo, source, root):
       
   338         def matchfn(node):
       
   339             if self.applied(repo, node, root):
       
   340                 return False
       
   341             if source.changelog.parents(node)[1] != revlog.nullid:
       
   342                 return False
       
   343             extra = source.changelog.read(node)[5]
       
   344             cnode = extra.get('transplant_source')
       
   345             if cnode and self.applied(repo, cnode, root):
       
   346                 return False
       
   347             return True
       
   348 
       
   349         return matchfn
       
   350 
       
   351 def hasnode(repo, node):
       
   352     try:
       
   353         return repo.changelog.rev(node) != None
       
   354     except revlog.RevlogError:
       
   355         return False
       
   356 
       
   357 def browserevs(ui, repo, nodes, opts):
       
   358     '''interactively transplant changesets'''
       
   359     def browsehelp(ui):
       
   360         ui.write('y: transplant this changeset\n'
       
   361                  'n: skip this changeset\n'
       
   362                  'm: merge at this changeset\n'
       
   363                  'p: show patch\n'
       
   364                  'c: commit selected changesets\n'
       
   365                  'q: cancel transplant\n'
       
   366                  '?: show this help\n')
       
   367 
       
   368     displayer = commands.show_changeset(ui, repo, opts)
       
   369     transplants = []
       
   370     merges = []
       
   371     for node in nodes:
       
   372         displayer.show(changenode=node)
       
   373         action = None
       
   374         while not action:
       
   375             action = ui.prompt(_('apply changeset? [ynmpcq?]:'))
       
   376             if action == '?':
       
   377                 browsehelp(ui)
       
   378                 action = None
       
   379             elif action == 'p':
       
   380                 parent = repo.changelog.parents(node)[0]
       
   381                 patch.diff(repo, parent, node)
       
   382                 action = None
       
   383             elif action not in ('y', 'n', 'm', 'c', 'q'):
       
   384                 ui.write('no such option\n')
       
   385                 action = None
       
   386         if action == 'y':
       
   387             transplants.append(node)
       
   388         elif action == 'm':
       
   389             merges.append(node)
       
   390         elif action == 'c':
       
   391             break
       
   392         elif action == 'q':
       
   393             transplants = ()
       
   394             merges = ()
       
   395             break
       
   396     return (transplants, merges)
       
   397 
       
   398 def transplant(ui, repo, *revs, **opts):
       
   399     '''transplant changesets from another branch
       
   400 
       
   401     Selected changesets will be applied on top of the current working
       
   402     directory with the log of the original changeset. If --log is
       
   403     specified, log messages will have a comment appended of the form:
       
   404 
       
   405     (transplanted from CHANGESETHASH)
       
   406 
       
   407     You can rewrite the changelog message with the --filter option.
       
   408     Its argument will be invoked with the current changelog message
       
   409     as $1 and the patch as $2.
       
   410 
       
   411     If --source is specified, selects changesets from the named
       
   412     repository. If --branch is specified, selects changesets from the
       
   413     branch holding the named revision, up to that revision. If --all
       
   414     is specified, all changesets on the branch will be transplanted,
       
   415     otherwise you will be prompted to select the changesets you want.
       
   416 
       
   417     hg transplant --branch REVISION --all will rebase the selected branch
       
   418     (up to the named revision) onto your current working directory.
       
   419 
       
   420     You can optionally mark selected transplanted changesets as
       
   421     merge changesets. You will not be prompted to transplant any
       
   422     ancestors of a merged transplant, and you can merge descendants
       
   423     of them normally instead of transplanting them.
       
   424 
       
   425     If no merges or revisions are provided, hg transplant will start
       
   426     an interactive changeset browser.
       
   427 
       
   428     If a changeset application fails, you can fix the merge by hand and
       
   429     then resume where you left off by calling hg transplant --continue.
       
   430     '''
       
   431     def getoneitem(opts, item, errmsg):
       
   432         val = opts.get(item)
       
   433         if val:
       
   434             if len(val) > 1:
       
   435                 raise util.Abort(errmsg)
       
   436             else:
       
   437                 return val[0]
       
   438 
       
   439     def getremotechanges(repo, url):
       
   440         sourcerepo = ui.expandpath(url)
       
   441         source = hg.repository(ui, sourcerepo)
       
   442         incoming = repo.findincoming(source, force=True)
       
   443         if not incoming:
       
   444             return (source, None, None)
       
   445 
       
   446         bundle = None
       
   447         if not source.local():
       
   448             cg = source.changegroup(incoming, 'incoming')
       
   449             bundle = commands.write_bundle(cg, compress=False)
       
   450             source = bundlerepo.bundlerepository(ui, repo.root, bundle)
       
   451 
       
   452         return (source, incoming, bundle)
       
   453 
       
   454     def incwalk(repo, incoming, branches, match=util.always):
       
   455         if not branches:
       
   456             branches=None
       
   457         for node in repo.changelog.nodesbetween(incoming, branches)[0]:
       
   458             if match(node):
       
   459                 yield node
       
   460 
       
   461     def transplantwalk(repo, root, branches, match=util.always):
       
   462         if not branches:
       
   463             branches = repo.heads()
       
   464         ancestors = []
       
   465         for branch in branches:
       
   466             ancestors.append(repo.changelog.ancestor(root, branch))
       
   467         for node in repo.changelog.nodesbetween(ancestors, branches)[0]:
       
   468             if match(node):
       
   469                 yield node
       
   470 
       
   471     def checkopts(opts, revs):
       
   472         if opts.get('continue'):
       
   473             if filter(lambda opt: opts.get(opt), ('branch', 'all', 'merge')):
       
   474                 raise util.Abort(_('--continue is incompatible with branch, all or merge'))
       
   475             return
       
   476         if not (opts.get('source') or revs or
       
   477                 opts.get('merge') or opts.get('branch')):
       
   478             raise util.Abort(_('no source URL, branch tag or revision list provided'))
       
   479         if opts.get('all'):
       
   480             if not opts.get('branch'):
       
   481                 raise util.Abort(_('--all requires a branch revision'))
       
   482             if revs:
       
   483                 raise util.Abort(_('--all is incompatible with a revision list'))
       
   484 
       
   485     checkopts(opts, revs)
       
   486 
       
   487     if not opts.get('log'):
       
   488         opts['log'] = ui.config('transplant', 'log')
       
   489     if not opts.get('filter'):
       
   490         opts['filter'] = ui.config('transplant', 'filter')
       
   491 
       
   492     tp = transplanter(ui, repo)
       
   493 
       
   494     p1, p2 = repo.dirstate.parents()
       
   495     if p1 == revlog.nullid:
       
   496         raise util.Abort(_('no revision checked out'))
       
   497     if not opts.get('continue'):
       
   498         if p2 != revlog.nullid:
       
   499             raise util.Abort(_('outstanding uncommitted merges'))
       
   500         m, a, r, d = repo.status()[:4]
       
   501         if m or a or r or d:
       
   502             raise util.Abort(_('outstanding local changes'))
       
   503 
       
   504     bundle = None
       
   505     source = opts.get('source')
       
   506     if source:
       
   507         (source, incoming, bundle) = getremotechanges(repo, source)
       
   508     else:
       
   509         source = repo
       
   510 
       
   511     try:
       
   512         if opts.get('continue'):
       
   513             n, node = tp.resume(repo, source, opts)
       
   514             return
       
   515 
       
   516         tf=tp.transplantfilter(repo, source, p1)
       
   517         if opts.get('prune'):
       
   518             prune = [source.lookup(r)
       
   519                      for r in cmdutil.revrange(source, opts.get('prune'))]
       
   520             matchfn = lambda x: tf(x) and x not in prune
       
   521         else:
       
   522             matchfn = tf
       
   523         branches = map(source.lookup, opts.get('branch', ()))
       
   524         merges = map(source.lookup, opts.get('merge', ()))
       
   525         revmap = {}
       
   526         if revs:
       
   527             for r in cmdutil.revrange(source, revs):
       
   528                 revmap[int(r)] = source.lookup(r)
       
   529         elif opts.get('all') or not merges:
       
   530             if source != repo:
       
   531                 alltransplants = incwalk(source, incoming, branches, match=matchfn)
       
   532             else:
       
   533                 alltransplants = transplantwalk(source, p1, branches, match=matchfn)
       
   534             if opts.get('all'):
       
   535                 revs = alltransplants
       
   536             else:
       
   537                 revs, newmerges = browserevs(ui, source, alltransplants, opts)
       
   538                 merges.extend(newmerges)
       
   539             for r in revs:
       
   540                 revmap[source.changelog.rev(r)] = r
       
   541         for r in merges:
       
   542             revmap[source.changelog.rev(r)] = r
       
   543 
       
   544         revs = revmap.keys()
       
   545         revs.sort()
       
   546         pulls = []
       
   547 
       
   548         tp.apply(repo, source, revmap, merges, opts)
       
   549     finally:
       
   550         if bundle:
       
   551             os.unlink(bundle)
       
   552 
       
   553 cmdtable = {
       
   554     "transplant":
       
   555         (transplant,
       
   556          [('s', 'source', '', _('pull patches from REPOSITORY')),
       
   557           ('b', 'branch', [], _('pull patches from branch BRANCH')),
       
   558           ('a', 'all', None, _('pull all changesets up to BRANCH')),
       
   559           ('p', 'prune', [], _('skip over REV')),
       
   560           ('m', 'merge', [], _('merge at REV')),
       
   561           ('', 'log', None, _('append transplant info to log message')),
       
   562           ('c', 'continue', None, _('continue last transplant session after repair')),
       
   563           ('', 'filter', '', _('filter changesets through FILTER'))],
       
   564          _('hg transplant [-s REPOSITORY] [-b BRANCH] [-p REV] [-m REV] [-n] REV...'))
       
   565 }