Skip to content

Latest commit

 

History

History
204 lines (164 loc) · 9.38 KB

File metadata and controls

204 lines (164 loc) · 9.38 KB

gitxtend — Python API Contract

This is the exact Python-visible surface the compiled module must expose. It 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.

Conventions

  • path accepts str | os.PathLike. Internally resolved to an absolute path.
  • "Soft-fail" methods mirror git-tend's current behaviour exactly: they return a sentinel (None, 0, [], {}) instead of raising, so callers don't change. These are marked soft-fail below.
  • Methods that git-tend lets propagate process errors may raise GitxtendError (subclass of RuntimeError).

Read primitives (port of GitService)

def is_git_repo(path) -> bool
    # GitService.is_git_repo — `git rev-parse --git-dir` exit==0

def is_clean(path) -> bool
    # GitService.is_clean — `git status --porcelain` empty

def current_branch(path) -> str | None        # soft-fail (None if detached)
    # GitService.current_branch — `rev-parse --abbrev-ref HEAD`, None if "HEAD"

def tracking_branch(path) -> str | None        # soft-fail
    # GitService.tracking_branch — `rev-parse --abbrev-ref @{upstream}`

def head_sha(path) -> str | None               # soft-fail
    # GitService.head_sha — `rev-parse HEAD`

def remote_head_sha(path, remote_ref="origin/main") -> str | None   # soft-fail
    # GitService.remote_head_sha — `rev-parse <remote_ref>` (after fetch)

def ahead_behind(path, upstream) -> tuple[int, int]
    # Replaces two GitService.rev_list_count calls:
    #   ahead  = rev_list_count(f"{upstream}..HEAD")
    #   behind = rev_list_count(f"HEAD..{upstream}")
    # gix computes both in one graph walk. Returns (ahead, behind).

def rev_list_count(path, range_spec) -> int    # soft-fail (0 on error)
    # GitService.rev_list_count — kept for 1:1 compatibility / other callers

def log_subjects(path, range_spec, max_count=10) -> list[str]   # soft-fail
    # GitService.log_oneline — commit subjects (%s) in range, newest first

def remote_urls(path) -> dict[str, str]        # soft-fail ({} on error)
    # GitService.remote_urls — {remote_name: fetch_url}

def last_commit_date(path) -> str | None       # soft-fail
    # GitService.last_commit_date — ISO 8601 (%aI) of HEAD commit

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

def fetch(path, remote=None) -> bool
    # GitService.fetch — fetch <remote> or --all. Returns True on success.
    # See PORTING.md → fetch for the gix-vs-shell decision. May be a thin
    # shell-out behind the same signature if gix fetch proves unstable; the
    # Python caller must not care which.

Roll-up (port of check_repo)

class RepoStatus:                # field names match the source RepoStatus exactly
    path: str                    # a Path in Python; resolved absolute
    sync_state: str              # NOT `state` — one of SyncState values (see below)
    local_branch: str | None
    tracking_branch: str | None
    local_sha: str | None
    remote_sha: str | None
    ahead_count: int
    behind_count: int
    new_remote_commits: list[str]
    is_dirty: bool
    error: str | None

def repo_status(path, fetch=True) -> RepoStatus
    # Mirrors check_repo VERBATIM. The field is `sync_state`, not `state`.
    # See PORTING.md → repo_status for the line-for-line sequence.

SyncState values (exact strings)

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

State decision (must match check_repo — verbatim)

check_repo gates on SHA equality first, then ahead/behind. It does not return DIRTYis_dirty is recorded as a flag only. The DIRTY state is produced by the separate scan path (workspace auto-discovery), which a later milestone ports alongside this roll-up.

not path.exists()        -> "error"   (error = "Directory not found: {path}")
not is_git_repo(path)    -> "error"   (error = "Not a git repository: {path}")
tracking_branch is None  -> "no-remote"   (sets local_branch, local_sha,
                                            is_dirty; leaves tracking_branch None)
fetch fails              -> "error"   (error = "Fetch failed: {stderr}";
                                        sets local_branch, tracking_branch)
local_sha == remote_sha  -> "up-to-date"
else:  ahead  = count("{tracking}..HEAD")
       behind = count("HEAD..{tracking}")
       ahead>0 and behind>0 -> "diverged"
       behind>0             -> "behind"
       else                 -> "ahead"

ahead_count / behind_count are always populated; new_remote_commits = log_subjects("HEAD..{tracking}", 10) only when behind_count > 0.

Field name caveat for the implementor. The compiled RepoStatus #[pyclass] currently exposes state; the source contract (and the consuming Python code) uses sync_state. For a true drop-in, rename the pyclass getter to sync_state when repo_status is implemented (touches src/python.rs and the .pyi stub). Flagged here rather than changed, to avoid racing the in-flight per-method implementation.

Adapter for the plugin

git-tend can adopt this with a shim that keeps the old class name:

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

class GitService:
    def is_git_repo(self, path):       return gitxtend.is_git_repo(path)
    def is_clean(self, path):          return gitxtend.is_clean(path)
    def current_branch(self, path):    return gitxtend.current_branch(path)
    def tracking_branch(self, path):   return gitxtend.tracking_branch(path)
    def head_sha(self, path):          return gitxtend.head_sha(path)
    def remote_head_sha(self, p, r="origin/main"):
                                       return gitxtend.remote_head_sha(p, r)
    def rev_list_count(self, p, spec): return gitxtend.rev_list_count(p, spec)
    def log_oneline(self, p, spec, max_count=10):
                                       return gitxtend.log_subjects(p, spec, max_count)
    def remote_urls(self, path):       return gitxtend.remote_urls(path)
    def last_commit_date(self, path):  return gitxtend.last_commit_date(path)
    def status_counts(self, path):     return gitxtend.status_counts(path)
    def fetch(self, path, remote=None):return gitxtend.fetch(path, remote)
    # write methods (pull/push/add/commit/stash/branch/reset) unchanged for now

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.