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.
110#[derive(Clone)]
111pub struct CommitInfo {
112 pub id: String,
113 pub short: String,
114 pub summary: String,
115 pub author: String,
116 /// Commit time in Unix seconds.
117 pub time: i64,
118}
119
120/// How a file changed between two commits.
121#[derive(Clone, Copy, PartialEq, Eq)]
122pub enum ChangeKind {
123 Added,
124 Deleted,
125 Modified,
126}
127
128/// A single file's change in a commit, with old/new contents for diffing.
129pub struct FileChange {
130 pub path: String,
131 pub kind: ChangeKind,
132 pub old: Option<Vec<u8>>,
133 pub new: Option<Vec<u8>>,
134}
135
136/// A commit plus its diff against its first parent.
137pub struct CommitDetail {
138 pub info: CommitInfo,
139 pub parent: Option<String>,
140 pub message: String,
141 pub changes: Vec<FileChange>,
142}
143
144/// For each immediate entry of the tree at `path` (empty for root) in `rev`,
145/// find the most recent commit that changed it: the newest commit, walking
146/// first-parent history up to `limit` commits, where the entry's oid differs
147/// from (or is absent in) the parent. Returns entry name → commit. Entries
148/// last touched beyond the walk window are absent (callers omit the
149/// annotation). On a merge commit, side-branch changes attribute to the merge
150/// itself — acceptable for the linear histories this targets.
151pub fn latest_entry_commits(
152 repo_path: &Path,
153 rev: &str,
154 path: &str,
155 limit: usize,
156) -> Result<BTreeMap<String, CommitInfo>> {
157 let repo = gix::open(repo_path).map_err(read)?;
158 let mut commit = repo
159 .rev_parse_single(rev)
160 .map_err(read)?
161 .object()
162 .map_err(read)?
163 .peel_to_commit()
164 .map_err(read)?;
165
166 // Entries as of `rev`. While an entry's oid matches the parent's it is
167 // unchanged, so carrying the original oid through the walk stays correct.
168 let mut remaining = entries_at(&commit, path)?;
169 let mut out = BTreeMap::new();
170
171 for _ in 0..limit {
172 if remaining.is_empty() {
173 break;
174 }
175 let parent = match commit.parent_ids().next() {
176 Some(p) => Some(
177 repo.find_object(p.detach())
178 .map_err(read)?
179 .try_into_commit()
180 .map_err(read)?,
181 ),
182 None => None,
183 };
184 let parent_entries = match &parent {
185 Some(p) => entries_at(p, path)?,
186 None => BTreeMap::new(), // root commit: everything left was added here
187 };
188 let info = commit_info(&commit)?;
189 remaining.retain(|name, oid| {
190 if parent_entries.get(name) == Some(oid) {
191 return true;
192 }
193 out.insert(name.clone(), info.clone());
194 false
195 });
196 match parent {
197 Some(p) => commit = p,
198 None => break,
199 }
200 }
201 Ok(out)
202}
203
204/// Immediate entries (name → oid) of the tree at `path` within `commit`;
205/// empty if `path` is not a tree in that commit.
206fn entries_at(commit: &gix::Commit, path: &str) -> Result<BTreeMap<String, gix::ObjectId>> {
207 let root = commit.tree().map_err(read)?;
208 let tree = if path.is_empty() {
209 root
210 } else {
211 match root.lookup_entry_by_path(path).map_err(read)? {
212 Some(e) if e.mode().is_tree() => {
213 e.object().map_err(read)?.peel_to_tree().map_err(read)?
214 }
215 _ => return Ok(BTreeMap::new()),
216 }
217 };
218 let mut out = BTreeMap::new();
219 for e in tree.iter() {
220 let e = e.map_err(read)?;
221 out.insert(e.filename().to_string(), e.oid().to_owned());
222 }
223 Ok(out)
224}
225
226/// Walk commit history starting at `rev`, newest first, up to `limit` commits.
227pub fn commit_log(repo_path: &Path, rev: &str, limit: usize) -> Result<Vec<CommitInfo>> {
228 let repo = gix::open(repo_path).map_err(read)?;
229 let start = repo
230 .rev_parse_single(rev)
231 .map_err(read)?
232 .object()
233 .map_err(read)?
234 .peel_to_commit()
235 .map_err(read)?
236 .id;
237
238 let mut out = Vec::new();
239 for info in repo.rev_walk(Some(start)).all().map_err(read)?.take(limit) {
240 let info = info.map_err(read)?;
241 let commit = repo
242 .find_object(info.id)
243 .map_err(read)?
244 .try_into_commit()
245 .map_err(read)?;
246 out.push(commit_info(&commit)?);
247 }
248 Ok(out)
249}
250
251/// Load a commit and compute its file-level diff against its first parent
252/// (against the empty tree for a root commit).
253pub fn commit_detail(repo_path: &Path, rev: &str) -> Result<CommitDetail> {
254 let repo = gix::open(repo_path).map_err(read)?;
255 let commit = repo
256 .rev_parse_single(rev)
257 .map_err(read)?
258 .object()
259 .map_err(read)?
260 .peel_to_commit()
261 .map_err(read)?;
262
263 let info = commit_info(&commit)?;
264 let message = commit.message_raw().map_err(read)?.to_string();
265 let parent = commit.parent_ids().next().map(|p| p.detach());
266
267 let mut new_map = BTreeMap::new();
268 flatten_tree(&commit.tree().map_err(read)?, "", &mut new_map)?;
269 let mut old_map = BTreeMap::new();
270 if let Some(pid) = parent {
271 let parent_commit = repo
272 .find_object(pid)
273 .map_err(read)?
274 .try_into_commit()
275 .map_err(read)?;
276 flatten_tree(&parent_commit.tree().map_err(read)?, "", &mut old_map)?;
277 }
278
279 let blob = |oid: gix::ObjectId| -> Result<Vec<u8>> {
280 Ok(repo.find_object(oid).map_err(read)?.data.clone())
281 };
282
283 let mut paths: BTreeSet<&String> = new_map.keys().collect();
284 paths.extend(old_map.keys());
285 let mut changes = Vec::new();
286 for path in paths {
287 match (old_map.get(path), new_map.get(path)) {
288 (None, Some(n)) => changes.push(FileChange {
289 path: path.clone(),
290 kind: ChangeKind::Added,
291 old: None,
292 new: Some(blob(*n)?),
293 }),
294 (Some(o), None) => changes.push(FileChange {
295 path: path.clone(),
296 kind: ChangeKind::Deleted,
297 old: Some(blob(*o)?),
298 new: None,
299 }),
300 (Some(o), Some(n)) if o != n => changes.push(FileChange {
301 path: path.clone(),
302 kind: ChangeKind::Modified,
303 old: Some(blob(*o)?),
304 new: Some(blob(*n)?),
305 }),
306 _ => {}
307 }
308 }
309
310 Ok(CommitDetail {
311 info,
312 parent: parent.map(|p| p.to_string()),
313 message,
314 changes,
315 })
316}
317
318fn commit_info(commit: &gix::Commit) -> Result<CommitInfo> {
319 let id = commit.id.to_string();
320 let raw = commit.message_raw().map_err(read)?;
321 let summary = raw
322 .to_string()
323 .lines()
324 .next()
325 .unwrap_or_default()
326 .to_string();
327 let author = commit.author().map_err(read)?.name.to_string();
328 let time = commit.time().map_err(read)?.seconds;
329 Ok(CommitInfo {
330 short: id[..id.len().min(8)].to_string(),
331 id,
332 summary,
333 author,
334 time,
335 })
336}
337
338/// Recursively collect blob paths → object id from a tree.
339fn flatten_tree(
340 tree: &gix::Tree,
341 prefix: &str,
342 out: &mut BTreeMap<String, gix::ObjectId>,
343) -> Result<()> {
344 for e in tree.iter() {
345 let e = e.map_err(read)?;
346 let name = e.filename().to_string();
347 let path = if prefix.is_empty() {
348 name
349 } else {
350 format!("{prefix}/{name}")
351 };
352 if e.mode().is_tree() {
353 let sub = e.object().map_err(read)?.peel_to_tree().map_err(read)?;
354 flatten_tree(&sub, &path, out)?;
355 } else if e.mode().is_blob() {
356 out.insert(path, e.oid().to_owned());
357 }
358 }
359 Ok(())
360}
361
362/// A file materialized from a tree: path, executable bit, and content.
363pub struct TreeFile {
364 pub path: String,
365 pub executable: bool,
366 pub content: Vec<u8>,
367}
368
369/// Read every file in the tree at `rev`, recursively — for materializing a
370/// checkout (e.g. a CI workspace). Loads all contents into memory, which is
371/// fine for the modest repos this targets.
372pub fn read_tree_files(repo_path: &Path, rev: &str) -> Result<Vec<TreeFile>> {
373 let repo = gix::open(repo_path).map_err(read)?;
374 let tree = repo
375 .rev_parse_single(rev)
376 .map_err(read)?
377 .object()
378 .map_err(read)?
379 .peel_to_commit()
380 .map_err(read)?
381 .tree()
382 .map_err(read)?;
383 let mut out = Vec::new();
384 collect_files(&repo, &tree, "", &mut out)?;
385 Ok(out)
386}
387
388fn collect_files(
389 repo: &gix::Repository,
390 tree: &gix::Tree,
391 prefix: &str,
392 out: &mut Vec<TreeFile>,
393) -> Result<()> {
394 for e in tree.iter() {
395 let e = e.map_err(read)?;
396 let name = e.filename().to_string();
397 let path = if prefix.is_empty() {
398 name
399 } else {
400 format!("{prefix}/{name}")
401 };
402 let mode = e.mode();
403 if mode.is_tree() {
404 let sub = e.object().map_err(read)?.peel_to_tree().map_err(read)?;
405 collect_files(repo, &sub, &path, out)?;
406 } else if mode.is_blob() {
407 let content = repo
408 .find_object(e.oid().to_owned())
409 .map_err(read)?
410 .data
411 .clone();
412 out.push(TreeFile {
413 path,
414 executable: mode.is_executable(),
415 content,
416 });
417 }
418 }
419 Ok(())
420}
421
422/// Read the raw bytes of the blob at `path` for revision `rev`. Returns `None`
423/// if the path does not exist or is not a blob.
424pub fn read_blob(repo_path: &Path, rev: &str, path: &str) -> Result<Option<Vec<u8>>> {
425 Ok(read_blob_with_id(repo_path, rev, path)?.map(|(_, bytes)| bytes))
426}
427
428/// Like [`read_blob`], but also returns the blob's object id — a stable
429/// identity for the content, usable as a render-cache key.
430pub fn read_blob_with_id(
431 repo_path: &Path,
432 rev: &str,
433 path: &str,
434) -> Result<Option<(String, Vec<u8>)>> {
435 let repo = gix::open(repo_path).map_err(read)?;
436 let tree = repo
437 .rev_parse_single(rev)
438 .map_err(read)?
439 .object()
440 .map_err(read)?
441 .peel_to_commit()
442 .map_err(read)?
443 .tree()
444 .map_err(read)?;
445
446 match tree.lookup_entry_by_path(path).map_err(read)? {
447 Some(entry) if entry.mode().is_blob() => {
448 let oid = entry.oid().to_string();
449 let object = repo.find_object(entry.oid().to_owned()).map_err(read)?;
450 Ok(Some((oid, object.data.clone())))
451 }
452 _ => Ok(None),
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn git(dir: &Path, args: &[&str]) {
461 let out = std::process::Command::new("git")
462 .args(args)
463 .current_dir(dir)
464 .env("GIT_AUTHOR_NAME", "t")
465 .env("GIT_AUTHOR_EMAIL", "t@example.com")
466 .env("GIT_COMMITTER_NAME", "t")
467 .env("GIT_COMMITTER_EMAIL", "t@example.com")
468 .output()
469 .expect("run git");
470 assert!(out.status.success(), "git {args:?}: {:?}", out);
471 }
472
473 #[test]
474 fn latest_entry_commits_attributes_each_entry() {
475 let tmp = tempfile::tempdir().unwrap();
476 let dir = tmp.path();
477 git(dir, &["init", "-q", "-b", "main"]);
478
479 std::fs::write(dir.join("a.txt"), "one").unwrap();
480 std::fs::create_dir(dir.join("sub")).unwrap();
481 std::fs::write(dir.join("sub/b.txt"), "b").unwrap();
482 git(dir, &["add", "."]);
483 git(dir, &["commit", "-qm", "first"]);
484
485 std::fs::write(dir.join("a.txt"), "two").unwrap();
486 git(dir, &["commit", "-qam", "second: touch a.txt"]);
487
488 let latest = latest_entry_commits(dir, "main", "", 100).unwrap();
489 assert_eq!(latest["a.txt"].summary, "second: touch a.txt");
490 assert_eq!(
491 latest["sub"].summary, "first",
492 "untouched dir keeps its original commit"
493 );
494
495 // Within the subdirectory too.
496 let latest = latest_entry_commits(dir, "main", "sub", 100).unwrap();
497 assert_eq!(latest["b.txt"].summary, "first");
498
499 // A walk window of 1 only attributes what the head commit changed.
500 let latest = latest_entry_commits(dir, "main", "", 1).unwrap();
501 assert!(latest.contains_key("a.txt"));
502 assert!(!latest.contains_key("sub"));
503 }
504}