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 00000000..1d5c9a0d --- /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/cli/entry_commands.py b/pyrite/cli/entry_commands.py index 0e10b9ed..549de51f 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 1c9baf8a..cbbd9fb3 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 @@ -533,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``: @@ -553,6 +558,8 @@ def get_entry( return None result = self.db.get_entry(entry_id, kb_name) if result: + 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 @@ -562,10 +569,75 @@ def get_entry( continue result = self.db.get_entry(entry_id, kb.name) if result: + 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 + @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 6c9ac0aa..af14963e 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 diff --git a/tests/test_test_affected.py b/tests/test_test_affected.py index d9f23e3f..319e5940 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):