diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index bb38092d03..a9a5e2671d 100644 --- a/src/borg/helpers/parseformat.py +++ b/src/borg/helpers/parseformat.py @@ -27,7 +27,7 @@ from .fs import get_keys_dir, make_path_safe, slashify from .argparsing import Action, ArgumentError, ArgumentTypeError, register_type from .msgpack import Timestamp -from .time import OutputTimestamp, format_time, safe_timestamp +from .time import OutputTimestamp, format_time, format_timestamp_pair, safe_timestamp from .. import __version__ as borg_version from .. import __version_tuple__ as borg_version_tuple from ..constants import * # NOQA @@ -1235,7 +1235,12 @@ def format_content(self, diff: "ItemDiff"): def format_time(self, key, diff: "ItemDiff"): change = diff.changes().get(key) - return f"[{key}: {change.diff_data['item1']} -> {change.diff_data['item2']}]" if change else "" + if not change: + return "" + ts1 = change.diff_data["item1"].ts + ts2 = change.diff_data["item2"].ts + s1, s2 = format_timestamp_pair(ts1, ts2) + return f"[{key}: {s1} -> {s2}]" def format_iso_time(self, key, diff: "ItemDiff"): change = diff.changes().get(key) diff --git a/src/borg/helpers/time.py b/src/borg/helpers/time.py index 49a036c8f4..887af82b15 100644 --- a/src/borg/helpers/time.py +++ b/src/borg/helpers/time.py @@ -105,6 +105,31 @@ def format_time(ts: datetime, format_spec=""): return ts.astimezone().strftime("%a, %Y-%m-%d %H:%M:%S %z" if format_spec == "" else format_spec) +def format_timestamp_pair(ts1: datetime, ts2: datetime) -> "tuple[str, str]": + """ + Format two timestamps for diff display. + + If the timestamps appear equal when truncated to seconds but differ at the + microsecond level, use microsecond precision so the difference is visible + to the user. Otherwise use second precision (existing behavior). + + Returns a tuple (formatted_ts1, formatted_ts2). + """ + fmt_seconds = "%a, %Y-%m-%d %H:%M:%S %z" + fmt_microseconds = "%a, %Y-%m-%d %H:%M:%S.%f %z" + + t1_local = ts1.astimezone() + t2_local = ts2.astimezone() + + # Only use microsecond format when timestamps differ at sub-second level + # (i.e. they are identical when truncated to seconds but actually different). + # Identical timestamps or timestamps differing by >= 1 second use second format. + same_at_seconds = t1_local != t2_local and t1_local.replace(microsecond=0) == t2_local.replace(microsecond=0) + fmt = fmt_microseconds if same_at_seconds else fmt_seconds + + return t1_local.strftime(fmt), t2_local.strftime(fmt) + + def format_timedelta(td): """Format a timedelta in a human-friendly format.""" ts = td.total_seconds() diff --git a/src/borg/testsuite/archiver/diff_cmd_test.py b/src/borg/testsuite/archiver/diff_cmd_test.py index e096b0e33d..8abc150a5a 100644 --- a/src/borg/testsuite/archiver/diff_cmd_test.py +++ b/src/borg/testsuite/archiver/diff_cmd_test.py @@ -1,13 +1,14 @@ import json import os from pathlib import Path +import re import stat import time import pytest from ...constants import * # NOQA from .. import are_symlinks_supported, are_hardlinks_supported, granularity_sleep -from ...platformflags import is_win32, is_freebsd, is_netbsd +from ...platformflags import is_win32, is_freebsd, is_netbsd, is_openbsd from . import ( cmd, create_regular_file, @@ -428,8 +429,8 @@ def test_sort_by_all_keys_with_directions(archivers, request, sort_key): @pytest.mark.skipif( - not are_hardlinks_supported() or is_freebsd or is_netbsd or is_win32, - reason="hardlinks not supported or test failing on freebsd, netbsd and windows", + not are_hardlinks_supported() or is_win32, + reason="hardlinks not supported or not available on windows", ) def test_hard_link_deletion_and_replacement(archivers, request): archiver = request.getfixturevalue(archivers) @@ -511,5 +512,26 @@ def test_hard_link_deletion_and_replacement(archivers, request): assert_line_exists(lines, "[cm]time:.*[cm]time:.*input/a$") # From test1 to test2's POV, the a/hardlink file is a fresh new file. assert_line_exists(lines, "added.*B.*input/a/hardlink") - # But the b/hardlink file was not modified at all. - assert_line_not_exists(lines, ".*input/b/hardlink") + # On Linux/macOS: b/hardlink was not touched at all — must not appear in diff. + # On BSD (FreeBSD, NetBSD, OpenBSD): creating a new file at a previously + # hard-linked path can cause a POSIX-valid sub-second ctime update on surviving + # hard links. If b/hardlink appears, it must be a ctime-only change — no content, + # mode, or mtime changes. + if is_freebsd or is_netbsd or is_openbsd: + # BSD may show a sub-second ctime change on b/hardlink (POSIX-valid). + # If it appears, verify the diff output actually shows distinguishable timestamps + # (i.e. the format_timestamp_pair fix is working), and no other changes. + bsd_ctime_lines = [l for l in lines if re.search(r"input/b/hardlink", l)] + for line in bsd_ctime_lines: + # Must have a ctime entry with different-looking timestamps + m = re.search(r"\[ctime: (.+?) -> (.+?)\]", line) + assert m is not None, f"b/hardlink line missing ctime entry: {line!r}" + assert m.group(1) != m.group(2), ( + f"b/hardlink ctime looks identical in output: {line!r} — " + "format_timestamp_pair fix may not be working" + ) + assert_line_not_exists(lines, r"mtime:.*input/b/hardlink") + assert_line_not_exists(lines, r"modified.*input/b/hardlink") + assert_line_not_exists(lines, r"-[r-][w-][x-].*input/b/hardlink") + else: + assert_line_not_exists(lines, r".*input/b/hardlink") diff --git a/src/borg/testsuite/helpers/parseformat_test.py b/src/borg/testsuite/helpers/parseformat_test.py index c9cc1b5d59..225d5fbade 100644 --- a/src/borg/testsuite/helpers/parseformat_test.py +++ b/src/borg/testsuite/helpers/parseformat_test.py @@ -1,5 +1,6 @@ import base64 import os +import re from datetime import datetime, timezone @@ -25,8 +26,10 @@ swidth_slice, eval_escapes, ChunkerParams, + DiffFormatter, ) from ...helpers.time import format_timedelta, parse_timestamp +from ...item import ItemDiff, Item from ...platformflags import is_win32 @@ -642,3 +645,77 @@ def test_valid_chunkerparams(chunker_params, expected_return): def test_invalid_chunkerparams(invalid_chunker_params): with pytest.raises(ArgumentTypeError): ChunkerParams(invalid_chunker_params) + + +def _make_item_with_ctime(ctime_ns: int) -> Item: + """Helper: create a minimal Item with the given ctime nanoseconds. + Item.ctime is a PropDictProperty(int, encode=int_to_timestamp) — set via + attribute assignment, not dict subscript. + """ + return Item(path="test/file", mode=0o100644, mtime=0, ctime=ctime_ns) + + +def test_diff_formatter_format_time_shows_microseconds_when_same_second(): + """DiffFormatter.format_time() must use microsecond precision when + two ctimes differ only at sub-second level (the BSD hardlink issue).""" + # Two nanosecond timestamps that are the same second but different microsecond + # 2025-11-05 17:45:53.000123 UTC → 1746467153000123000 ns + # 2025-11-05 17:45:53.000456 UTC → 1746467153000456000 ns + ctime1_ns = 1746467153_000123_000 + ctime2_ns = 1746467153_000456_000 + + item1 = _make_item_with_ctime(ctime1_ns) + item2 = _make_item_with_ctime(ctime2_ns) + + diff = ItemDiff( + path="test/file", + item1=item1, + item2=item2, + chunk_1=iter([]), + chunk_2=iter([]), + can_compare_chunk_ids=True, + ) + + fmt = DiffFormatter("{ctime} {path}{NL}", content_only=False) + result = fmt.format_item(diff) + + # Must contain a dot — microseconds visible + assert "." in result, f"Expected microseconds in output, got: {result!r}" + # Must not look like [ctime: X -> X] (same string both sides) + m = re.search(r"\[ctime: (.+?) -> (.+?)\]", result) + assert m is not None + assert m.group(1) != m.group(2), "Timestamps should differ in output" + + +def test_diff_formatter_format_time_no_microseconds_for_different_seconds(): + """DiffFormatter.format_time() must use second precision (no dot) when + timestamps differ by more than one second.""" + # Two nanosecond timestamps that differ by a whole second + # 2025-11-05 17:45:53 UTC → 1746467153000000000 ns + # 2025-11-05 17:45:54 UTC → 1746467154000000000 ns + ctime1_ns = 1746467153_000000_000 + ctime2_ns = 1746467154_000000_000 + + item1 = _make_item_with_ctime(ctime1_ns) + item2 = _make_item_with_ctime(ctime2_ns) + + diff = ItemDiff( + path="test/file", + item1=item1, + item2=item2, + chunk_1=iter([]), + chunk_2=iter([]), + can_compare_chunk_ids=True, + ) + + fmt = DiffFormatter("{ctime} {path}{NL}", content_only=False) + result = fmt.format_item(diff) + + # Must NOT contain a dot — second-precision only + m = re.search(r"\[ctime: (.+?) -> (.+?)\]", result) + assert m is not None + assert "." not in m.group(1), f"Unexpected microseconds in output: {result!r}" + assert "." not in m.group(2), f"Unexpected microseconds in output: {result!r}" + # Timestamps must differ + assert m.group(1) != m.group(2), "Different-second timestamps should differ in output" + diff --git a/src/borg/testsuite/helpers/time_test.py b/src/borg/testsuite/helpers/time_test.py index 7dcffc8278..e6cb01a42f 100644 --- a/src/borg/testsuite/helpers/time_test.py +++ b/src/borg/testsuite/helpers/time_test.py @@ -1,7 +1,7 @@ import pytest -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta -from ...helpers.time import safe_ns, safe_s, SUPPORT_32BIT_PLATFORMS +from ...helpers.time import safe_ns, safe_s, SUPPORT_32BIT_PLATFORMS, format_timestamp_pair def utcfromtimestamp(timestamp): @@ -36,3 +36,36 @@ def test_safe_timestamps(): utcfromtimestamp(beyond_y10k) assert utcfromtimestamp(safe_s(beyond_y10k)) > datetime(2262, 1, 1) assert utcfromtimestamp(safe_ns(beyond_y10k) / 1000000000) > datetime(2262, 1, 1) + + +def test_format_timestamp_pair_different_seconds(): + """When timestamps differ at second level, use second-precision format.""" + ts1 = datetime(2025, 11, 5, 17, 45, 53, 123456, tzinfo=timezone.utc) + ts2 = datetime(2025, 11, 5, 17, 45, 54, 123456, tzinfo=timezone.utc) + s1, s2 = format_timestamp_pair(ts1, ts2) + # Must NOT contain a dot (no microseconds shown) + assert "." not in s1 + assert "." not in s2 + # Must differ + assert s1 != s2 + + +def test_format_timestamp_pair_same_second_different_microsecond(): + """When timestamps look equal at second resolution but differ in microseconds, + use microsecond-precision format so the difference is visible.""" + ts1 = datetime(2025, 11, 5, 17, 45, 53, 123, tzinfo=timezone.utc) + ts2 = datetime(2025, 11, 5, 17, 45, 53, 456, tzinfo=timezone.utc) + s1, s2 = format_timestamp_pair(ts1, ts2) + # Must contain a dot (microseconds shown) + assert "." in s1 + assert "." in s2 + # Must differ + assert s1 != s2 + + +def test_format_timestamp_pair_identical(): + """When timestamps are completely identical, use second-precision format.""" + ts = datetime(2025, 11, 5, 17, 45, 53, 0, tzinfo=timezone.utc) + s1, s2 = format_timestamp_pair(ts, ts) + assert "." not in s1 + assert s1 == s2