From 821183b95840c4b2ba1a272cacb6a284e990de24 Mon Sep 17 00:00:00 2001 From: Ben Davidson Date: Tue, 6 Oct 2026 13:17:55 +1030 Subject: [PATCH 1/3] Leave the config alone when only its generated comments differ The startup migration compared its render against the file byte for byte, so two builds that word a settings hint differently rewrote each other's config on every switch, each time printing "config updated, adopt the canonical layout" and leaving another backup. It now rewrites only when the parsed values differ. --- fnd/config.py | 9 +++++---- tests/test_config_render.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/fnd/config.py b/fnd/config.py index bf5f61dc..44494796 100644 --- a/fnd/config.py +++ b/fnd/config.py @@ -1053,9 +1053,10 @@ def load(path: Path | None = None) -> Config: def ensure_current(config_path: Path | None = None) -> list[str]: """Rewrite the config in the current shape, keeping a timestamped backup. - Returns what was applied, empty when the file was already current. Called - once at startup so every config converges on one syntax rather than each - legacy shape being honoured forever. + Returns what was applied, empty when the file already holds the current + values: builds that word a hint differently must not rewrite each other's + file. Called once at startup so every config converges on one syntax rather + than each legacy shape being honoured forever. """ from fnd._perms import secure_write_text from fnd.config_migrations import migrate @@ -1069,7 +1070,7 @@ def ensure_current(config_path: Path | None = None) -> list[str]: version, applied = migrate(raw) config = Config.model_validate(raw) rendered = render_config(config, preserved=extract_preserved(text), version=version) - if rendered == text: + if rendered == text or tomllib.loads(rendered) == tomllib.loads(text): return [] _refuse_lossy(rendered, config, raw) stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") diff --git a/tests/test_config_render.py b/tests/test_config_render.py index 8332e33b..0a4833ee 100644 --- a/tests/test_config_render.py +++ b/tests/test_config_render.py @@ -617,6 +617,25 @@ def test_ensure_current_is_a_no_op_on_a_current_file(self, tmp_path: Path) -> No assert conf.ensure_current(path) == [] assert not list(tmp_path.glob("config.toml.bak-*")) + def test_reworded_generated_comments_alone_are_not_rewritten(self, tmp_path: Path) -> None: + """Builds that word a hint differently otherwise rewrite each other's file on launch.""" + path = tmp_path / "config.toml" + current = render_config(_sample()) + older = current.replace("# fnd configuration.", "# fnd configuration, older wording.") + assert older != current + path.write_text(older, encoding="utf-8") + assert conf.ensure_current(path) == [] + assert path.read_text(encoding="utf-8") == older + assert not list(tmp_path.glob("config.toml.bak-*")) + + def test_a_value_in_a_non_canonical_form_is_still_rewritten(self, tmp_path: Path) -> None: + path = tmp_path / "config.toml" + current = render_config(_sample()) + assert '"~/Notes"' in current + path.write_text(current.replace('"~/Notes"', f'"{Path.home()}/Notes"'), encoding="utf-8") + assert conf.ensure_current(path) == ["Adopt the canonical layout"] + assert path.read_text(encoding="utf-8") == current + class TestWritersStayCanonical: @pytest.mark.parametrize( From fae8ca4c0fb18ce6b2b652c51a2261b7d7bde337 Mon Sep 17 00:00:00 2001 From: Ben Davidson Date: Tue, 6 Oct 2026 15:39:54 +1030 Subject: [PATCH 2/3] Write the absolute home path as a TOML literal string, so Windows backslashes parse --- tests/test_config_render.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_config_render.py b/tests/test_config_render.py index 0a4833ee..a3b4effe 100644 --- a/tests/test_config_render.py +++ b/tests/test_config_render.py @@ -632,7 +632,8 @@ def test_a_value_in_a_non_canonical_form_is_still_rewritten(self, tmp_path: Path path = tmp_path / "config.toml" current = render_config(_sample()) assert '"~/Notes"' in current - path.write_text(current.replace('"~/Notes"', f'"{Path.home()}/Notes"'), encoding="utf-8") + absolute = f"'{Path.home() / 'Notes'}'" # a literal string, so Windows backslashes parse + path.write_text(current.replace('"~/Notes"', absolute), encoding="utf-8") assert conf.ensure_current(path) == ["Adopt the canonical layout"] assert path.read_text(encoding="utf-8") == current From f992fc8a65552c3502e7c2f70fc83f3d60024b60 Mon Sep 17 00:00:00 2001 From: Ben Davidson Date: Tue, 6 Oct 2026 20:35:28 +1030 Subject: [PATCH 3/3] Build the visible-only layout map directly in the stops regression test The test reached its stale-map state through `Screen._refresh_layout(scroll=True)`, which takes Textual's visible-only fast path only while no widget has a layout pending. On a slow runner one did, the screen reflowed in full, and the test's own precondition failed on Windows. It now calls `Compositor.reflow_visible`, the fast path itself. With a pending layout injected, the old setup fails with the CI message and this one passes; it still catches a single derived offset. --- tests/test_match_nav_integration.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_match_nav_integration.py b/tests/test_match_nav_integration.py index 6e608632..1d00a38e 100644 --- a/tests/test_match_nav_integration.py +++ b/tests/test_match_nav_integration.py @@ -599,8 +599,10 @@ async def test_stops_hold_their_rows_while_a_scroll_awaits_its_render( message=f"the stops never read as settled at y={start}", ) if state == "visible_map": - # The render Textual makes after a scroll: visible widgets only. - app.screen._refresh_layout(scroll=True) + # Textual's post-scroll fast path, called directly: the screen takes + # it only while no widget has a layout pending, which a slow runner + # does not guarantee. + app.screen._compositor.reflow_visible(app.screen, app.screen.outer_size) assert app.screen._compositor._visible_map is not None pane.scroll_to(y=end, animate=False, immediate=True)