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
12 changes: 10 additions & 2 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,22 @@ 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.
#
# The standalone CLI is built first because the front-end parity test runs the
# binary against the console script; without it that test skips.
if [ -x .venv/bin/maturin ] || [ -x .venv/Scripts/maturin.exe ]; then
echo "[pre-push] cargo build --release --bin gitxtend"
cargo build --release --bin gitxtend
fi

if [ -x .venv/bin/maturin ]; then
echo "[pre-push] maturin develop + pytest coverage"
.venv/bin/maturin develop --release --extras dev >/dev/null
.venv/bin/python -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80
CI=1 .venv/bin/python -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80
elif [ -x .venv/Scripts/maturin.exe ]; then
echo "[pre-push] maturin develop + pytest coverage"
.venv/Scripts/maturin.exe develop --release --extras dev >/dev/null
.venv/Scripts/python.exe -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80
CI=1 .venv/Scripts/python.exe -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80
else
echo "[pre-push] (skipping Python E2E — no .venv/bin/maturin; gated by CI python-e2e)"
fi
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,16 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# The standalone CLI, needed by the front-end parity test (it runs the
# binary and the console script over the same argv and compares). Without
# it that test would SKIP locally — and `CI` being set turns the skip into
# a failure, so this step is load-bearing, not a convenience.
- name: build the standalone CLI
run: cargo build --release --bin gitxtend

- name: build wheel + run the E2E suite (the compiled module vs the real git CLI)
env:
CI: "1"
run: |
python -m venv .venv
.venv/bin/pip install -q maturin
Expand Down
11 changes: 9 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ 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 repo/status.
# Primary artifact: a PyO3 extension module (Python import target).
[lib]
name = "gitxtend"
crate-type = ["cdylib", "rlib"]

# The standalone CLI (roadmap M3): `gitxtend submodule sync|status`. It links the
# rlib above and uses no PyO3, so it builds under every feature combination —
# including `extension-module`, where the linker simply drops the unreferenced
# pyo3 objects. Verified by the `build (python)` CI step, which builds all targets.
[[bin]]
name = "gitxtend"
path = "src/main.rs"

