anvilsign in

collin/anvil

1//! Read-only repository browsing via gix: refs, trees, and blobs.
2//!
3//! These helpers back the web UI. Each opens the bare repo by path; that is
4//! cheap enough at our scale and keeps the API stateless.
5
6use std::collections::{
7 BTreeMap,
8 BTreeSet,
9};
10use std::fmt::Display;
11use std::path::Path;
12
13use crate::error::{
14 Error,
15 Result,
16};
17
18/// Summary of a repository's refs and state, for the repo landing page.
19pub struct Overview {
20 /// Short name of the default branch (from `HEAD`), if resolvable.
21 pub default_branch: Option<String>,
22 pub branches: Vec<String>,
23 pub tags: Vec<String>,
24 /// True if the repository has no commits yet (unborn `HEAD`).
25 pub is_empty: bool,
26}
27
28/// One entry in a tree listing.
29pub struct TreeEntry {
30 pub name: String,
31 pub is_dir: bool,
32 pub oid: String,
33}
34
35/// Map any gix error into our error type without a forest of `From` impls.
36fn read(e: impl Display) -> Error {
37 Error::Read(e.to_string())
38}
39
40/// Summarize a repository's default branch, branches, and tags.
41pub fn overview(repo_path: &Path) -> Result<Overview> {
42 let repo = gix::open(repo_path).map_err(read)?;
43 let default_branch = repo
44 .head_name()
45 .ok()
46 .flatten()
47 .map(|n| n.shorten().to_string());
48 let is_empty = repo.head_id().is_err();
49
50 let refs = repo.references().map_err(read)?;
51 let mut branches = Vec::new();
52 for r in refs.local_branches().map_err(read)?.flatten() {
53 branches.push(r.name().shorten().to_string());
54 }
55 let mut tags = Vec::new();
56 for r in refs.tags().map_err(read)?.flatten() {
57 tags.push(r.name().shorten().to_string());
58 }
59 branches.sort();
60 tags.sort();
61
62 Ok(Overview {
63 default_branch,
64 branches,
65 tags,
66 is_empty,
67 })
68}
69
70/// List the entries of the tree at `path` (empty for root) for revision `rev`
71/// (a branch, tag, or commit-ish). Directories sort before files. Returns an
72/// empty list if `path` is not a directory.
73pub fn list_tree(repo_path: &Path, rev: &str, path: &str) -> Result<Vec<TreeEntry>> {
74 let repo = gix::open(repo_path).map_err(read)?;
75 let root = repo
76 .rev_parse_single(rev)
77 .map_err(read)?
78 .object()
79 .map_err(read)?
80 .peel_to_commit()
81 .map_err(read)?
82 .tree()
83 .map_err(read)?;
84
85 let tree = if path.is_empty() {
86 root
87 } else {
88 match root.lookup_entry_by_path(path).map_err(read)? {
89 Some(entry) if entry.mode().is_tree() => {
90 entry.object().map_err(read)?.peel_to_tree().map_err(read)?
91 }
92 _ => return Ok(Vec::new()),
93 }
94 };
95
96 let mut entries = Vec::new();
97 for e in tree.iter() {
98 let e = e.map_err(read)?;
99 entries.push(TreeEntry {
100 name: e.filename().to_string(),
101 is_dir: e.mode().is_tree(),
102 oid: e.oid().to_string(),
103 });
104 }
105 entries.sort_by(|a, b| (!a.is_dir, &a.name).cmp(&(!b.is_dir, &b.name)));
106 Ok(entries)
107}
108
109/// Metadata about a single commit.
110pub struct CommitInfo {
111 pub id: String,
112 pub short: String,
113 pub summary: String,
114 pub author: String,
115 /// Commit time in Unix seconds.
116 pub time: i64,
117}
118
119/// How a file changed between two commits.
120#[derive(Clone, Copy, PartialEq, Eq)]
121pub enum ChangeKind {
122 Added,
123 Deleted,
124 Modified,
125}
126
127/// A single file's change in a commit, with old/new contents for diffing.
128pub struct FileChange {
129 pub path: String,
130 pub kind: ChangeKind,
131 pub old: Option<Vec<u8>>,
132 pub new: Option<Vec<u8>>,
133}
134
135/// A commit plus its diff against its first parent.
136pub struct CommitDetail {
137 pub info: CommitInfo,
138 pub parent: Option<String>,
139 pub message: String,
140 pub changes: Vec<FileChange>,
141}
142
143/// Walk commit history starting at `rev`, newest first, up to `limit` commits.
144pub fn commit_log(repo_path: &Path, rev: &str, limit: usize) -> Result<Vec<CommitInfo>> {
145 let repo = gix::open(repo_path).map_err(read)?;
146 let start = repo
147 .rev_parse_single(rev)
148 .map_err(read)?
149 .object()
150 .map_err(read)?
151 .peel_to_commit()
152 .map_err(read)?
153 .id;
154
155 let mut out = Vec::new();
156 for info in repo.rev_walk(Some(start)).all().map_err(read)?.take(limit) {
157 let info = info.map_err(read)?;
158 let commit = repo
159 .find_object(info.id)
160 .map_err(read)?
161 .try_into_commit()
162 .map_err(read)?;
163 out.push(commit_info(&commit)?);
164 }
165 Ok(out)
166}
167
168/// Load a commit and compute its file-level diff against its first parent
169/// (against the empty tree for a root commit).
170pub fn commit_detail(repo_path: &Path, rev: &str) -> Result<CommitDetail> {
171 let repo = gix::open(repo_path).map_err(read)?;
172 let commit = repo
173 .rev_parse_single(rev)
174 .map_err(read)?
175 .object()
176 .map_err(read)?
177 .peel_to_commit()
178 .map_err(read)?;
179
180 let info = commit_info(&commit)?;
181 let message = commit.message_raw().map_err(read)?.to_string();
182 let parent = commit.parent_ids().next().map(|p| p.detach());
183
184 let mut new_map = BTreeMap::new();
185 flatten_tree(&commit.tree().map_err(read)?, "", &mut new_map)?;
186 let mut old_map = BTreeMap::new();
187 if let Some(pid) = parent {
188 let parent_commit = repo
189 .find_object(pid)
190 .map_err(read)?
191 .try_into_commit()
192 .map_err(read)?;
193 flatten_tree(&parent_commit.tree().map_err(read)?, "", &mut old_map)?;
194 }
195
196 let blob = |oid: gix::ObjectId| -> Result<Vec<u8>> {
197 Ok(repo.find_object(oid).map_err(read)?.data.clone())
198 };
199
200 let mut paths: BTreeSet<&String> = new_map.keys().collect();
201 paths.extend(old_map.keys());
202 let mut changes = Vec::new();
203 for path in paths {
204 match (old_map.get(path), new_map.get(path)) {
205 (None, Some(n)) => changes.push(FileChange {
206 path: path.clone(),
207 kind: ChangeKind::Added,
208 old: None,
209 new: Some(blob(*n)?),
210 }),
211 (Some(o), None) => changes.push(FileChange {
212 path: path.clone(),
213 kind: ChangeKind::Deleted,
214 old: Some(blob(*o)?),
215 new: None,
216 }),
217 (Some(o), Some(n)) if o != n => changes.push(FileChange {
218 path: path.clone(),
219 kind: ChangeKind::Modified,
220 old: Some(blob(*o)?),
221 new: Some(blob(*n)?),
222 }),
223 _ => {}
224 }
225 }
226
227 Ok(CommitDetail {
228 info,
229 parent: parent.map(|p| p.to_string()),
230 message,
231 changes,
232 })
233}
234
235fn commit_info(commit: &gix::Commit) -> Result<CommitInfo> {
236 let id = commit.id.to_string();
237 let raw = commit.message_raw().map_err(read)?;
238 let summary = raw
239 .to_string()
240 .lines()
241 .next()
242 .unwrap_or_default()
243 .to_string();
244 let author = commit.author().map_err(read)?.name.to_string();
245 let time = commit.time().map_err(read)?.seconds;
246 Ok(CommitInfo {
247 short: id[..id.len().min(8)].to_string(),
248 id,
249 summary,
250 author,
251 time,
252 })
253}
254
255/// Recursively collect blob paths → object id from a tree.
256fn flatten_tree(
257 tree: &gix::Tree,
258 prefix: &str,
259 out: &mut BTreeMap<String, gix::ObjectId>,
260) -> Result<()> {
261 for e in tree.iter() {
262 let e = e.map_err(read)?;
263 let name = e.filename().to_string();
264 let path = if prefix.is_empty() {
265 name
266 } else {
267 format!("{prefix}/{name}")
268 };
269 if e.mode().is_tree() {
270 let sub = e.object().map_err(read)?.peel_to_tree().map_err(read)?;
271 flatten_tree(&sub, &path, out)?;
272 } else if e.mode().is_blob() {
273 out.insert(path, e.oid().to_owned());
274 }
275 }
276 Ok(())
277}
278
279/// Read the raw bytes of the blob at `path` for revision `rev`. Returns `None`
280/// if the path does not exist or is not a blob.
281pub fn read_blob(repo_path: &Path, rev: &str, path: &str) -> Result<Option<Vec<u8>>> {
282 let repo = gix::open(repo_path).map_err(read)?;
283 let tree = repo
284 .rev_parse_single(rev)
285 .map_err(read)?
286 .object()
287 .map_err(read)?
288 .peel_to_commit()
289 .map_err(read)?
290 .tree()
291 .map_err(read)?;
292
293 match tree.lookup_entry_by_path(path).map_err(read)? {
294 Some(entry) if entry.mode().is_blob() => {
295 let object = repo.find_object(entry.oid().to_owned()).map_err(read)?;
296 Ok(Some(object.data.clone()))
297 }
298 _ => Ok(None),
299 }
300}