diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cd7ba84d3c..2c0d33a244 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -626,6 +626,8 @@ jobs:
env:
PY_COLORS: 1
+ MSYS2_ARG_CONV_EXCL: "*"
+ MSYS2_ENV_CONV_EXCL: "*"
defaults:
run:
diff --git a/docs/installation.rst b/docs/installation.rst
index 8272cd0831..f37609534c 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -326,6 +326,29 @@ Install the dependencies with the provided script::
./scripts/msys2-install-deps
+.. _msys2_path_translation:
+
+MSYS2 Path Translation
+++++++++++++++++++++++
+
+When running Borg within an MSYS2 environment, the shell
+automatically translates POSIX-style paths (like ``/tmp`` or ``/C/Users``) to
+Windows paths (like ``C:\msys64\tmp`` or ``C:\Users``) before they reach the
+Borg process.
+
+This behavior can result in absolute Windows paths being stored in your backups,
+which may not be what you intended if you use POSIX paths for portability.
+
+To disable this automatic translation for Borg, you can use environment variables
+to exclude everything from conversion. Similarly, MSYS2 also translates
+environment variables that look like paths. To disable this generally for Borg,
+set both variables::
+
+ export MSYS2_ARG_CONV_EXCL="*"
+ export MSYS2_ENV_CONV_EXCL="*"
+
+For more details, see the `MSYS2 documentation on filesystem paths `__.
+
Windows 10's Linux Subsystem
++++++++++++++++++++++++++++
diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py
index 591f02913c..dadb4c7d83 100644
--- a/src/borg/archiver/__init__.py
+++ b/src/borg/archiver/__init__.py
@@ -46,6 +46,7 @@
from ..helpers import msgpack
from ..helpers import sig_int
from ..helpers import get_config_dir
+ from ..platformflags import is_msystem
from ..remote import RemoteRepository
from ..selftest import selftest
except BaseException:
@@ -397,7 +398,16 @@ def get_func(self, args, parser):
return functools.partial(self.do_maincommand_help, parser)
def prerun_checks(self, logger, is_serve):
-
+ if (
+ not is_serve
+ and is_msystem
+ and ("MSYS2_ARG_CONV_EXCL" not in os.environ or "MSYS2_ENV_CONV_EXCL" not in os.environ)
+ ):
+ logger.warning(
+ "MSYS2 path translation is active. This can cause POSIX paths to be mangled into "
+ "Windows paths in archives. Consider setting MSYS2_ARG_CONV_EXCL='*' and "
+ "MSYS2_ENV_CONV_EXCL='*'. See https://www.msys2.org/docs/filesystem-paths/ for details."
+ )
selftest(logger)
def _setup_implied_logging(self, args):
diff --git a/src/borg/platformflags.py b/src/borg/platformflags.py
index 9abf7d0652..cc84f7c7ac 100644
--- a/src/borg/platformflags.py
+++ b/src/borg/platformflags.py
@@ -4,6 +4,7 @@
Use these flags instead of sys.platform.startswith('') or try/except.
"""
+import os
import sys
is_win32 = sys.platform.startswith("win32")
@@ -15,3 +16,6 @@
is_openbsd = sys.platform.startswith("openbsd")
is_darwin = sys.platform.startswith("darwin")
is_haiku = sys.platform.startswith("haiku")
+
+# MSYS2 (on Windows)
+is_msystem = is_win32 and "MSYSTEM" in os.environ
diff --git a/src/borg/testsuite/archiver/create_cmd_test.py b/src/borg/testsuite/archiver/create_cmd_test.py
index 3536adde63..2562aa3779 100644
--- a/src/borg/testsuite/archiver/create_cmd_test.py
+++ b/src/borg/testsuite/archiver/create_cmd_test.py
@@ -14,6 +14,7 @@
from ...constants import zeros
from ...manifest import Manifest
from ...platform import is_win32
+from ...platformflags import is_msystem
from ...repository import Repository
from ...helpers import CommandError, BackupPermissionError
from .. import has_lchflags, has_mknod
@@ -150,6 +151,45 @@ def test_archived_paths(archivers, request):
assert expected_paths == sorted([json.loads(line)["path"] for line in archive_list.splitlines() if line])
+@pytest.mark.skipif(not is_msystem, reason="only for msystem")
+def test_create_msys2_path_translation_warning(archivers, request, monkeypatch):
+ archiver = request.getfixturevalue(archivers)
+ cmd(archiver, "repo-create", RK_ENCRYPTION)
+ create_regular_file(archiver.input_path, "test")
+
+ # When MSYS2 path translation is active (variables NOT set), a warning should be emitted.
+ monkeypatch.delenv("MSYS2_ARG_CONV_EXCL", raising=False)
+ monkeypatch.delenv("MSYS2_ENV_CONV_EXCL", raising=False)
+ output = cmd(archiver, "create", "test1", "input", fork=True)
+ assert "MSYS2 path translation is active." in output
+
+ # When the variables ARE set, the warning should not be emitted,
+ # and /tmp should be archived properly without being translated to msys64/tmp.
+ monkeypatch.setenv("MSYS2_ARG_CONV_EXCL", "*")
+ monkeypatch.setenv("MSYS2_ENV_CONV_EXCL", "*")
+
+ # We must create a real /tmp directory to avoid file not found errors,
+ # since we will pass '/tmp' directly to Borg
+ tmp_path = os.path.abspath("/tmp")
+ os.makedirs(tmp_path, exist_ok=True)
+ test_filepath = os.path.join(tmp_path, "borg_msys2_test_file")
+ with open(test_filepath, "w") as f:
+ f.write("test")
+
+ try:
+ output2 = cmd(archiver, "create", "test2", "/tmp", fork=True)
+ assert "MSYS2 path translation is active." not in output2
+
+ archive_list = cmd(archiver, "list", "test2", "--json-lines")
+ paths = [json.loads(line)["path"] for line in archive_list.splitlines() if line]
+
+ # Verify that msys64 is not present and paths start with tmp/
+ assert not any("msys64" in p for p in paths)
+ assert any(p.startswith("tmp/borg_msys2_test_file") for p in paths)
+ finally:
+ os.unlink(test_filepath)
+
+
@requires_hardlinks
def test_create_duplicate_root(archivers, request):
archiver = request.getfixturevalue(archivers)