git-ents.gitmain
⌘K
foforge
commit be1e3c6
anchor: add ents-anchor crate (capture, retention, projection, fuzzy fallback)

Port the pre-redo git-anchor projection (two-point tree diff with rename tracking plus hunk-walk range mapping, and the context-window fuzzy fallback) and re-home it on the new retention rule: the anchor struct embeds the anchored blob and a fresh context blob as ordinary tree entries via facet-git-tree, never a gitlink, so the anchored content stays reachable from the storing document’s own ref. Reads take a gix::Repository instead of opening a path per call, snippet/context derive from the embedded bytes rather than the odb, and project() degrades to the context match itself once the anchored commit is gone.

Covers anchor.definition, anchor.immutable, anchor.retention, anchor.projection, anchor.fuzzy-fallback.

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 @@ -369,6 +369,20 @@ "cfg-if", ] +[[package]] +name = "ents-anchor" +version = "0.0.0" +dependencies = [ + "ents-model", + "facet", + "facet-git-tree", + "gix", + "proptest", + "rstest", + "tempfile", + "thiserror", +] + [[package]] name = "ents-gate" version = "0.0.0"
Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "3" members = [ + "crates/ents-anchor", "crates/ents-gate", "crates/ents-model", "crates/ents-query",
crates/ents-anchor/Cargo.toml @@ -1,0 +1,21 @@ +[package] +name = "ents-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] +ents-model = { path = "../ents-model" } +facet-git-tree = { workspace = true } +proptest = { workspace = true } +rstest = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true
crates/ents-anchor/src/anchor.rs @@ -1,0 +1,360 @@ +//! [`Anchor`] itself: identity, embedded retention, and capture. +//! +//! Spec coverage: `anchor.definition`, `anchor.immutable`, `anchor.retention`. + +use facet::Facet; +use gix::ObjectId; +use gix::bstr::ByteSlice as _; + +use crate::error::{Error, Result}; +use crate::util::{lines_of, read_blob, resolve_commit}; + +/// A 1-based inclusive range of lines within an anchored file. +/// +/// # Examples +/// +/// ``` +/// use ents_anchor::LineRange; +/// +/// let range = LineRange { start: 3, end: 4 }; +/// assert_eq!(range.end - range.start + 1, 2, "two lines, inclusive"); +/// ``` +#[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, +} + +/// How many lines of surrounding source [`capture`] retains on each side of +/// an anchored range as `context` — enough for +/// [`crate::project_from_context`]'s line-window scan to recognize the +/// anchored lines' neighborhood even after they themselves moved a little, +/// without dragging in unrelated parts of a large file. +pub(crate) const CONTEXT_MARGIN: u64 = 3; + +/// A durable pointer into source: authoritative at creation +/// (`anchor.immutable`) and never mutated afterward — every function that +/// takes one borrows it immutably, and projecting onto another commit +/// ([`crate::project`]) only ever produces a new [`crate::Projection`], never +/// a changed `Anchor`. +/// +/// `commit` and `blob` identify exactly what was captured +/// (`anchor.definition`); `content` and `context` are the retained copies +/// (`anchor.retention`) that make the anchor durable — `content` is the +/// anchored blob's own bytes (so writing it into a store reproduces `blob`'s +/// object id exactly: content addressing makes "referenced rather than +/// copied" a fact about the bytes, not extra machinery), and `context` is a +/// small window around the anchored range, captured fresh, that +/// [`crate::project_from_context`] falls back to once `commit` itself has +/// been garbage collected. Neither is ever recomputed from the other after +/// capture: the anchored *text* ([`snippet`]) is always re-derived from +/// `content` and `lines` at read time rather than cached a third time. +/// +/// `commit` is recorded on a best-effort basis only (`anchor.retention`): +/// nothing in this crate keeps it reachable, so it may already be gone by +/// the time the anchor is read back — that is exactly the case +/// [`crate::project_from_context`] exists for. +/// +/// Serializing an `Anchor` (`facet_git_tree::serialize_into`) writes +/// `content` and `context` as ordinary blob tree entries alongside the +/// identity fields, in the same tree — never a gitlink, which names a commit +/// in another repository and would keep nothing reachable +/// (`anchor.retention`). +/// +/// # Examples +/// +/// ``` +/// use ents_anchor::{Anchor, LineRange}; +/// use facet_git_tree::{EntryKind, ObjectStore, serialize}; +/// +/// # fn write_numbered_file(dir: &std::path::Path) { +/// # std::fs::write(dir.join("file.txt"), (1..=10).map(|n| format!("line {n}\n")).collect::<String>()).unwrap(); +/// # } +/// # fn commit(dir: &std::path::Path) { +/// # std::process::Command::new("git").arg("-C").arg(dir).args(["add", "-A"]).status().unwrap(); +/// # std::process::Command::new("git").arg("-C").arg(dir) +/// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"]) +/// # .status().unwrap(); +/// # } +/// let dir = tempfile::tempdir().expect("tempdir"); +/// std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap(); +/// write_numbered_file(dir.path()); +/// commit(dir.path()); +/// +/// let repo = gix::open(dir.path()).expect("open"); +/// let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", Some(LineRange { start: 3, end: 4 })) +/// .expect("capture"); +/// +/// // The embedded content reproduces the exact anchored blob's own object +/// // id — "referenced ... rather than copied" (`anchor.retention`). +/// let (root, store) = serialize(&anchor).expect("serialize"); +/// let (kind, oid) = { +/// let entries = store.get_tree(&root).expect("tree"); +/// let entry = entries.iter().find(|e| e.filename == "content").expect("content entry"); +/// (entry.mode.kind(), entry.oid) +/// }; +/// assert_eq!(kind, EntryKind::Blob, "never a gitlink"); +/// assert_eq!(oid, anchor.blob()); +/// ``` +// @relation(anchor.definition, anchor.immutable, anchor.retention, scope=file) +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +pub struct Anchor { + pub(crate) commit: [u8; 20], + /// The repository-relative path of the anchored file at `commit`. + pub path: String, + pub(crate) blob: [u8; 20], + /// The anchored lines, or `None` for a whole-file anchor. + pub lines: Option<LineRange>, + /// The anchored blob's full bytes, embedded verbatim + /// (`anchor.retention`) — reproduces [`Anchor::blob`]'s object id when + /// written into any store, by content addressing. + pub content: Vec<u8>, + /// A window of up to `CONTEXT_MARGIN` (three) lines on either side of `lines` + /// (or the whole file, for a whole-file anchor), captured fresh at + /// [`capture`] time for [`crate::project_from_context`] to fuzzy-match + /// against once `commit` is gone. + pub context: Vec<u8>, +} + +impl Anchor { + /// The commit `self` was captured against, recorded on a best-effort + /// basis: nothing keeps it reachable, so it may be gone (garbage + /// collected) by the time the anchor is read back. + /// [`crate::project_exact`] needs it to still exist; + /// [`crate::project_from_context`] does not. + #[must_use] + pub fn commit(&self) -> ObjectId { + ObjectId::from_bytes_or_panic(&self.commit) + } + + /// The object id of the anchored file's blob at [`Anchor::commit`] — an + /// integrity check and the fast path for "has this file changed at + /// all". + #[must_use] + pub fn blob(&self) -> ObjectId { + ObjectId::from_bytes_or_panic(&self.blob) + } +} + +/// Build the [`Anchor`] for `path` (and optionally `lines`) as it exists at +/// `revision` in `repo`, embedding the file's full content and a +/// `CONTEXT_MARGIN`-line (three-line) window around `lines` +/// (`anchor.retention`). +/// Fails when the path is not a file at that commit or the range does not +/// fit it (`anchor.definition`). +/// +/// # Examples +/// +/// ``` +/// # let dir = tempfile::tempdir().expect("tempdir"); +/// # std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap(); +/// # std::fs::write(dir.path().join("file.txt"), "line 1\nline 2\nline 3\n").unwrap(); +/// # std::process::Command::new("git").arg("-C").arg(dir.path()).args(["add", "-A"]).status().unwrap(); +/// # std::process::Command::new("git").arg("-C").arg(dir.path()) +/// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"]) +/// # .status().unwrap(); +/// let repo = gix::open(dir.path()).expect("open"); +/// let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", None).expect("capture"); +/// assert_eq!(anchor.path, "file.txt"); +/// assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "line 1\nline 2\nline 3\n"); +/// ``` +// @relation(anchor.definition, anchor.retention, scope=function) +pub fn capture( + repo: &gix::Repository, + revision: &str, + path: &str, + lines: Option<LineRange>, +) -> Result<Anchor> { + let commit = resolve_commit(repo, revision)?; + let commit_id = commit.id().detach(); + 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, + path: path.to_owned(), + })?; + let blob = entry.object_id(); + let content = read_blob(repo, blob)?; + if let Some(range) = lines { + lines_of(&content, path, range)?; + } + let context = capture_context(&content, lines); + + let mut commit_bytes = [0u8; 20]; + commit_bytes.copy_from_slice(commit_id.as_slice()); + let mut blob_bytes = [0u8; 20]; + blob_bytes.copy_from_slice(blob.as_slice()); + Ok(Anchor { + commit: commit_bytes, + path: path.to_owned(), + blob: blob_bytes, + lines, + content, + context, + }) +} + +/// The exact text of `anchor`'s lines — the whole file for a whole-file +/// anchor — derived at read time from [`Anchor::content`], so it can never +/// disagree with what was captured and is never itself stored +/// (`anchor.immutable`). +/// +/// # Examples +/// +/// ``` +/// # let dir = tempfile::tempdir().expect("tempdir"); +/// # std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap(); +/// # std::fs::write(dir.path().join("file.txt"), "a\nb\nc\n").unwrap(); +/// # std::process::Command::new("git").arg("-C").arg(dir.path()).args(["add", "-A"]).status().unwrap(); +/// # std::process::Command::new("git").arg("-C").arg(dir.path()) +/// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"]) +/// # .status().unwrap(); +/// let repo = gix::open(dir.path()).expect("open"); +/// let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", Some(ents_anchor::LineRange { start: 2, end: 2 })) +/// .expect("capture"); +/// assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "b\n"); +/// ``` +// @relation(anchor.immutable, scope=function) +pub fn snippet(anchor: &Anchor) -> Result<String> { + match anchor.lines { + None => Ok(String::from_utf8_lossy(&anchor.content).into_owned()), + Some(range) => lines_of(&anchor.content, &anchor.path, range), + } +} + +/// The anchored range (or, for a whole-file anchor, the whole file) plus up +/// to [`CONTEXT_MARGIN`] lines on either side within `content` — a small, +/// independently-retainable snapshot of the anchor's surroundings for +/// [`crate::project_from_context`] to fuzzy-match once the anchor's commit +/// is gone. +fn capture_context(content: &[u8], lines: Option<LineRange>) -> Vec<u8> { + let Some(range) = lines else { + return content.to_vec(); + }; + let all: Vec<&[u8]> = content.lines_with_terminator().collect(); + let len = u64::try_from(all.len()).unwrap_or(u64::MAX); + let start0 = range.start.saturating_sub(1); + let margin_before = CONTEXT_MARGIN.min(start0); + let ctx_start = start0.saturating_sub(margin_before); + let margin_after = CONTEXT_MARGIN.min(len.saturating_sub(range.end)); + let ctx_end = range.end.saturating_add(margin_after).min(len); + let (Ok(ctx_start), Ok(ctx_end)) = (usize::try_from(ctx_start), usize::try_from(ctx_end)) + else { + return Vec::new(); + }; + all.get(ctx_start..ctx_end).unwrap_or_default().concat() +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::panic, + reason = "unit test; the panic is an assertion the type reflects as a struct at all" + )] + + use facet::{Facet as _, Type, UserType}; + use rstest::rstest; + + use super::*; + use crate::fixture::{commit_all, head, numbered, repo}; + + fn range(start: u64, end: u64) -> Option<LineRange> { + Some(LineRange { start, end }) + } + + // @relation(anchor.definition, scope=function, role=Verifies) + #[test] + fn capture_records_the_commit_and_blob_and_snippet_derives_the_text() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let git_repo = gix::open(dir.path()).unwrap(); + + let anchor = capture(&git_repo, "HEAD", "file.txt", range(3, 4)).unwrap(); + assert_eq!(anchor.commit().to_string(), head(dir.path())); + assert_eq!(anchor.path, "file.txt"); + assert_eq!(anchor.lines, range(3, 4)); + assert_eq!(anchor.content, numbered(1..=10).into_bytes()); + assert_eq!(snippet(&anchor).unwrap(), "line 3\nline 4\n"); + } + + #[rstest] + #[case::missing_path("absent.txt", None)] + #[case::oversized_range("file.txt", range(2, 9))] + // @relation(anchor.definition, scope=function, role=Verifies) + fn capture_rejects_a_missing_path_and_an_oversized_range( + #[case] path: &str, + #[case] lines: Option<LineRange>, + ) { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=3)).unwrap(); + commit_all(dir.path(), "one"); + let git_repo = gix::open(dir.path()).unwrap(); + + let error = capture(&git_repo, "HEAD", path, lines).unwrap_err(); + assert!(matches!( + error, + Error::MissingPath { .. } | Error::LinesOutOfRange { .. } + )); + } + + // @relation(anchor.retention, scope=function, role=Verifies) + #[test] + fn context_captures_a_margin_around_the_anchored_range() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let git_repo = gix::open(dir.path()).unwrap(); + let anchor = capture(&git_repo, "HEAD", "file.txt", range(5, 6)).unwrap(); + + // 3 lines of margin on each side of a 2-line range: lines 2..=9. + let expected: String = (2..=9).map(|n| format!("line {n}\n")).collect(); + assert_eq!(anchor.context, expected.into_bytes()); + } + + // @relation(anchor.retention, scope=function, role=Verifies) + #[test] + fn context_clamps_to_the_file_when_the_margin_would_overrun_it() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=4)).unwrap(); + commit_all(dir.path(), "one"); + let git_repo = gix::open(dir.path()).unwrap(); + let anchor = capture(&git_repo, "HEAD", "file.txt", range(1, 2)).unwrap(); + + assert_eq!(anchor.context, numbered(1..=4).into_bytes()); + } + + // @relation(anchor.retention, scope=function, role=Verifies) + #[test] + fn context_of_a_whole_file_anchor_is_the_whole_file() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=5)).unwrap(); + commit_all(dir.path(), "one"); + let git_repo = gix::open(dir.path()).unwrap(); + let anchor = capture(&git_repo, "HEAD", "file.txt", None).unwrap(); + + assert_eq!(anchor.context, numbered(1..=5).into_bytes()); + } + + // @relation(anchor.immutable, scope=function, role=Verifies) + #[test] + fn snippet_derives_text_from_content_and_never_stores_it_separately() { + let Type::User(UserType::Struct(struct_ty)) = Anchor::SHAPE.ty else { + panic!("Anchor must reflect as a struct"); + }; + let names: Vec<_> = struct_ty.fields.iter().map(|f| f.name).collect(); + assert_eq!( + names, + vec!["commit", "path", "blob", "lines", "content", "context"], + "Anchor must derive its snippet from `content`, never cache it in a separate field" + ); + } +}
crates/ents-anchor/src/error.rs @@ -1,0 +1,52 @@ +//! The error type every `ents-anchor` operation returns. + +use gix::ObjectId; + +/// Everything that can go wrong capturing or projecting an [`crate::Anchor`]. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A revision string ([`crate::capture`]'s or [`crate::project`]'s + /// `revision`/`target` argument) could not be resolved to a commit in + /// the repository. + #[error("could not resolve {0:?}")] + Resolve(String), + /// A git object could not be read or decoded. + #[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 regular file in the commit it + /// was captured against (`anchor.definition`'s path validation). + #[error("no file at {path:?} in {commit}")] + MissingPath { + /// The commit the path was looked up in. + commit: ObjectId, + /// The path that is not a file there. + path: String, + }, + /// The line range does not fit the file it is anchored to + /// (`anchor.definition`'s line-range validation). + #[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, + }, + /// [`crate::project_exact`]'s anchor commit is no longer present in the + /// repository (garbage collected) — [`crate::project`] catches this and + /// retries with [`crate::project_from_context`] + /// (`anchor.fuzzy-fallback`); a caller invoking + /// [`crate::project_exact`] directly sees it as an ordinary error. + #[error("the anchor commit {0} no longer exists")] + AnchorCommitMissing(ObjectId), +} + +/// The `Result` alias every `ents-anchor` operation returns. +pub type Result<T> = std::result::Result<T, Error>;
crates/ents-anchor/src/fixture.rs @@ -1,0 +1,84 @@ +//! Test-only git fixtures: a throwaway repository and the plumbing helpers +//! this crate's test suites drive it with (ported from `pre-redo`'s +//! `git-store::test_support`). +//! +//! Compiled only under `cfg(test)`. When a second crate needs these +//! helpers, they move to the shared `ents-testutil` dev-dependency crate +//! the workspace test strategy calls for; extracting them now would create +//! a crate no second consumer exists for yet. + +#![allow(clippy::unwrap_used, reason = "test fixture")] + +use std::path::Path; +use std::process::Command; + +/// A fresh temporary directory holding an initialized git repository. +#[must_use] +pub(crate) 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()); + for (key, value) in [("user.email", "test@example.com"), ("user.name", "test")] { + let status = Command::new("git") + .arg("-C") + .arg(dir.path()) + .args(["config", key, value]) + .status() + .unwrap(); + assert!(status.success()); + } + dir +} + +/// Stage everything in `dir` and commit it as `message` under the fixed +/// test identity. +pub(crate) 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()); +} + +/// The full hex id of `dir`'s `HEAD` commit. +#[must_use] +pub(crate) 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() +} + +/// `range.map(|n| "line {n}\n")` concatenated — the numbered fixture file +/// every projection test edits. +#[must_use] +pub(crate) fn numbered(range: std::ops::RangeInclusive<u32>) -> String { + range.map(|n| format!("line {n}\n")).collect() +}
crates/ents-anchor/src/lib.rs @@ -1,0 +1,96 @@ +//! Anchor storage and projection: durable pointers into source — a blob, +//! an optional line range, and a specific commit — and their read-time +//! projection onto any other commit. +//! +//! This crate owns the `Anchor` abstraction from `docs/spec/anchor.sdoc` +//! (overview.sdoc abstraction 3). Anchors resolve and project independently +//! of any consumer: `ents-model`'s `Comment` is merely the first client +//! (its `anchor: RawTree` field embeds the tree an [`Anchor`] serializes +//! to), and reviews, TODO trackers, and blame overlays can reuse the same +//! mechanism. +//! +//! # Spec coverage +//! +//! This crate implements, from `docs/spec/anchor.sdoc`: +//! +//! - `anchor.definition` — [`Anchor`] and [`capture`]'s validation. +//! - `anchor.immutable` — no mutating API exists; [`snippet`] derives the +//! anchored text at read time; the commit id is plain data. +//! - `anchor.retention` — [`Anchor::content`] and [`Anchor::context`] are +//! ordinary blob tree entries in the anchor's own serialized tree, never +//! a gitlink. +//! - `anchor.projection` — [`project`] / [`project_exact`] and the +//! four-outcome [`Projection`] taxonomy. +//! - `anchor.fuzzy-fallback` — [`project_from_context`], which [`project`] +//! degrades to once the anchored commit has been garbage collected. +//! +//! # Examples +//! +//! Capture an anchor, store it inside a `Comment`, read it back, and +//! project it onto a later commit: +//! +//! ``` +//! use ents_anchor::{Anchor, LineRange, Projection}; +//! use ents_model::Comment; +//! use facet_git_tree::RawTree; +//! +//! # fn git(dir: &std::path::Path, args: &[&str]) { +//! # let status = std::process::Command::new("git").arg("-C").arg(dir) +//! # .args(["-c", "user.name=t", "-c", "user.email=t@example.com"]) +//! # .args(args).status().unwrap(); +//! # assert!(status.success()); +//! # } +//! # let dir = tempfile::tempdir().expect("tempdir"); +//! # std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap(); +//! # std::fs::write(dir.path().join("file.txt"), (1..=10).map(|n| format!("line {n}\n")).collect::<String>()).unwrap(); +//! # git(dir.path(), &["add", "-A"]); +//! # git(dir.path(), &["commit", "-q", "-m", "one"]); +//! let repo = gix::open(dir.path()).expect("open"); +//! +//! // Capture against HEAD: commit, path, blob, and range are validated +//! // and recorded; content and context are embedded (`anchor.retention`). +//! let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", Some(LineRange { start: 3, end: 4 })) +//! .expect("capture"); +//! assert_eq!(ents_anchor::snippet(&anchor).expect("snippet"), "line 3\nline 4\n"); +//! +//! // The anchor serializes into the same store the comment does; the +//! // comment embeds it by tree id (`RawTree`), so the anchored content is +//! // reachable from the comment's own ref. +//! let store = facet_git_tree::ObjectStore::default(); +//! let anchor_tree = facet_git_tree::serialize_into(&anchor, &store).expect("serialize anchor"); +//! let comment = Comment { +//! body: "these two lines look off by one".to_owned(), +//! anchor: RawTree::new(anchor_tree), +//! }; +//! let root = facet_git_tree::serialize_into(&comment, &store).expect("serialize comment"); +//! +//! // Read the comment back and recover the identical anchor. +//! let back: Comment = facet_git_tree::deserialize(&root, &store).expect("deserialize comment"); +//! let anchor_back: Anchor = +//! facet_git_tree::deserialize(&back.anchor.oid(), &store).expect("deserialize anchor"); +//! assert_eq!(anchor_back, anchor); +//! +//! // Edit above the range and project: the anchor relocates, unmutated. +//! # std::fs::write(dir.path().join("file.txt"), format!("added\n{}", (1..=10).map(|n| format!("line {n}\n")).collect::<String>())).unwrap(); +//! # git(dir.path(), &["add", "-A"]); +//! # git(dir.path(), &["commit", "-q", "-m", "two"]); +//! let repo = gix::open(dir.path()).expect("reopen"); +//! assert_eq!( +//! ents_anchor::project(&repo, &anchor_back, "HEAD").expect("project"), +//! Projection::Relocated { +//! path: "file.txt".to_owned(), +//! lines: Some(LineRange { start: 4, end: 5 }), +//! } +//! ); +//! ``` + +mod anchor; +mod error; +#[cfg(test)] +mod fixture; +mod projection; +mod util; + +pub use anchor::{Anchor, LineRange, capture, snippet}; +pub use error::{Error, Result}; +pub use projection::{Projection, project, project_exact, project_from_context};
crates/ents-anchor/src/projection.rs @@ -1,0 +1,686 @@ +//! Read-time projection of an [`Anchor`](crate::Anchor) onto another +//! commit: an exact tree diff when the anchor's own commit still exists, +//! degrading to fuzzy context matching once it is gone. +//! +//! Spec coverage: `anchor.projection`, `anchor.fuzzy-fallback`. +//! +//! Projection is a two-point tree diff, not a history walk: [`project_exact`] +//! 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 +//! unrelated history. Blame answers the backwards question (which commit +//! introduced a line); the forward question asked here needs only the diff. + +use gix::ObjectId; +use gix::bstr::ByteSlice as _; +use gix::diff::blob::{Algorithm, Diff, InternedInput}; +use gix::diff::tree_with_rewrites::Change; + +use crate::anchor::{Anchor, CONTEXT_MARGIN, LineRange}; +use crate::error::{Error, Result}; +use crate::util::{commit_at, read_blob, resolve_commit}; + +/// Where an [`Anchor`] sits on a target commit, as computed by [`project`]. +/// +/// # Examples +/// +/// ``` +/// use ents_anchor::Projection; +/// +/// let outcome = Projection::Outdated { path: "src/lib.rs".to_owned() }; +/// assert!(matches!(outcome, Projection::Outdated { .. })); +/// ``` +// @relation(anchor.projection, scope=file) +#[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. + Deleted, +} + +/// Project `anchor` onto `target` (a revision in `repo`), degrading to +/// [`project_from_context`] once `anchor`'s own commit has been garbage +/// collected (`anchor.fuzzy-fallback`) — the one entry point most callers +/// need; [`project_exact`] and [`project_from_context`] are exposed +/// separately for callers that need to distinguish an exact projection from +/// an approximate one. +/// +/// Never mutates `anchor`: every outcome, including [`Projection::Outdated`] +/// and [`Projection::Deleted`], is a fresh [`Projection`] value, and the +/// anchor itself remains displayable regardless of the outcome +/// (`anchor.fuzzy-fallback`). +/// +/// # Examples +/// +/// ``` +/// # let dir = tempfile::tempdir().expect("tempdir"); +/// # std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap(); +/// # std::fs::write(dir.path().join("file.txt"), "a\nb\nc\n").unwrap(); +/// # std::process::Command::new("git").arg("-C").arg(dir.path()).args(["add", "-A"]).status().unwrap(); +/// # std::process::Command::new("git").arg("-C").arg(dir.path()) +/// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"]) +/// # .status().unwrap(); +/// let repo = gix::open(dir.path()).expect("open"); +/// let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", None).expect("capture"); +/// assert_eq!(ents_anchor::project(&repo, &anchor, "HEAD").unwrap(), ents_anchor::Projection::Current); +/// ``` +// @relation(anchor.projection, anchor.fuzzy-fallback, scope=function) +pub fn project(repo: &gix::Repository, anchor: &Anchor, target: &str) -> Result<Projection> { + match project_exact(repo, anchor, target) { + Err(Error::AnchorCommitMissing(_)) => project_from_context(repo, anchor, target), + other => other, + } +} + +/// Project `anchor` onto `target` by diffing `anchor`'s own commit tree +/// against `target`'s, with rename tracking, and mapping the line range +/// through the blob diff's hunks — shifted past edits that land entirely +/// outside it, [`Projection::Outdated`] when an edit touches it. +/// +/// Fails with [`Error::AnchorCommitMissing`] when `anchor`'s commit no +/// longer exists (it is retained on a best-effort basis only, +/// `anchor.retention`); [`project`] catches exactly this and retries with +/// [`project_from_context`], which needs no commit at all. +// @relation(anchor.projection, scope=function) +pub fn project_exact(repo: &gix::Repository, anchor: &Anchor, target: &str) -> Result<Projection> { + let anchor_blob = anchor.blob(); + let anchor_commit_id = anchor.commit(); + 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); + } + + if !repo.has_object(anchor_commit_id) { + return Err(Error::AnchorCommitMissing(anchor_commit_id)); + } + let anchor_commit = commit_at(repo, anchor_commit_id)?; + 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::Deleted); + } + 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_id, + 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 new = read_blob(repo, blob)?; + match map_range(&anchor.content, &new, range) { + Some(mapped) => Some(mapped), + None => return Ok(Projection::Outdated { path }), + } + } + }; + Ok(Projection::Relocated { path, lines }) +} + +/// Project `anchor` onto `target` by fuzzy-matching `anchor`'s retained +/// `context` (`anchor.retention`) against `target`'s version of +/// `anchor.path`, for use once `anchor`'s commit no longer exists and +/// [`project_exact`] can no longer diff against its tree. +/// +/// Looks up `anchor.path` in `target`'s tree directly (no rename tracking is +/// possible without the anchor commit's tree, so a genuine rename reports +/// [`Projection::Deleted`] here, same as a real deletion); a whole-file +/// anchor (`anchor.lines` is `None`) survives any edit at that path, same as +/// [`project_exact`]. For a line-range anchor, every contiguous window of +/// the target file's lines the same length as `context` is scored by how +/// many lines match `context` exactly; the best-scoring window (at least +/// half its lines matching) is accepted and the anchored sub-range is mapped +/// back through the same margin [`crate::capture`] used to build `context`. +/// No match clearing that bar reports [`Projection::Outdated`], the same as +/// an unrecoverable edit would under [`project_exact`]. +// @relation(anchor.fuzzy-fallback, scope=function) +pub fn project_from_context( + repo: &gix::Repository, + anchor: &Anchor, + target: &str, +) -> Result<Projection> { + let target_commit = resolve_commit(repo, target)?; + let target_tree = target_commit + .tree() + .map_err(|error| Error::Object(error.to_string()))?; + let Some(entry) = target_tree + .lookup_entry_by_path(&anchor.path) + .map_err(|error| Error::Object(error.to_string()))? + else { + return Ok(Projection::Deleted); + }; + if !entry.mode().is_blob() { + return Ok(Projection::Outdated { + path: anchor.path.clone(), + }); + } + let Some(range) = anchor.lines else { + return Ok(Projection::Relocated { + path: anchor.path.clone(), + lines: None, + }); + }; + + let data = read_blob(repo, entry.object_id())?; + let target_lines: Vec<&[u8]> = data.lines_with_terminator().collect(); + let context_lines: Vec<&[u8]> = anchor.context.lines_with_terminator().collect(); + let window = context_lines.len(); + if window == 0 || window > target_lines.len() { + return Ok(Projection::Outdated { + path: anchor.path.clone(), + }); + } + + let mut best: Option<(usize, usize)> = None; + for (start, slice) in target_lines.windows(window).enumerate() { + let score = slice + .iter() + .zip(context_lines.iter()) + .filter(|(have, want)| have == want) + .count(); + if best.is_none_or(|(_start, best_score)| score > best_score) { + best = Some((start, score)); + } + } + // Require at least half the window's lines to match exactly, so an + // unrelated coincidence of blank or near-empty lines is not mistaken + // for the anchored region having relocated there. + let Some((start, _score)) = best.filter(|(_start, score)| { + score + .checked_mul(2) + .is_some_and(|doubled| doubled >= window) + }) else { + return Ok(Projection::Outdated { + path: anchor.path.clone(), + }); + }; + + let margin_before = CONTEXT_MARGIN.min(range.start.saturating_sub(1)); + let range_len = range.end.saturating_sub(range.start).saturating_add(1); + let Ok(start) = u64::try_from(start) else { + return Ok(Projection::Outdated { + path: anchor.path.clone(), + }); + }; + let mapped_start = start.saturating_add(margin_before).saturating_add(1); + let mapped_end = mapped_start.saturating_add(range_len).saturating_sub(1); + Ok(Projection::Relocated { + path: anchor.path.clone(), + lines: Some(LineRange { + start: mapped_start, + end: mapped_end, + }), + }) +} + +/// 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. +// @relation(anchor.projection, scope=function) +fn map_range(old: &[u8], new: &[u8], range: LineRange) -> Option<LineRange> { + // Work in 0-based half-open line coordinates, as the hunks do. + // Everything stays unsigned: the shift is tallied as lines added and + // lines removed above the range, and any overflow is an honest `None` + // (outdated) via the checked arithmetic rather than a saturated wrong + // answer. + let start = range.start.checked_sub(1)?; + let end = range.end; + if end <= start { + return None; + } + let input = InternedInput::new(old, new); + if end > u64::try_from(input.before.len()).ok()? { + return None; + } + let diff = Diff::compute(Algorithm::Histogram, &input); + let mut added: u64 = 0; + let mut removed: u64 = 0; + for hunk in diff.hunks() { + let before_start = u64::from(hunk.before.start); + let before_end = u64::from(hunk.before.end); + if before_end <= start { + removed = removed.checked_add(before_end.checked_sub(before_start)?)?; + added = added + .checked_add(u64::from(hunk.after.end).checked_sub(u64::from(hunk.after.start))?)?; + } else if before_start >= end { + break; + } else { + return None; + } + } + let map = |line: u64| line.checked_add(added)?.checked_sub(removed); + Some(LineRange { + start: map(start)?.checked_add(1)?, + end: map(end)?, + }) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::arithmetic_side_effects, + reason = "unit test; property inputs are bounded well below overflow" + )] + + use rstest::rstest; + + use super::*; + use crate::anchor::capture; + use crate::fixture::{commit_all, numbered, repo}; + + fn range(start: u64, end: u64) -> Option<LineRange> { + Some(LineRange { start, end }) + } + + /// One post-capture edit per taxonomy row of + /// [`projection_reports_the_spec_outcomes`]. + #[derive(Debug, Clone, Copy)] + enum Mutation { + TouchOtherFile, + PrependTwoLines, + EditLineFive, + Rename, + RenameAndPrependOneLine, + Delete, + } + + impl Mutation { + fn apply(self, dir: &std::path::Path) { + let file = dir.join("file.txt"); + match self { + Self::TouchOtherFile => { + std::fs::write(dir.join("other.txt"), "unrelated\n").unwrap(); + } + Self::PrependTwoLines => { + std::fs::write(&file, format!("added a\nadded b\n{}", numbered(1..=10))) + .unwrap(); + } + Self::EditLineFive => { + let edited = numbered(1..=10).replace("line 5\n", "line five\n"); + std::fs::write(&file, edited).unwrap(); + } + Self::Rename => { + std::fs::rename(&file, dir.join("moved.txt")).unwrap(); + } + Self::RenameAndPrependOneLine => { + std::fs::remove_file(&file).unwrap(); + std::fs::write( + dir.join("moved.txt"), + format!("added a\n{}", numbered(1..=10)), + ) + .unwrap(); + } + Self::Delete => { + std::fs::remove_file(&file).unwrap(); + std::fs::write(dir.join("unrelated.txt"), "different content\n").unwrap(); + } + } + } + } + + /// `anchor.projection`'s outcome taxonomy, enumerated over the + /// scenarios that select each outcome: unchanged (current), an edit + /// above the range (relocated: shifted), an edit inside the range + /// (outdated), a pure rename (relocated: same lines), a rename with an + /// edit above (relocated: new path and shifted lines), a deletion + /// (deleted), and a whole-file anchor surviving a modification + /// (relocated: no lines). + #[rstest] + #[case::unchanged_is_current(Mutation::TouchOtherFile, range(3, 4), Projection::Current)] + #[case::edit_above_shifts( + Mutation::PrependTwoLines, + range(5, 6), + Projection::Relocated { path: "file.txt".to_owned(), lines: range(7, 8) } + )] + #[case::edit_inside_outdates( + Mutation::EditLineFive, + range(5, 6), + Projection::Outdated { path: "file.txt".to_owned() } + )] + #[case::pure_rename_relocates( + Mutation::Rename, + range(3, 4), + Projection::Relocated { path: "moved.txt".to_owned(), lines: range(3, 4) } + )] + #[case::rename_with_edit_above( + Mutation::RenameAndPrependOneLine, + range(5, 6), + Projection::Relocated { path: "moved.txt".to_owned(), lines: range(6, 7) } + )] + #[case::deletion_is_deleted(Mutation::Delete, range(3, 4), Projection::Deleted)] + #[case::whole_file_survives_an_edit( + Mutation::EditLineFive, + None, + Projection::Relocated { path: "file.txt".to_owned(), lines: None } + )] + // @relation(anchor.projection, scope=function, role=Verifies) + fn projection_reports_the_spec_outcomes( + #[case] mutation: Mutation, + #[case] lines: Option<LineRange>, + #[case] expected: Projection, + ) { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let git_repo = gix::open(dir.path()).unwrap(); + let anchor = capture(&git_repo, "HEAD", "file.txt", lines).unwrap(); + + mutation.apply(dir.path()); + commit_all(dir.path(), "two"); + + // Re-open: the first handle predates commit two. + let git_repo = gix::open(dir.path()).unwrap(); + assert_eq!(project_exact(&git_repo, &anchor, "HEAD").unwrap(), expected); + // The umbrella entry point gives the identical answer while the + // anchor commit exists. + assert_eq!(project(&git_repo, &anchor, "HEAD").unwrap(), expected); + } + + // @relation(anchor.projection, scope=function, role=Verifies) + #[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 git_repo = gix::open(dir.path()).unwrap(); + let old = git_repo.head_id().unwrap().detach().to_string(); + + 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 git_repo = gix::open(dir.path()).unwrap(); + let anchor = capture(&git_repo, "HEAD", "file.txt", range(6, 7)).unwrap(); + + assert_eq!( + project_exact(&git_repo, &anchor, &old).unwrap(), + Projection::Relocated { + path: "file.txt".to_owned(), + lines: range(5, 6), + } + ); + } + + proptest::proptest! { + /// Projection stability under content perturbation + /// (`anchor.projection`): inserting lines strictly above the + /// anchored range shifts it by exactly the insertion count, and + /// appending lines strictly below leaves it untouched — for any + /// file size, range, and insertion size. + // @relation(anchor.projection, scope=function, role=Verifies) + #[test] + fn map_range_shifts_past_outside_edits_and_only_outside_edits( + file_len in 1u64..200, + range_start in 1u64..200, + range_len in 0u64..20, + inserted in 1u64..50, + ) { + proptest::prop_assume!(range_start + range_len <= file_len); + let range = LineRange { start: range_start, end: range_start + range_len }; + let old: String = (1..=file_len).map(|n| format!("line {n}\n")).collect(); + + // Insert `inserted` distinct lines at the very top. Even for a + // range starting at line 1 this touches no anchored line — the + // insertion hunk ends where the range begins — so it must + // shift, never outdate. + let above: String = (0..inserted) + .map(|n| format!("inserted {n}\n")) + .chain((1..=file_len).map(|n| format!("line {n}\n"))) + .collect(); + proptest::prop_assert_eq!( + map_range(old.as_bytes(), above.as_bytes(), range), + Some(LineRange { start: range.start + inserted, end: range.end + inserted }) + ); + + // Append strictly below the range: the range must not move. + let below: String = (1..=file_len) + .map(|n| format!("line {n}\n")) + .chain((0..inserted).map(|n| format!("appended {n}\n"))) + .collect(); + proptest::prop_assert_eq!( + map_range(old.as_bytes(), below.as_bytes(), range), + Some(range) + ); + } + } + + // @relation(anchor.projection, scope=function, role=Verifies) + #[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); + } + + /// A copy of `anchor` whose recorded commit is a made-up id that was + /// never written to the repository — standing in for "gc'd away" + /// without actually having to run gc in a unit test; `has_object` + /// answers `false` either way. + fn with_missing_commit(anchor: &Anchor) -> Anchor { + let mut forged = anchor.clone(); + let fake = gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap(); + forged.commit.copy_from_slice(fake.as_slice()); + forged + } + + // @relation(anchor.fuzzy-fallback, scope=function, role=Verifies) + #[test] + fn project_exact_reports_the_anchor_commit_as_missing_and_project_degrades() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let git_repo = gix::open(dir.path()).unwrap(); + let anchor = capture(&git_repo, "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"); + let git_repo = gix::open(dir.path()).unwrap(); + + let anchor = with_missing_commit(&anchor); + assert!(matches!( + project_exact(&git_repo, &anchor, "HEAD"), + Err(Error::AnchorCommitMissing(_)) + )); + // The umbrella entry point degrades to the context fallback + // instead of failing (`anchor.fuzzy-fallback`), and recovers the + // same relocation the exact path would have found. + assert_eq!( + project(&git_repo, &anchor, "HEAD").unwrap(), + Projection::Relocated { + path: "file.txt".to_owned(), + lines: range(7, 8), + } + ); + } + + // @relation(anchor.fuzzy-fallback, scope=function, role=Verifies) + #[test] + fn project_from_context_relocates_across_an_edit_above_the_range() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let git_repo = gix::open(dir.path()).unwrap(); + let anchor = capture(&git_repo, "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"); + let git_repo = gix::open(dir.path()).unwrap(); + + // Same answer `project_exact` would give, but derived with no + // reference at all to the anchor's own (still very much present) + // commit — exercising the exact code path that stands in once it + // is gone. + assert_eq!( + project_from_context(&git_repo, &anchor, "HEAD").unwrap(), + Projection::Relocated { + path: "file.txt".to_owned(), + lines: range(7, 8), + } + ); + } + + // @relation(anchor.fuzzy-fallback, scope=function, role=Verifies) + #[test] + fn project_from_context_reports_outdated_when_no_window_matches_well() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let git_repo = gix::open(dir.path()).unwrap(); + let anchor = capture(&git_repo, "HEAD", "file.txt", range(5, 6)).unwrap(); + + // A wholesale rewrite leaves nothing resembling the captured + // neighborhood anywhere in the file. + std::fs::write(dir.path().join("file.txt"), "totally\nunrelated\ncontent\n").unwrap(); + commit_all(dir.path(), "two"); + let git_repo = gix::open(dir.path()).unwrap(); + + assert_eq!( + project_from_context(&git_repo, &anchor, "HEAD").unwrap(), + Projection::Outdated { + path: "file.txt".to_owned(), + } + ); + } + + // @relation(anchor.fuzzy-fallback, scope=function, role=Verifies) + #[test] + fn project_from_context_reports_deleted() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let git_repo = gix::open(dir.path()).unwrap(); + let anchor = capture(&git_repo, "HEAD", "file.txt", range(5, 6)).unwrap(); + + std::fs::remove_file(dir.path().join("file.txt")).unwrap(); + std::fs::write(dir.path().join("unrelated.txt"), "different\n").unwrap(); + commit_all(dir.path(), "two"); + let git_repo = gix::open(dir.path()).unwrap(); + + assert_eq!( + project_from_context(&git_repo, &anchor, "HEAD").unwrap(), + Projection::Deleted + ); + } + + // @relation(anchor.fuzzy-fallback, scope=function, role=Verifies) + #[test] + fn project_from_context_of_a_whole_file_anchor_survives_any_edit() { + let dir = repo(); + std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); + commit_all(dir.path(), "one"); + let git_repo = gix::open(dir.path()).unwrap(); + let anchor = capture(&git_repo, "HEAD", "file.txt", None).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"); + let git_repo = gix::open(dir.path()).unwrap(); + + assert_eq!( + project_from_context(&git_repo, &anchor, "HEAD").unwrap(), + Projection::Relocated { + path: "file.txt".to_owned(), + lines: None, + } + ); + } +}
crates/ents-anchor/src/util.rs @@ -1,0 +1,71 @@ +//! Small `gix` plumbing helpers shared by [`crate::capture`] and +//! [`crate::project`]/[`crate::project_exact`]/[`crate::project_from_context`]. +//! +//! Nothing here is public API; each function is a thin, single-purpose +//! wrapper over a `gix::Repository` lookup, kept out of the call sites that +//! use it so the projection and capture logic reads as policy rather than +//! plumbing. + +use gix::ObjectId; +use gix::bstr::ByteSlice as _; + +use crate::error::{Error, Result}; + +/// Resolve `revision` (a hex id, ref name, or revspec) to the commit it +/// names in `repo`. +pub(crate) fn resolve_commit<'repo>( + repo: &'repo gix::Repository, + revision: &str, +) -> Result<gix::Commit<'repo>> { + let resolve = || Error::Resolve(revision.to_owned()); + repo.rev_parse_single(revision) + .map_err(|_error| resolve())? + .object() + .map_err(|_error| resolve())? + .peel_to_kind(gix::object::Kind::Commit) + .map_err(|_error| resolve())? + .try_into_commit() + .map_err(|_error| resolve()) +} + +/// Look up the commit `id` names directly, with no revision parsing — for an +/// [`crate::Anchor`]'s own recorded commit, which already names a concrete +/// object rather than an arbitrary revision. +pub(crate) fn commit_at(repo: &gix::Repository, id: ObjectId) -> Result<gix::Commit<'_>> { + let resolve = || Error::Resolve(id.to_string()); + repo.find_object(id) + .map_err(|_error| resolve())? + .peel_to_kind(gix::object::Kind::Commit) + .map_err(|_error| resolve())? + .try_into_commit() + .map_err(|_error| resolve()) +} + +/// Read the full contents of the blob at `id`. +pub(crate) fn read_blob(repo: &gix::Repository, id: ObjectId) -> Result<Vec<u8>> { + Ok(repo + .find_blob(id) + .map_err(|error| Error::Object(error.to_string()))? + .take_data()) +} + +/// The text of the 1-based inclusive `range` within `data`, or +/// [`Error::LinesOutOfRange`] (naming `path`) when the range does not fit. +pub(crate) fn lines_of(data: &[u8], path: &str, range: crate::LineRange) -> Result<String> { + let all: Vec<&[u8]> = data.lines_with_terminator().collect(); + let out_of_range = || Error::LinesOutOfRange { + path: path.to_owned(), + start: range.start, + end: range.end, + len: u64::try_from(all.len()).unwrap_or(u64::MAX), + }; + // One slice lookup validates the whole range: start == 0 dies in + // checked_sub, an inverted or oversized range dies in get. + let first = usize::try_from(range.start) + .ok() + .and_then(|start| start.checked_sub(1)) + .ok_or_else(out_of_range)?; + let last = usize::try_from(range.end).ok().ok_or_else(out_of_range)?; + let bytes = all.get(first..last).ok_or_else(out_of_range)?.concat(); + Ok(String::from_utf8_lossy(&bytes).into_owned()) +}
crates/ents-anchor/tests/retention.rs @@ -1,0 +1,139 @@ +//! Integration tests for `anchor.retention`: the serialized anchor embeds +//! the anchored blob and context blob as ordinary tree entries — reachable +//! from the storing document's tree, reproducing the original blob's object +//! id by content addressing, and never via a gitlink. + +#![allow(clippy::unwrap_used, clippy::expect_used, reason = "integration test")] + +use std::process::Command; + +use ents_anchor::LineRange; +use ents_model::Comment; +use facet_git_tree::{EntryKind, ObjectStore, RawTree, serialize_into}; + +fn fixture_repo(content: &str) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let run = |args: &[&str]| { + let status = Command::new("git") + .arg("-C") + .arg(dir.path()) + .args(["-c", "user.name=test", "-c", "user.email=test@example.com"]) + .args(args) + .status() + .unwrap(); + assert!(status.success()); + }; + run(&["init", "-q"]); + std::fs::write(dir.path().join("file.txt"), content).unwrap(); + run(&["add", "-A"]); + run(&["commit", "-q", "-m", "one"]); + dir +} + +fn numbered(range: std::ops::RangeInclusive<u32>) -> String { + range.map(|n| format!("line {n}\n")).collect() +} + +/// The serialized anchor's `content` and `context` entries are blobs — mode +/// `100644`, never a gitlink (`160000`) — and `content`'s object id is the +/// anchored blob's own id: referenced by content addressing, not copied +/// under a new identity. +// @relation(anchor.retention, scope=function, role=Verifies) +#[test] +fn retention_embeds_blobs_by_the_original_object_id_and_never_a_gitlink() { + let dir = fixture_repo(&numbered(1..=10)); + let repo = gix::open(dir.path()).unwrap(); + let anchor = ents_anchor::capture( + &repo, + "HEAD", + "file.txt", + Some(LineRange { start: 3, end: 4 }), + ) + .unwrap(); + + let store = ObjectStore::default(); + let root = serialize_into(&anchor, &store).expect("serialize"); + let entries = store.get_tree(&root).expect("anchor tree"); + + for entry in &entries { + assert_ne!( + entry.mode.kind(), + EntryKind::Commit, + "a gitlink retains nothing (anchor.retention): {:?}", + entry.filename + ); + } + let content = entries + .iter() + .find(|e| e.filename == "content") + .expect("content entry"); + assert_eq!(content.mode.kind(), EntryKind::Blob); + assert_eq!( + content.oid, + anchor.blob(), + "content addressing must reproduce the anchored blob's own id" + ); + let context = entries + .iter() + .find(|e| e.filename == "context") + .expect("context entry"); + assert_eq!(context.mode.kind(), EntryKind::Blob); +} + +/// The anchored content stays reachable from the storing document's own +/// tree: walking the comment's tree (the shape `refs/meta/comments/*` +/// points at) reaches the anchored blob, so the ref keeps it alive through +/// force-push, branch deletion, and gc with no special-casing. +// @relation(anchor.retention, scope=function, role=Verifies) +#[test] +fn anchored_content_is_reachable_from_the_storing_documents_tree() { + let dir = fixture_repo(&numbered(1..=10)); + let repo = gix::open(dir.path()).unwrap(); + let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", None).unwrap(); + + let store = ObjectStore::default(); + let anchor_tree = serialize_into(&anchor, &store).expect("serialize anchor"); + let comment = Comment { + body: "anchored".to_owned(), + anchor: RawTree::new(anchor_tree), + }; + let root = serialize_into(&comment, &store).expect("serialize comment"); + + // Walk every tree reachable from the comment root; the anchored blob + // must be among the reachable objects. + let mut stack = vec![root]; + let mut found = false; + while let Some(tree) = stack.pop() { + for entry in store.get_tree(&tree).expect("tree") { + match entry.mode.kind() { + EntryKind::Tree => stack.push(entry.oid), + _ => { + if entry.oid == anchor.blob() { + found = true; + } + } + } + } + } + assert!( + found, + "the anchored blob must be reachable from the comment's own tree" + ); +} + +/// A captured anchor round-trips through its tree unchanged — the struct is +/// the schema, and the retained bytes survive storage verbatim, non-ASCII +/// included. +// @relation(anchor.retention, scope=function, role=Verifies) +#[test] +fn anchor_round_trips_through_its_tree() { + let dir = fixture_repo("line 1\nline 2\n\u{fe}\u{ff} non-ascii bytes\n"); + let repo = gix::open(dir.path()).unwrap(); + for lines in [None, Some(LineRange { start: 2, end: 3 })] { + let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", lines).unwrap(); + let store = ObjectStore::default(); + let root = serialize_into(&anchor, &store).unwrap(); + let back: ents_anchor::Anchor = facet_git_tree::deserialize(&root, &store).unwrap(); + assert_eq!(back, anchor); + } +}