Repository: borgbackup/borg

Issue:
add a comment to an archive
see: https://github.com/jborg/attic/issues/147

Would need 2 things:
- a --comment option to borg create (to immediately specify a comment)
- a borg comment command to add a comment to an archive later

---

:moneybag: [there is a bounty for this](https://www.bountysource.com/issues/32643831-add-a-comment-to-an-archive)



Discussion:
We cannot add the comment field to archive metadata since it would affect the hash. Instead we could add a new directory `comments` to the repository, containing a file for each archive. The files could be named with the archive hash and the content would just be the comment text.

A more extensible approach would be to similarly add a new folder but call it `mutables` (we need a better name) and then store a data structure that allows adding more mutable fields besides the comments. Perhaps one of the fields would be a checksum hash for the file itself.

Any comments on this?

If "--comment" is part of "create" than it would kinda make sense to have it in "recreate" to add/change it at a later point.

@Ape Mutable/immutable comment, which one do we want? I'd say immutable. Everything about Archives is immutable so far (including the name), so I'd be weird to have the comment mutable but the name not.

Okay, I agree. The comment field is very similar to the name.

@enkore with borg rename, you can change the archive name, so from the user perspective, it is mutable.

... changing the archive ID/fingerprint in the process.

@enkore changing the archive ID/fingerprint is not good idea.

@infectormp Actually, I think this kind of immutable data is a very good thing. Compare it to how Git stores its data. Things can still be modified, but under the hood everything is clean and immutable (except the thin mutable layer referencing all that immutable data).

Yep, that's what I meant. You can change it, just like e.g. "git rebase" or "borg recreate" (formerly known as rewrite) allows to change committed / archived things. But the commit / archive ID changes, so you can see that it isn't the _same thing_ anymore. I think this is highly desirable.

If I wanted to, I could write my archive IDs down[1], and even when I didn't encrypt the repository, nobody could change these archives without me noticing -- either you get a different archive ID, or an IntegrityError.

[1] or print them on continuous paper with a line printer! :)

@enkore with your comments i not have any questions more.


Resolving patch:
diff --git a/borg/archive.py b/borg/archive.py
--- a/borg/archive.py
+++ b/borg/archive.py
@@ -180,7 +180,7 @@ def _load_meta(self, id):
     def load(self, id):
         self.id = id
         self.metadata = self._load_meta(self.id)
-        decode_dict(self.metadata, (b'name', b'hostname', b'username', b'time', b'time_end'))
+        decode_dict(self.metadata, (b'name', b'comment', b'hostname', b'username', b'time', b'time_end'))
         self.metadata[b'cmdline'] = [arg.decode('utf-8', 'surrogateescape') for arg in self.metadata[b'cmdline']]
         self.name = self.metadata[b'name']
 
@@ -240,7 +240,7 @@ def write_checkpoint(self):
         del self.manifest.archives[self.checkpoint_name]
         self.cache.chunk_decref(self.id, self.stats)
 
-    def save(self, name=None, timestamp=None):
+    def save(self, name=None, comment=None, timestamp=None):
         name = name or self.name
         if name in self.manifest.archives:
             raise self.AlreadyExists(name)
