Browse Source

Merge pull request #9034 from ThomasWaldmann/format-inode

list --format: add "inode" support to formatter, add tests
TW 2 weeks ago
parent
commit
a8e10c4a5c

+ 4 - 1
src/borg/helpers/parseformat.py

@@ -860,6 +860,7 @@ class ItemFormatter(BaseFormatter):
         "path": "file path",
         "target": "link target for symlinks",
         "hlid": "hard link identity (same if hardlinking same fs object)",
+        "inode": "inode number",
         "flags": "file flags",
         "extra": 'prepends {target} with " -> " for soft links and " link to " for hard links',
         "size": "file size",
@@ -875,7 +876,7 @@ class ItemFormatter(BaseFormatter):
         "archivename": "name of the archive",
     }
     KEY_GROUPS = (
-        ("type", "mode", "uid", "gid", "user", "group", "path", "target", "hlid", "flags"),
+        ("type", "mode", "uid", "gid", "user", "group", "path", "target", "hlid", "inode", "flags"),
         ("size", "num_chunks"),
         ("mtime", "ctime", "atime", "isomtime", "isoctime", "isoatime"),
         tuple(sorted(hash_algorithms)),
@@ -937,6 +938,8 @@ class ItemFormatter(BaseFormatter):
         item_data.update(text_to_json("group", item.get("group", str(item_data["gid"]))))
 
         item_data["flags"] = item.get("bsdflags")  # int if flags known, else (if flags unknown) None
+        # inode number from source filesystem (may be absent on some platforms)
+        item_data["inode"] = item.get("inode")
         for key in self.used_call_keys:
             item_data[key] = self.call_keys[key](item)
         return item_data

+ 42 - 1
src/borg/testsuite/archiver/list_cmd_test.py

@@ -1,8 +1,9 @@
 import json
 import os
+import pytest
 
 from ...constants import *  # NOQA
-from . import src_dir, cmd, create_regular_file, generate_archiver_tests, RK_ENCRYPTION
+from . import src_dir, cmd, create_regular_file, generate_archiver_tests, RK_ENCRYPTION, requires_hardlinks
 
 pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,remote,binary")  # NOQA
 
@@ -137,3 +138,43 @@ def test_list_depth(archivers, request):
     assert "input/dir1/file_at_depth_2.txt" in output_no_depth
     assert "input/dir1/dir2" in output_no_depth
     assert "input/dir1/dir2/file_at_depth_3.txt" in output_no_depth
+
+
+@requires_hardlinks
+def test_list_inode_hardlinks(archivers, request):
+    archiver = request.getfixturevalue(archivers)
+
+    # Prepare repository and input files: two hardlinks to same file and one separate file
+    cmd(archiver, "repo-create", RK_ENCRYPTION)
+    create_regular_file(archiver.input_path, "fileA", contents=b"DATA")
+    os.link(os.path.join(archiver.input_path, "fileA"), os.path.join(archiver.input_path, "fileB"))
+    create_regular_file(archiver.input_path, "fileC", contents=b"DATA")
+
+    # Create archive
+    cmd(archiver, "create", "test", "input")
+
+    # Use ItemFormatter via list --format to output {inode}
+    output = cmd(archiver, "list", "test", "--format", "{path} {inode}{NL}")
+
+    # Parse output lines and collect inode numbers for our files
+    inodes = {}
+    for line in output.splitlines():
+        try:
+            path, inode_str = line.rsplit(" ", 1)
+        except ValueError:
+            continue
+        if path in {"input/fileA", "input/fileB", "input/fileC"}:
+            # inode may be missing (None) on some platforms; convert to int if possible
+            inode = None if inode_str in ("", "None") else int(inode_str)
+            inodes[path] = inode
+
+    # Ensure we captured all three files
+    assert set(inodes) == {"input/fileA", "input/fileB", "input/fileC"}
+
+    # On platforms where inode is available, verify hardlinks share same inode
+    # If inode is None, the formatter still worked, but platform didn't provide an inode; skip in that case.
+    if inodes["input/fileA"] is not None and inodes["input/fileB"] is not None and inodes["input/fileC"] is not None:
+        assert inodes["input/fileA"] == inodes["input/fileB"]
+        assert inodes["input/fileA"] != inodes["input/fileC"]
+    else:
+        pytest.skip("Platform does not provide inode numbers for items")

+ 1 - 0
src/borg/testsuite/archiver/transfer_cmd_test.py

@@ -156,6 +156,7 @@ def test_transfer_upgrade(archivers, request, monkeypatch):
                 del e["mode"], g["mode"]
 
             del e["healthy"]  # not supported anymore
+            del g["inode"]  # new in borg2
 
             assert g == e