This commit is contained in:
gmatht 2026-03-25 15:31:19 +01:00 committed by GitHub
commit 17c0b0c644
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 930 additions and 4 deletions

40
scripts/test-ux.sh Normal file
View file

@ -0,0 +1,40 @@
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
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 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.

View file

@ -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]
<command> ...
tip: For details of accepted options run: borg --help
error: argument <command>: 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]
<command> ...
tip: For details of accepted options run: borg --help
error: argument <command>: 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]
<command> ...
tip: For details of accepted options run: borg --help
error: argument <command>: 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]
<command> ...
tip: For details of accepted options run: borg --help
error: argument <command>: 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]
<command> ...
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]
<command> ...
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]
<command> ...
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]
<command> ...
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]
<command> ...
tip: For details of accepted options run: borg --help
error: argument <command>: 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.

View file

@ -166,9 +166,28 @@ class Archiver(
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=...

View file

@ -94,6 +94,11 @@ the subcommand namespace and the outer (top-level) default flows through
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,288 @@ from jsonargparse import ArgumentParser as _ArgumentParser # we subclass that t
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 `<canonical>` not `<typed>`:\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 / <command> 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 <command> (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 _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 (
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 `<command>`: {reorder}")
if "missing repository" in message.lower():
hints.append("Set the repository via --repo REPO or BORG_REPO.")
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 "invalid choice" in message and "<command>" 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:
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\n"
"Common fixes:\n"
" * 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:
message = f"{message}\nCommon fixes:\n- " + "\n- ".join(common_hints)
super().error(message, *args, **kwargs)
def flatten_namespace(ns: Any) -> Namespace:
"""

View file

@ -616,6 +616,9 @@ class Location:
self.raw = text # as given by user, might contain placeholders
self.processed = replace_placeholders(self.raw, overrides) # after placeholder replacement
if "::" in self.processed:
# 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

View file

@ -150,6 +150,26 @@ class LegacyRepository:
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 LegacyRepository:
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 [{}]."""

View file

@ -52,6 +52,26 @@ class Repository:
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 Repository:
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 @@ class Repository:
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())

View file

@ -0,0 +1,227 @@
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 <command>: 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 <command>: 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 <command>: 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 list --repo" in output
assert "\t\tOR\n" 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_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
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 `<command>`:" 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

View file

@ -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")

View file

@ -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"})

View file

@ -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"})