From d89f014241f7a0a6b6450213f43b804bf971c21d Mon Sep 17 00:00:00 2001 From: sb123sb123 <152394158+sb123sb123@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:46:18 +0800 Subject: [PATCH 1/2] fix: read current markdown body in get Fixes #753 Signed-off-by: sb123sb123 <152394158+sb123sb123@users.noreply.github.com> --- .../get-body-from-current-file.fixed.md | 1 + pyrite/services/kb_service.py | 66 +++++++++++++++++ tests/test_cli_json_output.py | 73 +++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 changelog.d/get-body-from-current-file.fixed.md diff --git a/changelog.d/get-body-from-current-file.fixed.md b/changelog.d/get-body-from-current-file.fixed.md new file mode 100644 index 000000000..1d5c9a0df --- /dev/null +++ b/changelog.d/get-body-from-current-file.fixed.md @@ -0,0 +1 @@ +- **`pyrite get` reads the current body from its Markdown file (#753).** The JSON body and SHA-256 now reflect edits made since the index was built, so echoing the body through `pyrite update --body-file` no longer discards a newer section. diff --git a/pyrite/services/kb_service.py b/pyrite/services/kb_service.py index 1c9baf8a7..ab76812be 100644 --- a/pyrite/services/kb_service.py +++ b/pyrite/services/kb_service.py @@ -8,6 +8,7 @@ import copy import dataclasses +import hashlib import logging import os from collections.abc import Callable @@ -553,6 +554,7 @@ def get_entry( return None result = self.db.get_entry(entry_id, kb_name) if result: + result = self._refresh_entry_body(result, entry_id, kb_name) self._attach_links(result, entry_id, kb_name, readable_kbs) return result @@ -562,10 +564,74 @@ def get_entry( continue result = self.db.get_entry(entry_id, kb.name) if result: + result = self._refresh_entry_body(result, entry_id, kb.name) self._attach_links(result, entry_id, kb.name, readable_kbs) return result return None + @staticmethod + def _body_from_entry_file(content: bytes) -> str | None: + """Return the body's exact text after the YAML frontmatter. + + Consume the closing fence's line ending and the one blank separator + emitted by the entry serializer. Preserve body whitespace and its + original line endings so get can be echoed to update losslessly. + """ + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + return None + if text.startswith("\ufeff"): + text = text[1:] + if not text.startswith(("---\n", "---\r\n")): + return None + + cursor = text.find("\n") + 1 + while cursor > 0 and cursor <= len(text): + line_end = text.find("\n", cursor) + if line_end < 0: + line_end = len(text) + line = text[cursor:line_end].removesuffix("\r") + if line.strip(" \t") == "---": + body_start = line_end + 1 if line_end < len(text) else line_end + if text.startswith("\r\n", body_start): + body_start += 2 + elif text.startswith("\n", body_start): + body_start += 1 + return text[body_start:] + if line_end == len(text): + break + cursor = line_end + 1 + return None + + def _refresh_entry_body( + self, result: dict[str, Any], entry_id: str, kb_name: str + ) -> dict[str, Any]: + """Use the current file for a single-entry body's content and hash. + + The index is a search/listing cache and can lag a hand edit. A caller + that gets an entry and writes its body back must see the file's current + content, even before the next index sync. + """ + kb_config = self.get_kb(kb_name) + if kb_config is None: + return result + + entry = KBRepository(kb_config).load(entry_id) + if entry is None or entry.file_path is None: + return result + try: + content = Path(entry.file_path).read_bytes() + except OSError: + logger.warning("Could not read current entry file %s", entry.file_path, exc_info=True) + return result + + body = self._body_from_entry_file(content) + result["body"] = entry.body if body is None else body + result["file_path"] = str(entry.file_path) + result["content_hash"] = hashlib.sha256(content).hexdigest() + return result + def _attach_links( self, result: dict[str, Any], diff --git a/tests/test_cli_json_output.py b/tests/test_cli_json_output.py index 6c9ac0aaf..af14963e3 100644 --- a/tests/test_cli_json_output.py +++ b/tests/test_cli_json_output.py @@ -3,7 +3,10 @@ Verifies that commands with --format json return valid JSON with expected keys. """ +import hashlib import json +import os +import subprocess import tempfile from pathlib import Path from unittest.mock import patch @@ -433,6 +436,76 @@ def test_search_error_logged_at_debug(cli_env, caplog): ) +@pytest.mark.cli +@pytest.mark.core +def test_get_reads_current_file_body_and_body_round_trip_is_clean(cli_env): + """A stale index must not hide a section present in the file from get.""" + kb = cli_env["config"].knowledge_bases[0] + repo = KBRepository(kb) + entry, entry_path = next(repo.list_entries()) + entry_id = entry.id + model_body = ( + " Body text. \n\n### Conductor QC (2026-10-05)\n\nThe existing section remains intact. " + ) + expected_body = model_body.replace("\n", os.linesep) + os.linesep + entry.body = model_body + entry_path.write_text(entry.to_markdown(), encoding="utf-8") + committed_content = entry_path.read_bytes() + + # The fixture's index still has the old body; commit the current file as the + # baseline so the get/update round trip must leave it unchanged. + db = PyriteDB(cli_env["config"].settings.index_path) + try: + indexed = db.get_entry(entry_id, kb.name) + finally: + db.close() + assert indexed is not None + assert indexed["body"] == "Body text." + + for args in ( + ["git", "init", "--quiet"], + ["git", "config", "user.name", "Pyrite test"], + ["git", "config", "user.email", "pyrite-test@example.invalid"], + ["git", "add", "--all"], + ["git", "commit", "--quiet", "-m", "baseline"], + ): + subprocess.run(args, cwd=kb.path, check=True, capture_output=True) + + with _patch_config("pyrite.cli.entry_commands.load_config", cli_env): + result = runner.invoke(app, ["get", entry_id, "--kb", kb.name, "--format", "json"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["body"] == expected_body + assert data["content_hash"] == hashlib.sha256(committed_content).hexdigest() + + body_file = cli_env["tmpdir"] / "round-trip-body.md" + body_file.write_text(data["body"], encoding="utf-8", newline="") + with _patch_config("pyrite.cli.entry_commands.load_config", cli_env): + updated = runner.invoke( + app, + [ + "update", + entry_id, + "--kb", + kb.name, + "--body-file", + str(body_file), + "--format", + "json", + ], + ) + assert updated.exit_code == 0, updated.output + assert entry_path.read_bytes() == committed_content + diff = subprocess.run( + ["git", "diff", "--exit-code"], + cwd=kb.path, + check=False, + capture_output=True, + text=True, + ) + assert diff.returncode == 0, diff.stdout + diff.stderr + + @pytest.mark.cli def test_get_not_found_json_is_parseable(cli_env): """get -f json for a missing entry emits a valid JSON error object on From 034aebad36ebf6c788a9ed391159f0df593bb37f Mon Sep 17 00:00:00 2001 From: sb123sb123 <152394158+sb123sb123@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:22:39 +0800 Subject: [PATCH 2/2] fix: limit current-file reads to CLI get Signed-off-by: sb123sb123 <152394158+sb123sb123@users.noreply.github.com> --- pyrite/cli/entry_commands.py | 7 ++++++- pyrite/services/kb_service.py | 10 ++++++++-- tests/test_test_affected.py | 1 + 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pyrite/cli/entry_commands.py b/pyrite/cli/entry_commands.py index 0e10b9ed1..549de51f3 100644 --- a/pyrite/cli/entry_commands.py +++ b/pyrite/cli/entry_commands.py @@ -153,7 +153,12 @@ def get_entry( ): """Get a specific entry by ID.""" with cli_context() as (config, db, svc): - result = svc.get_entry(entry_id, kb_name=kb_name, readable_kbs=UNSCOPED) + result = svc.get_entry( + entry_id, + kb_name=kb_name, + readable_kbs=UNSCOPED, + read_current_body=True, + ) if not result: _cli_error(f"Entry '{entry_id}' not found", output_format, "NOT_FOUND") diff --git a/pyrite/services/kb_service.py b/pyrite/services/kb_service.py index ab76812be..cbbd9fb36 100644 --- a/pyrite/services/kb_service.py +++ b/pyrite/services/kb_service.py @@ -534,10 +534,14 @@ def get_entry( kb_name: str | None = None, *, readable_kbs: set[str] | None, + read_current_body: bool = False, ) -> dict[str, Any] | None: """ Get entry by ID, with its outlinks and backlinks. + CLI callers may request the exact current file body for a lossless + get/update round trip; other read surfaces keep their normal projection. + If kb_name not specified, searches all KBs in config order. ``readable_kbs`` is the caller's ``ReadScope`` set (``None``: @@ -554,7 +558,8 @@ def get_entry( return None result = self.db.get_entry(entry_id, kb_name) if result: - result = self._refresh_entry_body(result, entry_id, kb_name) + if read_current_body: + result = self._refresh_entry_body(result, entry_id, kb_name) self._attach_links(result, entry_id, kb_name, readable_kbs) return result @@ -564,7 +569,8 @@ def get_entry( continue result = self.db.get_entry(entry_id, kb.name) if result: - result = self._refresh_entry_body(result, entry_id, kb.name) + if read_current_body: + result = self._refresh_entry_body(result, entry_id, kb.name) self._attach_links(result, entry_id, kb.name, readable_kbs) return result return None diff --git a/tests/test_test_affected.py b/tests/test_test_affected.py index d9f23e3fd..319e59405 100644 --- a/tests/test_test_affected.py +++ b/tests/test_test_affected.py @@ -651,6 +651,7 @@ def test_selector_parses_the_repository(self): "tests/test_cli_commands.py::TestTyperGetCommand", "tests/test_cli_commands.py::TestTyperCreateCommand", "tests/test_cli_commands.py::TestTopLevelHelpAdvertisesOrient", + "tests/test_cli_json_output.py::test_get_reads_current_file_body_and_body_round_trip_is_clean", } def test_core_set_is_exactly_the_named_surfaces(self):