use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Branch {
pub name: String,
pub is_head: bool,
pub tip: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommitRow {
pub id: String,
pub summary: String,
pub author: String,
pub time: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreeEntry {
pub name: String,
pub is_dir: bool,
pub oid: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RepoSnapshot {
pub workdir: String,
pub branches: Vec<Branch>,
pub commits: Vec<CommitRow>,
pub tree: Vec<TreeEntry>,
}
impl RepoSnapshot {
#[must_use]
pub fn head_branch(&self) -> Option<&str> {
self.branches.iter().find(|b| b.is_head).map(|b| b.name.as_str())
}
#[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() },
],
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Blob {
pub path: String,
pub text: String,
pub binary: bool,
}
#[cfg(feature = "gix")]
mod live {
use super::*;
fn short(id: &gix::ObjectId) -> String {
id.to_hex_with_len(7).to_string()
}
impl RepoSnapshot {
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());
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)));
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 });
}
}
}
}
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::*;
#[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"));
let j = serde_json::to_string(&s).unwrap();
let back: RepoSnapshot = serde_json::from_str(&j).unwrap();
assert_eq!(s, back);
}
}