fabrica

hanna/fabrica

48639 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! An open repository and its read operations.
6//!
7//! Every method here is synchronous and blocking; see the crate docs for the
8//! `spawn_blocking` requirement. The public surface speaks only in the plain
9//! [`crate::types`] vocabulary — `git2` handles never escape this module.
10
11use std::collections::HashMap;
12use std::path::Path;
13
14use git2::{
15 BranchType, Commit, Delta, DiffFindOptions, DiffLineType, DiffOptions, Patch, Repository,
16 Signature, Sort, Tree, TreeEntry as GitTreeEntry,
17};
18
19use crate::GitError;
20use crate::types::{
21 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
22 FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo,
23 TreeAnnotations, TreeEntry,
24};
25
26/// Filemode bits git uses to distinguish tree-entry kinds.
27const MODE_DIR: i32 = 0o040_000;
28const MODE_SYMLINK: i32 = 0o120_000;
29const MODE_SUBMODULE: i32 = 0o160_000;
30
31/// Bytes sniffed from the head of a blob when deciding whether it is binary.
32const BINARY_SNIFF_BYTES: usize = 8_000;
33
34/// An open bare repository.
35pub struct Repo {
36 inner: Repository,
37}
38
39impl Repo {
40 /// Open the repository at `path` (expected to be a bare `.git` directory).
41 ///
42 /// # Errors
43 ///
44 /// Returns [`GitError::Libgit2`] if the path is not a repository.
45 pub fn open_path(path: &Path) -> Result<Self, GitError> {
46 Ok(Self {
47 inner: Repository::open_bare(path)?,
48 })
49 }
50
51 /// The branch name `HEAD` points at, even when that branch is unborn (a
52 /// freshly created repo with no commits). Returns `None` if `HEAD` is
53 /// detached.
54 ///
55 /// # Errors
56 ///
57 /// Returns [`GitError::Libgit2`] if `HEAD` cannot be read.
58 pub fn head_branch(&self) -> Result<Option<String>, GitError> {
59 let head = self.inner.find_reference("HEAD")?;
60 Ok(head
61 .symbolic_target()
62 .and_then(|t| t.strip_prefix("refs/heads/"))
63 .map(str::to_string))
64 }
65
66 /// Resolve a revision string — a branch or tag name, `HEAD`, or a raw commit
67 /// id — to the commit it names.
68 ///
69 /// Resolution order is branch, then tag, then a general parse (which covers
70 /// `HEAD`, full and abbreviated oids, and revspecs). A branch and a tag of the
71 /// same name resolve to the branch, matching git's own precedence for a bare
72 /// short name in this context.
73 ///
74 /// # Errors
75 ///
76 /// Returns [`GitError::RevNotFound`] if nothing matches.
77 pub fn resolve_ref(&self, rev: &str) -> Result<Resolved, GitError> {
78 if let Ok(branch) = self.inner.find_branch(rev, BranchType::Local) {
79 let commit = branch.get().peel_to_commit()?;
80 return Ok(Resolved {
81 oid: oid_of(commit.id()),
82 kind: RefKind::Branch,
83 name: rev.to_string(),
84 });
85 }
86 if let Ok(reference) = self.inner.find_reference(&format!("refs/tags/{rev}")) {
87 let commit = reference.peel_to_commit()?;
88 return Ok(Resolved {
89 oid: oid_of(commit.id()),
90 kind: RefKind::Tag,
91 name: rev.to_string(),
92 });
93 }
94 let object = self
95 .inner
96 .revparse_single(rev)
97 .map_err(|_| GitError::RevNotFound(rev.to_string()))?;
98 let commit = object
99 .peel_to_commit()
100 .map_err(|_| GitError::RevNotFound(rev.to_string()))?;
101 Ok(Resolved {
102 oid: oid_of(commit.id()),
103 kind: if rev == "HEAD" {
104 RefKind::Head
105 } else {
106 RefKind::Commit
107 },
108 name: rev.to_string(),
109 })
110 }
111
112 /// List the entries of the tree at `path` within revision `rev`. An empty
113 /// `path` lists the root tree. Directories sort before files, each
114 /// alphabetically.
115 ///
116 /// # Errors
117 ///
118 /// Returns [`GitError::PathNotFound`] if `path` is absent or names a file.
119 pub fn tree_entries(&self, rev: &Resolved, path: &Path) -> Result<Vec<TreeEntry>, GitError> {
120 let commit = self.commit_at(&rev.oid)?;
121 let root = commit.tree()?;
122
123 let tree = if path.components().next().is_none() {
124 root
125 } else {
126 let entry = root
127 .get_path(path)
128 .map_err(|_| GitError::PathNotFound(path.display().to_string()))?;
129 entry
130 .to_object(&self.inner)?
131 .into_tree()
132 .map_err(|_| GitError::PathNotFound(path.display().to_string()))?
133 };
134
135 let mut entries: Vec<TreeEntry> = tree
136 .iter()
137 .map(|entry| self.map_tree_entry(&entry))
138 .collect();
139 entries.sort_by(|a, b| {
140 let a_dir = a.kind == EntryKind::Directory;
141 let b_dir = b.kind == EntryKind::Directory;
142 b_dir.cmp(&a_dir).then_with(|| a.name.cmp(&b.name))
143 });
144 Ok(entries)
145 }
146
147 /// Annotate the directory `path` at `rev`: which file entries are git-LFS
148 /// pointers (small blobs whose content is a pointer), and the whole-repo
149 /// submodule→URL map from `.gitmodules`. Best-effort — a missing or malformed
150 /// `.gitmodules`, or an unreadable blob, simply yields fewer annotations.
151 ///
152 /// # Errors
153 ///
154 /// Returns [`GitError`] only if the revision's commit or root tree is missing.
155 pub fn tree_annotations(
156 &self,
157 rev: &Resolved,
158 path: &Path,
159 ) -> Result<TreeAnnotations, GitError> {
160 /// Pointer files are tiny; skip reading anything larger.
161 const POINTER_MAX: usize = 1024;
162
163 let commit = self.commit_at(&rev.oid)?;
164 let root = commit.tree()?;
165
166 let submodules = root
167 .get_path(Path::new(".gitmodules"))
168 .ok()
169 .and_then(|e| e.to_object(&self.inner).ok())
170 .and_then(|o| o.into_blob().ok())
171 .map(|b| parse_gitmodules(b.content()))
172 .unwrap_or_default();
173
174 // `.gitattributes` filter=lfs is the authoritative "tracked by LFS" signal
175 // (a file can be LFS-tracked even if a given blob was committed inline).
176 let attrs = self.attributes_set(&rev.oid).ok();
177 let dir_prefix = path_string(path);
178
179 let tree = if path.components().next().is_none() {
180 Some(root)
181 } else {
182 root.get_path(path)
183 .ok()
184 .and_then(|e| e.to_object(&self.inner).ok())
185 .and_then(|o| o.into_tree().ok())
186 };
187 let mut lfs = std::collections::HashSet::new();
188 if let Some(tree) = tree {
189 for entry in &tree {
190 let Some(name) = entry.name() else { continue };
191 if entry.kind() != Some(git2::ObjectType::Blob) {
192 continue;
193 }
194 let full = if dir_prefix.is_empty() {
195 name.to_string()
196 } else {
197 format!("{dir_prefix}/{name}")
198 };
199 let tracked = attrs
200 .as_ref()
201 .is_some_and(|set| set.attributes(&full).is_lfs());
202 let pointer = entry
203 .to_object(&self.inner)
204 .ok()
205 .and_then(|o| {
206 o.as_blob()
207 .map(|b| b.size() < POINTER_MAX && is_lfs_pointer(b.content()))
208 })
209 .unwrap_or(false);
210 if tracked || pointer {
211 lfs.insert(name.to_string());
212 }
213 }
214 }
215 Ok(TreeAnnotations { lfs, submodules })
216 }
217
218 /// Read the file at `path` within revision `rev`.
219 ///
220 /// # Errors
221 ///
222 /// Returns [`GitError::PathNotFound`] if `path` is absent, or
223 /// [`GitError::NotAFile`] if it names a directory or submodule.
224 pub fn blob(&self, rev: &Resolved, path: &Path) -> Result<Blob, GitError> {
225 let commit = self.commit_at(&rev.oid)?;
226 let entry = commit
227 .tree()?
228 .get_path(path)
229 .map_err(|_| GitError::PathNotFound(path.display().to_string()))?;
230 let object = entry.to_object(&self.inner)?;
231 let blob = object
232 .into_blob()
233 .map_err(|_| GitError::NotAFile(path.display().to_string()))?;
234 let content = blob.content().to_vec();
235 let size = content.len() as u64;
236 Ok(Blob {
237 is_binary: looks_binary(&content),
238 size,
239 content,
240 })
241 }
242
243 /// Create a lightweight tag `name` pointing at `rev`, failing if it already
244 /// exists (releases must not silently move a tag). For cutting a release from
245 /// a branch/commit.
246 ///
247 /// # Errors
248 ///
249 /// Returns [`GitError::Libgit2`] if the tag exists or the ref cannot be written.
250 pub fn create_tag(&self, name: &str, rev: &Resolved) -> Result<(), GitError> {
251 let oid = git2::Oid::from_str(&rev.oid.to_string())?;
252 self.inner.reference(
253 &format!("refs/tags/{name}"),
254 oid,
255 false,
256 "fabrica release tag",
257 )?;
258 Ok(())
259 }
260
261 /// The local branches, each with its position (ahead/behind) relative to the
262 /// default branch and its tip commit.
263 ///
264 /// # Errors
265 ///
266 /// Returns [`GitError::Libgit2`] on a libgit2 failure.
267 pub fn branches(&self) -> Result<Vec<BranchInfo>, GitError> {
268 let default = self.head_branch()?;
269 let default_oid = default
270 .as_deref()
271 .and_then(|name| self.inner.find_branch(name, BranchType::Local).ok())
272 .and_then(|branch| branch.get().peel_to_commit().ok())
273 .map(|commit| commit.id());
274
275 let mut out = Vec::new();
276 for branch in self.inner.branches(Some(BranchType::Local))? {
277 let (branch, _) = branch?;
278 let Some(name) = branch.name()?.map(str::to_string) else {
279 continue;
280 };
281 let tip = branch.get().peel_to_commit()?;
282 let (ahead, behind) = match default_oid {
283 Some(base) if base != tip.id() => self.inner.graph_ahead_behind(tip.id(), base)?,
284 _ => (0, 0),
285 };
286 out.push(BranchInfo {
287 is_default: default.as_deref() == Some(name.as_str()),
288 name,
289 oid: oid_of(tip.id()),
290 ahead,
291 behind,
292 tip: commit_summary(&tip),
293 });
294 }
295 out.sort_by(|a, b| {
296 b.is_default
297 .cmp(&a.is_default)
298 .then_with(|| a.name.cmp(&b.name))
299 });
300 Ok(out)
301 }
302
303 /// The local branch names, default first then alphabetical. Cheaper than
304 /// [`Self::branches`] — it skips the ahead/behind graph walk, for the branch
305 /// switcher.
306 ///
307 /// # Errors
308 ///
309 /// Returns [`GitError::Libgit2`] on a libgit2 failure.
310 pub fn branch_names(&self) -> Result<Vec<String>, GitError> {
311 let default = self.head_branch()?;
312 let mut names: Vec<String> = Vec::new();
313 for branch in self.inner.branches(Some(BranchType::Local))? {
314 let (branch, _) = branch?;
315 if let Some(name) = branch.name()?.map(str::to_string) {
316 names.push(name);
317 }
318 }
319 names.sort_by(|a, b| {
320 let da = default.as_deref() == Some(a.as_str());
321 let db = default.as_deref() == Some(b.as_str());
322 db.cmp(&da).then_with(|| a.cmp(b))
323 });
324 Ok(names)
325 }
326
327 /// The tags, peeled to the commit each ultimately points at, annotated tags
328 /// carrying their message and tagger.
329 ///
330 /// # Errors
331 ///
332 /// Returns [`GitError::Libgit2`] on a libgit2 failure.
333 pub fn tags(&self) -> Result<Vec<TagInfo>, GitError> {
334 let mut out = Vec::new();
335 for name in self.inner.tag_names(None)?.iter().flatten() {
336 let object = self.inner.revparse_single(&format!("refs/tags/{name}"))?;
337 let commit = object.peel_to_commit()?;
338 let (annotated, message, tagger) = match object.as_tag() {
339 Some(tag) => (
340 true,
341 tag.message().map(str::to_string),
342 tag.tagger().as_ref().map(person),
343 ),
344 None => (false, None, None),
345 };
346 out.push(TagInfo {
347 name: name.to_string(),
348 oid: oid_of(commit.id()),
349 annotated,
350 message,
351 tagger,
352 });
353 }
354 out.sort_by(|a, b| a.name.cmp(&b.name));
355 Ok(out)
356 }
357
358 /// Walk commits reachable from `rev`, newest first, returning one page.
359 ///
360 /// When `path` is given, only commits that changed that path are returned
361 /// (compared against the first parent — a pragmatic history simplification,
362 /// not full merge-aware path following). Paging is applied *after* filtering.
363 ///
364 /// # Errors
365 ///
366 /// Returns [`GitError::Libgit2`] on a libgit2 failure.
367 pub fn commits(
368 &self,
369 rev: &Resolved,
370 path: Option<&Path>,
371 page: Page,
372 ) -> Result<Vec<CommitSummary>, GitError> {
373 let start = git2::Oid::from_str(rev.oid.as_str())?;
374 let mut walk = self.inner.revwalk()?;
375 walk.set_sorting(Sort::TIME)?;
376 walk.push(start)?;
377
378 let mut out = Vec::with_capacity(page.limit);
379 let mut skipped = 0usize;
380 for oid in walk {
381 let commit = self.inner.find_commit(oid?)?;
382 if let Some(path) = path {
383 if !commit_touches(&commit, path)? {
384 continue;
385 }
386 }
387 if skipped < page.offset {
388 skipped += 1;
389 continue;
390 }
391 out.push(commit_summary(&commit));
392 if out.len() >= page.limit {
393 break;
394 }
395 }
396 Ok(out)
397 }
398
399 /// The best common ancestor of two commits (the merge base), or `None` if they
400 /// have unrelated histories.
401 ///
402 /// # Errors
403 ///
404 /// Returns [`GitError::Libgit2`] on a lookup failure.
405 pub fn merge_base(&self, a: &Oid, b: &Oid) -> Result<Option<Oid>, GitError> {
406 let a = git2::Oid::from_str(a.as_str())?;
407 let b = git2::Oid::from_str(b.as_str())?;
408 match self.inner.merge_base(a, b) {
409 Ok(oid) => Ok(Some(oid_of(oid))),
410 Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
411 Err(e) => Err(e.into()),
412 }
413 }
414
415 /// Recent commits across **all** local branches, deduplicated, each paired
416 /// with the first branch it was found on, for the dashboard activity feed and
417 /// heatmap. `per_branch` bounds the walk per branch. Order is per-branch
418 /// (callers sort by time).
419 ///
420 /// # Errors
421 ///
422 /// Returns [`GitError::Libgit2`] on a revwalk failure.
423 pub fn commits_all_branches(
424 &self,
425 per_branch: usize,
426 ) -> Result<Vec<(CommitSummary, String)>, GitError> {
427 let branches = self.branch_names()?;
428 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
429 let mut out: Vec<(CommitSummary, String)> = Vec::new();
430 for branch in &branches {
431 let Ok(rev) = self.resolve_ref(branch) else {
432 continue;
433 };
434 let start = git2::Oid::from_str(rev.oid.as_str())?;
435 let mut walk = self.inner.revwalk()?;
436 walk.set_sorting(Sort::TIME)?;
437 walk.push(start)?;
438 for oid in walk.take(per_branch) {
439 let oid = oid?;
440 if !seen.insert(oid.to_string()) {
441 continue;
442 }
443 let commit = self.inner.find_commit(oid)?;
444 out.push((commit_summary(&commit), branch.clone()));
445 }
446 }
447 Ok(out)
448 }
449
450 /// The commits reachable from `head` but not `base`, newest first — the commits
451 /// a pull request would contribute. Capped at `limit`.
452 ///
453 /// # Errors
454 ///
455 /// Returns [`GitError::Libgit2`] on a revwalk failure.
456 pub fn commits_between(
457 &self,
458 base: &Oid,
459 head: &Oid,
460 limit: usize,
461 ) -> Result<Vec<CommitSummary>, GitError> {
462 let base = git2::Oid::from_str(base.as_str())?;
463 let head = git2::Oid::from_str(head.as_str())?;
464 let mut walk = self.inner.revwalk()?;
465 walk.set_sorting(Sort::TIME)?;
466 walk.push(head)?;
467 walk.hide(base)?;
468 let mut out = Vec::with_capacity(limit.min(64));
469 for oid in walk {
470 let commit = self.inner.find_commit(oid?)?;
471 out.push(commit_summary(&commit));
472 if out.len() >= limit {
473 break;
474 }
475 }
476 Ok(out)
477 }
478
479 /// Fetch a single commit in full.
480 ///
481 /// # Errors
482 ///
483 /// Returns [`GitError::RevNotFound`] if no such commit exists.
484 pub fn commit(&self, oid: &Oid) -> Result<CommitDetail, GitError> {
485 let commit = self.commit_at(oid)?;
486 Ok(CommitDetail {
487 oid: oid_of(commit.id()),
488 message: commit.message().unwrap_or("").to_string(),
489 author: person(&commit.author()),
490 committer: person(&commit.committer()),
491 parents: commit.parent_ids().map(oid_of).collect(),
492 tree: oid_of(commit.tree_id()),
493 })
494 }
495
496 /// Diff two revisions file by file. `base` of `None` diffs against the empty
497 /// tree, so a root commit (or an explicit "show everything") renders as all
498 /// additions; callers wanting a commit's own diff pass its parent as `base`.
499 ///
500 /// Rename detection is enabled. Binary files carry no hunks.
501 ///
502 /// # Errors
503 ///
504 /// Returns [`GitError::RevNotFound`] if a revision is unknown, or
505 /// [`GitError::Libgit2`] on a diff failure.
506 pub fn diff(
507 &self,
508 base: Option<&Oid>,
509 head: &Oid,
510 opts: DiffOpts,
511 ) -> Result<FileDiffs, GitError> {
512 let head_tree = self.commit_at(head)?.tree()?;
513 let base_tree = match base {
514 Some(oid) => Some(self.commit_at(oid)?.tree()?),
515 None => None,
516 };
517
518 let mut diff_opts = DiffOptions::new();
519 diff_opts.context_lines(opts.context_lines);
520 let mut diff = self.inner.diff_tree_to_tree(
521 base_tree.as_ref(),
522 Some(&head_tree),
523 Some(&mut diff_opts),
524 )?;
525 diff.find_similar(Some(DiffFindOptions::new().renames(true)))?;
526
527 let mut files = Vec::new();
528 for idx in 0..diff.deltas().len() {
529 let Some(delta) = diff.get_delta(idx) else {
530 continue;
531 };
532 let patch = Patch::from_diff(&diff, idx)?;
533 let is_binary = patch.is_none() || delta.flags().is_binary();
534 let hunks = match &patch {
535 Some(patch) if !is_binary => extract_hunks(patch)?,
536 _ => Vec::new(),
537 };
538 files.push(FileDiff {
539 old_path: delta.old_file().path().map(path_string),
540 new_path: delta.new_file().path().map(path_string),
541 status: map_status(delta.status()),
542 is_binary,
543 hunks,
544 });
545 }
546 Ok(FileDiffs { files })
547 }
548
549 /// Return the post-image lines of `file` at revision `head` for the 1-based,
550 /// inclusive range `[from, to]`, as context lines. This powers "expand
551 /// context" between hunks; the range is clamped to the file's length.
552 ///
553 /// # Errors
554 ///
555 /// Returns [`GitError::PathNotFound`]/[`GitError::NotAFile`] if `file` is not a
556 /// readable blob at `head`.
557 pub fn diff_context(
558 &self,
559 head: &Oid,
560 file: &Path,
561 from: u32,
562 to: u32,
563 ) -> Result<Vec<DiffLine>, GitError> {
564 let commit = self.commit_at(head)?;
565 let entry = commit
566 .tree()?
567 .get_path(file)
568 .map_err(|_| GitError::PathNotFound(file.display().to_string()))?;
569 let blob = entry
570 .to_object(&self.inner)?
571 .into_blob()
572 .map_err(|_| GitError::NotAFile(file.display().to_string()))?;
573 let text = String::from_utf8_lossy(blob.content());
574 let lines: Vec<&str> = text.split_inclusive('\n').collect();
575
576 let start = from.saturating_sub(1) as usize;
577 let end = (to as usize).min(lines.len());
578 let mut out = Vec::new();
579 for (offset, line) in lines.get(start..end).unwrap_or(&[]).iter().enumerate() {
580 let lineno = from + u32::try_from(offset).unwrap_or(0);
581 out.push(DiffLine {
582 origin: LineOrigin::Context,
583 old_lineno: None,
584 new_lineno: Some(lineno),
585 content: (*line).to_string(),
586 });
587 }
588 Ok(out)
589 }
590
591 /// For each entry directly under `path` in revision `rev`, the most recent
592 /// commit that changed it. Powers the tree view's "latest change" column.
593 ///
594 /// Implemented as a single path-scoped revwalk, capped at `limit` commits (the
595 /// classic performance trap). Entries not resolved within the cap are simply
596 /// absent from the map, so the caller can drop the column rather than block.
597 ///
598 /// # Errors
599 ///
600 /// Returns [`GitError::Libgit2`] on a libgit2 failure.
601 pub fn last_commit_per_entry(
602 &self,
603 rev: &Resolved,
604 path: &Path,
605 limit: usize,
606 ) -> Result<HashMap<String, CommitSummary>, GitError> {
607 let mut remaining: std::collections::HashSet<String> = self
608 .tree_entries(rev, path)?
609 .into_iter()
610 .map(|entry| entry.name)
611 .collect();
612 let mut result = HashMap::new();
613
614 let start = git2::Oid::from_str(rev.oid.as_str())?;
615 let mut walk = self.inner.revwalk()?;
616 walk.set_sorting(Sort::TIME)?;
617 walk.push(start)?;
618
619 for (count, oid) in walk.enumerate() {
620 if remaining.is_empty() || count >= limit {
621 break;
622 }
623 let commit = self.inner.find_commit(oid?)?;
624 let here = self.subtree_at(&commit, path)?;
625 let parent_tree = commit
626 .parent(0)
627 .ok()
628 .map(|parent| self.subtree_at(&parent, path))
629 .transpose()?
630 .flatten();
631
632 let mut resolved = Vec::new();
633 for name in &remaining {
634 let here_oid = here.as_ref().and_then(|t| t.get_name(name)).map(|e| e.id());
635 let parent_oid = parent_tree
636 .as_ref()
637 .and_then(|t| t.get_name(name))
638 .map(|e| e.id());
639 // The entry changed here, and still exists (added or modified).
640 if here_oid != parent_oid && here_oid.is_some() {
641 resolved.push(name.clone());
642 }
643 }
644 for name in resolved {
645 result.insert(name.clone(), commit_summary(&commit));
646 remaining.remove(&name);
647 }
648 }
649 Ok(result)
650 }
651
652 /// Every regular-file path in the tree of revision `rev`, for search walks.
653 /// Directories, submodules, and symlinks are excluded.
654 ///
655 /// # Errors
656 ///
657 /// Returns [`GitError`] if the revision or its tree cannot be read.
658 pub fn file_paths(&self, rev: &Resolved) -> Result<Vec<String>, GitError> {
659 let commit = self.commit_at(&rev.oid)?;
660 let tree = commit.tree()?;
661 let mut paths = Vec::new();
662 tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
663 if entry.filemode() != i32::from(git2::FileMode::Tree)
664 && entry.filemode() != i32::from(git2::FileMode::Commit)
665 && entry.filemode() != i32::from(git2::FileMode::Link)
666 {
667 if let Some(name) = entry.name() {
668 paths.push(format!("{root}{name}"));
669 }
670 }
671 git2::TreeWalkResult::Ok
672 })?;
673 Ok(paths)
674 }
675
676 /// Every regular-file path in `rev`'s tree paired with its blob size in bytes,
677 /// for language statistics. Sizes come from the object header (no content is
678 /// decompressed). Bounded to 20000 files so a pathological tree can't stall a
679 /// page.
680 ///
681 /// # Errors
682 ///
683 /// Returns [`GitError`] if the revision or its tree cannot be read.
684 pub fn blob_sizes(&self, rev: &Resolved) -> Result<Vec<(String, u64)>, GitError> {
685 const CAP: usize = 20_000;
686 let commit = self.commit_at(&rev.oid)?;
687 let tree = commit.tree()?;
688 let odb = self.inner.odb()?;
689 let mut out: Vec<(String, u64)> = Vec::new();
690 tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
691 if out.len() >= CAP {
692 return git2::TreeWalkResult::Abort;
693 }
694 let is_blob = entry.filemode() != i32::from(git2::FileMode::Tree)
695 && entry.filemode() != i32::from(git2::FileMode::Commit)
696 && entry.filemode() != i32::from(git2::FileMode::Link);
697 if is_blob
698 && let Some(name) = entry.name()
699 && let Ok((size, _)) = odb.read_header(entry.id())
700 {
701 out.push((format!("{root}{name}"), size as u64));
702 }
703 git2::TreeWalkResult::Ok
704 })?;
705 Ok(out)
706 }
707
708 /// The tree at `path` within a commit, or `None` if the path is absent or is
709 /// not a directory. An empty `path` yields the root tree.
710 fn subtree_at<'a>(
711 &'a self,
712 commit: &Commit<'a>,
713 path: &Path,
714 ) -> Result<Option<Tree<'a>>, GitError> {
715 let root = commit.tree()?;
716 if path.components().next().is_none() {
717 return Ok(Some(root));
718 }
719 match root.get_path(path) {
720 Ok(entry) => Ok(entry.to_object(&self.inner)?.into_tree().ok()),
721 Err(_) => Ok(None),
722 }
723 }
724
725 /// The underlying libgit2 handle, for sibling modules in this crate (e.g. the
726 /// attributes resolver). Never exposed outside the crate.
727 pub(crate) fn raw(&self) -> &Repository {
728 &self.inner
729 }
730
731 /// Look up a commit by our [`Oid`], mapping a miss to [`GitError::RevNotFound`].
732 pub(crate) fn commit_at(&self, oid: &Oid) -> Result<Commit<'_>, GitError> {
733 let parsed = git2::Oid::from_str(oid.as_str())
734 .map_err(|_| GitError::RevNotFound(oid.to_string()))?;
735 self.inner
736 .find_commit(parsed)
737 .map_err(|_| GitError::RevNotFound(oid.to_string()))
738 }
739
740 /// Map a libgit2 tree entry into our [`TreeEntry`], reading blob sizes for
741 /// files and symlinks.
742 fn map_tree_entry(&self, entry: &GitTreeEntry<'_>) -> TreeEntry {
743 let mode = entry.filemode();
744 let kind = match mode {
745 MODE_DIR => EntryKind::Directory,
746 MODE_SUBMODULE => EntryKind::Submodule,
747 MODE_SYMLINK => EntryKind::Symlink,
748 _ => EntryKind::File,
749 };
750 let size = match kind {
751 EntryKind::File | EntryKind::Symlink => self
752 .inner
753 .find_blob(entry.id())
754 .ok()
755 .map(|b| b.size() as u64),
756 EntryKind::Directory | EntryKind::Submodule => None,
757 };
758 TreeEntry {
759 name: entry.name().unwrap_or_default().to_string(),
760 kind,
761 // Filemodes are small positive octal constants; the fallback is
762 // unreachable for a well-formed tree entry.
763 mode: u32::try_from(mode).unwrap_or(0),
764 oid: oid_of(entry.id()),
765 size,
766 }
767 }
768}
769
770/// Whether `commit` changed `path` relative to its first parent (or, for a root
771/// commit, whether the path exists in it).
772fn commit_touches(commit: &Commit<'_>, path: &Path) -> Result<bool, GitError> {
773 let here = entry_oid(commit, path)?;
774 match commit.parent(0) {
775 Ok(parent) => Ok(here != entry_oid(&parent, path)?),
776 // No parent: the commit touches the path iff the path exists in it.
777 Err(_) => Ok(here.is_some()),
778 }
779}
780
781/// The object id at `path` in a commit's tree, or `None` if the path is absent.
782fn entry_oid(commit: &Commit<'_>, path: &Path) -> Result<Option<git2::Oid>, GitError> {
783 let tree = commit.tree()?;
784 Ok(tree.get_path(path).ok().map(|entry| entry.id()))
785}
786
787/// Convert a libgit2 oid to our hex [`Oid`].
788fn oid_of(oid: git2::Oid) -> Oid {
789 Oid::new(oid.to_string())
790}
791
792/// Render a repo-relative path as a string for a [`FileDiff`].
793fn path_string(path: &Path) -> String {
794 path.display().to_string()
795}
796
797/// Map a libgit2 delta status to our [`DeltaStatus`], folding copies into renames
798/// and the remaining exotic states into `Modified`.
799fn map_status(status: Delta) -> DeltaStatus {
800 match status {
801 Delta::Added => DeltaStatus::Added,
802 Delta::Deleted => DeltaStatus::Deleted,
803 Delta::Renamed | Delta::Copied => DeltaStatus::Renamed,
804 Delta::Typechange => DeltaStatus::TypeChange,
805 _ => DeltaStatus::Modified,
806 }
807}
808
809/// Pull the hunks and their lines out of a libgit2 patch into our owned types.
810fn extract_hunks(patch: &Patch<'_>) -> Result<Vec<Hunk>, GitError> {
811 let mut hunks = Vec::with_capacity(patch.num_hunks());
812 for h in 0..patch.num_hunks() {
813 let (hunk, line_count) = patch.hunk(h)?;
814 let header = String::from_utf8_lossy(hunk.header())
815 .trim_end_matches(['\r', '\n'])
816 .to_string();
817 let mut lines = Vec::with_capacity(line_count);
818 for l in 0..line_count {
819 let line = patch.line_in_hunk(h, l)?;
820 let origin = match line.origin_value() {
821 DiffLineType::Addition | DiffLineType::AddEOFNL => LineOrigin::Addition,
822 DiffLineType::Deletion | DiffLineType::DeleteEOFNL => LineOrigin::Deletion,
823 _ => LineOrigin::Context,
824 };
825 lines.push(DiffLine {
826 origin,
827 old_lineno: line.old_lineno(),
828 new_lineno: line.new_lineno(),
829 content: String::from_utf8_lossy(line.content()).to_string(),
830 });
831 }
832 hunks.push(Hunk { header, lines });
833 }
834 Ok(hunks)
835}
836
837/// Build a [`Person`] from a libgit2 signature, converting the second-resolution
838/// time to epoch milliseconds.
839fn person(sig: &Signature<'_>) -> Person {
840 Person {
841 name: sig.name().unwrap_or_default().to_string(),
842 email: sig.email().unwrap_or_default().to_string(),
843 time_ms: sig.when().seconds().saturating_mul(1000),
844 }
845}
846
847/// Build a [`CommitSummary`] for a log row.
848fn commit_summary(commit: &Commit<'_>) -> CommitSummary {
849 CommitSummary {
850 oid: oid_of(commit.id()),
851 summary: commit.summary().unwrap_or("").to_string(),
852 author: person(&commit.author()),
853 parents: commit.parent_count(),
854 }
855}
856
857/// Heuristic binary sniff: a NUL byte in the head of the content marks it binary,
858/// matching git's own `core.check-blob-content` behaviour closely enough for a
859/// "don't try to render this" decision.
860fn looks_binary(bytes: &[u8]) -> bool {
861 let window = &bytes[..bytes.len().min(BINARY_SNIFF_BYTES)];
862 window.contains(&0)
863}
864
865/// Whether a blob's content is a git-LFS pointer (its mandatory first line).
866fn is_lfs_pointer(content: &[u8]) -> bool {
867 content.starts_with(b"version https://git-lfs.github.com/spec/v1")
868}
869
870/// Parse a `.gitmodules` file into a `submodule path → url` map. The format is
871/// git-config: `[submodule "name"]` sections each with `path` and `url` keys.
872fn parse_gitmodules(content: &[u8]) -> HashMap<String, String> {
873 let text = String::from_utf8_lossy(content);
874 let mut map = HashMap::new();
875 let mut path: Option<String> = None;
876 let mut url: Option<String> = None;
877 for line in text.lines() {
878 let line = line.trim();
879 if line.starts_with('[') {
880 if let (Some(p), Some(u)) = (path.take(), url.take()) {
881 map.insert(p, u);
882 }
883 } else if let Some(rest) = line.strip_prefix("path") {
884 if let Some(v) = rest.trim_start().strip_prefix('=') {
885 path = Some(v.trim().to_string());
886 }
887 } else if let Some(rest) = line.strip_prefix("url") {
888 if let Some(v) = rest.trim_start().strip_prefix('=') {
889 url = Some(v.trim().to_string());
890 }
891 }
892 }
893 if let (Some(p), Some(u)) = (path, url) {
894 map.insert(p, u);
895 }
896 map
897}
898
899#[cfg(test)]
900mod tests {
901 #![allow(clippy::unwrap_used, clippy::expect_used)]
902
903 use std::path::{Path, PathBuf};
904
905 use git2::{Repository, Signature, Time};
906 use tempfile::TempDir;
907
908 use super::*;
909 use crate::{create_bare, repo_path};
910
911 #[test]
912 fn detects_lfs_pointer_content() {
913 let pointer = b"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 12\n";
914 assert!(is_lfs_pointer(pointer));
915 assert!(!is_lfs_pointer(b"#!/bin/sh\necho hi\n"));
916 assert!(!is_lfs_pointer(b""));
917 }
918
919 #[test]
920 fn parses_gitmodules_path_and_url() {
921 let text = b"[submodule \"vendor/lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n\
922 [submodule \"x\"]\n\tpath = tools/x\n\turl = git@host:org/x.git\n";
923 let map = parse_gitmodules(text);
924 assert_eq!(
925 map.get("vendor/lib").map(String::as_str),
926 Some("https://example.com/lib.git")
927 );
928 assert_eq!(
929 map.get("tools/x").map(String::as_str),
930 Some("git@host:org/x.git")
931 );
932 }
933
934 const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x";
935
936 /// A populated bare repository plus the temp dir keeping it alive.
937 struct Fixture {
938 _dir: TempDir,
939 path: PathBuf,
940 /// Oids of the commits made, in creation order.
941 commits: Vec<git2::Oid>,
942 }
943
944 /// Build a bare repo with:
945 /// - `main`: commit1 (README + src/main.rs), commit2 (README changed only);
946 /// - `feature`: commit3 on top of commit2 (adds src/extra.rs);
947 /// - an annotated tag `v1.0` on commit1 and a lightweight tag `light` on commit2.
948 fn fixture() -> Fixture {
949 let dir = tempfile::tempdir().unwrap();
950 create_bare(dir.path(), ID, "main", Path::new("/usr/bin/fabrica")).unwrap();
951 let path = repo_path(dir.path(), ID).unwrap();
952 let raw = Repository::open_bare(&path).unwrap();
953
954 let c1 = commit(
955 &raw,
956 "main",
957 &[],
958 &[("README.md", "hello\n"), ("src/main.rs", "fn main() {}\n")],
959 "initial import",
960 1,
961 );
962 let c2 = commit(
963 &raw,
964 "main",
965 &[c1],
966 &[
967 ("README.md", "hello world\n"),
968 ("src/main.rs", "fn main() {}\n"),
969 ],
970 "expand readme",
971 2,
972 );
973 let c3 = commit(
974 &raw,
975 "feature",
976 &[c2],
977 &[
978 ("README.md", "hello world\n"),
979 ("src/main.rs", "fn main() {}\n"),
980 ("src/extra.rs", "// extra\n"),
981 ],
982 "add extra module",
983 3,
984 );
985
986 // Tags: annotated on c1, lightweight on c2.
987 let obj = raw.find_object(c1, None).unwrap();
988 let tagger = Signature::new("Ada", "ada@example.com", &Time::new(10_000, 0)).unwrap();
989 raw.tag("v1.0", &obj, &tagger, "the first release", false)
990 .unwrap();
991 raw.reference("refs/tags/light", c2, true, "lightweight")
992 .unwrap();
993
994 Fixture {
995 _dir: dir,
996 path,
997 commits: vec![c1, c2, c3],
998 }
999 }
1000
1001 /// Write `files` (paths may contain one level of nesting) as a tree and commit
1002 /// it onto `branch`, returning the new commit oid. `t` seeds the commit time so
1003 /// ordering is deterministic.
1004 fn commit(
1005 raw: &Repository,
1006 branch: &str,
1007 parents: &[git2::Oid],
1008 files: &[(&str, &str)],
1009 message: &str,
1010 t: i64,
1011 ) -> git2::Oid {
1012 use std::collections::BTreeMap;
1013
1014 // Group files by their (single) directory prefix.
1015 let mut by_dir: BTreeMap<Option<String>, Vec<(String, git2::Oid)>> = BTreeMap::new();
1016 for (path, content) in files {
1017 let blob = raw.blob(content.as_bytes()).unwrap();
1018 let (dir, name) = match path.split_once('/') {
1019 Some((dir, name)) => (Some(dir.to_string()), name.to_string()),
1020 None => (None, (*path).to_string()),
1021 };
1022 by_dir.entry(dir).or_default().push((name, blob));
1023 }
1024
1025 let mut root = raw.treebuilder(None).unwrap();
1026 for (dir, entries) in by_dir {
1027 match dir {
1028 None => {
1029 for (name, blob) in entries {
1030 root.insert(&name, blob, 0o100_644).unwrap();
1031 }
1032 }
1033 Some(dir) => {
1034 let mut sub = raw.treebuilder(None).unwrap();
1035 for (name, blob) in entries {
1036 sub.insert(&name, blob, 0o100_644).unwrap();
1037 }
1038 let sub_oid = sub.write().unwrap();
1039 root.insert(&dir, sub_oid, 0o040_000).unwrap();
1040 }
1041 }
1042 }
1043 let tree_oid = root.write().unwrap();
1044 let tree = raw.find_tree(tree_oid).unwrap();
1045
1046 let sig = Signature::new("Ada Lovelace", "ada@example.com", &Time::new(t, 0)).unwrap();
1047 let parent_commits: Vec<_> = parents
1048 .iter()
1049 .map(|p| raw.find_commit(*p).unwrap())
1050 .collect();
1051 let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect();
1052 raw.commit(
1053 Some(&format!("refs/heads/{branch}")),
1054 &sig,
1055 &sig,
1056 message,
1057 &tree,
1058 &parent_refs,
1059 )
1060 .unwrap()
1061 }
1062
1063 fn open(fx: &Fixture) -> Repo {
1064 Repo::open_path(&fx.path).unwrap()
1065 }
1066
1067 #[test]
1068 fn resolve_ref_covers_branch_tag_head_oid_and_miss() {
1069 let fx = fixture();
1070 let repo = open(&fx);
1071 let c2 = fx.commits[1].to_string();
1072
1073 let main = repo.resolve_ref("main").unwrap();
1074 assert_eq!(main.kind, RefKind::Branch);
1075 assert_eq!(main.oid.as_str(), c2);
1076
1077 let head = repo.resolve_ref("HEAD").unwrap();
1078 assert_eq!(head.kind, RefKind::Head);
1079 assert_eq!(head.oid.as_str(), c2, "HEAD tracks the default branch");
1080
1081 let tag = repo.resolve_ref("v1.0").unwrap();
1082 assert_eq!(tag.kind, RefKind::Tag);
1083 assert_eq!(tag.oid.as_str(), fx.commits[0].to_string());
1084
1085 let raw = repo.resolve_ref(&c2).unwrap();
1086 assert_eq!(raw.kind, RefKind::Commit);
1087 assert_eq!(raw.oid.as_str(), c2);
1088
1089 assert!(matches!(
1090 repo.resolve_ref("nope"),
1091 Err(GitError::RevNotFound(_))
1092 ));
1093 }
1094
1095 #[test]
1096 fn tree_entries_lists_root_and_subdir_dirs_first() {
1097 let fx = fixture();
1098 let repo = open(&fx);
1099 let main = repo.resolve_ref("main").unwrap();
1100
1101 let root = repo.tree_entries(&main, Path::new("")).unwrap();
1102 let names: Vec<_> = root.iter().map(|e| e.name.as_str()).collect();
1103 assert_eq!(names, ["src", "README.md"], "directories sort first");
1104 assert_eq!(root[0].kind, EntryKind::Directory);
1105 assert_eq!(root[1].kind, EntryKind::File);
1106 assert_eq!(root[1].size, Some("hello world\n".len() as u64));
1107
1108 let src = repo.tree_entries(&main, Path::new("src")).unwrap();
1109 assert_eq!(
1110 src.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
1111 ["main.rs"]
1112 );
1113
1114 assert!(matches!(
1115 repo.tree_entries(&main, Path::new("does/not/exist")),
1116 Err(GitError::PathNotFound(_))
1117 ));
1118 // A file path is not a directory.
1119 assert!(matches!(
1120 repo.tree_entries(&main, Path::new("README.md")),
1121 Err(GitError::PathNotFound(_))
1122 ));
1123 }
1124
1125 #[test]
1126 fn blob_reads_content_and_flags() {
1127 let fx = fixture();
1128 let repo = open(&fx);
1129 let main = repo.resolve_ref("main").unwrap();
1130
1131 let readme = repo.blob(&main, Path::new("README.md")).unwrap();
1132 assert_eq!(readme.content, b"hello world\n");
1133 assert_eq!(readme.size, 12);
1134 assert!(!readme.is_binary);
1135
1136 // A directory is not a file.
1137 assert!(matches!(
1138 repo.blob(&main, Path::new("src")),
1139 Err(GitError::NotAFile(_))
1140 ));
1141 assert!(matches!(
1142 repo.blob(&main, Path::new("missing")),
1143 Err(GitError::PathNotFound(_))
1144 ));
1145 }
1146
1147 #[test]
1148 fn branches_report_default_and_ahead_behind() {
1149 let fx = fixture();
1150 let repo = open(&fx);
1151 let branches = repo.branches().unwrap();
1152
1153 let names: Vec<_> = branches.iter().map(|b| b.name.as_str()).collect();
1154 assert_eq!(names, ["main", "feature"], "default sorts first");
1155
1156 let main = &branches[0];
1157 assert!(main.is_default);
1158 assert_eq!((main.ahead, main.behind), (0, 0));
1159
1160 let feature = &branches[1];
1161 assert!(!feature.is_default);
1162 // feature is one commit ahead of main and behind by none.
1163 assert_eq!((feature.ahead, feature.behind), (1, 0));
1164 assert_eq!(feature.tip.summary, "add extra module");
1165 }
1166
1167 #[test]
1168 fn tags_distinguish_annotated_from_lightweight() {
1169 let fx = fixture();
1170 let repo = open(&fx);
1171 let tags = repo.tags().unwrap();
1172
1173 let names: Vec<_> = tags.iter().map(|t| t.name.as_str()).collect();
1174 assert_eq!(names, ["light", "v1.0"]);
1175
1176 let light = tags.iter().find(|t| t.name == "light").unwrap();
1177 assert!(!light.annotated);
1178 assert_eq!(light.oid.as_str(), fx.commits[1].to_string());
1179 assert!(light.message.is_none());
1180
1181 let v1 = tags.iter().find(|t| t.name == "v1.0").unwrap();
1182 assert!(v1.annotated);
1183 assert_eq!(v1.oid.as_str(), fx.commits[0].to_string());
1184 assert_eq!(v1.message.as_deref(), Some("the first release"));
1185 assert_eq!(v1.tagger.as_ref().unwrap().name, "Ada");
1186 }
1187
1188 #[test]
1189 fn commits_walk_newest_first_and_filter_by_path() {
1190 let fx = fixture();
1191 let repo = open(&fx);
1192 let main = repo.resolve_ref("main").unwrap();
1193
1194 let all = repo.commits(&main, None, Page::first(10)).unwrap();
1195 let summaries: Vec<_> = all.iter().map(|c| c.summary.as_str()).collect();
1196 assert_eq!(summaries, ["expand readme", "initial import"]);
1197
1198 // Paging: skip the newest, take one.
1199 let page = repo
1200 .commits(
1201 &main,
1202 None,
1203 Page {
1204 offset: 1,
1205 limit: 1,
1206 },
1207 )
1208 .unwrap();
1209 assert_eq!(page.len(), 1);
1210 assert_eq!(page[0].summary, "initial import");
1211
1212 // src/main.rs was only introduced in commit1 and unchanged since.
1213 let touched = repo
1214 .commits(&main, Some(Path::new("src/main.rs")), Page::first(10))
1215 .unwrap();
1216 assert_eq!(
1217 touched
1218 .iter()
1219 .map(|c| c.summary.as_str())
1220 .collect::<Vec<_>>(),
1221 ["initial import"]
1222 );
1223 }
1224
1225 #[test]
1226 fn diff_reports_added_and_modified_files_with_hunks() {
1227 let fx = fixture();
1228 let repo = open(&fx);
1229 let c1 = Oid::new(fx.commits[0].to_string());
1230 let c2 = Oid::new(fx.commits[1].to_string());
1231
1232 // c1 vs empty tree: everything is an addition.
1233 let initial = repo.diff(None, &c1, DiffOpts::default()).unwrap();
1234 let paths: Vec<_> = initial
1235 .files
1236 .iter()
1237 .filter_map(|f| f.new_path.as_deref())
1238 .collect();
1239 assert!(paths.contains(&"README.md"));
1240 assert!(paths.contains(&"src/main.rs"));
1241 assert!(initial.files.iter().all(|f| f.status == DeltaStatus::Added));
1242
1243 // c1 -> c2 changed only README.md.
1244 let delta = repo.diff(Some(&c1), &c2, DiffOpts::default()).unwrap();
1245 assert_eq!(delta.files.len(), 1);
1246 let readme = &delta.files[0];
1247 assert_eq!(readme.new_path.as_deref(), Some("README.md"));
1248 assert_eq!(readme.status, DeltaStatus::Modified);
1249 assert!(!readme.is_binary);
1250 assert!(!readme.hunks.is_empty());
1251 // The change replaced "hello" with "hello world".
1252 let has_addition = readme
1253 .hunks
1254 .iter()
1255 .flat_map(|h| &h.lines)
1256 .any(|l| l.origin == LineOrigin::Addition && l.content.contains("hello world"));
1257 assert!(has_addition, "expected the new line in the hunk");
1258 }
1259
1260 #[test]
1261 fn diff_context_returns_post_image_lines() {
1262 let fx = fixture();
1263 let repo = open(&fx);
1264 let c2 = Oid::new(fx.commits[1].to_string());
1265
1266 // README.md at c2 is a single line "hello world\n".
1267 let lines = repo
1268 .diff_context(&c2, Path::new("README.md"), 1, 5)
1269 .unwrap();
1270 assert_eq!(lines.len(), 1, "clamped to the file length");
1271 assert_eq!(lines[0].origin, LineOrigin::Context);
1272 assert_eq!(lines[0].new_lineno, Some(1));
1273 assert_eq!(lines[0].content, "hello world\n");
1274 }
1275
1276 #[test]
1277 fn last_commit_per_entry_attributes_the_right_change() {
1278 let fx = fixture();
1279 let repo = open(&fx);
1280 let main = repo.resolve_ref("main").unwrap();
1281
1282 let latest = repo
1283 .last_commit_per_entry(&main, Path::new(""), 100)
1284 .unwrap();
1285 // README.md last changed in commit2; src last changed in commit1 (its
1286 // subtree oid is unchanged since).
1287 assert_eq!(latest["README.md"].summary, "expand readme");
1288 assert_eq!(latest["src"].summary, "initial import");
1289 }
1290
1291 #[test]
1292 fn last_commit_per_entry_degrades_under_a_tight_cap() {
1293 let fx = fixture();
1294 let repo = open(&fx);
1295 let main = repo.resolve_ref("main").unwrap();
1296
1297 // With a cap of 1 commit, only entries changed by the tip resolve.
1298 let capped = repo.last_commit_per_entry(&main, Path::new(""), 1).unwrap();
1299 assert_eq!(
1300 capped.get("README.md").map(|c| c.summary.as_str()),
1301 Some("expand readme")
1302 );
1303 assert!(
1304 !capped.contains_key("src"),
1305 "src is not reachable within the cap"
1306 );
1307 }
1308
1309 #[test]
1310 fn commits_all_branches_dedupes_and_covers_feature() {
1311 let fx = fixture();
1312 let repo = open(&fx);
1313 let all = repo.commits_all_branches(50).unwrap();
1314 // main has 2 commits, feature adds 1 on top; the shared two are counted
1315 // once, so three distinct commits total.
1316 assert_eq!(all.len(), 3, "deduplicated across branches");
1317 // The feature-only commit is present, attributed to `feature`.
1318 let feat = all.iter().find(|(c, _)| c.summary == "add extra module");
1319 assert_eq!(feat.map(|(_, b)| b.as_str()), Some("feature"));
1320 }
1321
1322 #[test]
1323 fn commit_returns_full_detail() {
1324 let fx = fixture();
1325 let repo = open(&fx);
1326 let c2 = Oid::new(fx.commits[1].to_string());
1327
1328 let detail = repo.commit(&c2).unwrap();
1329 assert_eq!(detail.message, "expand readme");
1330 assert_eq!(detail.author.name, "Ada Lovelace");
1331 assert_eq!(detail.author.email, "ada@example.com");
1332 assert_eq!(detail.parents, vec![Oid::new(fx.commits[0].to_string())]);
1333
1334 assert!(matches!(
1335 repo.commit(&Oid::new("0".repeat(40))),
1336 Err(GitError::RevNotFound(_))
1337 ));
1338 }
1339}