diff hgext3rd/topic/__init__.py @ 6360:e959390490c2

branching: merge with stable
author Anton Shestakov <av6@dwimlabs.net>
date Fri, 09 Dec 2022 15:01:59 +0400
parents 453861da6922
children 573174ef1bbf
line wrap: on
line diff
--- a/hgext3rd/topic/__init__.py	Fri Dec 09 14:57:14 2022 +0400
+++ b/hgext3rd/topic/__init__.py	Fri Dec 09 15:01:59 2022 +0400
@@ -168,7 +168,9 @@
     changelog,
     cmdutil,
     commands,
+    configitems,
     context,
+    encoding,
     error,
     exchange,
     extensions,
@@ -231,64 +233,86 @@
               b'log.topic': b'green_background',
               }
 
-__version__ = b'0.24.3.dev'
+__version__ = b'0.25.0.dev'
 
-testedwith = b'4.8 4.9 5.0 5.1 5.2 5.3 5.4 5.5 5.6 5.7 5.8 5.9 6.0 6.1 6.2 6.3'
+testedwith = b'4.8 4.9 5.0 5.1 5.2 5.3 5.4 5.5 5.6 5.7 5.8 5.9 6.0 6.1 6.2'
 minimumhgversion = b'4.8'
 buglink = b'https://bz.mercurial-scm.org/'
 
-if util.safehasattr(registrar, 'configitem'):
-
-    from mercurial import configitems
-
-    configtable = {}
-    configitem = registrar.configitem(configtable)
+configtable = {}
+configitem = registrar.configitem(configtable)
 
-    configitem(b'experimental', b'enforce-topic',
-               default=False,
-    )
-    configitem(b'experimental', b'enforce-single-head',
-               default=False,
-    )
-    configitem(b'experimental', b'topic-mode',
-               default=None,
-    )
-    configitem(b'experimental', b'topic.publish-bare-branch',
-               default=False,
-    )
-    configitem(b'experimental', b'topic.allow-publish',
-               default=configitems.dynamicdefault,
-    )
-    configitem(b'_internal', b'keep-topic',
-               default=False,
-    )
-    configitem(b'experimental', b'topic-mode.server',
-               default=configitems.dynamicdefault,
-    )
-    configitem(b'experimental', b'topic.server-gate-topic-changesets',
-               default=False,
-    )
-    configitem(b'experimental', b'topic.linear-merge',
-               default="reject",
-    )
+configitem(b'experimental', b'enforce-topic',
+           default=False,
+)
+configitem(b'experimental', b'enforce-single-head',
+           default=False,
+)
+configitem(b'experimental', b'topic-mode',
+           default=None,
+)
+configitem(b'experimental', b'topic.publish-bare-branch',
+           default=False,
+)
+configitem(b'experimental', b'topic.allow-publish',
+           default=configitems.dynamicdefault,
+)
+configitem(b'_internal', b'keep-topic',
+           default=False,
+)
+# used for signaling that ctx.branch() shouldn't return fqbn even if topic is
+# enabled for local repo
+configitem(b'_internal', b'tns-disable-fqbn',
+           default=False,
+)
+# used for signaling that push will publish changesets
+configitem(b'_internal', b'tns-publish',
+           default=False,
+)
+configitem(b'experimental', b'topic-mode.server',
+           default=configitems.dynamicdefault,
+)
+configitem(b'experimental', b'topic.server-gate-topic-changesets',
+           default=False,
+)
+configitem(b'experimental', b'topic.linear-merge',
+           default="reject",
+)
 
