fabrica

hanna/fabrica

feat(git): diff, context expansion, and per-entry last commit

47f3255 · hanna committed on 2026-07-25

Complete the §3.7 read API. diff() renders per-file changes between two
revisions (base=None diffs against the empty tree for root commits), with
rename detection, delta status, binary detection, and hunks whose lines carry
old/new line numbers. diff_context() returns a file's post-image lines over a
1-based range for "expand context" between hunks. last_commit_per_entry() runs
a single path-scoped revwalk capped at a caller-supplied limit, attributing each
tree entry to the commit that last changed it and degrading (entries simply
absent) rather than blocking when the cap is hit.

Tests cover added/modified deltas with hunk contents, context clamping, correct
attribution, and cap degradation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
3 files changed · +397 −5UnifiedSplit
crates/git/src/lib.rs +3 −2
@@ -39,8 +39,9 @@ use git2::{Repository, RepositoryInitOptions};
3939
4040 pub use crate::repo::Repo;
4141 pub use crate::types::{
42- Blob, BranchInfo, CommitDetail, CommitSummary, EntryKind, Oid, Page, Person, RefKind, Resolved,
43- TagInfo, TreeEntry,
42+ Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
43+ FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo,
44+ TreeEntry,
4445 };
4546
4647 /// An error from the git layer.
crates/git/src/repo.rs +310 −3
@@ -8,14 +8,19 @@
88 //! `spawn_blocking` requirement. The public surface speaks only in the plain
99 //! [`crate::types`] vocabulary — `git2` handles never escape this module.
1010
11+use std::collections::HashMap;
1112 use std::path::Path;
1213
13-use git2::{BranchType, Commit, Repository, Signature, Sort, TreeEntry as GitTreeEntry};
14+use git2::{
15+ BranchType, Commit, Delta, DiffFindOptions, DiffLineType, DiffOptions, Patch, Repository,
16+ Signature, Sort, Tree, TreeEntry as GitTreeEntry,
17+};
1418
1519 use crate::GitError;
1620 use crate::types::{
17- Blob, BranchInfo, CommitDetail, CommitSummary, EntryKind, Oid, Page, Person, RefKind, Resolved,
18- TagInfo, TreeEntry,
21+ Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
22+ FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo,
23+ TreeEntry,
1924 };
2025
2126 /// Filemode bits git uses to distinguish tree-entry kinds.
@@ -295,6 +300,179 @@ impl Repo {
295300 })
296301 }
297302
303+ /// Diff two revisions file by file. `base` of `None` diffs against the empty
304+ /// tree, so a root commit (or an explicit "show everything") renders as all
305+ /// additions; callers wanting a commit's own diff pass its parent as `base`.
306+ ///
307+ /// Rename detection is enabled. Binary files carry no hunks.
308+ ///
309+ /// # Errors
310+ ///
311+ /// Returns [`GitError::RevNotFound`] if a revision is unknown, or
312+ /// [`GitError::Libgit2`] on a diff failure.
313+ pub fn diff(
314+ &self,
315+ base: Option<&Oid>,
316+ head: &Oid,
317+ opts: DiffOpts,
318+ ) -> Result<FileDiffs, GitError> {
319+ let head_tree = self.commit_at(head)?.tree()?;
320+ let base_tree = match base {
321+ Some(oid) => Some(self.commit_at(oid)?.tree()?),
322+ None => None,
323+ };
324+
325+ let mut diff_opts = DiffOptions::new();
326+ diff_opts.context_lines(opts.context_lines);
327+ let mut diff = self.inner.diff_tree_to_tree(
328+ base_tree.as_ref(),
329+ Some(&head_tree),
330+ Some(&mut diff_opts),
331+ )?;
332+ diff.find_similar(Some(DiffFindOptions::new().renames(true)))?;
333+
334+ let mut files = Vec::new();
335+ for idx in 0..diff.deltas().len() {
336+ let Some(delta) = diff.get_delta(idx) else {
337+ continue;
338+ };
339+ let patch = Patch::from_diff(&diff, idx)?;
340+ let is_binary = patch.is_none() || delta.flags().is_binary();
341+ let hunks = match &patch {
342+ Some(patch) if !is_binary => extract_hunks(patch)?,
343+ _ => Vec::new(),
344+ };
345+ files.push(FileDiff {
346+ old_path: delta.old_file().path().map(path_string),
347+ new_path: delta.new_file().path().map(path_string),
348+ status: map_status(delta.status()),
349+ is_binary,
350+ hunks,
351+ });
352+ }
353+ Ok(FileDiffs { files })
354+ }
355+
356+ /// Return the post-image lines of `file` at revision `head` for the 1-based,
357+ /// inclusive range `[from, to]`, as context lines. This powers "expand
358+ /// context" between hunks; the range is clamped to the file's length.
359+ ///
360+ /// # Errors
361+ ///
362+ /// Returns [`GitError::PathNotFound`]/[`GitError::NotAFile`] if `file` is not a
363+ /// readable blob at `head`.
364+ pub fn diff_context(
365+ &self,
366+ head: &Oid,
367+ file: &Path,
368+ from: u32,
369+ to: u32,
370+ ) -> Result<Vec<DiffLine>, GitError> {
371+ let commit = self.commit_at(head)?;
372+ let entry = commit
373+ .tree()?
374+ .get_path(file)
375+ .map_err(|_| GitError::PathNotFound(file.display().to_string()))?;
376+ let blob = entry
377+ .to_object(&self.inner)?
378+ .into_blob()
379+ .map_err(|_| GitError::NotAFile(file.display().to_string()))?;
380+ let text = String::from_utf8_lossy(blob.content());
381+ let lines: Vec<&str> = text.split_inclusive('\n').collect();
382+
383+ let start = from.saturating_sub(1) as usize;
384+ let end = (to as usize).min(lines.len());
385+ let mut out = Vec::new();
386+ for (offset, line) in lines.get(start..end).unwrap_or(&[]).iter().enumerate() {
387+ let lineno = from + u32::try_from(offset).unwrap_or(0);
388+ out.push(DiffLine {
389+ origin: LineOrigin::Context,
390+ old_lineno: None,
391+ new_lineno: Some(lineno),
392+ content: (*line).to_string(),
393+ });
394+ }
395+ Ok(out)
396+ }
397+
398+ /// For each entry directly under `path` in revision `rev`, the most recent
399+ /// commit that changed it. Powers the tree view's "latest change" column.
400+ ///
401+ /// Implemented as a single path-scoped revwalk, capped at `limit` commits (the
402+ /// classic performance trap). Entries not resolved within the cap are simply
403+ /// absent from the map, so the caller can drop the column rather than block.
404+ ///
405+ /// # Errors
406+ ///
407+ /// Returns [`GitError::Libgit2`] on a libgit2 failure.
408+ pub fn last_commit_per_entry(
409+ &self,
410+ rev: &Resolved,
411+ path: &Path,
412+ limit: usize,
413+ ) -> Result<HashMap<String, CommitSummary>, GitError> {
414+ let mut remaining: std::collections::HashSet<String> = self
415+ .tree_entries(rev, path)?
416+ .into_iter()
417+ .map(|entry| entry.name)
418+ .collect();
419+ let mut result = HashMap::new();
420+
421+ let start = git2::Oid::from_str(rev.oid.as_str())?;
422+ let mut walk = self.inner.revwalk()?;
423+ walk.set_sorting(Sort::TIME)?;
424+ walk.push(start)?;
425+
426+ for (count, oid) in walk.enumerate() {
427+ if remaining.is_empty() || count >= limit {
428+ break;
429+ }
430+ let commit = self.inner.find_commit(oid?)?;
431+ let here = self.subtree_at(&commit, path)?;
432+ let parent_tree = commit
433+ .parent(0)
434+ .ok()
435+ .map(|parent| self.subtree_at(&parent, path))
436+ .transpose()?
437+ .flatten();
438+
439+ let mut resolved = Vec::new();
440+ for name in &remaining {
441+ let here_oid = here.as_ref().and_then(|t| t.get_name(name)).map(|e| e.id());
442+ let parent_oid = parent_tree
443+ .as_ref()
444+ .and_then(|t| t.get_name(name))
445+ .map(|e| e.id());
446+ // The entry changed here, and still exists (added or modified).
447+ if here_oid != parent_oid && here_oid.is_some() {
448+ resolved.push(name.clone());
449+ }
450+ }
451+ for name in resolved {
452+ result.insert(name.clone(), commit_summary(&commit));
453+ remaining.remove(&name);
454+ }
455+ }
456+ Ok(result)
457+ }
458+
459+ /// The tree at `path` within a commit, or `None` if the path is absent or is
460+ /// not a directory. An empty `path` yields the root tree.
461+ fn subtree_at<'a>(
462+ &'a self,
463+ commit: &Commit<'a>,
464+ path: &Path,
465+ ) -> Result<Option<Tree<'a>>, GitError> {
466+ let root = commit.tree()?;
467+ if path.components().next().is_none() {
468+ return Ok(Some(root));
469+ }
470+ match root.get_path(path) {
471+ Ok(entry) => Ok(entry.to_object(&self.inner)?.into_tree().ok()),
472+ Err(_) => Ok(None),
473+ }
474+ }
475+
298476 /// Look up a commit by our [`Oid`], mapping a miss to [`GitError::RevNotFound`].
299477 fn commit_at(&self, oid: &Oid) -> Result<Commit<'_>, GitError> {
300478 let parsed = git2::Oid::from_str(oid.as_str())
@@ -356,6 +534,51 @@ fn oid_of(oid: git2::Oid) -> Oid {
356534 Oid::new(oid.to_string())
357535 }
358536
537+/// Render a repo-relative path as a string for a [`FileDiff`].
538+fn path_string(path: &Path) -> String {
539+ path.display().to_string()
540+}
541+
542+/// Map a libgit2 delta status to our [`DeltaStatus`], folding copies into renames
543+/// and the remaining exotic states into `Modified`.
544+fn map_status(status: Delta) -> DeltaStatus {
545+ match status {
546+ Delta::Added => DeltaStatus::Added,
547+ Delta::Deleted => DeltaStatus::Deleted,
548+ Delta::Renamed | Delta::Copied => DeltaStatus::Renamed,
549+ Delta::Typechange => DeltaStatus::TypeChange,
550+ _ => DeltaStatus::Modified,
551+ }
552+}
553+
554+/// Pull the hunks and their lines out of a libgit2 patch into our owned types.
555+fn extract_hunks(patch: &Patch<'_>) -> Result<Vec<Hunk>, GitError> {
556+ let mut hunks = Vec::with_capacity(patch.num_hunks());
557+ for h in 0..patch.num_hunks() {
558+ let (hunk, line_count) = patch.hunk(h)?;
559+ let header = String::from_utf8_lossy(hunk.header())
560+ .trim_end_matches(['\r', '\n'])
561+ .to_string();
562+ let mut lines = Vec::with_capacity(line_count);
563+ for l in 0..line_count {
564+ let line = patch.line_in_hunk(h, l)?;
565+ let origin = match line.origin_value() {
566+ DiffLineType::Addition | DiffLineType::AddEOFNL => LineOrigin::Addition,
567+ DiffLineType::Deletion | DiffLineType::DeleteEOFNL => LineOrigin::Deletion,
568+ _ => LineOrigin::Context,
569+ };
570+ lines.push(DiffLine {
571+ origin,
572+ old_lineno: line.old_lineno(),
573+ new_lineno: line.new_lineno(),
574+ content: String::from_utf8_lossy(line.content()).to_string(),
575+ });
576+ }
577+ hunks.push(Hunk { header, lines });
578+ }
579+ Ok(hunks)
580+}
581+
359582 /// Build a [`Person`] from a libgit2 signature, converting the second-resolution
360583 /// time to epoch milliseconds.
361584 fn person(sig: &Signature<'_>) -> Person {
@@ -688,6 +911,90 @@ mod tests {
688911 }
689912
690913 #[test]
914+ fn diff_reports_added_and_modified_files_with_hunks() {
915+ let fx = fixture();
916+ let repo = open(&fx);
917+ let c1 = Oid::new(fx.commits[0].to_string());
918+ let c2 = Oid::new(fx.commits[1].to_string());
919+
920+ // c1 vs empty tree: everything is an addition.
921+ let initial = repo.diff(None, &c1, DiffOpts::default()).unwrap();
922+ let paths: Vec<_> = initial
923+ .files
924+ .iter()
925+ .filter_map(|f| f.new_path.as_deref())
926+ .collect();
927+ assert!(paths.contains(&"README.md"));
928+ assert!(paths.contains(&"src/main.rs"));
929+ assert!(initial.files.iter().all(|f| f.status == DeltaStatus::Added));
930+
931+ // c1 -> c2 changed only README.md.
932+ let delta = repo.diff(Some(&c1), &c2, DiffOpts::default()).unwrap();
933+ assert_eq!(delta.files.len(), 1);
934+ let readme = &delta.files[0];
935+ assert_eq!(readme.new_path.as_deref(), Some("README.md"));
936+ assert_eq!(readme.status, DeltaStatus::Modified);
937+ assert!(!readme.is_binary);
938+ assert!(!readme.hunks.is_empty());
939+ // The change replaced "hello" with "hello world".
940+ let has_addition = readme
941+ .hunks
942+ .iter()
943+ .flat_map(|h| &h.lines)
944+ .any(|l| l.origin == LineOrigin::Addition && l.content.contains("hello world"));
945+ assert!(has_addition, "expected the new line in the hunk");
946+ }
947+
948+ #[test]
949+ fn diff_context_returns_post_image_lines() {
950+ let fx = fixture();
951+ let repo = open(&fx);
952+ let c2 = Oid::new(fx.commits[1].to_string());
953+
954+ // README.md at c2 is a single line "hello world\n".
955+ let lines = repo
956+ .diff_context(&c2, Path::new("README.md"), 1, 5)
957+ .unwrap();
958+ assert_eq!(lines.len(), 1, "clamped to the file length");
959+ assert_eq!(lines[0].origin, LineOrigin::Context);
960+ assert_eq!(lines[0].new_lineno, Some(1));
961+ assert_eq!(lines[0].content, "hello world\n");
962+ }
963+
964+ #[test]
965+ fn last_commit_per_entry_attributes_the_right_change() {
966+ let fx = fixture();
967+ let repo = open(&fx);
968+ let main = repo.resolve_ref("main").unwrap();
969+
970+ let latest = repo
971+ .last_commit_per_entry(&main, Path::new(""), 100)
972+ .unwrap();
973+ // README.md last changed in commit2; src last changed in commit1 (its
974+ // subtree oid is unchanged since).
975+ assert_eq!(latest["README.md"].summary, "expand readme");
976+ assert_eq!(latest["src"].summary, "initial import");
977+ }
978+
979+ #[test]
980+ fn last_commit_per_entry_degrades_under_a_tight_cap() {
981+ let fx = fixture();
982+ let repo = open(&fx);
983+ let main = repo.resolve_ref("main").unwrap();
984+
985+ // With a cap of 1 commit, only entries changed by the tip resolve.
986+ let capped = repo.last_commit_per_entry(&main, Path::new(""), 1).unwrap();
987+ assert_eq!(
988+ capped.get("README.md").map(|c| c.summary.as_str()),
989+ Some("expand readme")
990+ );
991+ assert!(
992+ !capped.contains_key("src"),
993+ "src is not reachable within the cap"
994+ );
995+ }
996+
997+ #[test]
691998 fn commit_returns_full_detail() {
692999 let fx = fixture();
6931000 let repo = open(&fx);
crates/git/src/types.rs +84 −0
@@ -182,6 +182,90 @@ pub struct TagInfo {
182182 pub tagger: Option<Person>,
183183 }
184184
185+/// How a file changed between two trees.
186+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187+pub enum DeltaStatus {
188+ /// The file was added.
189+ Added,
190+ /// The file was deleted.
191+ Deleted,
192+ /// The file's content changed.
193+ Modified,
194+ /// The file was renamed (and possibly also modified).
195+ Renamed,
196+ /// The file's mode changed (e.g. became executable) with no content change.
197+ TypeChange,
198+}
199+
200+/// Where a diff line came from.
201+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202+pub enum LineOrigin {
203+ /// Unchanged context, present on both sides.
204+ Context,
205+ /// A line only on the new side.
206+ Addition,
207+ /// A line only on the old side.
208+ Deletion,
209+}
210+
211+/// One line within a diff hunk, addressable by its line number on each side.
212+#[derive(Debug, Clone, PartialEq, Eq)]
213+pub struct DiffLine {
214+ /// Which side(s) the line belongs to.
215+ pub origin: LineOrigin,
216+ /// The 1-based line number on the old side, if present there.
217+ pub old_lineno: Option<u32>,
218+ /// The 1-based line number on the new side, if present there.
219+ pub new_lineno: Option<u32>,
220+ /// The line content, without the leading origin marker; the trailing newline
221+ /// is preserved as it appears in the file.
222+ pub content: String,
223+}
224+
225+/// A contiguous run of changed lines, with the classic `@@ -a,b +c,d @@` header.
226+#[derive(Debug, Clone, PartialEq, Eq)]
227+pub struct Hunk {
228+ /// The `@@ … @@` header line (without a trailing newline).
229+ pub header: String,
230+ /// The hunk's lines, in file order.
231+ pub lines: Vec<DiffLine>,
232+}
233+
234+/// The diff of a single file between two revisions.
235+#[derive(Debug, Clone, PartialEq, Eq)]
236+pub struct FileDiff {
237+ /// The path on the old side, for deletions and renames.
238+ pub old_path: Option<String>,
239+ /// The path on the new side, for additions and renames.
240+ pub new_path: Option<String>,
241+ /// How the file changed.
242+ pub status: DeltaStatus,
243+ /// Whether either side is binary (no textual hunks are produced then).
244+ pub is_binary: bool,
245+ /// The textual hunks; empty for binary or pure-mode changes.
246+ pub hunks: Vec<Hunk>,
247+}
248+
249+/// A whole diff: the per-file changes between two revisions.
250+#[derive(Debug, Clone, PartialEq, Eq)]
251+pub struct FileDiffs {
252+ /// One entry per changed file.
253+ pub files: Vec<FileDiff>,
254+}
255+
256+/// Options controlling diff generation.
257+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258+pub struct DiffOpts {
259+ /// Lines of unchanged context to include around each change.
260+ pub context_lines: u32,
261+}
262+
263+impl Default for DiffOpts {
264+ fn default() -> Self {
265+ Self { context_lines: 3 }
266+ }
267+}
268+
185269 /// A half-open slice of a listing: skip `offset`, take `limit`.
186270 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
187271 pub struct Page {