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