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