remove handwritten bash/zsh shell completions, fixes #9178

Remove the handwritten bash and zsh shell completion scripts now that
auto-generated completions via borg completion bash/zsh (powered by
shtab, #9172) are tested and working. Fish completions are kept as
shtab does not yet support fish.

Replace string-matching tests with focused behavior tests: script size
sanity, shell syntax validation (bash -n / zsh -n), and tests that
invoke the custom preamble functions in bash (sortby key dedup,
filescachemode mutual exclusivity, archive name and aid: prefix
completion against a real repository).
This commit is contained in:
Mrityunjay Raj 2026-02-18 00:03:54 +05:30 committed by GitHub
parent a4bfc185df
commit 1c0bf36275
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 179 additions and 1852 deletions

View file

@ -167,10 +167,15 @@ Fixes:
Other changes:
- remove handwritten bash and zsh shell completions, #9178.
these are now auto-generated via ``borg completion bash/zsh`` (using shtab).
fish completions are kept until shtab gains fish support.
- mount: warn about symlinks pointing outside of the mountpoint, #9254
- Version: do not access private attributes, #9263
- tests / CI:
- completion: focused tests for auto-generated shell completions
(syntax validation, size sanity, borg-specific preamble behavior)
- fix and re-enable Windows CI (some tests are skipped on Windows)
- CI: faster with borg-dir/borg.exe, #9236
- fix mismatch in xattr test, #9238

View file

@ -1,223 +0,0 @@
# Bash completions for Borg
# https://www.borgbackup.org/
# Note:
# Listing archives works on password-protected repositories only if $BORG_PASSPHRASE is set.
# Install:
# Copy this file to /usr/share/bash-completion/completions/ or /etc/bash_completion.d/.
_borg()
{
compopt -o default
COMPREPLY=()
local cur="${COMP_WORDS[COMP_CWORD]}"
local prev="${COMP_WORDS[COMP_CWORD-1]}"
local prevprev="${COMP_WORDS[COMP_CWORD-2]}"
local common_opts="-h --help --critical --error --warning --info -v --verbose --debug --debug-topic -p --progress --iec --log-json --lock-wait --show-version --show-rc --umask --remote-path --upload-ratelimit --upload-buffer --debug-profile --rsh -r --repo"
local archive_filter_opts="--sort-by --first --last --oldest --newest --older --newer"
local opts="${common_opts}"
# Commands
if [[ ${COMP_CWORD} == 1 ]] ; then
local borg_commands="analyze benchmark break-lock check compact create debug delete diff export-tar extract help import-tar info key list mount prune recreate rename repo-compress repo-create repo-delete repo-info repo-list repo-space serve tag transfer umount undelete version with-lock"
COMPREPLY=( $(compgen -W "${borg_commands}" -- ${cur}) )
compopt +o default
return 0
fi
case "${prev}" in
'key')
COMPREPLY=( $(compgen -W "change-location change-passphrase export import" -- ${cur}) )
return 0
;;
'benchmark')
COMPREPLY=( $(compgen -W "cpu crud" -- ${cur}) )
return 0
;;
'debug')
COMPREPLY=( $(compgen -W "info dump-archive-items dump-archive dump-manifest dump-repo-objs search-repo-objs get-obj id-hash parse-obj format-obj put-obj delete-obj convert-profile" -- ${cur}) )
return 0
;;
'help')
COMPREPLY=( $(compgen -W "patterns placeholders compression" -- ${cur}) )
return 0
;;
'--encryption' | '-e')
local encryption_modes="authenticated authenticated-blake2 keyfile-aes-ocb keyfile-blake2-aes-ocb keyfile-blake2-chacha20-poly1305 keyfile-chacha20-poly1305 none repokey-aes-ocb repokey-blake2-aes-ocb repokey-blake2-chacha20-poly1305 repokey-chacha20-poly1305"
COMPREPLY=( $(compgen -W "${encryption_modes}" -- ${cur}) )
return 0
;;
'--files-cache')
local files_cache_mode="ctime,size,inode mtime,size,inode ctime,size mtime,size rechunk,ctime rechunk,mtime size disabled"
COMPREPLY=( $(compgen -W "${files_cache_mode}" -- ${cur}) )
return 0
;;
'--compression' | '-C')
local compression_methods="none auto lz4 zstd,1 zstd,2 zstd,3 zstd,4 zstd,5 zstd,6 zstd,7 zstd,8 zstd,9 zstd,10 zstd,11 zstd,12 zstd,13 zstd,14 zstd,15 zstd,16 zstd,17 zstd,18 zstd,19 zstd,20 zstd,21 zstd,22 zlib,1 zlib,2 zlib,3 zlib,4 zlib,5 zlib,6 zlib,7 zlib,8 zlib,9 lzma,0 lzma,1 lzma,2 lzma,3 lzma,4 lzma,5 lzma,6 lzma,7 lzma,8 lzma,9"
COMPREPLY=( $(compgen -W "${compression_methods}" -- ${cur}) )
return 0
;;
'--sort-by')
local sort_keys="timestamp archive name id tags host user"
COMPREPLY=( $(compgen -W "${sort_keys}" -- ${cur}) )
return 0
;;
'-o')
# FIXME This list is probably not complete, but I tried to pick only those that are relevant to borg mount -o:
local fuse_options="ac_attr_timeout= allow_damaged_files allow_other allow_root attr_timeout= auto auto_cache auto_unmount default_permissions entry_timeout= gid= group_id= kernel_cache max_read= negative_timeout= noauto noforget remember= remount rootmode= uid= umask= user user_id= versions"
COMPREPLY=( $(compgen -W "${fuse_options}" -- ${cur}) )
return 0
;;
'--recompress')
local recompress_when="if-different always never"
COMPREPLY=( $(compgen -W "${recompress_when}" -- ${cur}) )
return 0
;;
'--upgrader')
local upgraders="From12To20 NoOp"
COMPREPLY=( $(compgen -W "${upgraders}" -- ${cur}) )
return 0
;;
esac
if [[ ${cur} == -* ]] ; then
case "${COMP_LINE}" in
*' analyze '*)
local opts="-a --match-archives ${archive_filter_opts} ${common_opts}"
;;
*' repo-create '*)
local opts="--other-repo --from-borg1 -e --encryption --copy-crypt-key ${common_opts}"
;;
*' repo-list '*)
local opts="--short --format --json ${common_opts} -a --match-archives ${archive_filter_opts} --deleted"
;;
*' repo-info '*)
local opts="--json ${common_opts}"
;;
*' repo-compress '*)
local opts="-C --compression -s --stats ${common_opts}"
;;
*' repo-delete '*)
local opts="-n --dry-run --list --force --cache-only --keep-security-info ${common_opts}"
;;
*' repo-space '*)
local opts="--reserve --free ${common_opts}"
;;
*' create '*)
local opts="-n --dry-run -s --stats --list --filter --json --stdin-name --stdin-user --stdin-group --stdin-mode --content-from-command --paths-from-stdin --paths-from-command --paths-delimiter -e --exclude --exclude-from --pattern --patterns-from --exclude-caches --exclude-if-present --keep-exclude-tags --exclude-nodump -x --one-file-system --numeric-ids --atime --noctime --nobirthtime --noflags --noacls --noxattrs --sparse --files-cache --read-special --comment --timestamp --chunker-params -C --compression ${common_opts}"
;;
*' extract '*)
local opts="--list -n --dry-run --numeric-ids --noflags --noacls --noxattrs --stdout --sparse -e --exclude --exclude-from --pattern --patterns-from --strip-components ${common_opts}"
;;
*' check '*)
local opts="--repository-only --archives-only --verify-data --repair --find-lost-archives --max-duration -a --match-archives ${archive_filter_opts} ${common_opts}"
;;
# rename
# no specific options
*" list "*)
local opts="--short --format --json-lines -e --exclude --exclude-from --pattern --patterns-from ${common_opts}"
;;
*' diff '*)
local opts="--numeric-ids --same-chunker-params --sort --json-lines -e --exclude --exclude-from --pattern --patterns-from ${common_opts}"
;;
*' delete '*)
local opts="-n --dry-run --list -s --stats --cache-only --force -a --match-archives ${archive_filter_opts} ${common_opts}"
;;
*' prune '*)
local opts="-n --dry-run --force -s --stats --list --keep-within --keep-last --keep-secondly --keep-minutely -H --keep-hourly -d --keep-daily -w --keep-weekly -m --keep-monthly --keep-13weekly --keep-3monthly -y --keep-yearly -a --match-archives ${common_opts}"
;;
*' compact '*)
local opts="-n --dry-run -s --stats ${common_opts}"
;;
*' info '*)
local opts="--json -a --match-archives ${archive_filter_opts} ${common_opts}"
;;
*' mount '*)
local opts="-f --foreground -o --numeric-ids -a --match-archives ${archive_filter_opts} -e --exclude --exclude-from --pattern --patterns-from --strip-components ${common_opts}"
;;
# umount
# no specific options
# key change-passphrase
# no specific options
*' change-location '*)
local opts="${common_opts} keyfile repokey --keep"
;;
*' export '*)
local opts="--paper --qr-html ${common_opts}"
;;
*' import '*)
local opts="--paper ${common_opts}"
;;
*' recreate '*)
local opts="--list --filter -n --dry-run -s --stats -e --exclude --exclude-from --pattern --patterns-from --exclude-caches --exclude-if-present --keep-exclude-tags -a --match-archives ${archive_filter_opts} --target --comment --timestamp -C --compression --chunker-params ${common_opts}"
;;
*' export-tar '*)
local opts="--tar-filter --list --tar-format -e --exclude --exclude-from --pattern --patterns-from --strip-components ${common_opts}"
;;
*' import-tar '*)
local opts="--tar-filter -s --stats --list --filter --json --ignore-zeros ${common_opts} --comment --timestamp --chunker-params -C --compression"
;;
*' transfer '*)
local opts="-n --dry-run --other-repo --upgrader ${common_opts} -a --match-archives ${archive_filter_opts}"
;;
*' serve '*)
local opts="--restrict-to-path --restrict-to-repository ${common_opts}"
;;
*' tag '*)
local opts="--set --add --remove -a --match-archives ${archive_filter_opts} ${common_opts}"
;;
*' undelete '*)
local opts="-n --dry-run --list -a --match-archives ${archive_filter_opts} ${common_opts}"
;;
# debug
# has subcommands, handled separately
# version
# no specific options
# with-lock
# no specific options
# break-lock
# no specific options
# benchmark crud
# no specific options
esac
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0
fi
# Get the repository name if available
# If there is a space before the "::" it means that no repository name was typed,
# so probably $BORG_REPO was set and we can still list the archives.
local repository_name="${COMP_LINE%%::*}"
repository_name=${repository_name##* }
# Listing archives.
# Since "::" is treated as separate word in Bash,
# it is $cur when the cursor is right behind it
# and $prev if the user has started to type an archive name.
local typed_word=${cur}
local -i list_archives=0
if [[ ${cur} == "::" ]] ; then
list_archives=1
typed_word=""
fi
if [[ ${prev} == "::" ]] ; then
list_archives=1
fi
# Second archive listing for borg diff
if [[ ${COMP_LINE} =~ ^.*\ diff\ .*::[^\ ]+\ ${cur}$ ]] ; then
list_archives=1
fi
# Additional archive listing for borg delete
if [[ ${COMP_LINE} =~ ^.*\ delete\ .*::[^\ ]+.*${cur}$ ]] ; then
list_archives=1
fi
if (( $list_archives )) ; then
local archives=$(borg list --short "${repository_name}" 2>/dev/null)
COMPREPLY=( $(compgen -W "${archives}" -- "${typed_word}" ) )
return 0
fi
return 0
}
complete -F _borg borg

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,184 @@
from . import cmd, generate_archiver_tests
import functools
import os
import subprocess
import tempfile
import pytest
from . import cmd, generate_archiver_tests, RK_ENCRYPTION
pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local") # NOQA
def test_bash_completion(archivers, request):
"""Ensure the generated Bash completion includes our helper."""
@functools.lru_cache
def cmd_available(cmd):
"""Check if a shell command is available."""
try:
subprocess.run(cmd.split(), capture_output=True, check=True)
return True
except (subprocess.SubprocessError, FileNotFoundError):
return False
needs_bash = pytest.mark.skipif(not cmd_available("bash --version"), reason="Bash not available")
needs_zsh = pytest.mark.skipif(not cmd_available("zsh --version"), reason="Zsh not available")
def _run_bash_completion_fn(completion_script, setup_code):
"""Source the completion script in bash and run setup_code, return subprocess result."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".bash", delete=False) as f:
f.write(completion_script)
script_path = f.name
try:
result = subprocess.run(
["bash", "-c", f"source {script_path}\n{setup_code}"], capture_output=True, text=True, timeout=120
)
finally:
os.unlink(script_path)
return result
# -- output sanity checks -----------------------------------------------------
def test_bash_completion_nontrivial(archivers, request):
"""Verify the generated Bash completion is non-trivially sized."""
archiver = request.getfixturevalue(archivers)
output = cmd(archiver, "completion", "bash")
assert "_borg_complete_archive() {" in output
assert "_borg_complete_sortby() {" in output
assert "_borg_complete_filescachemode() {" in output
assert len(output) > 5000, f"Bash completion suspiciously small: {len(output)} chars"
assert output.count("\n") > 100, f"Bash completion suspiciously few lines: {output.count(chr(10))}"
def test_zsh_completion(archivers, request):
"""Ensure the generated Zsh completion includes our helper."""
def test_zsh_completion_nontrivial(archivers, request):
"""Verify the generated Zsh completion is non-trivially sized."""
archiver = request.getfixturevalue(archivers)
output = cmd(archiver, "completion", "zsh")
assert "_borg_complete_archive() {" in output
assert "_borg_complete_sortby() {" in output
assert "_borg_complete_filescachemode() {" in output
assert len(output) > 5000, f"Zsh completion suspiciously small: {len(output)} chars"
assert output.count("\n") > 100, f"Zsh completion suspiciously few lines: {output.count(chr(10))}"
# -- syntax validation --------------------------------------------------------
def _check_shell_syntax(script_content, shell, suffix):
"""Write script_content to a temp file and verify syntax with ``shell -n``."""
with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as f:
f.write(script_content)
script_path = f.name
try:
result = subprocess.run([shell, "-n", script_path], capture_output=True)
finally:
os.unlink(script_path)
return result
@needs_bash
def test_bash_completion_syntax(archivers, request):
"""Verify the generated Bash completion script has valid syntax."""
archiver = request.getfixturevalue(archivers)
output = cmd(archiver, "completion", "bash")
result = _check_shell_syntax(output, "bash", ".bash")
assert result.returncode == 0, f"Generated Bash completion has syntax errors: {result.stderr.decode()}"
@needs_zsh
def test_zsh_completion_syntax(archivers, request):
"""Verify the generated Zsh completion script has valid syntax."""
archiver = request.getfixturevalue(archivers)
output = cmd(archiver, "completion", "zsh")
result = _check_shell_syntax(output, "zsh", ".zsh")
assert result.returncode == 0, f"Generated Zsh completion has syntax errors: {result.stderr.decode()}"
# -- borg-specific preamble function behavior (bash) --------------------------
@needs_bash
def test_bash_sortby_dedup(archivers, request):
"""_borg_complete_sortby should not re-offer already-selected sort keys."""
archiver = request.getfixturevalue(archivers)
script = cmd(archiver, "completion", "bash")
# Simulate: user typed "borg repo-list --sort-by timestamp,"
# The function should offer remaining keys but NOT "timestamp" again.
result = _run_bash_completion_fn(
script, 'COMP_WORDS=(borg repo-list --sort-by "timestamp,")\n' "COMP_CWORD=3\n" "_borg_complete_sortby\n"
)
assert result.returncode == 0, f"stderr: {result.stderr}"
lines = [line for line in result.stdout.strip().splitlines() if line.strip()]
# "timestamp" must not appear as a standalone completion candidate
bare_keys = [line.rsplit(",", 1)[-1] for line in lines]
assert "timestamp" not in bare_keys, f"timestamp was re-offered: {lines}"
# Other keys like "archive" should be offered
assert any("archive" in line for line in lines), f"expected 'archive' in completions: {lines}"
@needs_bash
def test_bash_filescachemode_exclusivity(archivers, request):
"""_borg_complete_filescachemode should enforce ctime/mtime and disabled mutual exclusion."""
archiver = request.getfixturevalue(archivers)
script = cmd(archiver, "completion", "bash")
# After selecting "ctime,", mtime should not be offered
result = _run_bash_completion_fn(
script, 'COMP_WORDS=(borg create --files-cache "ctime,")\n' "COMP_CWORD=3\n" "_borg_complete_filescachemode\n"
)
assert result.returncode == 0, f"stderr: {result.stderr}"
bare_keys = [line.rsplit(",", 1)[-1] for line in result.stdout.strip().splitlines() if line.strip()]
assert "mtime" not in bare_keys, f"mtime offered after ctime: {bare_keys}"
assert "disabled" not in bare_keys, f"disabled offered after ctime: {bare_keys}"
# After selecting "disabled,", nothing should be offered
result2 = _run_bash_completion_fn(
script,
'COMP_WORDS=(borg create --files-cache "disabled,")\n' "COMP_CWORD=3\n" "_borg_complete_filescachemode\n",
)
assert result2.returncode == 0
assert result2.stdout.strip() == "", f"completions offered after disabled: {result2.stdout}"
# After selecting "size,", disabled should not be offered
result3 = _run_bash_completion_fn(
script, 'COMP_WORDS=(borg create --files-cache "size,")\n' "COMP_CWORD=3\n" "_borg_complete_filescachemode\n"
)
assert result3.returncode == 0
bare_keys3 = [line.rsplit(",", 1)[-1] for line in result3.stdout.strip().splitlines() if line.strip()]
assert "disabled" not in bare_keys3, f"disabled offered after size: {bare_keys3}"
@needs_bash
def test_bash_archive_name_completion(archivers, request):
"""_borg_complete_archive should complete archive names from a real repo."""
archiver = request.getfixturevalue(archivers)
cmd(archiver, "repo-create", RK_ENCRYPTION)
cmd(archiver, "create", "mybackup-2024", archiver.input_path)
cmd(archiver, "create", "mybackup-2025", archiver.input_path)
script = cmd(archiver, "completion", "bash")
repo = archiver.repository_path
result = _run_bash_completion_fn(
script, f'COMP_WORDS=(borg delete --repo "{repo}" "mybackup")\n' f"COMP_CWORD=4\n" f"_borg_complete_archive\n"
)
assert result.returncode == 0, f"stderr: {result.stderr}"
assert "mybackup-2024" in result.stdout, f"archive name missing: {result.stdout}"
assert "mybackup-2025" in result.stdout, f"archive name missing: {result.stdout}"
@needs_bash
def test_bash_archive_aid_completion(archivers, request):
"""_borg_complete_archive should complete aid: prefixed archive IDs."""
archiver = request.getfixturevalue(archivers)
cmd(archiver, "repo-create", RK_ENCRYPTION)
cmd(archiver, "create", "testarchive", archiver.input_path)
script = cmd(archiver, "completion", "bash")
repo = archiver.repository_path
result = _run_bash_completion_fn(
script, f'COMP_WORDS=(borg info --repo "{repo}" "aid:")\n' f"COMP_CWORD=4\n" f"_borg_complete_archive\n"
)
assert result.returncode == 0, f"stderr: {result.stderr}"
lines = [line for line in result.stdout.strip().splitlines() if line.strip()]
assert len(lines) >= 1, "Expected at least one archive ID completion"
for line in lines:
assert line.startswith("aid:"), f"Expected aid: prefix, got: {line}"

View file

@ -6,22 +6,6 @@ import pytest
SHELL_COMPLETIONS_DIR = Path(__file__).parent / ".." / ".." / ".." / "scripts" / "shell_completions"
def test_bash_completion_is_valid():
"""Test that the Bash completion file is valid Bash syntax."""
bash_completion_file = SHELL_COMPLETIONS_DIR / "bash" / "borg"
assert bash_completion_file.is_file()
# Check if Bash is available
try:
subprocess.run(["bash", "--version"], capture_output=True, check=True)
except (subprocess.SubprocessError, FileNotFoundError):
pytest.skip("Bash not available")
# Test whether the Bash completion file can be sourced without errors
result = subprocess.run(["bash", "-n", str(bash_completion_file)], capture_output=True)
assert result.returncode == 0, f"Bash completion file has syntax errors: {result.stderr.decode()}"
def test_fish_completion_is_valid():
"""Test that the Fish completion file is valid Fish syntax."""
fish_completion_file = SHELL_COMPLETIONS_DIR / "fish" / "borg.fish"
@ -36,19 +20,3 @@ def test_fish_completion_is_valid():
# Test whether the Fish completion file can be sourced without errors
result = subprocess.run(["fish", "-c", f"source {str(fish_completion_file)}"], capture_output=True)
assert result.returncode == 0, f"Fish completion file has syntax errors: {result.stderr.decode()}"
def test_zsh_completion_is_valid():
"""Test that the Zsh completion file is valid Zsh syntax."""
zsh_completion_file = SHELL_COMPLETIONS_DIR / "zsh" / "_borg"
assert zsh_completion_file.is_file()
# Check if Zsh is available
try:
subprocess.run(["zsh", "--version"], capture_output=True, check=True)
except (subprocess.SubprocessError, FileNotFoundError):
pytest.skip("Zsh not available")
# Test whether the Zsh completion file can be sourced without errors
result = subprocess.run(["zsh", "-n", str(zsh_completion_file)], capture_output=True)
assert result.returncode == 0, f"Zsh completion file has syntax errors: {result.stderr.decode()}"