Browse Source

update "modern" error RCs (docs and code)

Thomas Waldmann 1 year ago
parent
commit
62ad0369ef

+ 138 - 66
docs/internals/frontends.rst

@@ -538,92 +538,164 @@ Message IDs are strings that essentially give a log message or operation a name,
 full text, since texts change more frequently. Message IDs are unambiguous and reduce the need to parse
 log messages.
 
-Assigned message IDs are:
+Assigned message IDs and related error RCs (exit codes) are:
 
 .. See scripts/errorlist.py; this is slightly edited.
 
 Errors
-    Archive.AlreadyExists
+    Error rc: 2 traceback: no
+        Error: {}
+    ErrorWithTraceback rc: 2 traceback: yes
+        Error: {}
+
+    ExtensionModuleError rc: 2 traceback: no
+        The Borg binary extension modules do not seem to be properly installed.
+    PythonLibcTooOld rc: 2 traceback: no
+        FATAL: this Python was compiled for a too old (g)libc and misses required functionality.
+    Buffer.MemoryLimitExceeded rc: 2 traceback: no
+        Requested buffer size {} is above the limit of {}.
+    EfficientCollectionQueue.SizeUnderflow rc: 2 traceback: no
+        Could not pop_front first {} elements, collection only has {} elements..
+    RTError rc: 2 traceback: no
+        Runtime Error: {}
+
+    CancelledByUser rc: 3 traceback: no
+        Cancelled by user.
+
+    CommandError rc: 4 traceback: no
+        Command Error: {}
+    PlaceholderError rc: 5 traceback: no
+        Formatting Error: "{}".format({}): {}({})
+    InvalidPlaceholder rc: 6 traceback: no
+        Invalid placeholder "{}" in string: {}
+
+    Repository.AlreadyExists rc: 10 traceback: no
+        A repository already exists at {}.
+    Repository.AtticRepository rc: 11 traceback: no
+        Attic repository detected. Please run "borg upgrade {}".
+    Repository.CheckNeeded rc: 12 traceback: yes
+        Inconsistency detected. Please run "borg check {}".
+    Repository.DoesNotExist rc: 13 traceback: no
+        Repository {} does not exist.
+    Repository.InsufficientFreeSpaceError rc: 14 traceback: no
+        Insufficient free space to complete transaction (required: {}, available: {}).
+    Repository.InvalidRepository rc: 15 traceback: no
+        {} is not a valid repository. Check repo config.
+    Repository.InvalidRepositoryConfig rc: 16 traceback: no
+        {} does not have a valid configuration. Check repo config [{}].
+    Repository.ObjectNotFound rc: 17 traceback: yes
+        Object with key {} not found in repository {}.
+    Repository.ParentPathDoesNotExist rc: 18 traceback: no
+        The parent path of the repo directory [{}] does not exist.
+    Repository.PathAlreadyExists rc: 19 traceback: no
+        There is already something at {}.
+    Repository.StorageQuotaExceeded rc: 20 traceback: no
+        The storage quota ({}) has been exceeded ({}). Try deleting some archives.
+
+    MandatoryFeatureUnsupported rc: 25 traceback: no
+        Unsupported repository feature(s) {}. A newer version of borg is required to access this repository.
+    NoManifestError rc: 26 traceback: no
+        Repository has no manifest.
+    UnsupportedManifestError rc: 27 traceback: no
+        Unsupported manifest envelope. A newer version is required to access this repository.
+
+    Archive.AlreadyExists rc: 30 traceback: no
         Archive {} already exists
-    Archive.DoesNotExist
+    Archive.DoesNotExist rc: 31 traceback: no
         Archive {} does not exist
-    Archive.IncompatibleFilesystemEncodingError
+    Archive.IncompatibleFilesystemEncodingError rc: 32 traceback: no
         Failed to encode filename "{}" into file system encoding "{}". Consider configuring the LANG environment variable.
-    Cache.CacheInitAbortedError
-        Cache initialization aborted
-    Cache.EncryptionMethodMismatch
-        Repository encryption method changed since last access, refusing to continue
-    Cache.RepositoryAccessAborted
-        Repository access aborted
-    Cache.RepositoryIDNotUnique
-        Cache is newer than repository - do you have multiple, independently updated repos with same ID?
-    Cache.RepositoryReplay
-        Cache is newer than repository - this is either an attack or unsafe (multiple repos with same ID)
-    Buffer.MemoryLimitExceeded
-        Requested buffer size {} is above the limit of {}.
-    ExtensionModuleError
-        The Borg binary extension modules do not seem to be properly installed
-    IntegrityError
-        Data integrity error: {}
-    NoManifestError
-        Repository has no manifest.
-    PlaceholderError
-        Formatting Error: "{}".format({}): {}({})
-    KeyfileInvalidError
+
+    KeyfileInvalidError rc: 40 traceback: no
         Invalid key file for repository {} found in {}.
-    KeyfileMismatchError
+    KeyfileMismatchError rc: 41 traceback: no
         Mismatch between repository {} and key file {}.
-    KeyfileNotFoundError
+    KeyfileNotFoundError rc: 42 traceback: no
         No key file for repository {} found in {}.
-    PassphraseWrong
-        passphrase supplied in BORG_PASSPHRASE is incorrect
-    PasswordRetriesExceeded
-        exceeded the maximum password retries
-    RepoKeyNotFoundError
-        No key entry found in the config of repository {}.
-    UnsupportedManifestError
-        Unsupported manifest envelope. A newer version is required to access this repository.
-    UnsupportedPayloadError
-        Unsupported payload type {}. A newer version is required to access this repository.
-    NotABorgKeyFile
+    NotABorgKeyFile rc: 43 traceback: no
         This file is not a borg key backup, aborting.
-    RepoIdMismatch
+    RepoKeyNotFoundError rc: 44 traceback: no
+        No key entry found in the config of repository {}.
+    RepoIdMismatch rc: 45 traceback: no
         This key backup seems to be for a different backup repository, aborting.
