facett-git 0.1.11

facett — git repository facet: browse branches, commit log, the file tree, and syntax-coloured source, all clickable. gix-backed (optional feature), egui-rendered. A consumer (nornir) drops it in to show git for any repo.
Documentation
//! **Pure git snapshot model** — the data a [`crate::GitView`] renders: branches,
//! the commit log, and a directory listing of the tree. The data types are always
//! available + serializable (so the facet builds, tests, and `state_json`s with no
//! git dependency); the live reader [`RepoSnapshot::open`] is gated behind the
//! `gix` feature.

use serde::{Deserialize, Serialize};

/// One ref/branch with its tip.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Branch {
    pub name: String,
    /// Is this the currently checked-out branch (`HEAD`)?
    pub is_head: bool,
    /// Short tip commit id.
    pub tip: String,
}

/// One row of the commit log.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommitRow {
    /// Short commit id.
    pub id: String,
    /// First line of the message.
    pub summary: String,
    /// Author name.
    pub author: String,
    /// Commit time (RFC3339 or `seconds`), as a display string.
    pub time: String,
}

/// One entry in a tree listing (a file or a sub-directory).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreeEntry {
    pub name: String,
    pub is_dir: bool,
    /// Object id (short).
    pub oid: String,
}

/// A snapshot of a repository at one point — what the facet shows.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RepoSnapshot {
    /// Absolute working-directory path (display).
    pub workdir: String,
    pub branches: Vec<Branch>,
    pub commits: Vec<CommitRow>,
    /// The listing of the current tree path (root by default).
    pub tree: Vec<TreeEntry>,
}

impl RepoSnapshot {
    /// The name of the currently checked-out branch, if any.
    #[must_use]
    pub fn head_branch(&self) -> Option<&str> {
        self.branches.iter().find(|b| b.is_head).map(|b| b.name.as_str())
    }

    /// A small deterministic snapshot for demos/tests (no git needed).
    #[must_use]
    pub fn demo() -> Self {
        RepoSnapshot {
            workdir: "/repo".into(),
            branches: vec![
                Branch { name: "main".into(), is_head: true, tip: "a1b2c3d".into() },
                Branch { name: "feature/x".into(), is_head: false, tip: "0f0f0f0".into() },
            ],
            commits: vec![
                CommitRow { id: "a1b2c3d".into(), summary: "add the thing".into(), author: "rickard".into(), time: "2026-06-30T10:00:00Z".into() },
                CommitRow { id: "9988776".into(), summary: "fix the other thing".into(), author: "ada".into(), time: "2026-06-29T09:00:00Z".into() },
            ],
            tree: vec![
                TreeEntry { name: "src".into(), is_dir: true, oid: "tttt001".into() },
                TreeEntry { name: "Cargo.toml".into(), is_dir: false, oid: "ffff002".into() },
                TreeEntry { name: "README.md".into(), is_dir: false, oid: "ffff003".into() },
            ],
        }
    }
}

/// A loaded blob (the source pane content).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Blob {
    pub path: String,
    pub text: String,
    /// True if the bytes weren't valid UTF-8 (binary file).
    pub binary: bool,
}

// ───────────────────────────── live gix reader ─────────────────────────────
#[cfg(feature = "gix")]
mod live {
    use super::*;

    fn short(id: &gix::ObjectId) -> String {
        id.to_hex_with_len(7).to_string()
    }

    impl RepoSnapshot {
        /// Open the repo at `path` and read branches + the latest `max_commits`
        /// commits + the root tree listing. Returns an error string on any gix
        /// failure (not-a-repo, unborn HEAD, …) so a host can show it instead of
        /// panicking.
        pub fn open(path: &str, max_commits: usize) -> Result<RepoSnapshot, String> {
            let repo = gix::open(path).map_err(|e| format!("open {path}: {e}"))?;
            let workdir = repo
                .workdir()
                .map(|p| p.display().to_string())
                .unwrap_or_else(|| path.to_string());

            // branches
            let head_name = repo.head_name().ok().flatten().map(|n| n.shorten().to_string());
            let mut branches = Vec::new();
            if let Ok(refs) = repo.references() {
                if let Ok(locals) = refs.local_branches() {
                    for r in locals.flatten() {
                        let name = r.name().shorten().to_string();
                        let tip = r.try_id().map(|id| short(&id.detach())).unwrap_or_default();
                        let is_head = head_name.as_deref() == Some(name.as_str());
                        branches.push(Branch { name, is_head, tip });
                    }
                }
            }
            branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then(a.name.cmp(&b.name)));

            // commit log from HEAD
            let mut commits = Vec::new();
            if let Ok(head_id) = repo.head_id() {
                if let Ok(walk) = head_id.ancestors().all() {
                    for info in walk.take(max_commits).flatten() {
                        if let Ok(commit) = info.object() {
                            let summary = commit
                                .message()
                                .ok()
                                .map(|m| m.summary().to_string())
                                .unwrap_or_default();
                            let author = commit
                                .author()
                                .map(|a| a.name.to_string())
                                .unwrap_or_default();
                            let time = commit.time().map(|t| t.seconds.to_string()).unwrap_or_default();
                            commits.push(CommitRow { id: short(&info.id), summary, author, time });
                        }
                    }
                }
            }

            // root tree listing
            let mut tree = Vec::new();
            if let Ok(commit) = repo.head_commit() {
                if let Ok(t) = commit.tree() {
                    for e in t.iter().flatten() {
                        let is_dir = e.mode().is_tree();
                        tree.push(TreeEntry {
                            name: e.filename().to_string(),
                            is_dir,
                            oid: short(&e.oid().to_owned()),
                        });
                    }
                }
            }
            tree.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));

            Ok(RepoSnapshot { workdir, branches, commits, tree })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// INJECT-ASSERT: the demo snapshot is well-formed — a head branch exists and
    /// directories sort before files in a typical listing.
    #[test]
    fn demo_snapshot_is_wellformed() {
        let s = RepoSnapshot::demo();
        assert_eq!(s.head_branch(), Some("main"));
        assert_eq!(s.branches.len(), 2);
        assert_eq!(s.commits.len(), 2);
        assert!(s.tree.iter().any(|e| e.is_dir && e.name == "src"));
        // round-trips through serde (state_json relies on it).
        let j = serde_json::to_string(&s).unwrap();
        let back: RepoSnapshot = serde_json::from_str(&j).unwrap();
        assert_eq!(s, back);
    }
}