// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! An open repository and its read operations. //! //! Every method here is synchronous and blocking; see the crate docs for the //! `spawn_blocking` requirement. The public surface speaks only in the plain //! [`crate::types`] vocabulary — `git2` handles never escape this module. use std::collections::HashMap; use std::path::Path; use git2::{ BranchType, Commit, Delta, DiffFindOptions, DiffLineType, DiffOptions, Patch, Repository, Signature, Sort, Tree, TreeEntry as GitTreeEntry, }; use crate::GitError; use crate::types::{ Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind, FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo, TreeAnnotations, TreeEntry, }; /// Filemode bits git uses to distinguish tree-entry kinds. const MODE_DIR: i32 = 0o040_000; const MODE_SYMLINK: i32 = 0o120_000; const MODE_SUBMODULE: i32 = 0o160_000; /// Bytes sniffed from the head of a blob when deciding whether it is binary. const BINARY_SNIFF_BYTES: usize = 8_000; /// An open bare repository. pub struct Repo { inner: Repository, } impl Repo { /// Open the repository at `path` (expected to be a bare `.git` directory). /// /// # Errors /// /// Returns [`GitError::Libgit2`] if the path is not a repository. pub fn open_path(path: &Path) -> Result { Ok(Self { inner: Repository::open_bare(path)?, }) } /// The branch name `HEAD` points at, even when that branch is unborn (a /// freshly created repo with no commits). Returns `None` if `HEAD` is /// detached. /// /// # Errors /// /// Returns [`GitError::Libgit2`] if `HEAD` cannot be read. pub fn head_branch(&self) -> Result, GitError> { let head = self.inner.find_reference("HEAD")?; Ok(head .symbolic_target() .and_then(|t| t.strip_prefix("refs/heads/")) .map(str::to_string)) } /// Resolve a revision string — a branch or tag name, `HEAD`, or a raw commit /// id — to the commit it names. /// /// Resolution order is branch, then tag, then a general parse (which covers /// `HEAD`, full and abbreviated oids, and revspecs). A branch and a tag of the /// same name resolve to the branch, matching git's own precedence for a bare /// short name in this context. /// /// # Errors /// /// Returns [`GitError::RevNotFound`] if nothing matches. pub fn resolve_ref(&self, rev: &str) -> Result { if let Ok(branch) = self.inner.find_branch(rev, BranchType::Local) { let commit = branch.get().peel_to_commit()?; return Ok(Resolved { oid: oid_of(commit.id()), kind: RefKind::Branch, name: rev.to_string(), }); } if let Ok(reference) = self.inner.find_reference(&format!("refs/tags/{rev}")) { let commit = reference.peel_to_commit()?; return Ok(Resolved { oid: oid_of(commit.id()), kind: RefKind::Tag, name: rev.to_string(), }); } let object = self .inner .revparse_single(rev) .map_err(|_| GitError::RevNotFound(rev.to_string()))?; let commit = object .peel_to_commit() .map_err(|_| GitError::RevNotFound(rev.to_string()))?; Ok(Resolved { oid: oid_of(commit.id()), kind: if rev == "HEAD" { RefKind::Head } else { RefKind::Commit }, name: rev.to_string(), }) } /// List the entries of the tree at `path` within revision `rev`. An empty /// `path` lists the root tree. Directories sort before files, each /// alphabetically. /// /// # Errors /// /// Returns [`GitError::PathNotFound`] if `path` is absent or names a file. pub fn tree_entries(&self, rev: &Resolved, path: &Path) -> Result, GitError> { let commit = self.commit_at(&rev.oid)?; let root = commit.tree()?; let tree = if path.components().next().is_none() { root } else { let entry = root .get_path(path) .map_err(|_| GitError::PathNotFound(path.display().to_string()))?; entry .to_object(&self.inner)? .into_tree() .map_err(|_| GitError::PathNotFound(path.display().to_string()))? }; let mut entries: Vec = tree .iter() .map(|entry| self.map_tree_entry(&entry)) .collect(); entries.sort_by(|a, b| { let a_dir = a.kind == EntryKind::Directory; let b_dir = b.kind == EntryKind::Directory; b_dir.cmp(&a_dir).then_with(|| a.name.cmp(&b.name)) }); Ok(entries) } /// Annotate the directory `path` at `rev`: which file entries are git-LFS /// pointers (small blobs whose content is a pointer), and the whole-repo /// submodule→URL map from `.gitmodules`. Best-effort — a missing or malformed /// `.gitmodules`, or an unreadable blob, simply yields fewer annotations. /// /// # Errors /// /// Returns [`GitError`] only if the revision's commit or root tree is missing. pub fn tree_annotations( &self, rev: &Resolved, path: &Path, ) -> Result { /// Pointer files are tiny; skip reading anything larger. const POINTER_MAX: usize = 1024; let commit = self.commit_at(&rev.oid)?; let root = commit.tree()?; let submodules = root .get_path(Path::new(".gitmodules")) .ok() .and_then(|e| e.to_object(&self.inner).ok()) .and_then(|o| o.into_blob().ok()) .map(|b| parse_gitmodules(b.content())) .unwrap_or_default(); // `.gitattributes` filter=lfs is the authoritative "tracked by LFS" signal // (a file can be LFS-tracked even if a given blob was committed inline). let attrs = self.attributes_set(&rev.oid).ok(); let dir_prefix = path_string(path); let tree = if path.components().next().is_none() { Some(root) } else { root.get_path(path) .ok() .and_then(|e| e.to_object(&self.inner).ok()) .and_then(|o| o.into_tree().ok()) }; let mut lfs = std::collections::HashSet::new(); if let Some(tree) = tree { for entry in &tree { let Some(name) = entry.name() else { continue }; if entry.kind() != Some(git2::ObjectType::Blob) { continue; } let full = if dir_prefix.is_empty() { name.to_string() } else { format!("{dir_prefix}/{name}") }; let tracked = attrs .as_ref() .is_some_and(|set| set.attributes(&full).is_lfs()); let pointer = entry .to_object(&self.inner) .ok() .and_then(|o| { o.as_blob() .map(|b| b.size() < POINTER_MAX && is_lfs_pointer(b.content())) }) .unwrap_or(false); if tracked || pointer { lfs.insert(name.to_string()); } } } Ok(TreeAnnotations { lfs, submodules }) } /// Read the file at `path` within revision `rev`. /// /// # Errors /// /// Returns [`GitError::PathNotFound`] if `path` is absent, or /// [`GitError::NotAFile`] if it names a directory or submodule. pub fn blob(&self, rev: &Resolved, path: &Path) -> Result { let commit = self.commit_at(&rev.oid)?; let entry = commit .tree()? .get_path(path) .map_err(|_| GitError::PathNotFound(path.display().to_string()))?; let object = entry.to_object(&self.inner)?; let blob = object .into_blob() .map_err(|_| GitError::NotAFile(path.display().to_string()))?; let content = blob.content().to_vec(); let size = content.len() as u64; Ok(Blob { is_binary: looks_binary(&content), size, content, }) } /// Create a lightweight tag `name` pointing at `rev`, failing if it already /// exists (releases must not silently move a tag). For cutting a release from /// a branch/commit. /// /// # Errors /// /// Returns [`GitError::Libgit2`] if the tag exists or the ref cannot be written. pub fn create_tag(&self, name: &str, rev: &Resolved) -> Result<(), GitError> { let oid = git2::Oid::from_str(&rev.oid.to_string())?; self.inner.reference( &format!("refs/tags/{name}"), oid, false, "fabrica release tag", )?; Ok(()) } /// The local branches, each with its position (ahead/behind) relative to the /// default branch and its tip commit. /// /// # Errors /// /// Returns [`GitError::Libgit2`] on a libgit2 failure. pub fn branches(&self) -> Result, GitError> { let default = self.head_branch()?; let default_oid = default .as_deref() .and_then(|name| self.inner.find_branch(name, BranchType::Local).ok()) .and_then(|branch| branch.get().peel_to_commit().ok()) .map(|commit| commit.id()); let mut out = Vec::new(); for branch in self.inner.branches(Some(BranchType::Local))? { let (branch, _) = branch?; let Some(name) = branch.name()?.map(str::to_string) else { continue; }; let tip = branch.get().peel_to_commit()?; let (ahead, behind) = match default_oid { Some(base) if base != tip.id() => self.inner.graph_ahead_behind(tip.id(), base)?, _ => (0, 0), }; out.push(BranchInfo { is_default: default.as_deref() == Some(name.as_str()), name, oid: oid_of(tip.id()), ahead, behind, tip: commit_summary(&tip), }); } out.sort_by(|a, b| { b.is_default .cmp(&a.is_default) .then_with(|| a.name.cmp(&b.name)) }); Ok(out) } /// The local branch names, default first then alphabetical. Cheaper than /// [`Self::branches`] — it skips the ahead/behind graph walk, for the branch /// switcher. /// /// # Errors /// /// Returns [`GitError::Libgit2`] on a libgit2 failure. pub fn branch_names(&self) -> Result, GitError> { let default = self.head_branch()?; let mut names: Vec = Vec::new(); for branch in self.inner.branches(Some(BranchType::Local))? { let (branch, _) = branch?; if let Some(name) = branch.name()?.map(str::to_string) { names.push(name); } } names.sort_by(|a, b| { let da = default.as_deref() == Some(a.as_str()); let db = default.as_deref() == Some(b.as_str()); db.cmp(&da).then_with(|| a.cmp(b)) }); Ok(names) } /// The tags, peeled to the commit each ultimately points at, annotated tags /// carrying their message and tagger. /// /// # Errors /// /// Returns [`GitError::Libgit2`] on a libgit2 failure. pub fn tags(&self) -> Result, GitError> { let mut out = Vec::new(); for name in self.inner.tag_names(None)?.iter().flatten() { let object = self.inner.revparse_single(&format!("refs/tags/{name}"))?; let commit = object.peel_to_commit()?; let (annotated, message, tagger) = match object.as_tag() { Some(tag) => ( true, tag.message().map(str::to_string), tag.tagger().as_ref().map(person), ), None => (false, None, None), }; out.push(TagInfo { name: name.to_string(), oid: oid_of(commit.id()), annotated, message, tagger, }); } out.sort_by(|a, b| a.name.cmp(&b.name)); Ok(out) } /// Walk commits reachable from `rev`, newest first, returning one page. /// /// When `path` is given, only commits that changed that path are returned /// (compared against the first parent — a pragmatic history simplification, /// not full merge-aware path following). Paging is applied *after* filtering. /// /// # Errors /// /// Returns [`GitError::Libgit2`] on a libgit2 failure. pub fn commits( &self, rev: &Resolved, path: Option<&Path>, page: Page, ) -> Result, GitError> { let start = git2::Oid::from_str(rev.oid.as_str())?; let mut walk = self.inner.revwalk()?; walk.set_sorting(Sort::TIME)?; walk.push(start)?; let mut out = Vec::with_capacity(page.limit); let mut skipped = 0usize; for oid in walk { let commit = self.inner.find_commit(oid?)?; if let Some(path) = path { if !commit_touches(&commit, path)? { continue; } } if skipped < page.offset { skipped += 1; continue; } out.push(commit_summary(&commit)); if out.len() >= page.limit { break; } } Ok(out) } /// The best common ancestor of two commits (the merge base), or `None` if they /// have unrelated histories. /// /// # Errors /// /// Returns [`GitError::Libgit2`] on a lookup failure. pub fn merge_base(&self, a: &Oid, b: &Oid) -> Result, GitError> { let a = git2::Oid::from_str(a.as_str())?; let b = git2::Oid::from_str(b.as_str())?; match self.inner.merge_base(a, b) { Ok(oid) => Ok(Some(oid_of(oid))), Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), Err(e) => Err(e.into()), } } /// Recent commits across **all** local branches, deduplicated, each paired /// with the first branch it was found on, for the dashboard activity feed and /// heatmap. `per_branch` bounds the walk per branch. Order is per-branch /// (callers sort by time). /// /// # Errors /// /// Returns [`GitError::Libgit2`] on a revwalk failure. pub fn commits_all_branches( &self, per_branch: usize, ) -> Result, GitError> { let branches = self.branch_names()?; let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let mut out: Vec<(CommitSummary, String)> = Vec::new(); for branch in &branches { let Ok(rev) = self.resolve_ref(branch) else { continue; }; let start = git2::Oid::from_str(rev.oid.as_str())?; let mut walk = self.inner.revwalk()?; walk.set_sorting(Sort::TIME)?; walk.push(start)?; for oid in walk.take(per_branch) { let oid = oid?; if !seen.insert(oid.to_string()) { continue; } let commit = self.inner.find_commit(oid)?; out.push((commit_summary(&commit), branch.clone())); } } Ok(out) } /// The commits reachable from `head` but not `base`, newest first — the commits /// a pull request would contribute. Capped at `limit`. /// /// # Errors /// /// Returns [`GitError::Libgit2`] on a revwalk failure. pub fn commits_between( &self, base: &Oid, head: &Oid, limit: usize, ) -> Result, GitError> { let base = git2::Oid::from_str(base.as_str())?; let head = git2::Oid::from_str(head.as_str())?; let mut walk = self.inner.revwalk()?; walk.set_sorting(Sort::TIME)?; walk.push(head)?; walk.hide(base)?; let mut out = Vec::with_capacity(limit.min(64)); for oid in walk { let commit = self.inner.find_commit(oid?)?; out.push(commit_summary(&commit)); if out.len() >= limit { break; } } Ok(out) } /// Fetch a single commit in full. /// /// # Errors /// /// Returns [`GitError::RevNotFound`] if no such commit exists. pub fn commit(&self, oid: &Oid) -> Result { let commit = self.commit_at(oid)?; Ok(CommitDetail { oid: oid_of(commit.id()), message: commit.message().unwrap_or("").to_string(), author: person(&commit.author()), committer: person(&commit.committer()), parents: commit.parent_ids().map(oid_of).collect(), tree: oid_of(commit.tree_id()), }) } /// Diff two revisions file by file. `base` of `None` diffs against the empty /// tree, so a root commit (or an explicit "show everything") renders as all /// additions; callers wanting a commit's own diff pass its parent as `base`. /// /// Rename detection is enabled. Binary files carry no hunks. /// /// # Errors /// /// Returns [`GitError::RevNotFound`] if a revision is unknown, or /// [`GitError::Libgit2`] on a diff failure. pub fn diff( &self, base: Option<&Oid>, head: &Oid, opts: DiffOpts, ) -> Result { let head_tree = self.commit_at(head)?.tree()?; let base_tree = match base { Some(oid) => Some(self.commit_at(oid)?.tree()?), None => None, }; let mut diff_opts = DiffOptions::new(); diff_opts.context_lines(opts.context_lines); let mut diff = self.inner.diff_tree_to_tree( base_tree.as_ref(), Some(&head_tree), Some(&mut diff_opts), )?; diff.find_similar(Some(DiffFindOptions::new().renames(true)))?; let mut files = Vec::new(); for idx in 0..diff.deltas().len() { let Some(delta) = diff.get_delta(idx) else { continue; }; let patch = Patch::from_diff(&diff, idx)?; let is_binary = patch.is_none() || delta.flags().is_binary(); let hunks = match &patch { Some(patch) if !is_binary => extract_hunks(patch)?, _ => Vec::new(), }; files.push(FileDiff { old_path: delta.old_file().path().map(path_string), new_path: delta.new_file().path().map(path_string), status: map_status(delta.status()), is_binary, hunks, }); } Ok(FileDiffs { files }) } /// Return the post-image lines of `file` at revision `head` for the 1-based, /// inclusive range `[from, to]`, as context lines. This powers "expand /// context" between hunks; the range is clamped to the file's length. /// /// # Errors /// /// Returns [`GitError::PathNotFound`]/[`GitError::NotAFile`] if `file` is not a /// readable blob at `head`. pub fn diff_context( &self, head: &Oid, file: &Path, from: u32, to: u32, ) -> Result, GitError> { let commit = self.commit_at(head)?; let entry = commit .tree()? .get_path(file) .map_err(|_| GitError::PathNotFound(file.display().to_string()))?; let blob = entry .to_object(&self.inner)? .into_blob() .map_err(|_| GitError::NotAFile(file.display().to_string()))?; let text = String::from_utf8_lossy(blob.content()); let lines: Vec<&str> = text.split_inclusive('\n').collect(); let start = from.saturating_sub(1) as usize; let end = (to as usize).min(lines.len()); let mut out = Vec::new(); for (offset, line) in lines.get(start..end).unwrap_or(&[]).iter().enumerate() { let lineno = from + u32::try_from(offset).unwrap_or(0); out.push(DiffLine { origin: LineOrigin::Context, old_lineno: None, new_lineno: Some(lineno), content: (*line).to_string(), }); } Ok(out) } /// For each entry directly under `path` in revision `rev`, the most recent /// commit that changed it. Powers the tree view's "latest change" column. /// /// Implemented as a single path-scoped revwalk, capped at `limit` commits (the /// classic performance trap). Entries not resolved within the cap are simply /// absent from the map, so the caller can drop the column rather than block. /// /// # Errors /// /// Returns [`GitError::Libgit2`] on a libgit2 failure. pub fn last_commit_per_entry( &self, rev: &Resolved, path: &Path, limit: usize, ) -> Result, GitError> { let mut remaining: std::collections::HashSet = self .tree_entries(rev, path)? .into_iter() .map(|entry| entry.name) .collect(); let mut result = HashMap::new(); let start = git2::Oid::from_str(rev.oid.as_str())?; let mut walk = self.inner.revwalk()?; walk.set_sorting(Sort::TIME)?; walk.push(start)?; for (count, oid) in walk.enumerate() { if remaining.is_empty() || count >= limit { break; } let commit = self.inner.find_commit(oid?)?; let here = self.subtree_at(&commit, path)?; let parent_tree = commit .parent(0) .ok() .map(|parent| self.subtree_at(&parent, path)) .transpose()? .flatten(); let mut resolved = Vec::new(); for name in &remaining { let here_oid = here.as_ref().and_then(|t| t.get_name(name)).map(|e| e.id()); let parent_oid = parent_tree .as_ref() .and_then(|t| t.get_name(name)) .map(|e| e.id()); // The entry changed here, and still exists (added or modified). if here_oid != parent_oid && here_oid.is_some() { resolved.push(name.clone()); } } for name in resolved { result.insert(name.clone(), commit_summary(&commit)); remaining.remove(&name); } } Ok(result) } /// Every regular-file path in the tree of revision `rev`, for search walks. /// Directories, submodules, and symlinks are excluded. /// /// # Errors /// /// Returns [`GitError`] if the revision or its tree cannot be read. pub fn file_paths(&self, rev: &Resolved) -> Result, GitError> { let commit = self.commit_at(&rev.oid)?; let tree = commit.tree()?; let mut paths = Vec::new(); tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { if entry.filemode() != i32::from(git2::FileMode::Tree) && entry.filemode() != i32::from(git2::FileMode::Commit) && entry.filemode() != i32::from(git2::FileMode::Link) { if let Some(name) = entry.name() { paths.push(format!("{root}{name}")); } } git2::TreeWalkResult::Ok })?; Ok(paths) } /// Every regular-file path in `rev`'s tree paired with its blob size in bytes, /// for language statistics. Sizes come from the object header (no content is /// decompressed). Bounded to 20000 files so a pathological tree can't stall a /// page. /// /// # Errors /// /// Returns [`GitError`] if the revision or its tree cannot be read. pub fn blob_sizes(&self, rev: &Resolved) -> Result, GitError> { const CAP: usize = 20_000; let commit = self.commit_at(&rev.oid)?; let tree = commit.tree()?; let odb = self.inner.odb()?; let mut out: Vec<(String, u64)> = Vec::new(); tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { if out.len() >= CAP { return git2::TreeWalkResult::Abort; } let is_blob = entry.filemode() != i32::from(git2::FileMode::Tree) && entry.filemode() != i32::from(git2::FileMode::Commit) && entry.filemode() != i32::from(git2::FileMode::Link); if is_blob && let Some(name) = entry.name() && let Ok((size, _)) = odb.read_header(entry.id()) { out.push((format!("{root}{name}"), size as u64)); } git2::TreeWalkResult::Ok })?; Ok(out) } /// The tree at `path` within a commit, or `None` if the path is absent or is /// not a directory. An empty `path` yields the root tree. fn subtree_at<'a>( &'a self, commit: &Commit<'a>, path: &Path, ) -> Result>, GitError> { let root = commit.tree()?; if path.components().next().is_none() { return Ok(Some(root)); } match root.get_path(path) { Ok(entry) => Ok(entry.to_object(&self.inner)?.into_tree().ok()), Err(_) => Ok(None), } } /// The underlying libgit2 handle, for sibling modules in this crate (e.g. the /// attributes resolver). Never exposed outside the crate. pub(crate) fn raw(&self) -> &Repository { &self.inner } /// Look up a commit by our [`Oid`], mapping a miss to [`GitError::RevNotFound`]. pub(crate) fn commit_at(&self, oid: &Oid) -> Result, GitError> { let parsed = git2::Oid::from_str(oid.as_str()) .map_err(|_| GitError::RevNotFound(oid.to_string()))?; self.inner .find_commit(parsed) .map_err(|_| GitError::RevNotFound(oid.to_string())) } /// Map a libgit2 tree entry into our [`TreeEntry`], reading blob sizes for /// files and symlinks. fn map_tree_entry(&self, entry: &GitTreeEntry<'_>) -> TreeEntry { let mode = entry.filemode(); let kind = match mode { MODE_DIR => EntryKind::Directory, MODE_SUBMODULE => EntryKind::Submodule, MODE_SYMLINK => EntryKind::Symlink, _ => EntryKind::File, }; let size = match kind { EntryKind::File | EntryKind::Symlink => self .inner .find_blob(entry.id()) .ok() .map(|b| b.size() as u64), EntryKind::Directory | EntryKind::Submodule => None, }; TreeEntry { name: entry.name().unwrap_or_default().to_string(), kind, // Filemodes are small positive octal constants; the fallback is // unreachable for a well-formed tree entry. mode: u32::try_from(mode).unwrap_or(0), oid: oid_of(entry.id()), size, } } } /// Whether `commit` changed `path` relative to its first parent (or, for a root /// commit, whether the path exists in it). fn commit_touches(commit: &Commit<'_>, path: &Path) -> Result { let here = entry_oid(commit, path)?; match commit.parent(0) { Ok(parent) => Ok(here != entry_oid(&parent, path)?), // No parent: the commit touches the path iff the path exists in it. Err(_) => Ok(here.is_some()), } } /// The object id at `path` in a commit's tree, or `None` if the path is absent. fn entry_oid(commit: &Commit<'_>, path: &Path) -> Result, GitError> { let tree = commit.tree()?; Ok(tree.get_path(path).ok().map(|entry| entry.id())) } /// Convert a libgit2 oid to our hex [`Oid`]. fn oid_of(oid: git2::Oid) -> Oid { Oid::new(oid.to_string()) } /// Render a repo-relative path as a string for a [`FileDiff`]. fn path_string(path: &Path) -> String { path.display().to_string() } /// Map a libgit2 delta status to our [`DeltaStatus`], folding copies into renames /// and the remaining exotic states into `Modified`. fn map_status(status: Delta) -> DeltaStatus { match status { Delta::Added => DeltaStatus::Added, Delta::Deleted => DeltaStatus::Deleted, Delta::Renamed | Delta::Copied => DeltaStatus::Renamed, Delta::Typechange => DeltaStatus::TypeChange, _ => DeltaStatus::Modified, } } /// Pull the hunks and their lines out of a libgit2 patch into our owned types. fn extract_hunks(patch: &Patch<'_>) -> Result, GitError> { let mut hunks = Vec::with_capacity(patch.num_hunks()); for h in 0..patch.num_hunks() { let (hunk, line_count) = patch.hunk(h)?; let header = String::from_utf8_lossy(hunk.header()) .trim_end_matches(['\r', '\n']) .to_string(); let mut lines = Vec::with_capacity(line_count); for l in 0..line_count { let line = patch.line_in_hunk(h, l)?; let origin = match line.origin_value() { DiffLineType::Addition | DiffLineType::AddEOFNL => LineOrigin::Addition, DiffLineType::Deletion | DiffLineType::DeleteEOFNL => LineOrigin::Deletion, _ => LineOrigin::Context, }; lines.push(DiffLine { origin, old_lineno: line.old_lineno(), new_lineno: line.new_lineno(), content: String::from_utf8_lossy(line.content()).to_string(), }); } hunks.push(Hunk { header, lines }); } Ok(hunks) } /// Build a [`Person`] from a libgit2 signature, converting the second-resolution /// time to epoch milliseconds. fn person(sig: &Signature<'_>) -> Person { Person { name: sig.name().unwrap_or_default().to_string(), email: sig.email().unwrap_or_default().to_string(), time_ms: sig.when().seconds().saturating_mul(1000), } } /// Build a [`CommitSummary`] for a log row. fn commit_summary(commit: &Commit<'_>) -> CommitSummary { CommitSummary { oid: oid_of(commit.id()), summary: commit.summary().unwrap_or("").to_string(), author: person(&commit.author()), parents: commit.parent_count(), } } /// Heuristic binary sniff: a NUL byte in the head of the content marks it binary, /// matching git's own `core.check-blob-content` behaviour closely enough for a /// "don't try to render this" decision. fn looks_binary(bytes: &[u8]) -> bool { let window = &bytes[..bytes.len().min(BINARY_SNIFF_BYTES)]; window.contains(&0) } /// Whether a blob's content is a git-LFS pointer (its mandatory first line). fn is_lfs_pointer(content: &[u8]) -> bool { content.starts_with(b"version https://git-lfs.github.com/spec/v1") } /// Parse a `.gitmodules` file into a `submodule path → url` map. The format is /// git-config: `[submodule "name"]` sections each with `path` and `url` keys. fn parse_gitmodules(content: &[u8]) -> HashMap { let text = String::from_utf8_lossy(content); let mut map = HashMap::new(); let mut path: Option = None; let mut url: Option = None; for line in text.lines() { let line = line.trim(); if line.starts_with('[') { if let (Some(p), Some(u)) = (path.take(), url.take()) { map.insert(p, u); } } else if let Some(rest) = line.strip_prefix("path") { if let Some(v) = rest.trim_start().strip_prefix('=') { path = Some(v.trim().to_string()); } } else if let Some(rest) = line.strip_prefix("url") { if let Some(v) = rest.trim_start().strip_prefix('=') { url = Some(v.trim().to_string()); } } } if let (Some(p), Some(u)) = (path, url) { map.insert(p, u); } map } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use std::path::{Path, PathBuf}; use git2::{Repository, Signature, Time}; use tempfile::TempDir; use super::*; use crate::{create_bare, repo_path}; #[test] fn detects_lfs_pointer_content() { let pointer = b"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 12\n"; assert!(is_lfs_pointer(pointer)); assert!(!is_lfs_pointer(b"#!/bin/sh\necho hi\n")); assert!(!is_lfs_pointer(b"")); } #[test] fn parses_gitmodules_path_and_url() { let text = b"[submodule \"vendor/lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n\ [submodule \"x\"]\n\tpath = tools/x\n\turl = git@host:org/x.git\n"; let map = parse_gitmodules(text); assert_eq!( map.get("vendor/lib").map(String::as_str), Some("https://example.com/lib.git") ); assert_eq!( map.get("tools/x").map(String::as_str), Some("git@host:org/x.git") ); } const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x"; /// A populated bare repository plus the temp dir keeping it alive. struct Fixture { _dir: TempDir, path: PathBuf, /// Oids of the commits made, in creation order. commits: Vec, } /// Build a bare repo with: /// - `main`: commit1 (README + src/main.rs), commit2 (README changed only); /// - `feature`: commit3 on top of commit2 (adds src/extra.rs); /// - an annotated tag `v1.0` on commit1 and a lightweight tag `light` on commit2. fn fixture() -> Fixture { let dir = tempfile::tempdir().unwrap(); create_bare(dir.path(), ID, "main", Path::new("/usr/bin/fabrica")).unwrap(); let path = repo_path(dir.path(), ID).unwrap(); let raw = Repository::open_bare(&path).unwrap(); let c1 = commit( &raw, "main", &[], &[("README.md", "hello\n"), ("src/main.rs", "fn main() {}\n")], "initial import", 1, ); let c2 = commit( &raw, "main", &[c1], &[ ("README.md", "hello world\n"), ("src/main.rs", "fn main() {}\n"), ], "expand readme", 2, ); let c3 = commit( &raw, "feature", &[c2], &[ ("README.md", "hello world\n"), ("src/main.rs", "fn main() {}\n"), ("src/extra.rs", "// extra\n"), ], "add extra module", 3, ); // Tags: annotated on c1, lightweight on c2. let obj = raw.find_object(c1, None).unwrap(); let tagger = Signature::new("Ada", "ada@example.com", &Time::new(10_000, 0)).unwrap(); raw.tag("v1.0", &obj, &tagger, "the first release", false) .unwrap(); raw.reference("refs/tags/light", c2, true, "lightweight") .unwrap(); Fixture { _dir: dir, path, commits: vec![c1, c2, c3], } } /// Write `files` (paths may contain one level of nesting) as a tree and commit /// it onto `branch`, returning the new commit oid. `t` seeds the commit time so /// ordering is deterministic. fn commit( raw: &Repository, branch: &str, parents: &[git2::Oid], files: &[(&str, &str)], message: &str, t: i64, ) -> git2::Oid { use std::collections::BTreeMap; // Group files by their (single) directory prefix. let mut by_dir: BTreeMap, Vec<(String, git2::Oid)>> = BTreeMap::new(); for (path, content) in files { let blob = raw.blob(content.as_bytes()).unwrap(); let (dir, name) = match path.split_once('/') { Some((dir, name)) => (Some(dir.to_string()), name.to_string()), None => (None, (*path).to_string()), }; by_dir.entry(dir).or_default().push((name, blob)); } let mut root = raw.treebuilder(None).unwrap(); for (dir, entries) in by_dir { match dir { None => { for (name, blob) in entries { root.insert(&name, blob, 0o100_644).unwrap(); } } Some(dir) => { let mut sub = raw.treebuilder(None).unwrap(); for (name, blob) in entries { sub.insert(&name, blob, 0o100_644).unwrap(); } let sub_oid = sub.write().unwrap(); root.insert(&dir, sub_oid, 0o040_000).unwrap(); } } } let tree_oid = root.write().unwrap(); let tree = raw.find_tree(tree_oid).unwrap(); let sig = Signature::new("Ada Lovelace", "ada@example.com", &Time::new(t, 0)).unwrap(); let parent_commits: Vec<_> = parents .iter() .map(|p| raw.find_commit(*p).unwrap()) .collect(); let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect(); raw.commit( Some(&format!("refs/heads/{branch}")), &sig, &sig, message, &tree, &parent_refs, ) .unwrap() } fn open(fx: &Fixture) -> Repo { Repo::open_path(&fx.path).unwrap() } #[test] fn resolve_ref_covers_branch_tag_head_oid_and_miss() { let fx = fixture(); let repo = open(&fx); let c2 = fx.commits[1].to_string(); let main = repo.resolve_ref("main").unwrap(); assert_eq!(main.kind, RefKind::Branch); assert_eq!(main.oid.as_str(), c2); let head = repo.resolve_ref("HEAD").unwrap(); assert_eq!(head.kind, RefKind::Head); assert_eq!(head.oid.as_str(), c2, "HEAD tracks the default branch"); let tag = repo.resolve_ref("v1.0").unwrap(); assert_eq!(tag.kind, RefKind::Tag); assert_eq!(tag.oid.as_str(), fx.commits[0].to_string()); let raw = repo.resolve_ref(&c2).unwrap(); assert_eq!(raw.kind, RefKind::Commit); assert_eq!(raw.oid.as_str(), c2); assert!(matches!( repo.resolve_ref("nope"), Err(GitError::RevNotFound(_)) )); } #[test] fn tree_entries_lists_root_and_subdir_dirs_first() { let fx = fixture(); let repo = open(&fx); let main = repo.resolve_ref("main").unwrap(); let root = repo.tree_entries(&main, Path::new("")).unwrap(); let names: Vec<_> = root.iter().map(|e| e.name.as_str()).collect(); assert_eq!(names, ["src", "README.md"], "directories sort first"); assert_eq!(root[0].kind, EntryKind::Directory); assert_eq!(root[1].kind, EntryKind::File); assert_eq!(root[1].size, Some("hello world\n".len() as u64)); let src = repo.tree_entries(&main, Path::new("src")).unwrap(); assert_eq!( src.iter().map(|e| e.name.as_str()).collect::>(), ["main.rs"] ); assert!(matches!( repo.tree_entries(&main, Path::new("does/not/exist")), Err(GitError::PathNotFound(_)) )); // A file path is not a directory. assert!(matches!( repo.tree_entries(&main, Path::new("README.md")), Err(GitError::PathNotFound(_)) )); } #[test] fn blob_reads_content_and_flags() { let fx = fixture(); let repo = open(&fx); let main = repo.resolve_ref("main").unwrap(); let readme = repo.blob(&main, Path::new("README.md")).unwrap(); assert_eq!(readme.content, b"hello world\n"); assert_eq!(readme.size, 12); assert!(!readme.is_binary); // A directory is not a file. assert!(matches!( repo.blob(&main, Path::new("src")), Err(GitError::NotAFile(_)) )); assert!(matches!( repo.blob(&main, Path::new("missing")), Err(GitError::PathNotFound(_)) )); } #[test] fn branches_report_default_and_ahead_behind() { let fx = fixture(); let repo = open(&fx); let branches = repo.branches().unwrap(); let names: Vec<_> = branches.iter().map(|b| b.name.as_str()).collect(); assert_eq!(names, ["main", "feature"], "default sorts first"); let main = &branches[0]; assert!(main.is_default); assert_eq!((main.ahead, main.behind), (0, 0)); let feature = &branches[1]; assert!(!feature.is_default); // feature is one commit ahead of main and behind by none. assert_eq!((feature.ahead, feature.behind), (1, 0)); assert_eq!(feature.tip.summary, "add extra module"); } #[test] fn tags_distinguish_annotated_from_lightweight() { let fx = fixture(); let repo = open(&fx); let tags = repo.tags().unwrap(); let names: Vec<_> = tags.iter().map(|t| t.name.as_str()).collect(); assert_eq!(names, ["light", "v1.0"]); let light = tags.iter().find(|t| t.name == "light").unwrap(); assert!(!light.annotated); assert_eq!(light.oid.as_str(), fx.commits[1].to_string()); assert!(light.message.is_none()); let v1 = tags.iter().find(|t| t.name == "v1.0").unwrap(); assert!(v1.annotated); assert_eq!(v1.oid.as_str(), fx.commits[0].to_string()); assert_eq!(v1.message.as_deref(), Some("the first release")); assert_eq!(v1.tagger.as_ref().unwrap().name, "Ada"); } #[test] fn commits_walk_newest_first_and_filter_by_path() { let fx = fixture(); let repo = open(&fx); let main = repo.resolve_ref("main").unwrap(); let all = repo.commits(&main, None, Page::first(10)).unwrap(); let summaries: Vec<_> = all.iter().map(|c| c.summary.as_str()).collect(); assert_eq!(summaries, ["expand readme", "initial import"]); // Paging: skip the newest, take one. let page = repo .commits( &main, None, Page { offset: 1, limit: 1, }, ) .unwrap(); assert_eq!(page.len(), 1); assert_eq!(page[0].summary, "initial import"); // src/main.rs was only introduced in commit1 and unchanged since. let touched = repo .commits(&main, Some(Path::new("src/main.rs")), Page::first(10)) .unwrap(); assert_eq!( touched .iter() .map(|c| c.summary.as_str()) .collect::>(), ["initial import"] ); } #[test] fn diff_reports_added_and_modified_files_with_hunks() { let fx = fixture(); let repo = open(&fx); let c1 = Oid::new(fx.commits[0].to_string()); let c2 = Oid::new(fx.commits[1].to_string()); // c1 vs empty tree: everything is an addition. let initial = repo.diff(None, &c1, DiffOpts::default()).unwrap(); let paths: Vec<_> = initial .files .iter() .filter_map(|f| f.new_path.as_deref()) .collect(); assert!(paths.contains(&"README.md")); assert!(paths.contains(&"src/main.rs")); assert!(initial.files.iter().all(|f| f.status == DeltaStatus::Added)); // c1 -> c2 changed only README.md. let delta = repo.diff(Some(&c1), &c2, DiffOpts::default()).unwrap(); assert_eq!(delta.files.len(), 1); let readme = &delta.files[0]; assert_eq!(readme.new_path.as_deref(), Some("README.md")); assert_eq!(readme.status, DeltaStatus::Modified); assert!(!readme.is_binary); assert!(!readme.hunks.is_empty()); // The change replaced "hello" with "hello world". let has_addition = readme .hunks .iter() .flat_map(|h| &h.lines) .any(|l| l.origin == LineOrigin::Addition && l.content.contains("hello world")); assert!(has_addition, "expected the new line in the hunk"); } #[test] fn diff_context_returns_post_image_lines() { let fx = fixture(); let repo = open(&fx); let c2 = Oid::new(fx.commits[1].to_string()); // README.md at c2 is a single line "hello world\n". let lines = repo .diff_context(&c2, Path::new("README.md"), 1, 5) .unwrap(); assert_eq!(lines.len(), 1, "clamped to the file length"); assert_eq!(lines[0].origin, LineOrigin::Context); assert_eq!(lines[0].new_lineno, Some(1)); assert_eq!(lines[0].content, "hello world\n"); } #[test] fn last_commit_per_entry_attributes_the_right_change() { let fx = fixture(); let repo = open(&fx); let main = repo.resolve_ref("main").unwrap(); let latest = repo .last_commit_per_entry(&main, Path::new(""), 100) .unwrap(); // README.md last changed in commit2; src last changed in commit1 (its // subtree oid is unchanged since). assert_eq!(latest["README.md"].summary, "expand readme"); assert_eq!(latest["src"].summary, "initial import"); } #[test] fn last_commit_per_entry_degrades_under_a_tight_cap() { let fx = fixture(); let repo = open(&fx); let main = repo.resolve_ref("main").unwrap(); // With a cap of 1 commit, only entries changed by the tip resolve. let capped = repo.last_commit_per_entry(&main, Path::new(""), 1).unwrap(); assert_eq!( capped.get("README.md").map(|c| c.summary.as_str()), Some("expand readme") ); assert!( !capped.contains_key("src"), "src is not reachable within the cap" ); } #[test] fn commits_all_branches_dedupes_and_covers_feature() { let fx = fixture(); let repo = open(&fx); let all = repo.commits_all_branches(50).unwrap(); // main has 2 commits, feature adds 1 on top; the shared two are counted // once, so three distinct commits total. assert_eq!(all.len(), 3, "deduplicated across branches"); // The feature-only commit is present, attributed to `feature`. let feat = all.iter().find(|(c, _)| c.summary == "add extra module"); assert_eq!(feat.map(|(_, b)| b.as_str()), Some("feature")); } #[test] fn commit_returns_full_detail() { let fx = fixture(); let repo = open(&fx); let c2 = Oid::new(fx.commits[1].to_string()); let detail = repo.commit(&c2).unwrap(); assert_eq!(detail.message, "expand readme"); assert_eq!(detail.author.name, "Ada Lovelace"); assert_eq!(detail.author.email, "ada@example.com"); assert_eq!(detail.parents, vec![Oid::new(fx.commits[0].to_string())]); assert!(matches!( repo.commit(&Oid::new("0".repeat(40))), Err(GitError::RevNotFound(_)) )); } }