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
26 changes: 26 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -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"
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 32 additions & 12 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
73 changes: 73 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> 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<Self>`. Use it as
/// `.map_err(GitxtendError::from_err)`.
pub fn from_err<E: fmt::Display>(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<T> = std::result::Result<T, GitxtendError>;

#[cfg(feature = "python")]
impl From<GitxtendError> 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");
}
}
152 changes: 15 additions & 137 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[pyo3(get)]
pub tracking_branch: Option<String>,
#[pyo3(get)]
pub local_sha: Option<String>,
#[pyo3(get)]
pub remote_sha: Option<String>,
#[pyo3(get)]
pub ahead_count: usize,
#[pyo3(get)]
pub behind_count: usize,
#[pyo3(get)]
pub new_remote_commits: Vec<String>,
#[pyo3(get)]
pub is_dirty: bool,
#[pyo3(get)]
pub error: Option<String>,
}

// ---- Read primitives (port of GitService read side) ---------------------

#[pyfunction]
fn is_git_repo(_path: String) -> PyResult<bool> {
todo!("PORTING.md → is_git_repo (gix::discover)")
}

#[pyfunction]
fn is_clean(_path: String) -> PyResult<bool> {
todo!("PORTING.md → is_clean (gix status, empty)")
}

#[pyfunction]
fn current_branch(_path: String) -> PyResult<Option<String>> {
todo!("PORTING.md → current_branch (None if detached)")
}

#[pyfunction]
fn tracking_branch(_path: String) -> PyResult<Option<String>> {
todo!("PORTING.md → tracking_branch (configured upstream)")
}

#[pyfunction]
fn head_sha(_path: String) -> PyResult<Option<String>> {
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<Option<String>> {
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<usize> {
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<Vec<String>> {
todo!("PORTING.md → log_subjects (summaries, newest first)")
}

#[pyfunction]
fn remote_urls(_path: String) -> PyResult<std::collections::HashMap<String, String>> {
todo!("PORTING.md → remote_urls (name -> fetch url)")
}

#[pyfunction]
fn last_commit_date(_path: String) -> PyResult<Option<String>> {
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<String>) -> PyResult<bool> {
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<RepoStatus> {
todo!("PORTING.md → repo_status (full SyncState decision tree)")
}

#[pymodule]
fn _gitxtend(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<RepoStatus>()?;
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;
Loading
Loading