Merge pull request #9174 from ThomasWaldmann/shtab-master
Some checks are pending
Lint / lint (push) Waiting to run
CI / lint (push) Waiting to run
CI / security (push) Waiting to run
CI / asan_ubsan (push) Blocked by required conditions
CI / native_tests (push) Blocked by required conditions
CI / vm_tests (Haiku, false, haiku, r1beta5) (push) Blocked by required conditions
CI / vm_tests (NetBSD, false, netbsd, 10.1) (push) Blocked by required conditions
CI / vm_tests (OpenBSD, false, openbsd, 7.7) (push) Blocked by required conditions
CI / vm_tests (borg-freebsd-14-x86_64-gh, FreeBSD, true, freebsd, 14.3) (push) Blocked by required conditions
CI / windows_tests (push) Blocked by required conditions
CodeQL / Analyze (push) Waiting to run

completion: generate completion scripts for supported shells, fixes #9172
This commit is contained in:
TW 2025-11-17 19:26:04 +01:00 committed by GitHub
commit 45fbc2f0e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 363 additions and 0 deletions

View file

@ -0,0 +1,76 @@
.\" Man page generated from reStructuredText.
.
.
.nr rst2man-indent-level 0
.
.de1 rstReportMargin
\\$1 \\n[an-margin]
level \\n[rst2man-indent-level]
level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
-
\\n[rst2man-indent0]
\\n[rst2man-indent1]
\\n[rst2man-indent2]
..
.de1 INDENT
.\" .rstReportMargin pre:
. RS \\$1
. nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin]
. nr rst2man-indent-level +1
.\" .rstReportMargin post:
..
.de UNINDENT
. RE
.\" indent \\n[an-margin]
.\" old: \\n[rst2man-indent\\n[rst2man-indent-level]]
.nr rst2man-indent-level -1
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
..
.TH "BORG-COMPLETION" "1" "2025-11-17" "" "borg backup tool"
.SH NAME
borg-completion \- Output shell completion script for the given shell.
.SH SYNOPSIS
.sp
borg [common options] completion [options] SHELL
.SH DESCRIPTION
.sp
This command prints a shell completion script for the given shell.
.sp
Please note that for some dynamic completions (like archive IDs), the shell
completion script will call borg to query the repository. This will work best
if that call can be made without prompting for user input, so you may want to
set BORG_REPO and BORG_PASSPHRASE environment variables.
.SH OPTIONS
.sp
See \fIborg\-common(1)\fP for common options of Borg commands.
.SS arguments
.INDENT 0.0
.TP
.B SHELL
shell to generate completion for (one of: %(choices)s)
.UNINDENT
.SH EXAMPLES
.sp
To activate completion in your current shell session, evaluate the output
of this command. To enable it persistently, add the corresponding line to
your shell\(aqs startup file.
.INDENT 0.0
.INDENT 3.5
.sp
.EX
# Bash (in ~/.bashrc)
eval \(dq$(borg completion bash)\(dq
# Zsh (in ~/.zshrc)
eval \(dq$(borg completion zsh)\(dq
.EE
.UNINDENT
.UNINDENT
.SH SEE ALSO
.sp
\fIborg\-common(1)\fP
.SH AUTHOR
The Borg Collective
.\" Generated by docutils manpage writer.
.

View file

@ -68,5 +68,6 @@ Usage
usage/benchmark
usage/help
usage/completion
usage/debug
usage/notes

17
docs/usage/completion.rst Normal file
View file

@ -0,0 +1,17 @@
.. include:: completion.rst.inc
Examples
~~~~~~~~
To activate completion in your current shell session, evaluate the output
of this command. To enable it persistently, add the corresponding line to
your shell's startup file.
::
# Bash (in ~/.bashrc)
eval "$(borg completion bash)"
# Zsh (in ~/.zshrc)
eval "$(borg completion zsh)"

View file

@ -0,0 +1,50 @@
.. IMPORTANT: this file is auto-generated from borg's built-in help, do not edit!
.. _borg_completion:
borg completion
---------------
.. code-block:: none
borg [common options] completion [options] SHELL
.. only:: html
.. class:: borg-options-table
+-------------------------------------------------------+-----------+--------------------------------------------------------+
| **positional arguments** |
+-------------------------------------------------------+-----------+--------------------------------------------------------+
| | ``SHELL`` | shell to generate completion for (one of: %(choices)s) |
+-------------------------------------------------------+-----------+--------------------------------------------------------+
| .. class:: borg-common-opt-ref |
| |
| :ref:`common_options` |
+-------------------------------------------------------+-----------+--------------------------------------------------------+
.. raw:: html
<script type='text/javascript'>
$(document).ready(function () {
$('.borg-options-table colgroup').remove();
})
</script>
.. only:: latex
SHELL
shell to generate completion for (one of: %(choices)s)
:ref:`common_options`
|
Description
~~~~~~~~~~~
This command prints a shell completion script for the given shell.
Please note that for some dynamic completions (like archive IDs), the shell
completion script will call borg to query the repository. This will work best
if that call can be made without prompting for user input, so you may want to
set BORG_REPO and BORG_PASSPHRASE environment variables.

View file

@ -37,6 +37,7 @@ dependencies = [
"platformdirs >=3.0.0, <5.0.0; sys_platform == 'darwin'", # for macOS: breaking changes in 3.0.0.
"platformdirs >=2.6.0, <5.0.0; sys_platform != 'darwin'", # for others: 2.6+ works consistently.
"argon2-cffi",
"shtab>=1.7.0",
]
[project.optional-dependencies]

View file

@ -79,6 +79,7 @@ from .analyze_cmd import AnalyzeMixIn
from .benchmark_cmd import BenchmarkMixIn
from .check_cmd import CheckMixIn
from .compact_cmd import CompactMixIn
from .completion_cmd import CompletionMixIn
from .create_cmd import CreateMixIn
from .debug_cmd import DebugMixIn
from .delete_cmd import DeleteMixIn
@ -112,6 +113,7 @@ class Archiver(
BenchmarkMixIn,
CheckMixIn,
CompactMixIn,
CompletionMixIn,
CreateMixIn,
DebugMixIn,
DeleteMixIn,
@ -354,6 +356,7 @@ class Archiver(
self.build_parser_benchmarks(subparsers, common_parser, mid_common_parser)
self.build_parser_check(subparsers, common_parser, mid_common_parser)
self.build_parser_compact(subparsers, common_parser, mid_common_parser)
self.build_parser_completion(subparsers, common_parser, mid_common_parser)
self.build_parser_create(subparsers, common_parser, mid_common_parser)
self.build_parser_debug(subparsers, common_parser, mid_common_parser)
self.build_parser_delete(subparsers, common_parser, mid_common_parser)

View file

@ -0,0 +1,198 @@
import argparse
import shtab
from ._common import process_epilog
from ..constants import * # NOQA
from ..helpers import archivename_validator # used to detect ARCHIVE args for dynamic completion
# Dynamic completion for archive IDs (aid:...)
#
# This integrates with shtab by:
# - tagging argparse actions that accept an ARCHIVE (identified by type == archivename_validator)
# with a .complete mapping pointing to our helper function.
# - using shtab.complete's 'preamble' parameter to inject the helper into the
# generated completion script for supported shells.
#
# Notes / constraints (per plan):
# - Calls `borg repo-list --format ...` and filters results by the typed aid: hex prefix.
# - Non-interactive only. We rely on Borg to fail fast without prompting in non-interactive contexts.
# If it cannot, we simply return no suggestions.
# Name of the helper function inserted into the generated completion script(s)
AID_BASH_FN_NAME = "_borg_complete_aid"
AID_ZSH_FN_NAME = "_borg_complete_aid"
# 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
# - a minimal dynamic completion helper for aid: archive IDs
BASH_PREAMBLE = r"""
# keep ':' and '=' intact so tokens like 'aid:' and '--repo=/path' stay whole
if [[ ${COMP_WORDBREAKS-} == *:* ]]; then COMP_WORDBREAKS=${COMP_WORDBREAKS//:}; fi
if [[ ${COMP_WORDBREAKS-} == *=* ]]; then COMP_WORDBREAKS=${COMP_WORDBREAKS//=}; fi
# Complete aid:<hex-prefix> archive IDs by querying "borg repo-list --short"
# Note: we only suggest the first 8 hex digits (short ID) for completion.
_borg_complete_aid() {
local cur="${COMP_WORDS[COMP_CWORD]}"
[[ "$cur" == aid:* ]] || return 0
local prefix="${cur#aid:}"
[[ -n "$prefix" && ! "$prefix" =~ ^[0-9a-fA-F]*$ ]] && return 0
# derive repo context from words: --repo=V, --repo V, -r=V, -rV, or -r V
local repo_arg=()
local i w
for (( i=0; i<${#COMP_WORDS[@]}; i++ )); do
w="${COMP_WORDS[i]}"
if [[ "$w" == --repo=* ]]; then repo_arg=( --repo "${w#--repo=}" ); break
elif [[ "$w" == -r=* ]]; then repo_arg=( -r "${w#-r=}" ); break
elif [[ "$w" == -r* && "$w" != "-r" ]]; then repo_arg=( -r "${w#-r}" ); break
elif [[ "$w" == "--repo" || "$w" == "-r" ]]; then
if (( i+1 < ${#COMP_WORDS[@]} )); then repo_arg=( "$w" "${COMP_WORDS[i+1]}" ); fi
break
fi
done
# ask borg for raw IDs; avoid prompts and suppress stderr
local out
if [[ -n "${repo_arg[*]}" ]]; then
out=$( borg repo-list "${repo_arg[@]}" --short 2>/dev/null </dev/null )
else
out=$( borg repo-list --short 2>/dev/null </dev/null )
fi
[[ -z "$out" ]] && return 0
# filter by (case-insensitive) hex prefix and emit candidates
local IFS=$'\n' id prelower idlower
prelower="$(printf '%s' "$prefix" | tr '[:upper:]' '[:lower:]')"
while IFS= read -r id; do
[[ -z "$id" ]] && continue
idlower="$(printf '%s' "$id" | tr '[:upper:]' '[:lower:]')"
# Print only the first 8 hex digits of the ID for completion suggestions.
[[ "$idlower" == "$prelower"* ]] && printf 'aid:%s\n' "${id:0:8}"
done <<< "$out"
return 0
}
"""
# Global zsh preamble providing dynamic completion for aid:<hex> archive IDs.
#
# Notes:
# - We use zsh's $words/$CURRENT arrays to inspect the command line.
# - Candidates are returned via `compadd`.
# - We try to detect repo context from --repo=V, --repo V, -r=V, -rV, -r V.
ZSH_PREAMBLE = r"""
# Complete aid:<hex-prefix> archive IDs by querying "borg repo-list --short"
# Note: we only suggest the first 8 hex digits (short ID) for completion.
_borg_complete_aid() {
local cur
cur="${words[$CURRENT]}"
[[ "$cur" == aid:* ]] || return 0
local prefix="${cur#aid:}"
# allow only hex digits as prefix; empty prefix also allowed (list all)
[[ -n "$prefix" && ! "$prefix" == [0-9a-fA-F]# ]] && return 0
# derive repo context from words: --repo=V, --repo V, -r=V, -rV, or -r V
local -a repo_arg=()
local i w
for i in {1..$#words}; do
w="$words[$i]"
if [[ "$w" == --repo=* ]]; then repo_arg=( --repo "${w#--repo=}" ); break
elif [[ "$w" == -r=* ]]; then repo_arg=( -r "${w#-r=}" ); break
elif [[ "$w" == -r* && "$w" != "-r" ]]; then repo_arg=( -r "${w#-r}" ); break
elif [[ "$w" == "--repo" || "$w" == "-r" ]]; then
if (( i+1 <= $#words )); then repo_arg=( "$w" "${words[$((i+1))]}" ); fi
break
fi
done
# ask borg for raw IDs; avoid prompts and suppress stderr
local out
if (( ${#repo_arg[@]} > 0 )); then
out=$( borg repo-list "${repo_arg[@]}" --short 2>/dev/null </dev/null )
else
out=$( borg repo-list --short 2>/dev/null </dev/null )
fi
[[ -z "$out" ]] && return 0
# filter by (case-insensitive) hex prefix and emit candidates
local prelower id idlower
prelower="${prefix:l}"
local -a candidates=()
for id in ${(f)out}; do
[[ -z "$id" ]] && continue
idlower="${id:l}"
if [[ "$idlower" == "$prelower"* ]]; then
candidates+=( "aid:${id[1,8]}" )
fi
done
# -Q: do not escape special chars, so ':' remains as-is
compadd -Q -- $candidates
return 0
}
"""
def _attach_aid_completion(parser: argparse.ArgumentParser):
"""Tag all arguments that accept an ARCHIVE with aid:-completion.
We detect ARCHIVE arguments by their type being archivename_validator.
This function mutates the parser actions to add a .complete mapping used by shtab.
"""
for action in parser._actions:
# Recurse into subparsers
if isinstance(action, argparse._SubParsersAction):
for sub in action.choices.values():
_attach_aid_completion(sub)
continue
# Assign dynamic completion only for arguments that take an archive name.
if action.type is archivename_validator:
action.complete = {"bash": AID_BASH_FN_NAME, "zsh": AID_ZSH_FN_NAME} # type: ignore[attr-defined]
class CompletionMixIn:
def do_completion(self, args):
"""Output shell completion script for the given shell."""
# Automagically generates completions for subcommands and options. Also
# adds dynamic completion for archive IDs with the aid: prefix for all ARCHIVE
# arguments (identified by archivename_validator). It reuses `borg repo-list`
# to enumerate archives and does not introduce any new commands or caching.
parser = self.build_parser()
_attach_aid_completion(parser)
preamble = {"bash": BASH_PREAMBLE, "zsh": ZSH_PREAMBLE}
script = shtab.complete(parser, shell=args.shell, preamble=preamble) # nosec B604
print(script)
def build_parser_completion(self, subparsers, common_parser, mid_common_parser):
shells = tuple(shtab.SUPPORTED_SHELLS)
completion_epilog = process_epilog(
"""
This command prints a shell completion script for the given shell.
Please note that for some dynamic completions (like archive IDs), the shell
completion script will call borg to query the repository. This will work best
if that call can be made without prompting for user input, so you may want to
set BORG_REPO and BORG_PASSPHRASE environment variables.
"""
)
subparser = subparsers.add_parser(
"completion",
parents=[common_parser],
add_help=False,
description=self.do_completion.__doc__,
epilog=completion_epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
help="output shell completion script",
)
subparser.set_defaults(func=self.do_completion)
subparser.add_argument(
"shell", metavar="SHELL", choices=shells, help="shell to generate completion for (one of: %(choices)s)"
)

View file

@ -0,0 +1,17 @@
from . import cmd, generate_archiver_tests
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."""
archiver = request.getfixturevalue(archivers)
output = cmd(archiver, "completion", "bash")
assert "_borg_complete_aid() {" in output
def test_zsh_completion(archivers, request):
"""Ensure the generated Zsh completion includes our helper."""
archiver = request.getfixturevalue(archivers)
output = cmd(archiver, "completion", "zsh")
assert "_borg_complete_aid() {" in output