Sfoglia il codice sorgente

Merge pull request #9184 from ThomasWaldmann/shtab2-master

more shell completion work
TW 1 mese fa
parent
commit
d6c334c4ec

+ 3 - 2
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

+ 1 - 1
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]

+ 635 - 68
src/borg/archiver/completion_cmd.py

@@ -1,45 +1,162 @@
+"""
+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
+
+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)
+
+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
 
 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"
+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
+from ..manifest import AI_HUMAN_SORT_KEYS
 
 # 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
 
-# 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() {
+_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=()
+  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
+
+  # Check if completing aid: prefix
+  if [[ "$cur" == aid:* ]]; then
+    local prefix="${cur#aid:}"
+    [[ -n "$prefix" && ! "$prefix" =~ ^[0-9a-fA-F]*$ ]] && return 0
+
+    # ask borg for raw IDs; avoid prompts and suppress stderr
+    local out
+    if [[ -n "${repo_arg[*]}" ]]; then
+      out=$( borg repo-list "${repo_arg[@]}" --format '{id}{NL}' 2>/dev/null </dev/null )
+    else
+      out=$( borg repo-list --format '{id}{NL}' 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"
+  else
+    # Complete archive names
+    local out
+    if [[ -n "${repo_arg[*]}" ]]; then
+      out=$( borg repo-list "${repo_arg[@]}" --format '{archive}{NL}' 2>/dev/null </dev/null )
+    else
+      out=$( borg repo-list --format '{archive}{NL}' 2>/dev/null </dev/null )
+    fi
+    [[ -z "$out" ]] && return 0
+
+    # filter by prefix and emit candidates
+    local IFS=$'\n' name
+    while IFS= read -r name; do
+      [[ -z "$name" ]] && continue
+      [[ -z "$cur" || "$name" == "$cur"* ]] && printf '%s\n' "$name"
+    done <<< "$out"
+  fi
+  return 0
+}
+
+# Complete compression spec options
+_borg_complete_compression_spec() {
+  local choices="{COMP_SPEC_CHOICES}"
+  local IFS=$' \t\n'
+  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 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=()
@@ -55,26 +172,173 @@ _borg_complete_aid() {
     fi
   done
 
-  # ask borg for raw IDs; avoid prompts and suppress stderr
+  # ask borg for tags; avoid prompts and suppress stderr
   local out
   if [[ -n "${repo_arg[*]}" ]]; then
-    out=$( borg repo-list "${repo_arg[@]}" --short 2>/dev/null </dev/null )
+    out=$( borg repo-list "${repo_arg[@]}" --format '{tags}{NL}' 2>/dev/null </dev/null )
   else
-    out=$( borg repo-list --short 2>/dev/null </dev/null )
+    out=$( borg repo-list --format '{tags}{NL}' 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}"
+  # extract unique tags and filter by prefix
+  local IFS=$'\n' line tag
+  local -A seen
+  while IFS= read -r line; do
+    [[ -z "$line" ]] && continue
+    # tags are comma-separated, split and deduplicate
+    IFS=',' read -ra tags <<< "$line"
+    for tag in "${tags[@]}"; do
+      tag="${tag# }"
+      tag="${tag% }"
+      [[ -z "$tag" ]] && continue
+      [[ -n "${seen[$tag]}" ]] && continue
+      seen[$tag]=1
+      [[ -z "$cur" || "$tag" == "$cur"* ]] && printf '%s\n' "$tag"
+    done
   done <<< "$out"
   return 0
 }
+
+# Complete relative time markers
+_borg_complete_relative_time() {
+  local choices="{RELATIVE_TIME_CHOICES}"
+  local IFS=$' \t\n'
+  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 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() {
+  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
+}
+
+# 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
+}
+
+_borg_help_topics() {
+    local choices="{HELP_CHOICES}"
+    local IFS=$' \t\n'
+    compgen -W "${choices}" -- "$1"
+}
 """
 
 
