mirror of
https://github.com/borgbackup/borg.git
synced 2026-06-10 17:32:13 -04:00
Merge cf7bd04599 into e016a51cf2
This commit is contained in:
commit
4d966e4d55
5 changed files with 160 additions and 9 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue