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
40pub use crate::repo::Repo;40pub use crate::repo::Repo;
41pub use crate::types::{41pub use crate::types::{
42 Blob, BranchInfo, CommitDetail, CommitSummary, EntryKind, Oid, Page, Person, RefKind, Resolved,42 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
43 TagInfo, TreeEntry,43 FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo,
44 TreeEntry,
44};45};
4546
46/// An error from the git layer.47/// An error from the git layer.
crates/git/src/repo.rs +310 −3
@@ -8,14 +8,19 @@
8//! `spawn_blocking` requirement. The public surface speaks only in the plain8//! `spawn_blocking` requirement. The public surface speaks only in the plain
9//! [`crate::types`] vocabulary — `git2` handles never escape this module.9//! [`crate::types`] vocabulary — `git2` handles never escape this module.
1010
11use std::collections::HashMap;
11use std::path::Path;12use std::path::Path;
1213
13use git2::{BranchType, Commit, Repository, Signature, Sort, TreeEntry as GitTreeEntry};14use git2::{
15 BranchType, Commit, Delta, DiffFindOptions, DiffLineType, DiffOptions, Patch, Repository,
16 Signature, Sort, Tree, TreeEntry as GitTreeEntry,
17};
1418
15use crate::GitError;19use crate::GitError;
16use crate::types::{20use crate::types::{
17 Blob, BranchInfo, CommitDetail, CommitSummary, EntryKind, Oid, Page, Person, RefKind, Resolved,21 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
18 TagInfo, TreeEntry,22 FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo,
23 TreeEntry,
19};24};
2025
21/// Filemode bits git uses to distinguish tree-entry kinds.26/// Filemode bits git uses to distinguish tree-entry kinds.
@@ -295,6 +300,179 @@ impl Repo {
295 })300 })
296 }301 }
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
298 /// Look up a commit by our [`Oid`], mapping a miss to [`GitError::RevNotFound`].476 /// Look up a commit by our [`Oid`], mapping a miss to [`GitError::RevNotFound`].
299 fn commit_at(&self, oid: &Oid) -> Result<Commit<'_>, GitError> {477 fn commit_at(&self, oid: &Oid) -> Result<Commit<'_>, GitError> {
300 let parsed = git2::Oid::from_str(oid.as_str())478 let parsed = git2::Oid::from_str(oid.as_str())
@@ -356,6 +534,51 @@ fn oid_of(oid: git2::Oid) -> Oid {
356 Oid::new(oid.to_string())534 Oid::new(oid.to_string())
357}535}
358536
537/// Render a repo-relative path as a string for a [`FileDiff`].
538fn 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`.
544fn 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.
555fn 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
359/// Build a [`Person`] from a libgit2 signature, converting the second-resolution582/// Build a [`Person`] from a libgit2 signature, converting the second-resolution
360/// time to epoch milliseconds.583/// time to epoch milliseconds.
361fn person(sig: &Signature<'_>) -> Person {584fn person(sig: &Signature<'_>) -> Person {
@@ -688,6 +911,90 @@ mod tests {
688 }911 }
689912
690 #[test]913 #[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]
691 fn commit_returns_full_detail() {998 fn commit_returns_full_detail() {
692 let fx = fixture();999 let fx = fixture();
693 let repo = open(&fx);1000 let repo = open(&fx);
crates/git/src/types.rs +84 −0
@@ -182,6 +182,90 @@ pub struct TagInfo {
182 pub tagger: Option<Person>,182 pub tagger: Option<Person>,
183}183}
184184
185/// How a file changed between two trees.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub 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)]
202pub 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)]
213pub 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)]
227pub 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)]
236pub 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)]
251pub 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)]
258pub struct DiffOpts {
259 /// Lines of unchanged context to include around each change.
260 pub context_lines: u32,
261}
262
263impl Default for DiffOpts {
264 fn default() -> Self {
265 Self { context_lines: 3 }
266 }
267}
268
185/// A half-open slice of a listing: skip `offset`, take `limit`.269/// A half-open slice of a listing: skip `offset`, take `limit`.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct Page {271pub struct Page {