[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.
Expand Down
111 changes: 104 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ 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: 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).
> **Status: v0.1.0 — read side implemented, plus the submodule command.** All 13
> read primitives and 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. On top of that: `gitxtend submodule sync`, the one-command
> [submodule updater](#keeping-a-repo-full-of-submodules-up-to-date), available
> as a standalone binary and as a Python console script. Next: plugin adoption
> and the rest of the write side — see [`docs/ROADMAP.md`](docs/ROADMAP.md).
> [`docs/DESIGN.md`](docs/DESIGN.md) and [`docs/PORTING.md`](docs/PORTING.md)
> cover the architecture.

Expand Down Expand Up @@ -52,23 +55,117 @@ work that needs attention, without mutating any repo. All of it is implemented:
| Modified / untracked counts | `status_counts` | `status_counts(path)` |
| Fetch from remote | `fetch` | `fetch(path, remote=None)` |
| **Roll-up** | `check_repo` | `repo_status(path, fetch=True) -> RepoStatus` |
| Submodule status | — | `submodule_status(path, recursive=True)` |
| Submodule update (+record) | — | `update_submodules(path, ..., commit=False)` · CLI: `gitxtend submodule sync` |

The **write side** (`pull --ff-only`, `push`, `add`, `commit`, `stash`,
`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).

**One deliberate exception:** the submodule commands below *do* mutate — they
check out submodules and, with `--commit`, write a commit in the superproject.
They are also the one place this crate does not use gix: submodule update and
status are delegated to the local `git` CLI on purpose, so the semantics are
Git's own rather than a reimplementation of them. Submodule updating is the
use case that motivated the CLI, and it is self-contained enough not to wait on
the general write-side port.

## Keeping a repo full of submodules up to date

One command moves every submodule to the tip of the branch it tracks:

```bash
gitxtend submodule sync # the current directory
gitxtend submodule sync ~/src/myrepo # or a named one
```

```
modA 9bfc36b -> 3bb8896
modB 2211b7a -> 4ff51bd (devel)

2 submodules advanced (not recorded — re-run with --commit)
```

`--commit` records the moves in the superproject, which is what makes them
stick:

```bash
gitxtend submodule sync --commit
gitxtend submodule sync --commit -m "bump vendored deps"
```

```
2 submodules advanced, recorded as f14b311
```

Also available: `gitxtend submodule status` for a structured view, `--json` on
either subcommand, `--no-remote` to *restore* submodules to the recorded SHAs
instead of advancing them, and `--no-recursive`. `gitxtend --help` has the rest.
Exit codes are `0` ok, `1` a git operation failed, `2` bad usage.

### Why `--commit` matters

`git submodule update --remote` moves each submodule's working tree and stops
there — leaving a detached HEAD in each submodule and a **modified gitlink** in
the superproject. By itself it makes the superproject *dirty*, not *up to date*:
the next plain `git submodule update` snaps everything back to the SHA the
superproject still records. Recording the bumps is a separate commit, and
`--commit` is it.

Two caveats worth knowing:

- With `--recursive`, a nested submodule shows up as `outer/inner`. The
superproject cannot stage that path, so `--commit` records **top-level**
gitlinks only; a nested bump needs a commit inside `outer` first.
- A submodule with no `branch =` line in `.gitmodules` follows the remote's
default branch. Those print without a branch annotation, rather than being
guessed at.

### Two front ends, one program

The command ships twice: as the standalone `gitxtend` binary (no Python needed —
good for cron) and as a console script in the wheel. Both are thin shims over
the same `cli::run` in the library, so they cannot disagree about a flag, an
output line, or an exit code — a parity test runs both over the same argv and
compares. `python -m gitxtend` works too.

```bash
cargo build --release --bin gitxtend # the standalone binary
pip install gitxtend # puts the console script on PATH
```

If both are installed, whichever comes first on `PATH` wins; they behave
identically.

### From Python

```python
import gitxtend

report = gitxtend.update_submodules("~/src/myrepo", commit=True)
for change in report.changed:
print(change.path, change.from_commit, "->", change.to_commit, change.branch)
print(report.commit) # superproject commit that recorded the bumps, or None
```

Idempotent: a repeat run reports nothing changed and makes no empty commit.

## Layout

```
gitxtend/
├── Cargo.toml # Rust crate (cdylib for PyO3; optional bin target)
├── pyproject.toml # maturin build backend → Python wheel
├── Cargo.toml # Rust crate (cdylib for PyO3 + the `gitxtend` bin)
├── pyproject.toml # maturin build backend → Python wheel + console script
├── src/
│ ├── lib.rs # crate root (error/repo/status modules; python feature)
│ ├── lib.rs # crate root (cli/error/repo/status; python feature)
│ ├── cli.rs # the `gitxtend` command: argv → (code, stdout, stderr)
│ ├── main.rs # the standalone binary — a shim over cli::run
│ ├── 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/
│ ├── _cli.py # console script — the other shim over cli::run
│ ├── __main__.py # `python -m gitxtend`
│ └── __init__.pyi # type stubs for the compiled module
└── docs/
├── DESIGN.md # architecture & rationale
Expand Down
48 changes: 48 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,56 @@ def last_commit_date(path) -> str | None # soft-fail

def status_counts(path) -> tuple[int, int] # soft-fail ((0,0) on error)
# GitService.status_counts — (modified, untracked) from porcelain status

def submodule_status(path, recursive=True) -> list[tuple[str, str, str, str]]
# Returns rows from `git submodule status`:
# (path, state, commit, detail)
# state ∈ {"clean","not-initialized","out-of-date","unmerged","unknown"}

def sync_submodules(path, recursive=True, update_remote=True) -> tuple[bool, str]
# Raw primitive. Runs `git submodule update --init` with optional
# `--recursive` and `--remote`. Returns (ok, stderr). Reports nothing about
# WHAT moved and leaves the superproject holding modified gitlinks — prefer
# update_submodules below unless you specifically want the bare command.

def update_submodules(path, recursive=True, remote=True, commit=False, message=None)
-> SubmoduleUpdate
# The whole "keep every submodule on the tip of the branch it tracks"
# operation: snapshot -> update -> diff the snapshots -> optionally record.
#
# SubmoduleUpdate.ok bool every step succeeded
# SubmoduleUpdate.changed [SubmoduleChange]
# SubmoduleUpdate.commit str | None superproject commit, if recorded
# SubmoduleUpdate.stderr str diagnostics from the failing step
#
# SubmoduleChange.path str submodule path in the superproject
# SubmoduleChange.from_commit str where it sat before
# SubmoduleChange.to_commit str where it sits now
# SubmoduleChange.initialized bool this run checked it out for the first
# time (the SHA may not have moved)
# SubmoduleChange.branch str tracked branch from .gitmodules; empty
# when none is declared (git then
# follows the remote's default branch)
#
# Idempotent: a repeat run reports changed == [] and makes no empty commit.
# `commit=True` records only TOP-LEVEL gitlinks; see the note below.
```

## Why an update is three steps, not one

`git submodule update --remote` moves each submodule's working tree to the tip
of the branch it tracks and stops there — leaving a **detached HEAD** in each
submodule and a **modified gitlink** in the superproject. On its own it makes
the superproject dirty rather than up to date: the next plain
`git submodule update` snaps everything back to the SHA the superproject still
records. Recording the bumps is a separate commit, and that is what
`update_submodules(..., commit=True)` adds.

With `recursive=True`, a nested submodule appears as `outer/inner`. The
superproject cannot stage that path — it lives inside `outer` — so `commit=True`
records **top-level** gitlinks only. Recording a nested bump needs a commit
inside `outer` first, which is left to the caller rather than done implicitly.

## The one network call in v1 scope

```python
Expand Down
11 changes: 8 additions & 3 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ Implement, with parity tests vs the `git` CLI, in this order:
- Run the git-tend `scan` / `status` across the real workspace; compare
output to the subprocess implementation byte-for-byte.

## M3 — Standalone CLI (optional)
- Add a `[[bin]]` target reusing `repo.rs`/`status.rs` so `gitxtend status
<dir>` works without Python (for cron / shell). Same logic, no PyO3.
## M3 — Standalone CLI (optional) — *started*
- **Done:** `[[bin]] gitxtend` exists and ships `gitxtend submodule sync` /
`gitxtend submodule status`, with no PyO3 in the path. The command itself
lives in `src/cli.rs` (a pure `argv -> (code, stdout, stderr)` function), so
the binary and the `gitxtend` Python console script are both shims over the
same code rather than two parsers — pinned by a front-end parity test.
- **Next:** `gitxtend status <dir>` over the `repo_status` roll-up, so the read
side is reachable from cron/shell too.

## M4 — Write side (only after read side is trusted in prod)
- Evaluate porting `pull --ff-only`, `push`, `add`, `commit`, `stash`,
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ classifiers = [
"Programming Language :: Python :: 3",
]

[project.scripts]
# The same command as the standalone Rust binary: both are shims over
# `gitxtend::cli::run`. Installing the wheel puts `gitxtend` on PATH; if the
# standalone binary is ALSO installed, whichever comes first on PATH wins — they
# behave identically, so it does not matter which. `python -m gitxtend` always
# resolves to this one.
gitxtend = "gitxtend._cli:main"

[project.urls]
Repository = "https://github.com/hartsock/gitxtend"

Expand Down
10 changes: 10 additions & 0 deletions python/gitxtend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
remote_head_sha,
remote_urls,
repo_status,
submodule_status,
sync_submodules,
update_submodules,
SubmoduleChange,
SubmoduleUpdate,
rev_list_count,
status_counts,
tracking_branch,
Expand All @@ -40,6 +45,11 @@
"remote_head_sha",
"remote_urls",
"repo_status",
"submodule_status",
"sync_submodules",
"update_submodules",
"SubmoduleChange",
"SubmoduleUpdate",
"rev_list_count",
"status_counts",
"tracking_branch",
Expand Down
23 changes: 23 additions & 0 deletions python/gitxtend/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,29 @@ def ahead_behind(path: _Path, upstream: str) -> tuple[int, int]: ...
def rev_list_count(path: _Path, range_spec: str) -> int: ...
def log_subjects(path: _Path, range_spec: str, max_count: int = ...) -> list[str]: ...
def remote_urls(path: _Path) -> dict[str, str]: ...
def submodule_status(path: _Path, recursive: bool = ...) -> list[tuple[str, str, str, str]]: ...
def sync_submodules(path: _Path, recursive: bool = ..., update_remote: bool = ...) -> tuple[bool, str]: ...

class SubmoduleChange:
path: str
from_commit: str
to_commit: str
initialized: bool
branch: str

class SubmoduleUpdate:
ok: bool
changed: list[SubmoduleChange]
commit: str | None
stderr: str

def update_submodules(
path: _Path,
recursive: bool = ...,
remote: bool = ...,
commit: bool = ...,
message: str | None = ...,
) -> SubmoduleUpdate: ...
def last_commit_date(path: _Path) -> str | None: ...
def status_counts(path: _Path) -> tuple[int, int]: ...
def fetch(path: _Path, remote: str | None = ...) -> bool: ...
Expand Down
10 changes: 10 additions & 0 deletions python/gitxtend/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""``python -m gitxtend`` — same command as the ``gitxtend`` console script.

No-cover: this module raises ``SystemExit`` at import time, so it is only
reachable from a subprocess, where coverage does not follow. Its behaviour is
pinned by `test_python_dash_m_runs_the_same_command`.
"""

from ._cli import main # pragma: no cover

raise SystemExit(main()) # pragma: no cover
38 changes: 38 additions & 0 deletions python/gitxtend/_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""The ``gitxtend`` console script.

This is a *shim*, deliberately. Argument parsing, output rendering and exit
codes all live in Rust (``src/cli.rs``) and are reached through the compiled
``cli_main``; this module only forwards argv in and the captured streams out.

Keeping it this thin is the point: the standalone ``gitxtend`` binary and this
console script execute the same parser, so they cannot disagree about a flag
name, an output line, or an exit code. There is nothing here to keep in sync.
"""

from __future__ import annotations

import sys
from typing import Sequence

from ._gitxtend import cli_main


def main(argv: Sequence[str] | None = None) -> int:
"""Entry point for the ``gitxtend`` console script and ``python -m gitxtend``.

``argv`` excludes the program name; it defaults to ``sys.argv[1:]``.
Returns the process exit code (0 ok, 1 git failed, 2 bad usage).
"""
args = list(sys.argv[1:] if argv is None else argv)
code, out, err = cli_main(args)
if out:
sys.stdout.write(out)
sys.stdout.flush()
if err:
sys.stderr.write(err)
sys.stderr.flush()
return code


if __name__ == "__main__": # pragma: no cover - exercised via __main__.py
raise SystemExit(main())
Loading
Loading