-    def extsetup(ui):
-        # register config that strictly belong to other code (thg, core, etc)
-        #
-        # To ensure all config items we used are registered, we register them if
-        # nobody else did so far.
-        from mercurial import configitems
-        extraitem = functools.partial(configitems._register, ui._knownconfig)
-        if (b'experimental' not in ui._knownconfig
-                or not ui._knownconfig[b'experimental'].get(b'thg.displaynames')):
-            extraitem(b'experimental', b'thg.displaynames',
-                      default=None,
-            )
-        if (b'devel' not in ui._knownconfig
-                or not ui._knownconfig[b'devel'].get(b'random')):
-            extraitem(b'devel', b'randomseed',
-                      default=None,
-            )
+def extsetup(ui):
+    # register config that strictly belong to other code (thg, core, etc)
+    #
+    # To ensure all config items we used are registered, we register them if
+    # nobody else did so far.
+    extraitem = functools.partial(configitems._register, ui._knownconfig)
+    if (b'experimental' not in ui._knownconfig
+            or not ui._knownconfig[b'experimental'].get(b'thg.displaynames')):
+        extraitem(b'experimental', b'thg.displaynames',
+                  default=None,
+        )
+    if (b'devel' not in ui._knownconfig
+            or not ui._knownconfig[b'devel'].get(b'random')):
+        extraitem(b'devel', b'randomseed',
+                  default=None,
+        )
+
+def _contexttns(self, force=False):
+    if not force and not self.mutable():
+        return b'default'
+    cache = getattr(self._repo, '_tnscache', None)
+    # topic loaded, but not enabled (eg: multiple repo in the same process)
+    if cache is None:
+        return b'default'
+    if self.rev() is None:
+        # don't cache volatile ctx instances that aren't stored on-disk yet
+        return self.extra().get(b'topic-namespace', b'default')
+    tns = cache.get(self.rev())
+    if tns is None:
+        tns = self.extra().get(b'topic-namespace', b'default')
+        self._repo._tnscache[self.rev()] = tns
+    return tns
+
+context.basectx.topic_namespace = _contexttns
 
 def _contexttopic(self, force=False):
     if not (force or self.mutable()):
@@ -322,6 +346,15 @@
         return None
 context.basectx.topicidx = _contexttopicidx
 
+def _contextfqbn(self):
+    """return branch//namespace/topic of the changeset, also known as fully
+    qualified branch name
+    """
+    branch = encoding.tolocal(self.extra()[b'branch'])
+    return common.formatfqbn(branch, self.topic_namespace(), self.topic())
+
+context.basectx.fqbn = _contextfqbn
+
 stackrev = re.compile(br'^s\d+$')
 topicrev = re.compile(br'^t\d+$')
 
@@ -475,6 +508,7 @@
         def _restrictcapabilities(self, caps):
             caps = super(topicrepo, self)._restrictcapabilities(caps)
             caps.add(b'topics')
