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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ python = ["dep:pyo3"]
extension-module = ["python", "pyo3/extension-module"]

[dependencies]
# gitoxide read side. Pinned to a recent release on gnuc (rustc 1.93). Features
# gitoxide read side. Pinned to a recent release (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).
Expand Down
41 changes: 19 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,31 @@ 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. The Rust implementation is built
> on **gnuc** (the development machine), not on the bastion. See
> design, the API contract, and build stubs. See
> [`docs/DESIGN.md`](docs/DESIGN.md) and [`docs/PORTING.md`](docs/PORTING.md)
> for exactly what to implement.

## Why this exists

The [`gila git-tend`][gittend] plugin already does repository tending well, but
A Python repository-*tending* tool (`git-tend`) already does this well, but
every git operation forks the `git` CLI via `subprocess.run(["git", ...])`.
A `git-tend status` / `scan` across a workspace of N repos spawns dozens of
short-lived `git` processes per run, and the tool's behaviour is coupled to
whatever `git` binary and version happens to be on `PATH`.
A `status` / `scan` across a workspace of N repos spawns dozens of short-lived
`git` processes per run, and the tool's behaviour is coupled to whatever `git`
binary and version happens to be on `PATH`.

`gitxtend` replaces that seam with **in-process git** via gitoxide:

- **No fork-per-call.** A scan of a whole workspace runs in one process.
- **No `git` on `PATH` dependency.** The git logic is compiled in.
- **One artifact.** A single compiled module (`.so` wheel) — or, optionally, a
standalone CLI binary — carries the whole git layer.
- **Same contract.** It re-implements the exact method surface of git-tend's
`GitService` so the Python plugin can adopt it with a one-line import swap.
- **Same contract.** It re-implements the exact method surface of the Python
`GitService` git layer it replaces, so the tending tool can adopt it with a
one-line import swap.

The motivating incident: while merging `my_home#46`, 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.
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)

Expand All @@ -49,10 +49,10 @@ work that needs attention, without mutating any repo:
| Last commit date (ISO 8601) | `last_commit_date` | `last_commit_date(path)` |
| Modified / untracked counts | `status_counts` | `status_counts(path)` |
| Fetch from remote | `fetch` | `fetch(path, remote=None)` |
| **Roll-up** | `StatusService.check_repo` | `repo_status(path, fetch=True) -> RepoStatus` |
| **Roll-up** | `check_repo` | `repo_status(path, fetch=True) -> RepoStatus` |

The **write side** (`pull --ff-only`, `push`, `add`, `commit`, `stash`,
`branch`, `reset --hard`) stays in the Python plugin shelling out to `git` until
`branch`, `reset --hard`) stays in the host tool shelling out to `git` until
the read path is proven in production. See [`docs/ROADMAP.md`](docs/ROADMAP.md).

## Layout
Expand All @@ -74,24 +74,22 @@ gitxtend/
└── ROADMAP.md # milestones; read-side first, write-side later
```

## Building (on gnuc)
## Building

```bash
# from a checkout on gnuc, inside ~/venv
# from a checkout, inside your Python virtualenv
maturin develop --release # build + install into the active venv
# or, to produce a distributable wheel:
maturin build --release
```

Toolchain: Rust (pin to the gilabot CI toolchain — see
`.ci/tool-versions.toml` in gilabot), `maturin`, Python 3.11+.
Toolchain: a recent stable Rust, `maturin`, Python 3.11+.

## Integration target

Drop-in for `gila_plugin_git_tend.services.git_service.GitService`'s read
methods. The plugin keeps its CLI, config, forge (gh/glab), and board logic;
only the git layer changes. See [`docs/API.md`](docs/API.md) for the adapter
shape.
Drop-in for a Python `GitService` git layer's read methods. The host tool keeps
its CLI, config, forge (gh/glab), and board logic; only the git layer changes.
See [`docs/API.md`](docs/API.md) for the adapter shape.

## License

Expand All @@ -100,4 +98,3 @@ Apache License 2.0 — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE).
[gix]: https://github.com/Byron/gitoxide
[PyO3]: https://pyo3.rs
[maturin]: https://www.maturin.rs
[gittend]: https://github.com/hartsock/gilabot (gila-plugin-git-tend)
16 changes: 8 additions & 8 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# gitxtend — Python API Contract

This is the exact Python-visible surface the compiled module must expose. It
mirrors `gila_plugin_git_tend.services.git_service.GitService` (read side) plus
one roll-up that mirrors `StatusService.check_repo`.
mirrors the `git-tend` tool's `GitService` (read side) plus one roll-up that
mirrors its `check_repo`.

Type stubs live in [`../python/gitxtend/__init__.pyi`](../python/gitxtend/__init__.pyi).

Expand Down Expand Up @@ -68,7 +68,7 @@ def fetch(path, remote=None) -> bool
# Python caller must not care which.
```

