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