git-ents.gitmain
⌘K
foforge
commit dc82777
feat: add the git-anchor crate: blob anchors projected onto other commits

An Anchor records the commit, path, blob, optional 1-based line range, and exact snippet something was attached to — authoritative at creation and never mutated. project() is the read-time view Gerrit-style comment porting needs: a fast path when the target tree still holds the blob, a two-point tree diff with rename tracking to follow the file, and a hunk-walk over the blob diff that shifts the range past outside edits and reports Outdated when an edit touches it. Two-point diffing works forwards, backwards, and across unrelated commits alike.

feat: add Anchor, LineRange, and Projection feat: add capture building an anchor from a revision, path, and range feat: add project re-anchoring onto a target revision Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -1243,6 +1243,16 @@ "r-efi 6.0.0", ] +[[package]] +name = "git-anchor" +version = "0.0.0" +dependencies = [ + "facet", + "gix", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "git-ents" version = "0.0.0"
Cargo.toml @@ -1,6 +1,11 @@ [workspace] resolver = "3" -members = ["crates/git-ents", "crates/git-ents-server", "crates/git-store"] +members = [ + "crates/git-anchor", + "crates/git-ents", + "crates/git-ents-server", + "crates/git-store", +] [workspace.package] edition = "2024" @@ -40,6 +45,7 @@ getrandom = "0.4" facet-git-tree = { git = "https://github.com/git-ents/facet-git-tree" } form_urlencoded = "1" +git-anchor = { path = "crates/git-anchor" } git-store = { path = "crates/git-store" } gix = "0.84" gix-hash = { version = "0.25", features = ["sha1"] }
crates/git-anchor/Cargo.toml @@ -1,0 +1,17 @@ +[package] +name = "git-anchor" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +facet = { workspace = true } +gix = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true
crates/git-anchor/src/lib.rs @@ -1,0 +1,610 @@ +//! Blob-anchored positions and their forward projection onto later commits. +//! +//! An [`Anchor`] records exactly where in a repository something (a comment, a +//! review note) was attached: the commit it was written against, the path and +//! blob at that commit, an optional line range, and the anchored text itself. +//! The anchor is authoritative at creation and never mutated. [`project`] +//! answers, at read time, where that position sits on any *other* commit — +//! following renames through git's rewrite tracking and shifting line ranges +//! through the blob's diff hunks, the way git itself re-derives positions when +//! replaying diffs on rebase. +//! +//! Projection is a two-point tree diff, not a history walk: it compares the +//! anchor commit's tree directly against the target commit's tree, so it works +//! whether the target is a descendant, an ancestor, or an unrelated commit. +//! Blame answers the backwards question (which commit introduced a line); the +//! forward question asked here needs only the diff. + +use std::path::Path; + +use facet::Facet; +use gix::ObjectId; +use gix::bstr::ByteSlice as _; +use gix::diff::blob::{Algorithm, Diff, InternedInput}; +use gix::diff::tree_with_rewrites::Change; + +/// A failure opening the repository or resolving the objects an anchor names. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The repository could not be opened. + #[error("could not open the repository")] + Open(#[from] Box<gix::open::Error>), + /// A revision or object id could not be resolved to the object it names. + #[error("could not resolve {0:?}")] + Resolve(String), + /// A git object could not be read. + #[error("git object operation failed: {0}")] + Object(String), + /// The tree diff between the anchor commit and the target commit failed. + #[error("tree diff failed: {0}")] + Diff(String), + /// The anchor names a path that is not a file in its commit. + #[error("no file at {path:?} in {commit}")] + MissingPath { + /// The commit the path was looked up in. + commit: String, + /// The path that is not a file there. + path: String, + }, + /// The line range does not fit the file it is anchored to. + #[error("lines {start}..={end} do not fit {path:?} ({len} lines)")] + LinesOutOfRange { + /// The file the range was checked against. + path: String, + /// The 1-based first line of the range. + start: u64, + /// The 1-based last line of the range. + end: u64, + /// How many lines the file actually has. + len: u64, + }, +} + +/// A 1-based inclusive range of lines within an anchored file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)] +pub struct LineRange { + /// The first line of the range, 1-based. + pub start: u64, + /// The last line of the range, inclusive. + pub end: u64, +} + +/// Where in the repository something was attached — authoritative at creation +/// and never mutated afterwards. Projection onto other commits is always a +/// read-time view derived from this record. +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +pub struct Anchor { + /// The commit the anchor was created against, as a hex object id. + pub commit: String, + /// The repository-relative path of the anchored file at that commit. + pub path: String, + /// The object id of the anchored file's blob, as hex — an integrity check + /// and the fast path for "has this file changed at all". + pub blob: String, + /// The anchored lines, or `None` for a whole-file anchor. + pub lines: Option<LineRange>, + /// The exact text of the anchored lines (empty for a whole-file anchor), + /// kept for display and for fuzzy re-matching as a later enhancement. + pub snippet: String, +} + +/// Where an [`Anchor`] sits on a target commit, as computed by [`project`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Projection { + /// The target tree holds the anchor's exact blob at its exact path; the + /// anchor applies unchanged. + Current, + /// The file moved and/or its content shifted, but the anchored region + /// itself is intact — the anchor now applies at `path` and `lines`. + Relocated { + /// The anchored file's path in the target tree. + path: String, + /// The anchored lines mapped into the target blob, or `None` for a + /// whole-file anchor. + lines: Option<LineRange>, + }, + /// The file survives at `path` but the anchored lines were edited (or the + /// entry is no longer a regular file); the anchor no longer maps cleanly. + Outdated { + /// The anchored file's path in the target tree. + path: String, + }, + /// The anchored file does not exist in the target tree. + FileDeleted, +} + +/// Build the [`Anchor`] for `path` (and optionally `lines`) as it exists at +/// `revision` in `repo`, resolving the revision to a full commit id, recording +/// the file's blob id, and capturing the anchored lines' exact text. Fails +/// when the path is not a file at that commit or the range does not fit it. +pub fn capture( + repo: &Path, + revision: &str, + path: &str, + lines: Option<LineRange>, +) -> Result<Anchor, Error> { + let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?; + let commit = resolve_commit(&repo, revision)?; + let commit_id = commit.id().to_string(); + let tree = commit + .tree() + .map_err(|error| Error::Object(error.to_string()))?; + let entry = tree + .lookup_entry_by_path(path) + .map_err(|error| Error::Object(error.to_string()))? + .filter(|entry| entry.mode().is_blob()) + .ok_or_else(|| Error::MissingPath { + commit: commit_id.clone(), + path: path.to_owned(), + })?; + let blob = entry.object_id(); + let snippet = match lines { + None => String::new(), + Some(range) => { + let data = read_blob(&repo, blob)?; + let all: Vec<&[u8]> = data.lines_with_terminator().collect(); + let len = u64::try_from(all.len()).unwrap_or(u64::MAX); + let out_of_range = Error::LinesOutOfRange { + path: path.to_owned(), + start: range.start, + end: range.end, + len, + }; + if range.start == 0 || range.end < range.start || range.end > len { + return Err(out_of_range); + } + let first = usize::try_from(range.start) + .unwrap_or(usize::MAX) + .saturating_sub(1); + let last = usize::try_from(range.end).unwrap_or(usize::MAX); + let bytes = all.get(first..last).ok_or(out_of_range)?.concat(); + String::from_utf8_lossy(&bytes).into_owned() + } + }; + Ok(Anchor { + commit: commit_id, + path: path.to_owned(), + blob: blob.to_string(), + lines, + snippet, + }) +} + +/// Project `anchor` onto `target` (a revision in `repo`): the fast path +/// returns [`Projection::Current`] when the target tree holds the anchor's +/// blob at its path; otherwise the anchor commit's tree is diffed against the +/// target's with rename tracking to find where the file went, and the line +/// range is mapped through the blob diff's hunks — shifted past edits that +/// land entirely outside it, [`Projection::Outdated`] when an edit touches it. +pub fn project(repo: &Path, anchor: &Anchor, target: &str) -> Result<Projection, Error> { + let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?; + let anchor_blob = ObjectId::from_hex(anchor.blob.as_bytes()) + .map_err(|_error| Error::Resolve(anchor.blob.clone()))?; + let target_commit = resolve_commit(&repo, target)?; + let target_tree = target_commit + .tree() + .map_err(|error| Error::Object(error.to_string()))?; + + if let Some(entry) = target_tree + .lookup_entry_by_path(&anchor.path) + .map_err(|error| Error::Object(error.to_string()))? + && entry.mode().is_blob() + && entry.object_id() == anchor_blob + { + return Ok(Projection::Current); + } + + let anchor_commit = resolve_commit(&repo, &anchor.commit)?; + let anchor_tree = anchor_commit + .tree() + .map_err(|error| Error::Object(error.to_string()))?; + // Rename tracking is pinned to git's defaults (50% similarity, no copies) + // rather than read from repository configuration, so a projection is the + // same answer everywhere the repository is checked out. + let options = gix::diff::Options::default().with_rewrites(Some(gix::diff::Rewrites::default())); + let changes = repo + .diff_tree_to_tree(Some(&anchor_tree), Some(&target_tree), options) + .map_err(|error| Error::Diff(error.to_string()))?; + + // Find where the anchored path went: its old-side location is `location` + // for a deletion or modification and `source_location` for a rename. + let mut destination: Option<(String, ObjectId, bool)> = None; + for change in changes { + match change { + Change::Deletion { location, .. } if location.as_bytes() == anchor.path.as_bytes() => { + return Ok(Projection::FileDeleted); + } + Change::Modification { + location, + id, + entry_mode, + .. + } if location.as_bytes() == anchor.path.as_bytes() => { + destination = Some((anchor.path.clone(), id, entry_mode.is_blob())); + break; + } + Change::Rewrite { + source_location, + location, + id, + entry_mode, + copy: false, + .. + } if source_location.as_bytes() == anchor.path.as_bytes() => { + destination = Some(( + location.to_str_lossy().into_owned(), + id, + entry_mode.is_blob(), + )); + break; + } + _ => {} + } + } + let Some((path, blob, is_blob)) = destination else { + // The diff never touched the path, yet the fast path did not match: + // the anchor's blob is not what its own commit holds there, so the + // anchor itself is broken. + return Err(Error::MissingPath { + commit: anchor.commit.clone(), + path: anchor.path.clone(), + }); + }; + if !is_blob { + return Ok(Projection::Outdated { path }); + } + if blob == anchor_blob { + // A pure rename: the content is byte-identical, so every line is + // exactly where it was. + return Ok(Projection::Relocated { + path, + lines: anchor.lines, + }); + } + let lines = match anchor.lines { + None => None, + Some(range) => { + let old = read_blob(&repo, anchor_blob)?; + let new = read_blob(&repo, blob)?; + match map_range(&old, &new, range) { + Some(mapped) => Some(mapped), + None => return Ok(Projection::Outdated { path }), + } + } + }; + Ok(Projection::Relocated { path, lines }) +} + +/// Resolve `revision` (a hex id, ref name, or revspec) to the commit it names. +fn resolve_commit<'repo>( + repo: &'repo gix::Repository, + revision: &str, +) -> Result<gix::Commit<'repo>, Error> { + let resolve = |_ignored| Error::Resolve(revision.to_owned()); + repo.rev_parse_single(revision) + .map_err(|error| resolve(error.to_string()))? + .object() + .map_err(|error| resolve(error.to_string()))? + .peel_to_kind(gix::object::Kind::Commit) + .map_err(|error| resolve(error.to_string()))? + .try_into_commit() + .map_err(|error| resolve(error.to_string())) +} + +/// Read the full contents of the blob at `id`. +fn read_blob(repo: &gix::Repository, id: ObjectId) -> Result<Vec<u8>, Error> { + Ok(repo + .find_blob(id) + .map_err(|error| Error::Object(error.to_string()))? + .take_data()) +} + +/// Map the 1-based inclusive `range` from `old`'s lines to `new`'s by walking +/// the diff's hunks in order: a hunk entirely above the range shifts it by +/// the hunk's growth, a hunk entirely below is ignored, and any hunk touching +/// the range — including an insertion strictly inside it — means the anchored +/// region itself changed, reported as `None` (outdated) rather than guessed +/// at. +fn map_range(old: &[u8], new: &[u8], range: LineRange) -> Option<LineRange> { + // Work in 0-based half-open line coordinates, as the hunks do. + let start = i64::try_from(range.start).ok()?.checked_sub(1)?; + let end = i64::try_from(range.end).ok()?; + if start < 0 || end <= start { + return None; + } + let input = InternedInput::new(old, new); + if end > i64::try_from(input.before.len()).ok()? { + return None; + } + let diff = Diff::compute(Algorithm::Histogram, &input); + let mut offset: i64 = 0; + for hunk in diff.hunks() { + let before_start = i64::from(hunk.before.start); + let before_end = i64::from(hunk.before.end); + if before_end <= start { + let removed = before_end.saturating_sub(before_start); + let added = i64::from(hunk.after.end).saturating_sub(i64::from(hunk.after.start)); + offset = offset.saturating_add(added.saturating_sub(removed)); + } else if before_start >= end { + break; + } else { + return None; + } + } + Some(LineRange { + start: u64::try_from(start.saturating_add(offset).saturating_add(1)).ok()?, + end: u64::try_from(end.saturating_add(offset)).ok()?, + }) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::let_underscore_must_use, + reason = "unit test" + )] + + use std::path::Path; + use std::process::Command; + + use super::*; + + fn repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let status = Command::new("git") + .arg("-C") + .arg(dir.path()) + .args(["init", "-q"]) + .status() + .unwrap(); + assert!(status.success()); + dir + } + + fn commit_all(dir: &Path, message: &str) { + let status = Command::new("git") + .arg("-C") + .arg(dir) + .args(["add", "-A"]) + .status() + .unwrap(); + assert!(status.success()); + let status = Command::new("git") + .arg("-C") + .arg(dir) + .args([ + "-c", + "user.name=test", + "-c", + "user.email=test@example.com", + "commit", + "-q", + "-m", + message, + ]) + .status() + .unwrap(); + assert!(status.success()); + } + + fn head(dir: &Path) -> String { + let output = Command::new("git") + .arg("-C") + .arg(dir) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + } + + fn numbered(range: std::ops::RangeInclusive<u32>) -> String { + range.map(|n| format!("line {n}\n")).collect() + } + + fn range(start: u64, end: u64) -> Option<LineRange> { + Some(LineRange { start, end }) + } + + #[test] + fn capture_records_the_commit_blob_and_snippet() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + + let anchor = capture(dir.path(), "HEAD", "file.txt", range(3, 4)).unwrap(); + assert_eq!(anchor.commit, head(dir.path())); + assert_eq!(anchor.path, "file.txt"); + assert_eq!(anchor.snippet, "line 3\nline 4\n"); + assert_eq!(anchor.lines, range(3, 4)); + assert!(!anchor.blob.is_empty()); + } + + #[test] + fn capture_rejects_a_missing_path_and_an_oversized_range() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=3)).unwrap(); + commit_all(dir.path(), "one"); + + assert!(matches!( + capture(dir.path(), "HEAD", "absent.txt", None), + Err(Error::MissingPath { .. }) + )); + assert!(matches!( + capture(dir.path(), "HEAD", "file.txt", range(2, 9)), + Err(Error::LinesOutOfRange { len: 3, .. }) + )); + } + + #[test] + fn unchanged_file_projects_as_current() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let anchor = capture(dir.path(), "HEAD", "file.txt", range(3, 4)).unwrap(); + + std::fs::write(dir.path().join("other.txt"), "unrelated\n").unwrap(); + commit_all(dir.path(), "two"); + + assert_eq!( + project(dir.path(), &anchor, "HEAD").unwrap(), + Projection::Current + ); + } + + #[test] + fn an_edit_above_the_range_shifts_it() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let anchor = capture(dir.path(), "HEAD", "file.txt", range(5, 6)).unwrap(); + + let edited = format!("added a\nadded b\n{}", numbered(1..=10)); + std::fs::write(dir.path().join("file.txt"), edited).unwrap(); + commit_all(dir.path(), "two"); + + assert_eq!( + project(dir.path(), &anchor, "HEAD").unwrap(), + Projection::Relocated { + path: "file.txt".to_owned(), + lines: range(7, 8), + } + ); + } + + #[test] + fn an_edit_inside_the_range_is_outdated() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let anchor = capture(dir.path(), "HEAD", "file.txt", range(5, 6)).unwrap(); + + let edited = numbered(1..=10).replace("line 5\n", "line five\n"); + std::fs::write(dir.path().join("file.txt"), edited).unwrap(); + commit_all(dir.path(), "two"); + + assert_eq!( + project(dir.path(), &anchor, "HEAD").unwrap(), + Projection::Outdated { + path: "file.txt".to_owned(), + } + ); + } + + #[test] + fn a_pure_rename_relocates_with_the_same_lines() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let anchor = capture(dir.path(), "HEAD", "file.txt", range(3, 4)).unwrap(); + + std::fs::rename(dir.path().join("file.txt"), dir.path().join("moved.txt")).unwrap(); + commit_all(dir.path(), "two"); + + assert_eq!( + project(dir.path(), &anchor, "HEAD").unwrap(), + Projection::Relocated { + path: "moved.txt".to_owned(), + lines: range(3, 4), + } + ); + } + + #[test] + fn a_rename_with_an_edit_above_relocates_and_shifts() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let anchor = capture(dir.path(), "HEAD", "file.txt", range(5, 6)).unwrap(); + + std::fs::remove_file(dir.path().join("file.txt")).unwrap(); + let edited = format!("added a\n{}", numbered(1..=10)); + std::fs::write(dir.path().join("moved.txt"), edited).unwrap(); + commit_all(dir.path(), "two"); + + assert_eq!( + project(dir.path(), &anchor, "HEAD").unwrap(), + Projection::Relocated { + path: "moved.txt".to_owned(), + lines: range(6, 7), + } + ); + } + + #[test] + fn a_deleted_file_projects_as_deleted() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let anchor = capture(dir.path(), "HEAD", "file.txt", range(3, 4)).unwrap(); + + std::fs::remove_file(dir.path().join("file.txt")).unwrap(); + std::fs::write(dir.path().join("unrelated.txt"), "different content\n").unwrap(); + commit_all(dir.path(), "two"); + + assert_eq!( + project(dir.path(), &anchor, "HEAD").unwrap(), + Projection::FileDeleted + ); + } + + #[test] + fn a_whole_file_anchor_survives_a_modification() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let anchor = capture(dir.path(), "HEAD", "file.txt", None).unwrap(); + assert_eq!(anchor.snippet, ""); + + let edited = numbered(1..=10).replace("line 5\n", "line five\n"); + std::fs::write(dir.path().join("file.txt"), edited).unwrap(); + commit_all(dir.path(), "two"); + + assert_eq!( + project(dir.path(), &anchor, "HEAD").unwrap(), + Projection::Relocated { + path: "file.txt".to_owned(), + lines: None, + } + ); + } + + #[test] + fn projection_works_backwards_onto_an_ancestor() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let old = head(dir.path()); + + let edited = format!("added a\n{}", numbered(1..=10)); + std::fs::write(dir.path().join("file.txt"), edited).unwrap(); + commit_all(dir.path(), "two"); + let anchor = capture(dir.path(), "HEAD", "file.txt", range(6, 7)).unwrap(); + + assert_eq!( + project(dir.path(), &anchor, &old).unwrap(), + Projection::Relocated { + path: "file.txt".to_owned(), + lines: range(5, 6), + } + ); + } + + #[test] + fn map_range_handles_edges() { + let old = b"a\nb\nc\nd\n".as_slice(); + // An insertion exactly at the range start shifts it; one exactly at + // its end leaves it alone. + let above = b"x\na\nb\nc\nd\n".as_slice(); + assert_eq!( + map_range(old, above, LineRange { start: 2, end: 3 }), + Some(LineRange { start: 3, end: 4 }) + ); + // An insertion strictly inside the range outdates it. + let inside = b"a\nb\nx\nc\nd\n".as_slice(); + assert_eq!(map_range(old, inside, LineRange { start: 2, end: 3 }), None); + // A range past the end of the old file cannot map. + assert_eq!(map_range(old, old, LineRange { start: 4, end: 9 }), None); + } +}