Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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)
}