-    UnencryptedRepo
-        Keymanagement not available for unencrypted repositories.
-    UnknownKeyType
-        Keytype {0} is unknown.
-    LockError
+    UnencryptedRepo rc: 46 traceback: no
+        Key management not available for unencrypted repositories.
+    UnknownKeyType rc: 47 traceback: no
+        Key type {0} is unknown.
+   UnsupportedPayloadError rc: 48 traceback: no
+        Unsupported payload type {}. A newer version is required to access this repository.
+
+    NoPassphraseFailure rc: 50 traceback: no
+        can not acquire a passphrase: {}
+    PasscommandFailure rc: 51 traceback: no
+        passcommand supplied in BORG_PASSCOMMAND failed: {}
+    PassphraseWrong rc: 52 traceback: no
+        passphrase supplied in BORG_PASSPHRASE, by BORG_PASSCOMMAND or via BORG_PASSPHRASE_FD is incorrect.
+    PasswordRetriesExceeded rc: 53 traceback: no
+        exceeded the maximum password retries
+
+    Cache.CacheInitAbortedError rc: 60 traceback: no
+        Cache initialization aborted
+    Cache.EncryptionMethodMismatch rc: 61 traceback: no
+        Repository encryption method changed since last access, refusing to continue
+    Cache.RepositoryAccessAborted rc: 62 traceback: no
+        Repository access aborted
+    Cache.RepositoryIDNotUnique rc: 63 traceback: no
+        Cache is newer than repository - do you have multiple, independently updated repos with same ID?
+    Cache.RepositoryReplay rc: 64 traceback: no
+        Cache, or information obtained from the security directory is newer than repository - this is either an attack or unsafe (multiple repos with same ID)
+
+    LockError rc: 70 traceback: no
         Failed to acquire the lock {}.
-    LockErrorT
+    LockErrorT rc: 71 traceback: yes
         Failed to acquire the lock {}.
-    ConnectionClosed
+    LockFailed rc: 72 traceback: yes
+        Failed to create/acquire the lock {} ({}).
+    LockTimeout rc: 73 traceback: no
+        Failed to create/acquire the lock {} (timeout).
+    NotLocked rc: 74 traceback: yes
+        Failed to release the lock {} (was not locked).
+    NotMyLock rc: 75 traceback: yes
+        Failed to release the lock {} (was/is locked, but not by me).
+
+    ConnectionClosed rc: 80 traceback: no
         Connection closed by remote host
-    InvalidRPCMethod
+    ConnectionClosedWithHint rc: 81 traceback: no
+        Connection closed by remote host. {}
+    InvalidRPCMethod rc: 82 traceback: no
         RPC method {} is not valid
-    PathNotAllowed
-        Repository path not allowed
-    RemoteRepository.RPCServerOutdated
+    PathNotAllowed rc: 83 traceback: no
+        Repository path not allowed: {}
+    RemoteRepository.RPCServerOutdated rc: 84 traceback: no
         Borg server is too old for {}. Required version {}
-    UnexpectedRPCDataFormatFromClient
+    UnexpectedRPCDataFormatFromClient rc: 85 traceback: no
         Borg {}: Got unexpected RPC data format from client.
-    UnexpectedRPCDataFormatFromServer
+    UnexpectedRPCDataFormatFromServer rc: 86 traceback: no
         Got unexpected RPC data format from server:
         {}
-    Repository.AlreadyExists
-        Repository {} already exists.
-    Repository.CheckNeeded
-        Inconsistency detected. Please run "borg check {}".
-    Repository.DoesNotExist
-        Repository {} does not exist.
-    Repository.InsufficientFreeSpaceError
-        Insufficient free space to complete transaction (required: {}, available: {}).
-    Repository.InvalidRepository
-        {} is not a valid repository. Check repo config.
-    Repository.AtticRepository
-        Attic repository detected. Please run "borg upgrade {}".
-    Repository.ObjectNotFound
-        Object with key {} not found in repository {}.
+
+    IntegrityError rc: 90 traceback: yes
+        Data integrity error: {}
+    FileIntegrityError rc: 91 traceback: yes
+        File failed integrity check: {}
+    DecompressionError rc: 92 traceback: yes
+        Decompression error: {}
+
+    ArchiveTAMInvalid rc: 95 traceback: yes
+        Data integrity error: {}
+    ArchiveTAMRequiredError rc: 96 traceback: yes
+        Archive '{}' is unauthenticated, but it is required for this repository.
+    TAMInvalid rc: 97 traceback: yes
+        Data integrity error: {}
+    TAMRequiredError rc: 98 traceback: yes
+        Manifest is unauthenticated, but it is required for this repository.
+
+        This either means that you are under attack, or that you modified this repository
+        with a Borg version older than 1.0.9 after TAM authentication was enabled.
+
+        In the latter case, use "borg upgrade --tam --force '{}'" to re-authenticate the manifest.
+    TAMUnsupportedSuiteError rc: 99 traceback: yes
+        Could not verify manifest: Unsupported suite {!r}; a newer version is needed.
 
 Operations
     - cache.begin_transaction

+ 3 - 0
docs/usage/general/environment.rst.inc

@@ -35,6 +35,9 @@ General:
         Main usecase for this is to fully automate ``borg change-passphrase``.
     BORG_DISPLAY_PASSPHRASE
         When set, use the value to answer the "display the passphrase for verification" question when defining a new passphrase for encrypted repositories.
