From 8646c4e045cd2a3ecfbf77d4df23c69451dfeefb Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 18 Nov 2025 17:58:41 +0100 Subject: [PATCH 01/20] shtab: require >= 1.8.0 in 1.7.x, zsh completions were still broken, 1.8.0 fixed that. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2a585cc7a..06b04c13f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +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", + "shtab>=1.8.0", ] [project.optional-dependencies] From e376d8910c03d3205a409cfc2bbad5e29174f96c Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 18 Nov 2025 21:36:36 +0100 Subject: [PATCH 02/20] completion: support SortBySpec type options --- src/borg/archiver/completion_cmd.py | 123 +++++++++++++++++- .../testsuite/archiver/completion_cmd_test.py | 2 + 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 949c79105..3bf1a561b 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -4,7 +4,8 @@ import shtab from ._common import process_epilog from ..constants import * # NOQA -from ..helpers import archivename_validator # used to detect ARCHIVE args for dynamic completion +from ..helpers import archivename_validator, SortBySpec # used to detect ARCHIVE args for dynamic completion +from ..manifest import AI_HUMAN_SORT_KEYS # Dynamic completion for archive IDs (aid:...) # @@ -23,11 +24,15 @@ from ..helpers import archivename_validator # used to detect ARCHIVE args for d AID_BASH_FN_NAME = "_borg_complete_aid" AID_ZSH_FN_NAME = "_borg_complete_aid" +# Name of the helper function inserted for completing SortBySpec options +SORTBY_BASH_FN_NAME = "_borg_complete_sortby" +SORTBY_ZSH_FN_NAME = "_borg_complete_sortby" + # 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""" +BASH_PREAMBLE_TMPL = 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 @@ -75,6 +80,52 @@ _borg_complete_aid() { done <<< "$out" return 0 } + +# Complete comma-separated sort keys for any option with type=SortBySpec. +# Keys are validated against Borg's AI_HUMAN_SORT_KEYS. +_borg_complete_sortby() { + local cur="${COMP_WORDS[COMP_CWORD]}" + + # Extract value part for --opt=value forms; otherwise the value is the word itself + local val prefix_eq + if [[ "$cur" == *=* ]]; then + prefix_eq="${cur%%=*}=" + val="${cur#*=}" + else + prefix_eq="" + val="$cur" + fi + + # Split into head (selected keys + trailing comma if any) and fragment (last token being typed) + local head frag + if [[ "$val" == *,* ]]; then + head="${val%,*}," + frag="${val##*,}" + else + head="" + frag="$val" + fi + + # Build a comma-delimited list for cheap membership testing + local headlist + if [[ -n "$head" ]]; then + headlist=",${head%,}," + else + headlist="," # nothing selected yet + fi + + # Valid keys (embedded at generation time) + local keys=(___SORT_KEYS___) + + local k + for k in "${keys[@]}"; do + # skip already-selected keys + [[ "$headlist" == *",${k},"* ]] && continue + # match prefix of last fragment + [[ -n "$frag" && "$k" != "$frag"* ]] && continue + printf '%s\n' "${prefix_eq}${head}${k}" + done +} """ @@ -84,7 +135,7 @@ _borg_complete_aid() { # - 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""" +ZSH_PREAMBLE_TMPL = r""" # Complete aid: archive IDs by querying "borg repo-list --short" # Note: we only suggest the first 8 hex digits (short ID) for completion. _borg_complete_aid() { @@ -134,6 +185,50 @@ _borg_complete_aid() { compadd -Q -- $candidates return 0 } + +# Complete comma-separated sort keys for any option with type=SortBySpec. +_borg_complete_sortby() { + local cur + cur="${words[$CURRENT]}" + + local val prefix_eq + if [[ "$cur" == *"="* ]]; then + prefix_eq="${cur%%\=*}=" + val="${cur#*=}" + else + prefix_eq="" + val="$cur" + fi + + local head frag + if [[ "$val" == *","* ]]; then + head="${val%,*}," + frag="${val##*,}" + else + head="" + frag="$val" + fi + + local headlist + if [[ -n "$head" ]]; then + headlist=",${head%,}," + else + headlist="," # nothing selected yet + fi + + # Valid keys (embedded at generation time) + local -a keys=(___SORT_KEYS___) + + local -a candidates=() + local k + for k in ${keys[@]}; do + [[ "$headlist" == *",${k},"* ]] && continue + [[ -n "$frag" && "$k" != "$frag"* ]] && continue + candidates+=( "${prefix_eq}${head}${k}" ) + done + compadd -Q -- $candidates + return 0 +} """ @@ -156,6 +251,20 @@ def _attach_aid_completion(parser: argparse.ArgumentParser): action.complete = {"bash": AID_BASH_FN_NAME, "zsh": AID_ZSH_FN_NAME} # type: ignore[attr-defined] +def _attach_sortby_completion(parser: argparse.ArgumentParser): + """Tag all arguments with type SortBySpec with sort-key completion.""" + + for action in parser._actions: + # Recurse into subparsers + if isinstance(action, argparse._SubParsersAction): + for sub in action.choices.values(): + _attach_sortby_completion(sub) + continue + + if action.type is SortBySpec: + action.complete = {"bash": SORTBY_BASH_FN_NAME, "zsh": SORTBY_ZSH_FN_NAME} # type: ignore[attr-defined] + + class CompletionMixIn: def do_completion(self, args): """Output shell completion script for the given shell.""" @@ -165,7 +274,13 @@ class CompletionMixIn: # 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} + _attach_sortby_completion(parser) + + # Build preambles with embedded SortBy keys + sort_keys = " ".join(AI_HUMAN_SORT_KEYS) + bash_preamble = BASH_PREAMBLE_TMPL.replace("___SORT_KEYS___", sort_keys) + zsh_preamble = ZSH_PREAMBLE_TMPL.replace("___SORT_KEYS___", sort_keys) + preamble = {"bash": bash_preamble, "zsh": zsh_preamble} script = shtab.complete(parser, shell=args.shell, preamble=preamble) # nosec B604 print(script) diff --git a/src/borg/testsuite/archiver/completion_cmd_test.py b/src/borg/testsuite/archiver/completion_cmd_test.py index 32d646539..49e0e5e83 100644 --- a/src/borg/testsuite/archiver/completion_cmd_test.py +++ b/src/borg/testsuite/archiver/completion_cmd_test.py @@ -8,6 +8,7 @@ def test_bash_completion(archivers, request): archiver = request.getfixturevalue(archivers) output = cmd(archiver, "completion", "bash") assert "_borg_complete_aid() {" in output + assert "_borg_complete_sortby() {" in output def test_zsh_completion(archivers, request): @@ -15,3 +16,4 @@ def test_zsh_completion(archivers, request): archiver = request.getfixturevalue(archivers) output = cmd(archiver, "completion", "zsh") assert "_borg_complete_aid() {" in output + assert "_borg_complete_sortby() {" in output From 3aa7f53b097b8c3c321c9bcc5032d92487088743 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 18 Nov 2025 21:47:06 +0100 Subject: [PATCH 03/20] completion: support FilesCacheMode type options --- src/borg/archiver/completion_cmd.py | 145 +++++++++++++++++- .../testsuite/archiver/completion_cmd_test.py | 2 + 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 3bf1a561b..59b4ddda5 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -4,7 +4,7 @@ import shtab from ._common import process_epilog from ..constants import * # NOQA -from ..helpers import archivename_validator, SortBySpec # used to detect ARCHIVE args for dynamic completion +from ..helpers import archivename_validator, SortBySpec, FilesCacheMode from ..manifest import AI_HUMAN_SORT_KEYS # Dynamic completion for archive IDs (aid:...) @@ -28,6 +28,10 @@ AID_ZSH_FN_NAME = "_borg_complete_aid" SORTBY_BASH_FN_NAME = "_borg_complete_sortby" SORTBY_ZSH_FN_NAME = "_borg_complete_sortby" +# Name of the helper function inserted for completing FilesCacheMode options +FCM_BASH_FN_NAME = "_borg_complete_filescachemode" +FCM_ZSH_FN_NAME = "_borg_complete_filescachemode" + # 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 @@ -126,6 +130,67 @@ _borg_complete_sortby() { printf '%s\n' "${prefix_eq}${head}${k}" done } + +# Complete comma-separated files cache mode tokens for options with type=FilesCacheMode. +_borg_complete_filescachemode() { + local cur="${COMP_WORDS[COMP_CWORD]}" + + # Extract value part for --opt=value forms; otherwise the value is the word itself + local val prefix_eq + if [[ "$cur" == *=* ]]; then + prefix_eq="${cur%%=*}=" + val="${cur#*=}" + else + prefix_eq="" + val="$cur" + fi + + # Split into head (selected keys + trailing comma if any) and fragment (last token being typed) + local head frag + if [[ "$val" == *,* ]]; then + head="${val%,*}," + frag="${val##*,}" + else + head="" + frag="$val" + fi + + # Build a comma-delimited list for cheap membership testing + local headlist + if [[ -n "$head" ]]; then + headlist=",${head%,}," + else + headlist="," # nothing selected yet + fi + + # Valid tokens (embedded at generation time) + local keys=(___FCM_KEYS___) + + # If 'disabled' is already selected, there is nothing else to suggest. + if [[ "$headlist" == *",disabled,"* ]]; then + return 0 + fi + + local k + for k in "${keys[@]}"; do + # skip duplicates + [[ "$headlist" == *",${k},"* ]] && continue + # do not suggest 'disabled' if any other token is already selected + if [[ -n "$head" && "$k" == "disabled" ]]; then + continue + fi + # ctime/mtime are mutually exclusive: don't suggest the other if one is present + if [[ "$k" == "ctime" && "$headlist" == *",mtime,"* ]]; then + continue + fi + if [[ "$k" == "mtime" && "$headlist" == *",ctime,"* ]]; then + continue + fi + # match prefix of last fragment + [[ -n "$frag" && "$k" != "$frag"* ]] && continue + printf '%s\n' "${prefix_eq}${head}${k}" + done +} """ @@ -229,6 +294,64 @@ _borg_complete_sortby() { compadd -Q -- $candidates return 0 } + +# Complete comma-separated files cache mode tokens for options with type=FilesCacheMode. +_borg_complete_filescachemode() { + local cur + cur="${words[$CURRENT]}" + + local val prefix_eq + if [[ "$cur" == *"="* ]]; then + prefix_eq="${cur%%\=*}=" + val="${cur#*=}" + else + prefix_eq="" + val="$cur" + fi + + local head frag + if [[ "$val" == *","* ]]; then + head="${val%,*}," + frag="${val##*,}" + else + head="" + frag="$val" + fi + + local headlist + if [[ -n "$head" ]]; then + headlist=",${head%,}," + else + headlist="," # nothing selected yet + fi + + # Valid tokens (embedded at generation time) + local -a keys=(___FCM_KEYS___) + + # If 'disabled' is already selected, there is nothing else to suggest. + if [[ "$headlist" == *",disabled,"* ]]; then + return 0 + fi + + local -a candidates=() + local k + for k in ${keys[@]}; do + [[ "$headlist" == *",${k},"* ]] && continue + if [[ -n "$head" && "$k" == "disabled" ]]; then + continue + fi + if [[ "$k" == "ctime" && "$headlist" == *",mtime,"* ]]; then + continue + fi + if [[ "$k" == "mtime" && "$headlist" == *",ctime,"* ]]; then + continue + fi + [[ -n "$frag" && "$k" != "$frag"* ]] && continue + candidates+=( "${prefix_eq}${head}${k}" ) + done + compadd -Q -- $candidates + return 0 +} """ @@ -265,6 +388,20 @@ def _attach_sortby_completion(parser: argparse.ArgumentParser): action.complete = {"bash": SORTBY_BASH_FN_NAME, "zsh": SORTBY_ZSH_FN_NAME} # type: ignore[attr-defined] +def _attach_filescachemode_completion(parser: argparse.ArgumentParser): + """Tag all arguments with type FilesCacheMode with files-cache-mode completion.""" + + for action in parser._actions: + # Recurse into subparsers + if isinstance(action, argparse._SubParsersAction): + for sub in action.choices.values(): + _attach_filescachemode_completion(sub) + continue + + if action.type is FilesCacheMode: + action.complete = {"bash": FCM_BASH_FN_NAME, "zsh": FCM_ZSH_FN_NAME} # type: ignore[attr-defined] + + class CompletionMixIn: def do_completion(self, args): """Output shell completion script for the given shell.""" @@ -275,11 +412,13 @@ class CompletionMixIn: parser = self.build_parser() _attach_aid_completion(parser) _attach_sortby_completion(parser) + _attach_filescachemode_completion(parser) # Build preambles with embedded SortBy keys sort_keys = " ".join(AI_HUMAN_SORT_KEYS) - bash_preamble = BASH_PREAMBLE_TMPL.replace("___SORT_KEYS___", sort_keys) - zsh_preamble = ZSH_PREAMBLE_TMPL.replace("___SORT_KEYS___", sort_keys) + fcm_keys = " ".join(["ctime", "mtime", "size", "inode", "rechunk", "disabled"]) # keep in sync with parser + bash_preamble = BASH_PREAMBLE_TMPL.replace("___SORT_KEYS___", sort_keys).replace("___FCM_KEYS___", fcm_keys) + zsh_preamble = ZSH_PREAMBLE_TMPL.replace("___SORT_KEYS___", sort_keys).replace("___FCM_KEYS___", fcm_keys) preamble = {"bash": bash_preamble, "zsh": zsh_preamble} script = shtab.complete(parser, shell=args.shell, preamble=preamble) # nosec B604 print(script) diff --git a/src/borg/testsuite/archiver/completion_cmd_test.py b/src/borg/testsuite/archiver/completion_cmd_test.py index 49e0e5e83..2afbb362d 100644 --- a/src/borg/testsuite/archiver/completion_cmd_test.py +++ b/src/borg/testsuite/archiver/completion_cmd_test.py @@ -9,6 +9,7 @@ def test_bash_completion(archivers, request): output = cmd(archiver, "completion", "bash") assert "_borg_complete_aid() {" in output assert "_borg_complete_sortby() {" in output + assert "_borg_complete_filescachemode() {" in output def test_zsh_completion(archivers, request): @@ -17,3 +18,4 @@ def test_zsh_completion(archivers, request): output = cmd(archiver, "completion", "zsh") assert "_borg_complete_aid() {" in output assert "_borg_complete_sortby() {" in output + assert "_borg_complete_filescachemode() {" in output From 4dddc43b852b6503c7ea40a779a4236466814c4e Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 18 Nov 2025 21:56:47 +0100 Subject: [PATCH 04/20] completion: add support for "borg transfer --upgrader" --- src/borg/archiver/transfer_cmd.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/borg/archiver/transfer_cmd.py b/src/borg/archiver/transfer_cmd.py index 8ae6d3bd9..1ebc496cc 100644 --- a/src/borg/archiver/transfer_cmd.py +++ b/src/borg/archiver/transfer_cmd.py @@ -349,6 +349,7 @@ class TransferMixIn: metavar="UPGRADER", dest="upgrader", type=str, + choices=("NoOp", "From12To20"), default="NoOp", action=Highlander, help="use the upgrader to convert transferred data (default: no conversion)", From 7e5651ad8b003f99507d7993e5a9ccace084f842 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 18 Nov 2025 22:15:17 +0100 Subject: [PATCH 05/20] completion: use partial_format --- src/borg/archiver/completion_cmd.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 59b4ddda5..5b3324aa0 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -5,6 +5,7 @@ import shtab from ._common import process_epilog from ..constants import * # NOQA from ..helpers import archivename_validator, SortBySpec, FilesCacheMode +from ..helpers.parseformat import partial_format from ..manifest import AI_HUMAN_SORT_KEYS # Dynamic completion for archive IDs (aid:...) @@ -119,7 +120,7 @@ _borg_complete_sortby() { fi # Valid keys (embedded at generation time) - local keys=(___SORT_KEYS___) + local keys=({SORT_KEYS}) local k for k in "${keys[@]}"; do @@ -164,7 +165,7 @@ _borg_complete_filescachemode() { fi # Valid tokens (embedded at generation time) - local keys=(___FCM_KEYS___) + local keys=({FCM_KEYS}) # If 'disabled' is already selected, there is nothing else to suggest. if [[ "$headlist" == *",disabled,"* ]]; then @@ -282,7 +283,7 @@ _borg_complete_sortby() { fi # Valid keys (embedded at generation time) - local -a keys=(___SORT_KEYS___) + local -a keys=({SORT_KEYS}) local -a candidates=() local k @@ -326,7 +327,7 @@ _borg_complete_filescachemode() { fi # Valid tokens (embedded at generation time) - local -a keys=(___FCM_KEYS___) + local -a keys=({FCM_KEYS}) # If 'disabled' is already selected, there is nothing else to suggest. if [[ "$headlist" == *",disabled,"* ]]; then @@ -414,11 +415,12 @@ class CompletionMixIn: _attach_sortby_completion(parser) _attach_filescachemode_completion(parser) - # Build preambles with embedded SortBy keys + # Build preambles using partial_format to avoid escaping braces etc. sort_keys = " ".join(AI_HUMAN_SORT_KEYS) fcm_keys = " ".join(["ctime", "mtime", "size", "inode", "rechunk", "disabled"]) # keep in sync with parser - bash_preamble = BASH_PREAMBLE_TMPL.replace("___SORT_KEYS___", sort_keys).replace("___FCM_KEYS___", fcm_keys) - zsh_preamble = ZSH_PREAMBLE_TMPL.replace("___SORT_KEYS___", sort_keys).replace("___FCM_KEYS___", fcm_keys) + mapping = {"SORT_KEYS": sort_keys, "FCM_KEYS": fcm_keys} + bash_preamble = partial_format(BASH_PREAMBLE_TMPL, mapping) + zsh_preamble = partial_format(ZSH_PREAMBLE_TMPL, mapping) preamble = {"bash": bash_preamble, "zsh": zsh_preamble} script = shtab.complete(parser, shell=args.shell, preamble=preamble) # nosec B604 print(script) From 03b0cf0b9b6a7504ccbd3032d2d25d3b7f182701 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 19 Nov 2025 01:46:35 +0100 Subject: [PATCH 06/20] completion: add support for borg help --- src/borg/archiver/completion_cmd.py | 42 ++++++++++++++++++++++++++++- src/borg/archiver/help_cmd.py | 4 ++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 5b3324aa0..dbc80bc3e 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -192,6 +192,12 @@ _borg_complete_filescachemode() { printf '%s\n' "${prefix_eq}${head}${k}" done } + +_borg_help_topics() { + local choices="{HELP_CHOICES}" + local IFS=$' \t\n' + compgen -W "${choices}" -- "$1" +} """ @@ -353,6 +359,11 @@ _borg_complete_filescachemode() { compadd -Q -- $candidates return 0 } + +_borg_help_topics() { + local choices=({HELP_CHOICES}) + _describe 'help topics' choices +} """ @@ -403,6 +414,18 @@ def _attach_filescachemode_completion(parser: argparse.ArgumentParser): action.complete = {"bash": FCM_BASH_FN_NAME, "zsh": FCM_ZSH_FN_NAME} # type: ignore[attr-defined] +def _attach_help_completion(parser: argparse.ArgumentParser, completion_dict: dict): + """Tag the 'topic' argument of the 'help' command with static completion choices.""" + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + for sub in action.choices.values(): + _attach_help_completion(sub, completion_dict) + continue + + if action.dest == "topic": + action.complete = completion_dict # type: ignore[attr-defined] + + class CompletionMixIn: def do_completion(self, args): """Output shell completion script for the given shell.""" @@ -415,10 +438,27 @@ class CompletionMixIn: _attach_sortby_completion(parser) _attach_filescachemode_completion(parser) + # Collect all commands and help topics for "borg help" completion + help_choices = list(self.helptext.keys()) + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + help_choices.extend(action.choices.keys()) + + help_completion_fn = "_borg_help_topics" + _attach_help_completion(parser, {"bash": help_completion_fn, "zsh": help_completion_fn}) + # Build preambles using partial_format to avoid escaping braces etc. sort_keys = " ".join(AI_HUMAN_SORT_KEYS) fcm_keys = " ".join(["ctime", "mtime", "size", "inode", "rechunk", "disabled"]) # keep in sync with parser - mapping = {"SORT_KEYS": sort_keys, "FCM_KEYS": fcm_keys} + + # Help completion templates + help_choices = " ".join(sorted(help_choices)) + + mapping = { + "SORT_KEYS": sort_keys, + "FCM_KEYS": fcm_keys, + "HELP_CHOICES": help_choices, + } bash_preamble = partial_format(BASH_PREAMBLE_TMPL, mapping) zsh_preamble = partial_format(ZSH_PREAMBLE_TMPL, mapping) preamble = {"bash": bash_preamble, "zsh": zsh_preamble} diff --git a/src/borg/archiver/help_cmd.py b/src/borg/archiver/help_cmd.py index b07089433..40237aed1 100644 --- a/src/borg/archiver/help_cmd.py +++ b/src/borg/archiver/help_cmd.py @@ -536,7 +536,9 @@ class HelpMixIn: do_maincommand_help = do_subcommand_help def build_parser_help(self, subparsers, common_parser, mid_common_parser, parser): - subparser = subparsers.add_parser("help", parents=[common_parser], add_help=False, description="Extra help") + subparser = subparsers.add_parser( + "help", parents=[common_parser], add_help=False, description="Extra help", help="Extra help" + ) subparser.add_argument("--epilog-only", dest="epilog_only", action="store_true") subparser.add_argument("--usage-only", dest="usage_only", action="store_true") subparser.set_defaults(func=functools.partial(self.do_help, parser, subparsers.choices)) From 562a08dda1c86af6795cc7cb9435c7eba18f52b7 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 19 Nov 2025 02:22:07 +0100 Subject: [PATCH 07/20] completion: support CompressionSpec --- src/borg/archiver/completion_cmd.py | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index dbc80bc3e..4765eb3a9 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -5,6 +5,7 @@ import shtab from ._common import process_epilog from ..constants import * # NOQA from ..helpers import archivename_validator, SortBySpec, FilesCacheMode +from ..compress import CompressionSpec from ..helpers.parseformat import partial_format from ..manifest import AI_HUMAN_SORT_KEYS @@ -33,6 +34,10 @@ SORTBY_ZSH_FN_NAME = "_borg_complete_sortby" FCM_BASH_FN_NAME = "_borg_complete_filescachemode" FCM_ZSH_FN_NAME = "_borg_complete_filescachemode" +# Name of the helper function inserted for completing CompressionSpec options +COMP_SPEC_BASH_FN_NAME = "_borg_complete_compression_spec" +COMP_SPEC_ZSH_FN_NAME = "_borg_complete_compression_spec" + # 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 @@ -86,6 +91,13 @@ _borg_complete_aid() { return 0 } +# Complete compression spec options +_borg_complete_compression_spec() { + local choices="{COMP_SPEC_CHOICES}" + local IFS=$' \t\n' + compgen -W "${choices}" -- "$1" +} + # Complete comma-separated sort keys for any option with type=SortBySpec. # Keys are validated against Borg's AI_HUMAN_SORT_KEYS. _borg_complete_sortby() { @@ -258,6 +270,13 @@ _borg_complete_aid() { return 0 } +# Complete compression spec options +_borg_complete_compression_spec() { + local choices=({COMP_SPEC_CHOICES}) + # use compadd -V to preserve order (do not sort) + compadd -V 'compression algorithms' -Q -a choices +} + # Complete comma-separated sort keys for any option with type=SortBySpec. _borg_complete_sortby() { local cur @@ -414,6 +433,20 @@ def _attach_filescachemode_completion(parser: argparse.ArgumentParser): action.complete = {"bash": FCM_BASH_FN_NAME, "zsh": FCM_ZSH_FN_NAME} # type: ignore[attr-defined] +def _attach_compression_spec_completion(parser: argparse.ArgumentParser): + """Tag all arguments with type CompressionSpec with compression-spec completion.""" + + for action in parser._actions: + # Recurse into subparsers + if isinstance(action, argparse._SubParsersAction): + for sub in action.choices.values(): + _attach_compression_spec_completion(sub) + continue + + if action.type is CompressionSpec: + action.complete = {"bash": COMP_SPEC_BASH_FN_NAME, "zsh": COMP_SPEC_ZSH_FN_NAME} # type: ignore[attr-defined] + + def _attach_help_completion(parser: argparse.ArgumentParser, completion_dict: dict): """Tag the 'topic' argument of the 'help' command with static completion choices.""" for action in parser._actions: @@ -437,6 +470,7 @@ class CompletionMixIn: _attach_aid_completion(parser) _attach_sortby_completion(parser) _attach_filescachemode_completion(parser) + _attach_compression_spec_completion(parser) # Collect all commands and help topics for "borg help" completion help_choices = list(self.helptext.keys()) @@ -454,9 +488,22 @@ class CompletionMixIn: # Help completion templates help_choices = " ".join(sorted(help_choices)) + # Compression spec choices (static list) + comp_spec_choices = [ + "lz4", + "zstd,3", + "auto,zstd,10", + "zlib,6", + "lzma,6", + "obfuscate,250,lz4", + "none", + ] + comp_spec_choices_str = " ".join(comp_spec_choices) + mapping = { "SORT_KEYS": sort_keys, "FCM_KEYS": fcm_keys, + "COMP_SPEC_CHOICES": comp_spec_choices_str, "HELP_CHOICES": help_choices, } bash_preamble = partial_format(BASH_PREAMBLE_TMPL, mapping) From 9e1d71d517c4bd48e5847eb637b846d0ccb22f20 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 19 Nov 2025 02:32:26 +0100 Subject: [PATCH 08/20] completion: consolidate funcs into generic _attach_completion --- src/borg/archiver/completion_cmd.py | 67 +++++------------------------ 1 file changed, 11 insertions(+), 56 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 4765eb3a9..effb7e73a 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -386,65 +386,18 @@ _borg_help_topics() { """ -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. - """ +def _attach_completion(parser: argparse.ArgumentParser, type_class, completion_dict: dict): + """Tag all arguments with type `type_class` with completion choices from `completion_dict`.""" for action in parser._actions: # Recurse into subparsers if isinstance(action, argparse._SubParsersAction): for sub in action.choices.values(): - _attach_aid_completion(sub) + _attach_completion(sub, type_class, completion_dict) 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] - - -def _attach_sortby_completion(parser: argparse.ArgumentParser): - """Tag all arguments with type SortBySpec with sort-key completion.""" - - for action in parser._actions: - # Recurse into subparsers - if isinstance(action, argparse._SubParsersAction): - for sub in action.choices.values(): - _attach_sortby_completion(sub) - continue - - if action.type is SortBySpec: - action.complete = {"bash": SORTBY_BASH_FN_NAME, "zsh": SORTBY_ZSH_FN_NAME} # type: ignore[attr-defined] - - -def _attach_filescachemode_completion(parser: argparse.ArgumentParser): - """Tag all arguments with type FilesCacheMode with files-cache-mode completion.""" - - for action in parser._actions: - # Recurse into subparsers - if isinstance(action, argparse._SubParsersAction): - for sub in action.choices.values(): - _attach_filescachemode_completion(sub) - continue - - if action.type is FilesCacheMode: - action.complete = {"bash": FCM_BASH_FN_NAME, "zsh": FCM_ZSH_FN_NAME} # type: ignore[attr-defined] - - -def _attach_compression_spec_completion(parser: argparse.ArgumentParser): - """Tag all arguments with type CompressionSpec with compression-spec completion.""" - - for action in parser._actions: - # Recurse into subparsers - if isinstance(action, argparse._SubParsersAction): - for sub in action.choices.values(): - _attach_compression_spec_completion(sub) - continue - - if action.type is CompressionSpec: - action.complete = {"bash": COMP_SPEC_BASH_FN_NAME, "zsh": COMP_SPEC_ZSH_FN_NAME} # type: ignore[attr-defined] + if action.type is type_class: + action.complete = completion_dict # type: ignore[attr-defined] def _attach_help_completion(parser: argparse.ArgumentParser, completion_dict: dict): @@ -467,10 +420,12 @@ class CompletionMixIn: # 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) - _attach_sortby_completion(parser) - _attach_filescachemode_completion(parser) - _attach_compression_spec_completion(parser) + _attach_completion(parser, archivename_validator, {"bash": AID_BASH_FN_NAME, "zsh": AID_ZSH_FN_NAME}) + _attach_completion(parser, SortBySpec, {"bash": SORTBY_BASH_FN_NAME, "zsh": SORTBY_ZSH_FN_NAME}) + _attach_completion(parser, FilesCacheMode, {"bash": FCM_BASH_FN_NAME, "zsh": FCM_ZSH_FN_NAME}) + _attach_completion( + parser, CompressionSpec, {"bash": COMP_SPEC_BASH_FN_NAME, "zsh": COMP_SPEC_ZSH_FN_NAME} + ) # Collect all commands and help topics for "borg help" completion help_choices = list(self.helptext.keys()) From 7bfc1bea2e0bd85d2cdefb7ea866be6643da3d21 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 19 Nov 2025 02:36:47 +0100 Subject: [PATCH 09/20] completion: remove _FN_NAME constants --- src/borg/archiver/completion_cmd.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index effb7e73a..6f353cce8 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -22,21 +22,7 @@ from ..manifest import AI_HUMAN_SORT_KEYS # - 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" -# Name of the helper function inserted for completing SortBySpec options -SORTBY_BASH_FN_NAME = "_borg_complete_sortby" -SORTBY_ZSH_FN_NAME = "_borg_complete_sortby" - -# Name of the helper function inserted for completing FilesCacheMode options -FCM_BASH_FN_NAME = "_borg_complete_filescachemode" -FCM_ZSH_FN_NAME = "_borg_complete_filescachemode" - -# Name of the helper function inserted for completing CompressionSpec options -COMP_SPEC_BASH_FN_NAME = "_borg_complete_compression_spec" -COMP_SPEC_ZSH_FN_NAME = "_borg_complete_compression_spec" # Global bash preamble that is prepended to the generated completion script. # It aggregates only what we need: @@ -420,11 +406,17 @@ class CompletionMixIn: # 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_completion(parser, archivename_validator, {"bash": AID_BASH_FN_NAME, "zsh": AID_ZSH_FN_NAME}) - _attach_completion(parser, SortBySpec, {"bash": SORTBY_BASH_FN_NAME, "zsh": SORTBY_ZSH_FN_NAME}) - _attach_completion(parser, FilesCacheMode, {"bash": FCM_BASH_FN_NAME, "zsh": FCM_ZSH_FN_NAME}) _attach_completion( - parser, CompressionSpec, {"bash": COMP_SPEC_BASH_FN_NAME, "zsh": COMP_SPEC_ZSH_FN_NAME} + parser, archivename_validator, {"bash": "_borg_complete_aid", "zsh": "_borg_complete_aid"} + ) + _attach_completion(parser, SortBySpec, {"bash": "_borg_complete_sortby", "zsh": "_borg_complete_sortby"}) + _attach_completion( + parser, FilesCacheMode, {"bash": "_borg_complete_filescachemode", "zsh": "_borg_complete_filescachemode"} + ) + _attach_completion( + parser, + CompressionSpec, + {"bash": "_borg_complete_compression_spec", "zsh": "_borg_complete_compression_spec"}, ) # Collect all commands and help topics for "borg help" completion From 0a0e4e18fd7826e42ee980a4c6b0010d3959eabb Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 19 Nov 2025 02:59:34 +0100 Subject: [PATCH 10/20] completion: support PathSpec --- src/borg/archiver/completion_cmd.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 6f353cce8..408c61bab 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -4,7 +4,7 @@ import shtab from ._common import process_epilog from ..constants import * # NOQA -from ..helpers import archivename_validator, SortBySpec, FilesCacheMode +from ..helpers import archivename_validator, SortBySpec, FilesCacheMode, PathSpec from ..compress import CompressionSpec from ..helpers.parseformat import partial_format from ..manifest import AI_HUMAN_SORT_KEYS @@ -418,6 +418,7 @@ class CompletionMixIn: CompressionSpec, {"bash": "_borg_complete_compression_spec", "zsh": "_borg_complete_compression_spec"}, ) + _attach_completion(parser, PathSpec, shtab.DIRECTORY) # Collect all commands and help topics for "borg help" completion help_choices = list(self.helptext.keys()) From 271a3f24660502d307a938c09d2f091c86471908 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 19 Nov 2025 03:07:24 +0100 Subject: [PATCH 11/20] completion: support ChunkerParams --- src/borg/archiver/completion_cmd.py | 31 ++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 408c61bab..7eb101583 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -4,7 +4,7 @@ import shtab from ._common import process_epilog from ..constants import * # NOQA -from ..helpers import archivename_validator, SortBySpec, FilesCacheMode, PathSpec +from ..helpers import archivename_validator, SortBySpec, FilesCacheMode, PathSpec, ChunkerParams from ..compress import CompressionSpec from ..helpers.parseformat import partial_format from ..manifest import AI_HUMAN_SORT_KEYS @@ -84,6 +84,13 @@ _borg_complete_compression_spec() { compgen -W "${choices}" -- "$1" } +# Complete chunker params options +_borg_complete_chunker_params() { + local choices="{CHUNKER_PARAMS_CHOICES}" + local IFS=$' \t\n' + compgen -W "${choices}" -- "$1" +} + # Complete comma-separated sort keys for any option with type=SortBySpec. # Keys are validated against Borg's AI_HUMAN_SORT_KEYS. _borg_complete_sortby() { @@ -263,6 +270,13 @@ _borg_complete_compression_spec() { compadd -V 'compression algorithms' -Q -a choices } +# Complete chunker params options +_borg_complete_chunker_params() { + local choices=({CHUNKER_PARAMS_CHOICES}) + # use compadd -V to preserve order (do not sort) + compadd -V 'chunker params' -Q -a choices +} + # Complete comma-separated sort keys for any option with type=SortBySpec. _borg_complete_sortby() { local cur @@ -419,6 +433,11 @@ class CompletionMixIn: {"bash": "_borg_complete_compression_spec", "zsh": "_borg_complete_compression_spec"}, ) _attach_completion(parser, PathSpec, shtab.DIRECTORY) + _attach_completion( + parser, + ChunkerParams, + {"bash": "_borg_complete_chunker_params", "zsh": "_borg_complete_chunker_params"}, + ) # Collect all commands and help topics for "borg help" completion help_choices = list(self.helptext.keys()) @@ -448,10 +467,20 @@ class CompletionMixIn: ] comp_spec_choices_str = " ".join(comp_spec_choices) + # Chunker params choices (static list) + chunker_params_choices = [ + "default", + "fixed,4194304", + "buzhash,19,23,21,4095", + "buzhash64,19,23,21,4095", + ] + chunker_params_choices_str = " ".join(chunker_params_choices) + mapping = { "SORT_KEYS": sort_keys, "FCM_KEYS": fcm_keys, "COMP_SPEC_CHOICES": comp_spec_choices_str, + "CHUNKER_PARAMS_CHOICES": chunker_params_choices_str, "HELP_CHOICES": help_choices, } bash_preamble = partial_format(BASH_PREAMBLE_TMPL, mapping) From 5b5c06b80fbb4d5b943eca4e984678510ea06769 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 19 Nov 2025 03:36:06 +0100 Subject: [PATCH 12/20] completion: support archive name and id completion --- src/borg/archiver/completion_cmd.py | 144 +++++++++++------- .../testsuite/archiver/completion_cmd_test.py | 4 +- 2 files changed, 92 insertions(+), 56 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 7eb101583..f0d40a693 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -18,7 +18,7 @@ from ..manifest import AI_HUMAN_SORT_KEYS # generated completion script for supported shells. # # Notes / constraints (per plan): -# - Calls `borg repo-list --format ...` and filters results by the typed aid: hex prefix. +# - Calls `borg repo-list --format ...` and filters results by the typed prefix (archive name or aid: hex). # - Non-interactive only. We rely on Borg to fail fast without prompting in non-interactive contexts. # If it cannot, we simply return no suggestions. @@ -33,14 +33,8 @@ BASH_PREAMBLE_TMPL = r""" if [[ ${COMP_WORDBREAKS-} == *:* ]]; then COMP_WORDBREAKS=${COMP_WORDBREAKS//:}; fi if [[ ${COMP_WORDBREAKS-} == *=* ]]; then COMP_WORDBREAKS=${COMP_WORDBREAKS//=}; fi -# Complete aid: archive IDs by querying "borg repo-list --short" -# Note: we only suggest the first 8 hex digits (short ID) for completion. -_borg_complete_aid() { +_borg_complete_archive() { 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=() @@ -56,24 +50,46 @@ _borg_complete_aid() { 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 /dev/null /dev/null /dev/null /dev/null archive IDs by querying "borg repo-list --short" -# Note: we only suggest the first 8 hex digits (short ID) for completion. -_borg_complete_aid() { +_borg_complete_archive() { 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=() @@ -238,28 +247,55 @@ _borg_complete_aid() { 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 0 )); then + out=$( borg repo-list "${repo_arg[@]}" --format '{id}{NL}' 2>/dev/null /dev/null 0 )); then + out=$( borg repo-list "${repo_arg[@]}" --format '{archive}{NL}' 2>/dev/null /dev/null Date: Wed, 19 Nov 2025 03:48:20 +0100 Subject: [PATCH 13/20] completion: archive id descriptions for zsh --- src/borg/archiver/completion_cmd.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index f0d40a693..e18bab030 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -253,28 +253,32 @@ _borg_complete_archive() { # allow only hex digits as prefix; empty prefix also allowed (list all) [[ -n "$prefix" && ! "$prefix" == [0-9a-fA-F]# ]] && return 0 - # ask borg for raw IDs; avoid prompts and suppress stderr + # ask borg for IDs with metadata; avoid prompts and suppress stderr + # Use tab as delimiter to avoid issues with spaces in archive names local out if (( ${#repo_arg[@]} > 0 )); then - out=$( borg repo-list "${repo_arg[@]}" --format '{id}{NL}' 2>/dev/null /dev/null /dev/null /dev/null Date: Wed, 19 Nov 2025 04:08:07 +0100 Subject: [PATCH 14/20] completion: module docstring --- src/borg/archiver/completion_cmd.py | 56 ++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index e18bab030..8255f2cf1 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -9,18 +9,50 @@ from ..compress import CompressionSpec from ..helpers.parseformat import partial_format from ..manifest import AI_HUMAN_SORT_KEYS -# 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 prefix (archive name or aid: hex). -# - Non-interactive only. We rely on Borg to fail fast without prompting in non-interactive contexts. -# If it cannot, we simply return no suggestions. +""" +Shell completion support for Borg commands. + +This module implements the `borg completion` command, which generates shell completion +scripts for bash and zsh. It uses the shtab library for basic completion generation +and extends it with custom dynamic completions for Borg-specific argument types. + +Dynamic Completions +------------------- +The following argument types have intelligent, context-aware completion: + +1. Archive names/IDs (archivename_validator): + - Completes archive names by default (e.g., "my-backup-2024") + - Completes archive IDs when prefixed with "aid:" (e.g., "aid:12345678") + - In zsh, shows archive metadata (name, timestamp, user@host) as descriptions + - Respects --repo/-r flags to query the correct repository + +2. Sort keys (SortBySpec): + - Completes comma-separated sort keys (timestamp, archive, name, id, tags, host, user) + - Prevents duplicate keys in the same option + +3. Files cache mode (FilesCacheMode): + - Completes comma-separated cache mode tokens (ctime, mtime, size, inode, rechunk, disabled) + - Enforces mutual exclusivity (e.g., ctime vs mtime, disabled vs others) + +4. Compression algorithms (CompressionSpec): + - Suggests compression specs with examples (lz4, zstd,3, auto,zstd,10, etc.) + +5. Chunker parameters (ChunkerParams): + - Suggests chunker param examples (default, fixed,4194304, buzhash,19,23,21,4095, etc.) + +6. Paths (PathSpec): + - Completes directories using standard shell directory completion + +7. Help topics: + - Completes help command topics and subcommand names + +Implementation Details +---------------------- +- Custom shell functions are injected via shtab's preamble mechanism +- Archive completion calls `borg repo-list --format ...` to fetch data dynamically +- All dynamic completions are non-interactive and suppress prompts/errors +- Bash and zsh have separate preamble templates with shell-specific syntax +""" From f787f4bfa50306bcabed11e92fc73fd3e137c693 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 19 Nov 2025 04:25:38 +0100 Subject: [PATCH 15/20] completion: complete tags with already present ones in repo --- src/borg/archiver/completion_cmd.py | 128 ++++++++++++++++++++++++---- 1 file changed, 111 insertions(+), 17 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 8255f2cf1..237500bbd 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -1,14 +1,3 @@ -import argparse - -import shtab - -from ._common import process_epilog -from ..constants import * # NOQA -from ..helpers import archivename_validator, SortBySpec, FilesCacheMode, PathSpec, ChunkerParams -from ..compress import CompressionSpec -from ..helpers.parseformat import partial_format -from ..manifest import AI_HUMAN_SORT_KEYS - """ Shell completion support for Borg commands. @@ -18,6 +7,7 @@ and extends it with custom dynamic completions for Borg-specific argument types. Dynamic Completions ------------------- + The following argument types have intelligent, context-aware completion: 1. Archive names/IDs (archivename_validator): @@ -46,15 +36,20 @@ The following argument types have intelligent, context-aware completion: 7. Help topics: - Completes help command topics and subcommand names -Implementation Details ----------------------- -- Custom shell functions are injected via shtab's preamble mechanism -- Archive completion calls `borg repo-list --format ...` to fetch data dynamically -- All dynamic completions are non-interactive and suppress prompts/errors -- Bash and zsh have separate preamble templates with shell-specific syntax +8. Tags (tag_validator): + - Completes existing tags from the repository """ +import argparse +import shtab + +from ._common import process_epilog +from ..constants import * # NOQA +from ..helpers import archivename_validator, SortBySpec, FilesCacheMode, PathSpec, ChunkerParams, tag_validator +from ..compress import CompressionSpec +from ..helpers.parseformat import partial_format +from ..manifest import AI_HUMAN_SORT_KEYS # Global bash preamble that is prepended to the generated completion script. # It aggregates only what we need: @@ -139,6 +134,52 @@ _borg_complete_chunker_params() { compgen -W "${choices}" -- "$1" } +# Complete tags from repository +_borg_complete_tags() { + local cur="${COMP_WORDS[COMP_CWORD]}" + + # 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 tags; avoid prompts and suppress stderr + local out + if [[ -n "${repo_arg[*]}" ]]; then + out=$( borg repo-list "${repo_arg[@]}" --format '{tags}{NL}' 2>/dev/null /dev/null 0 )); then + out=$( borg repo-list "${repo_arg[@]}" --format '{tags}{NL}' 2>/dev/null /dev/null Date: Wed, 19 Nov 2025 04:43:08 +0100 Subject: [PATCH 16/20] completion: support relative_time_marker_validator --- src/borg/archiver/completion_cmd.py | 37 ++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 237500bbd..c3c2df1d5 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -38,6 +38,9 @@ The following argument types have intelligent, context-aware completion: 8. Tags (tag_validator): - Completes existing tags from the repository + +9. Relative time markers (relative_time_marker_validator): + - Suggests common time intervals (60S, 60M, 24H, 7d, 4w, 12m, 1000y) """ import argparse @@ -46,7 +49,7 @@ import shtab from ._common import process_epilog from ..constants import * # NOQA -from ..helpers import archivename_validator, SortBySpec, FilesCacheMode, PathSpec, ChunkerParams, tag_validator +from ..helpers import archivename_validator, SortBySpec, FilesCacheMode, PathSpec, ChunkerParams, tag_validator, relative_time_marker_validator from ..compress import CompressionSpec from ..helpers.parseformat import partial_format from ..manifest import AI_HUMAN_SORT_KEYS @@ -180,6 +183,13 @@ _borg_complete_tags() { return 0 } +# Complete relative time markers +_borg_complete_relative_time() { + local choices="{RELATIVE_TIME_CHOICES}" + local IFS=$' \t\n' + compgen -W "${choices}" -- "$1" +} + # Complete comma-separated sort keys for any option with type=SortBySpec. # Keys are validated against Borg's AI_HUMAN_SORT_KEYS. _borg_complete_sortby() { @@ -440,6 +450,13 @@ _borg_complete_tags() { return 0 } +# Complete relative time markers +_borg_complete_relative_time() { + local choices=({RELATIVE_TIME_CHOICES}) + # use compadd -V to preserve order (do not sort) + compadd -V 'relative time' -Q -a choices +} + # Complete comma-separated sort keys for any option with type=SortBySpec. _borg_complete_sortby() { local cur @@ -604,6 +621,11 @@ class CompletionMixIn: _attach_completion( parser, tag_validator, {"bash": "_borg_complete_tags", "zsh": "_borg_complete_tags"} ) + _attach_completion( + parser, + relative_time_marker_validator, + {"bash": "_borg_complete_relative_time", "zsh": "_borg_complete_relative_time"}, + ) # Collect all commands and help topics for "borg help" completion help_choices = list(self.helptext.keys()) @@ -642,11 +664,24 @@ class CompletionMixIn: ] chunker_params_choices_str = " ".join(chunker_params_choices) + # Relative time marker choices (static list) + relative_time_choices = [ + "60S", + "60M", + "24H", + "7d", + "4w", + "12m", + "1000y", + ] + relative_time_choices_str = " ".join(relative_time_choices) + mapping = { "SORT_KEYS": sort_keys, "FCM_KEYS": fcm_keys, "COMP_SPEC_CHOICES": comp_spec_choices_str, "CHUNKER_PARAMS_CHOICES": chunker_params_choices_str, + "RELATIVE_TIME_CHOICES": relative_time_choices_str, "HELP_CHOICES": help_choices, } bash_preamble = partial_format(BASH_PREAMBLE_TMPL, mapping) From 12395967eac4504ff144d7b4434f0e16371d747b Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 19 Nov 2025 04:53:25 +0100 Subject: [PATCH 17/20] completion: support timestamp --- src/borg/archiver/completion_cmd.py | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index c3c2df1d5..d0106d3cc 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -41,6 +41,10 @@ The following argument types have intelligent, context-aware completion: 9. Relative time markers (relative_time_marker_validator): - Suggests common time intervals (60S, 60M, 24H, 7d, 4w, 12m, 1000y) + +10. Timestamps (timestamp): + - Completes file paths when starting with / or . + - Otherwise suggests current timestamp in ISO format """ import argparse @@ -50,6 +54,7 @@ import shtab from ._common import process_epilog from ..constants import * # NOQA from ..helpers import archivename_validator, SortBySpec, FilesCacheMode, PathSpec, ChunkerParams, tag_validator, relative_time_marker_validator +from ..helpers.time import timestamp from ..compress import CompressionSpec from ..helpers.parseformat import partial_format from ..manifest import AI_HUMAN_SORT_KEYS @@ -190,6 +195,19 @@ _borg_complete_relative_time() { compgen -W "${choices}" -- "$1" } +# Complete timestamp (file path or ISO timestamp) +_borg_complete_timestamp() { + local cur="${COMP_WORDS[COMP_CWORD]}" + + # If starts with / or ., complete as file path + if [[ "$cur" == /* || "$cur" == ./* || "$cur" == ../* || "$cur" == . || "$cur" == .. ]]; then + compgen -f -- "$cur" + else + # Suggest current timestamp in ISO format + date +"%Y-%m-%dT%H:%M:%S%z" | sed 's/\([0-9]\{2\}\)$/:\1/' + fi +} + # Complete comma-separated sort keys for any option with type=SortBySpec. # Keys are validated against Borg's AI_HUMAN_SORT_KEYS. _borg_complete_sortby() { @@ -457,6 +475,22 @@ _borg_complete_relative_time() { compadd -V 'relative time' -Q -a choices } +# Complete timestamp (file path or ISO timestamp) +_borg_complete_timestamp() { + local cur + cur="${words[$CURRENT]}" + + # If starts with / or ., complete as file path + if [[ "$cur" == /* || "$cur" == ./* || "$cur" == ../* || "$cur" == . || "$cur" == .. ]]; then + _files + else + # Suggest current timestamp in ISO format + local timestamp + timestamp=$(date +"%Y-%m-%dT%H:%M:%S%z" | sed 's/\([0-9]\{2\}\)$/:\1/') + compadd -Q -- "$timestamp" + fi +} + # Complete comma-separated sort keys for any option with type=SortBySpec. _borg_complete_sortby() { local cur @@ -626,6 +660,9 @@ class CompletionMixIn: relative_time_marker_validator, {"bash": "_borg_complete_relative_time", "zsh": "_borg_complete_relative_time"}, ) + _attach_completion( + parser, timestamp, {"bash": "_borg_complete_timestamp", "zsh": "_borg_complete_timestamp"} + ) # Collect all commands and help topics for "borg help" completion help_choices = list(self.helptext.keys()) From 5968382c9b68fb1b882c579865c7a3bfc2c24692 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 19 Nov 2025 05:01:52 +0100 Subject: [PATCH 18/20] completion: support parse_file_size --- src/borg/archiver/completion_cmd.py | 33 ++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index d0106d3cc..e90f7a4ad 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -45,6 +45,9 @@ The following argument types have intelligent, context-aware completion: 10. Timestamps (timestamp): - Completes file paths when starting with / or . - Otherwise suggests current timestamp in ISO format + +11. File sizes (parse_file_size): + - Suggests common file size values (500M, 1G, 10G, 100G, 1T, etc.) """ import argparse @@ -53,7 +56,7 @@ import shtab from ._common import process_epilog from ..constants import * # NOQA -from ..helpers import archivename_validator, SortBySpec, FilesCacheMode, PathSpec, ChunkerParams, tag_validator, relative_time_marker_validator +from ..helpers import archivename_validator, SortBySpec, FilesCacheMode, PathSpec, ChunkerParams, tag_validator, relative_time_marker_validator, parse_file_size from ..helpers.time import timestamp from ..compress import CompressionSpec from ..helpers.parseformat import partial_format @@ -208,6 +211,13 @@ _borg_complete_timestamp() { fi } +# Complete file size values +_borg_complete_file_size() { + local choices="{FILE_SIZE_CHOICES}" + local IFS=$' \t\n' + compgen -W "${choices}" -- "$1" +} + # Complete comma-separated sort keys for any option with type=SortBySpec. # Keys are validated against Borg's AI_HUMAN_SORT_KEYS. _borg_complete_sortby() { @@ -491,6 +501,13 @@ _borg_complete_timestamp() { fi } +# Complete file size values +_borg_complete_file_size() { + local choices=({FILE_SIZE_CHOICES}) + # use compadd -V to preserve order (do not sort) + compadd -V 'file size' -Q -a choices +} + # Complete comma-separated sort keys for any option with type=SortBySpec. _borg_complete_sortby() { local cur @@ -663,6 +680,9 @@ class CompletionMixIn: _attach_completion( parser, timestamp, {"bash": "_borg_complete_timestamp", "zsh": "_borg_complete_timestamp"} ) + _attach_completion( + parser, parse_file_size, {"bash": "_borg_complete_file_size", "zsh": "_borg_complete_file_size"} + ) # Collect all commands and help topics for "borg help" completion help_choices = list(self.helptext.keys()) @@ -713,12 +733,23 @@ class CompletionMixIn: ] relative_time_choices_str = " ".join(relative_time_choices) + # File size choices (static list) + file_size_choices = [ + "500M", + "1G", + "10G", + "100G", + "1T", + ] + file_size_choices_str = " ".join(file_size_choices) + mapping = { "SORT_KEYS": sort_keys, "FCM_KEYS": fcm_keys, "COMP_SPEC_CHOICES": comp_spec_choices_str, "CHUNKER_PARAMS_CHOICES": chunker_params_choices_str, "RELATIVE_TIME_CHOICES": relative_time_choices_str, + "FILE_SIZE_CHOICES": file_size_choices_str, "HELP_CHOICES": help_choices, } bash_preamble = partial_format(BASH_PREAMBLE_TMPL, mapping) From 895feaa71ae35e70f91733902a244c0a7db9d184 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 19 Nov 2025 17:26:29 +0100 Subject: [PATCH 19/20] completion: blacken --- src/borg/archiver/completion_cmd.py | 64 ++++++++++------------------- 1 file changed, 21 insertions(+), 43 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index e90f7a4ad..abcc04f79 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -56,7 +56,16 @@ import shtab from ._common import process_epilog from ..constants import * # NOQA -from ..helpers import archivename_validator, SortBySpec, FilesCacheMode, PathSpec, ChunkerParams, tag_validator, relative_time_marker_validator, parse_file_size +from ..helpers import ( + archivename_validator, + SortBySpec, + FilesCacheMode, + PathSpec, + ChunkerParams, + tag_validator, + relative_time_marker_validator, + parse_file_size, +) from ..helpers.time import timestamp from ..compress import CompressionSpec from ..helpers.parseformat import partial_format @@ -368,9 +377,11 @@ _borg_complete_archive() { # Use tab as delimiter to avoid issues with spaces in archive names local out if (( ${#repo_arg[@]} > 0 )); then - out=$( borg repo-list "${repo_arg[@]}" --format '{id}{TAB}{archive}{TAB}{time}{TAB}{username}@{hostname}{NL}' 2>/dev/null /dev/null /dev/null /dev/null Date: Wed, 19 Nov 2025 17:34:59 +0100 Subject: [PATCH 20/20] update CHANGES --- docs/changes.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 3373a22dc..9abbd4b8a 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -168,7 +168,7 @@ New features: check; default is "yes", use at your own risk, #9109. - diff: --sort-by=field[,field,...], #8998 - completion: generate completion scripts for supported shells, #9172, - uses shtab, supports bash, tcsh, zsh (zsh needs shtab > 1.7.2). + uses shtab, supports bash and zsh. Fixes: @@ -199,7 +199,7 @@ Other changes: - save space in test_create_* tests - CI/tests: add SFTP/rclone/S3 repo testing - CI: add local servers for S3 and SFTP testing - - CI: add *BSD and Haiku OS (on GitHub Actions) + - CI: add misc. BSDs and Haiku OS (on GitHub Actions) - CI: do dynamic code analysis, #6819 - transfer: add test for unexpected src repo index change, #9022 - pyproject.toml: correctly define test environments for FUSE testing @@ -222,6 +222,7 @@ Other changes: - how to debug borg mount, #5461 - document what happens when a new keyfile repo is created at the same path, #6230 - update install docs to include `SETUPTOOLS_SCM_PRETEND_VERSION` + - highlight archive series naming for fast incrementals, #8955 - add Arch Linux to the 'Installing from source' docs - add systemd-inhibit and examples, #8989 - code/docs: fix typos and grammar