From 593b1e89e9d91febac2af5161ce773fc95185f46 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sat, 1 Aug 2026 03:05:12 +0200 Subject: [PATCH] completion: generate fish completions, remove hand-written ones, fixes #9989 borg brings its own fish backend instead of using shtab's (new in shtab 1.9.0): it walks the parser tree and emits fish complete commands, with a preamble providing fish versions of the same dynamic completions the bash/zsh scripts have (archive names, aid: archive IDs, tags, sort keys, files cache modes, compression specs, chunker params, relative time markers, timestamps, file sizes, help topics). The generated script tracks the (sub)command path and the number of positional arguments already given, so completions are offered in the right place (e.g. the second archive name for borg diff) - more precise than shtab's __fish_seen_subcommand_from based conditions. shtab >= 1.9.2 is required now (older versions generate broken zsh completions or have no fish support), so shtab.SUPPORTED_SHELLS already contains fish and is used as-is for the SHELL argument choices. The hand-written, unmaintained fish completions in scripts/shell_completions/ are removed, as are their tests. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 2 +- scripts/shell_completions/fish/borg.fish | 538 ------------------ src/borg/archiver/completion_cmd.py | 410 ++++++++++++- .../testsuite/archiver/completion_cmd_test.py | 150 +++++ src/borg/testsuite/shell_completions_test.py | 22 - 5 files changed, 530 insertions(+), 592 deletions(-) delete mode 100644 scripts/shell_completions/fish/borg.fish delete mode 100644 src/borg/testsuite/shell_completions_test.py diff --git a/pyproject.toml b/pyproject.toml index 1327420aff..647d3012fd 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.8.0,!=1.8.2,!=1.9.0,!=1.9.1", # these generate broken zsh completions, see tqdm/shtab#224 + "shtab>=1.9.2", # older versions generate broken zsh completions (tqdm/shtab#224) or have no fish support "backports-zstd; python_version < '3.14'", # for python < 3.14. "jsonargparse>=4.47.0", "PyYAML>=6.0.2", # we need to register our types with yaml, jsonargparse uses yaml for config files diff --git a/scripts/shell_completions/fish/borg.fish b/scripts/shell_completions/fish/borg.fish deleted file mode 100644 index 3703053ccb..0000000000 --- a/scripts/shell_completions/fish/borg.fish +++ /dev/null @@ -1,538 +0,0 @@ -# Completions for borg -# https://www.borgbackup.org/ -# Note: -# Listing archives works on password-protected repositories only if $BORG_PASSPHRASE is set. -# Install: -# Copy this file to /usr/share/fish/vendor_completions.d/ - -# Commands - -complete -c borg -f -n __fish_is_first_token -a 'analyze' -d 'Analyze archives to find "hot spots"' -complete -c borg -f -n __fish_is_first_token -a 'create' -d 'Create a new archive' -complete -c borg -f -n __fish_is_first_token -a 'extract' -d 'Extract archive contents' -complete -c borg -f -n __fish_is_first_token -a 'check' -d 'Check repository consistency' -complete -c borg -f -n __fish_is_first_token -a 'rename' -d 'Rename an existing archive' -complete -c borg -f -n __fish_is_first_token -a 'list' -d 'List archive or repository contents' -complete -c borg -f -n __fish_is_first_token -a 'diff' -d 'Find differences between archives' -complete -c borg -f -n __fish_is_first_token -a 'delete' -d 'Delete an archive' -complete -c borg -f -n __fish_is_first_token -a 'prune' -d 'Prune repository archives' -complete -c borg -f -n __fish_is_first_token -a 'compact' -d 'Free repository space' -complete -c borg -f -n __fish_is_first_token -a 'info' -d 'Show archive details' -complete -c borg -f -n __fish_is_first_token -a 'mount' -d 'Mount archive or a repository' -complete -c borg -f -n __fish_is_first_token -a 'umount' -d 'Unmount the mounted archive' -complete -c borg -f -n __fish_is_first_token -a 'repo-create' -d 'Create a new, empty repository' -complete -c borg -f -n __fish_is_first_token -a 'repo-delete' -d 'Delete a repository' -complete -c borg -f -n __fish_is_first_token -a 'repo-info' -d 'Show repository information' -complete -c borg -f -n __fish_is_first_token -a 'repo-list' -d 'List repository contents' -complete -c borg -f -n __fish_is_first_token -a 'repo-space' -d 'Manage reserved space in a repository' -complete -c borg -f -n __fish_is_first_token -a 'tag' -d 'Tag archives' -complete -c borg -f -n __fish_is_first_token -a 'transfer' -d 'Transfer of archives from another repository' -complete -c borg -f -n __fish_is_first_token -a 'undelete' -d 'Undelete archives' -complete -c borg -f -n __fish_is_first_token -a 'version' -d 'Display Borg client version / Borg server version' - -function __fish_borg_seen_key - if __fish_seen_subcommand_from key - and not __fish_seen_subcommand_from import export change-passphrase - return 0 - end - return 1 -end -complete -c borg -f -n __fish_is_first_token -a 'key' -d 'Manage a repository key' -complete -c borg -f -n __fish_borg_seen_key -a 'import' -d 'Import a repository key' -complete -c borg -f -n __fish_borg_seen_key -a 'export' -d 'Export a repository key' -complete -c borg -f -n __fish_borg_seen_key -a 'change-passphrase' -d 'Change key file passphrase' - -complete -c borg -f -n __fish_is_first_token -a 'serve' -d 'Start in server mode' -complete -c borg -f -n __fish_is_first_token -a 'recreate' -d 'Recreate contents of existing archives' -complete -c borg -f -n __fish_is_first_token -a 'export-tar' -d 'Create tarball from an archive' -complete -c borg -f -n __fish_is_first_token -a 'with-lock' -d 'Run a command while the repository lock is held' -complete -c borg -f -n __fish_is_first_token -a 'break-lock' -d 'Break the repository lock' - -function __fish_borg_seen_benchmark - if __fish_seen_subcommand_from benchmark - and not __fish_seen_subcommand_from crud - return 0 - end - return 1 -end -complete -c borg -f -n __fish_is_first_token -a 'benchmark' -d 'Benchmark borg operations' -complete -c borg -f -n __fish_borg_seen_benchmark -a 'crud' -d 'Benchmark borg CRUD operations' - -function __fish_borg_seen_help - if __fish_seen_subcommand_from help - and not __fish_seen_subcommand_from patterns placeholders compression - return 0 - end - return 1 -end -complete -c borg -f -n __fish_is_first_token -a 'help' -d 'Miscellaneous Help' -complete -c borg -f -n __fish_borg_seen_help -a 'patterns' -d 'Help for patterns' -complete -c borg -f -n __fish_borg_seen_help -a 'placeholders' -d 'Help for placeholders' -complete -c borg -f -n __fish_borg_seen_help -a 'compression' -d 'Help for compression' - -# Common options -complete -c borg -f -s h -l 'help' -d 'Show help information' -complete -c borg -f -l 'version' -d 'Show version information' -complete -c borg -f -l 'critical' -d 'Log level CRITICAL' -complete -c borg -f -l 'error' -d 'Log level ERROR' -complete -c borg -f -l 'warning' -d 'Log level WARNING (default)' -complete -c borg -f -l 'info' -d 'Log level INFO' -complete -c borg -f -s v -l 'verbose' -d 'Log level INFO' -complete -c borg -f -l 'debug' -d 'Log level DEBUG' -complete -c borg -f -l 'debug-topic' -d 'Enable TOPIC debugging' -complete -c borg -f -s p -l 'progress' -d 'Show progress information' -complete -c borg -f -l 'log-json' -d 'Output one JSON object per log line' -complete -c borg -f -l 'lock-wait' -d 'Wait for lock max N seconds [1]' -complete -c borg -f -l 'show-version' -d 'Log version information' -complete -c borg -f -l 'show-rc' -d 'Log the return code' -complete -c borg -f -l 'umask' -d 'Set umask to M [0077]' - -# borg analyze options -complete -c borg -f -s a -l 'match-archives' -d 'Only archive names matching PATTERN' -n "__fish_seen_subcommand_from analyze" -set -l sort_keys "timestamp archive name id tags host user" -complete -c borg -f -l 'sort-by' -d 'Sorting KEYS [timestamp]' -a "$sort_keys" -n "__fish_seen_subcommand_from analyze" -complete -c borg -f -l 'first' -d 'Only first N archives' -n "__fish_seen_subcommand_from analyze" -complete -c borg -f -l 'last' -d 'Only last N archives' -n "__fish_seen_subcommand_from analyze" -complete -c borg -f -l 'oldest' -d 'Consider archives within TIMESPAN from oldest' -n "__fish_seen_subcommand_from analyze" -complete -c borg -f -l 'newest' -d 'Consider archives within TIMESPAN from newest' -n "__fish_seen_subcommand_from analyze" -complete -c borg -f -l 'older' -d 'Consider archives older than TIMESPAN' -n "__fish_seen_subcommand_from analyze" -complete -c borg -f -l 'newer' -d 'Consider archives newer than TIMESPAN' -n "__fish_seen_subcommand_from analyze" - -# borg create options -complete -c borg -f -s n -l 'dry-run' -d 'Do not create a backup archive' -n "__fish_seen_subcommand_from create" -complete -c borg -f -s s -l 'stats' -d 'Print verbose statistics' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'list' -d 'Print verbose list of items' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'filter' -d 'Only items with given STATUSCHARS' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'json' -d 'Print verbose stats as json' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'stdin-name' -d 'Use NAME in archive for stdin data' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'content-from-command' -d 'Interpret PATH as command and store its stdout' -n "__fish_seen_subcommand_from create" -# Exclusion options -complete -c borg -s e -l 'exclude' -d 'Exclude paths matching PATTERN' -n "__fish_seen_subcommand_from create" -complete -c borg -l 'exclude-from' -d 'Read exclude patterns from EXCLUDEFILE' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'pattern' -d 'Include/exclude paths matching PATTERN' -n "__fish_seen_subcommand_from create" -complete -c borg -l 'patterns-from' -d 'Include/exclude paths from PATTERNFILE' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'exclude-caches' -d 'Exclude directories tagged as cache' -n "__fish_seen_subcommand_from create" -complete -c borg -l 'exclude-if-present' -d 'Exclude directories that contain FILENAME' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'keep-exclude-tags' -d 'Keep tag files of excluded directories' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'exclude-nodump' -d 'Exclude files flagged NODUMP' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'exclude-dataless' -d 'Exclude files flagged DATALESS (macOS)' -n "__fish_seen_subcommand_from create" -# Filesystem options -complete -c borg -f -s x -l 'one-file-system' -d 'Stay in the same file system' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'numeric-ids' -d 'Only store numeric user:group identifiers' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'noatime' -d 'Do not store atime' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'noctime' -d 'Do not store ctime' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'nobirthtime' -d 'Do not store creation date' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'nobsdflags' -d 'Do not store bsdflags' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'noacls' -d 'Do not read and store ACLs into archive' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'noxattrs' -d 'Do not read and store xattrs into archive' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'noflags' -d 'Do not store flags' -n "__fish_seen_subcommand_from create" -set -l files_cache_mode "ctime,size,inode mtime,size,inode ctime,size mtime,size rechunk,ctime rechunk,mtime size disabled" -complete -c borg -f -l 'files-cache' -d 'Operate files cache in MODE' -a "$files_cache_mode" -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'read-special' -d 'Open device files like regular files' -n "__fish_seen_subcommand_from create" -# Archive options -complete -c borg -f -l 'comment' -d 'Add COMMENT to the archive' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'timestamp' -d 'Set creation TIME (yyyy-mm-ddThh:mm:ss)' -n "__fish_seen_subcommand_from create" -complete -c borg -l 'timestamp' -d 'Set creation time by reference FILE' -n "__fish_seen_subcommand_from create" -complete -c borg -f -l 'chunker-params' -d 'Chunker PARAMETERS [19,23,21,4095]' -n "__fish_seen_subcommand_from create" -complete -c borg -f -s C -l 'compression' -d 'Select compression ALGORITHM,LEVEL [lz4]' -a "$compression_methods" -n "__fish_seen_subcommand_from create" - -# borg extract options -complete -c borg -f -l 'list' -d 'Print verbose list of items' -n "__fish_seen_subcommand_from extract" -complete -c borg -f -s n -l 'dry-run' -d 'Do not actually extract any files' -n "__fish_seen_subcommand_from extract" -complete -c borg -f -l 'numeric-ids' -d 'Only obey numeric user:group identifiers' -n "__fish_seen_subcommand_from extract" -complete -c borg -f -l 'nobsdflags' -d 'Do not extract/set bsdflags' -n "__fish_seen_subcommand_from extract" -complete -c borg -f -l 'noflags' -d 'Do not extract/set flags' -n "__fish_seen_subcommand_from extract" -complete -c borg -f -l 'noacls' -d 'Do not extract/set ACLs' -n "__fish_seen_subcommand_from extract" -complete -c borg -f -l 'noxattrs' -d 'Do not extract/set xattrs' -n "__fish_seen_subcommand_from extract" -complete -c borg -f -l 'stdout' -d 'Write all extracted data to stdout' -n "__fish_seen_subcommand_from extract" -complete -c borg -f -l 'sparse' -d 'Create holes in output sparse file' -n "__fish_seen_subcommand_from extract" -# Exclusion options -complete -c borg -s e -l 'exclude' -d 'Exclude paths matching PATTERN' -n "__fish_seen_subcommand_from extract" -complete -c borg -l 'exclude-from' -d 'Read exclude patterns from EXCLUDEFILE' -n "__fish_seen_subcommand_from extract" -complete -c borg -l 'pattern' -d 'Include/exclude paths matching PATTERN' -n "__fish_seen_subcommand_from extract" -complete -c borg -l 'patterns-from' -d 'Include/exclude paths from PATTERNFILE' -n "__fish_seen_subcommand_from extract" -complete -c borg -f -l 'strip-components' -d 'Remove NUMBER of leading path elements' -n "__fish_seen_subcommand_from extract" - -# borg check options -complete -c borg -f -l 'repository-only' -d 'Only perform repository checks' -n "__fish_seen_subcommand_from check" -complete -c borg -f -l 'archives-only' -d 'Only perform archives checks' -n "__fish_seen_subcommand_from check" -complete -c borg -f -l 'verify-data' -d 'Cryptographic integrity verification' -n "__fish_seen_subcommand_from check" -complete -c borg -f -l 'repair' -d 'Attempt to repair found inconsistencies' -n "__fish_seen_subcommand_from check" -complete -c borg -f -l 'max-duration' -d 'Partial repo check for max. SECONDS' -n "__fish_seen_subcommand_from check" -# Archive filters -complete -c borg -f -s P -l 'prefix' -d 'Only archive names starting with PREFIX' -n "__fish_seen_subcommand_from check" -complete -c borg -f -s a -l 'match-archives' -d 'Only consider archives matching all patterns' -n "__fish_seen_subcommand_from check" -set -l sort_keys "timestamp archive name id tags host user" -complete -c borg -f -l 'sort-by' -d 'Sorting KEYS [timestamp]' -a "$sort_keys" -n "__fish_seen_subcommand_from check" -complete -c borg -f -l 'first' -d 'Only first N archives' -n "__fish_seen_subcommand_from check" -complete -c borg -f -l 'last' -d 'Only last N archives' -n "__fish_seen_subcommand_from check" -complete -c borg -f -l 'oldest' -d 'Consider archives within TIMESPAN from oldest' -n "__fish_seen_subcommand_from check" -complete -c borg -f -l 'newest' -d 'Consider archives within TIMESPAN from newest' -n "__fish_seen_subcommand_from check" -complete -c borg -f -l 'older' -d 'Consider archives older than TIMESPAN' -n "__fish_seen_subcommand_from check" -complete -c borg -f -l 'newer' -d 'Consider archives newer than TIMESPAN' -n "__fish_seen_subcommand_from check" - -# borg rename -# no specific options - -# borg list options -complete -c borg -f -l 'short' -d 'Only print file/directory names' -n "__fish_seen_subcommand_from list" -complete -c borg -f -l 'format' -d 'Specify FORMAT for file listing' -n "__fish_seen_subcommand_from list" -complete -c borg -f -l 'json' -d 'List contents in json format' -n "__fish_seen_subcommand_from list" -complete -c borg -f -l 'json-lines' -d 'List contents in json lines format' -n "__fish_seen_subcommand_from list" -# Archive filters -complete -c borg -f -s P -l 'prefix' -d 'Only archive names starting with PREFIX' -n "__fish_seen_subcommand_from list" -complete -c borg -f -s a -l 'match-archives' -d 'Only consider archives matching all patterns' -n "__fish_seen_subcommand_from list" -set -l sort_keys "timestamp archive name id tags host user" -complete -c borg -f -l 'sort-by' -d 'Sorting KEYS [timestamp]' -a "$sort_keys" -n "__fish_seen_subcommand_from list" -complete -c borg -f -l 'first' -d 'Only first N archives' -n "__fish_seen_subcommand_from list" -complete -c borg -f -l 'last' -d 'Only last N archives' -n "__fish_seen_subcommand_from list" -complete -c borg -f -l 'oldest' -d 'Consider archives within TIMESPAN from oldest' -n "__fish_seen_subcommand_from list" -complete -c borg -f -l 'newest' -d 'Consider archives within TIMESPAN from newest' -n "__fish_seen_subcommand_from list" -complete -c borg -f -l 'older' -d 'Consider archives older than TIMESPAN' -n "__fish_seen_subcommand_from list" -complete -c borg -f -l 'newer' -d 'Consider archives newer than TIMESPAN' -n "__fish_seen_subcommand_from list" -# Exclusion options -complete -c borg -s e -l 'exclude' -d 'Exclude paths matching PATTERN' -n "__fish_seen_subcommand_from list" -complete -c borg -l 'exclude-from' -d 'Read exclude patterns from EXCLUDEFILE' -n "__fish_seen_subcommand_from list" -complete -c borg -f -l 'pattern' -d 'Include/exclude paths matching PATTERN' -n "__fish_seen_subcommand_from list" -complete -c borg -l 'patterns-from' -d 'Include/exclude paths from PATTERNFILE' -n "__fish_seen_subcommand_from list" - -# borg diff options -complete -c borg -f -l 'numeric-ids' -d 'Only consider numeric user:group' -n "__fish_seen_subcommand_from diff" -complete -c borg -f -l 'same-chunker-params' -d 'Override check of chunker parameters' -n "__fish_seen_subcommand_from diff" -complete -c borg -f -l 'sort' -d 'Sort the output lines by file path' -n "__fish_seen_subcommand_from diff" -complete -c borg -f -l 'json-lines' -d 'Format output as JSON Lines' -n "__fish_seen_subcommand_from diff" -# Exclusion options -complete -c borg -s e -l 'exclude' -d 'Exclude paths matching PATTERN' -n "__fish_seen_subcommand_from diff" -complete -c borg -l 'exclude-from' -d 'Read exclude patterns from EXCLUDEFILE' -n "__fish_seen_subcommand_from diff" -complete -c borg -f -l 'pattern' -d 'Include/exclude paths matching PATTERN' -n "__fish_seen_subcommand_from diff" -complete -c borg -l 'patterns-from' -d 'Include/exclude paths from PATTERNFILE' -n "__fish_seen_subcommand_from diff" - -# borg delete options -complete -c borg -f -s n -l 'dry-run' -d 'Do not change the repository' -n "__fish_seen_subcommand_from delete" -complete -c borg -f -l 'list' -d 'Output verbose list of archives' -n "__fish_seen_subcommand_from delete" -# Archive filters -complete -c borg -f -s P -l 'prefix' -d 'Only archive names starting with PREFIX' -n "__fish_seen_subcommand_from delete" -complete -c borg -f -s a -l 'match-archives' -d 'Only consider archives matching all patterns' -n "__fish_seen_subcommand_from delete" -set -l sort_keys "timestamp archive name id tags host user" -complete -c borg -f -l 'sort-by' -d 'Sorting KEYS [timestamp]' -a "$sort_keys" -n "__fish_seen_subcommand_from delete" -complete -c borg -f -l 'first' -d 'Only first N archives' -n "__fish_seen_subcommand_from delete" -complete -c borg -f -l 'last' -d 'Only last N archives' -n "__fish_seen_subcommand_from delete" -complete -c borg -f -l 'oldest' -d 'Consider archives within TIMESPAN from oldest' -n "__fish_seen_subcommand_from delete" -complete -c borg -f -l 'newest' -d 'Consider archives within TIMESPAN from newest' -n "__fish_seen_subcommand_from delete" -complete -c borg -f -l 'older' -d 'Consider archives older than TIMESPAN' -n "__fish_seen_subcommand_from delete" -complete -c borg -f -l 'newer' -d 'Consider archives newer than TIMESPAN' -n "__fish_seen_subcommand_from delete" - -# borg prune options -complete -c borg -f -s n -l 'dry-run' -d 'Do not change the repository' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -l 'force' -d 'Force pruning of corrupted archives' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -s s -l 'stats' -d 'Print verbose statistics' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -l 'list' -d 'Print verbose list of items' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -l 'keep-within' -d 'Keep archives within time INTERVAL' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -l 'keep-last' -d 'NUMBER of secondly archives to keep' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -l 'keep-secondly' -d 'NUMBER of secondly archives to keep' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -l 'keep-minutely' -d 'NUMBER of minutely archives to keep' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -s H -l 'keep-hourly' -d 'NUMBER of hourly archives to keep' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -s d -l 'keep-daily' -d 'NUMBER of daily archives to keep' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -s w -l 'keep-weekly' -d 'NUMBER of weekly archives to keep' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -s m -l 'keep-monthly' -d 'NUMBER of monthly archives to keep' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -l 'keep-13weekly' -d 'NUMBER of quarterly archives to keep (13 week strategy)' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -l 'keep-3monthly' -d 'NUMBER of quarterly archives to keep (3 month strategy)' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -s y -l 'keep-yearly' -d 'NUMBER of yearly archives to keep' -n "__fish_seen_subcommand_from prune" -# Archive filters -complete -c borg -f -s P -l 'prefix' -d 'Only archive names starting with PREFIX' -n "__fish_seen_subcommand_from prune" -complete -c borg -f -s a -l 'match-archives' -d 'Only consider archives matching all patterns' -n "__fish_seen_subcommand_from prune" - -# borg compact options -complete -c borg -f -s n -l 'dry-run' -d 'Do nothing' -n "__fish_seen_subcommand_from compact" -complete -c borg -f -s s -l 'stats' -d 'Print statistics (might be much slower)' -n "__fish_seen_subcommand_from compact" - -# borg info options -complete -c borg -f -l 'json' -d 'Format output in json format' -n "__fish_seen_subcommand_from info" -# Archive filters -complete -c borg -f -s P -l 'prefix' -d 'Only archive names starting with PREFIX' -n "__fish_seen_subcommand_from info" -complete -c borg -f -s a -l 'match-archives' -d 'Only consider archives matching all patterns' -n "__fish_seen_subcommand_from info" -set -l sort_keys "timestamp archive name id tags host user" -complete -c borg -f -l 'sort-by' -d 'Sorting KEYS [timestamp]' -a "$sort_keys" -n "__fish_seen_subcommand_from info" -complete -c borg -f -l 'first' -d 'Only first N archives' -n "__fish_seen_subcommand_from info" -complete -c borg -f -l 'last' -d 'Only last N archives' -n "__fish_seen_subcommand_from info" -complete -c borg -f -l 'oldest' -d 'Consider archives within TIMESPAN from oldest' -n "__fish_seen_subcommand_from info" -complete -c borg -f -l 'newest' -d 'Consider archives within TIMESPAN from newest' -n "__fish_seen_subcommand_from info" -complete -c borg -f -l 'older' -d 'Consider archives older than TIMESPAN' -n "__fish_seen_subcommand_from info" -complete -c borg -f -l 'newer' -d 'Consider archives newer than TIMESPAN' -n "__fish_seen_subcommand_from info" - -# borg repo-list options -complete -c borg -f -l 'short' -d 'Only print the archive IDs, nothing else' -n "__fish_seen_subcommand_from repo-list" -complete -c borg -f -l 'format' -d 'Specify format for archive listing' -n "__fish_seen_subcommand_from repo-list" -complete -c borg -f -l 'json' -d 'Format output as JSON' -n "__fish_seen_subcommand_from repo-list" -# Archive filters -complete -c borg -f -s P -l 'prefix' -d 'Only archive names starting with PREFIX' -n "__fish_seen_subcommand_from repo-list" -complete -c borg -f -s a -l 'match-archives' -d 'Only consider archives matching all patterns' -n "__fish_seen_subcommand_from repo-list" -set -l sort_keys "timestamp archive name id tags host user" -complete -c borg -f -l 'sort-by' -d 'Sorting KEYS [timestamp]' -a "$sort_keys" -n "__fish_seen_subcommand_from repo-list" -complete -c borg -f -l 'first' -d 'Only first N archives' -n "__fish_seen_subcommand_from repo-list" -complete -c borg -f -l 'last' -d 'Only last N archives' -n "__fish_seen_subcommand_from repo-list" -complete -c borg -f -l 'oldest' -d 'Consider archives within TIMESPAN from oldest' -n "__fish_seen_subcommand_from repo-list" -complete -c borg -f -l 'newest' -d 'Consider archives within TIMESPAN from newest' -n "__fish_seen_subcommand_from repo-list" -complete -c borg -f -l 'older' -d 'Consider archives older than TIMESPAN' -n "__fish_seen_subcommand_from repo-list" -complete -c borg -f -l 'newer' -d 'Consider archives newer than TIMESPAN' -n "__fish_seen_subcommand_from repo-list" -complete -c borg -f -l 'deleted' -d 'Consider only soft-deleted archives' -n "__fish_seen_subcommand_from repo-list" - -# borg mount options -complete -c borg -f -s f -l 'foreground' -d 'Stay in foreground, do not daemonize' -n "__fish_seen_subcommand_from mount" -# FIXME This list is probably not full, but I tried to pick only those that are relevant to borg mount -o: -set -l fuse_options "ac_attr_timeout= allow_damaged_files allow_other allow_root attr_timeout= auto auto_cache auto_unmount default_permissions entry_timeout= gid= group_id= kernel_cache max_read= negative_timeout= noauto noforget remember= remount rootmode= uid= umask= user user_id= versions" -complete -c borg -f -s o -d 'Fuse mount OPTION' -a "$fuse_options" -n "__fish_seen_subcommand_from mount" -# Archive filters -complete -c borg -f -s P -l 'prefix' -d 'Only archive names starting with PREFIX' -n "__fish_seen_subcommand_from mount" -complete -c borg -f -s a -l 'match-archives' -d 'Only consider archives matching all patterns' -n "__fish_seen_subcommand_from mount" -set -l sort_keys "timestamp archive name id tags host user" -complete -c borg -f -l 'sort-by' -d 'Sorting KEYS [timestamp]' -a "$sort_keys" -n "__fish_seen_subcommand_from mount" -complete -c borg -f -l 'first' -d 'Only first N archives' -n "__fish_seen_subcommand_from mount" -complete -c borg -f -l 'last' -d 'Only last N archives' -n "__fish_seen_subcommand_from mount" -complete -c borg -f -l 'oldest' -d 'Consider archives within TIMESPAN from oldest' -n "__fish_seen_subcommand_from mount" -complete -c borg -f -l 'newest' -d 'Consider archives within TIMESPAN from newest' -n "__fish_seen_subcommand_from mount" -complete -c borg -f -l 'older' -d 'Consider archives older than TIMESPAN' -n "__fish_seen_subcommand_from mount" -complete -c borg -f -l 'newer' -d 'Consider archives newer than TIMESPAN' -n "__fish_seen_subcommand_from mount" -# Exclusion options -complete -c borg -s e -l 'exclude' -d 'Exclude paths matching PATTERN' -n "__fish_seen_subcommand_from mount" -complete -c borg -l 'exclude-from' -d 'Read exclude patterns from EXCLUDEFILE' -n "__fish_seen_subcommand_from mount" -complete -c borg -f -l 'pattern' -d 'Include/exclude paths matching PATTERN' -n "__fish_seen_subcommand_from mount" -complete -c borg -l 'patterns-from' -d 'Include/exclude paths from PATTERNFILE' -n "__fish_seen_subcommand_from mount" -complete -c borg -f -l 'strip-components' -d 'Remove NUMBER of leading path elements' -n "__fish_seen_subcommand_from mount" - -# borg umount -# no specific options - -# borg tag options -complete -c borg -f -l 'set' -d 'Set tags (can be given multiple times)' -n "__fish_seen_subcommand_from tag" -complete -c borg -f -l 'add' -d 'Add tags (can be given multiple times)' -n "__fish_seen_subcommand_from tag" -complete -c borg -f -l 'remove' -d 'Remove tags (can be given multiple times)' -n "__fish_seen_subcommand_from tag" -# Archive filters -complete -c borg -f -s a -l 'match-archives' -d 'Only consider archives matching all patterns' -n "__fish_seen_subcommand_from tag" -set -l sort_keys "timestamp archive name id tags host user" -complete -c borg -f -l 'sort-by' -d 'Sorting KEYS [timestamp]' -a "$sort_keys" -n "__fish_seen_subcommand_from tag" -complete -c borg -f -l 'first' -d 'Only first N archives' -n "__fish_seen_subcommand_from tag" -complete -c borg -f -l 'last' -d 'Only last N archives' -n "__fish_seen_subcommand_from tag" -complete -c borg -f -l 'oldest' -d 'Consider archives within TIMESPAN from oldest' -n "__fish_seen_subcommand_from tag" -complete -c borg -f -l 'newest' -d 'Consider archives within TIMESPAN from newest' -n "__fish_seen_subcommand_from tag" -complete -c borg -f -l 'older' -d 'Consider archives older than TIMESPAN' -n "__fish_seen_subcommand_from tag" -complete -c borg -f -l 'newer' -d 'Consider archives newer than TIMESPAN' -n "__fish_seen_subcommand_from tag" - -# borg key change-passphrase -# no specific options - -# borg key export -complete -c borg -f -l 'paper' -d 'Create an export for printing' -n "__fish_seen_subcommand_from export" -complete -c borg -f -l 'qr-html' -d 'Create an html file for printing and qr' -n "__fish_seen_subcommand_from export" - -# borg transfer options -complete -c borg -f -s n -l 'dry-run' -d 'Do not change repository, just check' -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -l 'other-repo' -d 'Transfer archives from the other repository' -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -l 'from-borg1' -d 'Other repository is borg 1.x' -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -l 'upgrader' -d 'Use the upgrader to convert transferred data' -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -s C -l 'compression' -d 'Select compression algorithm' -a "$compression_methods" -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -l 'recompress' -d 'Recompress chunks CONDITION' -a "$recompress_when" -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -l 'chunker-params' -d 'Chunker PARAMETERS [19,23,21,4095]' -n "__fish_seen_subcommand_from transfer" -# Archive filters -complete -c borg -f -s a -l 'match-archives' -d 'Only consider archives matching all patterns' -n "__fish_seen_subcommand_from transfer" -set -l sort_keys "timestamp archive name id tags host user" -complete -c borg -f -l 'sort-by' -d 'Sorting KEYS [timestamp]' -a "$sort_keys" -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -l 'first' -d 'Only first N archives' -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -l 'last' -d 'Only last N archives' -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -l 'oldest' -d 'Consider archives within TIMESPAN from oldest' -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -l 'newest' -d 'Consider archives within TIMESPAN from newest' -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -l 'older' -d 'Consider archives older than TIMESPAN' -n "__fish_seen_subcommand_from transfer" -complete -c borg -f -l 'newer' -d 'Consider archives newer than TIMESPAN' -n "__fish_seen_subcommand_from transfer" - -# borg key import -complete -c borg -f -l 'paper' -d 'Import from a backup done with --paper' -n "__fish_seen_subcommand_from import" - -# borg undelete options -complete -c borg -f -s n -l 'dry-run' -d 'Do not change repository' -n "__fish_seen_subcommand_from undelete" -complete -c borg -f -l 'list' -d 'Output verbose list of archives' -n "__fish_seen_subcommand_from undelete" -# Archive filters -complete -c borg -f -s a -l 'match-archives' -d 'Only consider archives matching all patterns' -n "__fish_seen_subcommand_from undelete" -set -l sort_keys "timestamp archive name id tags host user" -complete -c borg -f -l 'sort-by' -d 'Sorting KEYS [timestamp]' -a "$sort_keys" -n "__fish_seen_subcommand_from undelete" -complete -c borg -f -l 'first' -d 'Only first N archives' -n "__fish_seen_subcommand_from undelete" -complete -c borg -f -l 'last' -d 'Only last N archives' -n "__fish_seen_subcommand_from undelete" -complete -c borg -f -l 'oldest' -d 'Consider archives within TIMESPAN from oldest' -n "__fish_seen_subcommand_from undelete" -complete -c borg -f -l 'newest' -d 'Consider archives within TIMESPAN from newest' -n "__fish_seen_subcommand_from undelete" -complete -c borg -f -l 'older' -d 'Consider archives older than TIMESPAN' -n "__fish_seen_subcommand_from undelete" -complete -c borg -f -l 'newer' -d 'Consider archives newer than TIMESPAN' -n "__fish_seen_subcommand_from undelete" - - -# borg recreate -complete -c borg -f -l 'list' -d 'Print verbose list of items' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'filter' -d 'Only items with given STATUSCHARS' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -s n -l 'dry-run' -d 'Do not change the repository' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -s s -l 'stats' -d 'Print verbose statistics' -n "__fish_seen_subcommand_from recreate" -# Exclusion options -complete -c borg -s e -l 'exclude' -d 'Exclude paths matching PATTERN' -n "__fish_seen_subcommand_from recreate" -complete -c borg -l 'exclude-from' -d 'Read exclude patterns from EXCLUDEFILE' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'pattern' -d 'Include/exclude paths matching PATTERN' -n "__fish_seen_subcommand_from recreate" -complete -c borg -l 'patterns-from' -d 'Include/exclude paths from PATTERNFILE' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'exclude-caches' -d 'Exclude directories tagged as cache' -n "__fish_seen_subcommand_from recreate" -complete -c borg -l 'exclude-if-present' -d 'Exclude directories that contain FILENAME' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'keep-exclude-tags' -d 'Keep tag files of excluded directories' -n "__fish_seen_subcommand_from recreate" -# Archive filters -complete -c borg -f -s a -l 'match-archives' -d 'Only consider archives matching all patterns' -n "__fish_seen_subcommand_from recreate" -set -l sort_keys "timestamp archive name id tags host user" -complete -c borg -f -l 'sort-by' -d 'Sorting KEYS [timestamp]' -a "$sort_keys" -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'first' -d 'Only first N archives' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'last' -d 'Only last N archives' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'oldest' -d 'Consider archives within TIMESPAN from oldest' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'newest' -d 'Consider archives within TIMESPAN from newest' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'older' -d 'Consider archives older than TIMESPAN' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'newer' -d 'Consider archives newer than TIMESPAN' -n "__fish_seen_subcommand_from recreate" -# Archive options -complete -c borg -f -l 'target' -d "Create a new ARCHIVE" -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'comment' -d 'Add COMMENT to the archive' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'timestamp' -d 'Set creation TIME (yyyy-mm-ddThh:mm:ss)' -n "__fish_seen_subcommand_from recreate" -complete -c borg -l 'timestamp' -d 'Set creation time using reference FILE' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -s C -l 'compression' -d 'Select compression ALGORITHM,LEVEL [lz4]' -a "$compression_methods" -n "__fish_seen_subcommand_from recreate" -set -l recompress_when "if-different always never" -complete -c borg -f -l 'recompress' -d 'Recompress chunks CONDITION' -a "$recompress_when" -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'chunker-params' -d 'Chunker PARAMETERS [19,23,21,4095]' -n "__fish_seen_subcommand_from recreate" - -# borg export-tar options -complete -c borg -l 'tar-filter' -d 'Filter program to pipe data through' -n "__fish_seen_subcommand_from export-tar" -complete -c borg -f -l 'list' -d 'Print verbose list of items' -n "__fish_seen_subcommand_from export-tar" -complete -c borg -f -l 'tar-format' -d 'Select tar format: BORG, PAX or GNU' -n "__fish_seen_subcommand_from export-tar" -# Exclusion options -complete -c borg -s e -l 'exclude' -d 'Exclude paths matching PATTERN' -n "__fish_seen_subcommand_from export-tar" -complete -c borg -l 'exclude-from' -d 'Read exclude patterns from EXCLUDEFILE' -n "__fish_seen_subcommand_from export-tar" -complete -c borg -f -l 'pattern' -d 'Include/exclude paths matching PATTERN' -n "__fish_seen_subcommand_from export-tar" -complete -c borg -l 'patterns-from' -d 'Include/exclude paths from PATTERNFILE' -n "__fish_seen_subcommand_from export-tar" -complete -c borg -f -l 'strip-components' -d 'Remove NUMBER of leading path elements' -n "__fish_seen_subcommand_from export-tar" - -# borg import-tar options -complete -c borg -l 'tar-filter' -d 'Filter program to pipe data through' -n "__fish_seen_subcommand_from import-tar" -complete -c borg -f -s s -l 'stats' -d 'Print statistics for the created archive' -n "__fish_seen_subcommand_from import-tar" -complete -c borg -f -l 'list' -d 'Print verbose list of items' -n "__fish_seen_subcommand_from import-tar" -complete -c borg -f -l 'filter' -d 'Only display items with given STATUSCHARS' -n "__fish_seen_subcommand_from import-tar" -complete -c borg -f -l 'json' -d 'Output stats as JSON' -n "__fish_seen_subcommand_from import-tar" -complete -c borg -f -l 'ignore-zeros' -d 'Ignore zero-filled blocks in the input' -n "__fish_seen_subcommand_from import-tar" -complete -c borg -f -l 'comment' -d 'Add COMMENT to the archive' -n "__fish_seen_subcommand_from import-tar" -complete -c borg -f -l 'timestamp' -d 'Set creation TIME (yyyy-mm-ddThh:mm:ss)' -n "__fish_seen_subcommand_from import-tar" -complete -c borg -f -l 'chunker-params' -d 'Chunker PARAMETERS [19,23,21,4095]' -n "__fish_seen_subcommand_from import-tar" -complete -c borg -f -s C -l 'compression' -d 'Select compression ALGORITHM,LEVEL [lz4]' -a "$compression_methods" -n "__fish_seen_subcommand_from import-tar" -# Exclusion options -complete -c borg -s e -l 'exclude' -d 'Exclude paths matching PATTERN' -n "__fish_seen_subcommand_from recreate" -complete -c borg -l 'exclude-from' -d 'Read exclude patterns from EXCLUDEFILE' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'pattern' -d 'Include/exclude paths matching PATTERN' -n "__fish_seen_subcommand_from recreate" -complete -c borg -l 'patterns-from' -d 'Include/exclude paths from PATTERNFILE' -n "__fish_seen_subcommand_from recreate" -complete -c borg -f -l 'strip-components' -d 'Remove NUMBER of leading path elements' -n "__fish_seen_subcommand_from recreate" - -# borg serve -complete -c borg -l 'restrict-to-path' -d 'Restrict repository access to PATH' -n "__fish_seen_subcommand_from serve" -complete -c borg -l 'restrict-to-repository' -d 'Restrict repository access at PATH' -n "__fish_seen_subcommand_from serve" - - -# borg with-lock -# no specific options - -# borg break-lock -# no specific options - -# borg benchmark -# no specific options - -# borg help -# no specific options - - -function __fish_borg_archives - # additionally to aid:XXXXXXXX we show archive (series) name and timestamp - borg repo-list --format="aid:{id:.8}{TAB}{archive} {start}{NEWLINE}" 2>/dev/null -end - -function __fish_borg_archive_arg --description 'Test if current command is a specific borg command with token count' --argument command token_count - # Check if we're in the context of a specific borg command - set -l tokens (commandline --tokenize) - set -l cmdline (commandline --current-process) - - # Make sure we're in a borg command context - if not test $tokens[1] = "borg" - return 1 - end - - # Make sure we're in the specific command context - if not test $tokens[2] = "$command" - return 1 - end - - # Check if we're at the right token position - if not test (count $tokens) "-eq" "$token_count" - return 1 - end - - # Additional check to ensure we're not in the middle of typing an option - if string match --quiet --regex -- "^-" (commandline --current-token) - return 1 - end - - return 0 -end - -# The following completions use the -F flag to force disable filename completion -# for various borg commands, ensuring only archive names are suggested. -# We also use the -e flag to explicitly erase all default completions before adding our custom ones. - -# Global rules to disable filename completions for specific borg commands -# This ensures that no filename completions are shown for these commands -complete -c borg -e -n '__fish_seen_subcommand_from diff delete list info extract mount export-tar rename tag undelete recreate transfer check analyze' - -# First, explicitly erase all default completions for each command -# This is the most specific rule and should take precedence -complete -c borg -e -n '__fish_borg_archive_arg "diff" 2' -complete -c borg -e -n '__fish_borg_archive_arg "diff" 3' -complete -c borg -e -n '__fish_borg_archive_arg "delete" 2' -complete -c borg -e -n '__fish_borg_archive_arg "list" 2' -complete -c borg -e -n '__fish_borg_archive_arg "info" 2' -complete -c borg -e -n '__fish_borg_archive_arg "extract" 2' -complete -c borg -e -n '__fish_borg_archive_arg "mount" 2' -complete -c borg -e -n '__fish_borg_archive_arg "export-tar" 2' -complete -c borg -e -n '__fish_borg_archive_arg "rename" 2' -complete -c borg -e -n '__fish_borg_archive_arg "tag" 2' -complete -c borg -e -n '__fish_borg_archive_arg "undelete" 2' -complete -c borg -e -n '__fish_borg_archive_arg "recreate" 2' -complete -c borg -e -n '__fish_borg_archive_arg "transfer" 2' -complete -c borg -e -n '__fish_borg_archive_arg "check" 2' -complete -c borg -e -n '__fish_borg_archive_arg "analyze" 2' - -# Also add specific rules to disable filename completions at the exact position -# This ensures that no filename completions are shown at the position where we expect archive names -complete -c borg --no-files -n '__fish_borg_archive_arg "diff" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "diff" 3' -complete -c borg --no-files -n '__fish_borg_archive_arg "delete" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "list" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "info" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "extract" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "mount" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "export-tar" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "rename" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "tag" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "undelete" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "recreate" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "transfer" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "check" 2' -complete -c borg --no-files -n '__fish_borg_archive_arg "analyze" 2' - -# Then add our custom completions with high priority and no-files -# This ensures no filename completions are shown and our custom completions take precedence -complete -c borg -p 100 -n '__fish_borg_archive_arg "diff" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "diff" 3' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "delete" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "list" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "info" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "extract" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "mount" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "export-tar" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "rename" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "tag" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "undelete" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "recreate" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "transfer" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "check" 2' -a '(__fish_borg_archives)' --no-files -complete -c borg -p 100 -n '__fish_borg_archive_arg "analyze" 2' -a '(__fish_borg_archives)' --no-files diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 6b73444913..52e4809021 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -2,8 +2,16 @@ 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. +scripts for bash, zsh, tcsh and fish. For bash, zsh and tcsh, it uses the shtab library +for basic completion generation and extends it with custom dynamic completions for +Borg-specific argument types. For fish, it currently brings its own generator that +walks the parser tree and emits fish ``complete`` commands, extended with fish versions +of the same dynamic completions: released shtab versions have several issues in their +fish backend (no file completion, conditions confused by option values and by values +matching command names, positional order ignored). Fixes are upstream in +https://github.com/tqdm/shtab/pull/232 and https://github.com/tqdm/shtab/pull/233 - +once they are merged and released, borg will use shtab's fish backend (like it already +does for bash/zsh) and the then-duplicate fish generator code here will be removed. Dynamic Completions ------------------- @@ -13,7 +21,7 @@ 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 + - In zsh and fish, shows archive metadata (name, timestamp, user@host) as descriptions - Respects --repo/-r flags to query the correct repository 2. Sort keys (SortBySpec): @@ -67,6 +75,7 @@ ) from ..helpers.argparsing import ArgumentParser from ..helpers.argparsing import ActionSubCommands +from ..helpers.argparsing import SUPPRESS from ..helpers.time import timestamp from ..helpers.parseformat import partial_format from ..manifest import AI_HUMAN_SORT_KEYS @@ -626,6 +635,350 @@ } """ +# Global fish preamble. It provides: +# - _borg_scan / _borg_using_command: command line parsing helpers used in the +# conditions (-n) of the generated `complete` commands. They rely on the +# $_borg_commands and $_borg_opts_with_value lists emitted by the generator. +# - dynamic completion functions mirroring the bash/zsh ones above. +FISH_PREAMBLE_TMPL = r""" +# Parse the command line: set $_borg_cmdpath to the (sub)command path seen so far +# (e.g. "create" or "key export") and $_borg_npos to the number of positional +# arguments already given after it. Options and their values are skipped, based +# on the generated $_borg_opts_with_value and $_borg_commands lists. +function _borg_scan + set -g _borg_cmdpath '' + set -g _borg_npos 0 + set -l tokens (commandline -opc) + set -e tokens[1] + set -l expect_value 0 + for t in $tokens + if test $expect_value -eq 1 + set expect_value 0 + continue + end + switch "$t" + case '--*=*' + continue + case '-*' + if contains -- $t $_borg_opts_with_value + set expect_value 1 + end + continue + case '*' + if test $_borg_npos -eq 0 + set -l candidate $t + if test -n "$_borg_cmdpath" + set candidate "$_borg_cmdpath $t" + end + if contains -- $candidate $_borg_commands + set -g _borg_cmdpath $candidate + continue + end + end + set -g _borg_npos (math $_borg_npos + 1) + end + end +end + +# Condition helper: true if the current (sub)command path equals the given one. +function _borg_using_command + _borg_scan + test "$_borg_cmdpath" = "$argv" +end + +# derive repo context from the command line: --repo=V, --repo V, -r=V, -rV, or -r V +function _borg_repo_args + set -l tokens (commandline -opc) + set -l n (count $tokens) + for i in (seq 2 $n) + set -l w $tokens[$i] + switch "$w" + case '--repo=*' + printf '%s\n' --repo (string replace -- '--repo=' '' $w) + return + case '-r=*' + printf '%s\n' -r (string replace -- '-r=' '' $w) + return + case '--repo' '-r' + if test $i -lt $n + printf '%s\n' $w $tokens[(math $i + 1)] + end + return + case '-r*' + printf '%s\n' -r (string replace -r -- '^-r' '' $w) + return + end + end +end + +# Complete archive names, or archive IDs when the current token starts with "aid:" +function _borg_complete_archive + set -l cur (commandline -ct) + set -l repo_args (_borg_repo_args) + if string match -q -- 'aid:*' $cur + set -l prefix (string replace -- 'aid:' '' $cur) + if test -n "$prefix"; and not string match -qr -- '^[0-9a-fA-F]+$' $prefix + return + end + # ask borg for IDs with metadata; avoid prompts and suppress stderr + borg repo-list $repo_args --format '{id}{TAB}{archive}{TAB}{time}{TAB}{username}@{hostname}{NL}' \ + 2>/dev/null /dev/null /dev/null str: + """Escape text for inclusion in a single-quoted fish string.""" + return text.replace("\\", "\\\\").replace("'", "\\'") + + +def _fish_help_text(action) -> str: + """Expand %(...)s placeholders in an action's help like argparse does and collapse whitespace.""" + if not action.help or action.help is SUPPRESS: + return "" + params = {k: v for k, v in vars(action).items() if v is not SUPPRESS} + params["prog"] = "borg" + if params.get("choices") is not None: + params["choices"] = ", ".join(str(c) for c in params["choices"]) + try: + text = action.help % params + except (KeyError, ValueError, TypeError): + text = action.help + return " ".join(text.split()) + + +def _fish_option_line(cond, action) -> str: + """Build one fish `complete` command for an option action.""" + parts = ["complete -c borg"] + if cond: + parts.append(f"-n '{cond}'") + for opt in action.option_strings: + if opt.startswith("--"): + parts.append(f"-l {opt[2:]}") + else: + parts.append(f"-s {opt[1:]}") + if action.nargs == 0: # a flag, takes no value + parts.append("-f") + else: + complete = getattr(action, "complete", None) + fish_fn = complete.get("fish") if isinstance(complete, dict) else None + if action.choices: + parts.append(f"-x -a '{_fish_quote(' '.join(str(c) for c in action.choices))}'") + elif fish_fn: + parts.append(f"-x -a '({fish_fn})'") + else: + # no completion known for the value: fall back to file completion + parts.append("-r") + help_text = _fish_help_text(action) + if help_text: + parts.append(f"-d '{_fish_quote(help_text)}'") + return " ".join(parts) + + +def _fish_completion_script(parser: ArgumentParser, preamble: str) -> str: + """Generate the fish completion script by walking the parser tree.""" + + def iter_parsers(parser, path=""): + yield path, parser + for action in parser._actions: + if isinstance(action, ActionSubCommands): + for name, sub in action.choices.items(): + yield from iter_parsers(sub, f"{path} {name}".strip()) + + parsers = list(iter_parsers(parser)) + commands = [path for path, _ in parsers if path] + + # Option strings that expect a value; _borg_scan uses this list to skip option + # values when looking for subcommand names and positional arguments. If an + # option string is a flag in one command but takes a value in another one + # (currently only --keep: prune vs. key change-location), it ends up in the + # list, i.e. is treated as value-taking - the lesser evil. + opts_with_value = sorted({opt for _, p in parsers for a in p._actions if a.nargs != 0 for opt in a.option_strings}) + + # Options available identically in every context (-h and the common options) + # are emitted only once, without a condition; everything else is emitted per + # command, guarded by a _borg_using_command condition. + global_keys = set.intersection( + *({tuple(a.option_strings) for a in p._actions if a.option_strings} for _, p in parsers) + ) + + def quoted_list(items): + return " ".join(f"'{_fish_quote(i)}'" for i in items) + + lines = [ + "# fish completion script for borg, generated by 'borg completion fish'", + "", + preamble.strip("\n"), + "", + f"set -g _borg_commands {quoted_list(commands)}", + f"set -g _borg_opts_with_value {quoted_list(opts_with_value)}", + "", + "complete -c borg -e # remove any previously defined borg completions", + ] + + for path, p in parsers: + cond = f"_borg_using_command {path}" if path else "_borg_using_command" + lines.append("") + lines.append(f"# borg {path}" if path else "# borg (top level)") + + subcommands_action = next((a for a in p._actions if isinstance(a, ActionSubCommands)), None) + if subcommands_action is not None: + helps = {ca.dest: ca.help for ca in getattr(subcommands_action, "_choices_actions", [])} + for name in subcommands_action.choices: + line = f"complete -c borg -f -n '{cond}; and test $_borg_npos -eq 0' -a '{name}'" + help_text = " ".join((helps.get(name) or "").split()) + if help_text: + line += f" -d '{_fish_quote(help_text)}'" + lines.append(line) + + for action in p._actions: + if not action.option_strings or action.help is SUPPRESS: + continue + key = tuple(action.option_strings) + if key in global_keys and path: + continue # already emitted at the top level, without a condition + lines.append(_fish_option_line(None if key in global_keys else cond, action)) + + pos_index = 0 + for action in p._actions: + if action.option_strings or isinstance(action, ActionSubCommands): + continue + open_ended = action.nargs not in (None, "?") # i.e. "*", "+" or REMAINDER + complete = getattr(action, "complete", None) + fish_fn = complete.get("fish") if isinstance(complete, dict) else None + if action.choices: + candidates = f"-a '{_fish_quote(' '.join(str(c) for c in action.choices))}'" + elif fish_fn: + candidates = f"-a '({fish_fn})'" + else: + candidates = None # no completion known: fish's default file completion applies + if candidates: + op = "-ge" if open_ended else "-eq" + line = f"complete -c borg -f -n '{cond}; and test $_borg_npos {op} {pos_index}' {candidates}" + help_text = _fish_help_text(action) + if help_text: + line += f" -d '{_fish_quote(help_text)}'" + lines.append(line) + if open_ended: + break # everything further on the command line belongs to this argument + pos_index += 1 + + return "\n".join(lines) + "\n" + def _attach_completion(parser: ArgumentParser, type_class, completion_dict: dict): """Tag all arguments with type `type_class` with completion choices from `completion_dict`.""" @@ -660,32 +1013,21 @@ def do_completion(self, args): # 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": "_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"} - ) + + def for_all_shells(fn_name): + # same-named completion function in the bash, zsh and fish preambles + return {"bash": fn_name, "zsh": fn_name, "fish": fn_name} + + _attach_completion(parser, archivename_validator, for_all_shells("_borg_complete_archive")) + _attach_completion(parser, SortBySpec, for_all_shells("_borg_complete_sortby")) + _attach_completion(parser, FilesCacheMode, for_all_shells("_borg_complete_filescachemode")) + _attach_completion(parser, CompressionSpec, for_all_shells("_borg_complete_compression_spec")) + _attach_completion(parser, PathSpec, {**shtab.DIRECTORY, "fish": "__fish_complete_directories"}) + _attach_completion(parser, ChunkerParams, for_all_shells("_borg_complete_chunker_params")) + _attach_completion(parser, tag_validator, for_all_shells("_borg_complete_tags")) + _attach_completion(parser, relative_time_marker_validator, for_all_shells("_borg_complete_relative_time")) + _attach_completion(parser, timestamp, for_all_shells("_borg_complete_timestamp")) + _attach_completion(parser, parse_file_size, for_all_shells("_borg_complete_file_size")) # Collect all commands and help topics for "borg help" completion help_choices = list(self.helptext.keys()) @@ -693,8 +1035,7 @@ def do_completion(self, args): if isinstance(action, ActionSubCommands): help_choices.extend(action.choices.keys()) - help_completion_fn = "_borg_help_topics" - _attach_help_completion(parser, {"bash": help_completion_fn, "zsh": help_completion_fn}) + _attach_help_completion(parser, for_all_shells("_borg_help_topics")) # Build preambles using partial_format to avoid escaping braces etc. sort_keys = " ".join(AI_HUMAN_SORT_KEYS) @@ -734,6 +1075,12 @@ def do_completion(self, args): "FILE_SIZE_CHOICES": file_size_choices_str, "HELP_CHOICES": help_choices, } + if args.shell == "fish": + # borg brings its own fish backend until the shtab fixes are released, see the module docstring. + fish_preamble = partial_format(FISH_PREAMBLE_TMPL, mapping) + print(_fish_completion_script(parser, fish_preamble)) + return + bash_preamble = partial_format(BASH_PREAMBLE_TMPL, mapping) zsh_preamble = partial_format(ZSH_PREAMBLE_TMPL, mapping) @@ -747,6 +1094,7 @@ def do_completion(self, args): print(script) def build_parser_completion(self, subparsers, common_parser, mid_common_parser): + # includes fish, which do_completion dispatches to borg's own backend, not to shtab's. shells = tuple(shtab.SUPPORTED_SHELLS) completion_epilog = process_epilog( diff --git a/src/borg/testsuite/archiver/completion_cmd_test.py b/src/borg/testsuite/archiver/completion_cmd_test.py index ffa091199c..b5612ec3d5 100644 --- a/src/borg/testsuite/archiver/completion_cmd_test.py +++ b/src/borg/testsuite/archiver/completion_cmd_test.py @@ -1,10 +1,12 @@ import functools import os import subprocess +import sys import tempfile import pytest +import borg from . import cmd, generate_archiver_tests, RK_ENCRYPTION pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local") # NOQA @@ -22,6 +24,7 @@ def cmd_available(cmd): needs_bash = pytest.mark.skipif(not cmd_available("bash --version"), reason="Bash not available") needs_zsh = pytest.mark.skipif(not cmd_available("zsh --version"), reason="Zsh not available") +needs_fish = pytest.mark.skipif(not cmd_available("fish --version"), reason="Fish not available") def _run_bash_completion_fn(completion_script, setup_code): @@ -38,6 +41,42 @@ def _run_bash_completion_fn(completion_script, setup_code): return result +def _fish_quote(text): + """Escape text for inclusion in a single-quoted fish string.""" + return text.replace("\\", "\\\\").replace("'", "\\'") + + +def _fish_complete_candidates(completion_script, cmdline, path_prepend=None): + """Source the completion script in fish, return the completion candidates for cmdline.""" + code = completion_script + f"\ncomplete -C'{_fish_quote(cmdline)}'\n" + with tempfile.NamedTemporaryFile(mode="w", suffix=".fish", delete=False) as f: + f.write(code) + script_path = f.name + env = dict(os.environ) + if path_prepend: + env["PATH"] = path_prepend + os.pathsep + env.get("PATH", "") + try: + result = subprocess.run(["fish", script_path], capture_output=True, text=True, timeout=120, env=env) + finally: + os.unlink(script_path) + assert result.returncode == 0, f"fish failed: {result.stderr}" + # each output line is "candidatedescription" (or just "candidate") + return [line.split("\t")[0] for line in result.stdout.splitlines() if line.strip()] + + +def _borg_shim_dir(tmp_path): + """Create a dir with a borg script that runs the borg under test, for use inside fish.""" + src_dir = os.path.dirname(os.path.dirname(os.path.abspath(borg.__file__))) + shim_dir = tmp_path / "borg-shim" + shim_dir.mkdir() + shim = shim_dir / "borg" + shim.write_text( + f'#!/bin/sh\nexport PYTHONPATH="{src_dir}${{PYTHONPATH:+:$PYTHONPATH}}"\nexec "{sys.executable}" -m borg "$@"\n' + ) + shim.chmod(0o755) + return str(shim_dir) + + # -- output sanity checks ----------------------------------------------------- @@ -57,6 +96,14 @@ def test_zsh_completion_nontrivial(archivers, request): assert output.count("\n") > 100, f"Zsh completion suspiciously few lines: {output.count(chr(10))}" +def test_fish_completion_nontrivial(archivers, request): + """Verify the generated Fish completion is non-trivially sized.""" + archiver = request.getfixturevalue(archivers) + output = cmd(archiver, "completion", "fish") + assert len(output) > 5000, f"Fish completion suspiciously small: {len(output)} chars" + assert output.count("\n") > 100, f"Fish completion suspiciously few lines: {output.count(chr(10))}" + + # -- syntax validation -------------------------------------------------------- @@ -90,6 +137,15 @@ def test_zsh_completion_syntax(archivers, request): assert result.returncode == 0, f"Generated Zsh completion has syntax errors: {result.stderr.decode()}" +@needs_fish +def test_fish_completion_syntax(archivers, request): + """Verify the generated Fish completion script has valid syntax.""" + archiver = request.getfixturevalue(archivers) + output = cmd(archiver, "completion", "fish") + result = _check_shell_syntax(output, "fish", ".fish") + assert result.returncode == 0, f"Generated Fish completion has syntax errors: {result.stderr.decode()}" + + # -- borg-specific preamble function behavior (bash) -------------------------- @@ -182,3 +238,97 @@ def test_bash_archive_aid_completion(archivers, request): assert len(lines) >= 1, "Expected at least one archive ID completion" for line in lines: assert line.startswith("aid:"), f"Expected aid: prefix, got: {line}" + + +# -- borg-specific completion behavior (fish) --------------------------------- + + +@needs_fish +def test_fish_subcommand_completion(archivers, request): + """Command names should be completed at the top level and inside command groups.""" + archiver = request.getfixturevalue(archivers) + script = cmd(archiver, "completion", "fish") + + candidates = _fish_complete_candidates(script, "borg ") + for name in ("create", "extract", "repo-list", "key", "debug"): + assert name in candidates, f"expected command {name} in: {candidates}" + + candidates = _fish_complete_candidates(script, "borg key ") + for name in ("export", "import", "change-passphrase"): + assert name in candidates, f"expected key subcommand {name} in: {candidates}" + + +@needs_fish +def test_fish_option_completion(archivers, request): + """Option names and static option values should be completed.""" + archiver = request.getfixturevalue(archivers) + script = cmd(archiver, "completion", "fish") + + candidates = _fish_complete_candidates(script, "borg create --compres") + assert "--compression" in candidates, f"expected --compression in: {candidates}" + + candidates = _fish_complete_candidates(script, "borg create --compression ") + assert "lz4" in candidates, f"expected lz4 in: {candidates}" + assert "zstd,3" in candidates, f"expected zstd,3 in: {candidates}" + + +@needs_fish +def test_fish_sortby_dedup(archivers, request): + """_borg_complete_sortby should not re-offer already-selected sort keys.""" + archiver = request.getfixturevalue(archivers) + script = cmd(archiver, "completion", "fish") + + candidates = _fish_complete_candidates(script, "borg repo-list --sort-by timestamp,") + assert "timestamp,archive" in candidates, f"expected timestamp,archive in: {candidates}" + assert "timestamp,timestamp" not in candidates, f"timestamp was re-offered: {candidates}" + + +@needs_fish +def test_fish_filescachemode_exclusivity(archivers, request): + """_borg_complete_filescachemode should enforce ctime/mtime and disabled mutual exclusion.""" + archiver = request.getfixturevalue(archivers) + script = cmd(archiver, "completion", "fish") + + candidates = _fish_complete_candidates(script, "borg create --files-cache ctime,") + assert "ctime,size" in candidates, f"expected ctime,size in: {candidates}" + assert "ctime,mtime" not in candidates, f"mtime offered after ctime: {candidates}" + assert "ctime,disabled" not in candidates, f"disabled offered after ctime: {candidates}" + + candidates = _fish_complete_candidates(script, "borg create --files-cache disabled,") + assert candidates == [], f"completions offered after disabled: {candidates}" + + +@needs_fish +def test_fish_archive_name_completion(archivers, request, tmp_path): + """Archive names should be completed from a real repo.""" + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "mybackup-2024", archiver.input_path) + cmd(archiver, "create", "mybackup-2025", archiver.input_path) + + script = cmd(archiver, "completion", "fish") + repo = archiver.repository_path + + candidates = _fish_complete_candidates( + script, f"borg delete --repo {repo} mybackup", path_prepend=_borg_shim_dir(tmp_path) + ) + assert "mybackup-2024" in candidates, f"archive name missing: {candidates}" + assert "mybackup-2025" in candidates, f"archive name missing: {candidates}" + + +@needs_fish +def test_fish_archive_aid_completion(archivers, request, tmp_path): + """aid: prefixed archive IDs should be completed from a real repo.""" + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "testarchive", archiver.input_path) + + script = cmd(archiver, "completion", "fish") + repo = archiver.repository_path + + candidates = _fish_complete_candidates( + script, f"borg info --repo {repo} aid:", path_prepend=_borg_shim_dir(tmp_path) + ) + assert len(candidates) >= 1, "Expected at least one archive ID completion" + for candidate in candidates: + assert candidate.startswith("aid:"), f"Expected aid: prefix, got: {candidate}" diff --git a/src/borg/testsuite/shell_completions_test.py b/src/borg/testsuite/shell_completions_test.py deleted file mode 100644 index 4fa26faa5a..0000000000 --- a/src/borg/testsuite/shell_completions_test.py +++ /dev/null @@ -1,22 +0,0 @@ -import subprocess -from pathlib import Path - -import pytest - -SHELL_COMPLETIONS_DIR = Path(__file__).parent / ".." / ".." / ".." / "scripts" / "shell_completions" - - -def test_fish_completion_is_valid(): - """Test that the Fish completion file is valid Fish syntax.""" - fish_completion_file = SHELL_COMPLETIONS_DIR / "fish" / "borg.fish" - assert fish_completion_file.is_file() - - # Check if Fish is available - try: - subprocess.run(["fish", "--version"], capture_output=True, check=True) - except (subprocess.SubprocessError, FileNotFoundError): - pytest.skip("Fish not available") - - # Test whether the Fish completion file can be sourced without errors - result = subprocess.run(["fish", "-c", f"source {str(fish_completion_file)}"], capture_output=True) - assert result.returncode == 0, f"Fish completion file has syntax errors: {result.stderr.decode()}"