Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions pythonlings/core/curriculum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 27 additions & 1 deletion tests/integration/test_cli_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
)
47 changes: 47 additions & 0 deletions tests/unit/test_curriculum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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