[PATCH 1 of 5] compat: start the work to implement compatibility with mercurial 7.2

5 views
Skip to first unread message

Antonio Muci

unread,
May 2, 2026, 8:45:30 AM (23 hours ago) May 2
to thg...@googlegroups.com, a....@inwind.it
# HG changeset patch
# User Antonio Muci <a....@inwind.it>
# Date 1777673658 -7200
# Sat May 02 00:14:18 2026 +0200
# Branch stable
# Node ID 24465524d7419b499692d754b04cf7a3e3ce5a29
# Parent c92cd00dbfe34302c6ea6824a2636b28c92adcbc
compat: start the work to implement compatibility with mercurial 7.2

This commit adds a util.compat module, in order to centralize - where possible -
the backward compatibility hacks.

This first commit adapts the code base to parse_config_opts and early_parse_opts
being moved in a new main_script module.

In order to keep compatibility with mercurial <= 7.1.2, the old imports are kept
as a fallback. They are however renamed according to the new API.

The API incompatibility was introduced on 2025-06-18 in mercurial changeset
aa0ff1214f489a804a6a924df699bf72d890390d ("cycle-breaking: move some of
`dispatch.py` in a new `main_script` module") and subsequent changes.

diff --git a/tests/helpers.py b/tests/helpers.py
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -17,6 +17,8 @@ except ImportError:
# a lot of stuff.
QTextCodec = None

+from tortoisehg.util import compat as compatmod
+
from mercurial import (
dispatch,
encoding as encodingmod,
@@ -168,9 +170,9 @@ class HgClient:
ui.ferr = util.bytesio()
args = list(args)
req = dispatch.request(args, ui=ui)
- options = dispatch._earlyparseopts(ui, args)
+ options = compatmod.early_parse_opts(ui, args)
req.earlyoptions.update(options)
- dispatch._parseconfig(ui, req.earlyoptions[b'config'])
+ compatmod.parse_config_opts(ui, req.earlyoptions[b'config'])
try:
result = dispatch._dispatch(req) or 0
return result, ui.fout.getvalue(), ui.ferr.getvalue()
diff --git a/tortoisehg/util/compat.py b/tortoisehg/util/compat.py
new file mode 100644
--- /dev/null
+++ b/tortoisehg/util/compat.py
@@ -0,0 +1,18 @@
+# compat.py - TortoiseHg backward compatibility stubs
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+
+from __future__ import annotations
+
+try:
+ # mercurial >= 7.2
+ from mercurial.main_script.options import parse_config_opts
+except ModuleNotFoundError:
+ from mercurial.dispatch import _parseconfig as parse_config_opts
+
+try:
+ # mercurial >= 7.2
+ from mercurial.main_script.options import early_parse_opts
+except ModuleNotFoundError:
+ from mercurial.dispatch import _earlyparseopts as early_parse_opts
diff --git a/tortoisehg/util/hglib.py b/tortoisehg/util/hglib.py
--- a/tortoisehg/util/hglib.py
+++ b/tortoisehg/util/hglib.py
@@ -28,7 +28,6 @@ from mercurial import (
cmdutil,
config,
context,
- dispatch as dispatchmod,
encoding,
error,
extensions,
@@ -56,6 +55,7 @@ from mercurial.utils import (
from mercurial.node import nullrev

from . import (
+ compat as compatmod,
hgversion as hgversionmod,
paths,
)
@@ -1054,11 +1054,11 @@ def parseconfigopts(ui: uimod.ui,
>>> u.config(b'extensions', b'mq')
b'!'
"""
- config = dispatchmod._earlyparseopts(ui, args)[b'config']
+ config = compatmod.early_parse_opts(ui, args)[b'config']
# drop --config from args
args[:] = fancyopts.earlygetopt(args, b'', [b'config='],
gnu=True, keepsep=True)[1]
- return dispatchmod._parseconfig(ui, config)
+ return compatmod.parse_config_opts(ui, config)


# (unicode, QString) -> unicode, otherwise -> str

Antonio Muci

unread,
May 2, 2026, 8:45:31 AM (23 hours ago) May 2
to thg...@googlegroups.com, a....@inwind.it
# HG changeset patch
# User Antonio Muci <a....@inwind.it>
# Date 1777677708 -7200
# Sat May 02 01:21:48 2026 +0200
# Branch stable
# Node ID 1be319fc54fd22d870bab6ae7e3902c4d1038f6b
# Parent 90770099bfd549135c73749c7c268ac9b5ce2b31
compat: handle iterbranches being removed from branchmap in hg >= 7.2

Mercurial commit 02010bf96a32 ("branchmap: drop the `iterbranches` method from
the interface") removed branchmap().iterbranches(), which causes a runtime error
at TortoiseHg's startup:

```
Traceback (most recent call last):
File "<BASE>/tortoisehg/util/hglib.py", line xxx, in namedbranches
in branchmap.iterbranches()
^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'BranchCacheV2' object has no attribute 'iterbranches'
```

Immediately before that, 241e9d8ccf64 ("branchmap: add a `open_only` argument to
`hasbranch`") enriched hasbranch() api in a way that could be useful to us.

Both changes were integrated in mercurial 7.2.

This commit tries to be logically equivalent to the old (hg <= 7.1.2) fragment,
which is kept as a fallback. The change isinspired by what mercurial has done
in ce46d9d90f1c ("branchmap: use `hasbranch` to detect non-closed branch in `hg
branch`").

diff --git a/tortoisehg/util/hglib.py b/tortoisehg/util/hglib.py
--- a/tortoisehg/util/hglib.py
+++ b/tortoisehg/util/hglib.py
@@ -238,9 +238,33 @@ def activebookmark(repo) -> bytes:
def namedbranches(repo) -> List[bytes]:
branchmap = repo.branchmap()
dead = repo.deadbranches
- return sorted(br for br, _heads, _tip, isclosed
- in branchmap.iterbranches()
- if not isclosed and br not in dead)
+ try:
+ # mercurial >= 7.2
+ #
+ # Mercurial commit 02010bf96a32 ("branchmap: drop the `iterbranches`
+ # method from the interface") removed branchmap().iterbranches(), but
+ # immediately before that 241e9d8ccf64 ("branchmap: add a `open_only`
+ # argument to `hasbranch`") enriched hasbranch() api.
+ # Both changes were integrated in mercurial 7.2.
+ #
+ # The logic of this fragment is inspired by mercurial changeset
+ # ce46d9d90f1c ("branchmap: use `hasbranch` to detect non-closed branch
+ # in `hg branch`") and tries to be logically equivalent to the old one,
+ # which is kept in the 'except' branch below.
+ return sorted(bn for bn in branchmap
+ if branchmap.hasbranch(bn, open_only=True) and bn not in dead)
+ except TypeError as e:
+ # let's try to be strict with patching, and only revert back to the
+ # original (pre 7.2 logic) if the TypeError refers to the missing
+ # open_only keyword argument that was added to hasbranch() in hg 7.2.
+ #
+ # The full error message would be:
+ # "_LocalBranchCache.hasbranch() got an unexpected keyword argument 'open_only'"
+ if "unexpected keyword argument 'open_only'" not in e.args[0]:
+ raise
+ return sorted(br for br, _heads, _tip, isclosed
+ in branchmap.iterbranches()
+ if not isclosed and br not in dead)

def _firstchangectx(repo):
try:

Antonio Muci

unread,
May 2, 2026, 8:45:31 AM (23 hours ago) May 2
to thg...@googlegroups.com, a....@inwind.it
# HG changeset patch
# User Antonio Muci <a....@inwind.it>
# Date 1777679419 -7200
# Sat May 02 01:50:19 2026 +0200
# Branch stable
# Node ID f3b64e35413543211b243afd4ece15a65fd5b686
# Parent 1be319fc54fd22d870bab6ae7e3902c4d1038f6b
compat: handle repo.branchheads being removed from branchmap in hg >= 7.2

The removal happened on 2025-11-06, in mercurial commit a49c8fe2c986
("branchmap: drop the `repo.branchheads` method"), and was integrated in
mercurial 7.2.

Anyhow, a solution was already at hand: from at least 2019 (and possibly before:
I did not bother to check), branchmap was already exposing a branchheads()
method.

This changeset is inspired to what mercurial has done on 2025-11-06 in
dff46af5e54d ("branchmap: directly use the branchmap in `summary`").

From this commit on, tortoisehg is working under mercurial >= 7.2, while keeping
compatibility with hg <= 7.1.2.

However, tests are still broken and need an intervention. This will be done in
the next commit.

diff --git a/tortoisehg/hgqt/csinfo.py b/tortoisehg/hgqt/csinfo.py
--- a/tortoisehg/hgqt/csinfo.py
+++ b/tortoisehg/hgqt/csinfo.py
@@ -253,7 +253,7 @@ class SummaryInfo:
else:
return None
elif item == 'ishead':
- return ctx.node() in ctx.repo().branchheads(ctx.branch())
+ return ctx.node() in ctx.repo().branchmap().branchheads(ctx.branch())
elif item == 'mqoriginalparent':
target = ctx.thgmqoriginalparent()
if not target:
diff --git a/tortoisehg/hgqt/repomodel.py b/tortoisehg/hgqt/repomodel.py
--- a/tortoisehg/hgqt/repomodel.py
+++ b/tortoisehg/hgqt/repomodel.py
@@ -833,7 +833,7 @@ class HgRepoListModel(QAbstractTableMode
try:
branchheads = self._branchheads[branch]
except KeyError:
- branchheads = set(self.repo.branchheads(branch))
+ branchheads = set(self.repo.branchmap().branchheads(branch))
self._branchheads[branch] = branchheads

if ctx.rev() is None:
diff --git a/tortoisehg/hgqt/sync.py b/tortoisehg/hgqt/sync.py
--- a/tortoisehg/hgqt/sync.py
+++ b/tortoisehg/hgqt/sync.py
@@ -351,7 +351,7 @@ class SyncWidget(QWidget, qtlib.TaskWidg
for name in ctx.bookmarks():
uname = hglib.tounicode(name)
return self.targetcombo.findText(_('bookmark: ') + uname)
- if ctx.node() in self.repo.branchheads(ctx.branch()):
+ if ctx.node() in self.repo.branchmap().branchheads(ctx.branch()):
uname = hglib.tounicode(ctx.branch())
return self.targetcombo.findText(_('branch: ') + uname)
return 0
diff --git a/tortoisehg/util/hglib.py b/tortoisehg/util/hglib.py
--- a/tortoisehg/util/hglib.py
+++ b/tortoisehg/util/hglib.py
@@ -1449,7 +1449,7 @@ def parsecmdline(cmdline: str, cwd: str)
def createsnewhead(ctx, branchheads=None):
branch = ctx.branch()
if branchheads is None:
- branchheads = set(ctx.repo().branchheads(branch))
+ branchheads = set(ctx.repo().branchmap().branchheads(branch))
return branchheads and not any(
p.node() in branchheads and p.branch() == branch for p in ctx.parents()
)

Antonio Muci

unread,
May 2, 2026, 8:45:31 AM (23 hours ago) May 2
to thg...@googlegroups.com, a....@inwind.it
# HG changeset patch
# User Antonio Muci <a....@inwind.it>
# Date 1777674769 -7200
# Sat May 02 00:32:49 2026 +0200
# Branch stable
# Node ID 90770099bfd549135c73749c7c268ac9b5ce2b31
# Parent 24465524d7419b499692d754b04cf7a3e3ce5a29
compat: fix find_cmd being moved to main_script in hg >= 7.2

The previous commit fixed the first TortoiseHg startup error. This commit fixes
the one that pops out immediately after that.

Incompatibility introduced on 2025-08-06 in mercurial changeset
d32b62bf63c8614b2d88796c9e5feeda25fa3fdc ("cycle-breaking: extract command
finding logic in its own module").

diff --git a/tortoisehg/hgqt/clone.py b/tortoisehg/hgqt/clone.py
--- a/tortoisehg/hgqt/clone.py
+++ b/tortoisehg/hgqt/clone.py
@@ -32,13 +32,15 @@ from .qtgui import (
)

from mercurial import (
- cmdutil,
commands,
hg,
pycompat,
)

-from ..util import hglib
+from ..util import (
+ hglib,
+ compat as compatmod,
+)
from ..util.i18n import _
from . import (
cmdcore,
@@ -65,7 +67,7 @@ if typing.TYPE_CHECKING:


def _startrev_available() -> bool:
- entry = cmdutil.findcmd(b'clone', commands.table)[1]
+ entry = compatmod.findcmd(b'clone', commands.table)[1]
longopts = {e[1] for e in entry[1]}
return b'startrev' in longopts

diff --git a/tortoisehg/hgqt/run.py b/tortoisehg/hgqt/run.py
--- a/tortoisehg/hgqt/run.py
+++ b/tortoisehg/hgqt/run.py
@@ -19,7 +19,6 @@ from typing import (
)

from mercurial import (
- cmdutil,
encoding,
error,
extensions,
@@ -38,6 +37,7 @@ from mercurial.utils import (
)

from ..util import (
+ compat as compatmod,
hglib,
hgversion,
i18n,
@@ -308,7 +308,7 @@ def _parse(ui, args):
else:
alias, args = b'workbench', []

- aliases, i = cmdutil.findcmd(alias, table, ui.config(b"ui", b"strict"))
+ aliases, i = compatmod.find_cmd(alias, table, ui.config(b"ui", b"strict"))
for a in aliases:
if a.startswith(alias):
alias = a
@@ -842,7 +842,7 @@ def help_(ui, name: Optional[bytes]=None
ui.write(b'\n')

try:
- aliases, i = cmdutil.findcmd(name, table, False)
+ aliases, i = compatmod.find_cmd(name, table, False)
except error.AmbiguousCommand as inst:
prefix = inst.prefix
select = lambda c: c.startswith(prefix)
diff --git a/tortoisehg/util/compat.py b/tortoisehg/util/compat.py
--- a/tortoisehg/util/compat.py
+++ b/tortoisehg/util/compat.py
@@ -16,3 +16,9 @@ try:
from mercurial.main_script.options import early_parse_opts
except ModuleNotFoundError:
from mercurial.dispatch import _earlyparseopts as early_parse_opts
+
+try:
+ # mercurial >= 7.2
+ from mercurial.main_script.cmd_finder import find_cmd
+except ModuleNotFoundError:
+ from mercurial.cmdutil import findcmd as find_cmd

Antonio Muci

unread,
May 2, 2026, 8:45:32 AM (23 hours ago) May 2
to thg...@googlegroups.com, a....@inwind.it
# HG changeset patch
# User Antonio Muci <a....@inwind.it>
# Date 1777715277 -7200
# Sat May 02 11:47:57 2026 +0200
# Branch stable
# Node ID eee035b93bdb422b54f4e25298468259a24f9bfc
# Parent f3b64e35413543211b243afd4ece15a65fd5b686
compat: handle dispatch.request being moved into main_script.request in hg >= 7.2

The change happened on 2025-06-18, in mercurial d505cac73c02 ("cycle-breaking:
move dispatch.request into the new main_script module").

The errors that tests were triggering before this commit were of the form:
AttributeError: module 'mercurial.dispatch' has no attribute 'request'

After this commit, tortoisehg passess tests on my local machine with the
following version combinations:
- python 3.13, hg (6.9.5)
- python 3.14, hg (6.9.5, 7.1.2, 7.2, 7.2.1)

diff --git a/tests/helpers.py b/tests/helpers.py
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -169,7 +169,7 @@ class HgClient:
ui.fout = util.bytesio()
ui.ferr = util.bytesio()
args = list(args)
- req = dispatch.request(args, ui=ui)
+ req = compatmod.request(args, ui=ui)
options = compatmod.early_parse_opts(ui, args)
req.earlyoptions.update(options)
compatmod.parse_config_opts(ui, req.earlyoptions[b'config'])
diff --git a/tortoisehg/util/compat.py b/tortoisehg/util/compat.py
--- a/tortoisehg/util/compat.py
+++ b/tortoisehg/util/compat.py
@@ -22,3 +22,9 @@ try:
from mercurial.main_script.cmd_finder import find_cmd
except ModuleNotFoundError:
from mercurial.cmdutil import findcmd as find_cmd
+
+try:
+ # mercurial >= 7.2
+ from mercurial.main_script import request
+except ModuleNotFoundError:
+ from mercurial.dispatch import request
Reply all
Reply to author
Forward
0 new messages