From cf58f9a05ac92364cf7cdad8bd1b8f5e70477cb3 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 28 Aug 2026 06:30:19 +0200 Subject: [PATCH 1/2] copy: new command to copy an archive to a new archive name, #2300 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. 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. Archive.copy() is Archive.rename() without removing the original archive entry. Copying an archive to its own name is refused: the new metadata would be identical, thus have the same archive ID, and no second archive would be created. NEWNAME may well be the name of some other existing archive, the copy then just joins that archive series. OLDNAME must match precisely one archive, so it accepts an archive name (if it is unique) or an archive ID like "aid:d34db33f". Co-Authored-By: Claude Fable 5 --- docs/changes.rst | 5 + docs/man/borg-copy.1 | 103 +++++++++++++ docs/usage.rst | 1 + docs/usage/copy.rst | 21 +++ docs/usage/copy.rst.inc | 72 +++++++++ src/borg/archive.py | 27 ++++ src/borg/archiver/__init__.py | 3 + src/borg/archiver/copy_cmd.py | 60 ++++++++ src/borg/testsuite/archiver/copy_cmd_test.py | 145 +++++++++++++++++++ 9 files changed, 437 insertions(+) create mode 100644 docs/man/borg-copy.1 create mode 100644 docs/usage/copy.rst create mode 100644 docs/usage/copy.rst.inc create mode 100644 src/borg/archiver/copy_cmd.py create mode 100644 src/borg/testsuite/archiver/copy_cmd_test.py 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/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 From 1b5046493f4775f084ee76a6d3eb44b0e357336d Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 28 Aug 2026 06:32:09 +0200 Subject: [PATCH 2/2] docs: fix stale "borg repo-list" output in the rename example The example showed the borg 1.x output (just name and time). borg 2 lists archive ID, time, name, user and host, see docs/usage/repo-list.rst. Also use different archive IDs before and after the rename: renaming rewrites the archive metadata, so the archive ID changes, as the command description already says. Co-Authored-By: Claude Fable 5 --- docs/usage/rename.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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