+            caps.add(b'topics-namespaces')
             if self.ui.configbool(b'phases', b'publish'):
                 mode = b'all'
             elif self.ui.configbool(b'experimental',
@@ -503,6 +537,24 @@
             return super(topicrepo, self).commitctx(ctx, *args, **kwargs)
 
         @util.propertycache
+        def _tnscache(self):
+            return {}
+
+        @property
+        def topic_namespaces(self):
+            if self._topic_namespaces is not None:
+                return self._topic_namespaces
+            namespaces = set([self.currenttns])
+            for c in self.set(b'not public()'):
+                namespaces.add(c.topic_namespace())
+            self._topic_namespaces = namespaces
+            return namespaces
+
+        @property
+        def currenttns(self):
+            return self.vfs.tryread(b'topic-namespace') or b'default'
+
+        @util.propertycache
         def _topiccache(self):
             return {}
 
@@ -524,7 +576,26 @@
         # overwritten at the instance level by topicmap.py
         _autobranchmaptopic = True
 
-        def branchmap(self, topic=None):
+        def branchmap(self, topic=None, convertbm=False):
+            if topic is None:
+                topic = getattr(self, '_autobranchmaptopic', False)
+            topicfilter = topicmap.topicfilter(self.filtername)
+            if not topic or topicfilter == self.filtername:
+                return super(topicrepo, self).branchmap()
+            bm = self.filtered(topicfilter).branchmap()
+            if convertbm:
+                entries = compat.bcentries(bm)
+                for key in list(entries):
+                    branch, tns, topic = common.parsefqbn(key)
+                    if topic:
+                        value = entries.pop(key)
+                        # we lose namespace when converting to ":" format
+                        key = b'%s:%s' % (branch, topic)
+                        entries[key] = value
+            return bm
+
+        def branchmaptns(self, topic=None):
+            """branchmap using fqbn as keys"""
             if topic is None:
                 topic = getattr(self, '_autobranchmaptopic', False)
             topicfilter = topicmap.topicfilter(self.filtername)
@@ -535,8 +606,7 @@
         def branchheads(self, branch=None, start=None, closed=False):
             if branch is None:
                 branch = self[None].branch()
-                if self.currenttopic:
-                    branch = b"%s:%s" % (branch, self.currenttopic)
+                branch = common.formatfqbn(branch, self.currenttns, self.currenttopic)
             return super(topicrepo, self).branchheads(branch=branch,
                                                       start=start,
                                                       closed=closed)
@@ -548,6 +618,7 @@
         def invalidatevolatilesets(self):
             # XXX we might be able to move this to something invalidated less often
             super(topicrepo, self).invalidatevolatilesets()
+            self._topic_namespaces = None
             self._topics = None
 
         def peer(self, *args, **kwargs):
@@ -556,7 +627,11 @@
                 class topicpeer(peer.__class__):
                     def branchmap(self):
                         usetopic = not self._repo.publishing()
-                        return self._repo.branchmap(topic=usetopic)
+                        return self._repo.branchmap(topic=usetopic, convertbm=usetopic)
+
+                    def branchmaptns(self):
+                        usetopic = not self._repo.publishing()
+                        return self._repo.branchmaptns(topic=usetopic)
                 peer.__class__ = topicpeer
             return peer
 
@@ -568,13 +643,9 @@
 
             reporef = weakref.ref(self)
             if self.ui.configbool(b'experimental', b'enforce-single-head'):
-                if util.safehasattr(tr, 'validator'): # hg <= 4.7 (ebbba3ba3f66)
-                    origvalidator = tr.validator
-                elif util.safehasattr(tr, '_validator'):
+                if util.safehasattr(tr, '_validator'):
                     # hg <= 5.3 (36f08ae87ef6)
                     origvalidator = tr._validator
-                else:
-                    origvalidator = None
 
                 def _validate(tr2):
                     repo = reporef()
@@ -582,11 +653,9 @@
 
                 def validator(tr2):
                     _validate(tr2)
-                    origvalidator(tr2)
+                    return origvalidator(tr2)
 
-                if util.safehasattr(tr, 'validator'): # hg <= 4.7 (ebbba3ba3f66)
-                    tr.validator = validator
-                elif util.safehasattr(tr, '_validator'):
+                if util.safehasattr(tr, '_validator'):
                     # hg <= 5.3 (36f08ae87ef6)
                     tr._validator = validator
                 else:
@@ -598,13 +667,9 @@
                                              b'topic.publish-bare-branch')
             ispush = desc.startswith((b'push', b'serve'))
             if (topicmodeserver != b'ignore' and ispush):
-                if util.safehasattr(tr, 'validator'): # hg <= 4.7 (ebbba3ba3f66)
-                    origvalidator = tr.validator
-                elif util.safehasattr(tr, '_validator'):
+                if util.safehasattr(tr, '_validator'):
                     # hg <= 5.3 (36f08ae87ef6)
                     origvalidator = tr._validator
-                else:
-                    origvalidator = None
 
                 def _validate(tr2):
                     repo = reporef()
@@ -614,9 +679,7 @@
                     _validate(tr2)
                     return origvalidator(tr2)
 
-                if util.safehasattr(tr, 'validator'): # hg <= 4.7 (ebbba3ba3f66)
-                    tr.validator = validator
-                elif util.safehasattr(tr, '_validator'):
+                if util.safehasattr(tr, '_validator'):
                     # hg <= 5.3 (36f08ae87ef6)
                     tr._validator = validator
                 else:
@@ -636,13 +699,9 @@
                                                b'topic.allow-publish',
                                                True)
             if not allow_publish:
