//! Everything that reads from a git repository lives here. //! //! Functions take a `git2::Repository` and return plain Rust structs, so the //! HTML code never has to know about libgit2. `git2::Repository` can't be //! shared between threads, so the web handlers open a fresh one per request //! (that's cheap: it just reads a few files). use git2::{DiffFindOptions, DiffOptions, ObjectType, Oid, Repository, Sort}; use std::path::Path; use crate::util; /// Errors from this module. `NotFound` becomes a 404 page, `Git` a 500. #[derive(Debug)] pub enum Error { NotFound(String), Git(git2::Error), } impl From for Error { fn from(e: git2::Error) -> Self { if e.code() == git2::ErrorCode::NotFound { Error::NotFound(e.message().to_string()) } else { Error::Git(e) } } } pub type Result = std::result::Result; /// Open a bare (`foo.git`) or normal (`foo/.git`) repository at exactly this /// path. `NO_SEARCH` stops libgit2 from walking up into parent directories. pub fn open(path: &Path) -> Result { let flags = git2::RepositoryOpenFlags::NO_SEARCH; Ok(Repository::open_ext(path, flags, std::iter::empty::<&str>())?) } #[derive(Debug, Clone)] pub struct CommitInfo { pub id: String, pub short_id: String, pub summary: String, pub message: String, pub author_name: String, pub author_email: String, pub time: i64, pub offset_minutes: i32, pub parents: Vec, } impl CommitInfo { fn from_commit(c: &git2::Commit) -> CommitInfo { let id = c.id().to_string(); let author = c.author(); CommitInfo { short_id: id[..10.min(id.len())].to_string(), id, summary: String::from_utf8_lossy(c.summary_bytes().unwrap_or(b"")).into_owned(), message: String::from_utf8_lossy(c.message_bytes()).into_owned(), author_name: String::from_utf8_lossy(author.name_bytes()).into_owned(), author_email: String::from_utf8_lossy(author.email_bytes()).into_owned(), time: c.time().seconds(), offset_minutes: c.time().offset_minutes(), parents: c.parent_ids().map(|p| p.to_string()).collect(), } } pub fn age(&self) -> String { util::age(util::now_unix() - self.time) } pub fn date(&self) -> String { util::format_time(self.time, self.offset_minutes) } } /// A branch or tag and the commit it points to. #[derive(Debug, Clone)] pub struct RefInfo { pub name: String, pub commit: CommitInfo, } /// Turn a user-supplied ref (`?h=...`) into a commit id. /// /// `None` means "the default branch" (HEAD). We only accept branch names, tag /// names and hex commit ids, never general git revision syntax. pub fn resolve(repo: &Repository, rev: Option<&str>) -> Result { let Some(rev) = rev.filter(|r| !r.is_empty()) else { return default_commit(repo); }; if !util::is_safe_ref(rev) { return Err(Error::NotFound(format!("bad ref {rev:?}"))); } for full in [format!("refs/heads/{rev}"), format!("refs/tags/{rev}")] { if let Ok(r) = repo.find_reference(&full) { return Ok(r.peel_to_commit()?.id()); } } let is_hex = rev.len() >= 4 && rev.len() <= 40 && rev.chars().all(|c| c.is_ascii_hexdigit()); if is_hex { if let Ok(obj) = repo.revparse_single(rev) { if let Ok(c) = obj.peel_to_commit() { return Ok(c.id()); } } } Err(Error::NotFound(format!("no such ref {rev:?}"))) } /// HEAD's commit, or (if HEAD points at a branch that doesn't exist yet) the /// first branch we can find. fn default_commit(repo: &Repository) -> Result { if let Ok(head) = repo.head() { if let Ok(c) = head.peel_to_commit() { return Ok(c.id()); } } for b in repo.branches(Some(git2::BranchType::Local))? { let (b, _) = b?; if let Ok(c) = b.get().peel_to_commit() { return Ok(c.id()); } } Err(Error::NotFound("repository has no commits".into())) } pub fn commit(repo: &Repository, id: Oid) -> Result { Ok(CommitInfo::from_commit(&repo.find_commit(id)?)) } /// Local branches, newest commit first. pub fn branches(repo: &Repository) -> Result> { let mut out = Vec::new(); for b in repo.branches(Some(git2::BranchType::Local))? { let (b, _) = b?; let Ok(Some(name)) = b.name().map(|n| n.map(str::to_string)) else { continue }; if let Ok(c) = b.get().peel_to_commit() { out.push(RefInfo { name, commit: CommitInfo::from_commit(&c) }); } } out.sort_by(|a, b| b.commit.time.cmp(&a.commit.time).then(a.name.cmp(&b.name))); Ok(out) } /// Tags (lightweight and annotated), newest commit first. pub fn tags(repo: &Repository) -> Result> { let mut out = Vec::new(); for r in repo.references_glob("refs/tags/*")? { let r = r?; let Ok(name) = r.shorthand().map(str::to_string) else { continue }; if let Ok(c) = r.peel_to_commit() { out.push(RefInfo { name, commit: CommitInfo::from_commit(&c) }); } } out.sort_by(|a, b| b.commit.time.cmp(&a.commit.time).then(a.name.cmp(&b.name))); Ok(out) } /// Time of the newest commit on any branch (for the "idle" column). pub fn last_change(repo: &Repository) -> Option { let from_branches = branches(repo).ok()?.into_iter().map(|b| b.commit.time).max(); let from_head = repo.head().ok().and_then(|h| h.peel_to_commit().ok()).map(|c| c.time().seconds()); from_branches.max(from_head) } /// Commits reachable from `start`, newest first. Skips `skip` commits and /// returns at most `limit`, plus a flag saying whether more exist. pub fn log(repo: &Repository, start: Oid, skip: usize, limit: usize) -> Result<(Vec, bool)> { let mut walk = repo.revwalk()?; // Newest first, but never show a parent before its child (like `git log`). walk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?; walk.push(start)?; let mut out = Vec::new(); let mut more = false; for id in walk.skip(skip) { if out.len() == limit { more = true; break; } out.push(CommitInfo::from_commit(&repo.find_commit(id?)?)); } Ok((out, more)) } // ---------------------------------------------------------------- trees ---- #[derive(Debug, Clone, PartialEq, Eq)] pub enum EntryKind { Dir, File, Symlink, Submodule, } #[derive(Debug, Clone)] pub struct TreeEntry { pub name: String, /// Full path from the repo root, e.g. "src/main.rs". pub path: String, pub kind: EntryKind, pub mode: i32, pub size: Option, } /// What lives at a path: a directory listing or a file's bytes. pub enum PathContent { Dir(Vec), File { data: Vec, mode: i32 }, } /// Look up `path` (already cleaned by `util::clean_repo_path`) in a commit. pub fn path_content(repo: &Repository, commit: Oid, path: &str) -> Result { let root = repo.find_commit(commit)?.tree()?; if path.is_empty() { return Ok(PathContent::Dir(list_tree(repo, &root, "")?)); } let entry = root.get_path(Path::new(path)).map_err(|_| Error::NotFound(format!("no such path {path:?}")))?; match entry.kind() { Some(ObjectType::Tree) => { let tree = repo.find_tree(entry.id())?; Ok(PathContent::Dir(list_tree(repo, &tree, path)?)) } Some(ObjectType::Blob) => { let blob = repo.find_blob(entry.id())?; Ok(PathContent::File { data: blob.content().to_vec(), mode: entry.filemode() }) } _ => Err(Error::NotFound(format!("{path:?} is a submodule"))), } } fn list_tree(repo: &Repository, tree: &git2::Tree, prefix: &str) -> Result> { let odb = repo.odb()?; let mut out: Vec = tree .iter() .map(|e| { let name = String::from_utf8_lossy(e.name_bytes()).into_owned(); let kind = match (e.kind(), e.filemode()) { (Some(ObjectType::Tree), _) => EntryKind::Dir, (Some(ObjectType::Commit), _) => EntryKind::Submodule, (_, 0o120000) => EntryKind::Symlink, _ => EntryKind::File, }; let size = if kind == EntryKind::File { odb.read_header(e.id()).ok().map(|(size, _)| size) } else { None }; let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") }; TreeEntry { name, path, kind, mode: e.filemode(), size } }) .collect(); // Directories first, then files, each alphabetically (like most git web UIs). out.sort_by(|a, b| (a.kind != EntryKind::Dir).cmp(&(b.kind != EntryKind::Dir)).then(a.name.cmp(&b.name))); Ok(out) } /// Find a README at the root of the commit's tree. Returns (file name, text). pub fn readme(repo: &Repository, commit: Oid) -> Option<(String, String)> { let tree = repo.find_commit(commit).ok()?.tree().ok()?; for name in ["README.md", "readme.md", "README.markdown", "README", "README.txt"] { if let Some(entry) = tree.get_name(name) { if let Ok(blob) = repo.find_blob(entry.id()) { return Some((name.to_string(), String::from_utf8_lossy(blob.content()).into_owned())); } } } None } /// Heuristic used by git itself: a NUL byte in the first 8000 bytes means binary. pub fn is_binary(data: &[u8]) -> bool { data.iter().take(8000).any(|&b| b == 0) } // ---------------------------------------------------------------- diffs ---- #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineKind { Context, Add, Remove, HunkHeader, } #[derive(Debug, Clone)] pub struct DiffLine { pub kind: LineKind, pub text: String, } #[derive(Debug, Clone)] pub struct FileDiff { pub old_path: String, pub new_path: String, /// 'A'dded, 'D'eleted, 'M'odified, 'R'enamed, ... pub status: char, pub additions: usize, pub deletions: usize, pub binary: bool, pub lines: Vec, } #[derive(Debug, Clone, Default)] pub struct CommitDiff { pub files: Vec, /// True when we stopped early because the diff was huge. pub truncated: bool, } /// Lines of diff we'll render before giving up (keeps huge commits fast). const MAX_DIFF_LINES: usize = 20_000; /// Diff a commit against its first parent (or the empty tree for a root commit). pub fn commit_diff(repo: &Repository, id: Oid) -> Result { let commit = repo.find_commit(id)?; let new_tree = commit.tree()?; let old_tree = match commit.parent(0) { Ok(p) => Some(p.tree()?), Err(_) => None, }; let mut opts = DiffOptions::new(); opts.context_lines(3); let mut diff = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))?; diff.find_similar(Some(DiffFindOptions::new().renames(true)))?; let mut out = CommitDiff::default(); let mut total = 0usize; for idx in 0..diff.deltas().len() { let delta = diff.get_delta(idx).expect("index in range"); let path_of = |f: git2::DiffFile| f.path().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default(); let mut file = FileDiff { old_path: path_of(delta.old_file()), new_path: path_of(delta.new_file()), status: status_char(delta.status()), additions: 0, deletions: 0, binary: delta.flags().is_binary(), lines: Vec::new(), }; if let Some(patch) = git2::Patch::from_diff(&diff, idx)? { if patch.delta().flags().is_binary() { file.binary = true; } let (_, adds, dels) = patch.line_stats()?; file.additions = adds; file.deletions = dels; for h in 0..patch.num_hunks() { if total >= MAX_DIFF_LINES { out.truncated = true; break; } let (hunk, n_lines) = patch.hunk(h)?; file.lines.push(DiffLine { kind: LineKind::HunkHeader, text: bytes_to_line(hunk.header()) }); for l in 0..n_lines { let line = patch.line_in_hunk(h, l)?; let kind = match line.origin() { '+' => LineKind::Add, '-' => LineKind::Remove, ' ' => LineKind::Context, _ => continue, // "\ No newline at end of file" markers etc. }; file.lines.push(DiffLine { kind, text: bytes_to_line(line.content()) }); } total += n_lines; } } out.files.push(file); } Ok(out) } /// One-letter status like `git log --name-status` prints. fn status_char(d: git2::Delta) -> char { use git2::Delta::*; match d { Added => 'A', Deleted => 'D', Modified => 'M', Renamed => 'R', Copied => 'C', Typechange => 'T', _ => '?', } } fn bytes_to_line(b: &[u8]) -> String { let s = String::from_utf8_lossy(b); s.trim_end_matches(['\n', '\r']).to_string() }