From 44b5cd4d18ca578da226287a13f3ec30de3743e7 Mon Sep 17 00:00:00 2001 From: Morax Date: Sun, 9 Aug 2026 13:20:28 +0200 Subject: [PATCH 1/2] fix: preserve workspace gitignore entries Signed-off-by: Morax --- pythonlings/core/curriculum.py | 20 ++++++++--- tests/integration/test_cli_workspace.py | 28 ++++++++++++++- tests/unit/test_curriculum.py | 47 +++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/pythonlings/core/curriculum.py b/pythonlings/core/curriculum.py index 655dbf5..90ce085 100644 --- a/pythonlings/core/curriculum.py +++ b/pythonlings/core/curriculum.py @@ -59,10 +59,22 @@ def _copy_path(src: Path, dst: Path, *, overwrite: bool) -> None: def _write_workspace_gitignore(root: Path) -> None: - (root / ".gitignore").write_text( - "\n".join(GITIGNORE_LINES) + "\n", - encoding="utf-8", - ) + gitignore = root / ".gitignore" + if gitignore.exists(): + with gitignore.open(encoding="utf-8", newline="") as file: + existing = file.read() + else: + existing = "" + + existing_lines = set(existing.splitlines()) + missing_lines = [line for line in GITIGNORE_LINES if line not in existing_lines] + if not missing_lines: + return + + with gitignore.open("a", encoding="utf-8", newline="") as file: + if existing and not existing.endswith(("\n", "\r")): + file.write("\n") + file.write("\n".join(missing_lines) + "\n") def _sync_originals(root: Path, src_root: Path) -> None: diff --git a/tests/integration/test_cli_workspace.py b/tests/integration/test_cli_workspace.py index 7294d22..ab22769 100644 --- a/tests/integration/test_cli_workspace.py +++ b/tests/integration/test_cli_workspace.py @@ -12,6 +12,12 @@ def test_init_command_creates_workspace(tmp_path: Path) -> None: assert (target / "info.toml").exists() assert (target / "exercises").is_dir() assert (target / "checks").is_dir() + assert (target / ".gitignore").read_text(encoding="utf-8").splitlines() == [ + ".pythonlings/state.json", + ".pythonlings_debug.log", + "__pycache__/", + "*.pyc", + ] def test_init_rejects_non_empty_non_workspace_dir(tmp_path: Path, capsys) -> None: @@ -39,10 +45,21 @@ def test_init_on_existing_workspace_is_friendly_noop(tmp_path: Path, capsys) -> def test_init_force_overwrites_existing_workspace(tmp_path: Path) -> None: target = tmp_path / "ws" - assert main(["init", "--path", str(target)]) == 0 + target.mkdir() + gitignore = target / ".gitignore" + gitignore.write_text("# Local ignores\n.venv/\n", encoding="utf-8") + code = main(["init", "--path", str(target), "--force"]) + assert code == 0 assert (target / "info.toml").exists() + assert gitignore.read_text(encoding="utf-8") == ( + "# Local ignores\n.venv/\n" + ".pythonlings/state.json\n" + ".pythonlings_debug.log\n" + "__pycache__/\n" + "*.pyc\n" + ) def test_update_via_path_migrates_legacy_state_dir(tmp_path: Path) -> None: @@ -70,9 +87,18 @@ def test_update_command_preserves_user_exercises(tmp_path: Path) -> None: assert main(["init", "--path", str(target)]) == 0 exercise = next((target / "exercises").rglob("*.py")) exercise.write_text("# edited\n", encoding="utf-8") + gitignore = target / ".gitignore" + gitignore.write_text("# Team rules\n.coverage\n", encoding="utf-8") code = main(["update", "--path", str(target)]) assert code == 0 assert exercise.read_text(encoding="utf-8") == "# edited\n" assert (target / ".pythonlings" / "originals").is_dir() + assert gitignore.read_text(encoding="utf-8") == ( + "# Team rules\n.coverage\n" + ".pythonlings/state.json\n" + ".pythonlings_debug.log\n" + "__pycache__/\n" + "*.pyc\n" + ) diff --git a/tests/unit/test_curriculum.py b/tests/unit/test_curriculum.py index f923e88..52469c8 100644 --- a/tests/unit/test_curriculum.py +++ b/tests/unit/test_curriculum.py @@ -43,6 +43,37 @@ def test_init_workspace_refuses_non_empty_directory(tmp_path: Path) -> None: raise AssertionError("expected WorkspaceError") +def test_force_init_preserves_existing_gitignore_entries(tmp_path: Path) -> None: + target = tmp_path / "workspace" + target.mkdir() + gitignore = target / ".gitignore" + original = "# Local ignores\n.env\n\n*.pyc" + gitignore.write_text(original, encoding="utf-8") + + curriculum.init_workspace(target, force=True) + + expected = ( + original + + "\n.pythonlings/state.json\n.pythonlings_debug.log\n__pycache__/\n" + ) + assert gitignore.read_text(encoding="utf-8") == expected + + curriculum.init_workspace(target, force=True) + + assert gitignore.read_text(encoding="utf-8") == expected + + +def test_force_init_populates_empty_gitignore(tmp_path: Path) -> None: + target = tmp_path / "workspace" + target.mkdir() + gitignore = target / ".gitignore" + gitignore.touch() + + curriculum.init_workspace(target, force=True) + + assert gitignore.read_text(encoding="utf-8").splitlines() == curriculum.GITIGNORE_LINES + + def test_update_workspace_preserves_user_exercise_edit(tmp_path: Path) -> None: target = curriculum.init_workspace(tmp_path / "workspace") exercise = next((target / "exercises").rglob("*.py")) @@ -54,3 +85,19 @@ def test_update_workspace_preserves_user_exercise_edit(tmp_path: Path) -> None: original = target / ".pythonlings" / "originals" / exercise.relative_to(target / "exercises") assert original.exists() assert (target / "solutions" / "_answers.py").exists() + + +def test_update_workspace_preserves_existing_gitignore_entries(tmp_path: Path) -> None: + target = curriculum.init_workspace(tmp_path / "workspace") + gitignore = target / ".gitignore" + original = "# Team rules\n.coverage\n" + gitignore.write_text(original, encoding="utf-8") + + curriculum.update_workspace(target) + + expected = original + "\n".join(curriculum.GITIGNORE_LINES) + "\n" + assert gitignore.read_text(encoding="utf-8") == expected + + curriculum.update_workspace(target) + + assert gitignore.read_text(encoding="utf-8") == expected From b481327e21f4574e5f4cc921c2ebbbdb1934ce3d Mon Sep 17 00:00:00 2001 From: Morax Date: Tue, 11 Aug 2026 12:49:52 +0200 Subject: [PATCH 2/2] fix: preserve gitignore line endings --- pythonlings/core/curriculum.py | 5 +++-- tests/unit/test_curriculum.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/pythonlings/core/curriculum.py b/pythonlings/core/curriculum.py index 90ce085..43c0988 100644 --- a/pythonlings/core/curriculum.py +++ b/pythonlings/core/curriculum.py @@ -71,10 +71,11 @@ def _write_workspace_gitignore(root: Path) -> None: if not missing_lines: return + line_ending = "\r\n" if "\r\n" in existing else "\n" with gitignore.open("a", encoding="utf-8", newline="") as file: if existing and not existing.endswith(("\n", "\r")): - file.write("\n") - file.write("\n".join(missing_lines) + "\n") + file.write(line_ending) + file.write(line_ending.join(missing_lines) + line_ending) def _sync_originals(root: Path, src_root: Path) -> None: diff --git a/tests/unit/test_curriculum.py b/tests/unit/test_curriculum.py index 52469c8..fb2b4fb 100644 --- a/tests/unit/test_curriculum.py +++ b/tests/unit/test_curriculum.py @@ -74,6 +74,21 @@ def test_force_init_populates_empty_gitignore(tmp_path: Path) -> None: assert gitignore.read_text(encoding="utf-8").splitlines() == curriculum.GITIGNORE_LINES +def test_force_init_preserves_gitignore_crlf_line_endings(tmp_path: Path) -> None: + target = tmp_path / "workspace" + target.mkdir() + gitignore = target / ".gitignore" + original = b"# Windows workspace\r\n.env" + gitignore.write_bytes(original) + + curriculum.init_workspace(target, force=True) + + expected = original + b"\r\n" + b"\r\n".join( + line.encode("utf-8") for line in curriculum.GITIGNORE_LINES + ) + b"\r\n" + assert gitignore.read_bytes() == expected + + def test_update_workspace_preserves_user_exercise_edit(tmp_path: Path) -> None: target = curriculum.init_workspace(tmp_path / "workspace") exercise = next((target / "exercises").rglob("*.py"))