@@ -256,6 +256,7 @@ def save(self, name=None, timestamp=None):
         metadata = StableDict({
             'version': 1,
             'name': name,
+            'comment': comment,
             'items': self.items_buffer.chunks,
             'cmdline': sys.argv,
             'hostname': socket.gethostname(),
@@ -447,17 +448,22 @@ def restore_attrs(self, path, item, symlink=False, fd=None):
             except OSError:
                 pass
 
-    def rename(self, name):
-        if name in self.manifest.archives:
-            raise self.AlreadyExists(name)
+    def set_meta(self, key, value):
         metadata = StableDict(self._load_meta(self.id))
-        metadata[b'name'] = name
+        metadata[key] = value
         data = msgpack.packb(metadata, unicode_errors='surrogateescape')
         new_id = self.key.id_hash(data)
         self.cache.add_chunk(new_id, data, self.stats)
-        self.manifest.archives[name] = {'id': new_id, 'time': metadata[b'time']}
+        self.manifest.archives[self.name] = {'id': new_id, 'time': metadata[b'time']}
         self.cache.chunk_decref(self.id, self.stats)
-        del self.manifest.archives[self.name]
+
+    def rename(self, name):
+        if name in self.manifest.archives:
+            raise self.AlreadyExists(name)
+        oldname = self.name
+        self.name = name
+        self.set_meta(b'name', name)
+        del self.manifest.archives[oldname]
 
     def delete(self, stats, progress=False):
         unpacker = msgpack.Unpacker(use_list=False)
@@ -879,7 +885,7 @@ def report(msg, chunk_id, chunk_no):
                 archive = StableDict(msgpack.unpackb(data))
                 if archive[b'version'] != 1:
                     raise Exception('Unknown archive metadata version')
-                decode_dict(archive, (b'name', b'hostname', b'username', b'time', b'time_end'))
+                decode_dict(archive, (b'name', b'comment', b'hostname', b'username', b'time', b'time_end'))
                 archive[b'cmdline'] = [arg.decode('utf-8', 'surrogateescape') for arg in archive[b'cmdline']]
                 items_buffer = ChunkBuffer(self.key)
                 items_buffer.write_chunk = add_callback
diff --git a/borg/archiver.py b/borg/archiver.py
--- a/borg/archiver.py
+++ b/borg/archiver.py
@@ -262,7 +262,7 @@ def create_inner(archive, cache):
                               args.keep_tag_files, skip_inodes, path, restrict_dev,
                               read_special=args.read_special, dry_run=dry_run)
             if not dry_run:
-                archive.save(timestamp=args.timestamp)
+                archive.save(comment=args.comment, timestamp=args.timestamp)
                 if args.progress:
                     archive.stats.show_progress(final=True)
                 if args.stats:
@@ -628,6 +628,16 @@ def do_rename(self, args, repository, manifest, key, cache, archive):
         cache.commit()
         return self.exit_code
 
+    @with_repository(exclusive=True, cache=True)
+    @with_archive
+    def do_comment(self, args, repository, manifest, key, cache, archive):
+        """Set the archive comment"""
+        archive.set_meta(b'comment', args.comment)
+        manifest.write()
+        repository.commit()
+        cache.commit()
+        return self.exit_code
+
     @with_repository(exclusive=True, cache=True)
     def do_delete(self, args, repository, manifest, key, cache):
         """Delete an existing repository or archive"""
@@ -735,6 +745,7 @@ def format_cmdline(cmdline):
         stats = archive.calc_stats(cache)
         print('Name:', archive.name)
         print('Fingerprint: %s' % hexlify(archive.id).decode('ascii'))
+        print('Comment:', archive.metadata.get(b'comment', ''))
         print('Hostname:', archive.metadata[b'hostname'])
         print('Username:', archive.metadata[b'username'])
         print('Time (start): %s' % format_time(to_localtime(archive.ts)))
@@ -1179,6 +1190,8 @@ def build_parser(self, args=None, prog=None):
                                           formatter_class=argparse.RawDescriptionHelpFormatter,
                                           help='create backup')
         subparser.set_defaults(func=self.do_create)
+        subparser.add_argument('--comment', dest='comment', metavar='COMMENT', default='',
+                               help='add a comment text to the archive')
         subparser.add_argument('-s', '--stats', dest='stats',
                                action='store_true', default=False,
                                help='print statistics for the created archive')
@@ -1356,6 +1369,21 @@ def build_parser(self, args=None, prog=None):
                                type=archivename_validator(),
                                help='the new archive name to use')
 
+        comment_epilog = textwrap.dedent("""
+        This command sets the archive comment.
+        """)
+        subparser = subparsers.add_parser('comment', parents=[common_parser],
+                                          description=self.do_comment.__doc__,
+                                          epilog=comment_epilog,
+                                          formatter_class=argparse.RawDescriptionHelpFormatter,
+                                          help='set the archive comment')
+        subparser.set_defaults(func=self.do_comment)
+        subparser.add_argument('location', metavar='ARCHIVE',
+                               type=location_validator(archive=True),
+                               help='archive to modify')
+        subparser.add_argument('comment', metavar='COMMENT',
+                               help='the new archive comment')
+
         delete_epilog = textwrap.dedent("""
         This command deletes an archive from the repository or the complete repository.
         Disk space is reclaimed accordingly. If you delete the complete repository, the
diff --git a/borg/testsuite/archiver.py b/borg/testsuite/archiver.py
--- a/borg/testsuite/archiver.py
+++ b/borg/testsuite/archiver.py
@@ -754,6 +754,19 @@ def test_rename(self):
         self.assert_in('test.3', manifest.archives)
         self.assert_in('test.4', manifest.archives)
 
+    def test_comment(self):
+        self.create_regular_file('file1', size=1024 * 80)
+        self.cmd('init', self.repository_location)
+        self.cmd('create', self.repository_location + '::test1', 'input')
+        self.cmd('create', '--comment', 'this is the comment', self.repository_location + '::test2', 'input')
+        assert 'Comment: \n' in self.cmd('info', self.repository_location + '::test1')
+        assert 'Comment: this is the comment' in self.cmd('info', self.repository_location + '::test2')
+
+        self.cmd('comment', self.repository_location + '::test1', 'added comment')
+        self.cmd('comment', self.repository_location + '::test2', 'modified comment')
+        assert 'Comment: added comment' in self.cmd('info', self.repository_location + '::test1')
+        assert 'Comment: modified comment' in self.cmd('info', self.repository_location + '::test2')
+
     def test_delete(self):
         self.create_regular_file('file1', size=1024 * 80)
         self.create_regular_file('dir2/file2', size=1024 * 80)


Test patch: