From d6d0cd6de33bb2be8b4a2ce71a92eebe861bd8e1 Mon Sep 17 00:00:00 2001 From: gmatht Date: Wed, 25 Mar 2026 02:32:55 +0800 Subject: [PATCH 1/6] cli: expand repo::archive parse errors with corrected command lines When --repo uses legacy repo::archive syntax, show list and repo-info style examples. --- src/borg/helpers/parseformat.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index bb38092d03..fd8ffbe52b 100644 --- a/src/borg/helpers/parseformat.py +++ b/src/borg/helpers/parseformat.py @@ -616,6 +616,19 @@ def parse(self, text, overrides={}): self.raw = text # as given by user, might contain placeholders self.processed = replace_placeholders(self.raw, overrides) # after placeholder replacement + if "::" in self.processed: + repo, archive = self.processed.split("::", 1) + raise ValueError( + f'Invalid location format: "{self.processed}". ' + "Borg 2 does not accept repo::archive syntax. " + "Corrected command lines:\n" + f"borg -r {repo} list {archive}\n" + f"export BORG_REPO={repo}\n" + f"borg list {archive}\n" + f"borg -r {repo} repo-info\n" + f"export BORG_REPO={repo}\n" + f"borg repo-info" + ) valid = self._parse(self.processed) if valid: self.valid = True From ffbfb80a30d7231073079ddd25007276bbd587cd Mon Sep 17 00:00:00 2001 From: gmatht Date: Wed, 25 Mar 2026 02:33:11 +0800 Subject: [PATCH 2/6] cli: repository DoesNotExist and InvalidRepository common fixes Replace useless repo-info bullet with path reasons; add InvalidRepository guidance; get_message returns full text. --- src/borg/legacyrepository.py | 40 ++++++++++++++++++++++++++++++++ src/borg/repository.py | 44 ++++++++++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/borg/legacyrepository.py b/src/borg/legacyrepository.py index 9ef97a1a1b..f123b80da8 100644 --- a/src/borg/legacyrepository.py +++ b/src/borg/legacyrepository.py @@ -150,6 +150,26 @@ class DoesNotExist(Error): exit_mcode = 13 + def __init__(self, location): + from .crypto.key import key_argument_names + + mode_list = ", ".join(key_argument_names()) + location = str(location) + guidance = ( + f"Repository {location} does not exist.\n" + "Common fixes:\n" + f'- Specify Correct Path ("{location}" does not exist).\n' + f'- Create repository (-r): borg repo-create -r "{location}" -e repokey-aes-ocb\n' + f"- Create repository (BORG_REPO):\n" + f" export BORG_REPO={location}\n" + f" borg repo-create -e repokey-aes-ocb\n" + f"Available -e modes: {mode_list}" + ) + super().__init__(guidance) + + def get_message(self): + return self.args[0] + class InsufficientFreeSpaceError(Error): """Insufficient free space to complete transaction (required: {}, available: {}).""" @@ -160,6 +180,26 @@ class InvalidRepository(Error): exit_mcode = 15 + def __init__(self, location): + from .crypto.key import key_argument_names + + mode_list = ", ".join(key_argument_names()) + location = str(location) + guidance = ( + f"{location} is not a valid repository. Check repo config.\n" + "Common fixes:\n" + f'- Specify Correct Path ("{location}" is not a Borg repository).\n' + f'- Create repository (-r): borg repo-create -r "{location}" -e repokey-aes-ocb\n' + f"- Create repository (BORG_REPO):\n" + f" export BORG_REPO={location}\n" + f" borg repo-create -e repokey-aes-ocb\n" + f"Available -e modes: {mode_list}" + ) + super().__init__(guidance) + + def get_message(self): + return self.args[0] + class InvalidRepositoryConfig(Error): """{} does not have a valid configuration. Check repo config [{}].""" diff --git a/src/borg/repository.py b/src/borg/repository.py index a3f8aa8cc2..6d76341c43 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -52,6 +52,26 @@ class DoesNotExist(Error): exit_mcode = 13 + def __init__(self, location): + from .crypto.key import key_argument_names + + mode_list = ", ".join(key_argument_names()) + location = str(location) + guidance = ( + f"Repository {location} does not exist.\n" + "Common fixes:\n" + f'- Specify Correct Path ("{location}" does not exist).\n' + f'- Create repository (-r): borg repo-create -r "{location}" -e repokey-aes-ocb\n' + f"- Create repository (BORG_REPO):\n" + f" export BORG_REPO={location}\n" + f" borg repo-create -e repokey-aes-ocb\n" + f"Available -e modes: {mode_list}" + ) + super().__init__(guidance) + + def get_message(self): + return self.args[0] + class InsufficientFreeSpaceError(Error): """Insufficient free space to complete the transaction (required: {}, available: {}).""" @@ -62,6 +82,26 @@ class InvalidRepository(Error): exit_mcode = 15 + def __init__(self, location): + from .crypto.key import key_argument_names + + mode_list = ", ".join(key_argument_names()) + location = str(location) + guidance = ( + f"{location} is not a valid repository. Check the repository config.\n" + "Common fixes:\n" + f'- Specify Correct Path ("{location}" is not a Borg repository).\n' + f'- Create repository (-r): borg repo-create -r "{location}" -e repokey-aes-ocb\n' + f"- Create repository (BORG_REPO):\n" + f" export BORG_REPO={location}\n" + f" borg repo-create -e repokey-aes-ocb\n" + f"Available -e modes: {mode_list}" + ) + super().__init__(guidance) + + def get_message(self): + return self.args[0] + class InvalidRepositoryConfig(Error): """{} does not have a valid config. Check the repository config [{}].""" @@ -253,13 +293,13 @@ def open(self, *, exclusive, lock_wait=None, lock=True): try: self.store.open() except StoreBackendDoesNotExist: - raise self.DoesNotExist(str(self._location)) from None + raise self.DoesNotExist(self._location.processed) from None else: self.store_opened = True try: readme = self.store.load("config/readme").decode() except StoreObjectNotFound: - raise self.DoesNotExist(str(self._location)) from None + raise self.DoesNotExist(self._location.processed) from None if readme != REPOSITORY_README: raise self.InvalidRepository(str(self._location)) self.version = int(self.store.load("config/version").decode()) From d1e80cfab5b41073cc3b4c11536a0cb9a119dc95 Mon Sep 17 00:00:00 2001 From: gmatht Date: Wed, 25 Mar 2026 02:33:26 +0800 Subject: [PATCH 3/6] cli: ArgumentParser hints for commands, locations, and argv Invalid subcommand synonyms/fuzzy hints, argv-based examples, Common fixes for repo::archive and flags before subcommand, repo-create encryption reminder. --- src/borg/helpers/argparsing.py | 239 +++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/src/borg/helpers/argparsing.py b/src/borg/helpers/argparsing.py index 9a073705ba..0da7f3e322 100644 --- a/src/borg/helpers/argparsing.py +++ b/src/borg/helpers/argparsing.py @@ -94,6 +94,11 @@ unchanged. """ +import difflib +import os +import re +import shlex +import sys from typing import Any # here are the only imports from argparse and jsonargparse, @@ -103,12 +108,246 @@ from jsonargparse import Namespace, ActionSubCommands, SUPPRESS, REMAINDER # noqa: F401 from jsonargparse.typing import register_type, PositiveInt # noqa: F401 +# Borg 1.x / informal names -> borg2 top-level subcommand (same list as parser choices targets). +_TOP_COMMAND_SYNONYMS = { + "init": "repo-create", + "rcreate": "repo-create", + "repocreate": "repo-create", + "rm": "delete", + "clean": "compact", + "unrm": "undelete", + "undel": "undelete", + "restore": "undelete", +} + +# Example line after 'Maybe you meant `` not ``:\n\t' (placeholders intentionally generic). +_TOP_COMMAND_EXAMPLES = { + "repo-create": "borg -r REPO repo-create -e repokey-aes-ocb", + "delete": "borg -r REPO delete ARCHIVE_OR_AID", + "compact": "borg -r REPO compact", + "undelete": "borg -r REPO undelete …", + "list": "borg -r REPO list ARCHIVE", +} + +# Top-level subcommand names (must match build_parser / choices). +_TOP_LEVEL_COMMANDS = frozenset( + { + "analyze", + "benchmark", + "check", + "compact", + "completion", + "create", + "debug", + "delete", + "diff", + "extract", + "help", + "info", + "key", + "list", + "break-lock", + "with-lock", + "mount", + "umount", + "prune", + "repo-compress", + "repo-create", + "repo-delete", + "repo-info", + "repo-list", + "recreate", + "rename", + "repo-space", + "serve", + "tag", + "export-tar", + "import-tar", + "transfer", + "undelete", + "version", + } +) + + +def _parse_unrecognized_arguments_raw(message: str) -> str | None: + if "unrecognized arguments" not in message.lower(): + return None + m = re.search(r"Unrecognized arguments:\s*(.+?)(?:\n|$)", message, re.IGNORECASE | re.DOTALL) + if not m: + return None + return m.group(1).strip() + + +def _find_contiguous_subsequence(haystack: list[str], needle: list[str]) -> int | None: + if not needle or len(needle) > len(haystack): + return None + for i in range(len(haystack) - len(needle) + 1): + if haystack[i : i + len(needle)] == needle: + return i + return None + + +def _remove_contiguous_subsequence(haystack: list[str], needle: list[str]) -> list[str] | None: + i = _find_contiguous_subsequence(haystack, needle) + if i is None: + return None + return haystack[:i] + haystack[i + len(needle) :] + + +def _suggest_move_options_after_subcommand(message: str) -> str | None: + """ + If the user put subcommand-specific flags before (e.g. borg --stats create ...), + suggest the same argv with those flags after the subcommand. + """ + raw = _parse_unrecognized_arguments_raw(message) + if not raw: + return None + try: + tokens = shlex.split(raw) + except ValueError: + return None + if not tokens: + return None + argv = sys.argv + sub_idx = None + for i, a in enumerate(argv): + if a in _TOP_LEVEL_COMMANDS: + sub_idx = i + break + if sub_idx is None or sub_idx < 2: + return None + prefix = argv[1:sub_idx] + if _find_contiguous_subsequence(prefix, tokens) is None: + return None + keep = _remove_contiguous_subsequence(prefix, tokens) + if keep is None: + return None + corrected = [argv[0]] + keep + [argv[sub_idx]] + tokens + argv[sub_idx + 1 :] + return " ".join(shlex.quote(c) for c in corrected) + + +def _argv_tail_after_invalid_choice(invalid: str) -> list[str]: + """Tokens after the invalid top-level subcommand in sys.argv, if any.""" + try: + idx = sys.argv.index(invalid) + except ValueError: + return [] + return sys.argv[idx + 1 :] + + +def _argv_display_for_hint(argv: list[str]) -> list[str]: + """Normalize argv to a readable `borg ...` line when launched via python -m or a borg binary.""" + if ( + len(argv) >= 3 + and os.path.basename(argv[0]).lower().startswith("python") + and argv[1] == "-m" + and argv[2] == "borg" + ): + return ["borg"] + argv[3:] + if len(argv) >= 1 and os.path.basename(argv[0]).lower() in ("borg", "borg.exe"): + return ["borg"] + argv[1:] + return list(argv) + + +def _corrected_command_line_for_invalid_subcommand(invalid: str, canonical: str) -> str | None: + """Replace invalid with canonical in sys.argv; keep all other tokens (same order).""" + try: + idx = sys.argv.index(invalid) + except ValueError: + return None + if idx < 1: + return None + argv = list(sys.argv) + argv[idx] = canonical + display = _argv_display_for_hint(argv) + if not display: + return None + return " ".join(shlex.quote(a) for a in display) + + +def _apply_argv_tail_to_example(canonical: str, example: str, argv_tail: list[str]) -> str: + """Replace generic placeholders with argv tokens the user actually typed after the bad command.""" + if not argv_tail: + return example + tail = " ".join(shlex.quote(a) for a in argv_tail) + if canonical == "delete" and "ARCHIVE_OR_AID" in example: + return example.replace("ARCHIVE_OR_AID", tail) + if canonical == "list" and "ARCHIVE" in example: + return example.replace("ARCHIVE", tail) + if canonical == "undelete" and "…" in example: + return example.replace("…", tail) + return example + class ArgumentParser(_ArgumentParser): # the borg code always uses RawDescriptionHelpFormatter and add_help=False: def __init__(self, *args, formatter_class=RawDescriptionHelpFormatter, add_help=False, **kwargs): super().__init__(*args, formatter_class=formatter_class, add_help=add_help, **kwargs) + def _top_command_choice_hint(self, message: str) -> str | None: + match = re.search(r"invalid choice: '([^']+)' \(choose from ([^)]+)\)", message) + if not match: + return None + invalid = match.group(1) + choices = [choice.strip().strip("'\"") for choice in match.group(2).split(",")] + canonical = _TOP_COMMAND_SYNONYMS.get(invalid) + if canonical is None: + candidates = difflib.get_close_matches(invalid, choices, n=1, cutoff=0.6) + if not candidates: + return None + canonical = candidates[0] + if canonical == invalid: + return None + example = _corrected_command_line_for_invalid_subcommand(invalid, canonical) + if example is None: + example = _TOP_COMMAND_EXAMPLES.get(canonical, f"borg -r REPO {canonical}") + example = _apply_argv_tail_to_example(canonical, example, _argv_tail_after_invalid_choice(invalid)) + return f"Maybe you meant `{canonical}` not `{invalid}`:\n\t{example}" + + def _common_fix_hints(self, message: str) -> list[str]: + hints = [] + reorder = _suggest_move_options_after_subcommand(message) + if reorder: + hints.append(f"Put subcommand-specific options after ``: {reorder}") + if "missing repository" in message.lower(): + hints.append("Set the repository via --repo REPO or BORG_REPO.") + if "list.name is none" in message.lower() or ("list.name" in message and "is None" in message): + hints.append("For 'borg list', set repository via -r/--repo or BORG_REPO and pass an archive name.") + if "repo::archive" in message or "::archive" in message: + hints.append("Borg 2 uses --repo/BORG_REPO and separate archive arguments.") + if "invalid choice" in message and "" in message: + cmd_hint = self._top_command_choice_hint(message) + if cmd_hint: + hints.append(cmd_hint) + hints.append("Run 'borg help' to list valid borg2 commands.") + return hints + + def error(self, message, *args, **kwargs): + message = str(message) + if "Option 'repo-create.encryption' is required but not provided" in message: + from ..crypto.key import key_argument_names + + modes = key_argument_names() + mode_list = ", ".join(modes) + message = ( + f"{message}\n" + "Use -e/--encryption to choose a mode, for example: -e repokey-aes-ocb\n" + f"Available encryption modes: {mode_list}" + ) + if "Option 'list.paths' is required but not provided" in message: + message = ( + f"{message}\n" + "borg list requires an archive NAME to list contents.\n" + "Common fixes:\n" + "- Provide archive name: borg list NAME\n" + "- To list archives in a repository, use: borg -r REPO repo-list" + ) + common_hints = self._common_fix_hints(message) + if common_hints: + message = f"{message}\nCommon fixes:\n- " + "\n- ".join(common_hints) + super().error(message, *args, **kwargs) + def flatten_namespace(ns: Any) -> Namespace: """ From ed85f0004f9b0fb44e0c9c984d92632c700ab30e Mon Sep 17 00:00:00 2001 From: gmatht Date: Wed, 25 Mar 2026 02:34:32 +0800 Subject: [PATCH 4/6] cli: print borg1 option migration under Common fixes preprocess_args: glob-archives and related hints with example; omit Rust version branding. --- src/borg/archiver/__init__.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index dadb4c7d83..1b7c1f03e4 100644 --- a/src/borg/archiver/__init__.py +++ b/src/borg/archiver/__init__.py @@ -166,9 +166,28 @@ def print_file_status(self, status, path): logging.getLogger("borg.output.list").info("%1s %s", status, remove_surrogates(path)) def preprocess_args(self, args): + borg1_option_equivalents = { + "--glob-archives": "--match-archives 'sh:PATTERN'", + "--numeric-owner": "--numeric-ids", + "--nobsdflags": "--noflags", + "--remote-ratelimit": "--upload-ratelimit", + } deprecations = [ # ('--old', '--new' or None, 'Warning: "--old" has been deprecated. Use "--new" instead.'), ] + seen_borg1_options = [] + for arg in args: + if arg in borg1_option_equivalents and arg not in seen_borg1_options: + seen_borg1_options.append(arg) + if seen_borg1_options: + print("Common fixes:", file=sys.stderr) + for arg in seen_borg1_options: + print( + f'- borg1 option "{arg}" is not used in borg2. ' f'Use "{borg1_option_equivalents[arg]}" instead.', + file=sys.stderr, + ) + if "--glob-archives" in seen_borg1_options: + print("- Example: borg list ARCHIVE --match-archives 'sh:old-*'", file=sys.stderr) for i, arg in enumerate(args[:]): for old_name, new_name, warning in deprecations: # either --old_name or --old_name=... From dddbaf02fce4b6bf4f7a8e31443ca6145786e08f Mon Sep 17 00:00:00 2001 From: gmatht Date: Wed, 25 Mar 2026 02:35:58 +0800 Subject: [PATCH 5/6] tests: CLI UX regression coverage and test-ux.sh Add cli_guidance and argparsing hint tests; adjust repository remote tests; manual UX smoke script. --- scripts/test-ux.sh | 37 +++ .../testsuite/archiver/cli_guidance_test.py | 217 ++++++++++++++++++ src/borg/testsuite/helpers/argparsing_test.py | 13 ++ src/borg/testsuite/legacyrepository_test.py | 2 +- src/borg/testsuite/repository_test.py | 2 +- 5 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 scripts/test-ux.sh create mode 100644 src/borg/testsuite/archiver/cli_guidance_test.py create mode 100644 src/borg/testsuite/helpers/argparsing_test.py diff --git a/scripts/test-ux.sh b/scripts/test-ux.sh new file mode 100644 index 0000000000..9bd97de6a1 --- /dev/null +++ b/scripts/test-ux.sh @@ -0,0 +1,37 @@ +set -x +#errors that should be have helpful help + +borg --repo /tmp/demo-repo init -e repokey-aes-ocb +borg --repo /tmp/demo-repo rcreate -e repokey-aes-ocb + +#Typo suggestions (Did you mean ...?) + +borg repo-creat +borg repoo-list +Borg1 -> Borg2 option hints + +borg --repo /tmp/demo-repo list --glob-archives 'my*' +borg --repo /tmp/demo-repo create --numeric-owner test ~/data +borg --repo /tmp/demo-repo create --nobsdflags test ~/data +borg --repo /tmp/demo-repo create --remote-ratelimit 1000 test ~/data + +#Missing encryption guidance for repo-create + +borg --repo /tmp/demo-repo repo-create + +#repo::archive migration help (BORG_REPO / --repo guidance) + +borg --repo /tmp/demo-repo::test1 repo-info +borg --repo /tmp/demo-repo::test1 list + +#Missing repo recovery hint (includes repo-create example + -e modes) + +borg --repo /tmp/does-not-exist repo-info +borg --repo /tmp/does-not-exist list + +#Common fixes block (missing repo / unknown command) + +borg list +borg frobnicate + +#Options are preserved by command-line correction. diff --git a/src/borg/testsuite/archiver/cli_guidance_test.py b/src/borg/testsuite/archiver/cli_guidance_test.py new file mode 100644 index 0000000000..3bff930cbb --- /dev/null +++ b/src/borg/testsuite/archiver/cli_guidance_test.py @@ -0,0 +1,217 @@ +import os +import sys + +import pytest + +from ...helpers.argparsing import ArgumentParser +from ...repository import Repository +from . import exec_cmd + + +def test_unknown_command_typo_suggests_fuzzy_match(cmd_fixture): + exit_code, output = cmd_fixture("repo-creat") + assert exit_code == 2 + assert "Maybe you meant `repo-create` not `repo-creat`:" in output + assert "\tborg repo-create" in output + + +def test_unknown_command_typo_list(cmd_fixture): + exit_code, output = cmd_fixture("lst") + assert exit_code == 2 + assert "Maybe you meant `list` not `lst`:" in output + assert "\tborg list" in output + + +def test_fuzzy_typo_preserves_following_args(cmd_fixture): + exit_code, output = cmd_fixture("creat", "foo", "--stats") + assert exit_code == 2 + assert "Maybe you meant `create` not `creat`:" in output + assert "\tborg create foo --stats" in output + + +def test_legacy_rm_synonym(cmd_fixture): + exit_code, output = cmd_fixture("rm") + assert exit_code == 2 + assert "Maybe you meant `delete` not `rm`:" in output + assert "\tborg delete" in output + + +def test_legacy_rm_synonym_preserves_trailing_tokens_in_delete_example(cmd_fixture, tmp_path): + """Tokens after 'rm' must appear in the suggested delete line (not a generic placeholder).""" + repo = os.fspath(tmp_path / "repo") + exit_code, output = cmd_fixture("-r", repo, "rm", "dsfasdfsdfsdf") + assert exit_code == 2 + assert "Maybe you meant `delete` not `rm`:" in output + assert "ARCHIVE_OR_AID" not in output + assert f"\tborg -r {repo} delete dsfasdfsdfsdf" in output + + +def test_rm_synonym_example_includes_argv_tail(monkeypatch): + monkeypatch.setattr(sys, "argv", ["python", "-m", "borg", "-r", "/tmp/borg/outC", "rm", "dsfasdfsdfsdf"]) + parser = ArgumentParser(prog="borg") + message = "error: argument : invalid choice: 'rm' (choose from 'delete', 'list')" + hint = parser._top_command_choice_hint(message) + assert hint is not None + assert "Maybe you meant `delete` not `rm`:" in hint + assert "ARCHIVE_OR_AID" not in hint + assert "\tborg -r /tmp/borg/outC delete dsfasdfsdfsdf" in hint + + +def test_lst_typo_example_includes_argv_tail(monkeypatch): + monkeypatch.setattr(sys, "argv", ["python", "-m", "borg", "-r", "/r", "lst", "my-archive"]) + parser = ArgumentParser(prog="borg") + message = "error: argument : invalid choice: 'lst' (choose from 'list', 'delete')" + hint = parser._top_command_choice_hint(message) + assert hint is not None + assert "Maybe you meant `list` not `lst`:" in hint + assert "ARCHIVE" not in hint + assert "\tborg -r /r list my-archive" in hint + + +def test_restore_synonym_example_includes_argv_tail(monkeypatch): + monkeypatch.setattr(sys, "argv", ["python", "-m", "borg", "-r", "/r", "restore", "arch1"]) + parser = ArgumentParser(prog="borg") + message = "error: argument : invalid choice: 'restore' (choose from 'undelete', 'list')" + hint = parser._top_command_choice_hint(message) + assert hint is not None + assert "Maybe you meant `undelete` not `restore`:" in hint + assert "…" not in hint + assert "\tborg -r REPO undelete arch1" in hint + + +def test_maybe_you_meant_rm_is_common_fix_bullet(cmd_fixture): + """Invalid-command hint must appear under Common fixes with a '- ' bullet.""" + exit_code, output = cmd_fixture("rm") + assert exit_code == 2 + assert "Common fixes:" in output + assert "- Maybe you meant `delete` not `rm`:" in output + + +def test_maybe_you_meant_line_has_dash_prefix_before_maybe(cmd_fixture): + """Regression: '- ' must prefix the Maybe-you-meant line (not a bare paragraph before Common fixes).""" + exit_code, output = cmd_fixture("rm") + assert exit_code == 2 + assert "Common fixes:\n- Maybe you meant `delete` not `rm`:" in output + + +def test_legacy_clean_synonym(cmd_fixture): + exit_code, output = cmd_fixture("clean") + assert exit_code == 2 + assert "Maybe you meant `compact` not `clean`:" in output + assert "\tborg compact" in output + + +def test_legacy_restore_synonym(cmd_fixture): + exit_code, output = cmd_fixture("restore") + assert exit_code == 2 + assert "Maybe you meant `undelete` not `restore`:" in output + assert "\tborg undelete" in output + + +def test_legacy_init_synonym(cmd_fixture, tmp_path): + repo = os.fspath(tmp_path / "repo") + exit_code, output = cmd_fixture("--repo", repo, "init", "-e", "none") + assert exit_code == 2 + assert "Maybe you meant `repo-create` not `init`:" in output + assert f"\tborg --repo {repo} repo-create -e none" in output + + +def test_legacy_rcreate_synonym(cmd_fixture, tmp_path): + repo = os.fspath(tmp_path / "repo") + exit_code, output = cmd_fixture("--repo", repo, "rcreate", "-e", "none") + assert exit_code == 2 + assert "Maybe you meant `repo-create` not `rcreate`:" in output + assert f"\tborg --repo {repo} repo-create -e none" in output + + +def test_legacy_repocreate_synonym(cmd_fixture, tmp_path): + repo = os.fspath(tmp_path / "repo") + exit_code, output = cmd_fixture("--repo", repo, "repocreate", "-e", "none") + assert exit_code == 2 + assert "Maybe you meant `repo-create` not `repocreate`:" in output + assert f"\tborg --repo {repo} repo-create -e none" in output + + +def test_repo_create_missing_encryption_shows_available_modes(cmd_fixture, tmp_path): + repo = os.fspath(tmp_path / "repo") + exit_code, output = cmd_fixture("--repo", repo, "repo-create") + assert exit_code == 2 + assert "Use -e/--encryption to choose a mode" in output + assert "Available encryption modes:" in output + + +def test_repo_double_colon_syntax_shows_migration_hint(cmd_fixture, tmp_path): + repo = os.fspath(tmp_path / "repo::archive") + exit_code, output = cmd_fixture("--repo", repo, "repo-info") + assert exit_code == 2 + assert "does not accept repo::archive syntax" in output + assert "borg -r" in output + assert "borg list archive" in output + assert "borg repo-info" in output + assert "export BORG_REPO=" in output + + +def test_missing_repository_error_shows_create_example(cmd_fixture, tmp_path): + repo = os.fspath(tmp_path / "missing-repo") + exit_code, output = cmd_fixture("--repo", repo, "repo-info") + assert exit_code == 2 + assert "does not exist." in output + assert "Common fixes:" in output + assert f'Specify Correct Path ("{repo}" does not exist).' in output + assert "borg repo-info -r" not in output + assert "Create repository (-r): borg repo-create" in output + assert "Create repository (BORG_REPO):" in output + assert "Available -e modes:" in output + + +def test_repository_does_not_exist_common_fix_explains_missing_path(): + msg = Repository.DoesNotExist("/tmp/foo").get_message() + assert 'Specify Correct Path ("/tmp/foo" does not exist).' in msg + assert "borg repo-info -r" not in msg + + +def test_repository_invalid_common_fix_explains_not_a_borg_repo(): + msg = Repository.InvalidRepository("/tmp/foo").get_message() + assert 'Specify Correct Path ("/tmp/foo" is not a Borg repository).' in msg + assert "borg repo-info -r" not in msg + + +def test_list_name_none_common_fix_hint(): + parser = ArgumentParser(prog="borg") + hints = parser._common_fix_hints("Validation failed: list.name is None") + assert "For 'borg list', set repository via -r/--repo or BORG_REPO and pass an archive name." in hints + + +def test_list_paths_required_shows_path_and_repo_creation_hints(cmd_fixture, tmp_path): + repo = os.fspath(tmp_path / "does-not-exist") + exit_code, output = cmd_fixture("--repo", repo, "list") + assert exit_code == 2 + assert "Option 'list.paths' is required but not provided" in output + assert "borg list requires an archive NAME to list contents." in output + assert "- Provide archive name: borg list NAME" in output + assert "- To list archives in a repository, use: borg -r REPO repo-list" in output + + +def test_argument_parser_error_accepts_jsonargparse_extra_arg(): + parser = ArgumentParser(prog="borg") + with pytest.raises(SystemExit): + parser.error("bad message", ValueError("wrapped")) + + +def test_unrecognized_args_before_subcommand_shows_reordered_example(cmd_fixture): + exit_code, output = cmd_fixture("--stats", "create", "foo") + assert exit_code == 2 + assert "Unrecognized arguments" in output + assert "Common fixes:" in output + assert "Put subcommand-specific options after ``:" in output + assert "create" in output and "--stats" in output + + +def test_preprocess_prints_glob_archives_migration_hint(tmp_path): + repo = os.fspath(tmp_path / "repo") + exit_code, output = exec_cmd("--repo", repo, "list", "dummy-archive", "--glob-archives", "sh:old", fork=False) + assert exit_code == 2 + assert "Common fixes:" in output + assert '- borg1 option "--glob-archives" is not used in borg2.' in output + assert "--match-archives 'sh:PATTERN'" in output + assert "- Example: borg list ARCHIVE --match-archives 'sh:old-*'" in output diff --git a/src/borg/testsuite/helpers/argparsing_test.py b/src/borg/testsuite/helpers/argparsing_test.py new file mode 100644 index 0000000000..afa30ff68f --- /dev/null +++ b/src/borg/testsuite/helpers/argparsing_test.py @@ -0,0 +1,13 @@ +import sys + +import pytest + +from ...helpers.argparsing import _suggest_move_options_after_subcommand + + +def test_suggest_reorder_unrecognized_args_before_subcommand(monkeypatch): + monkeypatch.setattr(sys, "argv", ["borg", "--stats", "create", "foo"]) + s = _suggest_move_options_after_subcommand("error: Unrecognized arguments: --stats") + assert s is not None + assert "create" in s and "--stats" in s + assert s.index("create") < s.index("--stats") diff --git a/src/borg/testsuite/legacyrepository_test.py b/src/borg/testsuite/legacyrepository_test.py index a97a094c97..6d590ebf79 100644 --- a/src/borg/testsuite/legacyrepository_test.py +++ b/src/borg/testsuite/legacyrepository_test.py @@ -913,7 +913,7 @@ def test_remote_rpc_exception_transport(remote_repository): remote_repository.call("inject_exception", {"kind": "DoesNotExist"}) except LegacyRepository.DoesNotExist as e: assert len(e.args) == 1 - assert e.args[0] == remote_repository.location.processed + assert remote_repository.location.processed in e.args[0] try: remote_repository.call("inject_exception", {"kind": "AlreadyExists"}) diff --git a/src/borg/testsuite/repository_test.py b/src/borg/testsuite/repository_test.py index 112d2094d9..b370eb2f19 100644 --- a/src/borg/testsuite/repository_test.py +++ b/src/borg/testsuite/repository_test.py @@ -181,7 +181,7 @@ def test_remote_rpc_exception_transport(remote_repository): remote_repository.call("inject_exception", {"kind": "DoesNotExist"}) except Repository.DoesNotExist as e: assert len(e.args) == 1 - assert e.args[0] == remote_repository.location.processed + assert remote_repository.location.processed in e.args[0] try: remote_repository.call("inject_exception", {"kind": "AlreadyExists"}) From 5ba8acfe61d9b52e7d3f703c3cb99be94f2b76f4 Mon Sep 17 00:00:00 2001 From: "John C. McCabe-Dansted" Date: Wed, 25 Mar 2026 03:44:30 +0800 Subject: [PATCH 6/6] cli error help now exactly matches test-ux.sh.blessed_stderr --- scripts/test-ux.sh | 5 +- scripts/test-ux.sh.blessed_stderr | 263 ++++++++++++++++++ src/borg/helpers/argparsing.py | 56 +++- src/borg/helpers/parseformat.py | 14 +- .../testsuite/archiver/cli_guidance_test.py | 18 +- 5 files changed, 332 insertions(+), 24 deletions(-) create mode 100644 scripts/test-ux.sh.blessed_stderr diff --git a/scripts/test-ux.sh b/scripts/test-ux.sh index 9bd97de6a1..0398f1b99a 100644 --- a/scripts/test-ux.sh +++ b/scripts/test-ux.sh @@ -1,4 +1,8 @@ set -x +# Stable argparse usage() wrapping when comparing to test-ux.sh.blessed_stderr. +export COLUMNS="${COLUMNS:-80}" +# Fixed paths; remove leftovers so each run matches test-ux.sh.blessed_stderr. +rm -rf /tmp/demo-repo #errors that should be have helpful help borg --repo /tmp/demo-repo init -e repokey-aes-ocb @@ -21,7 +25,6 @@ borg --repo /tmp/demo-repo repo-create #repo::archive migration help (BORG_REPO / --repo guidance) -borg --repo /tmp/demo-repo::test1 repo-info borg --repo /tmp/demo-repo::test1 list #Missing repo recovery hint (includes repo-create example + -e modes) diff --git a/scripts/test-ux.sh.blessed_stderr b/scripts/test-ux.sh.blessed_stderr new file mode 100644 index 0000000000..4c35378538 --- /dev/null +++ b/scripts/test-ux.sh.blessed_stderr @@ -0,0 +1,263 @@ ++ export COLUMNS=80 ++ COLUMNS=80 ++ rm -rf /tmp/demo-repo ++ borg --repo /tmp/demo-repo init -e repokey-aes-ocb +usage: borg [--config CONFIG] [--print_config [=flags]] [-V] [--cockpit] [-h] + [--critical] [--error] [--warning] [--info] [--debug] + [--debug-topic TOPIC] [-p] [--iec] [--log-json] + [--lock-wait SECONDS] [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] + ... +tip: For details of accepted options run: borg --help +error: argument : invalid choice: 'init' (choose from 'analyze', 'benchmark', 'check', 'compact', 'completion', 'create', 'debug', 'delete', 'diff', 'extract', 'help', 'info', 'key', 'list', 'break-lock', 'with-lock', 'mount', 'umount', 'prune', 'repo-compress', 'repo-create', 'repo-delete', 'repo-info', 'repo-list', 'recreate', 'rename', 'repo-space', 'serve', 'tag', 'export-tar', 'import-tar', 'transfer', 'undelete', 'version') +Common fixes: +- Maybe you meant `repo-create` not `init`: + borg --repo /tmp/demo-repo repo-create -e repokey-aes-ocb +- Run 'borg help' to list valid borg2 commands. ++ borg --repo /tmp/demo-repo rcreate -e repokey-aes-ocb +usage: borg [--config CONFIG] [--print_config [=flags]] [-V] [--cockpit] [-h] + [--critical] [--error] [--warning] [--info] [--debug] + [--debug-topic TOPIC] [-p] [--iec] [--log-json] + [--lock-wait SECONDS] [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] + ... +tip: For details of accepted options run: borg --help +error: argument : invalid choice: 'rcreate' (choose from 'analyze', 'benchmark', 'check', 'compact', 'completion', 'create', 'debug', 'delete', 'diff', 'extract', 'help', 'info', 'key', 'list', 'break-lock', 'with-lock', 'mount', 'umount', 'prune', 'repo-compress', 'repo-create', 'repo-delete', 'repo-info', 'repo-list', 'recreate', 'rename', 'repo-space', 'serve', 'tag', 'export-tar', 'import-tar', 'transfer', 'undelete', 'version') +Common fixes: +- Maybe you meant `repo-create` not `rcreate`: + borg --repo /tmp/demo-repo repo-create -e repokey-aes-ocb +- Run 'borg help' to list valid borg2 commands. ++ borg repo-creat +usage: borg [--config CONFIG] [--print_config [=flags]] [-V] [--cockpit] [-h] + [--critical] [--error] [--warning] [--info] [--debug] + [--debug-topic TOPIC] [-p] [--iec] [--log-json] + [--lock-wait SECONDS] [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] + ... +tip: For details of accepted options run: borg --help +error: argument : invalid choice: 'repo-creat' (choose from 'analyze', 'benchmark', 'check', 'compact', 'completion', 'create', 'debug', 'delete', 'diff', 'extract', 'help', 'info', 'key', 'list', 'break-lock', 'with-lock', 'mount', 'umount', 'prune', 'repo-compress', 'repo-create', 'repo-delete', 'repo-info', 'repo-list', 'recreate', 'rename', 'repo-space', 'serve', 'tag', 'export-tar', 'import-tar', 'transfer', 'undelete', 'version') +Common fixes: +- Maybe you meant `repo-create` not `repo-creat`: + borg repo-create +- Run 'borg help' to list valid borg2 commands. ++ borg repoo-list +usage: borg [--config CONFIG] [--print_config [=flags]] [-V] [--cockpit] [-h] + [--critical] [--error] [--warning] [--info] [--debug] + [--debug-topic TOPIC] [-p] [--iec] [--log-json] + [--lock-wait SECONDS] [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] + ... +tip: For details of accepted options run: borg --help +error: argument : invalid choice: 'repoo-list' (choose from 'analyze', 'benchmark', 'check', 'compact', 'completion', 'create', 'debug', 'delete', 'diff', 'extract', 'help', 'info', 'key', 'list', 'break-lock', 'with-lock', 'mount', 'umount', 'prune', 'repo-compress', 'repo-create', 'repo-delete', 'repo-info', 'repo-list', 'recreate', 'rename', 'repo-space', 'serve', 'tag', 'export-tar', 'import-tar', 'transfer', 'undelete', 'version') +Common fixes: +- Maybe you meant `repo-list` not `repoo-list`: + borg repo-list +- Run 'borg help' to list valid borg2 commands. ++ Borg1 - option hints +./scripts/test-ux.sh: line 15: Borg1: command not found ++ borg --repo /tmp/demo-repo list --glob-archives 'my*' +Common fixes: +- borg1 option "--glob-archives" is not used in borg2. Use "--match-archives 'sh:PATTERN'" instead. +- Example: borg list ARCHIVE --match-archives 'sh:old-*' +usage: borg [options] list [-h] [--critical] [--error] [--warning] [--info] + [--debug] [--debug-topic TOPIC] [-p] [--iec] + [--log-json] [--lock-wait SECONDS] [--show-version] + [--show-rc] [--umask M] [--remote-path PATH] + [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] + [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] [--short] + [--format FORMAT] [--json-lines] [--depth N] + [-e PATTERN] [--exclude-from EXCLUDEFILE] + [--pattern PATTERN] [--patterns-from PATTERNFILE] + NAME [PATH ...] +tip: For details of accepted options run: borg list --help +error: argument NAME: Invalid archive name: "my*" [invalid chars detected matching "/\"<|>?*"] ++ borg --repo /tmp/demo-repo create --numeric-owner test /root/data +Common fixes: +- borg1 option "--numeric-owner" is not used in borg2. Use "--numeric-ids" instead. +usage: borg [options] create [-h] [--critical] [--error] [--warning] [--info] + [--debug] [--debug-topic TOPIC] [-p] [--iec] + [--log-json] [--lock-wait SECONDS] + [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] + [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] [-n] [-s] [--list] + [--filter STATUSCHARS] [--json] + [--stdin-name NAME] [--stdin-user USER] + [--stdin-group GROUP] [--stdin-mode M] + [--content-from-command] [--paths-from-stdin] + [--paths-from-command] + [--paths-from-shell-command] + [--paths-delimiter DELIM] [-e PATTERN] + [--exclude-from EXCLUDEFILE] [--pattern PATTERN] + [--patterns-from PATTERNFILE] [--exclude-caches] + [--exclude-if-present NAME] [--keep-exclude-tags] + [-x] [--numeric-ids] [--atime] [--noctime] + [--nobirthtime] [--noflags] [--noacls] + [--noxattrs] [--sparse] [--files-cache MODE] + [--files-changed MODE] [--read-special] + [--comment COMMENT] [--timestamp TIMESTAMP] + [--chunker-params PARAMS] [-C COMPRESSION] + [--hostname HOSTNAME] [--username USERNAME] + [--tags TAG [TAG ...]] + NAME [PATH ...] +tip: For details of accepted options run: borg create --help +error: Unrecognized arguments: --numeric-owner ++ borg --repo /tmp/demo-repo create --nobsdflags test /root/data +Common fixes: +- borg1 option "--nobsdflags" is not used in borg2. Use "--noflags" instead. +usage: borg [options] create [-h] [--critical] [--error] [--warning] [--info] + [--debug] [--debug-topic TOPIC] [-p] [--iec] + [--log-json] [--lock-wait SECONDS] + [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] + [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] [-n] [-s] [--list] + [--filter STATUSCHARS] [--json] + [--stdin-name NAME] [--stdin-user USER] + [--stdin-group GROUP] [--stdin-mode M] + [--content-from-command] [--paths-from-stdin] + [--paths-from-command] + [--paths-from-shell-command] + [--paths-delimiter DELIM] [-e PATTERN] + [--exclude-from EXCLUDEFILE] [--pattern PATTERN] + [--patterns-from PATTERNFILE] [--exclude-caches] + [--exclude-if-present NAME] [--keep-exclude-tags] + [-x] [--numeric-ids] [--atime] [--noctime] + [--nobirthtime] [--noflags] [--noacls] + [--noxattrs] [--sparse] [--files-cache MODE] + [--files-changed MODE] [--read-special] + [--comment COMMENT] [--timestamp TIMESTAMP] + [--chunker-params PARAMS] [-C COMPRESSION] + [--hostname HOSTNAME] [--username USERNAME] + [--tags TAG [TAG ...]] + NAME [PATH ...] +tip: For details of accepted options run: borg create --help +error: Unrecognized arguments: --nobsdflags ++ borg --repo /tmp/demo-repo create --remote-ratelimit 1000 test /root/data +Common fixes: +- borg1 option "--remote-ratelimit" is not used in borg2. Use "--upload-ratelimit" instead. +usage: borg [options] create [-h] [--critical] [--error] [--warning] [--info] + [--debug] [--debug-topic TOPIC] [-p] [--iec] + [--log-json] [--lock-wait SECONDS] + [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] + [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] [-n] [-s] [--list] + [--filter STATUSCHARS] [--json] + [--stdin-name NAME] [--stdin-user USER] + [--stdin-group GROUP] [--stdin-mode M] + [--content-from-command] [--paths-from-stdin] + [--paths-from-command] + [--paths-from-shell-command] + [--paths-delimiter DELIM] [-e PATTERN] + [--exclude-from EXCLUDEFILE] [--pattern PATTERN] + [--patterns-from PATTERNFILE] [--exclude-caches] + [--exclude-if-present NAME] [--keep-exclude-tags] + [-x] [--numeric-ids] [--atime] [--noctime] + [--nobirthtime] [--noflags] [--noacls] + [--noxattrs] [--sparse] [--files-cache MODE] + [--files-changed MODE] [--read-special] + [--comment COMMENT] [--timestamp TIMESTAMP] + [--chunker-params PARAMS] [-C COMPRESSION] + [--hostname HOSTNAME] [--username USERNAME] + [--tags TAG [TAG ...]] + NAME [PATH ...] +tip: For details of accepted options run: borg create --help +error: Unrecognized arguments: --remote-ratelimit ++ borg --repo /tmp/demo-repo repo-create +usage: borg [--config CONFIG] [--print_config [=flags]] [-V] [--cockpit] [-h] + [--critical] [--error] [--warning] [--info] [--debug] + [--debug-topic TOPIC] [-p] [--iec] [--log-json] + [--lock-wait SECONDS] [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] + ... +tip: For details of accepted options run: borg --help +error: Option 'repo-create.encryption' is required but not provided or its value is None. +Use -e/--encryption to choose a mode, for example: -e repokey-aes-ocb +Available encryption modes: none, authenticated, authenticated-blake2, keyfile-aes-ocb, repokey-aes-ocb, keyfile-chacha20-poly1305, repokey-chacha20-poly1305, keyfile-blake2-aes-ocb, repokey-blake2-aes-ocb, keyfile-blake2-chacha20-poly1305, repokey-blake2-chacha20-poly1305 ++ borg --repo /tmp/demo-repo::test1 list +usage: borg [--config CONFIG] [--print_config [=flags]] [-V] [--cockpit] [-h] + [--critical] [--error] [--warning] [--info] [--debug] + [--debug-topic TOPIC] [-p] [--iec] [--log-json] + [--lock-wait SECONDS] [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] + ... +tip: For details of accepted options run: borg --help +error: argument -r/--repo: Invalid location format: "/tmp/demo-repo::test1". + +Common fixes: + * Borg 2 does not accept repo::archive syntax. Corrected command lines: + borg list --repo /tmp/demo-repo list ::test1 + OR + export BORG_REPO=/tmp/demo-repo + borg list ::test1 ++ borg --repo /tmp/does-not-exist repo-info +Repository /tmp/does-not-exist does not exist. +Common fixes: +- Specify Correct Path ("/tmp/does-not-exist" does not exist). +- Create repository (-r): borg repo-create -r "/tmp/does-not-exist" -e repokey-aes-ocb +- Create repository (BORG_REPO): + export BORG_REPO=/tmp/does-not-exist + borg repo-create -e repokey-aes-ocb +Available -e modes: none, authenticated, authenticated-blake2, keyfile-aes-ocb, repokey-aes-ocb, keyfile-chacha20-poly1305, repokey-chacha20-poly1305, keyfile-blake2-aes-ocb, repokey-blake2-aes-ocb, keyfile-blake2-chacha20-poly1305, repokey-blake2-chacha20-poly1305 ++ borg --repo /tmp/does-not-exist list +usage: borg [--config CONFIG] [--print_config [=flags]] [-V] [--cockpit] [-h] + [--critical] [--error] [--warning] [--info] [--debug] + [--debug-topic TOPIC] [-p] [--iec] [--log-json] + [--lock-wait SECONDS] [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] + ... +tip: For details of accepted options run: borg --help +error: Option 'list.paths' is required but not provided or its value is None. + +Common fixes: +- "/tmp/does-not-exist" does not exist, pick a repository that exists or create one: + borg repo-create --repo /tmp/does-not-exist -e repokey-aes-ocb +Available -e modes: none, authenticated, authenticated-blake2, keyfile-aes-ocb, repokey-aes-ocb, keyfile-chacha20-poly1305, repokey-chacha20-poly1305, keyfile-blake2-aes-ocb, repokey-blake2-aes-ocb, keyfile-blake2-chacha20-poly1305, repokey-blake2-chacha20-poly1305 ++ borg list +usage: borg [--config CONFIG] [--print_config [=flags]] [-V] [--cockpit] [-h] + [--critical] [--error] [--warning] [--info] [--debug] + [--debug-topic TOPIC] [-p] [--iec] [--log-json] + [--lock-wait SECONDS] [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] + ... +tip: For details of accepted options run: borg --help +error: Option 'list.paths' is required but not provided or its value is None. +borg list requires an archive NAME to list contents. +Common fixes: +- Provide archive name: borg list NAME +- To list archives in a repository, use: borg -r REPO repo-list ++ borg frobnicate +usage: borg [--config CONFIG] [--print_config [=flags]] [-V] [--cockpit] [-h] + [--critical] [--error] [--warning] [--info] [--debug] + [--debug-topic TOPIC] [-p] [--iec] [--log-json] + [--lock-wait SECONDS] [--show-version] [--show-rc] [--umask M] + [--remote-path PATH] [--upload-ratelimit RATE] + [--upload-buffer UPLOAD_BUFFER] [--debug-profile FILE] [--rsh RSH] + [--socket [PATH]] [-r REPO] + ... +tip: For details of accepted options run: borg --help +error: argument : invalid choice: 'frobnicate' (choose from 'analyze', 'benchmark', 'check', 'compact', 'completion', 'create', 'debug', 'delete', 'diff', 'extract', 'help', 'info', 'key', 'list', 'break-lock', 'with-lock', 'mount', 'umount', 'prune', 'repo-compress', 'repo-create', 'repo-delete', 'repo-info', 'repo-list', 'recreate', 'rename', 'repo-space', 'serve', 'tag', 'export-tar', 'import-tar', 'transfer', 'undelete', 'version') +Common fixes: +- Run 'borg help' to list valid borg2 commands. diff --git a/src/borg/helpers/argparsing.py b/src/borg/helpers/argparsing.py index 0da7f3e322..69f99564f9 100644 --- a/src/borg/helpers/argparsing.py +++ b/src/borg/helpers/argparsing.py @@ -236,6 +236,24 @@ def _argv_tail_after_invalid_choice(invalid: str) -> list[str]: return sys.argv[idx + 1 :] +def _repo_path_from_argv() -> str | None: + """Return the path/URL after ``-r``/``--repo`` in ``sys.argv``, if present.""" + argv = sys.argv + for i, a in enumerate(argv): + if a in ("--repo", "-r") and i + 1 < len(argv): + return argv[i + 1] + return None + + +def _local_repo_path_missing(repo_path: str) -> bool: + """True if *repo_path* looks like a local filesystem path and does not exist.""" + if not repo_path or "://" in repo_path: + return False + if repo_path.startswith("/") or (len(repo_path) > 2 and repo_path[1] == ":"): + return not os.path.exists(repo_path) + return False + + def _argv_display_for_hint(argv: list[str]) -> list[str]: """Normalize argv to a readable `borg ...` line when launched via python -m or a borg binary.""" if ( @@ -312,10 +330,9 @@ def _common_fix_hints(self, message: str) -> list[str]: hints.append(f"Put subcommand-specific options after ``: {reorder}") if "missing repository" in message.lower(): hints.append("Set the repository via --repo REPO or BORG_REPO.") - if "list.name is none" in message.lower() or ("list.name" in message and "is None" in message): + list_name_missing = "list.name is none" in message.lower() or ("list.name" in message and "is None" in message) + if list_name_missing: hints.append("For 'borg list', set repository via -r/--repo or BORG_REPO and pass an archive name.") - if "repo::archive" in message or "::archive" in message: - hints.append("Borg 2 uses --repo/BORG_REPO and separate archive arguments.") if "invalid choice" in message and "" in message: cmd_hint = self._top_command_choice_hint(message) if cmd_hint: @@ -336,12 +353,37 @@ def error(self, message, *args, **kwargs): f"Available encryption modes: {mode_list}" ) if "Option 'list.paths' is required but not provided" in message: + repo_path = _repo_path_from_argv() + if repo_path and _local_repo_path_missing(repo_path): + from ..crypto.key import key_argument_names + + mode_list = ", ".join(key_argument_names()) + message = ( + f"{message}\n\n" + "Common fixes:\n" + f'- "{repo_path}" does not exist, pick a repository that exists or create one:\n' + f"\tborg repo-create --repo {repo_path} -e repokey-aes-ocb\n" + f"Available -e modes: {mode_list}" + ) + else: + message = ( + f"{message}\n" + "borg list requires an archive NAME to list contents.\n" + "Common fixes:\n" + "- Provide archive name: borg list NAME\n" + "- To list archives in a repository, use: borg -r REPO repo-list" + ) + loc_match = re.search(r'Invalid location format: "([^"]+)"', message) + if loc_match and "::" in loc_match.group(1): + repo, archive = loc_match.group(1).split("::", 1) message = ( - f"{message}\n" - "borg list requires an archive NAME to list contents.\n" + f"{message}\n\n" "Common fixes:\n" - "- Provide archive name: borg list NAME\n" - "- To list archives in a repository, use: borg -r REPO repo-list" + " * Borg 2 does not accept repo::archive syntax. Corrected command lines:\n" + f"\tborg list --repo {repo} list ::{archive}\n" + f"\t\tOR\n" + f"\texport BORG_REPO={repo}\n" + f"\tborg list ::{archive}" ) common_hints = self._common_fix_hints(message) if common_hints: diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index fd8ffbe52b..44ec502530 100644 --- a/src/borg/helpers/parseformat.py +++ b/src/borg/helpers/parseformat.py @@ -617,18 +617,8 @@ def parse(self, text, overrides={}): self.raw = text # as given by user, might contain placeholders self.processed = replace_placeholders(self.raw, overrides) # after placeholder replacement if "::" in self.processed: - repo, archive = self.processed.split("::", 1) - raise ValueError( - f'Invalid location format: "{self.processed}". ' - "Borg 2 does not accept repo::archive syntax. " - "Corrected command lines:\n" - f"borg -r {repo} list {archive}\n" - f"export BORG_REPO={repo}\n" - f"borg list {archive}\n" - f"borg -r {repo} repo-info\n" - f"export BORG_REPO={repo}\n" - f"borg repo-info" - ) + # Keep this message short; ArgumentParser.error() adds Common fixes with examples. + raise ValueError(f'Invalid location format: "{self.processed}".') valid = self._parse(self.processed) if valid: self.valid = True diff --git a/src/borg/testsuite/archiver/cli_guidance_test.py b/src/borg/testsuite/archiver/cli_guidance_test.py index 3bff930cbb..1a01a72129 100644 --- a/src/borg/testsuite/archiver/cli_guidance_test.py +++ b/src/borg/testsuite/archiver/cli_guidance_test.py @@ -145,9 +145,8 @@ def test_repo_double_colon_syntax_shows_migration_hint(cmd_fixture, tmp_path): exit_code, output = cmd_fixture("--repo", repo, "repo-info") assert exit_code == 2 assert "does not accept repo::archive syntax" in output - assert "borg -r" in output - assert "borg list archive" in output - assert "borg repo-info" in output + assert "borg list --repo" in output + assert "\t\tOR\n" in output assert "export BORG_REPO=" in output @@ -182,11 +181,22 @@ def test_list_name_none_common_fix_hint(): assert "For 'borg list', set repository via -r/--repo or BORG_REPO and pass an archive name." in hints -def test_list_paths_required_shows_path_and_repo_creation_hints(cmd_fixture, tmp_path): +def test_list_paths_required_shows_repo_create_when_repo_path_missing(cmd_fixture, tmp_path): repo = os.fspath(tmp_path / "does-not-exist") exit_code, output = cmd_fixture("--repo", repo, "list") assert exit_code == 2 assert "Option 'list.paths' is required but not provided" in output + assert f'"{repo}" does not exist, pick a repository that exists or create one:' in output + assert f"borg repo-create --repo {repo} -e repokey-aes-ocb" in output + assert "Available -e modes:" in output + + +def test_list_paths_required_shows_path_and_repo_creation_hints(cmd_fixture, tmp_path): + repo = os.fspath(tmp_path / "repo") + repo.mkdir() + exit_code, output = cmd_fixture("--repo", os.fspath(repo), "list") + assert exit_code == 2 + assert "Option 'list.paths' is required but not provided" in output assert "borg list requires an archive NAME to list contents." in output assert "- Provide archive name: borg list NAME" in output assert "- To list archives in a repository, use: borg -r REPO repo-list" in output