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