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::{
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/// Walk commit history starting at `rev`, newest first, up to `limit` commits.
268pub fn commit_log(repo_path: &Path, rev: &str, limit: usize) -> Result<Vec<CommitInfo>> {
269 let repo = gix::open(repo_path).map_err(read)?;
270 let start = repo
271 .rev_parse_single(rev)
272 .map_err(read)?
273 .object()
274 .map_err(read)?
275 .peel_to_commit()
276 .map_err(read)?
277 .id;
278
279 let mut out = Vec::new();
280 for info in repo.rev_walk(Some(start)).all().map_err(read)?.take(limit) {
281 let info = info.map_err(read)?;
282 let commit = repo
283 .find_object(info.id)
284 .map_err(read)?
285 .try_into_commit()
286 .map_err(read)?;
287 out.push(commit_info(&commit)?);
288 }
289 Ok(out)
290}
291
292/// Load a commit and compute its file-level diff against its first parent
293/// (against the empty tree for a root commit).
294pub fn commit_detail(repo_path: &Path, rev: &str) -> Result<CommitDetail> {
295 let repo = gix::open(repo_path).map_err(read)?;
296 let commit = repo
297 .rev_parse_single(rev)
298 .map_err(read)?
299 .object()
300 .map_err(read)?
301 .peel_to_commit()
302 .map_err(read)?;
303
304 let info = commit_info(&commit)?;
305 let message = commit.message_raw().map_err(read)?.to_string();
306 let parent = commit.parent_ids().next().map(|p| p.detach());
307
308 let mut new_map = BTreeMap::new();
309 flatten_tree(&commit.tree().map_err(read)?, "", &mut new_map)?;
310 let mut old_map = BTreeMap::new();
311 if let Some(pid) = parent {
312 let parent_commit = repo
313 .find_object(pid)
314 .map_err(read)?
315 .try_into_commit()
316 .map_err(read)?;
317 flatten_tree(&parent_commit.tree().map_err(read)?, "", &mut old_map)?;
318 }
319
320 let blob = |oid: gix::ObjectId| -> Result<Vec<u8>> {
321 Ok(repo.find_object(oid).map_err(read)?.data.clone())
322 };
323
324 let mut paths: BTreeSet<&String> = new_map.keys().collect();
325 paths.extend(old_map.keys());
326 let mut changes = Vec::new();
327 for path in paths {
328 match (old_map.get(path), new_map.get(path)) {
329 (None, Some(n)) => changes.push(FileChange {
330 path: path.clone(),
331 kind: ChangeKind::Added,
332 old: None,
333 new: Some(blob(*n)?),
334 }),
335 (Some(o), None) => changes.push(FileChange {
336 path: path.clone(),
337 kind: ChangeKind::Deleted,
338 old: Some(blob(*o)?),
339 new: None,
340 }),
341 (Some(o), Some(n)) if o != n => changes.push(FileChange {
342 path: path.clone(),
343 kind: ChangeKind::Modified,
344 old: Some(blob(*o)?),
345 new: Some(blob(*n)?),
346 }),
347 _ => {}
348 }
349 }
350
351 Ok(CommitDetail {
352 info,
353 parent: parent.map(|p| p.to_string()),
354 message,
355 changes,
356 })
357}
358
359fn commit_info(commit: &gix::Commit) -> Result<CommitInfo> {
360 let id = commit.id.to_string();
361 let raw = commit.message_raw().map_err(read)?;
362 let summary = raw
363 .to_string()
364 .lines()
365 .next()
366 .unwrap_or_default()
367 .to_string();
368 let author = commit.author().map_err(read)?.name.to_string();
369 let time = commit.time().map_err(read)?.seconds;
370 Ok(CommitInfo {
371 short: id[..id.len().min(8)].to_string(),
372 id,
373 summary,
374 author,
375 time,
376 })
377}
378
379/// Recursively collect blob paths → object id from a tree.
380fn flatten_tree(
381 tree: &gix::Tree,
382 prefix: &str,
383 out: &mut BTreeMap<String, gix::ObjectId>,
384) -> Result<()> {
385 for e in tree.iter() {
386 let e = e.map_err(read)?;
387 let name = e.filename().to_string();
388 let path = if prefix.is_empty() {
389 name
390 } else {
391 format!("{prefix}/{name}")
392 };
393 if e.mode().is_tree() {
394 let sub = e.object().map_err(read)?.peel_to_tree().map_err(read)?;
395 flatten_tree(&sub, &path, out)?;
396 } else if e.mode().is_blob() {
397 out.insert(path, e.oid().to_owned());
398 }
399 }
400 Ok(())
401}
402
403/// A file materialized from a tree: path, executable bit, and content.
404pub struct TreeFile {
405 pub path: String,
406 pub executable: bool,
407 pub content: Vec<u8>,
408}
409
410/// Read every file in the tree at `rev`, recursively — for materializing a
411/// checkout (e.g. a CI workspace). Loads all contents into memory, which is
412/// fine for the modest repos this targets.
413pub fn read_tree_files(repo_path: &Path, rev: &str) -> Result<Vec<TreeFile>> {
414 let repo = gix::open(repo_path).map_err(read)?;
415 let tree = repo
416 .rev_parse_single(rev)
417 .map_err(read)?
418 .object()
419 .map_err(read)?
420 .peel_to_commit()
421 .map_err(read)?
422 .tree()
423 .map_err(read)?;
424 let mut out = Vec::new();
425 collect_files(&repo, &tree, "", &mut out)?;
426 Ok(out)
427}
428
429fn collect_files(
430 repo: &gix::Repository,
431 tree: &gix::Tree,
432 prefix: &str,
433 out: &mut Vec<TreeFile>,
434) -> Result<()> {
435 for e in tree.iter() {
436 let e = e.map_err(read)?;
437 let name = e.filename().to_string();
438 let path = if prefix.is_empty() {
439 name
440 } else {
441 format!("{prefix}/{name}")
442 };
443 let mode = e.mode();
444 if mode.is_tree() {
445 let sub = e.object().map_err(read)?.peel_to_tree().map_err(read)?;
446 collect_files(repo, &sub, &path, out)?;
447 } else if mode.is_blob() {
448 let content = repo
449 .find_object(e.oid().to_owned())
450 .map_err(read)?
451 .data
452 .clone();
453 out.push(TreeFile {
454 path,
455 executable: mode.is_executable(),
456 content,
457 });
458 }
459 }
460 Ok(())
461}
462
463/// Read the raw bytes of the blob at `path` for revision `rev`. Returns `None`
464/// if the path does not exist or is not a blob.
465pub fn read_blob(repo_path: &Path, rev: &str, path: &str) -> Result<Option<Vec<u8>>> {
466 Ok(read_blob_with_id(repo_path, rev, path)?.map(|(_, bytes)| bytes))
467}
468
469/// Like [`read_blob`], but also returns the blob's object id — a stable
470/// identity for the content, usable as a render-cache key.
471pub fn read_blob_with_id(
472 repo_path: &Path,
473 rev: &str,
474 path: &str,
475) -> Result<Option<(String, Vec<u8>)>> {
476 let repo = gix::open(repo_path).map_err(read)?;
477 let tree = repo
478 .rev_parse_single(rev)
479 .map_err(read)?
480 .object()
481 .map_err(read)?
482 .peel_to_commit()
483 .map_err(read)?
484 .tree()
485 .map_err(read)?;
486
487 match tree.lookup_entry_by_path(path).map_err(read)? {
488 Some(entry) if entry.mode().is_blob() => {
489 let oid = entry.oid().to_string();
490 let object = repo.find_object(entry.oid().to_owned()).map_err(read)?;
491 Ok(Some((oid, object.data.clone())))
492 }
493 _ => Ok(None),
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 fn git(dir: &Path, args: &[&str]) {
502 let out = std::process::Command::new("git")
503 .args(args)
504 .current_dir(dir)
505 .env("GIT_AUTHOR_NAME", "t")
506 .env("GIT_AUTHOR_EMAIL", "t@example.com")
507 .env("GIT_COMMITTER_NAME", "t")
508 .env("GIT_COMMITTER_EMAIL", "t@example.com")
509 .output()
510 .expect("run git");
511 assert!(out.status.success(), "git {args:?}: {:?}", out);
512 }
513
514 #[test]
515 fn latest_entry_commits_attributes_each_entry() {
516 let tmp = tempfile::tempdir().unwrap();
517 let dir = tmp.path();
518 git(dir, &["init", "-q", "-b", "main"]);
519
520 std::fs::write(dir.join("a.txt"), "one").unwrap();
521 std::fs::create_dir(dir.join("sub")).unwrap();
522 std::fs::write(dir.join("sub/b.txt"), "b").unwrap();
523 git(dir, &["add", "."]);
524 git(dir, &["commit", "-qm", "first"]);
525
526 std::fs::write(dir.join("a.txt"), "two").unwrap();
527 git(dir, &["commit", "-qam", "second: touch a.txt"]);
528
529 let latest = latest_entry_commits(dir, "main", "", 100).unwrap();
530 assert_eq!(latest["a.txt"].summary, "second: touch a.txt");
531 assert_eq!(
532 latest["sub"].summary, "first",
533 "untouched dir keeps its original commit"
534 );
535
536 // Within the subdirectory too.
537 let latest = latest_entry_commits(dir, "main", "sub", 100).unwrap();
538 assert_eq!(latest["b.txt"].summary, "first");
539
540 // A walk window of 1 only attributes what the head commit changed.
541 let latest = latest_entry_commits(dir, "main", "", 1).unwrap();
542 assert!(latest.contains_key("a.txt"));
543 assert!(!latest.contains_key("sub"));
544 }
545
546 #[test]
547 fn overview_falls_back_when_head_is_unborn() {
548 let tmp = tempfile::tempdir().unwrap();
549 let dir = tmp.path();
550 // HEAD points at `main`, but the only branch pushed is `master`.
551 git(dir, &["init", "-q", "-b", "main"]);
552 git(dir, &["checkout", "-qb", "master"]);
553 std::fs::write(dir.join("a.txt"), "one").unwrap();
554 git(dir, &["add", "."]);
555 git(dir, &["commit", "-qm", "first"]);
556 git(dir, &["symbolic-ref", "HEAD", "refs/heads/main"]);
557
558 let ov = overview(dir).unwrap();
559 assert!(!ov.is_empty, "repo with a branch is not empty");
560 assert_eq!(ov.default_branch.as_deref(), Some("master"));
561 }
562}