Skip to main content

facett_git/
model.rs

1//! **Pure git snapshot model** — the data a [`crate::GitView`] renders: branches,
2//! the commit log, and a directory listing of the tree. The data types are always
3//! available + serializable (so the facet builds, tests, and `state_json`s with no
4//! git dependency); the live reader [`RepoSnapshot::open`] is gated behind the
5//! `gix` feature.
6
7use serde::{Deserialize, Serialize};
8
9/// One ref/branch with its tip.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct Branch {
12    pub name: String,
13    /// Is this the currently checked-out branch (`HEAD`)?
14    pub is_head: bool,
15    /// Short tip commit id.
16    pub tip: String,
17}
18
19/// One row of the commit log.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct CommitRow {
22    /// Short commit id.
23    pub id: String,
24    /// First line of the message.
25    pub summary: String,
26    /// Author name.
27    pub author: String,
28    /// Commit time (RFC3339 or `seconds`), as a display string.
29    pub time: String,
30}
31
32/// One entry in a tree listing (a file or a sub-directory).
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct TreeEntry {
35    pub name: String,
36    pub is_dir: bool,
37    /// Object id (short).
38    pub oid: String,
39}
40
41/// A snapshot of a repository at one point — what the facet shows.
42#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
43pub struct RepoSnapshot {
44    /// Absolute working-directory path (display).
45    pub workdir: String,
46    pub branches: Vec<Branch>,
47    pub commits: Vec<CommitRow>,
48    /// The listing of the current tree path (root by default).
49    pub tree: Vec<TreeEntry>,
50}
51
52impl RepoSnapshot {
53    /// The name of the currently checked-out branch, if any.
54    #[must_use]
55    pub fn head_branch(&self) -> Option<&str> {
56        self.branches.iter().find(|b| b.is_head).map(|b| b.name.as_str())
57    }
58
59    /// A small deterministic snapshot for demos/tests (no git needed).
60    #[must_use]
61    pub fn demo() -> Self {
62        RepoSnapshot {
63            workdir: "/repo".into(),
64            branches: vec![
65                Branch { name: "main".into(), is_head: true, tip: "a1b2c3d".into() },
66                Branch { name: "feature/x".into(), is_head: false, tip: "0f0f0f0".into() },
67            ],
68            commits: vec![
69                CommitRow { id: "a1b2c3d".into(), summary: "add the thing".into(), author: "rickard".into(), time: "2026-06-30T10:00:00Z".into() },
70                CommitRow { id: "9988776".into(), summary: "fix the other thing".into(), author: "ada".into(), time: "2026-06-29T09:00:00Z".into() },
71            ],
72            tree: vec![
73                TreeEntry { name: "src".into(), is_dir: true, oid: "tttt001".into() },
74                TreeEntry { name: "Cargo.toml".into(), is_dir: false, oid: "ffff002".into() },
75                TreeEntry { name: "README.md".into(), is_dir: false, oid: "ffff003".into() },
76            ],
77        }
78    }
79}
80
81/// A loaded blob (the source pane content).
82#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
83pub struct Blob {
84    pub path: String,
85    pub text: String,
86    /// True if the bytes weren't valid UTF-8 (binary file).
87    pub binary: bool,
88}
89
90// ───────────────────────────── live gix reader ─────────────────────────────
91#[cfg(feature = "gix")]
92mod live {
93    use super::*;
94
95    fn short(id: &gix::ObjectId) -> String {
96        id.to_hex_with_len(7).to_string()
97    }
98
99    impl RepoSnapshot {
100        /// Open the repo at `path` and read branches + the latest `max_commits`
101        /// commits + the root tree listing. Returns an error string on any gix
102        /// failure (not-a-repo, unborn HEAD, …) so a host can show it instead of
103        /// panicking.
104        pub fn open(path: &str, max_commits: usize) -> Result<RepoSnapshot, String> {
105            let repo = gix::open(path).map_err(|e| format!("open {path}: {e}"))?;
106            let workdir = repo
107                .workdir()
108                .map(|p| p.display().to_string())
109                .unwrap_or_else(|| path.to_string());
110
111            // branches
112            let head_name = repo.head_name().ok().flatten().map(|n| n.shorten().to_string());
113            let mut branches = Vec::new();
114            if let Ok(refs) = repo.references() {
115                if let Ok(locals) = refs.local_branches() {
116                    for r in locals.flatten() {
117                        let name = r.name().shorten().to_string();
118                        let tip = r.try_id().map(|id| short(&id.detach())).unwrap_or_default();
119                        let is_head = head_name.as_deref() == Some(name.as_str());
120                        branches.push(Branch { name, is_head, tip });
121                    }
122                }
123            }
124            branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then(a.name.cmp(&b.name)));
125
126            // commit log from HEAD
127            let mut commits = Vec::new();
128            if let Ok(head_id) = repo.head_id() {
129                if let Ok(walk) = head_id.ancestors().all() {
130                    for info in walk.take(max_commits).flatten() {
131                        if let Ok(commit) = info.object() {
132                            let summary = commit
133                                .message()
134                                .ok()
135                                .map(|m| m.summary().to_string())
136                                .unwrap_or_default();
137                            let author = commit
138                                .author()
139                                .map(|a| a.name.to_string())
140                                .unwrap_or_default();
141                            let time = commit.time().map(|t| t.seconds.to_string()).unwrap_or_default();
142                            commits.push(CommitRow { id: short(&info.id), summary, author, time });
143                        }
144                    }
145                }
146            }
147
148            // root tree listing
149            let mut tree = Vec::new();
150            if let Ok(commit) = repo.head_commit() {
151                if let Ok(t) = commit.tree() {
152                    for e in t.iter().flatten() {
153                        let is_dir = e.mode().is_tree();
154                        tree.push(TreeEntry {
155                            name: e.filename().to_string(),
156                            is_dir,
157                            oid: short(&e.oid().to_owned()),
158                        });
159                    }
160                }
161            }
162            tree.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
163
164            Ok(RepoSnapshot { workdir, branches, commits, tree })
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    /// INJECT-ASSERT: the demo snapshot is well-formed — a head branch exists and
174    /// directories sort before files in a typical listing.
175    #[test]
176    fn demo_snapshot_is_wellformed() {
177        let s = RepoSnapshot::demo();
178        assert_eq!(s.head_branch(), Some("main"));
179        assert_eq!(s.branches.len(), 2);
180        assert_eq!(s.commits.len(), 2);
181        assert!(s.tree.iter().any(|e| e.is_dir && e.name == "src"));
182        // round-trips through serde (state_json relies on it).
183        let j = serde_json::to_string(&s).unwrap();
184        let back: RepoSnapshot = serde_json::from_str(&j).unwrap();
185        assert_eq!(s, back);
186    }
187}