diff --git a/src/borg/archive.py b/src/borg/archive.py index 195fcb86d0..8181c9708c 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -1011,7 +1011,18 @@ def same_item(item, st): try: st = os.stat(path, follow_symlinks=False) if continue_extraction and same_item(item, st): - return # done! we already have fully extracted this file in a previous run. + # we already have fully extracted this file in a previous run. + if "hlid" not in item or not has_link: + return # done! + # it is part of a group of hard links, keep the group together: + link_target = hlm.retrieve(id=item.hlid) + if link_target is None: + # first item of the group: the following items of the group get hard linked to it. + hlm.remember(id=item.hlid, info=path) + return + if os.path.samestat(st, os.stat(link_target, follow_symlinks=False)): + return # done! it already is a hard link to the first item of the group. + # it is a separate file: replace it by a hard link to the first item of the group. if not stat.S_ISDIR(st.st_mode): os.unlink(path) elif stat.S_ISDIR(item.mode): diff --git a/src/borg/testsuite/archiver/extract_cmd_test.py b/src/borg/testsuite/archiver/extract_cmd_test.py index 2bfb9c4a7a..2d0d0f97d1 100644 --- a/src/borg/testsuite/archiver/extract_cmd_test.py +++ b/src/borg/testsuite/archiver/extract_cmd_test.py @@ -1010,6 +1010,38 @@ def test_extract_continue(archivers, request): assert f.read() == CONTENTS3 +@requires_hardlinks +@pytest.mark.parametrize("missing", ["first", "last"]) +def test_extract_continue_hardlinks(archivers, request, missing): + # --continue must not break up a group of hard links, no matter whether the first item of the + # group is skipped (it was already extracted) or extracted again. + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + create_regular_file(archiver.input_path, "file1", contents=b"123456") + os.link("input/file1", "input/file2") + os.link("input/file1", "input/file3") + cmd(archiver, "create", "arch", "input") + # the order of the hard links within the archive depends on the filesystem, so ask for it: + paths = [path for path in cmd(archiver, "list", "arch", "--format={path}{NL}").splitlines() if "file" in path] + assert len(paths) == 3 + first, missing_path = paths[0], paths[0] if missing == "first" else paths[-1] + + with changedir("output"): + cmd(archiver, "extract", "arch") + old_ino = os.stat(first).st_ino + # simulate an incomplete extraction: one of the hard links is missing + os.remove(missing_path) + cmd(archiver, "extract", "arch", "--continue") + sts = [os.stat(path) for path in paths] + assert len({st.st_ino for st in sts}) == 1 # they all are hard links to the same inode + assert all(st.st_nlink == 3 for st in sts) + if missing == "last": + assert sts[0].st_ino == old_ino # the already extracted files were NOT extracted again + for path in paths: + with open(path, "rb") as f: + assert f.read() == b"123456" + + def test_dry_run_extraction_flags(archivers, request): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION)