This commit is contained in:
Rezky Hamid 2026-05-22 11:18:39 +02:00 committed by GitHub
commit 4d966e4d55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 160 additions and 9 deletions

View file

@ -27,7 +27,7 @@ from .errors import Error
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 @@ class DiffFormatter(BaseFormatter):
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)

View file

@ -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()

View file

@ -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,7 @@ 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 +511,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")

View file

@ -1,5 +1,6 @@
import base64
import os
import re
from datetime import datetime, timezone
@ -25,8 +26,10 @@ from ...helpers.parseformat import (
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,67 @@ 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"

View file

@ -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