anvilsign in

collin/anvil

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