@@ -84,17 +348,101 @@ _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"""
-# 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() {
+ZSH_PREAMBLE_TMPL = r"""
+_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=()
+  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
+
+  # Check if completing aid: prefix
+  if [[ "$cur" == aid:* ]]; then
+    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
+
+    # 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}{TAB}{archive}{TAB}{time}{TAB}{username}@{hostname}{NL}' \
+             2>/dev/null </dev/null )
+    else
+      out=$( borg repo-list --format '{id}{TAB}{archive}{TAB}{time}{TAB}{username}@{hostname}{NL}' \
+             2>/dev/null </dev/null )
+    fi
+    [[ -z "$out" ]] && return 0
+
+    # filter by (case-insensitive) hex prefix and build candidates with descriptions
+    local prelower id idlower line
+    prelower="${prefix:l}"
+    local -a candidates=()
+    local -a descriptions=()
+    while IFS=$'\t' read -r id archive time userhost; do
+      [[ -z "$id" ]] && continue
+      idlower="${id:l}"
+      if [[ "$idlower" == "$prelower"* ]]; then
+        candidates+=( "aid:${id[1,8]}" )
+        # Description: show full ID, archive name, time, user@host
+        descriptions+=( "${id[1,8]}: ${archive} (${time} ${userhost})" )
+      fi
+    done <<< "$out"
+    # -Q: do not escape special chars, -d: provide descriptions, -l: one per line
+    compadd -Q -l -d descriptions -- $candidates
+  else
+    # Complete archive names
+    local out
+    if (( ${#repo_arg[@]} > 0 )); then
+      out=$( borg repo-list "${repo_arg[@]}" --format '{archive}{NL}' 2>/dev/null </dev/null )
+    else
+      out=$( borg repo-list --format '{archive}{NL}' 2>/dev/null </dev/null )
+    fi
+    [[ -z "$out" ]] && return 0
+
+    # filter by prefix and emit candidates
+    local -a candidates=()
+    local name
+    for name in ${(f)out}; do
+      [[ -z "$name" ]] && continue
+      if [[ -z "$cur" || "$name" == "$cur"* ]]; then
+        candidates+=( "$name" )
+      fi
+    done
+    compadd -Q -- $candidates
+  fi
+  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 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 tags from repository
+_borg_complete_tags() {
+  local cur
+  cur="${words[$CURRENT]}"
 
   # derive repo context from words: --repo=V, --repo V, -r=V, -rV, or -r V
   local -a repo_arg=()
@@ -110,50 +458,200 @@ _borg_complete_aid() {
     fi
   done
 
-  # ask borg for raw IDs; avoid prompts and suppress stderr
+  # ask borg for tags; avoid prompts and suppress stderr
   local out
   if (( ${#repo_arg[@]} > 0 )); then
-    out=$( borg repo-list "${repo_arg[@]}" --short 2>/dev/null </dev/null )
+    out=$( borg repo-list "${repo_arg[@]}" --format '{tags}{NL}' 2>/dev/null </dev/null )
   else
-    out=$( borg repo-list --short 2>/dev/null </dev/null )
+    out=$( borg repo-list --format '{tags}{NL}' 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}"
+  # extract unique tags and filter by prefix
+  local line tag
+  local -A seen
+  local -a candidates=()
+  for line in ${(f)out}; do
+    [[ -z "$line" ]] && continue
+    # tags are comma-separated, split and deduplicate
+    for tag in ${(s:,:)line}; do
+      tag="${tag## }"
+      tag="${tag%% }"
+      [[ -z "$tag" ]] && continue
+      [[ -n "${seen[$tag]}" ]] && continue
+      seen[$tag]=1
+      if [[ -z "$cur" || "$tag" == "$cur"* ]]; then
+        candidates+=( "$tag" )
+      fi
+    done
+  done
+  compadd -Q -- $candidates
+  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 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 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
+  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
+}
+
+# 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=()
-  for id in ${(f)out}; do
-    [[ -z "$id" ]] && continue
-    idlower="${id:l}"
-    if [[ "$idlower" == "$prelower"* ]]; then
-      candidates+=( "aid:${id[1,8]}" )
+  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
-  # -Q: do not escape special chars, so ':' remains as-is
   compadd -Q -- $candidates
   return 0
 }
-"""
 
+_borg_help_topics() {
+    local choices=({HELP_CHOICES})
+    _describe 'help topics' choices
+}
+"""
 
-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]
+        if action.type is type_class:
+            action.complete = completion_dict  # 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:
@@ -164,8 +662,77 @@ 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)
-        preamble = {"bash": BASH_PREAMBLE, "zsh": ZSH_PREAMBLE}
+        _attach_completion(
+            parser, archivename_validator, {"bash": "_borg_complete_archive", "zsh": "_borg_complete_archive"}
+        )
+        _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"},
+        )
+        _attach_completion(parser, PathSpec, shtab.DIRECTORY)
+        _attach_completion(
+            parser, ChunkerParams, {"bash": "_borg_complete_chunker_params", "zsh": "_borg_complete_chunker_params"}
+        )
+        _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"},
+        )
+        _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())
+        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
+
+        # 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)
+
+        # 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)
+
+        # Relative time marker choices (static list)
+        relative_time_choices = ["60S", "60M", "24H", "7d", "4w", "12m", "1000y"]
+        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)
+        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)
 

+ 3 - 1
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))

+ 1 - 0
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)",

+ 6 - 2
src/borg/testsuite/archiver/completion_cmd_test.py

@@ -7,11 +7,15 @@ 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
+    assert "_borg_complete_archive() {" in output
+    assert "_borg_complete_sortby() {" in output
+    assert "_borg_complete_filescachemode() {" 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
+    assert "_borg_complete_archive() {" in output
+    assert "_borg_complete_sortby() {" in output
+    assert "_borg_complete_filescachemode() {" in output