Skip to content
Merged
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
10 changes: 7 additions & 3 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ cargo build --features extension-module
# Python E2E (mirrors the ci.yml `python-e2e` job). Best-effort: needs a local
# .venv with maturin installed. CI is the authoritative gate.
if [ -x .venv/bin/maturin ]; then
echo "[pre-push] maturin develop + Python E2E vs git"
.venv/bin/maturin develop --release >/dev/null
.venv/bin/python python/tests/test_e2e.py
echo "[pre-push] maturin develop + pytest coverage"
.venv/bin/maturin develop --release --extras dev >/dev/null
.venv/bin/python -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80
elif [ -x .venv/Scripts/maturin.exe ]; then
echo "[pre-push] maturin develop + pytest coverage"
.venv/Scripts/maturin.exe develop --release --extras dev >/dev/null
.venv/Scripts/python.exe -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80
else
echo "[pre-push] (skipping Python E2E — no .venv/bin/maturin; gated by CI python-e2e)"
fi
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,5 @@ jobs:
run: |
python -m venv .venv
.venv/bin/pip install -q maturin
.venv/bin/maturin develop --release
.venv/bin/python python/tests/test_e2e.py
.venv/bin/maturin develop --release --extras dev
.venv/bin/python -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ Cargo.lock
__pycache__/
*.py[cod]
*.so
*.pdb
.venv/
build/
dist/
*.egg-info/
wheels/
.coverage
htmlcov/

# Editor / OS
.DS_Store
Expand Down
24 changes: 24 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ classifiers = [
[project.urls]
Repository = "https://github.com/hartsock/gitxtend"

[project.optional-dependencies]
dev = [
"pytest>=8,<9",
"pytest-cov>=6,<7",
"pytest-mock>=3,<4",
]

[tool.maturin]
# Compiled module imported as `import gitxtend`. Type stubs in python/gitxtend.
# `extension-module` is a crate feature that layers on `python` (which pulls
Expand All @@ -27,3 +34,20 @@ Repository = "https://github.com/hartsock/gitxtend"
features = ["extension-module"]
python-source = "python"
module-name = "gitxtend._gitxtend"

[tool.pytest.ini_options]
addopts = [
"--strict-markers",
]
markers = [
"integration: tests that exercise the compiled extension and real git CLI",
]
testpaths = ["python/tests"]

[tool.coverage.run]
branch = true
source = ["gitxtend"]
omit = ["python/tests/*"]

[tool.coverage.report]
fail_under = 80
37 changes: 37 additions & 0 deletions python/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

from unittest.mock import MagicMock

import pytest


PLANNED_WRITE_EXTENSION_NAMES = (
"pull",
"push",
"add",
"commit",
"stash_push",
"stash_pop",
"create_branch",
"reset_hard",
"rebase",
"stash_rebase",
)


@pytest.fixture
def mock_gitxtend(monkeypatch):
"""Patch the compiled extension surface for Python unit tests."""
import gitxtend
import gitxtend._gitxtend as extension

mock_extension = MagicMock(name="gitxtend._gitxtend")
public_extension_names = (*gitxtend.__all__, *PLANNED_WRITE_EXTENSION_NAMES)

for name in public_extension_names:
mock = MagicMock(name=f"gitxtend._gitxtend.{name}")
setattr(mock_extension, name, mock)
monkeypatch.setattr(extension, name, mock, raising=False)
monkeypatch.setattr(gitxtend, name, mock, raising=False)

return mock_extension
3 changes: 3 additions & 0 deletions python/tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
import unittest

import gitxtend
import pytest

pytestmark = pytest.mark.integration

_ENV = {
**os.environ,
Expand Down
34 changes: 34 additions & 0 deletions python/tests/test_mock_fixture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

import gitxtend
from conftest import PLANNED_WRITE_EXTENSION_NAMES


def test_mock_gitxtend_fixture_replaces_extension_exports(mock_gitxtend):
mock_gitxtend.is_git_repo.return_value = True

assert gitxtend.is_git_repo("/tmp/repo") is True
mock_gitxtend.is_git_repo.assert_called_once_with("/tmp/repo")


def test_mock_gitxtend_fixture_includes_write_side_exports(mock_gitxtend):
write_side_calls = {
"pull": ("/tmp/repo",),
"push": ("/tmp/repo", "origin", "main"),
"add": ("/tmp/repo", ["README.md"]),
"commit": ("/tmp/repo", "initial import"),
"stash_push": ("/tmp/repo", "before-rebase"),
"stash_pop": ("/tmp/repo",),
"create_branch": ("/tmp/repo", "feature/test"),
"reset_hard": ("/tmp/repo", "HEAD~1"),
"rebase": ("/tmp/repo", "origin/main"),
"stash_rebase": ("/tmp/repo", "origin/main"),
}

assert set(write_side_calls) == set(PLANNED_WRITE_EXTENSION_NAMES)

for name, args in write_side_calls.items():
getattr(mock_gitxtend, name).return_value = name

assert getattr(gitxtend, name)(*args) == name
getattr(mock_gitxtend, name).assert_called_once_with(*args)
Loading