-                if util.safehasattr(tr, 'validator'): # hg <= 4.7 (ebbba3ba3f66)
-                    origvalidator = tr.validator
-                elif util.safehasattr(tr, '_validator'):
+                if util.safehasattr(tr, '_validator'):
                     # hg <= 5.3 (36f08ae87ef6)
                     origvalidator = tr._validator
-                else:
-                    origvalidator = None
 
                 def _validate(tr2):
                     repo = reporef()
@@ -652,9 +711,7 @@
                     _validate(tr2)
                     return origvalidator(tr2)
 
-                if util.safehasattr(tr, 'validator'): # hg <= 4.7 (ebbba3ba3f66)
-                    tr.validator = validator
-                elif util.safehasattr(tr, '_validator'):
+                if util.safehasattr(tr, '_validator'):
                     # hg <= 5.3 (36f08ae87ef6)
                     tr._validator = validator
                 else:
@@ -694,6 +751,7 @@
             return tr
 
     repo.__class__ = topicrepo
+    repo._topic_namespaces = None
     repo._topics = None
     if util.safehasattr(repo, 'names'):
         repo.names.addnamespace(namespaces.namespace(
@@ -714,10 +772,28 @@
     ctx = context.resource(mapping, b'ctx')
     return ctx.topicidx()
 
+@templatekeyword(b'topic_namespace', requires={b'ctx'})
+def topicnamespacekw(context, mapping):
+    """:topic_namespace: String. The topic namespace of the changeset"""
+    ctx = context.resource(mapping, b'ctx')
+    return ctx.topic_namespace()
+
+@templatekeyword(b'fqbn', requires={b'ctx'})
+def fqbnkw(context, mapping):
+    """:fqbn: String. The branch//namespace/topic of the changeset"""
+    ctx = context.resource(mapping, b'ctx')
+    return ctx.fqbn()
+
 def wrapinit(orig, self, repo, *args, **kwargs):
     orig(self, repo, *args, **kwargs)
     if not hastopicext(repo):
         return
+    if b'topic-namespace' not in self._extra:
+        if getattr(repo, 'currenttns', b''):
+            self._extra[b'topic-namespace'] = repo.currenttns
+        else:
+            # Default value will be dropped from extra by another hack at the changegroup level
+            self._extra[b'topic-namespace'] = b'default'
     if constants.extrakey not in self._extra:
         if getattr(repo, 'currenttopic', b''):
             self._extra[constants.extrakey] = repo.currenttopic
@@ -728,6 +804,9 @@
 def wrapadd(orig, cl, manifest, files, desc, transaction, p1, p2, user,
             date=None, extra=None, p1copies=None, p2copies=None,
             filesadded=None, filesremoved=None):
+    if b'topic-namespace' in extra and extra[b'topic-namespace'] == b'default':
+        extra = extra.copy()
+        del extra[b'topic-namespace']
     if constants.extrakey in extra and not extra[constants.extrakey]:
         extra = extra.copy()
         del extra[constants.extrakey]
@@ -767,7 +846,8 @@
         (b'', b'current', None, b'display the current topic only'),
     ] + commands.formatteropts,
     _(b'hg topics [OPTION]... [-r REV]... [TOPIC]'),
-    **compat.helpcategorykwargs('CATEGORY_CHANGE_ORGANIZATION'))
+    helpcategory=registrar.command.CATEGORY_CHANGE_ORGANIZATION,
+)
 def topics(ui, repo, topic=None, **opts):
     """View current topic, set current topic, change topic for a set of revisions, or see all topics.
 
@@ -812,13 +892,15 @@
     age = opts.get('age')
 
     if current and topic:
-        raise error.Abort(_(b"cannot use --current when setting a topic"))
+        raise compat.InputError(_(b"cannot use --current when setting a topic"))
     if current and clear:
-        raise error.Abort(_(b"cannot use --current and --clear"))
+        raise compat.InputError(_(b"cannot use --current and --clear"))
     if clear and topic:
-        raise error.Abort(_(b"cannot use --clear when setting a topic"))
+        raise compat.InputError(_(b"cannot use --clear when setting a topic"))
     if age and topic:
-        raise error.Abort(_(b"cannot use --age while setting a topic"))
+        raise compat.InputError(_(b"cannot use --age while setting a topic"))
+
+    compat.check_incompatible_arguments(opts, 'list', ('clear', 'rev'))
 
     touchedrevs = set()
     if rev:
@@ -827,20 +909,24 @@
     if topic:
         topic = topic.strip()
         if not topic:
-            raise error.Abort(_(b"topic name cannot consist entirely of whitespaces"))
+            raise compat.InputError(_(b"topic names cannot consist entirely of whitespace"))
         # Have some restrictions on the topic name just like bookmark name
         scmutil.checknewlabel(repo, topic, b'topic')
 
-        rmatch = re.match(br'[-_.\w]+', topic)
-        if not rmatch or rmatch.group(0) != topic:
-            helptxt = _(b"topic names can only consist of alphanumeric, '-'"
-                        b" '_' and '.' characters")
-            raise error.Abort(_(b"invalid topic name: '%s'") % topic, hint=helptxt)
+        helptxt = _(b"topic names can only consist of alphanumeric, '-'"
+                    b" '_' and '.' characters")
+        try:
+            utopic = encoding.unifromlocal(topic)
+        except error.Abort:
+            # Maybe we should allow these topic names as well, as long as they
+            # don't break any other rules
+            utopic = ''
+        rmatch = re.match(r'[-_.\w]+', utopic, re.UNICODE)
+        if not utopic or not rmatch or rmatch.group(0) != utopic:
+            raise compat.InputError(_(b"invalid topic name: '%s'") % topic, hint=helptxt)
 
     if list:
         ui.pager(b'topics')
-        if clear or rev:
-            raise error.Abort(_(b"cannot use --clear or --rev with --list"))
         if not topic:
             topic = repo.currenttopic
         if not topic:
@@ -912,7 +998,8 @@
             _(b'display data about children outside of the stack'))
     ] + commands.formatteropts,
     _(b'hg stack [TOPIC]'),
-    **compat.helpcategorykwargs('CATEGORY_CHANGE_NAVIGATION'))
+    helpcategory=registrar.command.CATEGORY_CHANGE_NAVIGATION,
+)
 def cmdstack(ui, repo, topic=b'', **opts):
     """list all changesets in a topic and other information
 
