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: 10 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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"
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
|---|---|---|
Expand All @@ -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/
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
230 changes: 230 additions & 0 deletions python/tests/test_e2e.py
Original file line number Diff line number Diff line change
@@ -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.<method>(...)` 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 <args>`, 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()
Loading