From 2ded24650c4b34895b580165d32976b1a30402e3 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 31 May 2026 11:15:05 -0400 Subject: [PATCH] chore(foundation): M1 build/test scaffold + per-method module structure WHAT: - Pin deps: gix 0.70 (full read-side features; the 0.84 tip's `status` tree requires an unpublished gix-worktree-state and won't resolve), pyo3 0.28 (optional), tempfile 3. - Gate PyO3 behind an opt-in `python` feature so `cargo test`/`cargo build` run the pure-Rust core with no Python interpreter; `extension-module` (maturin) layers on top for the wheel. - Split src/lib.rs into error.rs (GitxtendError + Result), repo/ (one module per read method + a git-CLI temp-dir fixture helper for parity tests), status.rs (roll-up stub), python.rs (PyO3 wrappers, todo! until each method lands). - Add CI (.github/workflows/ci.yml) + a parity pre-push hook (.githooks/pre-push). WHY: M1 implements 13 read primitives + a roll-up, one per PR. The per-method module layout means each method PR adds a NEW file + a 2-line registration, so the sorties don't collide on a shared file. Pure-Rust-core-by-default keeps the method work unit-testable without libpython. See docs/ROADMAP.md M1. Co-Authored-By: Claude Opus 4.8 (1M context) --- .githooks/pre-push | 26 +++++++ .github/workflows/ci.yml | 41 +++++++++++ Cargo.toml | 44 ++++++++---- pyproject.toml | 5 +- src/error.rs | 73 +++++++++++++++++++ src/lib.rs | 152 ++++----------------------------------- src/python.rs | 137 +++++++++++++++++++++++++++++++++++ src/repo.rs | 29 -------- src/repo/mod.rs | 101 ++++++++++++++++++++++++++ src/status.rs | 22 +++--- 10 files changed, 441 insertions(+), 189 deletions(-) create mode 100755 .githooks/pre-push create mode 100644 .github/workflows/ci.yml create mode 100644 src/error.rs create mode 100644 src/python.rs delete mode 100644 src/repo.rs create mode 100644 src/repo/mod.rs diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..08b3ba4 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# gitxtend pre-push hook — the local equivalent of CI. +# +# PIPELINE PARITY: this hook mirrors .github/workflows/ci.yml. When editing the +# CI steps, update this hook to match (and vice-versa). Do not bypass with +# --no-verify; if a check fails, fix the issue. +# +# Install once per clone: git config core.hooksPath .githooks +set -euo pipefail + +echo "[pre-push] cargo fmt --all --check" +cargo fmt --all --check + +echo "[pre-push] cargo clippy (core) -D warnings" +cargo clippy --no-default-features --all-targets -- -D warnings + +echo "[pre-push] cargo test (core)" +cargo test --no-default-features + +echo "[pre-push] cargo clippy (python) -D warnings" +cargo clippy --features extension-module -- -D warnings + +echo "[pre-push] cargo build (python)" +cargo build --features extension-module + +echo "[pre-push] OK" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7802e9d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +# gitxtend CI — gates every PR to main. +# +# HOOK PARITY: this pipeline is mirrored by .githooks/pre-push. When editing the +# steps here, update the hook to match (and vice-versa). +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: fmt + clippy + test (core) + build (python) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + + - name: rustfmt + run: cargo fmt --all --check + + # The pure-Rust core (no PyO3 / no libpython): the M1 read methods live here. + - name: clippy (core) + run: cargo clippy --no-default-features --all-targets -- -D warnings + - name: test (core) + run: cargo test --no-default-features + + # The PyO3 wheel surface. `extension-module` links no libpython, so it + # builds on CI without python-dev. + - name: clippy (python) + run: cargo clippy --features extension-module -- -D warnings + - name: build (python) + run: cargo build --features extension-module diff --git a/Cargo.toml b/Cargo.toml index 40291d6..0a4a2e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,27 +7,47 @@ description = "gitoxide-backed git repository tending, exposed to Python via PyO repository = "https://github.com/hartsock/gitxtend" authors = ["Shawn Hartsock"] -# Primary artifact: a PyO3 extension module (Python import target). -# The optional standalone CLI (M3) is added later as a [[bin]] target reusing -# the same repo.rs/status.rs core. +# Primary artifact: a PyO3 extension module (Python import target). The optional +# standalone CLI (M3) is added later as a [[bin]] target reusing repo/status. [lib] name = "gitxtend" crate-type = ["cdylib", "rlib"] +[features] +# Pure-Rust core builds by DEFAULT → `cargo test` / `cargo build` need no Python +# interpreter and link no libpython. The per-method M1 work is validated this way. +default = [] +# PyO3 Python bindings (the `import gitxtend` surface). Opt-in. +python = ["dep:pyo3"] +# Turned on by maturin for the wheel build; layered on top of `python` so that a +# plain `cargo test` (neither feature) never pulls pyo3/extension-module. +extension-module = ["python", "pyo3/extension-module"] + [dependencies] -# Pin to a recent gix release on gnuc; align the Rust toolchain with gilabot -# CI (.ci/tool-versions.toml). Enable only the features the read side needs -# (status, revision walk, refs, config, and — for fetch — the network/transport -# features). Keep the feature set minimal. -gix = { version = "*", default-features = false, features = [ +# gitoxide read side. Pinned to a recent release on gnuc (rustc 1.93). Features +# are added per method: status (is_clean/status_counts), revision (ahead_behind/ +# rev_list_count/log_subjects), dirwalk (untracked), blocking-network-client +# (fetch — M1's one network call). +# NOTE: gix's default features are kept ON. Stripping them +# (`default-features = false`) cuts feature propagation into gix sub-crates +# (e.g. gix-hash's `Kind` match goes non-exhaustive) and fails to build on +# rustc 1.93. We add the read-side features on top of the defaults. +# +# Pinned to 0.70 (not the 0.84 tip): gix 0.84's `status` feature requires a +# `gix-worktree-state` version not published to crates.io — its sub-crate tree +# is internally inconsistent. 0.70 resolves cleanly with the full read-side set. +gix = { version = "0.70", features = [ "status", "revision", "dirwalk", + "blocking-network-client", ] } -# PyO3 — extension-module so the build links against the right Python. -pyo3 = { version = "*", features = ["extension-module"] } +# PyO3 — only compiled when the `python` feature is on. `extension-module` is +# layered via the crate feature of the same name, so `cargo test` links no +# libpython and the pure-Rust core stays interpreter-free. +pyo3 = { version = "0.28", optional = true, default-features = false, features = ["macros"] } [dev-dependencies] -# For parity tests vs the git CLI and temp-dir fixtures. -tempfile = "*" +# Temp-dir fixtures for parity tests vs the git CLI. +tempfile = "3" diff --git a/pyproject.toml b/pyproject.toml index 9ee506e..d44698c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,9 @@ Repository = "https://github.com/hartsock/gitxtend" [tool.maturin] # Compiled module imported as `import gitxtend`. Type stubs in python/gitxtend. -features = ["pyo3/extension-module"] +# `extension-module` is a crate feature that layers on `python` (which pulls +# pyo3); see Cargo.toml [features]. A plain `cargo test` enables neither, so the +# pure-Rust core stays interpreter-free. +features = ["extension-module"] python-source = "python" module-name = "gitxtend._gitxtend" diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..2354677 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,73 @@ +//! Crate error type. The PyO3 conversion is gated behind the `python` feature so +//! the pure-Rust core has no PyO3 dependency. + +use std::fmt; + +/// Error raised by gitxtend read primitives that do not soft-fail. +/// +/// On the Python side this surfaces as `GitxtendError` (a `RuntimeError` +/// subclass); see `docs/API.md`. Soft-fail methods return sentinels +/// (`None`/`0`/`[]`/`{}`) instead of producing this. +#[derive(Debug, Clone)] +pub struct GitxtendError { + message: String, +} + +impl GitxtendError { + /// Build an error from a message. + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + /// Build an error from anything `Display` (e.g. a gix error). + /// + /// This is a named constructor rather than a blanket `From` impl, which + /// would conflict with `From`. Use it as + /// `.map_err(GitxtendError::from_err)`. + pub fn from_err(e: E) -> Self { + Self::new(e.to_string()) + } + + /// The human-readable message. + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for GitxtendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for GitxtendError {} + +/// Crate result alias. +pub type Result = std::result::Result; + +#[cfg(feature = "python")] +impl From for pyo3::PyErr { + fn from(e: GitxtendError) -> Self { + pyo3::exceptions::PyRuntimeError::new_err(e.message) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_is_message() { + let e = GitxtendError::new("boom"); + assert_eq!(e.to_string(), "boom"); + assert_eq!(e.message(), "boom"); + } + + #[test] + fn from_err_uses_display() { + let io = std::io::Error::new(std::io::ErrorKind::NotFound, "nope"); + assert_eq!(GitxtendError::from_err(io).message(), "nope"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9920a60..eec2dd4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,143 +1,21 @@ //! gitxtend — gitoxide-backed git repository tending, exposed to Python. //! -//! This file is a SCAFFOLD. It declares the Python-visible surface defined in -//! `docs/API.md` so the module compiles and imports on gnuc; every function -//! body is `todo!()`. Implement them per `docs/PORTING.md`, keeping the pure -//! gix logic in `repo.rs` / `status.rs` (PyO3-free, unit-testable) and using -//! this file only for Python type/error conversion. +//! Crate layout: +//! - [`error`] — [`GitxtendError`] + the crate [`Result`] alias. +//! - [`repo`] — pure-Rust, gix-backed read primitives (one module per method). +//! - [`status`] — the `repo_status` roll-up + SyncState logic. +//! - `python` — the PyO3 surface, compiled ONLY with the `python` feature. //! -//! Soft-fail methods (see API.md) must return sentinels (None/0/[]/{}) instead -//! of raising, to stay drop-in compatible with git-tend's GitService. +//! The pure-Rust core (`repo`, `status`) carries NO PyO3, so it unit-tests with +//! gix fixtures and `cargo test` without a Python interpreter. Build the Python +//! wheel with `maturin` (which enables `python` + `extension-module`). See +//! `docs/DESIGN.md` / `docs/PORTING.md`. -use pyo3::prelude::*; +pub mod error; +pub mod repo; +pub mod status; -// Pure-Rust cores (to be implemented). Keep PyO3 out of these so they can be -// unit-tested with gix fixtures and reused by an optional CLI bin target. -// mod repo; -// mod status; +pub use error::{GitxtendError, Result}; -/// Roll-up mirroring `StatusService.check_repo` / `models.RepoStatus`. -#[pyclass] -#[derive(Clone, Default)] -pub struct RepoStatus { - #[pyo3(get)] - pub path: String, - #[pyo3(get)] - pub state: String, // SyncState value, see docs/API.md - #[pyo3(get)] - pub local_branch: Option, - #[pyo3(get)] - pub tracking_branch: Option, - #[pyo3(get)] - pub local_sha: Option, - #[pyo3(get)] - pub remote_sha: Option, - #[pyo3(get)] - pub ahead_count: usize, - #[pyo3(get)] - pub behind_count: usize, - #[pyo3(get)] - pub new_remote_commits: Vec, - #[pyo3(get)] - pub is_dirty: bool, - #[pyo3(get)] - pub error: Option, -} - -// ---- Read primitives (port of GitService read side) --------------------- - -#[pyfunction] -fn is_git_repo(_path: String) -> PyResult { - todo!("PORTING.md → is_git_repo (gix::discover)") -} - -#[pyfunction] -fn is_clean(_path: String) -> PyResult { - todo!("PORTING.md → is_clean (gix status, empty)") -} - -#[pyfunction] -fn current_branch(_path: String) -> PyResult> { - todo!("PORTING.md → current_branch (None if detached)") -} - -#[pyfunction] -fn tracking_branch(_path: String) -> PyResult> { - todo!("PORTING.md → tracking_branch (configured upstream)") -} - -#[pyfunction] -fn head_sha(_path: String) -> PyResult> { - todo!("PORTING.md → head_sha (repo.head_id)") -} - -#[pyfunction] -#[pyo3(signature = (path, remote_ref="origin/main".to_string()))] -fn remote_head_sha(_path: String, _remote_ref: String) -> PyResult> { - todo!("PORTING.md → remote_head_sha (resolve remote-tracking ref)") -} - -#[pyfunction] -fn ahead_behind(_path: String, _upstream: String) -> PyResult<(usize, usize)> { - todo!("PORTING.md → ahead_behind (single graph walk)") -} - -#[pyfunction] -fn rev_list_count(_path: String, _range_spec: String) -> PyResult { - todo!("PORTING.md → rev_list_count (soft-fail 0)") -} - -#[pyfunction] -#[pyo3(signature = (path, range_spec, max_count=10))] -fn log_subjects(_path: String, _range_spec: String, _max_count: usize) -> PyResult> { - todo!("PORTING.md → log_subjects (summaries, newest first)") -} - -#[pyfunction] -fn remote_urls(_path: String) -> PyResult> { - todo!("PORTING.md → remote_urls (name -> fetch url)") -} - -#[pyfunction] -fn last_commit_date(_path: String) -> PyResult> { - todo!("PORTING.md → last_commit_date (ISO 8601 %aI)") -} - -#[pyfunction] -fn status_counts(_path: String) -> PyResult<(usize, usize)> { - todo!("PORTING.md → status_counts (modified, untracked)") -} - -#[pyfunction] -#[pyo3(signature = (path, remote=None))] -fn fetch(_path: String, _remote: Option) -> PyResult { - todo!("PORTING.md → fetch (gix fetch, contained shell-out fallback)") -} - -// ---- Roll-up (port of StatusService.check_repo) ------------------------- - -#[pyfunction] -#[pyo3(signature = (path, fetch=true))] -fn repo_status(_path: String, _fetch: bool) -> PyResult { - todo!("PORTING.md → repo_status (full SyncState decision tree)") -} - -#[pymodule] -fn _gitxtend(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_class::()?; - m.add_function(wrap_pyfunction!(is_git_repo, m)?)?; - m.add_function(wrap_pyfunction!(is_clean, m)?)?; - m.add_function(wrap_pyfunction!(current_branch, m)?)?; - m.add_function(wrap_pyfunction!(tracking_branch, m)?)?; - m.add_function(wrap_pyfunction!(head_sha, m)?)?; - m.add_function(wrap_pyfunction!(remote_head_sha, m)?)?; - m.add_function(wrap_pyfunction!(ahead_behind, m)?)?; - m.add_function(wrap_pyfunction!(rev_list_count, m)?)?; - m.add_function(wrap_pyfunction!(log_subjects, m)?)?; - m.add_function(wrap_pyfunction!(remote_urls, m)?)?; - m.add_function(wrap_pyfunction!(last_commit_date, m)?)?; - m.add_function(wrap_pyfunction!(status_counts, m)?)?; - m.add_function(wrap_pyfunction!(fetch, m)?)?; - m.add_function(wrap_pyfunction!(repo_status, m)?)?; - Ok(()) -} +#[cfg(feature = "python")] +mod python; diff --git a/src/python.rs b/src/python.rs new file mode 100644 index 0000000..9065088 --- /dev/null +++ b/src/python.rs @@ -0,0 +1,137 @@ +//! PyO3 surface — compiled ONLY with the `python` feature. Thin wrappers that +//! call into the pure-Rust `repo` / `status` cores and convert types/errors for +//! Python. Each M1 method task replaces that method's `todo!()` wrapper with a +//! call into `crate::repo::` (soft-fail methods map errors to the +//! sentinel; the rest propagate `GitxtendError` → `PyRuntimeError`). +//! +//! Soft-fail methods (see `docs/API.md`) must return sentinels (None/0/[]/{}) +//! instead of raising, to stay drop-in compatible with git-tend's GitService. + +use pyo3::prelude::*; +use std::collections::HashMap; + +/// Roll-up mirroring `StatusService.check_repo` / `models.RepoStatus`. +/// +/// `skip_from_py_object`: this type is returned to Python, never parsed from it, +/// so we opt out of the (now-opt-in) `FromPyObject` derive. +#[pyclass(skip_from_py_object)] +#[derive(Clone, Default)] +pub struct RepoStatus { + #[pyo3(get)] + pub path: String, + #[pyo3(get)] + pub state: String, // SyncState value, see docs/API.md + #[pyo3(get)] + pub local_branch: Option, + #[pyo3(get)] + pub tracking_branch: Option, + #[pyo3(get)] + pub local_sha: Option, + #[pyo3(get)] + pub remote_sha: Option, + #[pyo3(get)] + pub ahead_count: usize, + #[pyo3(get)] + pub behind_count: usize, + #[pyo3(get)] + pub new_remote_commits: Vec, + #[pyo3(get)] + pub is_dirty: bool, + #[pyo3(get)] + pub error: Option, +} + +// ---- Read primitives — Python wrappers (todo! until each method lands) ---- +// +// As each `crate::repo::` lands, replace the matching `todo!()` body +// with a call into it. The pure-Rust core is what carries the gix logic + tests. + +#[pyfunction] +fn is_git_repo(_path: String) -> PyResult { + todo!("repo::is_git_repo (gix::discover)") +} + +#[pyfunction] +fn is_clean(_path: String) -> PyResult { + todo!("repo::is_clean (gix status, empty)") +} + +#[pyfunction] +fn current_branch(_path: String) -> PyResult> { + todo!("repo::current_branch (None if detached)") +} + +#[pyfunction] +fn tracking_branch(_path: String) -> PyResult> { + todo!("repo::tracking_branch (configured upstream)") +} + +#[pyfunction] +fn head_sha(_path: String) -> PyResult> { + todo!("repo::head_sha (repo.head_id)") +} + +#[pyfunction] +fn remote_head_sha(_path: String, _remote_ref: String) -> PyResult> { + todo!("repo::remote_head_sha (resolve remote-tracking ref)") +} + +#[pyfunction] +fn ahead_behind(_path: String, _upstream: String) -> PyResult<(usize, usize)> { + todo!("repo::ahead_behind (single graph walk)") +} + +#[pyfunction] +fn rev_list_count(_path: String, _range_spec: String) -> PyResult { + todo!("repo::rev_list_count (soft-fail 0)") +} + +#[pyfunction] +fn log_subjects(_path: String, _range_spec: String, _max_count: usize) -> PyResult> { + todo!("repo::log_subjects (summaries, newest first)") +} + +#[pyfunction] +fn remote_urls(_path: String) -> PyResult> { + todo!("repo::remote_urls (name -> fetch url)") +} + +#[pyfunction] +fn last_commit_date(_path: String) -> PyResult> { + todo!("repo::last_commit_date (ISO 8601 %aI)") +} + +#[pyfunction] +fn status_counts(_path: String) -> PyResult<(usize, usize)> { + todo!("repo::status_counts (modified, untracked)") +} + +#[pyfunction] +fn fetch(_path: String, _remote: Option) -> PyResult { + todo!("repo::fetch (gix fetch, contained shell-out fallback)") +} + +#[pyfunction] +fn repo_status(_path: String, _fetch: bool) -> PyResult { + todo!("status::repo_status (full SyncState decision tree)") +} + +#[pymodule] +fn _gitxtend(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_function(wrap_pyfunction!(is_git_repo, m)?)?; + m.add_function(wrap_pyfunction!(is_clean, m)?)?; + m.add_function(wrap_pyfunction!(current_branch, m)?)?; + m.add_function(wrap_pyfunction!(tracking_branch, m)?)?; + m.add_function(wrap_pyfunction!(head_sha, m)?)?; + m.add_function(wrap_pyfunction!(remote_head_sha, m)?)?; + m.add_function(wrap_pyfunction!(ahead_behind, m)?)?; + m.add_function(wrap_pyfunction!(rev_list_count, m)?)?; + m.add_function(wrap_pyfunction!(log_subjects, m)?)?; + m.add_function(wrap_pyfunction!(remote_urls, m)?)?; + m.add_function(wrap_pyfunction!(last_commit_date, m)?)?; + m.add_function(wrap_pyfunction!(status_counts, m)?)?; + m.add_function(wrap_pyfunction!(fetch, m)?)?; + m.add_function(wrap_pyfunction!(repo_status, m)?)?; + Ok(()) +} diff --git a/src/repo.rs b/src/repo.rs deleted file mode 100644 index b60ee60..0000000 --- a/src/repo.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Pure-Rust, gix-backed read primitives. NO PyO3 here — keep this module -//! testable with gix fixtures and reusable by an optional CLI bin target. -//! -//! Implement each function per docs/PORTING.md. The PyO3 layer in lib.rs is a -//! thin wrapper that calls into here and converts errors/types for Python. -//! -//! Suggested signatures (adjust to your gix version): -//! -//! pub fn is_git_repo(path: &Path) -> bool -//! pub fn is_clean(path: &Path) -> Result -//! pub fn current_branch(path: &Path) -> Result> -//! pub fn tracking_branch(path: &Path) -> Result> -//! pub fn head_sha(path: &Path) -> Result> -//! pub fn remote_head_sha(path: &Path, remote_ref: &str) -> Result> -//! pub fn ahead_behind(path: &Path, upstream: &str) -> Result<(usize, usize)> -//! pub fn rev_list_count(path: &Path, range_spec: &str) -> usize // soft-fail 0 -//! pub fn log_subjects(path: &Path, range_spec: &str, max: usize) -> Vec -//! pub fn remote_urls(path: &Path) -> HashMap -//! pub fn last_commit_date(path: &Path) -> Result> -//! pub fn status_counts(path: &Path) -> (usize, usize) // (modified, untracked) -//! pub fn fetch(path: &Path, remote: Option<&str>) -> Result - -// TODO(M1): implement per docs/PORTING.md, with parity tests vs the git CLI. - -#[cfg(test)] -mod tests { - // TODO(M1): temp-dir gix fixtures; assert parity with `git` for every - // method and every SyncState in the decision tree (see docs/PORTING.md). -} diff --git a/src/repo/mod.rs b/src/repo/mod.rs new file mode 100644 index 0000000..a8b0680 --- /dev/null +++ b/src/repo/mod.rs @@ -0,0 +1,101 @@ +//! Pure-Rust, gix-backed read primitives. NO PyO3 here — keep this module +//! testable with gix fixtures and reusable by an optional CLI bin target. +//! +//! ONE FILE PER METHOD. Each M1 task adds `src/repo/.rs` (the gix +//! implementation + its parity tests) and registers it with a two-line block +//! here: +//! +//! ```ignore +//! mod is_git_repo; +//! pub use is_git_repo::is_git_repo; +//! ``` +//! +//! so per-task PRs never collide on a shared function body. The matching PyO3 +//! wrapper for the method is added separately in `src/python.rs`. Implement each +//! function per `docs/PORTING.md`, with parity tests vs the real `git` CLI. + +#[allow(unused_imports)] +pub use crate::error::{GitxtendError, Result}; + +// ---- method registrations (one block per implemented method) ------------- +// (methods land here as M1 progresses — see docs/ROADMAP.md M1 ordering) + +/// Temp-dir git fixtures shared by the per-method parity tests. +/// +/// Fixtures are built with the real `git` CLI, so each parity test asserts +/// "gix agrees with git on a repo git itself created"; the method under test +/// uses gix. See `docs/PORTING.md` → Testing strategy. +#[cfg(test)] +pub(crate) mod fixtures { + use std::path::Path; + use std::process::Command; + use tempfile::TempDir; + + /// Run a `git` subcommand in `dir`, assert success, return trimmed stdout. + /// + /// Global/system git config is neutralized and a fixed identity is set so + /// fixtures are deterministic regardless of the host's `~/.gitconfig`. + pub fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_AUTHOR_NAME", "fix") + .env("GIT_AUTHOR_EMAIL", "fix@example.com") + .env("GIT_COMMITTER_NAME", "fix") + .env("GIT_COMMITTER_EMAIL", "fix@example.com") + .output() + .expect("spawn git"); + assert!( + out.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim_end().to_string() + } + + /// A fresh repo on branch `main` with a single empty commit. Keep the + /// returned `TempDir` alive for the duration of the test. + pub fn repo() -> TempDir { + let td = tempfile::tempdir().expect("tempdir"); + let p = td.path(); + git(p, &["init", "-q", "-b", "main"]); + git(p, &["commit", "-q", "--allow-empty", "-m", "init"]); + td + } + + /// Write `contents` to `name` under `dir` (parent dirs created). + pub fn write(dir: &Path, name: &str, contents: &str) { + let path = dir.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("mkdir"); + } + std::fs::write(path, contents).expect("write"); + } +} + +#[cfg(test)] +mod tests { + use super::fixtures; + + #[test] + fn fixture_repo_has_one_commit() { + let td = fixtures::repo(); + assert_eq!( + fixtures::git(td.path(), &["rev-list", "--count", "HEAD"]), + "1" + ); + } + + #[test] + fn fixture_write_creates_file() { + let td = fixtures::repo(); + fixtures::write(td.path(), "a/b.txt", "hi"); + assert_eq!( + std::fs::read_to_string(td.path().join("a/b.txt")).unwrap(), + "hi" + ); + } +} diff --git a/src/status.rs b/src/status.rs index 356e790..88b3d0c 100644 --- a/src/status.rs +++ b/src/status.rs @@ -3,16 +3,18 @@ //! Implement `repo_status(path, fetch) -> RepoStatusData` following the exact //! sequence and SyncState decision tree in docs/PORTING.md / docs/API.md: //! -//! 1. not a repo -> state="error", error set -//! 2. no upstream -> state="no-remote", is_dirty filled -//! 3. fetch (if requested) -//! 4. local/remote sha, ahead/behind, new_remote_commits when behind>0 -//! 5. is_dirty; then: -//! ahead>0 && behind>0 -> "diverged" -//! ahead>0 -> "ahead" -//! behind>0 -> "behind" -//! is_dirty -> "dirty" -//! else -> "up-to-date" +//! ```text +//! 1. not a repo -> state="error", error set +//! 2. no upstream -> state="no-remote", is_dirty filled +//! 3. fetch (if requested) +//! 4. local/remote sha, ahead/behind, new_remote_commits when behind>0 +//! 5. is_dirty; then: +//! ahead>0 && behind>0 -> "diverged" +//! ahead>0 -> "ahead" +//! behind>0 -> "behind" +//! is_dirty -> "dirty" +//! else -> "up-to-date" +//! ``` //! //! Return a plain Rust struct; lib.rs converts it to the #[pyclass] RepoStatus.