diff --git a/graphlink_app/graphlink_plugins/gitlink/repository.py b/graphlink_app/graphlink_plugins/gitlink/repository.py index e1ab2e2..0b1dfc7 100644 --- a/graphlink_app/graphlink_plugins/gitlink/repository.py +++ b/graphlink_app/graphlink_plugins/gitlink/repository.py @@ -155,7 +155,14 @@ def apply_change_set(local_root, pending_changes): backups.append((target_path, target_path.read_bytes() if target_path.exists() else None)) target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_text(file_item.get("content", ""), encoding="utf-8") + # newline="" writes the approved content byte-for-byte. The default + # (newline=None) translates on write - on Windows \n becomes \r\n + # and an existing \r\n becomes \r\r\n - so the file on disk would + # diverge from exactly what the user approved in the preview, which + # diffs via splitlines() (line-ending-agnostic) and so never + # surfaces the drift. The rollback path below already restores + # byte-exact via write_bytes; the forward write must match it. + target_path.write_text(file_item.get("content", ""), encoding="utf-8", newline="") written_files += 1 return written_files diff --git a/graphlink_app/tests/test_gitlink_repository.py b/graphlink_app/tests/test_gitlink_repository.py index 5e145f2..ed61a29 100644 --- a/graphlink_app/tests/test_gitlink_repository.py +++ b/graphlink_app/tests/test_gitlink_repository.py @@ -332,6 +332,29 @@ def test_delete_on_nonexistent_file_is_a_silent_no_op(self, tmp_path): written = apply_change_set(tmp_path, [{"path": "never_existed.py", "operation": "delete"}]) assert written == 0 + def test_write_preserves_content_bytes_exactly_including_line_endings(self, tmp_path): + # Regression: the default write_text newline translated content on + # write - on Windows \n -> \r\n and an existing \r\n -> \r\r\n - so the + # on-disk bytes diverged from what the user approved in the preview + # (which diffs line-ending-agnostically via splitlines and never showed + # it). Assert byte-exact: read_text() would MASK this by translating + # newlines back on read, so this must read_bytes(). + content = "a\nb\r\nc\n" + written = apply_change_set(tmp_path, [ + {"path": "mixed.txt", "operation": "create", "content": content}, + ]) + assert written == 1 + on_disk = (tmp_path / "mixed.txt").read_bytes() + assert on_disk == content.encode("utf-8") + assert b"\r\r\n" not in on_disk, "an existing CRLF must not be doubled to CR-CR-LF" + + def test_update_preserves_lf_only_content_byte_for_byte(self, tmp_path): + (tmp_path / "a.py").write_text("old") + apply_change_set(tmp_path, [ + {"path": "a.py", "operation": "update", "content": "x = 1\ny = 2\n"}, + ]) + assert (tmp_path / "a.py").read_bytes() == b"x = 1\ny = 2\n" + class TestApplyChangeSetRollback: """Audit finding A1: a mid-loop failure used to leave files 1..N-1 already