@@ -1058,8 +1145,7 @@
 
     if newtopic:
         with repo.wlock():
-            with repo.vfs.open(b'topic', b'w') as f:
-                f.write(newtopic)
+            repo.vfs.write(b'topic', newtopic)
     else:
         if repo.vfs.exists(b'topic'):
             repo.vfs.unlink(b'topic')
@@ -1321,8 +1407,7 @@
         hint = _(b"see 'hg help -e topic.topic-mode' for details")
         if opts.get('topic'):
             t = opts['topic']
-            with repo.vfs.open(b'topic', b'w') as f:
-                f.write(t)
+            repo.vfs.write(b'topic', t)
         elif opts.get('amend'):
             pass
         elif notopic and mayabort:
@@ -1333,8 +1418,7 @@
             if not ui.quiet:
                 ui.warn((b"(%s)\n") % hint)
         elif notopic and mayrandom:
-            with repo.vfs.open(b'topic', b'w') as f:
-                f.write(randomname.randomtopicname(ui))
+            repo.vfs.write(b'topic', randomname.randomtopicname(ui))
         return orig(ui, repo, *args, **opts)
 
 def committextwrap(orig, repo, ctx, subs, extramsg):
@@ -1377,9 +1461,11 @@
                     ret = super(overridebranch, self).__getitem__(rev)
                     if rev == node:
                         b = ret.branch()
+                        tns = ret.topic_namespace()
                         t = ret.topic()
+                        # topic is required for merging from bare branch
                         if t:
-                            ret.branch = lambda: b'%s//%s' % (b, t)
+                            ret.branch = lambda: common.formatfqbn(b, tns, t)
                     return ret
             unfi.__class__ = overridebranch
             if repo.filtername is not None:
@@ -1401,18 +1487,23 @@
         # if rebase is running and update the currenttopic to topic of new
         # rebased commit. We have explicitly stored in config if rebase is
         # running.
+        otns = repo.currenttns
         ot = repo.currenttopic
         if repo.ui.hasconfig(b'experimental', b'topicrebase'):
             isrebase = True
         if repo.ui.configbool(b'_internal', b'keep-topic'):
             ist0 = True
         if ((not partial and not branchmerge) or isrebase) and not ist0:
+            tns = b'default'
             t = b''
             pctx = repo[node]
             if pctx.phase() > phases.public:
+                tns = pctx.topic_namespace()
                 t = pctx.topic()
-            with repo.vfs.open(b'topic', b'w') as f:
-                f.write(t)
+            repo.vfs.write(b'topic-namespace', tns)
+            if tns != b'default' and tns != otns:
+                repo.ui.status(_(b"switching to topic-namespace %s\n") % tns)
+            repo.vfs.write(b'topic', t)
             if t and t != ot:
                 repo.ui.status(_(b"switching to topic %s\n") % t)
             if ot and not t:
@@ -1501,3 +1592,65 @@
     # Restore the topic if need
     if topic:
         _changecurrenttopic(repo, topic)
+
+def _changecurrenttns(repo, tns):
+    if tns:
+        with repo.wlock():
+            repo.vfs.write(b'topic-namespace', tns)
+    else:
+        repo.vfs.unlinkpath(b'topic-namespace', ignoremissing=True)
+
+@command(b'debug-topic-namespace', [
+        (b'', b'clear', False, b'clear active topic namespace if any'),
+    ],
+    _(b'[NAMESPACE|--clear]'))
+def debugtopicnamespace(ui, repo, tns=None, **opts):
+    """set or show the current topic namespace"""
+    if opts.get('clear'):
+        if tns:
+            raise error.Abort(_(b"cannot use --clear when setting a topic namespace"))
+        tns = None
+    elif not tns:
+        ui.write(b'%s\n' % repo.currenttns)
+        return
+    if tns:
+        tns = tns.strip()
+        if not tns:
+            raise error.Abort(_(b"topic namespace cannot consist entirely of whitespace"))
+        if b'/' in tns:
+            raise error.Abort(_(b"topic namespace cannot contain '/' character"))
+        scmutil.checknewlabel(repo, tns, b'topic namespace')
+    ctns = repo.currenttns
+    _changecurrenttns(repo, tns)
+    if ctns == b'default' and tns:
+        repo.ui.status(_(b'marked working directory as topic namespace: %s\n')
+                       % tns)
+
+@command(b'debug-topic-namespaces', [])
+def debugtopicnamespaces(ui, repo, **opts):
+    """list repository namespaces"""
+    for tns in repo.topic_namespaces:
+        ui.write(b'%s\n' % (tns,))
+
+@command(b'debug-parse-fqbn', commands.formatteropts, _(b'FQBN'), optionalrepo=True)
+def debugparsefqbn(ui, repo, fqbn, **opts):
+    """parse branch//namespace/topic string into its components"""
+    branch, tns, topic = common.parsefqbn(fqbn)
+    opts = pycompat.byteskwargs(opts)
+    fm = ui.formatter(b'debug-parse-namespace', opts)
+    fm.startitem()
+    fm.write(b'branch', b'branch:    %s\n', branch)
+    fm.write(b'topic_namespace', b'namespace: %s\n', tns)
+    fm.write(b'topic', b'topic:     %s\n', topic)
+    fm.end()
+
+@command(b'debug-format-fqbn', [
+        (b'b', b'branch', b'', b'branch'),
+        (b'n', b'topic-namespace', b'', b'topic namespace'),
+        (b't', b'topic', b'', b'topic'),
+        (b's', b'short', False, b'short format'),
+    ], optionalrepo=True)
+def debugformatfqbn(ui, repo, **opts):
+    """format branch, namespace and topic into branch//namespace/topic string"""
+    short = common.formatfqbn(opts.get('branch'), opts.get('topic_namespace'), opts.get('topic'), opts.get('short'))
+    ui.write(b'%s\n' % short)