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 use a recent stable Rust toolchain.
Legend: CLI = what git-tend runs today · gix = intended approach.
- CLI:
git rev-parse --git-dir(exit 0) - gix:
gix::open(path).is_ok()(orgix::discoverif you want to honor the "inside a repo, not just at the root" semantics —rev-parse --git-dirsucceeds from subdirectories, so prefergix::discover).
- CLI:
git status --porcelainis empty - gix: open repo, run a status that includes worktree modifications and
untracked files; clean == no entries. (gix
statusplatform; ensure untracked + ignored handling matches porcelain defaults.)
- CLI:
git rev-parse --abbrev-ref HEAD;Nonewhen output ==HEAD - gix:
repo.head()?; if detached returnNone, else the short ref name.
- CLI:
git rev-parse --abbrev-ref @{upstream} - gix: from the current branch ref, resolve its configured upstream
(
branch.<name>.remote+.merge) to a shortremote/branchname.
- CLI:
git fetch <remote>orgit fetch --all - gix: gix supports fetch, but network fetch is the least-mature path in
scope. Decision: implement behind the
fetch()signature with a preference order — (1) trygixfetch; if the build/feature proves unreliable, (2) fall back to shelling out togit fetchinside the Rust module (still one process from Python's view). Document which path shipped. Honor credentials/SSH exactly as the user's git does (respect~/.gitconfig, ssh-agent). This is the single riskiest method — keep it isolated.
- CLI:
git rev-parse HEAD - gix:
repo.head_id()?→ hex string;Noneon unborn/empty repo.
- CLI:
git rev-parse origin/main(after fetch) - gix: resolve the remote-tracking ref (
refs/remotes/<remote_ref>) to its object id.Noneif the ref doesn't exist.
- CLI (today, two calls):
git rev-list --count {upstream}..HEAD(ahead) andgit rev-list --count HEAD..{upstream}(behind) - gix: single merge-base + graph walk to count commits unique to each side.
Return
(ahead, behind). This is the headline efficiency win — one walk instead of twogitforks.
- CLI:
git rev-list --count <range_spec> - gix: parse a two-dot
A..Brange into endpoints and count via the same walker used byahead_behind. Soft-fail to0on parse/lookup error to match current behaviour.
- CLI:
git log --format=%s --max-count=N <range_spec> - gix: walk the range newest-first, take
max_count, return each commit's summary line (first line of the message). Soft-fail to[].
- CLI: parse
git remote -v(fetch)lines - gix: read remotes from config; map each remote name to its fetch URL.
Soft-fail to
{}.
- CLI:
git log -1 --format=%aI(author date, ISO 8601 strict) - gix: HEAD commit's author time, formatted as RFC3339/ISO-8601 with offset
to match
%aIexactly.Noneon empty repo.
- CLI: parse
git status --porcelain: lines starting??are untracked, all other non-empty lines are modified - gix: from the same status used by
is_clean, bucket entries into (modified, untracked) with the same definition porcelain uses (an untracked file is??; everything else — staged or unstaged change, rename, delete — counts as modified). Verify against fixtures.
Port the source check_repo verbatim. Order and error strings matter —
tests assert on them. The struct field is sync_state (not state); DIRTY
is never returned here.
path = expanduser(resolve(path))
if not path.exists(): -> sync_state=ERROR, error=f"Directory not found: {path}"; return
if not is_git_repo(path): -> sync_state=ERROR, error=f"Not a git repository: {path}"; return
local_branch = current_branch(path)
tracking = tracking_branch(path)
if tracking is None: -> sync_state=NO_REMOTE,
local_branch, local_sha=head_sha(path),
is_dirty=not is_clean(path);
(tracking_branch left None); return
if fetch:
ok, stderr = _fetch(path)
if not ok: -> sync_state=ERROR, local_branch, tracking_branch=tracking,
error=f"Fetch failed: {stderr}"; return
local_sha = head_sha(path)
remote_sha = remote_head_sha(path, tracking)
is_dirty = not is_clean(path)
if local_sha == remote_sha:
sync_state = UP_TO_DATE
else:
ahead = rev_list_count(f"{tracking}..HEAD")
behind = rev_list_count(f"HEAD..{tracking}")
sync_state = DIVERGED if (ahead>0 and behind>0) else BEHIND if behind>0 else AHEAD
ahead_count = rev_list_count(f"{tracking}..HEAD") # always recomputed
behind_count = rev_list_count(f"HEAD..{tracking}")
new_remote_commits = log_subjects(f"HEAD..{tracking}", 10) if behind_count>0 else []
return RepoStatus(path, sync_state, local_branch, tracking, local_sha, remote_sha,
ahead_count, behind_count, new_remote_commits, is_dirty)
Notes for the implementor:
check_reponever yieldsDIRTY;is_dirtyis a flag only. TheDIRTYstate lives in the separate scan path (workspace auto-discovery) — port that alongside, not inside,repo_status.- A fetch failure becomes
sync_state=ERRORwitherror="Fetch failed: {stderr}", so the roll-up needs fetch's stderr, not just a bool. Provide an internal_fetch(path) -> (ok, stderr); the publicfetch()may still returnboolfor the per-method shim. ahead_behind()is the efficiency win, but to stay byte-compatible the roll-up may call it once and reuse the pair for both the decision and the counts.- Keep every
errorstring identical to the text above.
- Rust unit tests in
repo.rs/status.rsagainst temp-dir fixtures built withgix(init repo, make commits, set upstream, dirty the tree, diverge). Cover every state in the SyncState tree. - Parity tests: for each method, assert the gix result equals the result of
the real
gitCLI on the same fixture. This is the acceptance bar — gitxtend must agree withgiton every fixture before the plugin adopts it. - Python smoke tests post-
maturin develop: import the module, runrepo_status()on a fixture, assert fields.
Rule of thumb: every behaviour needs a regression test; mock/contain external resources.