From ae5eb12a432ff174be479ff0e895cdf15649e157 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 31 May 2026 14:20:59 -0400 Subject: [PATCH] =?UTF-8?q?chore(release):=20v0.1.0=20=E2=80=94=20read=20s?= =?UTF-8?q?ide=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHAT: - Bump version 0.0.1 -> 0.1.0 (Cargo.toml, pyproject.toml, Cargo.lock). - Add the Python E2E suite (python/tests/test_e2e.py): the COMPILED wheel vs the real git CLI across all 13 read methods + every repo_status state (18 tests). - Wire it into CI (ci.yml `python-e2e` job: maturin develop + run the suite) and the pre-push hook (best-effort, mirrors CI). - Flip the README status scaffold -> "v0.1.0 — read side implemented"; correct the Layout to the per-method `repo/` module structure. WHY: All M1 read-side work (13 primitives + the repo_status roll-up) is merged, parity-tested vs git, and now verified end-to-end through the compiled wheel. Co-Authored-By: Claude Opus 4.8 (1M context) --- .githooks/pre-push | 10 ++ .github/workflows/ci.yml | 17 +++ Cargo.toml | 2 +- README.md | 19 ++-- pyproject.toml | 2 +- python/tests/test_e2e.py | 230 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 python/tests/test_e2e.py diff --git a/.githooks/pre-push b/.githooks/pre-push index 08b3ba4..4f83ec2 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -23,4 +23,14 @@ cargo clippy --features extension-module -- -D warnings echo "[pre-push] cargo build (python)" 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 +else + echo "[pre-push] (skipping Python E2E — no .venv/bin/maturin; gated by CI python-e2e)" +fi + echo "[pre-push] OK" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7802e9d..2c86634 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,20 @@ jobs: run: cargo clippy --features extension-module -- -D warnings - name: build (python) run: cargo build --features extension-module + + python-e2e: + name: maturin build + Python E2E vs git + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: build wheel + run the E2E suite (the compiled module vs the real git CLI) + run: | + python -m venv .venv + .venv/bin/pip install -q maturin + .venv/bin/maturin develop --release + .venv/bin/python python/tests/test_e2e.py diff --git a/Cargo.toml b/Cargo.toml index 60aaf30..e1b8d10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gitxtend" -version = "0.0.1" +version = "0.1.0" edition = "2021" license = "Apache-2.0" description = "gitoxide-backed git repository tending, exposed to Python via PyO3" diff --git a/README.md b/README.md index 4199e43..ef39611 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,12 @@ detecting unpushed commits, untracked work, and out-of-sync branches across many repositories — backed by [gitoxide (`gix`)][gix] and exposed to Python through [PyO3]/[maturin]. -> **Status: scaffold / specification.** This repository currently contains the -> design, the API contract, and build stubs. See +> **Status: v0.1.0 — read side implemented.** All 13 read primitives plus the +> `repo_status` roll-up are implemented (Rust/gix) and exposed to Python, each +> with parity tests vs the `git` CLI and an end-to-end suite. Next: +> plugin adoption and the write side — see [`docs/ROADMAP.md`](docs/ROADMAP.md). > [`docs/DESIGN.md`](docs/DESIGN.md) and [`docs/PORTING.md`](docs/PORTING.md) -> for exactly what to implement. +> cover the architecture. ## Why this exists @@ -32,10 +34,10 @@ The motivating incident: a local-only **unpushed** commit on `main` was nearly lost during a merge+reset. Tending is the discipline that catches that; `gitxtend` makes tending fast enough to run constantly. -## What it will do (v1 scope) +## What it does (v0.1.0 — the read side) The first milestone ports the **read side** of tending — the part that *detects* -work that needs attention, without mutating any repo: +work that needs attention, without mutating any repo. All of it is implemented: | Capability | git-tend method(s) | gitxtend | |---|---|---| @@ -62,9 +64,10 @@ gitxtend/ ├── Cargo.toml # Rust crate (cdylib for PyO3; optional bin target) ├── pyproject.toml # maturin build backend → Python wheel ├── src/ -│ ├── lib.rs # PyO3 module entry — #[pymodule] gitxtend -│ ├── repo.rs # gix-backed read operations (TODO) -│ └── status.rs # RepoStatus roll-up + SyncState logic (TODO) +│ ├── lib.rs # crate root (error/repo/status modules; python feature) +│ ├── python.rs # PyO3 module entry — #[pymodule] gitxtend (feature-gated) +│ ├── repo/ # gix-backed read primitives, one file per method +│ └── status.rs # repo_status roll-up + SyncState decision tree ├── python/gitxtend/ │ └── __init__.pyi # type stubs for the compiled module └── docs/ diff --git a/pyproject.toml b/pyproject.toml index d44698c..e6a7927 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "gitxtend" -version = "0.0.1" +version = "0.1.0" description = "gitoxide-backed git repository tending, exposed to Python via PyO3" readme = "README.md" license = { text = "Apache-2.0" } diff --git a/python/tests/test_e2e.py b/python/tests/test_e2e.py new file mode 100644 index 0000000..ec022f1 --- /dev/null +++ b/python/tests/test_e2e.py @@ -0,0 +1,230 @@ +"""End-to-end tests for the compiled `gitxtend` wheel vs the real `git` CLI. + +The oracle is `git`: every assertion compares `gitxtend.(...)` to the +output of the equivalent `git` command on the *same* temporary repository. +Standard library only (no pytest). Run after `maturin develop`: + + python -m unittest python.tests.test_e2e # or + python python/tests/test_e2e.py +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +import unittest + +import gitxtend + +_ENV = { + **os.environ, + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + "GIT_AUTHOR_NAME": "qa", + "GIT_AUTHOR_EMAIL": "qa@example.com", + "GIT_COMMITTER_NAME": "qa", + "GIT_COMMITTER_EMAIL": "qa@example.com", +} + + +def git(repo: str, *args: str) -> str: + """Run `git -C repo `, assert success, return trimmed stdout.""" + out = subprocess.run( + ["git", "-C", repo, *args], env=_ENV, capture_output=True, text=True + ) + if out.returncode != 0: + raise AssertionError(f"git {args} failed: {out.stderr}") + return out.stdout.strip() + + +def norm_iso(s: str) -> str: + """git renders a UTC offset as `Z` (newer git) or `+00:00` (older); gitxtend + always emits `+00:00`. Normalize for comparison.""" + return s[:-1] + "+00:00" if s.endswith("Z") else s + + +class GitxtendE2E(unittest.TestCase): + def mkrepo(self) -> str: + """Fresh repo on `main` with one commit. Auto-cleaned.""" + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d, ignore_errors=True) + git(d, "init", "-q", "-b", "main") + with open(os.path.join(d, "README"), "w") as fh: + fh.write("init\n") + git(d, "add", "-A") + git(d, "commit", "-q", "-m", "init") + return d + + def bare(self) -> str: + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d, ignore_errors=True) + git(d, "init", "--bare", "-q", "-b", "main") + return d + + def commit(self, repo: str, name: str, msg: str) -> None: + with open(os.path.join(repo, name), "w") as fh: + fh.write(msg + "\n") + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", msg) + + def with_remote(self) -> tuple[str, str]: + """(repo, bare) with `origin/main` pushed and in sync.""" + r = self.mkrepo() + b = self.bare() + git(r, "remote", "add", "origin", b) + git(r, "push", "-q", "-u", "origin", "main") + return r, b + + def advance_remote(self, bare: str) -> None: + c = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, c, ignore_errors=True) + git(c, "clone", "-q", bare, ".") + self.commit(c, "r.txt", "remote") + git(c, "push", "-q", "origin", "main") + + # ---- read primitives ------------------------------------------------- + + def test_is_git_repo(self): + r = self.mkrepo() + self.assertTrue(gitxtend.is_git_repo(r)) + sub = os.path.join(r, "sub") + os.makedirs(sub) + self.assertTrue(gitxtend.is_git_repo(sub)) + nonrepo = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, nonrepo, ignore_errors=True) + self.assertFalse(gitxtend.is_git_repo(nonrepo)) + + def test_head_sha(self): + r = self.mkrepo() + self.assertEqual(gitxtend.head_sha(r), git(r, "rev-parse", "HEAD")) + self.commit(r, "a.txt", "two") + self.assertEqual(gitxtend.head_sha(r), git(r, "rev-parse", "HEAD")) + + def test_current_branch(self): + r = self.mkrepo() + self.assertEqual(gitxtend.current_branch(r), "main") + git(r, "checkout", "--detach", git(r, "rev-parse", "HEAD")) + self.assertIsNone(gitxtend.current_branch(r)) + + def test_tracking_branch(self): + r = self.mkrepo() + self.assertIsNone(gitxtend.tracking_branch(r)) + r2, _ = self.with_remote() + self.assertEqual(gitxtend.tracking_branch(r2), "origin/main") + + def test_remote_head_sha(self): + r = self.mkrepo() + self.assertIsNone(gitxtend.remote_head_sha(r, "origin/main")) + r2, _ = self.with_remote() + self.assertEqual( + gitxtend.remote_head_sha(r2, "origin/main"), + git(r2, "rev-parse", "origin/main"), + ) + + def test_ahead_behind(self): + r, b = self.with_remote() + self.assertEqual(gitxtend.ahead_behind(r, "origin/main"), (0, 0)) + self.commit(r, "x.txt", "local1") + self.commit(r, "y.txt", "local2") + self.assertEqual(gitxtend.ahead_behind(r, "origin/main"), (2, 0)) + self.advance_remote(b) + git(r, "fetch", "-q") + self.assertEqual(gitxtend.ahead_behind(r, "origin/main"), (2, 1)) + + def test_rev_list_count(self): + r = self.mkrepo() + self.commit(r, "a.txt", "two") + self.assertEqual( + gitxtend.rev_list_count(r, "HEAD"), + int(git(r, "rev-list", "--count", "HEAD")), + ) + self.assertEqual(gitxtend.rev_list_count(r, "nope..HEAD"), 0) + + def test_log_subjects(self): + r = self.mkrepo() + self.commit(r, "a.txt", "two") + self.commit(r, "b.txt", "three") + self.assertEqual(gitxtend.log_subjects(r, "HEAD", 2), ["three", "two"]) + self.assertEqual( + gitxtend.log_subjects(r, "HEAD", 10), + git(r, "log", "--format=%s", "--max-count=10", "HEAD").splitlines(), + ) + + def test_is_clean(self): + r = self.mkrepo() + self.assertTrue(gitxtend.is_clean(r)) + with open(os.path.join(r, "untracked.txt"), "w") as fh: + fh.write("x") + self.assertFalse(gitxtend.is_clean(r)) + + def test_status_counts(self): + r = self.mkrepo() + self.assertEqual(gitxtend.status_counts(r), (0, 0)) + with open(os.path.join(r, "u.txt"), "w") as fh: + fh.write("x") + self.assertEqual(gitxtend.status_counts(r), (0, 1)) + + def test_remote_urls(self): + r = self.mkrepo() + self.assertEqual(gitxtend.remote_urls(r), {}) + git(r, "remote", "add", "origin", "https://example.com/x.git") + self.assertEqual(gitxtend.remote_urls(r), {"origin": "https://example.com/x.git"}) + + def test_last_commit_date(self): + r = self.mkrepo() + self.assertEqual( + gitxtend.last_commit_date(r), norm_iso(git(r, "log", "-1", "--format=%aI")) + ) + + def test_fetch(self): + r, b = self.with_remote() + self.advance_remote(b) + self.assertTrue(gitxtend.fetch(r, None)) + self.assertEqual( + git(r, "rev-parse", "origin/main"), + gitxtend.remote_head_sha(r, "origin/main"), + ) + self.assertFalse(gitxtend.fetch(r, "does-not-exist")) + + # ---- roll-up --------------------------------------------------------- + + def test_repo_status_error(self): + self.assertEqual( + gitxtend.repo_status("/definitely/not/real/xyzzy", False).sync_state, "error" + ) + nonrepo = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, nonrepo, ignore_errors=True) + self.assertEqual(gitxtend.repo_status(nonrepo, False).sync_state, "error") + + def test_repo_status_no_remote(self): + s = gitxtend.repo_status(self.mkrepo(), False) + self.assertEqual(s.sync_state, "no-remote") + self.assertIsNone(s.tracking_branch) + + def test_repo_status_up_to_date(self): + r, _ = self.with_remote() + s = gitxtend.repo_status(r, False) + self.assertEqual(s.sync_state, "up-to-date") + self.assertEqual(s.tracking_branch, "origin/main") + self.assertEqual((s.ahead_count, s.behind_count), (0, 0)) + + def test_repo_status_ahead(self): + r, _ = self.with_remote() + self.commit(r, "x.txt", "local") + s = gitxtend.repo_status(r, False) + self.assertEqual(s.sync_state, "ahead") + self.assertEqual((s.ahead_count, s.behind_count), (1, 0)) + + def test_repo_status_diverged(self): + r, b = self.with_remote() + self.commit(r, "l.txt", "local") + self.advance_remote(b) + s = gitxtend.repo_status(r, True) # fetch=True + self.assertEqual(s.sync_state, "diverged") + self.assertEqual((s.ahead_count, s.behind_count), (1, 1)) + + +if __name__ == "__main__": + unittest.main()