Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/borg/helpers/parseformat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions src/borg/helpers/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
32 changes: 27 additions & 5 deletions src/borg/testsuite/archiver/diff_cmd_test.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -428,8 +429,8 @@


@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)
Expand Down Expand Up @@ -511,5 +512,26 @@
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)]

Check failure on line 524 in src/borg/testsuite/archiver/diff_cmd_test.py

View workflow job for this annotation

GitHub Actions / lint

ruff (E741)

src/borg/testsuite/archiver/diff_cmd_test.py:524:34: E741 Ambiguous variable name: `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")
77 changes: 77 additions & 0 deletions src/borg/testsuite/helpers/parseformat_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import base64
import os
import re

from datetime import datetime, timezone

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


Expand Down Expand Up @@ -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"

37 changes: 35 additions & 2 deletions src/borg/testsuite/helpers/time_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta

Check failure on line 2 in src/borg/testsuite/helpers/time_test.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F401)

src/borg/testsuite/helpers/time_test.py:2:42: F401 `datetime.timedelta` imported but unused help: Remove unused import: `datetime.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):
Expand Down Expand Up @@ -36,3 +36,36 @@
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