add qsync extension to mutable history
authorPierre-Yves David <pierre-yves.david@logilab.fr>
Tue, 20 Mar 2012 16:11:57 +0100
changeset 153 c088f503cc97
parent 152 54c67d7f9eed
child 154 d3c3211fcfc4
add qsync extension to mutable history
hgext/qsync.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hgext/qsync.py	Tue Mar 20 16:11:57 2012 +0100
@@ -0,0 +1,230 @@
+
+import re
+
+from cStringIO import StringIO
+
+from mercurial.i18n import _
+from mercurial import commands
+from mercurial import patch
+from mercurial import util
+from mercurial.node import nullid, hex, short, bin
+from mercurial import cmdutil
+from mercurial import hg
+from mercurial import scmutil
+from mercurial import error
+from mercurial import extensions
+
+
+import re
+
+import json
+
+
+### old compat code
+#############################
+
+BRANCHNAME="qsubmit2"
+OLDBRANCHNAME="pyves-qsubmit"
+
+### new command
+#############################
+cmdtable = {}
+command = cmdutil.command(cmdtable)
+
+@command('^qsync|sync',
+    [
+     ('a', 'review-all', False, _('mark all touched patches ready for review (no editor)')),
+    ],
+    '')
+def cmdsync(ui, repo, **opts):
+    '''Export draft changeset as mq patch in a mq patches repository commit.
+
+    This command get all changesets in draft phase and create an mq changeset:
+
+        * on a "qsubmit2" branch (based on the last changeset)
+
+        * one patch per draft changeset
+
+        * a series files listing all generated patch
+
+        * qsubmitdata holding useful information
+
+    It does use obsolete relation to update patches that already existing in the qsubmit2 branch.
+
+    Already existing patch which became public, draft or got killed are remove from the mq repo.
+
+    Patch name are generated using the summary line for changeset description.
+
+    .. warning:: Series files is ordered topologically. So two series with
+                 interleaved changeset will appear interleaved.
+    '''
+
+    review = None
+    review = 'edit'
+    if opts['review_all']:
+        review = 'all'
+    mqrepo = repo.mq.qrepo()
+    try:
+        parent = mqrepo[BRANCHNAME]
+    except error.RepoLookupError:
+        try:
+            parent =  mqrepo[OLDBRANCHNAME]
+        except error.RepoLookupError:
+            parent = initqsubmit(mqrepo)
+    store, data, touched = fillstore(repo, parent)
+    if not touched:
+        raise util.Abort('Nothing changed')
+    files = ['qsubmitdata', 'series'] + touched
+    # mark some as ready for review
+    message = 'qsubmit commit\n\n'
+    review_list = []
+    if review:
+        for patch_name in touched:
+            try:
+                store.getfile(patch_name)
+                review_list.append(patch_name)
+            except IOError:
+                pass
+
+    if review:
+        message += '\n'.join('* %s ready for review' % x for x in review_list)
+    memctx = patch.makememctx(mqrepo, (parent.node(), nullid),
+                              message,
+                              None,
+                              None,
+                              parent.branch(), files, store,
+                              editor=None)
+    if review == 'edit':
+        memctx._text = cmdutil.commitforceeditor(mqrepo, memctx, [])
+    mqrepo.savecommitmessage(memctx.description())
+    n = memctx.commit()
+    return 0
+
+
+def makename(ctx):
+    """create a patch name form a changeset"""
+    descsummary = ctx.description().splitlines()[0]
+    descsummary = re.sub(r'\s+', '_', descsummary)
+    descsummary = re.sub(r'\W+', '', descsummary)
+    if len(descsummary) > 45:
+        descsummary = descsummary[:42] + '.'
+    return '%s-%s.diff' % (ctx.branch().upper(), descsummary)
+
+
+def get_old_data(mqctx):
+    """read qsubmit data to fetch previous export data
+
+    get old data from the content of an mq commit"""
+    try:
+        old_data = mqctx['qsubmitdata']
+        return json.loads(old_data.data())
+    except error.LookupError:
+        return []
+
+def get_current_data(repo):
+    """Return what would be exported if not previous data exists"""
+    data = []
+    for ctx in repo.set('draft() - obsolete()'):
+        name = makename(ctx)
+        data.append([ctx.hex(), makename(ctx)])
+    return data
+
+
+def patchmq(repo, store, olddata, newdata):
+    """export the mq patches and return all useful data to be exported"""
+    finaldata = []
+    touched = set()
+    currentdrafts = set(d[0] for d in newdata)
+    usednew = set()
+    usedold = set()
+    obsolete = extensions.find('obsolete')
+    for oldhex, oldname in olddata:
+        if oldhex in usedold:
+            continue # no duplicate
+        usedold.add(oldhex)
+        oldname = str(oldname)
+        oldnode = bin(oldhex)
+        newnodes = obsolete.newerversion(repo, oldnode)
+        if newnodes:
+            newnodes = [n for n in newnodes if n] # remove killing
+            if len(newnodes) > 1:
+                raise util.Abort('%s have more than one newer version: %s'% (oldname, newnodes))
+            if newnodes:
+                # else, changeset have been killed
+                newnode = list(newnodes)[0][0]
+                ctx = repo[newnode]
+                if ctx.hex() != oldhex and ctx.phase():
+                    fp = StringIO()
+                    cmdutil.export(repo, [ctx.rev()], fp=fp)
+                    data = fp.getvalue()
+                    store.setfile(oldname, data, (None, None))
+                    finaldata.append([ctx.hex(), oldname])
+                    usednew.add(ctx.hex())
+                    touched.add(oldname)
+                    continue
+        if oldhex in currentdrafts:
+            # else changeset is now public or secret
+            finaldata.append([oldhex, oldname])
+            usednew.add(ctx.hex())
+            continue
+        touched.add(oldname)
+
+    for newhex, newname in newdata:
+        if newhex in usednew:
+            continue
+        newnode = bin(newhex)
+        ctx = repo[newnode]
+        fp = StringIO()
+        cmdutil.export(repo, [ctx.rev()], fp=fp)
+        data = fp.getvalue()
+        store.setfile(newname, data, (None, None))
+        finaldata.append([ctx.hex(), newname])
+        touched.add(newname)
+    # sort by branchrev number
+    finaldata.sort(key=lambda x: sort_key(repo[x[0]]))
+    # sort touched too (ease review list)
+    stouched = [f[1] for f in finaldata if f[1] in touched]
+    return finaldata, stouched
+
+def sort_key(ctx):
+    """ctx sort key: (branch, rev)"""
+    return (ctx.branch(), ctx.rev())
+
+
+def fillstore(repo, basemqctx):
+    """file store with patch data"""
+    olddata = get_old_data(basemqctx)
+    newdata = get_current_data(repo)
+    store = patch.filestore()
+    try:
+        data, touched = patchmq(repo, store, olddata, newdata)
+        # put all name in the series
+        series ='\n'.join(d[1] for d in data) + '\n'
+        store.setfile('series', series, (False, False))
+
+        # export data to ease futur work
+        series ='\n'.join(d[1] for d in data) + '\n'
+        store.setfile('qsubmitdata', json.dumps(data, indent=True),
+                      (False, False))
+    finally:
+        store.close()
+    return store, data, touched
+
+
+def initqsubmit(mqrepo):
+    """create initial qsubmit branch"""
+    store = patch.filestore()
+    try:
+        files = set()
+        store.setfile('.hgignore', '^status$\n', (False, False))
+        memctx = patch.makememctx(mqrepo, (nullid, nullid),
+                              'qsubmit init',
+                              None,
+                              None,
+                              BRANCHNAME, ('.hgignore',), store,
+                              editor=None)
+        mqrepo.savecommitmessage(memctx.description())
+        n = memctx.commit()
+    finally:
+        store.close()
+    return mqrepo[n]