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.
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);
+ }
+}