Skip to content
Merged
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
29 changes: 28 additions & 1 deletion scripts/check-memory-rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@
a row naming no project, a row that matches more than one, or a row whose state
field is none of the three forms above
2 nothing was measured: no `projects.yml`, no bookmark, or a stamp that cannot be
ordered against an mtime - which is never a pass
ordered against an mtime - a value that does not parse, or one later than the
instant of the reading - which is never a pass

What it does not do
-------------------
Expand Down Expand Up @@ -99,6 +100,20 @@
#: value that does not fit is unmeasurable, and this is the string that decides.
STAMP_FORMAT = "%Y-%m-%d %H:%M:%S"


def now() -> datetime:
"""The instant this reading is taken, as one function so a test can pin it.

It exists to answer one question a stamp cannot answer about itself: **is this row's
instant in the future?** `newest_memory` keeps the files strictly newer than a row's
stamp, so a stamp later than the clock keeps *nothing*, every project reads
`unchanged`, and the report prints "every project is at or before its stamp" with exit
0 - a clean verdict that never changes, because the row stays in the future until
somebody edits it (issue #2421). A function rather than a `datetime.now()` call at the
one site that needs it, so the boundary can be measured instead of raced.
"""
return datetime.now()

#: The two states a row may carry instead of a stamp. Both are the reader's own note
#: about the project rather than a time, so neither can be ordered against an mtime.
NO_MEMORY_ROOT = "no memory root"
Expand Down Expand Up @@ -436,6 +451,18 @@ def project_state(project: dict, row: dict | None) -> dict:
f"the row for {name} carries {value!r}, which is not a {STAMP_FORMAT} stamp "
f"and cannot be ordered against a file's mtime ({exc})"
) from exc
taken = now()
if stamp > taken:
# Ordered, but against nothing real: an mtime cannot be later than what a cycle has
# read, so every file on disk is older than this row's instant and the project would
# read `unchanged` for as long as the row stands (issue #2421).
raise Unmeasurable(
f"the row for {name} carries the stamp {value}, later than "
f"{taken.strftime(STAMP_FORMAT)} - the instant this reading was taken - so it "
f"records a memory modification that has not happened yet: every file on disk is "
f"older than it, and the project would read `unchanged` for as long as the row "
f"stands"
)
state["bookmark"] = stamp.strftime(STAMP_FORMAT)
if not root.is_dir():
state.update(state="path missing", newest=None)
Expand Down
50 changes: 50 additions & 0 deletions tests/test_check_memory_rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,56 @@ def test_the_newest_of_several_is_the_one_named(mod, tmp_path):
assert reading["states"][0]["newest"]["path"] == str(newest)


def test_a_row_stamped_later_than_the_clock_is_unmeasurable(mod, tmp_path, monkeypatch):
"""A row stamped after the reading refuses, because it would keep every file for ever.

`newest_memory` keeps the files strictly newer than the row's instant, so a row stamped
later than the clock keeps *nothing*: its project reads `unchanged`, and a bookmark with
a row for every project prints "every project is at or before its stamp" and exits 0 —
the silently-forever failure the module docstring already catalogues for the ISO `T`
form and for the sub-second comparison, with a stamp that parses. Measured in the cycle
that filed issue #2421: rows stamped `2099-01-01 00:00:00` answered `changed since their
bookmark: 0` with exit 0, while the same bookmark stamped in the past named both changed
files. The refusal is asserted through `main` as well, because it is the tool's own
reading and not only a function's return.
"""
from datetime import datetime, timedelta

root = _root(tmp_path, "ahead")
_write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod))
taken = datetime.strptime(BASE, mod.STAMP_FORMAT)
monkeypatch.setattr(mod, "now", lambda: taken)
ahead = (taken + timedelta(seconds=1)).strftime(mod.STAMP_FORMAT)
register = _projects(tmp_path / "projects.yml", [{"name": "ahead", "path": root}])
book = _bookmark(tmp_path / "ahead.md", [("`ahead`", ahead)])
with pytest.raises(mod.Unmeasurable) as excinfo:
mod.read(register, book)
message = str(excinfo.value)
assert ahead in message, message
assert taken.strftime(mod.STAMP_FORMAT) in message, message
assert "ahead" in message, message
assert mod.main(["--projects", str(register), "--bookmark", str(book)]) == 2


def test_a_row_stamped_at_the_clock_is_read_normally(mod, tmp_path, monkeypatch):
"""The other half of the same second: one second earlier is a reading, not a refusal.

These two rows differ by one second and by nothing else, so a guard that refused both
would look exactly like one that refused neither.
"""
from datetime import datetime

root = _root(tmp_path, "at-the-clock")
_write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod))
taken = datetime.strptime(BASE, mod.STAMP_FORMAT)
monkeypatch.setattr(mod, "now", lambda: taken)
register = _projects(tmp_path / "projects.yml", [{"name": "at-the-clock", "path": root}])
book = _bookmark(tmp_path / "at.md", [("`at-the-clock`", BASE)])
reading = mod.read(register, book)
assert reading["states"][0]["state"] == "unchanged"
assert mod.main(["--projects", str(register), "--bookmark", str(book)]) == 0


def test_a_project_with_no_memory_root_and_one_whose_path_is_gone(mod, tmp_path):
"""Two states that are not a time: neither can be ordered against an mtime."""
bare = _root(tmp_path, "bare")
Expand Down
Loading