completion: no directory completion for repository URLs, fixes #10460

-r/--repo and --other-repo completed local directories even when the value
already started with a URL scheme like ssh:// or rclone:. In fish, this
offered e.g. "ssh://etc/" (fish completes the part after the ":" as a path),
tcsh printed "ssh:// not found".

Now, a URL scheme other than file: (the ones Location parses as a URL) gets
no completion in any shell, while local paths still complete directories:

- bash, zsh, fish: new _borg_complete_repo_dirs helper
- tcsh: a leading completion rule completing nothing for a URL, it is used
  before the directory rules of the repository options.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Thomas Waldmann
2026-09-29 21:18:34 +02:00
co-authored by Claude Opus 5.5
parent 1c5a3c37f0
commit fd71c9eca0
2 changed files with 107 additions and 11 deletions
+48 -6
View File
@@ -56,7 +56,8 @@ The following argument types have intelligent, context-aware completion:
12. Repositories (location_validator):
- Completes directories for -r/--repo and --other-repo (a local repository is a
directory; remote locations like ssh://... just do not match one)
directory), but nothing once the value starts with a URL scheme other than
file: (e.g. ssh://... or rclone:...)
13. Encryption modes (the --encryption choices):
- Completes the repo-create encryption modes, in zsh and fish with a short
@@ -79,6 +80,7 @@ from ..crypto.key import encryption_argument_names
from ..helpers import (
archivename_validator,
location_validator,
Location,
SortBySpec,
FilesCacheMode,
PathSpec,
@@ -96,6 +98,16 @@ from ..helpers.time import timestamp
from ..helpers.parseformat import partial_format
from ..manifest import AI_HUMAN_SORT_KEYS
# URL schemes of the repository locations that are not local directories, see Location.
REPO_URL_SCHEMES = [scheme for scheme in Location.BORG_SCHEMES + Location.BORGSTORE_SCHEMES if scheme != "file"]
# tcsh completion rule completing nothing for a repository URL (e.g. ssh://...): the rules of the
# repository options complete directories, and tcsh can not make that depend on the current word.
# But tcsh uses the first rule that matches, and this one comes first. A current word rule matches
# the beginning of the word being completed, so it covers --repo=URL (-r=URL, --other-repo=URL) as
# well as a URL being a word of its own (--repo URL, but also any other word starting with a URL).
TCSH_REPO_URL_RULE = "'c/{--repo=,-r=,--other-repo=,}{" + ",".join(REPO_URL_SCHEMES) + "}:/n/'"
# Global bash preamble that is prepended to the generated completion script.
# It aggregates only what we need:
# - wordbreak fixes for ':' and '=' so tokens like 'aid:' and '--repo=/path' stay intact
@@ -186,6 +198,13 @@ _borg_complete_encryption() {
compgen -W "${choices}" -- "$1"
}
# Complete local repository directories, but nothing for a URL like ssh://... or rclone:...
# The name must contain "_dir", so that shtab completes the results as filenames (e.g. adds a "/").
_borg_complete_repo_dirs() {
[[ "$1" =~ ^({REPO_URL_SCHEMES}): ]] && return 0
compgen -d -- "$1"
}
# Complete tags from repository
_borg_complete_tags() {
local cur="${COMP_WORDS[COMP_CWORD]}"
@@ -433,6 +452,12 @@ _borg_complete_encryption() {
compadd -V 'encryption modes' -Q -l -d descriptions -a choices
}
# Complete local repository directories, but nothing for a URL like ssh://... or rclone:...
_borg_complete_repo_dirs() {
[[ "$PREFIX" == ({REPO_URL_SCHEMES}):* ]] && return 1
_files -/
}
# Complete tags from repository
_borg_complete_tags() {
local cur
@@ -649,6 +674,17 @@ function _borg_complete_encryption
printf '%s\n' {ENCRYPTION_DESCRIPTIONS}
end
# Complete local repository directories, but nothing for a URL like ssh://... or rclone:...
# (fish's directory completion would complete the part after the ":" as a path, e.g. ssh://etc/).
function _borg_complete_repo_dirs
# the value being completed: without a leading --repo= (-r=, --other-repo=) or -r
set -l cur (commandline -ct | string replace -r -- '^(-[^=]*=|-r)' '')
if string match -qr -- '^({REPO_URL_SCHEMES}):' $cur
return
end
__fish_complete_directories
end
# Complete relative time markers
function _borg_complete_relative_time
printf '%s\n' {RELATIVE_TIME_CHOICES}
@@ -1005,10 +1041,11 @@ class CompletionMixIn:
return {"bash": fn_name, "zsh": fn_name, "tcsh": f"`{tcsh_fn or fn_name}`", "fish": f"({fn_name})"}
_attach_completion(parser, archivename_validator, for_all_shells("_borg_complete_archive"))
# -r/--repo and --other-repo: complete local repository directories (remote locations
# like ssh://... simply do not match anything then), see #3086.
_attach_completion(parser, location_validator(other=False), shtab.DIRECTORY)
_attach_completion(parser, location_validator(other=True), shtab.DIRECTORY)
# -r/--repo and --other-repo: complete local repository directories, see #3086, but no
# directories for a URL like ssh://..., see #10460 (tcsh: see TCSH_REPO_URL_RULE).
repo_dirs = for_all_shells("_borg_complete_repo_dirs") | {"tcsh": shtab.DIRECTORY["tcsh"]}
_attach_completion(parser, location_validator(other=False), repo_dirs)
_attach_completion(parser, location_validator(other=True), repo_dirs)
for sortby_type, sortby_fn, _ in SORTBY_COMPLETERS:
_attach_completion(parser, sortby_type, for_all_shells(sortby_fn, tcsh_fn=TCSH_SORTBY_FN))
_attach_completion(parser, FilesCacheMode, for_all_shells("_borg_complete_filescachemode"))
@@ -1092,6 +1129,7 @@ class CompletionMixIn:
"RELATIVE_TIME_CHOICES": relative_time_choices_str,
"FILE_SIZE_CHOICES": file_size_choices_str,
"HELP_CHOICES": help_choices,
"REPO_URL_SCHEMES": "|".join(REPO_URL_SCHEMES),
"SH_COMPLETE": TCSH_SH_COMPLETE,
# after SH_COMPLETE, so that the {PROG} in there is substituted as well
"PROG": completion_prog,
@@ -1107,7 +1145,11 @@ class CompletionMixIn:
template = preamble_templates.get(args.shell)
# Build the preamble using partial_format to avoid escaping braces etc.
preambles = [partial_format(template, mapping)] if template else []
print(parser.get_completion_script(f"shtab-{args.shell}", preambles=preambles))
script = parser.get_completion_script(f"shtab-{args.shell}", preambles=preambles)
if args.shell == "tcsh":
complete_cmd = f"complete {completion_prog} \\\n"
script = script.replace(complete_cmd, f"{complete_cmd} {TCSH_REPO_URL_RULE} \\\n", 1)
print(script)
def build_parser_completion(self, subparsers, common_parser, mid_common_parser):
shells = tuple(shtab.SUPPORTED_SHELLS)
@@ -596,16 +596,70 @@ def test_completion_falls_back_to_borg(archivers, request, shell, monkeypatch):
def test_completion_repo_is_a_directory(archivers, request, shell):
"""-r/--repo and --other-repo complete local repository directories."""
lines = completion_lines(archivers, request, shell)
# how each shell spells "complete directories", and how it refers to the two options
directory, repo, other_repo = {
"bash": ("_shtab_compgen_dirs", "_shtab_borg___repo_COMPGEN=", "_shtab_borg_repo_create___other_repo_COMPGEN="),
"zsh": ("_files -/", "{-r,--repo}", '"--other-repo['),
"fish": ("__fish_complete_directories", " -l repo ", " -l other-repo "),
# the helper completing directories (but not for a URL), and how each shell refers to the two options
directory = "_borg_complete_repo_dirs"
repo, other_repo = {
"bash": ("_shtab_borg___repo_COMPGEN=", "_shtab_borg_repo_create___other_repo_COMPGEN="),
"zsh": ("{-r,--repo}", '"--other-repo['),
"fish": (" -l repo ", " -l other-repo "),
}[shell]
assert any(repo in line and directory in line for line in lines), "-r/--repo does not complete directories"
assert any(other_repo in line and directory in line for line in lines), "--other-repo does not complete dirs"
# repository URLs: no directories must be completed for them, see #10460
REPO_URLS = ["ssh://", "ssh://host/", "sftp://", "rclone:", "s3:", "b2:", "http://", "https://"]
@needs_bash4
def test_bash_repo_url_no_directories(archivers, request, tmp_path):
archiver = request.getfixturevalue(archivers)
(tmp_path / "somerepo").mkdir()
for url in REPO_URLS:
# a local directory looking like the start of the URL, so that plain directory completion finds it
(tmp_path / url.split("/")[0]).mkdir(exist_ok=True)
script = cmd(archiver, "completion", "bash")
for url in REPO_URLS:
result = _run_bash_completion_fn(script, f'cd "{tmp_path}"\n_borg_complete_repo_dirs "{url}"\n')
assert result.returncode == 0, f"stderr: {result.stderr}"
assert result.stdout == "", f"directories offered for {url}: {result.stdout}"
result = _run_bash_completion_fn(script, f'cd "{tmp_path}"\n_borg_complete_repo_dirs some\n')
assert result.stdout.split() == ["somerepo"]
@needs_zsh
def test_zsh_repo_url_no_directories(archivers, request):
archiver = request.getfixturevalue(archivers)
script = cmd(archiver, "completion", "zsh")
# stub the zsh directory completion, it needs the whole completion system
setup = "_files() { print -r -- COMPLETING-DIRS }\n"
for url in REPO_URLS:
result = _run_zsh_completion_fn(script, setup + f"PREFIX='{url}'\n_borg_complete_repo_dirs\n")
assert "COMPLETING-DIRS" not in result.stdout, f"directories offered for {url}"
result = _run_zsh_completion_fn(script, setup + "PREFIX=/some/dir\n_borg_complete_repo_dirs\n")
assert "COMPLETING-DIRS" in result.stdout
@needs_fish
def test_fish_repo_url_no_directories(archivers, request):
archiver = request.getfixturevalue(archivers)
script = cmd(archiver, "completion", "fish")
for url in REPO_URLS:
for cmdline in (f"borg repo-create --repo={url}", f"borg repo-create -r {url}", f"borg repo-create -r{url}"):
candidates = _fish_complete_candidates(script, cmdline)
assert candidates == [], f"directories offered for {cmdline}: {candidates}"
candidates = _fish_complete_candidates(script, f"borg transfer --other-repo {url}")
assert candidates == [], f"directories offered for --other-repo {url}: {candidates}"
def test_tcsh_repo_url_no_directories(archivers, request):
"""tcsh uses the first matching rule, the one completing nothing for URLs must precede the directory ones."""
lines = [line.strip().rstrip(" \\") for line in completion_lines(archivers, request, "tcsh")]
url_rule = lines.index("'c/{--repo=,-r=,--other-repo=,}{ssh,sftp,http,https,s3,b2,rclone}:/n/'")
for repo_rule in ("'c/--repo=/d/'", "'n/--repo/d/'", "'n/-r/d/'", "'c/--other-repo=/d/'", "'n/--other-repo/d/'"):
assert url_rule < lines.index(repo_rule)
@needs_fish
def test_fish_repo_directory_completion(archivers, request, tmp_path):
"""-r/--repo completes directories, but no files."""