+    BORG_EXIT_CODES
+        When set to "modern", the borg process will return more specific exit codes (rc).
+        Default is "legacy" and returns rc 2 for all errors, 1 for all warnings, 0 for success.
     BORG_HOST_ID
         Borg usually computes a host id from the FQDN plus the results of ``uuid.getnode()`` (which usually returns
         a unique id based on the MAC address of the network interface. Except if that MAC happens to be all-zero - in

+ 4 - 2
docs/usage/general/return-codes.rst.inc

@@ -7,10 +7,12 @@ Borg can exit with the following return codes (rc):
 Return code Meaning
 =========== =======
 0           success (logged as INFO)
-1           warning (operation reached its normal end, but there were warnings --
+1           generic warning (operation reached its normal end, but there were warnings --
             you should check the log, logged as WARNING)
-2           error (like a fatal error, a local or remote exception, the operation
+2           generic error (like a fatal error, a local or remote exception, the operation
             did not reach its normal end, logged as ERROR)
+3..99       specific error (enabled by BORG_EXIT_CODES=modern)
+100..127    specific warning (enabled by BORG_EXIT_CODES=modern)
 128+N       killed by signal N (e.g. 137 == kill -9)
 =========== =======
 

+ 6 - 3
src/borg/archive.py

@@ -424,14 +424,17 @@ def get_item_uid_gid(item, *, numeric, uid_forced=None, gid_forced=None, uid_def
 
 class Archive:
 
-    class DoesNotExist(Error):
-        """Archive {} does not exist"""
-
     class AlreadyExists(Error):
         """Archive {} already exists"""
+        exit_mcode = 30
+
+    class DoesNotExist(Error):
+        """Archive {} does not exist"""
+        exit_mcode = 31
 
     class IncompatibleFilesystemEncodingError(Error):
         """Failed to encode filename "{}" into file system encoding "{}". Consider configuring the LANG environment variable."""
+        exit_mcode = 32
 
     def __init__(self, repository, key, manifest, name, cache=None, create=False,
                  checkpoint_interval=1800, numeric_ids=False, noatime=False, noctime=False,

+ 40 - 72
src/borg/archiver.py

@@ -46,7 +46,7 @@ try:
     from .crypto.key import key_creator, key_argument_names, tam_required_file, tam_required, RepoKey, PassphraseKey
     from .crypto.keymanager import KeyManager
     from .helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, EXIT_SIGNAL_BASE
-    from .helpers import Error, NoManifestError, set_ec
+    from .helpers import Error, NoManifestError, CancelledByUser, RTError, CommandError, modern_ec, set_ec
     from .helpers import positive_int_validator, location_validator, archivename_validator, ChunkerParams, Location
     from .helpers import PrefixSpec, GlobSpec, CommentSpec, PathSpec, SortBySpec, FilesCacheMode
     from .helpers import BaseFormatter, ItemFormatter, ArchiveFormatter
@@ -240,11 +240,6 @@ class Archiver:
         self.prog = prog
         self.last_checkpoint = time.monotonic()
 
-    def print_error(self, msg, *args):
-        msg = args and msg % args or msg
-        self.exit_code = EXIT_ERROR
-        logger.error(msg)
-
     def print_warning(self, msg, *args):
         msg = args and msg % args or msg
         self.exit_code = EXIT_WARNING  # we do not terminate here, so it is a warning
@@ -330,21 +325,18 @@ class Archiver:
             if not yes(msg, false_msg="Aborting.", invalid_msg="Invalid answer, aborting.",
                        truish=('YES', ), retry=False,
                        env_var_override='BORG_CHECK_I_KNOW_WHAT_I_AM_DOING'):
-                return EXIT_ERROR
+                raise CancelledByUser()
         if args.repo_only and any(
            (args.verify_data, args.first, args.last, args.prefix is not None, args.glob_archives)):
-            self.print_error("--repository-only contradicts --first, --last, --glob-archives, --prefix and --verify-data arguments.")
-            return EXIT_ERROR
+            raise CommandError("--repository-only contradicts --first, --last, --glob-archives, --prefix and --verify-data arguments.")
         if args.repair and args.max_duration:
-            self.print_error("--repair does not allow --max-duration argument.")
-            return EXIT_ERROR
+            raise CommandError("--repair does not allow --max-duration argument.")
         if args.max_duration and not args.repo_only:
             # when doing a partial repo check, we can only check crc32 checksums in segment files,
             # we can't build a fresh repo index in memory to verify the on-disk index against it.
             # thus, we should not do an archives check based on a unknown-quality on-disk repo index.
             # also, there is no max_duration support in the archives check code anyway.
-            self.print_error("--repository-only is required for --max-duration support.")
-            return EXIT_ERROR
+            raise CommandError("--repository-only is required for --max-duration support.")
         if not args.archives_only:
             if not repository.check(repair=args.repair, save_space=args.save_space, max_duration=args.max_duration):
                 return EXIT_WARNING
@@ -361,8 +353,7 @@ class Archiver:
     def do_change_passphrase(self, args, repository, manifest, key):
         """Change repository key file passphrase"""
         if not hasattr(key, 'change_passphrase'):
-            print('This repository is not encrypted, cannot change the passphrase.')
-            return EXIT_ERROR
+            raise CommandError('This repository is not encrypted, cannot change the passphrase.')
         key.change_passphrase()
         logger.info('Key updated')
         if hasattr(key, 'find_key'):
@@ -384,8 +375,7 @@ class Archiver:
                 else:
                     manager.export(args.path)
             except IsADirectoryError:
-                self.print_error(f"'{args.path}' must be a file, not a directory")
-                return EXIT_ERROR
+                raise CommandError(f"'{args.path}' must be a file, not a directory")
         return EXIT_SUCCESS
 
     @with_repository(lock=False, exclusive=False, manifest=False, cache=False)
@@ -394,16 +384,13 @@ class Archiver:
         manager = KeyManager(repository)
         if args.paper:
             if args.path:
-                self.print_error("with --paper import from file is not supported")
-                return EXIT_ERROR
+                raise CommandError("with --paper import from file is not supported")
             manager.import_paperkey(args)
         else:
             if not args.path:
-                self.print_error("input file to import key from expected")
-                return EXIT_ERROR
+                raise CommandError("expected input file to import key from")
             if args.path != '-' and not os.path.exists(args.path):
-                self.print_error("input file does not exist: " + args.path)
-                return EXIT_ERROR
+                raise CommandError("input file does not exist: " + args.path)
             manager.import_keyfile(args)
         return EXIT_SUCCESS
 
@@ -536,16 +523,13 @@ class Archiver:
                             env = prepare_subprocess_env(system=True)
                             proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=ignore_sigint)
                         except (FileNotFoundError, PermissionError) as e:
-                            self.print_error('Failed to execute command: %s', e)
-                            return self.exit_code
+                            raise CommandError('Failed to execute command: %s', e)
                         status = fso.process_pipe(path=path, cache=cache, fd=proc.stdout, mode=mode, user=user, group=group)
                         rc = proc.wait()
                         if rc != 0:
-                            self.print_error('Command %r exited with status %d', args.paths[0], rc)
-                            return self.exit_code
+                            raise CommandError('Command %r exited with status %d', args.paths[0], rc)
                     except BackupOSError as e:
-                        self.print_error('%s: %s', path, e)
-                        return self.exit_code
+                        raise Error('%s: %s', path, e)
                 else:
                     status = '-'
                 self.print_file_status(status, path)
@@ -556,8 +540,7 @@ class Archiver:
                         env = prepare_subprocess_env(system=True)
                         proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=ignore_sigint)
                     except (FileNotFoundError, PermissionError) as e:
-                        self.print_error('Failed to execute command: %s', e)
-                        return self.exit_code
+                        raise CommandError('Failed to execute command: %s', e)
                     pipe_bin = proc.stdout
                 else:  # args.paths_from_stdin == True
                     pipe_bin = sys.stdin.buffer
@@ -578,8 +561,7 @@ class Archiver:
                 if args.paths_from_command:
                     rc = proc.wait()
                     if rc != 0:
-                        self.print_error('Command %r exited with status %d', args.paths[0], rc)
-                        return self.exit_code
+                        raise CommandError('Command %r exited with status %d', args.paths[0], rc)
             else:
                 for path in args.paths:
                     if path == '-':  # stdin
@@ -621,7 +603,7 @@ class Archiver:
                 if sig_int:
                     # do not save the archive if the user ctrl-c-ed - it is valid, but incomplete.
                     # we already have a checkpoint archive in this case.
-                    self.print_error("Got Ctrl-C / SIGINT.")
+                    raise Error("Got Ctrl-C / SIGINT.")
                 else:
                     archive.save(comment=args.comment, timestamp=args.timestamp)
                     args.stats |= args.json
@@ -1189,8 +1171,7 @@ class Archiver:
         explicit_archives_specified = args.location.archive or args.archives
         self.output_list = args.output_list
         if archive_filter_specified and explicit_archives_specified:
-            self.print_error('Mixing archive filters and explicitly named archives is not supported.')
-            return self.exit_code
+            raise CommandError('Mixing archive filters and explicitly named archives is not supported.')
         if archive_filter_specified or explicit_archives_specified:
             return self._delete_archives(args, repository)
         else:
@@ -1270,7 +1251,7 @@ class Archiver:
                         uncommitted_deletes = 0 if checkpointed else (uncommitted_deletes + 1)
             if sig_int:
                 # Ctrl-C / SIGINT: do not checkpoint (commit) again, we already have a checkpoint in this case.
-                self.print_error("Got Ctrl-C / SIGINT.")
+                raise Error("Got Ctrl-C / SIGINT.")
             elif uncommitted_deletes > 0:
                 checkpoint_func()
             if args.stats:
@@ -1325,8 +1306,7 @@ class Archiver:
                 msg = '\n'.join(msg)
                 if not yes(msg, false_msg="Aborting.", invalid_msg='Invalid answer, aborting.', truish=('YES',),
                            retry=False, env_var_override='BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'):
-                    self.exit_code = EXIT_ERROR
-                    return self.exit_code
+                    raise CancelledByUser()
             if not dry_run:
                 repository.destroy()
                 logger.info("Repository deleted.")
@@ -1348,12 +1328,10 @@ class Archiver:
 
         from .fuse_impl import llfuse, BORG_FUSE_IMPL
         if llfuse is None:
-            self.print_error('borg mount not available: no FUSE support, BORG_FUSE_IMPL=%s.' % BORG_FUSE_IMPL)
-            return self.exit_code
+            raise RTError('borg mount not available: no FUSE support, BORG_FUSE_IMPL=%s.' % BORG_FUSE_IMPL)
 
         if not os.path.isdir(args.mountpoint) or not os.access(args.mountpoint, os.R_OK | os.W_OK | os.X_OK):
-            self.print_error('%s: Mountpoint must be a writable directory' % args.mountpoint)
-            return self.exit_code
+            raise RTError('%s: Mount point must be a writable directory' % args.mountpoint)
 
         return self._do_mount(args)
 
@@ -1368,7 +1346,7 @@ class Archiver:
                 operations.mount(args.mountpoint, args.options, args.foreground)
             except RuntimeError:
                 # Relevant error message already printed to stderr by FUSE
-                self.exit_code = EXIT_ERROR
+                raise RTError("FUSE mount failed")
         return self.exit_code
 
     def do_umount(self, args):
@@ -1380,13 +1358,11 @@ class Archiver:
         """List archive or repository contents"""
         if args.location.archive:
             if args.json:
-                self.print_error('The --json option is only valid for listing archives, not archive contents.')
-                return self.exit_code
+                raise CommandError('The --json option is only valid for listing archives, not archive contents.')
             return self._list_archive(args, repository, manifest, key)
         else:
             if args.json_lines:
-                self.print_error('The --json-lines option is only valid for listing archive contents, not archives.')
-                return self.exit_code
+                raise CommandError('The --json-lines option is only valid for listing archive contents, not archives.')
             return self._list_repository(args, repository, manifest, key)
 
     def _list_archive(self, args, repository, manifest, key):
@@ -1533,10 +1509,9 @@ class Archiver:
         """Prune repository archives according to specified rules"""
         if not any((args.secondly, args.minutely, args.hourly, args.daily,
                     args.weekly, args.monthly, args.yearly, args.within)):
-            self.print_error('At least one of the "keep-within", "keep-last", '
-                             '"keep-secondly", "keep-minutely", "keep-hourly", "keep-daily", '
-                             '"keep-weekly", "keep-monthly" or "keep-yearly" settings must be specified.')
-            return self.exit_code
+            raise CommandError('At least one of the "keep-within", "keep-last", '
+                               '"keep-secondly", "keep-minutely", "keep-hourly", "keep-daily", '
+                               '"keep-weekly", "keep-monthly" or "keep-yearly" settings must be specified.')
         if args.prefix is not None:
             args.glob_archives = args.prefix + '*'
         checkpoint_re = r'\.checkpoint(\.\d+)?'
@@ -1615,7 +1590,7 @@ class Archiver:
             pi.finish()
             if sig_int:
                 # Ctrl-C / SIGINT: do not checkpoint (commit) again, we already have a checkpoint in this case.
-                self.print_error("Got Ctrl-C / SIGINT.")
+                raise Error("Got Ctrl-C / SIGINT.")
             elif uncommitted_deletes > 0:
                 checkpoint_func()
             if args.stats:
@@ -1722,15 +1697,13 @@ class Archiver:
         if args.location.archive:
             name = args.location.archive
             if recreater.is_temporary_archive(name):
-                self.print_error('Refusing to work on temporary archive of prior recreate: %s', name)
-                return self.exit_code
+                raise CommandError('Refusing to work on temporary archive of prior recreate: %s', name)
             if not recreater.recreate(name, args.comment, args.target):
-                self.print_error('Nothing to do. Archive was not processed.\n'
-                                 'Specify at least one pattern, PATH, --comment, re-compression or re-chunking option.')
+                raise CommandError('Nothing to do. Archive was not processed.\n'
+                                   'Specify at least one pattern, PATH, --comment, re-compression or re-chunking option.')
         else:
             if args.target is not None:
-                self.print_error('--target: Need to specify single archive')
-                return self.exit_code
+                raise CommandError('--target: Need to specify single archive')
             for archive in manifest.archives.list(sort_by=['ts']):
                 name = archive.name
                 if recreater.is_temporary_archive(name):
@@ -1945,8 +1918,7 @@ class Archiver:
 
         if not args.list:
             if args.name is None:
-                self.print_error('No config key name was provided.')
-                return self.exit_code
+                raise CommandError('No config key name was provided.')
 
             try:
                 section, name = args.name.split('.')
@@ -2158,8 +2130,7 @@ class Archiver:
         except (ValueError, UnicodeEncodeError):
             wanted = None
         if not wanted:
-            self.print_error('search term needs to be hex:123abc or str:foobar style')
-            return EXIT_ERROR
+            raise CommandError('search term needs to be hex:123abc or str:foobar style')
 
         from .crypto.key import key_factory
         # set up the key without depending on a manifest obj
@@ -2212,13 +2183,11 @@ class Archiver:
             if len(id) != 32:  # 256bit
                 raise ValueError("id must be 256bits or 64 hex digits")
         except ValueError as err:
-            print(f"object id {hex_id} is invalid [{str(err)}].")
-            return EXIT_ERROR
+            raise CommandError(f"object id {hex_id} is invalid [{str(err)}].")
         try:
             data = repository.get(id)
         except Repository.ObjectNotFound:
-            print("object %s not found." % hex_id)
-            return EXIT_ERROR
+            raise RTError("object %s not found." % hex_id)
         with open(args.path, "wb") as f:
             f.write(data)
         print("object %s fetched." % hex_id)
@@ -2244,8 +2213,7 @@ class Archiver:
             if len(id) != 32:  # 256bit
                 raise ValueError("id must be 256bits or 64 hex digits")
         except ValueError as err:
-            print(f"object id {hex_id} is invalid [{str(err)}].")
-            return EXIT_ERROR
+            raise CommandError(f"object id {hex_id} is invalid [{str(err)}].")
         repository.put(id, data)
         print("object %s put." % hex_id)
         repository.commit(compact=False)
@@ -5330,7 +5298,7 @@ def main():  # pragma: no cover
         except argparse.ArgumentTypeError as e:
             # we might not have logging setup yet, so get out quickly
             print(str(e), file=sys.stderr)
-            sys.exit(EXIT_ERROR)
+            sys.exit(CommandError.exit_mcode if modern_ec else EXIT_ERROR)
         except Exception:
             msg = 'Local Exception'
             tb = f'{traceback.format_exc()}\n{sysinfo()}'
@@ -5388,9 +5356,9 @@ def main():  # pragma: no cover
             exit_msg = 'terminating with %s status, rc %d'
             if exit_code == EXIT_SUCCESS:
                 rc_logger.info(exit_msg % ('success', exit_code))
-            elif exit_code == EXIT_WARNING:
+            elif exit_code == EXIT_WARNING or EXIT_WARNING_BASE <= exit_code < EXIT_SIGNAL_BASE:
                 rc_logger.warning(exit_msg % ('warning', exit_code))
-            elif exit_code == EXIT_ERROR:
+            elif exit_code == EXIT_ERROR or EXIT_ERROR_BASE <= exit_code < EXIT_WARNING_BASE:
                 rc_logger.error(exit_msg % ('error', exit_code))
             elif exit_code >= EXIT_SIGNAL_BASE:
                 rc_logger.error(exit_msg % ('signal', exit_code))

+ 13 - 8
src/borg/cache.py

@@ -341,20 +341,25 @@ class CacheConfig:
 class Cache:
     """Client Side cache
     """
-    class RepositoryIDNotUnique(Error):
-        """Cache is newer than repository - do you have multiple, independently updated repos with same ID?"""
-
-    class RepositoryReplay(Error):
-        """Cache, or information obtained from the security directory is newer than repository - this is either an attack or unsafe (multiple repos with same ID)"""
-
     class CacheInitAbortedError(Error):
         """Cache initialization aborted"""
+        exit_mcode = 60
+
+    class EncryptionMethodMismatch(Error):
+        """Repository encryption method changed since last access, refusing to continue"""
+        exit_mcode = 61
 
     class RepositoryAccessAborted(Error):
         """Repository access aborted"""
+        exit_mcode = 62
 
-    class EncryptionMethodMismatch(Error):
-        """Repository encryption method changed since last access, refusing to continue"""
+    class RepositoryIDNotUnique(Error):
+        """Cache is newer than repository - do you have multiple, independently updated repos with same ID?"""
+        exit_mcode = 63
+
+    class RepositoryReplay(Error):
+        """Cache, or information obtained from the security directory is newer than repository - this is either an attack or unsafe (multiple repos with same ID)"""
+        exit_mcode = 64
 
     @staticmethod
     def break_lock(repository, path=None):

+ 4 - 3
src/borg/constants.py

@@ -93,10 +93,11 @@ FILES_CACHE_MODE_UI_DEFAULT = 'ctime,size,inode'  # default for "borg create" co
 FILES_CACHE_MODE_DISABLED = 'd'  # most borg commands do not use the files cache at all (disable)
 
 # return codes returned by borg command
-# when borg is killed by signal N, rc = 128 + N
 EXIT_SUCCESS = 0  # everything done, no problems
-EXIT_WARNING = 1  # reached normal end of operation, but there were issues
-EXIT_ERROR = 2  # terminated abruptly, did not reach end of operation
+EXIT_WARNING = 1  # reached normal end of operation, but there were issues (generic warning)
+EXIT_ERROR = 2  # terminated abruptly, did not reach end of operation (generic error)
+EXIT_ERROR_BASE = 3  # specific error codes are 3..99 (enabled by BORG_EXIT_CODES=modern)
+EXIT_WARNING_BASE = 100  # specific warning codes are 100..127 (enabled by BORG_EXIT_CODES=modern)
 EXIT_SIGNAL_BASE = 128  # terminated due to signal, rc = 128 + sig_no
 
 # never use datetime.isoformat(), it is evil. always use one of these:

+ 1 - 0
src/borg/crypto/file_integrity.py

@@ -119,6 +119,7 @@ SUPPORTED_ALGORITHMS = {
 
 class FileIntegrityError(IntegrityError):
     """File failed integrity check: {}"""
+    exit_mcode = 91
 
 
 class IntegrityCheckedFile(FileLikeWrapper):

+ 19 - 4
src/borg/crypto/key.py

@@ -39,42 +39,52 @@ AUTHENTICATED_NO_KEY = 'authenticated_no_key' in workarounds
 
 class NoPassphraseFailure(Error):
     """can not acquire a passphrase: {}"""
-
-
-class PassphraseWrong(Error):
-    """passphrase supplied in BORG_PASSPHRASE, by BORG_PASSCOMMAND or via BORG_PASSPHRASE_FD is incorrect."""
+    exit_mcode = 50
 
 
 class PasscommandFailure(Error):
     """passcommand supplied in BORG_PASSCOMMAND failed: {}"""
+    exit_mcode = 51
+
+
+class PassphraseWrong(Error):
+    """passphrase supplied in BORG_PASSPHRASE, by BORG_PASSCOMMAND or via BORG_PASSPHRASE_FD is incorrect."""
+    exit_mcode = 52
 
 
 class PasswordRetriesExceeded(Error):
     """exceeded the maximum password retries"""
+    exit_mcode = 53
 
 
 class UnsupportedPayloadError(Error):
     """Unsupported payload type {}. A newer version is required to access this repository."""
+    exit_mcode = 48
 
 
 class UnsupportedManifestError(Error):
     """Unsupported manifest envelope. A newer version is required to access this repository."""
+    exit_mcode = 27
 
 
 class KeyfileNotFoundError(Error):
     """No key file for repository {} found in {}."""
+    exit_mcode = 42
 
 
 class KeyfileInvalidError(Error):
     """Invalid key file for repository {} found in {}."""
+    exit_mcode = 40
 
 
 class KeyfileMismatchError(Error):
     """Mismatch between repository {} and key file {}."""
+    exit_mcode = 41
 
 
 class RepoKeyNotFoundError(Error):
     """No key entry found in the config of repository {}."""
+    exit_mcode = 44
 
 
 class TAMRequiredError(IntegrityError):
@@ -87,6 +97,7 @@ class TAMRequiredError(IntegrityError):
     In the latter case, use "borg upgrade --tam --force '{}'" to re-authenticate the manifest.
     """).strip()
     traceback = True
+    exit_mcode = 98
 
 
 class ArchiveTAMRequiredError(TAMRequiredError):
@@ -94,11 +105,13 @@ class ArchiveTAMRequiredError(TAMRequiredError):
     Archive '{}' is unauthenticated, but it is required for this repository.
     """).strip()
     traceback = True
+    exit_mcode = 96
 
 
 class TAMInvalid(IntegrityError):
     __doc__ = IntegrityError.__doc__
     traceback = True
+    exit_mcode = 97
 
     def __init__(self):
         # Error message becomes: "Data integrity error: Manifest authentication did not verify"
@@ -108,6 +121,7 @@ class TAMInvalid(IntegrityError):
 class ArchiveTAMInvalid(IntegrityError):
     __doc__ = IntegrityError.__doc__
     traceback = True
+    exit_mcode = 95
 
     def __init__(self):
         # Error message becomes: "Data integrity error: Archive authentication did not verify"
@@ -117,6 +131,7 @@ class ArchiveTAMInvalid(IntegrityError):
 class TAMUnsupportedSuiteError(IntegrityError):
     """Could not verify manifest: Unsupported suite {!r}; a newer version is needed."""
     traceback = True
+    exit_mcode = 99
 
 
 class KeyBlobStorage:

+ 12 - 8
src/borg/crypto/keymanager.py

@@ -10,20 +10,24 @@ from ..repository import Repository
 from .key import KeyfileKey, KeyfileNotFoundError, RepoKeyNotFoundError, KeyBlobStorage, identify_key
 
 
-class UnencryptedRepo(Error):
-    """Keymanagement not available for unencrypted repositories."""
-
-
-class UnknownKeyType(Error):
-    """Keytype {0} is unknown."""
+class NotABorgKeyFile(Error):
+    """This file is not a borg key backup, aborting."""
+    exit_mcode = 43
 
 
 class RepoIdMismatch(Error):
     """This key backup seems to be for a different backup repository, aborting."""
+    exit_mcode = 45
 
 
-class NotABorgKeyFile(Error):
-    """This file is not a borg key backup, aborting."""
+class UnencryptedRepo(Error):
+    """Key management not available for unencrypted repositories."""
+    exit_mcode = 46
+
+
+class UnknownKeyType(Error):
+    """Key type {0} is unknown."""
+    exit_mcode = 47
 
 
 def sha256_truncated(data, num):

+ 21 - 3
src/borg/helpers/errors.py

@@ -5,6 +5,9 @@ from ..constants import *  # NOQA
 import borg.crypto.low_level
 
 
+modern_ec = os.environ.get("BORG_EXIT_CODES", "legacy") == "modern"
+
+
 class Error(Exception):
     """Error: {}"""
     # Error base class
@@ -29,9 +32,8 @@ class Error(Exception):
     @property
     def exit_code(self):
         # legacy: borg used to always use rc 2 (EXIT_ERROR) for all errors.
-        # modern: users can opt in to more specific return codes, using BORG_RC_STYLE:
-        modern = os.environ.get("BORG_EXIT_CODES", "legacy") == "modern"
-        return self.exit_mcode if modern else EXIT_ERROR
+        # modern: users can opt in to more specific return codes, using BORG_EXIT_CODES:
+        return self.exit_mcode if modern_ec else EXIT_ERROR
 
 
 class ErrorWithTraceback(Error):
@@ -42,7 +44,23 @@ class ErrorWithTraceback(Error):
 
 class IntegrityError(ErrorWithTraceback, borg.crypto.low_level.IntegrityError):
     """Data integrity error: {}"""
+    exit_mcode = 90
 
 
 class DecompressionError(IntegrityError):
     """Decompression error: {}"""
+    exit_mcode = 92
+
+
+class CancelledByUser(Error):
+    """Cancelled by user."""
+    exit_mcode = 3
+
+
+class RTError(Error):
+    """Runtime Error: {}"""
+
+
+class CommandError(Error):
+    """Command Error: {}"""
+    exit_mcode = 4

+ 6 - 4
src/borg/helpers/manifest.py

@@ -18,12 +18,14 @@ from .. import shellpattern
 from ..constants import *  # NOQA
 
 
-class NoManifestError(Error):
-    """Repository has no manifest."""
-
-
 class MandatoryFeatureUnsupported(Error):
     """Unsupported repository feature(s) {}. A newer version of borg is required to access this repository."""
+    exit_mcode = 25
+
+
+class NoManifestError(Error):
+    """Repository has no manifest."""
+    exit_mcode = 26
 
 
 ArchiveInfo = namedtuple('ArchiveInfo', 'name id ts')

+ 2 - 0
src/borg/helpers/parseformat.py

@@ -176,10 +176,12 @@ class DatetimeWrapper:
 
 class PlaceholderError(Error):
     """Formatting Error: "{}".format({}): {}({})"""
+    exit_mcode = 5
 
 
 class InvalidPlaceholder(PlaceholderError):
     """Invalid placeholder "{}" in string: {}"""
+    exit_mcode = 6
 
 
 def format_line(format, data):

+ 10 - 4
src/borg/locking.py

@@ -69,26 +69,32 @@ class TimeoutTimer:
 
 class LockError(Error):
     """Failed to acquire the lock {}."""
+    exit_mcode = 70
 
 
 class LockErrorT(ErrorWithTraceback):
     """Failed to acquire the lock {}."""
-
-
-class LockTimeout(LockError):
-    """Failed to create/acquire the lock {} (timeout)."""
+    exit_mcode = 71
 
 
 class LockFailed(LockErrorT):
     """Failed to create/acquire the lock {} ({})."""
+    exit_mcode = 72
+
+
+class LockTimeout(LockError):
+    """Failed to create/acquire the lock {} (timeout)."""
+    exit_mcode = 73
 
 
 class NotLocked(LockErrorT):
     """Failed to release the lock {} (was not locked)."""
+    exit_mcode = 74
 
 
 class NotMyLock(LockErrorT):
     """Failed to release the lock {} (was/is locked, but not by me)."""
+    exit_mcode = 75
 
 
 class ExclusiveLock:

+ 7 - 0
src/borg/remote.py

@@ -66,26 +66,32 @@ def os_write(fd, data):
 
 class ConnectionClosed(Error):
     """Connection closed by remote host"""
+    exit_mcode = 80
 
 
 class ConnectionClosedWithHint(ConnectionClosed):
     """Connection closed by remote host. {}"""
+    exit_mcode = 81
 
 
 class PathNotAllowed(Error):
     """Repository path not allowed: {}"""
+    exit_mcode = 83
 
 
 class InvalidRPCMethod(Error):
     """RPC method {} is not valid"""
+    exit_mcode = 82
 
 
 class UnexpectedRPCDataFormatFromClient(Error):
     """Borg {}: Got unexpected RPC data format from client."""
+    exit_mcode = 85
 
 
 class UnexpectedRPCDataFormatFromServer(Error):
     """Got unexpected RPC data format from server:\n{}"""
+    exit_mcode = 86
 
     def __init__(self, data):
         try:
@@ -517,6 +523,7 @@ class RemoteRepository:
 
     class RPCServerOutdated(Error):
         """Borg server is too old for {}. Required version {}"""
+        exit_mcode = 84
 
         @property
         def method(self):

+ 26 - 15
src/borg/repository.py

@@ -120,43 +120,54 @@ class Repository:
     will still get rid of them.
     """
 
-    class DoesNotExist(Error):
-        """Repository {} does not exist."""
-
     class AlreadyExists(Error):
         """A repository already exists at {}."""
+        exit_mcode = 10
 
-    class PathAlreadyExists(Error):
-        """There is already something at {}."""
+    class AtticRepository(Error):
+        """Attic repository detected. Please run "borg upgrade {}"."""
+        exit_mcode = 11
 
-    class ParentPathDoesNotExist(Error):
-        """The parent path of the repo directory [{}] does not exist."""
+    class CheckNeeded(ErrorWithTraceback):
+        """Inconsistency detected. Please run "borg check {}"."""
+        exit_mcode = 12
+
+    class DoesNotExist(Error):
+        """Repository {} does not exist."""
+        exit_mcode = 13
+
+    class InsufficientFreeSpaceError(Error):
+        """Insufficient free space to complete transaction (required: {}, available: {})."""
+        exit_mcode = 14
 
     class InvalidRepository(Error):
         """{} is not a valid repository. Check repo config."""
+        exit_mcode = 15
 
     class InvalidRepositoryConfig(Error):
         """{} does not have a valid configuration. Check repo config [{}]."""
-
-    class AtticRepository(Error):
-        """Attic repository detected. Please run "borg upgrade {}"."""
-
-    class CheckNeeded(ErrorWithTraceback):
-        """Inconsistency detected. Please run "borg check {}"."""
+        exit_mcode = 16
 
     class ObjectNotFound(ErrorWithTraceback):
         """Object with key {} not found in repository {}."""
+        exit_mcode = 17
 
         def __init__(self, id, repo):
             if isinstance(id, bytes):
                 id = bin_to_hex(id)
             super().__init__(id, repo)
 
-    class InsufficientFreeSpaceError(Error):
-        """Insufficient free space to complete transaction (required: {}, available: {})."""
+    class ParentPathDoesNotExist(Error):
+        """The parent path of the repo directory [{}] does not exist."""
+        exit_mcode = 18
+
+    class PathAlreadyExists(Error):
+        """There is already something at {}."""
+        exit_mcode = 19
 
     class StorageQuotaExceeded(Error):
         """The storage quota ({}) has been exceeded ({}). Try deleting some archives."""
+        exit_mcode = 20
 
     def __init__(self, path, create=False, exclusive=False, lock_wait=None, lock=True,
                  append_only=False, storage_quota=None, check_segment_magic=True,

+ 53 - 15
src/borg/testsuite/archiver.py

@@ -40,7 +40,7 @@ from ..crypto.keymanager import RepoIdMismatch, NotABorgKeyFile
 from ..crypto.file_integrity import FileIntegrityError
 from ..helpers import Location, get_security_dir
 from ..helpers import Manifest, MandatoryFeatureUnsupported, ArchiveInfo
-from ..helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR
+from ..helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, Error, CancelledByUser, RTError, CommandError
 from ..helpers import bin_to_hex
 from ..helpers import MAX_S
 from ..helpers import msgpack
@@ -1171,9 +1171,14 @@ class ArchiverTestCase(ArchiverTestCaseBase):
 
     def test_create_content_from_command_with_failed_command(self):
         self.cmd('init', '--encryption=repokey', self.repository_location)
-        output = self.cmd('create', '--content-from-command', self.repository_location + '::test',
-                          '--', 'sh', '-c', 'exit 73;', exit_code=2)
-        assert output.endswith("Command 'sh' exited with status 73\n")
+        if self.FORK_DEFAULT:
+            output = self.cmd('create', '--content-from-command', self.repository_location + '::test',
+                              '--', 'sh', '-c', 'exit 73;', exit_code=2)
+            assert output.endswith("Command 'sh' exited with status 73\n")
+        else:
+            with pytest.raises(CommandError):
+                self.cmd('create', '--content-from-command', self.repository_location + '::test',
+                         '--', 'sh', '-c', 'exit 73;')
         archive_list = json.loads(self.cmd('list', '--json', self.repository_location))
         assert archive_list['archives'] == []
 
@@ -1212,9 +1217,14 @@ class ArchiverTestCase(ArchiverTestCaseBase):
 
     def test_create_paths_from_command_with_failed_command(self):
         self.cmd('init', '--encryption=repokey', self.repository_location)
-        output = self.cmd('create', '--paths-from-command', self.repository_location + '::test',
-                          '--', 'sh', '-c', 'exit 73;', exit_code=2)
-        assert output.endswith("Command 'sh' exited with status 73\n")
+        if self.FORK_DEFAULT:
+            output = self.cmd('create', '--paths-from-command', self.repository_location + '::test',
+                              '--', 'sh', '-c', 'exit 73;', exit_code=2)
+            assert output.endswith("Command 'sh' exited with status 73\n")
+        else:
+            with pytest.raises(CommandError):
+                self.cmd('create', '--paths-from-command', self.repository_location + '::test',
+                         '--', 'sh', '-c', 'exit 73;')
         archive_list = json.loads(self.cmd('list', '--json', self.repository_location))
         assert archive_list['archives'] == []
 
@@ -1699,7 +1709,11 @@ class ArchiverTestCase(ArchiverTestCaseBase):
         self.cmd('create', self.repository_location + '::test', 'input')
         self.cmd('create', self.repository_location + '::test.2', 'input')
         os.environ['BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'] = 'no'
-        self.cmd('delete', self.repository_location, exit_code=2)
+        if self.FORK_DEFAULT:
+            self.cmd('delete', self.repository_location, exit_code=2)
+        else:
+            with pytest.raises(CancelledByUser):
+                self.cmd('delete', self.repository_location)
         assert os.path.exists(self.repository_path)
         os.environ['BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'] = 'YES'
         self.cmd('delete', self.repository_location)
@@ -2470,8 +2484,16 @@ class ArchiverTestCase(ArchiverTestCaseBase):
 
     def test_list_json_args(self):
         self.cmd('init', '--encryption=repokey', self.repository_location)
-        self.cmd('list', '--json-lines', self.repository_location, exit_code=2)
-        self.cmd('list', '--json', self.repository_location + '::archive', exit_code=2)
+        if self.FORK_DEFAULT:
+            self.cmd('list', '--json-lines', self.repository_location, exit_code=2)
+        else:
+            with pytest.raises(CommandError):
+                self.cmd('list', '--json-lines', self.repository_location)
+        if self.FORK_DEFAULT:
+            self.cmd('list', '--json', self.repository_location + '::archive', exit_code=2)
+        else:
+            with pytest.raises(CommandError):
+                self.cmd('list', '--json', self.repository_location + '::archive')
 
     def test_log_json(self):
         self.create_test_files()
@@ -3025,8 +3047,12 @@ class ArchiverTestCase(ArchiverTestCaseBase):
 
     def test_recreate_target_rc(self):
         self.cmd('init', '--encryption=repokey', self.repository_location)
-        output = self.cmd('recreate', self.repository_location, '--target=asdf', exit_code=2)
-        assert 'Need to specify single archive' in output
+        if self.FORK_DEFAULT:
+            output = self.cmd('recreate', self.repository_location, '--target=asdf', exit_code=2)
+            assert 'Need to specify single archive' in output
+        else:
+            with pytest.raises(CommandError):
+                self.cmd('recreate', self.repository_location, '--target=asdf')
 
     def test_recreate_target(self):
         self.create_test_files()
@@ -3317,13 +3343,21 @@ class ArchiverTestCase(ArchiverTestCaseBase):
 
         self.cmd('init', self.repository_location, '--encryption', 'repokey')
 
-        self.cmd('key', 'export', self.repository_location, export_directory, exit_code=EXIT_ERROR)
+        if self.FORK_DEFAULT:
+            self.cmd('key', 'export', self.repository_location, export_directory, exit_code=EXIT_ERROR)
+        else:
+            with pytest.raises(CommandError):
+                self.cmd('key', 'export', self.repository_location, export_directory)
 
     def test_key_import_errors(self):
         export_file = self.output_path + '/exported'
         self.cmd('init', self.repository_location, '--encryption', 'keyfile')
 
-        self.cmd('key', 'import', self.repository_location, export_file, exit_code=EXIT_ERROR)
+        if self.FORK_DEFAULT:
+            self.cmd('key', 'import', self.repository_location, export_file, exit_code=EXIT_ERROR)
+        else:
+            with pytest.raises(CommandError):
+                self.cmd('key', 'import', self.repository_location, export_file)
 
         with open(export_file, 'w') as fd:
             fd.write('something not a key\n')
@@ -3503,7 +3537,11 @@ id: 2 / e29442 3506da 4e1ea7 / 25f62a 5a3d41 - 02
             self.cmd('config', self.repository_location, cfg_key, exit_code=1)
 
         self.cmd('config', '--list', '--delete', self.repository_location, exit_code=2)
-        self.cmd('config', self.repository_location, exit_code=2)
+        if self.FORK_DEFAULT:
+            self.cmd('config', self.repository_location, exit_code=2)
+        else:
+            with pytest.raises(CommandError):
+                self.cmd('config', self.repository_location)
         self.cmd('config', self.repository_location, 'invalid-option', exit_code=1)
 
     requires_gnutar = pytest.mark.skipif(not have_gnutar(), reason='GNU tar must be installed for this test.')