Repository: borgbackup/borg

Issue:
'borg config' command
Similar to [`git config`](https://git-scm.com/docs/git-config), it would be nice to have a convenience `borg config` command that just opens the repo config and changes an option accordingly.


Discussion:

Resolving patch:
diff --git a/src/borg/archiver.py b/src/borg/archiver.py
--- a/src/borg/archiver.py
+++ b/src/borg/archiver.py
@@ -1,5 +1,6 @@
 import argparse
 import collections
+import configparser
 import faulthandler
 import functools
 import hashlib
@@ -1709,6 +1710,41 @@ def do_with_lock(self, args, repository):
             # see issue #1867.
             repository.commit()
 
+    @with_repository(exclusive=True, cache=True, compatibility=(Manifest.Operation.WRITE,))
+    def do_config(self, args, repository, manifest, key, cache):
+        """get, set, and delete values in a repository or cache config file"""
+        try:
+            section, name = args.name.split('.')
+        except ValueError:
+            section = args.cache and "cache" or "repository"
+            name = args.name
+
+        if args.cache:
+            cache.cache_config.load()
+            config = cache.cache_config._config
+            save = cache.cache_config.save
+        else:
+            config = repository.config
+            save = lambda: repository.save_config(repository.path, repository.config)
+
+        if args.delete:
+            config.remove_option(section, name)
+            if len(config.options(section)) == 0:
+                config.remove_section(section)
+            save()
+        elif args.value:
+            if section not in config.sections():
+                config.add_section(section)
+            config.set(section, name, args.value)
+            save()
+        else:
+            try:
+                print(config.get(section, name))
+            except (configparser.NoOptionError, configparser.NoSectionError) as e:
+                print(e, file=sys.stderr)
+                return EXIT_WARNING
+        return EXIT_SUCCESS
+
     def do_debug_info(self, args):
         """display system information for debugging / bug reports"""
         print(sysinfo())
@@ -3681,6 +3717,37 @@ def define_archive_filters_group(subparser, *, sort_by=True, first_last=True):
         subparser.add_argument('args', metavar='ARGS', nargs=argparse.REMAINDER,
                                help='command arguments')
 
+        config_epilog = process_epilog("""
+        This command gets and sets options in a local repository or cache config file.
+        For security reasons, this command only works on local repositories.
+
+        To delete a config value entirely, use ``--delete``. To get an existing key, pass
+        only the key name. To set a key, pass both the key name and the new value. Keys
+        can be specified in the format "section.name" or simply "name"; the section will
+        default to "repository" and "cache" for the repo and cache configs, respectively.
+
+        By default, borg config manipulates the repository config file. Using ``--cache``
+        edits the repository cache's config file instead.
+        """)
+        subparser = subparsers.add_parser('config', parents=[common_parser], add_help=False,
+                                          description=self.do_config.__doc__,
+                                          epilog=config_epilog,
+                                          formatter_class=argparse.RawDescriptionHelpFormatter,
+                                          help='get and set configuration values')
+        subparser.set_defaults(func=self.do_config)
+        subparser.add_argument('-c', '--cache', dest='cache', action='store_true',
+                               help='get and set values from the repo cache')
+        subparser.add_argument('-d', '--delete', dest='delete', action='store_true',
+                               help='delete the key from the config file')
+
+        subparser.add_argument('location', metavar='REPOSITORY',
+                               type=location_validator(archive=False, proto='file'),
+                               help='repository to configure')
+        subparser.add_argument('name', metavar='NAME',
+                               help='name of config key')
+        subparser.add_argument('value', metavar='VALUE', nargs='?',
+                               help='new value for key')
+
         subparser = subparsers.add_parser('help', parents=[common_parser], add_help=False,
                                           description='Extra help')
         subparser.add_argument('--epilog-only', dest='epilog_only', action='store_true')
diff --git a/src/borg/helpers.py b/src/borg/helpers.py
--- a/src/borg/helpers.py
+++ b/src/borg/helpers.py
@@ -1139,7 +1139,7 @@ def canonical_path(self):
                                            path)
 
 
-def location_validator(archive=None):
+def location_validator(archive=None, proto=None):
     def validator(text):
         try:
             loc = Location(text)
@@ -1148,7 +1148,12 @@ def validator(text):
         if archive is True and not loc.archive:
             raise argparse.ArgumentTypeError('"%s": No archive specified' % text)
         elif archive is False and loc.archive:
-            raise argparse.ArgumentTypeError('"%s" No archive can be specified' % text)
+            raise argparse.ArgumentTypeError('"%s": No archive can be specified' % text)
+        if proto is not None and loc.proto != proto:
+            if proto == 'file':
+                raise argparse.ArgumentTypeError('"%s": Repository must be local' % text)
+            else:
+                raise argparse.ArgumentTypeError('"%s": Repository must be remote' % text)
         return loc
     return validator
 
diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py
--- a/src/borg/testsuite/archiver.py
+++ b/src/borg/testsuite/archiver.py
@@ -2778,6 +2778,25 @@ def test_benchmark_crud(self):
         with environment_variable(_BORG_BENCHMARK_CRUD_TEST='YES'):
             self.cmd('benchmark', 'crud', self.repository_location, self.input_path)
 
+    def test_config(self):
+        self.create_test_files()
+        os.unlink('input/flagfile')
+        self.cmd('init', '--encryption=repokey', self.repository_location)
+        for cfg_key in {'testkey', 'testsection.testkey'}:
+            self.cmd('config', self.repository_location, cfg_key, exit_code=1)
+            self.cmd('config', self.repository_location, cfg_key, 'testcontents')
+            output = self.cmd('config', self.repository_location, cfg_key)
+            assert output == 'testcontents\n'
+            self.cmd('config', self.repository_location, '--delete', cfg_key)
+            self.cmd('config', self.repository_location, cfg_key, exit_code=1)
+
+            self.cmd('config', self.repository_location, '--cache', cfg_key, exit_code=1)
+            self.cmd('config', self.repository_location, '--cache', cfg_key, 'testcontents')
+            output = self.cmd('config', self.repository_location, '--cache', cfg_key)
+            assert output == 'testcontents\n'
+            self.cmd('config', self.repository_location, '--cache', '--delete', cfg_key)
+            self.cmd('config', self.repository_location, '--cache', cfg_key, exit_code=1)
+
     requires_gnutar = pytest.mark.skipif(not have_gnutar(), reason='GNU tar must be installed for this test.')
     requires_gzip = pytest.mark.skipif(not shutil.which('gzip'), reason='gzip must be installed for this test.')
 
@@ -3260,6 +3279,10 @@ def test_remote_repo_restrict_to_repository(self):
     def test_debug_put_get_delete_obj(self):
         pass
 
+    @unittest.skip('only works locally')
+    def test_config(self):
+        pass
+
     def test_strip_components_doesnt_leak(self):
         self.cmd('init', '--encryption=repokey', self.repository_location)
         self.create_regular_file('dir/file', contents=b"test file contents 1")


Test patch: