From e998d6c4c5399d7fd9a6b6afec21fa38fa796a22 Mon Sep 17 00:00:00 2001 From: "John C. McCabe-Dansted" Date: Mon, 18 May 2026 18:27:59 +1200 Subject: [PATCH 1/4] Warn if borg1 `init` command used on borg2 --- src/borg/archiver/__init__.py | 59 ++++++++++++++++++++ src/borg/testsuite/archiver/help_cmd_test.py | 21 ++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index dadb4c7d83..16b2c63725 100644 --- a/src/borg/archiver/__init__.py +++ b/src/borg/archiver/__init__.py @@ -308,6 +308,61 @@ def build_parser(self): self.build_parser_version(subparsers, common_parser, mid_common_parser) return parser + @staticmethod + def _first_toplevel_command_index(args, parser): + option_actions = {} + for action in parser._actions: + for option_string in getattr(action, "option_strings", ()): + option_actions[option_string] = action + + i = 0 + while i < len(args): + token = args[i] + if token == "--": + return None + if not token.startswith("-") or token == "-": + return i + + option_name, has_value, _ = token.partition("=") + action = option_actions.get(option_name) + if action is None: + return None + if has_value: + i += 1 + continue + + nargs = getattr(action, "nargs", None) + if nargs in (0, "0"): + i += 1 + elif nargs == "?": + if i + 1 < len(args) and not args[i + 1].startswith("-"): + i += 2 + else: + i += 1 + elif isinstance(nargs, int): + i += 1 + nargs + else: + i += 2 + + return None + + def _legacy_command_hint(self, args, parser): + command_index = self._first_toplevel_command_index(args, parser) + if command_index is None or args[command_index] != "init": + return None + + corrected_args = list(args) + corrected_args[command_index] = "repo-create" + corrected_command = shlex.join([self.prog or "borg", *corrected_args]) + return "\n".join( + [ + "init is not a borg2 command; use repo-create.", + "Corrected command:", + corrected_command, + "Use `borg help` to see the list of valid commands.", + ] + ) + def get_args(self, argv, cmd): """Usually just returns argv, except when dealing with an SSH forced command for borg serve.""" result = self.parse_args(argv[1:]) @@ -345,6 +400,10 @@ def parse_args(self, args=None): if args: args = self.preprocess_args(args) parser = self.build_parser() + if args: + legacy_hint = self._legacy_command_hint(args, parser) + if legacy_hint: + parser.exit(EXIT_ERROR, legacy_hint + "\n") args = parser.parse_args(args or ["-h"]) args = flatten_namespace(args) diff --git a/src/borg/testsuite/archiver/help_cmd_test.py b/src/borg/testsuite/archiver/help_cmd_test.py index fc6fdebc7d..815372ff39 100644 --- a/src/borg/testsuite/archiver/help_cmd_test.py +++ b/src/borg/testsuite/archiver/help_cmd_test.py @@ -2,7 +2,7 @@ from ...constants import * # NOQA from ...helpers.nanorst import RstToTextLazy, rst_to_terminal -from . import Archiver, cmd +from . import Archiver, cmd, exec_cmd def get_all_parsers(): @@ -43,6 +43,25 @@ def test_help(archiver): assert "creates a new, empty repository" not in cmd(archiver, "help", "repo-create", "--usage-only") +def test_borg1_init_shows_repo_create_hint(archiver): + ret, output = exec_cmd( + "--repo", + archiver.repository_location, + "init", + "-e", + "repokey-aes-ocb", + archiver=archiver.archiver, + fork=archiver.FORK_DEFAULT, + exe=archiver.EXE, + ) + + assert ret == 2 + assert "init is not a borg2 command; use repo-create." in output + assert "Corrected command:" in output + assert f"borg --repo {archiver.repository_location} repo-create -e repokey-aes-ocb" in output + assert "Use `borg help` to see the list of valid commands." in output + + @pytest.mark.parametrize("command, parser", list(get_all_parsers().items())) def test_help_formatting(command, parser): if isinstance(parser.epilog, RstToTextLazy): From c108b79c62edbdb3287896c8f08d2dbe759d8167 Mon Sep 17 00:00:00 2001 From: "John C. McCabe-Dansted" Date: Mon, 18 May 2026 18:35:45 +1200 Subject: [PATCH 2/4] Warn user --glob-archives has been replaced with --match-archives --- src/borg/archiver/__init__.py | 24 ++++++++++++++++++++ src/borg/testsuite/archiver/help_cmd_test.py | 23 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index 16b2c63725..69bd1c9610 100644 --- a/src/borg/archiver/__init__.py +++ b/src/borg/archiver/__init__.py @@ -363,6 +363,27 @@ def _legacy_command_hint(self, args, parser): ] ) + def _legacy_option_hint(self, args, parser): + command_index = self._first_toplevel_command_index(args, parser) + if command_index is None or args[command_index] != "list": + return None + + if not any(arg == "--glob-archives" or arg.startswith("--glob-archives=") for arg in args[command_index + 1 :]): + return None + + prog = self.prog or "borg" + example = shlex.join([prog, "list", "ARCHIVE", "--match-archives", "sh:my*"]) + return "\n".join( + [ + "--glob-archives is a borg1 option and is not used in borg2.", + "Use --match-archives in borg2. It defaults to exact `id:` matching, " + "so use `sh:` for borg1-style globbing.", + "Example:", + example, + f"tip: For details of accepted options run: {prog} list --help", + ] + ) + def get_args(self, argv, cmd): """Usually just returns argv, except when dealing with an SSH forced command for borg serve.""" result = self.parse_args(argv[1:]) @@ -404,6 +425,9 @@ def parse_args(self, args=None): legacy_hint = self._legacy_command_hint(args, parser) if legacy_hint: parser.exit(EXIT_ERROR, legacy_hint + "\n") + legacy_hint = self._legacy_option_hint(args, parser) + if legacy_hint: + parser.exit(EXIT_ERROR, legacy_hint + "\n") args = parser.parse_args(args or ["-h"]) args = flatten_namespace(args) diff --git a/src/borg/testsuite/archiver/help_cmd_test.py b/src/borg/testsuite/archiver/help_cmd_test.py index 815372ff39..230af930b1 100644 --- a/src/borg/testsuite/archiver/help_cmd_test.py +++ b/src/borg/testsuite/archiver/help_cmd_test.py @@ -62,6 +62,29 @@ def test_borg1_init_shows_repo_create_hint(archiver): assert "Use `borg help` to see the list of valid commands." in output +def test_borg1_glob_archives_shows_match_archives_hint(archiver): + ret, output = exec_cmd( + "--repo", + archiver.repository_location, + "list", + "--glob-archives", + "my*", + archiver=archiver.archiver, + fork=archiver.FORK_DEFAULT, + exe=archiver.EXE, + ) + + assert ret == 2 + assert "--glob-archives is a borg1 option and is not used in borg2." in output + assert ( + "Use --match-archives in borg2. It defaults to exact `id:` matching, " + "so use `sh:` for borg1-style globbing." in output + ) + assert "Example:" in output + assert "borg list ARCHIVE --match-archives 'sh:my*'" in output + assert "tip: For details of accepted options run: borg list --help" in output + + @pytest.mark.parametrize("command, parser", list(get_all_parsers().items())) def test_help_formatting(command, parser): if isinstance(parser.epilog, RstToTextLazy): From 317713378f411f718166949c8ba5a5b017ddb6f3 Mon Sep 17 00:00:00 2001 From: "John C. McCabe-Dansted" Date: Mon, 18 May 2026 18:52:30 +1200 Subject: [PATCH 3/4] Warn user borg2 does not accept repo::archive notation --- src/borg/archiver/__init__.py | 41 ++++++++++++++++++++ src/borg/testsuite/archiver/help_cmd_test.py | 36 +++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index 69bd1c9610..e697640e75 100644 --- a/src/borg/archiver/__init__.py +++ b/src/borg/archiver/__init__.py @@ -384,6 +384,44 @@ def _legacy_option_hint(self, args, parser): ] ) + @staticmethod + def _option_value(args, option_strings): + for i, arg in enumerate(args): + for option_string in option_strings: + if arg == option_string: + return args[i + 1] if i + 1 < len(args) else None + if arg.startswith(option_string + "="): + return arg.split("=", 1)[1] + return None + + def _legacy_repo_archive_hint(self, args, parser): + command_index = self._first_toplevel_command_index(args, parser) + if command_index is None or args[command_index] != "list": + return None + + repo_value = self._option_value(args, ("-r", "--repo")) + if not repo_value or "::" not in repo_value: + return None + + repo, archive = repo_value.split("::", 1) + if not repo or not archive: + return None + + prog = self.prog or "borg" + corrected = shlex.join([prog, "--repo", repo, "list", f"::{archive}"]) + export_cmd = f"export BORG_REPO={shlex.quote(repo)}" + positional = shlex.join([prog, "list", f"::{archive}"]) + return "\n".join( + [ + "Borg2 does not accept repo::archive in --repo.", + "Use one of these borg2 forms instead:", + corrected, + export_cmd, + positional, + f"tip: For details of accepted options run: {prog} list --help", + ] + ) + def get_args(self, argv, cmd): """Usually just returns argv, except when dealing with an SSH forced command for borg serve.""" result = self.parse_args(argv[1:]) @@ -428,6 +466,9 @@ def parse_args(self, args=None): legacy_hint = self._legacy_option_hint(args, parser) if legacy_hint: parser.exit(EXIT_ERROR, legacy_hint + "\n") + legacy_hint = self._legacy_repo_archive_hint(args, parser) + if legacy_hint: + parser.exit(EXIT_ERROR, legacy_hint + "\n") args = parser.parse_args(args or ["-h"]) args = flatten_namespace(args) diff --git a/src/borg/testsuite/archiver/help_cmd_test.py b/src/borg/testsuite/archiver/help_cmd_test.py index 230af930b1..a7fa3d0eb8 100644 --- a/src/borg/testsuite/archiver/help_cmd_test.py +++ b/src/borg/testsuite/archiver/help_cmd_test.py @@ -85,6 +85,42 @@ def test_borg1_glob_archives_shows_match_archives_hint(archiver): assert "tip: For details of accepted options run: borg list --help" in output +def test_borg1_repo_archive_in_repo_shows_borg2_forms(archiver): + ret, output = exec_cmd( + "--repo", + f"{archiver.repository_location}::test1", + "list", + archiver=archiver.archiver, + fork=archiver.FORK_DEFAULT, + exe=archiver.EXE, + ) + + assert ret == 2 + assert "Borg2 does not accept repo::archive in --repo." in output + assert "Use one of these borg2 forms instead:" in output + assert f"borg --repo {archiver.repository_location} list ::test1" in output + assert f"export BORG_REPO={archiver.repository_location}" in output + assert "borg list ::test1" in output + assert "tip: For details of accepted options run: borg list --help" in output + + +def test_borg1_repo_archive_in_repo_shows_borg2_forms_when_repo_is_after_command(archiver): + ret, output = exec_cmd( + "list", + "--repo", + f"{archiver.repository_location}::test1", + archiver=archiver.archiver, + fork=archiver.FORK_DEFAULT, + exe=archiver.EXE, + ) + + assert ret == 2 + assert "Borg2 does not accept repo::archive in --repo." in output + assert f"borg --repo {archiver.repository_location} list ::test1" in output + assert f"export BORG_REPO={archiver.repository_location}" in output + assert "borg list ::test1" in output + + @pytest.mark.parametrize("command, parser", list(get_all_parsers().items())) def test_help_formatting(command, parser): if isinstance(parser.epilog, RstToTextLazy): From b3a573a1f0382153c72c7f2bfbb44f71e83e164b Mon Sep 17 00:00:00 2001 From: "John C. McCabe-Dansted" Date: Mon, 18 May 2026 19:05:39 +1200 Subject: [PATCH 4/4] Warn user `borg list` warns needs name, unless they meant repo-list --- src/borg/archiver/__init__.py | 44 ++++++++++++++++++-- src/borg/testsuite/archiver/help_cmd_test.py | 27 ++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index e697640e75..95400dffcb 100644 --- a/src/borg/archiver/__init__.py +++ b/src/borg/archiver/__init__.py @@ -309,7 +309,7 @@ def build_parser(self): return parser @staticmethod - def _first_toplevel_command_index(args, parser): + def _first_positional_index(args, parser): option_actions = {} for action in parser._actions: for option_string in getattr(action, "option_strings", ()): @@ -319,7 +319,7 @@ def _first_toplevel_command_index(args, parser): while i < len(args): token = args[i] if token == "--": - return None + return i + 1 if i + 1 < len(args) else len(args) if not token.startswith("-") or token == "-": return i @@ -344,7 +344,14 @@ def _first_toplevel_command_index(args, parser): else: i += 2 - return None + return len(args) + + @staticmethod + def _first_toplevel_command_index(args, parser): + index = Archiver._first_positional_index(args, parser) + if index is None or index == len(args): + return None + return index def _legacy_command_hint(self, args, parser): command_index = self._first_toplevel_command_index(args, parser) @@ -422,6 +429,34 @@ def _legacy_repo_archive_hint(self, args, parser): ] ) + def _missing_list_name_hint(self, args, parser): + command_index = self._first_toplevel_command_index(args, parser) + if command_index is None or args[command_index] != "list": + return None + + commands = getattr(parser, "_subcommands_action", None) + commands = commands._name_parser_map if commands else {} + list_parser = commands.get("list") + if list_parser is None: + return None + + subcommand_args = args[command_index + 1 :] + positional_index = self._first_positional_index(subcommand_args, list_parser) + if positional_index is None or positional_index != len(subcommand_args): + return None + + prog = self.prog or "borg" + repo_value = self._option_value(args, ("-r", "--repo")) or "REPO" + repo_list_command = shlex.join([prog, "-r", repo_value, "repo-list"]) + return "\n".join( + [ + "borg list NAME lists contents of an archive and needs an archive NAME.", + "If you meant to list archives in a repository, use repo-list:", + repo_list_command, + f"tip: For details of accepted options run: {prog} list --help", + ] + ) + def get_args(self, argv, cmd): """Usually just returns argv, except when dealing with an SSH forced command for borg serve.""" result = self.parse_args(argv[1:]) @@ -469,6 +504,9 @@ def parse_args(self, args=None): legacy_hint = self._legacy_repo_archive_hint(args, parser) if legacy_hint: parser.exit(EXIT_ERROR, legacy_hint + "\n") + legacy_hint = self._missing_list_name_hint(args, parser) + if legacy_hint: + parser.exit(EXIT_ERROR, legacy_hint + "\n") args = parser.parse_args(args or ["-h"]) args = flatten_namespace(args) diff --git a/src/borg/testsuite/archiver/help_cmd_test.py b/src/borg/testsuite/archiver/help_cmd_test.py index a7fa3d0eb8..bec69d732c 100644 --- a/src/borg/testsuite/archiver/help_cmd_test.py +++ b/src/borg/testsuite/archiver/help_cmd_test.py @@ -121,6 +121,33 @@ def test_borg1_repo_archive_in_repo_shows_borg2_forms_when_repo_is_after_command assert "borg list ::test1" in output +def test_list_without_name_suggests_repo_list(archiver): + ret, output = exec_cmd("list", archiver=archiver.archiver, fork=archiver.FORK_DEFAULT, exe=archiver.EXE) + + assert ret == 2 + assert "borg list NAME lists contents of an archive and needs an archive NAME." in output + assert "If you meant to list archives in a repository, use repo-list:" in output + assert "borg -r REPO repo-list" in output + assert "tip: For details of accepted options run: borg list --help" in output + + +def test_list_without_name_with_repo_suggests_repo_list(archiver): + ret, output = exec_cmd( + "--repo", + archiver.repository_location, + "list", + archiver=archiver.archiver, + fork=archiver.FORK_DEFAULT, + exe=archiver.EXE, + ) + + assert ret == 2 + assert "borg list NAME lists contents of an archive and needs an archive NAME." in output + assert "If you meant to list archives in a repository, use repo-list:" in output + assert f"borg -r {archiver.repository_location} repo-list" in output + assert "tip: For details of accepted options run: borg list --help" in output + + @pytest.mark.parametrize("command, parser", list(get_all_parsers().items())) def test_help_formatting(command, parser): if isinstance(parser.epilog, RstToTextLazy):