crates/gunnar-client/src/porcelain.rs
//! The ordinary git verbs: `log`, `diff`, `status`, `commit`, `branch`,
//! `checkout`, `tag`.
//!
//! **None of this is reimplemented.** The client runs on a normal OS, always
//! — the no-OS rule is the server's — so `gix`'s porcelain is used directly: `Repository::commit`
//! / `commit_as`, `write_object`, `rev_walk`, `diff_tree_to_tree`, `status`,
//! `edit_reference`. Where `nornir-git` already carries a verb (the worktree
//! checkout, the snapshot commit, the lightweight tag), this module **delegates**
//! rather than growing a second copy (one implementation).
//!
//! What is added is a stable, protocol-free shape for the results, so a CLI or a
//! gRPC service can render them without importing gix's type surface.
//!
//! `status` used to be the one exception: a second copy of `nornir-git`'s status
//! walk, because the upstream one was private and its two public derivatives drop
//! the per-path state. `nornir-git 0.1.6` made it public; see [`status`].
use std::path::Path;
use gix::hash::ObjectId;
use gix::objs::FindHeader;
use crate::error::{Error, Result};
// ── log ─────────────────────────────────────────────────────────────────────
/// One commit, flattened.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitSummary {
/// The commit's own id.
pub id: ObjectId,
/// Its parents, in order. Empty for a root commit, two or more for a merge.
pub parents: Vec<ObjectId>,
/// The tree it names.
pub tree: ObjectId,
/// `Name <email>`.
pub author: String,
/// `Name <email>`.
pub committer: String,
/// Commit time, unix seconds.
pub time_unix: i64,
/// The first line of the message.
pub summary: String,
/// The whole message.
pub message: String,
}
/// Walk history from `rev` (default `HEAD`), newest first.
///
/// `limit` of `None` walks the whole reachable history.
pub fn log(
repo: &gix::Repository,
rev: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<CommitSummary>> {
let tip = match rev {
Some(r) => repo
.rev_parse_single(r)
.map_err(|e| Error::repo(format!("resolve {r:?}"), e))?
.detach(),
None => repo
.head_id()
.map_err(|e| Error::repo("resolve HEAD", e))?
.detach(),
};
let walk = repo
.rev_walk(Some(tip))
.all()
.map_err(|e| Error::repo("walk history", e))?;
let mut out = Vec::new();
for info in walk {
let info = info.map_err(|e| Error::repo("read a commit", e))?;
out.push(commit_summary(repo, info.id)?);
if limit.is_some_and(|n| out.len() >= n) {
break;
}
}
Ok(out)
}
/// One commit, by id.
pub fn commit_summary(repo: &gix::Repository, id: ObjectId) -> Result<CommitSummary> {
let commit = repo
.find_commit(id)
.map_err(|e| Error::repo(format!("find commit {id}"), e))?;
let author = commit
.author()
.map_err(|e| Error::repo(format!("decode the author of {id}"), e))?;
let committer = commit
.committer()
.map_err(|e| Error::repo(format!("decode the committer of {id}"), e))?;
let message = commit
.message_raw()
.map_err(|e| Error::repo(format!("decode the message of {id}"), e))?
.to_string();
Ok(CommitSummary {
id,
parents: commit.parent_ids().map(|p| p.detach()).collect(),
tree: commit
.tree_id()
.map_err(|e| Error::repo(format!("read the tree of {id}"), e))?
.detach(),
author: format!("{} <{}>", author.name, author.email),
committer: format!("{} <{}>", committer.name, committer.email),
time_unix: commit.time().map(|t| t.seconds).unwrap_or(0),
summary: message.lines().next().unwrap_or_default().to_string(),
message,
})
}
// ── diff ────────────────────────────────────────────────────────────────────
/// What happened to one path between two trees.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileChange {
/// The path is new.
Added {
/// Repository-relative path.
path: String,
/// The blob or tree now there.
id: ObjectId,
},
/// The path is gone.
Deleted {
/// Repository-relative path.
path: String,
/// What used to be there.
id: ObjectId,
},
/// The path's content or mode changed.
Modified {
/// Repository-relative path.
path: String,
/// Before.
old: ObjectId,
/// After.
new: ObjectId,
},
/// The path moved (or was copied) from somewhere else.
Renamed {
/// Where it came from.
from: String,
/// Where it is now.
to: String,
/// The object at the destination.
id: ObjectId,
},
}
impl FileChange {
/// The path this change is about; for a rename, the destination.
pub fn path(&self) -> &str {
match self {
FileChange::Added { path, .. }
| FileChange::Deleted { path, .. }
| FileChange::Modified { path, .. } => path,
FileChange::Renamed { to, .. } => to,
}
}
}
/// Diff two trees.
///
/// `None` for either side means the empty tree, so `diff_trees(repo, None,
/// Some(t))` is "everything in `t`", which is what a root commit's diff is.
pub fn diff_trees(
repo: &gix::Repository,
old: Option<ObjectId>,
new: Option<ObjectId>,
) -> Result<Vec<FileChange>> {
use gix::object::tree::diff::ChangeDetached as C;
let old_tree = match old {
Some(id) => Some(
repo.find_tree(id)
.map_err(|e| Error::repo(format!("find tree {id}"), e))?,
),
None => None,
};
let new_tree = match new {
Some(id) => Some(
repo.find_tree(id)
.map_err(|e| Error::repo(format!("find tree {id}"), e))?,
),
None => None,
};
let changes = repo
.diff_tree_to_tree(old_tree.as_ref(), new_tree.as_ref(), None)
.map_err(|e| Error::repo("diff trees", e))?;
Ok(changes
.into_iter()
.map(|c| match c {
C::Addition { location, id, .. } => FileChange::Added {
path: location.to_string(),
id,
},
C::Deletion { location, id, .. } => FileChange::Deleted {
path: location.to_string(),
id,
},
C::Modification {
location,
previous_id,
id,
..
} => FileChange::Modified {
path: location.to_string(),
old: previous_id,
new: id,
},
C::Rewrite {
source_location,
location,
id,
..
} => FileChange::Renamed {
from: source_location.to_string(),
to: location.to_string(),
id,
},
})
.collect())
}
/// Diff a commit against its first parent, the way `git show` does.
///
/// A root commit diffs against the empty tree, which is the whole of its
/// content rather than nothing. Returning an empty diff there is the classic
/// off-by-one that makes the first commit of every repository invisible.
pub fn diff_commit(repo: &gix::Repository, id: ObjectId) -> Result<Vec<FileChange>> {
let commit = commit_summary(repo, id)?;
let parent_tree = match commit.parents.first() {
Some(p) => Some(commit_summary(repo, *p)?.tree),
None => None,
};
diff_trees(repo, parent_tree, Some(commit.tree))
}
// ── status ──────────────────────────────────────────────────────────────────
/// How one path differs from `HEAD`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeState {
/// Staged: differs between `HEAD` and the index.
Staged,
/// Differs between the index and the working tree.
Modified,
/// Not tracked at all.
Untracked,
/// Moved within the working tree.
Renamed,
}
/// One entry of `git status --porcelain`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusEntry {
/// What kind of difference.
pub state: ChangeState,
/// Repository-relative path.
pub path: String,
}
impl From<nornir_git::gitio::ChangeKind> for ChangeState {
fn from(k: nornir_git::gitio::ChangeKind) -> Self {
use nornir_git::gitio::ChangeKind as K;
match k {
K::Staged => ChangeState::Staged,
K::Modified => ChangeState::Modified,
K::Untracked => ChangeState::Untracked,
K::Renamed => ChangeState::Renamed,
}
}
}
/// The working tree's status, sorted by path.
///
/// **Delegates to `nornir_git::gitio::porcelain_status`.** That walk used to
/// be private, exposed only through `worktree_changed_paths` (paths, no state)
/// and `worktree_freshness` (a digest). Neither carries the per-path state a
/// `status` verb has to print, so this module repeated it. `nornir-git 0.1.6`
/// made it public with the state intact, and the copy is gone (one
/// implementation). All that is left here is the shape adapter: gix's item
/// kinds are already collapsed upstream, and this maps them onto the
/// protocol-free [`ChangeState`] a CLI or a gRPC service renders.
///
/// A **bare** repository is an error rather than an empty list: "clean" and "has
/// no working tree" are different answers and only one of them is true.
///
/// # Why the PATH and not this `repo`
///
/// The obvious call is `porcelain_status_in(repo)`, and it was that until
/// 2026-08-27. Two independent reasons say otherwise, and either alone decides it:
///
/// 1. **The zombie leak.** `porcelain_status` opens through
/// `nornir_git::gitio::open_for_status`, which strips `filter.<name>.process`
/// from the in-memory config. Any plain `gix::open` on a box whose
/// `/etc/gitconfig` declares `filter.lfs.process` parks a long-running
/// `git-lfs filter-process` child per status walk that gix never reaps —
/// measured 2026-08-23 as 12,605 `<defunct>` children under
/// `dwarves-server`, growing ~36/min. A repository opened by OUR caller has
/// not been disarmed; one opened here has. Passing the caller's `repo`
/// through was quietly opting every gunnar `status` out of that fix.
/// 2. **The gix major.** `nornir-git` 0.1.8 links gix **0.87**; this crate, and
/// dwarves, and nornir, and three edda leaves, link gix **0.86**. A
/// `&gix::Repository` cannot cross that boundary (E0308: expected
/// `gix::types::Repository`, found `gix::Repository`) — a `&Path` can, and
/// carries exactly as much information, because the callee opens it anyway.
///
/// `repo.path()` is the `.git` directory, which is what `gix::open` wants.
pub fn status(repo: &gix::Repository) -> Result<Vec<StatusEntry>> {
let (_workdir, entries) =
nornir_git::gitio::porcelain_status(repo.path()).map_err(Error::Repo)?;
let mut out: Vec<StatusEntry> = entries
.into_iter()
.map(|e| StatusEntry {
state: e.kind.into(),
path: e.rela,
})
.collect();
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
/// True when nothing is staged, modified or untracked.
pub fn is_clean(repo: &gix::Repository) -> Result<bool> {
Ok(status(repo)?.is_empty())
}
// ── commit ──────────────────────────────────────────────────────────────────
/// Commit `tree` onto `reference`, with `parents`.
///
/// The plumbing form: the tree is already written and the caller decides the
/// parents. `gix::Repository::commit` does the work (writing the commit object,
/// updating the ref and its reflog), so this is a shape adapter, not an
/// implementation.
pub fn commit_tree(
repo: &gix::Repository,
reference: &str,
message: &str,
tree: ObjectId,
parents: &[ObjectId],
) -> Result<ObjectId> {
let id = repo
.commit(reference, message, tree, parents.iter().copied())
.map_err(|e| Error::repo(format!("commit onto {reference}"), e))?;
Ok(id.detach())
}
/// Snapshot the whole working tree into a new commit on `HEAD`, returning its
/// id. In other words, `git add -A && git commit`.
///
/// **Delegates to `nornir_git::gitio::commit_all`.** Its semantics, stated
/// plainly because they are not quite git's: the tree is rebuilt from the
/// **filesystem** each call rather than from the index, so additions,
/// modifications and deletions are all captured and the index is bypassed. That
/// is right for a snapshot commit and wrong for a staged one; use
/// [`commit_tree`] when the index matters.
pub fn commit_all(root: &Path, message: &str) -> Result<ObjectId> {
let sha = nornir_git::gitio::commit_all(root, message).map_err(Error::Repo)?;
ObjectId::from_hex(sha.as_bytes())
.map_err(|e| Error::repo_msg(format!("commit_all returned {sha:?}: {e}")))
}
// ── branch ──────────────────────────────────────────────────────────────────
/// A local branch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Branch {
/// Short name, e.g. `main`.
pub name: String,
/// Full ref name, e.g. `refs/heads/main`.
pub full_name: String,
/// The commit it points at.
pub target: ObjectId,
/// Whether `HEAD` points at it.
pub is_head: bool,
}
/// Every local branch, sorted by name.
pub fn branches(repo: &gix::Repository) -> Result<Vec<Branch>> {
let head_name = repo
.head_ref()
.ok()
.flatten()
.map(|r| r.name().as_bstr().to_string());
let platform = repo
.references()
.map_err(|e| Error::repo("open the ref store", e))?;
let mut out = Vec::new();
for r in platform
.prefixed("refs/heads/")
.map_err(|e| Error::repo("iterate branches", e))?
{
let mut r = r.map_err(|e| Error::repo("read a branch", e))?;
let full_name = r.name().as_bstr().to_string();
let target = r
.peel_to_id()
.map_err(|e| Error::repo(format!("peel {full_name}"), e))?
.detach();
out.push(Branch {
name: full_name
.strip_prefix("refs/heads/")
.unwrap_or(&full_name)
.to_string(),
is_head: head_name.as_deref() == Some(full_name.as_str()),
full_name,
target,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
/// Create `refs/heads/<name>` at `at` (default: `HEAD`).
///
/// Fails if the branch already exists.
///
/// **`PreviousValue::MustNotExist` alone is not enough**, and finding that out
/// is what this comment is for: gix's file ref store short-circuits an edit
/// whose new value equals the current one, and the short-circuit happens
/// *before* the precondition is evaluated. So `MustNotExist` fires when
/// re-creating a branch at a different commit and stays silent when re-creating
/// it at the same one, a guard that goes red only in the case nobody tests.
/// The explicit existence check below is the one that always runs; the
/// precondition is kept as well, because it is what makes the write atomic
/// against a concurrent creator.
pub fn create_branch(repo: &gix::Repository, name: &str, at: Option<ObjectId>) -> Result<ObjectId> {
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
let target = match at {
Some(id) => id,
None => repo
.head_id()
.map_err(|e| Error::repo("resolve HEAD", e))?
.detach(),
};
let full = format!("refs/heads/{name}");
if repo.find_reference(full.as_str()).is_ok() {
return Err(Error::Invalid(format!(
"{full} already exists; delete it or use a different name"
)));
}
repo.edit_reference(RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: format!("branch: created from {target}").into(),
},
expected: PreviousValue::MustNotExist,
new: gix::refs::Target::Object(target),
},
name: full
.as_str()
.try_into()
.map_err(|e| Error::Invalid(format!("invalid branch name {name:?}: {e}")))?,
deref: false,
})
.map_err(|e| Error::repo(format!("create {full}"), e))?;
Ok(target)
}
/// Delete `refs/heads/<name>`.
///
/// Refuses to delete the branch `HEAD` is on: the result is a repository whose
/// `HEAD` names a ref that does not exist, which most tools read as an empty
/// repository.
pub fn delete_branch(repo: &gix::Repository, name: &str) -> Result<()> {
use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog};
let full = format!("refs/heads/{name}");
if let Ok(Some(head)) = repo.head_ref() {
if head.name().as_bstr() == full.as_str() {
return Err(Error::Invalid(format!(
"{name} is the current branch; check out another one before deleting it"
)));
}
}
repo.edit_reference(RefEdit {
change: Change::Delete {
expected: PreviousValue::MustExist,
log: RefLog::AndReference,
},
name: full
.as_str()
.try_into()
.map_err(|e| Error::Invalid(format!("invalid branch name {name:?}: {e}")))?,
deref: false,
})
.map_err(|e| Error::repo(format!("delete {full}"), e))?;
Ok(())
}
// ── checkout ────────────────────────────────────────────────────────────────
/// Check out `branch` at `sha`, updating `HEAD` and materialising the worktree.
///
/// **Delegates to `nornir_git::gitio::set_head_and_checkout`.**
pub fn checkout_branch(root: &Path, branch: &str, at: ObjectId) -> Result<()> {
nornir_git::gitio::set_head_and_checkout(root, branch, &at.to_hex().to_string())
.map_err(Error::Repo)
}
/// Detach `HEAD` at `id` and materialise that tree.
///
/// **Delegates to `nornir_git::gitio::detach_head_and_checkout`.**
pub fn checkout_detached(root: &Path, id: ObjectId) -> Result<()> {
nornir_git::gitio::detach_head_and_checkout(root, &id.to_hex().to_string()).map_err(Error::Repo)
}
// ── tag ─────────────────────────────────────────────────────────────────────
/// A tag as it exists in the ref store.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TagInfo {
/// Short name, e.g. `v1.0`.
pub name: String,
/// What `refs/tags/<name>` points at: the **tag object** for an annotated
/// tag, the commit for a lightweight one.
pub target: ObjectId,
/// For an annotated tag, the commit it ultimately names.
pub peeled: Option<ObjectId>,
/// True when a tag object exists (annotated), false for a lightweight tag.
pub annotated: bool,
}
/// Every tag, sorted by name.
pub fn tags(repo: &gix::Repository) -> Result<Vec<TagInfo>> {
let platform = repo
.references()
.map_err(|e| Error::repo("open the ref store", e))?;
let mut out = Vec::new();
for r in platform
.prefixed("refs/tags/")
.map_err(|e| Error::repo("iterate tags", e))?
{
let mut r = r.map_err(|e| Error::repo("read a tag", e))?;
let full = r.name().as_bstr().to_string();
let Some(target) = r.target().try_id().map(|id| id.to_owned()) else {
continue; // a symbolic tag ref is not something git creates
};
let annotated = repo
.objects
.try_header(&target)
.map_err(|e| Error::repo(format!("read the header of {target}"), e))?
.map(|h| h.kind == gix::objs::Kind::Tag)
.unwrap_or(false);
let peeled = annotated
.then(|| {
r.peel_to_id()
.map(|id| id.detach())
.map_err(|e| Error::repo(format!("peel {full}"), e))
})
.transpose()?;
out.push(TagInfo {
name: full.strip_prefix("refs/tags/").unwrap_or(&full).to_string(),
target,
peeled,
annotated,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
/// Create a lightweight tag.
///
/// **Delegates to `nornir_git::gitio::tag_lightweight`**, which fails if the tag
/// already exists.
pub fn tag_lightweight(root: &Path, name: &str, target: ObjectId) -> Result<()> {
nornir_git::gitio::tag_lightweight(root, name, &target.to_hex().to_string())
.map_err(Error::Repo)
}
/// Create an **annotated** tag: a real tag object with a message and a tagger,
/// plus the ref pointing at it.
///
/// The tag object is what a push actually carries (see
/// [`crate::objects::plan_objects`]), and a tag ref pointing at an object the
/// remote does not have is a broken push, so the two are created together, here,
/// rather than left to two call sites.
pub fn tag_annotated(
repo: &gix::Repository,
name: &str,
target: ObjectId,
message: &str,
tagger: Option<gix::actor::Signature>,
) -> Result<ObjectId> {
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
let full = format!("refs/tags/{name}");
// See `create_branch`: `MustNotExist` cannot be relied on alone, because an
// edit whose new value equals the old one is short-circuited before the
// precondition runs. Two `tag_annotated` calls with the same message and
// tagger produce the *same* tag object, so that is exactly the case here.
if repo.find_reference(full.as_str()).is_ok() {
return Err(Error::Invalid(format!(
"{full} already exists; delete it or use a different name"
)));
}
let target_kind = repo
.objects
.try_header(&target)
.map_err(|e| Error::repo(format!("read the header of {target}"), e))?
.map(|h| h.kind)
.ok_or_else(|| {
Error::repo_msg(format!("cannot tag {target}: it is not in this repository"))
})?;
let tagger = match tagger {
Some(t) => t,
None => repo
.committer()
.ok_or_else(|| {
Error::repo_msg(
"no committer identity is configured, so an annotated tag has no tagger; \
set user.name and user.email or pass one explicitly",
)
})?
.map_err(|e| Error::repo("read the committer identity", e))?
.to_owned()
.map_err(|e| Error::repo("read the committer identity", e))?,
};
let tag = gix::objs::Tag {
target,
target_kind,
name: name.into(),
tagger: Some(tagger),
message: message.into(),
signature: None,
};
let tag_id = repo
.write_object(&tag)
.map_err(|e| Error::repo(format!("write the tag object for {name}"), e))?
.detach();
repo.edit_reference(RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: format!("tag: annotated {name}").into(),
},
expected: PreviousValue::MustNotExist,
new: gix::refs::Target::Object(tag_id),
},
name: full
.as_str()
.try_into()
.map_err(|e| Error::Invalid(format!("invalid tag name {name:?}: {e}")))?,
deref: false,
})
.map_err(|e| Error::repo(format!("create {full}"), e))?;
Ok(tag_id)
}