## Roll-up (port of StatusService.check_repo)
## Roll-up (port of check_repo)

```python
class RepoStatus:
Expand All @@ -85,19 +85,19 @@ class RepoStatus:
error: str | None

def repo_status(path, fetch=True) -> RepoStatus
# Mirrors StatusService.check_repo exactly:
# Mirrors check_repo exactly:
# 1. not a repo -> state="error", error set
# 2. no upstream -> state="no-remote", is_dirty filled
# 3. fetch (if requested)
# 4. compute ahead/behind, fill new_remote_commits when behind>0
# 5. decide state via the tree below
```

### SyncState values (exact strings, from models.SyncState)
### SyncState values (exact strings)

`"up-to-date" | "ahead" | "behind" | "diverged" | "dirty" | "no-remote" | "error"`

### State decision tree (must match status_service.py)
### State decision tree

```
ahead>0 and behind>0 -> "diverged"
Expand All @@ -112,7 +112,7 @@ else -> "up-to-date"
git-tend can adopt this with a shim that keeps the old class name:

```python
# gila_plugin_git_tend/services/git_service.py (read side)
# services/git_service.py (read side)
import gitxtend

class GitService:
Expand All @@ -133,6 +133,6 @@ class GitService:
# write methods (pull/push/add/commit/stash/branch/reset) unchanged for now
```

Or, better, route `StatusService` straight at `gitxtend.repo_status()` and
Or, better, route the status roll-up straight at `gitxtend.repo_status()` and
delete the per-method round-trips. Both are acceptable; the per-method shim is
the lowest-risk first step.
12 changes: 6 additions & 6 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import swap in the Python plugin.

## Background: the seam we are replacing

In `gila-plugin-git-tend`, **all** git operations funnel through one class:
In the `git-tend` tool, **all** git operations funnel through one class:

```python
# gila_plugin_git_tend/services/git_service.py
# services/git_service.py
class GitService:
def run(self, path, args):
return subprocess.run(["git"] + args, cwd=path,
Expand All @@ -23,8 +23,8 @@ class GitService:
# last_commit_date, status_counts
```

Everything above `GitService` (`StatusService`, `ScanService`, `TendService`,
`PRService`, config, board, CLI) is untouched by this project. They consume
Everything above `GitService` (the higher-level services, config, board, CLI)
is untouched by this project. They consume
`GitService` purely through its method surface. That surface is our contract.

## Why gitoxide
Expand Down Expand Up @@ -66,7 +66,7 @@ src/lib.rs #[pymodule] — registers the Python-visible functions/classes,
src/repo.rs The gix-backed primitives, one per GitService read method.
Pure Rust, no PyO3 — unit-testable with gix fixtures.

src/status.rs repo_status(): the StatusService.check_repo roll-up, including
src/status.rs repo_status(): the check_repo roll-up, including
the SyncState decision tree, expressed in Rust over repo.rs.
```

Expand All @@ -86,6 +86,6 @@ and reused by an optional CLI `bin` target.
## Non-goals

- Reimplementing the CLI, YAML config, forge (gh/glab) integration, the
knowledge-board logic, or the systemd timer. Those stay in Python.
board logic, or the systemd timer. Those stay in Python.
- Replacing the write/merge/conflict machinery in v1.
- Being a general-purpose git library. Scope is exactly git-tend's needs.
6 changes: 3 additions & 3 deletions docs/PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Per-method mapping from git-tend's `GitService` (which shells out to `git`) to
gitoxide (`gix`) calls. This is the implementation checklist for `src/repo.rs`
and `src/status.rs`. Crate versions are not pinned here — pin `gix` to a recent
release in `Cargo.toml` and align the Rust toolchain with gilabot CI.
release in `Cargo.toml` and use a recent stable Rust toolchain.

Legend: **CLI** = what git-tend runs today · **gix** = intended approach.

Expand Down Expand Up @@ -90,7 +90,7 @@ Legend: **CLI** = what git-tend runs today · **gix** = intended approach.

## repo_status(path, fetch) -> RepoStatus (src/status.rs)

Port `StatusService.check_repo` verbatim:
Port `check_repo` verbatim:

```
status = RepoStatus(path)
Expand Down Expand Up @@ -123,5 +123,5 @@ tests assert on them.
- **Python smoke tests** post-`maturin develop`: import the module, run
`repo_status()` on a fixture, assert fields.

See gilabot's rule: every behaviour needs a regression test; mock/contain
Rule of thumb: every behaviour needs a regression test; mock/contain
external resources.
12 changes: 6 additions & 6 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- README, DESIGN, API contract, PORTING guide.
- Build stubs: `Cargo.toml`, `pyproject.toml`, `src/lib.rs` (signatures +
`todo!()`), `.pyi` type stubs.
- **Outcome:** gnuc can `git clone`, `maturin develop`, and get an importable
- **Outcome:** you can `git clone`, `maturin develop`, and get an importable
module whose functions raise `NotImplementedError`/`todo!()`.

## M1 — Read side (the unpushed-work detector)
Expand All @@ -17,16 +17,16 @@ Implement, with parity tests vs the `git` CLI, in this order:
5. `remote_urls`, `last_commit_date`.
6. `repo_status()` roll-up + full SyncState tree.
- **Acceptance:** every method agrees with `git` on the fixture matrix;
`repo_status()` reproduces `StatusService.check_repo` on diverged/ahead/
`repo_status()` reproduces `check_repo` on diverged/ahead/
behind/dirty/no-remote/error fixtures.
- **Note:** `fetch()` may ship as a contained shell-out if gix fetch is
unstable (see PORTING.md). Everything else is pure gix.

## M2 — Plugin adoption
- Add the `GitService` read-method shim (API.md) in gila-plugin-git-tend, or
point `StatusService` straight at `gitxtend.repo_status()`.
- Add the `GitService` read-method shim (API.md) in the git-tend tool, or
point the status roll-up straight at `gitxtend.repo_status()`.
- Gate behind a feature flag / env var so it can be rolled back instantly.
- Run `gila git-tend scan` / `status` across the real workspace; compare
- Run the git-tend `scan` / `status` across the real workspace; compare
output to the subprocess implementation byte-for-byte.

## M3 — Standalone CLI (optional)
Expand All @@ -41,4 +41,4 @@ Implement, with parity tests vs the `git` CLI, in this order:

## Out of scope (stays in Python, indefinitely)
- CLI/UX, YAML config, forge integration (gh/glab PR/MR auto-merge),
knowledge-board conflict resolution, systemd timer.
board conflict resolution, systemd timer.
2 changes: 1 addition & 1 deletion src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
use pyo3::prelude::*;
use std::collections::HashMap;

/// Roll-up mirroring `StatusService.check_repo` / `models.RepoStatus`.
/// Roll-up mirroring `check_repo` / `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.
Expand Down
2 changes: 1 addition & 1 deletion src/status.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Pure-Rust roll-up mirroring `StatusService.check_repo`. NO PyO3 here.
//! Pure-Rust roll-up mirroring `check_repo`. NO PyO3 here.
//!
//! Implement `repo_status(path, fetch) -> RepoStatusData` following the exact
//! sequence and SyncState decision tree in docs/PORTING.md / docs/API.md:
Expand Down
Loading