diff --git a/docs/changes.rst b/docs/changes.rst index 8f79031a81..3d2facce6d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -170,6 +170,11 @@ New features: - locking: tell who is holding a lock we wait for or time out on: lock type, host, pid and age of that lock. Also name the repository in the message (instead of just the storage backend) and log which locks ``borg break-lock`` breaks, #2261 +- new "borg copy OLDNAME NEWNAME" command: copy an archive to a new archive name, #2300. + Copying is cheap: no file content is read or written, only a new archive metadata + object is created, and the two archives share their data like any other deduplicated + archives do. The copy is an independent archive: deleting either of the two archives + keeps the other one intact. Fixes: diff --git a/docs/man/borg-copy.1 b/docs/man/borg-copy.1 new file mode 100644 index 0000000000..faf16129cd --- /dev/null +++ b/docs/man/borg-copy.1 @@ -0,0 +1,103 @@ +.\" Man page generated from reStructuredText +.\" by the Docutils 0.22.4 manpage writer. +. +. +.nr rst2man-indent-level 0 +. +.de1 rstReportMargin +\\$1 \\n[an-margin] +level \\n[rst2man-indent-level] +level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] +- +\\n[rst2man-indent0] +\\n[rst2man-indent1] +\\n[rst2man-indent2] +.. +.de1 INDENT +.\" .rstReportMargin pre: +. RS \\$1 +. nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] +. nr rst2man-indent-level +1 +.\" .rstReportMargin post: +.. +.de UNINDENT +. RE +.\" indent \\n[an-margin] +.\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] +.nr rst2man-indent-level -1 +.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] +.in \\n[rst2man-indent\\n[rst2man-indent-level]]u +.. +.TH "borg-copy" "1" "2026-08-28" "" "borg backup tool" +.SH Name +borg-copy \- Copy an archive to a new archive name. +.SH SYNOPSIS +.sp +borg [common options] copy [options] OLDNAME NEWNAME +.SH DESCRIPTION +.sp +This command copies an existing archive to a new archive with a different name, +keeping the existing archive. +.sp +Afterwards, the repository has two archives with the same contents, but with +different names and different archive IDs. The copy is an independent archive: +deleting either of the two archives keeps the other one intact, because +\fBborg compact\fP only frees chunks that no remaining archive references. +.sp +Copying is cheap and fast: no file content is read or written, only a new archive +metadata object is created. Like any deduplicated archives, the two archives share +their data, so a copy needs almost no additional repository space. +.sp +Because archive names do not need to be unique, NEWNAME may also be the name of +some \fIother\fP already existing archive \- the copy then just becomes another archive +of that archive series. +.sp +NEWNAME must be different from the name of the archive that is copied, though: the +copy would get identical metadata and thus the same archive ID as its source, so no +second archive could be created. +.sp +OLDNAME must match precisely one archive: give an archive name (if it is unique) or +an archive ID, like \fBaid:d34db33f\fP\&. +.sp +Note: to copy archives into a \fIdifferent\fP repository, use \fBborg transfer\fP\&. +.SH OPTIONS +.sp +See \fIborg\-common(1)\fP for common options of Borg commands. +.SS arguments +.INDENT 0.0 +.TP +.B OLDNAME +specify the existing archive name or ID +.TP +.B NEWNAME +specify the new archive name +.UNINDENT +.SH EXAMPLES +.INDENT 0.0 +.INDENT 3.5 +.sp +.EX +# create an archive, then keep a copy of it under a stable name: +$ borg create backup\-2016\-02\-15 ~ +$ borg copy backup\-2016\-02\-15 known\-good +$ borg repo\-list +e6a2b1c4 Mon, 2016\-02\-15 19:50:19 +0100 backup\-2016\-02\-15 tw MacBook\-Pro +9f3d0a77 Mon, 2016\-02\-15 19:50:19 +0100 known\-good tw MacBook\-Pro + +# the copy is an independent archive: +# after deleting (and compacting away) the original, the copy is still complete. +$ borg delete backup\-2016\-02\-15 +$ borg compact +$ borg extract known\-good + +# if the archive name is not unique, address the archive by its ID: +$ borg copy aid:e6a2b1c4 known\-good +.EE +.UNINDENT +.UNINDENT +.SH SEE ALSO +.sp +\fIborg\-common(1)\fP +.SH Author +The Borg Collective +.\" End of generated man page. diff --git a/docs/usage.rst b/docs/usage.rst index d049fe82b3..02a74f95e2 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -55,6 +55,7 @@ Usage usage/find usage/tag usage/rename + usage/copy usage/diff usage/delete usage/prune diff --git a/docs/usage/copy.rst b/docs/usage/copy.rst new file mode 100644 index 0000000000..30b3d390c4 --- /dev/null +++ b/docs/usage/copy.rst @@ -0,0 +1,21 @@ +.. include:: copy.rst.inc + +Examples +~~~~~~~~ +:: + + # create an archive, then keep a copy of it under a stable name: + $ borg create backup-2016-02-15 ~ + $ borg copy backup-2016-02-15 known-good + $ borg repo-list + e6a2b1c4 Mon, 2016-02-15 19:50:19 +0100 backup-2016-02-15 tw MacBook-Pro + 9f3d0a77 Mon, 2016-02-15 19:50:19 +0100 known-good tw MacBook-Pro + + # the copy is an independent archive: + # after deleting (and compacting away) the original, the copy is still complete. + $ borg delete backup-2016-02-15 + $ borg compact + $ borg extract known-good + + # if the archive name is not unique, address the archive by its ID: + $ borg copy aid:e6a2b1c4 known-good diff --git a/docs/usage/copy.rst.inc b/docs/usage/copy.rst.inc new file mode 100644 index 0000000000..dc131a7678 --- /dev/null +++ b/docs/usage/copy.rst.inc @@ -0,0 +1,72 @@ +.. IMPORTANT: this file is auto-generated from borg's built-in help, do not edit! + +.. _borg_copy: + +borg copy +--------- +.. code-block:: none + + borg [common options] copy [options] OLDNAME NEWNAME + +.. only:: html + + .. class:: borg-options-table + + +-------------------------------------------------------+-------------+-----------------------------------------+ + | **positional arguments** | + +-------------------------------------------------------+-------------+-----------------------------------------+ + | | ``OLDNAME`` | specify the existing archive name or ID | + +-------------------------------------------------------+-------------+-----------------------------------------+ + | | ``NEWNAME`` | specify the new archive name | + +-------------------------------------------------------+-------------+-----------------------------------------+ + | .. class:: borg-common-opt-ref | + | | + | :ref:`common_options` | + +-------------------------------------------------------+-------------+-----------------------------------------+ + + .. raw:: html + + + +.. only:: latex + + OLDNAME + specify the existing archive name or ID + NEWNAME + specify the new archive name + + + :ref:`common_options` + | + +Description +~~~~~~~~~~~ + +This command copies an existing archive to a new archive with a different name, +keeping the existing archive. + +Afterwards, the repository has two archives with the same contents, but with +different names and different archive IDs. The copy is an independent archive: +deleting either of the two archives keeps the other one intact, because +``borg compact`` only frees chunks that no remaining archive references. + +Copying is cheap and fast: no file content is read or written, only a new archive +metadata object is created. Like any deduplicated archives, the two archives share +their data, so a copy needs almost no additional repository space. + +Because archive names do not need to be unique, NEWNAME may also be the name of +some *other* already existing archive - the copy then just becomes another archive +of that archive series. + +NEWNAME must be different from the name of the archive that is copied, though: the +copy would get identical metadata and thus the same archive ID as its source, so no +second archive could be created. + +OLDNAME must match precisely one archive: give an archive name (if it is unique) or +an archive ID, like ``aid:d34db33f``. + +Note: to copy archives into a *different* repository, use ``borg transfer``. \ No newline at end of file diff --git a/docs/usage/rename.rst b/docs/usage/rename.rst index 24e578ead3..08d16518b8 100644 --- a/docs/usage/rename.rst +++ b/docs/usage/rename.rst @@ -6,9 +6,10 @@ Examples $ borg create archivename ~ $ borg repo-list - archivename Mon, 2016-02-15 19:50:19 + 8df049de Mon, 2016-02-15 19:50:19 +0100 archivename tw MacBook-Pro + # renaming rewrites the archive metadata, so the archive ID changes: $ borg rename archivename newname $ borg repo-list - newname Mon, 2016-02-15 19:50:19 + 69ea925b Mon, 2016-02-15 19:50:19 +0100 newname tw MacBook-Pro diff --git a/src/borg/archive.py b/src/borg/archive.py index d325664a50..1286f89be7 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -1158,6 +1158,33 @@ def rename(self, name): self.set_meta("name", name) self.manifest.archives.delete_by_id(old_id) + def copy(self, name): + """Copy this archive to a new archive with the given name, keeping this archive. + + This is like .rename(), but the original archive entry is not removed, so afterwards the + repository has two archives with identical contents under two different names (and two + different archive IDs). + + The copy is an independent archive: deleting either of the two archives does not affect + the other one, because "borg compact" only frees chunks that no remaining archive + references. + + Copying is cheap: no file content is read or written, only a new archive metadata object + is created. The item metadata stream and the file content chunks are shared between the + two archives (like they are between any deduplicated archives). + + The new name may be the name of some other existing archive (archive names do not need + to be unique), but it must be different from this archive's name, see below. + + Afterwards, this Archive instance refers to the new archive (new name, new archive ID). + """ + if name == self.name: + # the new metadata would be identical, thus have the same archive ID and just overwrite + # the existing archives directory entry - no second archive would be created. + raise Error(f"Archive {name} can not be copied to the same name.") + self.name = name + self.set_meta("name", name) + def delete(self): # quick and dirty: we just nuke the archive from the archives list - that will # potentially orphan all chunks previously referenced by the archive, except the ones also diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index 181378e8b4..1f0516f398 100644 --- a/src/borg/archiver/__init__.py +++ b/src/borg/archiver/__init__.py @@ -69,6 +69,7 @@ from .check_cmd import CheckMixIn from .compact_cmd import CompactMixIn from .completion_cmd import CompletionMixIn +from .copy_cmd import CopyMixIn from .create_cmd import CreateMixIn from .debug_cmd import DebugMixIn from .delete_cmd import DeleteMixIn @@ -105,6 +106,7 @@ class Archiver( CheckMixIn, CompactMixIn, CompletionMixIn, + CopyMixIn, CreateMixIn, DebugMixIn, DeleteMixIn, @@ -298,6 +300,7 @@ def build_parser(self): self.build_parser_check(subparsers, common_parser, mid_common_parser) self.build_parser_compact(subparsers, common_parser, mid_common_parser) self.build_parser_completion(subparsers, common_parser, mid_common_parser) + self.build_parser_copy(subparsers, common_parser, mid_common_parser) self.build_parser_create(subparsers, common_parser, mid_common_parser) self.build_parser_debug(subparsers, common_parser, mid_common_parser) self.build_parser_delete(subparsers, common_parser, mid_common_parser) diff --git a/src/borg/archiver/copy_cmd.py b/src/borg/archiver/copy_cmd.py new file mode 100644 index 0000000000..92f45ae261 --- /dev/null +++ b/src/borg/archiver/copy_cmd.py @@ -0,0 +1,60 @@ +from ._common import with_repository, with_archive +from ..constants import * # NOQA +from ..helpers import archivename_validator, bin_to_hex +from ..helpers.argparsing import ArgumentParser +from ..manifest import Manifest + +from ..logger import create_logger + +logger = create_logger() + + +class CopyMixIn: + @with_repository(cache=True, compatibility=(Manifest.Operation.CHECK,)) + @with_archive + def do_copy(self, args, repository, manifest, cache, archive): + """Copy an archive to a new archive name.""" + old_id = archive.id + archive.copy(args.newname) + manifest.write() + logger.info(f"id: {bin_to_hex(old_id):.8} -> {bin_to_hex(archive.id):.8}, name: {archive.name}.") + + def build_parser_copy(self, subparsers, common_parser, mid_common_parser): + from ._common import process_epilog + + copy_epilog = process_epilog( + """ + This command copies an existing archive to a new archive with a different name, + keeping the existing archive. + + Afterwards, the repository has two archives with the same contents, but with + different names and different archive IDs. The copy is an independent archive: + deleting either of the two archives keeps the other one intact, because + ``borg compact`` only frees chunks that no remaining archive references. + + Copying is cheap and fast: no file content is read or written, only a new archive + metadata object is created. Like any deduplicated archives, the two archives share + their data, so a copy needs almost no additional repository space. + + Because archive names do not need to be unique, NEWNAME may also be the name of + some *other* already existing archive - the copy then just becomes another archive + of that archive series. + + NEWNAME must be different from the name of the archive that is copied, though: the + copy would get identical metadata and thus the same archive ID as its source, so no + second archive could be created. + + OLDNAME must match precisely one archive: give an archive name (if it is unique) or + an archive ID, like ``aid:d34db33f``. + + Note: to copy archives into a *different* repository, use ``borg transfer``. + """ + ) + subparser = ArgumentParser(parents=[common_parser], description=self.do_copy.__doc__, epilog=copy_epilog) + subparsers.add_subcommand("copy", subparser, help="copy an archive to a new archive name") + subparser.add_argument( + "name", metavar="OLDNAME", type=archivename_validator, help="specify the existing archive name or ID" + ) + subparser.add_argument( + "newname", metavar="NEWNAME", type=archivename_validator, help="specify the new archive name" + ) diff --git a/src/borg/testsuite/archiver/copy_cmd_test.py b/src/borg/testsuite/archiver/copy_cmd_test.py new file mode 100644 index 0000000000..6195095dc0 --- /dev/null +++ b/src/borg/testsuite/archiver/copy_cmd_test.py @@ -0,0 +1,145 @@ +from ...archive import Archive +from ...constants import * # NOQA +from ...helpers import bin_to_hex, CommandError, Error +from ...manifest import Manifest +from .. import changedir +from . import cmd, create_regular_file, assert_dirs_equal, open_repository +from . import generate_archiver_tests, RK_ENCRYPTION + +pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,binary") # NOQA + + +def archive_id(archiver, name): + with open_repository(archiver) as repository: + manifest = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) + return manifest.archives.get_one([name]).id + + +def test_copy(archivers, request): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", size=1024 * 80) + create_regular_file(archiver.input_path, "dir2/file2", size=1024 * 80) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test", "input") + old_id = archive_id(archiver, "test") + + cmd(archiver, "copy", "test", "test.copy") + + # both archives exist now, under different names and with different archive IDs. + new_id = archive_id(archiver, "test.copy") + assert new_id != old_id + with open_repository(archiver) as repository: + manifest = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) + assert manifest.archives.count() == 2 + assert manifest.archives.exists_name_and_id("test", old_id) + assert manifest.archives.exists_name_and_id("test.copy", new_id) + + # the copy has the same contents as the original archive. + with changedir("output"): + cmd(archiver, "extract", "test.copy") + assert_dirs_equal(archiver.input_path, "input") + + +def test_copy_by_archive_id(archivers, request): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", size=1024 * 80) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test", "input") + old_id = archive_id(archiver, "test") + + cmd(archiver, "copy", f"aid:{bin_to_hex(old_id)[:8]}", "test.copy") + + with open_repository(archiver) as repository: + manifest = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) + assert manifest.archives.count() == 2 + assert manifest.archives.exists("test.copy") + + +def test_copy_shares_item_stream(archivers, request): + """The copy must reuse the item metadata stream, not rewrite it.""" + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", size=1024 * 80) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test", "input") + + cmd(archiver, "copy", "test", "test.copy") + + with open_repository(archiver) as repository: + manifest = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) + original = Archive(manifest, manifest.archives.get_one(["test"]).id) + copied = Archive(manifest, manifest.archives.get_one(["test.copy"]).id) + assert original.metadata.item_ptrs == copied.metadata.item_ptrs + assert original.metadata.time == copied.metadata.time + assert original.metadata.name == "test" + assert copied.metadata.name == "test.copy" + + +def test_copy_delete_original(archivers, request): + """The two archives are independent: deleting and compacting away one keeps the other intact.""" + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", size=1024 * 80) + create_regular_file(archiver.input_path, "dir2/file2", size=1024 * 80) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test", "input") + + cmd(archiver, "copy", "test", "test.copy") + + cmd(archiver, "delete", "test") + cmd(archiver, "compact") # actually free everything the deleted archive was the only referrer of + + with open_repository(archiver) as repository: + manifest = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) + assert manifest.archives.count() == 1 + assert manifest.archives.exists("test.copy") + + cmd(archiver, "check") # no chunks of the surviving archive were freed + with changedir("output"): + cmd(archiver, "extract", "test.copy") + assert_dirs_equal(archiver.input_path, "input") + + +def test_copy_to_existing_name(archivers, request): + """Archive names do not need to be unique, so copying into an existing archive series works.""" + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", size=1024 * 80) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test", "input") + cmd(archiver, "create", "series", "input") + + cmd(archiver, "copy", "test", "series") + + with open_repository(archiver) as repository: + manifest = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) + assert manifest.archives.count() == 3 + assert len(list(manifest.archives.list(match=["series"]))) == 2 + + +def test_copy_to_same_name(archivers, request): + """Copying to the archive's own name would not create a second archive, so it is refused.""" + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", size=1024 * 80) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test", "input") + + output = cmd(archiver, "copy", "test", "test", fork=True, exit_code=Error.exit_mcode) + assert "can not be copied to the same name" in output + + with open_repository(archiver) as repository: + manifest = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) + assert manifest.archives.count() == 1 + + +def test_copy_ambiguous_name(archivers, request): + """OLDNAME must match precisely one archive.""" + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", size=1024 * 80) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test", "input") + cmd(archiver, "create", "test", "input") + + output = cmd(archiver, "copy", "test", "test.copy", fork=True, exit_code=CommandError.exit_mcode) + assert "needed to match precisely one archive" in output + + with open_repository(archiver) as repository: + manifest = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) + assert manifest.archives.count() == 2