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