anchor: capture from and project onto the working tree
commit
adcd8baanchor: capture from and project onto the working tree
The working tree becomes a first-class source and target (anchor.working-tree): capture_worktree writes the file’s on-disk bytes to the odb as a blob, embeds them per anchor.retention, and records HEAD as the best-effort commit field; project_worktree diffs the embedded content against the path’s current bytes or a caller-supplied buffer, reporting the same four anchor.projection outcomes, with rename following degraded to the same-path stance the context fallback takes.
Assisted-by: Claude:claude-fable-5
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
crates/kernel/ents-anchor/src/anchor.rs
@@ -201,6 +201,91 @@
})
}
+/// Build the [`Anchor`] for `path` (and optionally `lines`) as it currently
+/// sits in `repo`'s working tree (`anchor.working-tree`): the file's
+/// on-disk bytes are written to the object database as a blob and embedded
+/// exactly as [`capture`] embeds a committed blob (`anchor.retention`), so
+/// an anchor to uncommitted content survives that content being committed,
+/// amended, or discarded.
+///
+/// The anchor's commit field records `HEAD`'s commit — the same
+/// best-effort, never-load-bearing data field it is for a [`capture`]d
+/// anchor (`anchor.immutable`): the anchored blob at `HEAD` is usually a
+/// *different* blob than the one recorded here, and nothing ever diffs
+/// against `HEAD`'s tree to read this anchor back — its content is
+/// embedded.
+///
+/// Fails with [`Error::NoWorkingTree`] on a bare repository, with
+/// [`Error::MissingPath`] when `path` is not a readable file on disk, and
+/// with [`Error::LinesOutOfRange`] when the range does not fit the on-disk
+/// content (`anchor.definition`'s validation, applied to the bytes actually
+/// captured).
+///
+/// # 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"), "committed\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();
+/// // Dirty the file after the commit: the anchor captures the *on-disk*
+/// // bytes, not what HEAD holds.
+/// std::fs::write(dir.path().join("file.txt"), "edited, not yet committed\n").unwrap();
+/// let repo = gix::open(dir.path()).expect("open");
+/// let anchor = ents_anchor::capture_worktree(&repo, "file.txt", None).expect("capture");
+/// assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "edited, not yet committed\n");
+/// assert_eq!(anchor.commit(), repo.head_id().expect("head").detach());
+/// ```
+// @relation(anchor.working-tree, anchor.definition, anchor.retention, scope=function)
+pub fn capture_worktree(
+ repo: &gix::Repository,
+ path: &str,
+ lines: Option<LineRange>,
+) -> Result<Anchor> {
+ let workdir = repo.workdir().ok_or(Error::NoWorkingTree)?;
+ // HEAD is recorded as plain data (`anchor.working-tree`); a repository
+ // with no commit yet has no best-effort commit to record, and the
+ // Resolve error names exactly that.
+ let commit_id = resolve_commit(repo, "HEAD")?.id().detach();
+ let file = workdir.join(path);
+ let missing = || Error::MissingPath {
+ commit: commit_id,
+ path: path.to_owned(),
+ };
+ if !file.is_file() {
+ return Err(missing());
+ }
+ let content = std::fs::read(&file).map_err(|_source| missing())?;
+ if let Some(range) = lines {
+ lines_of(&content, path, range)?;
+ }
+ // Written to the odb now (`anchor.working-tree`), so the blob exists
+ // under its own id from the moment of capture — embedding it in the
+ // anchor's stored tree later reproduces this same id by content
+ // addressing (`anchor.retention`).
+ let blob = repo
+ .write_blob(content.as_slice())
+ .map_err(|error| Error::Object(error.to_string()))?
+ .detach();
+ 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
@@ -344,6 +429,71 @@
assert_eq!(anchor.context, numbered(1..=5).into_bytes());
}
+ /// `anchor.working-tree`: capture reads the *on-disk* bytes (not
+ /// `HEAD`'s blob), writes them to the odb as a blob, and records
+ /// `HEAD`'s commit as the plain-data commit field.
+ // @relation(anchor.working-tree, anchor.retention, scope=function, role=Verifies)
+ #[test]
+ fn capture_worktree_records_dirty_bytes_head_and_an_odb_blob() {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
+ commit_all(dir.path(), "one");
+ let dirty = numbered(1..=10).replace("line 5\n", "line five\n");
+ std::fs::write(dir.path().join("file.txt"), &dirty).unwrap();
+ let git_repo = gix::open(dir.path()).unwrap();
+
+ let anchor = capture_worktree(&git_repo, "file.txt", range(5, 6)).unwrap();
+ assert_eq!(anchor.commit().to_string(), head(dir.path()));
+ assert_eq!(anchor.content, dirty.clone().into_bytes());
+ assert_eq!(snippet(&anchor).unwrap(), "line five\nline 6\n");
+ // The blob exists in the odb from the moment of capture, under the
+ // on-disk bytes' own id — not HEAD's version of the file.
+ assert!(git_repo.has_object(anchor.blob()));
+ let committed = capture(&git_repo, "HEAD", "file.txt", None).unwrap();
+ assert_ne!(anchor.blob(), committed.blob());
+ }
+
+ /// The anchor survives the uncommitted content being committed
+ /// (`anchor.working-tree`): after `git commit`, the same blob sits at
+ /// the anchored path, so projection reports it current.
+ // @relation(anchor.working-tree, scope=function, role=Verifies)
+ #[test]
+ fn capture_worktree_anchor_survives_the_content_being_committed() {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), numbered(1..=3)).unwrap();
+ commit_all(dir.path(), "one");
+ std::fs::write(dir.path().join("file.txt"), numbered(1..=4)).unwrap();
+ let git_repo = gix::open(dir.path()).unwrap();
+ let anchor = capture_worktree(&git_repo, "file.txt", range(4, 4)).unwrap();
+
+ commit_all(dir.path(), "two");
+ let git_repo = gix::open(dir.path()).unwrap();
+ assert_eq!(
+ crate::project(&git_repo, &anchor, "HEAD").unwrap(),
+ crate::Projection::Current
+ );
+ }
+
+ #[rstest]
+ #[case::missing_path("absent.txt", None)]
+ #[case::oversized_range("file.txt", range(2, 9))]
+ // @relation(anchor.working-tree, anchor.definition, scope=function, role=Verifies)
+ fn capture_worktree_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_worktree(&git_repo, path, lines).unwrap_err();
+ assert!(matches!(
+ error,
+ Error::MissingPath { .. } | Error::LinesOutOfRange { .. }
+ ));
+ }
+
// @relation(anchor.immutable, scope=function, role=Verifies)
#[test]
fn snippet_derives_text_from_content_and_never_stores_it_separately() {
crates/kernel/ents-anchor/src/error.rs
@@ -46,6 +46,12 @@
/// [`crate::project_exact`] directly sees it as an ordinary error.
#[error("the anchor commit {0} no longer exists")]
AnchorCommitMissing(ObjectId),
+ /// [`crate::capture_worktree`] or [`crate::project_worktree`] was asked
+ /// to read the working tree of a repository that has none (a bare
+ /// repository). Capture or project against a revision instead
+ /// (`anchor.working-tree` applies only where a working tree exists).
+ #[error("the repository has no working tree")]
+ NoWorkingTree,
}
/// The `Result` alias every `ents-anchor` operation returns.
crates/kernel/ents-anchor/src/lib.rs
@@ -25,6 +25,10 @@
//! four-outcome [`Projection`] taxonomy.
//! - `anchor.fuzzy-fallback` — [`project_from_context`], which [`project`]
//! degrades to once the anchored commit has been garbage collected.
+//! - `anchor.working-tree` — [`capture_worktree`] (the on-disk bytes as a
+//! capture source, `HEAD` recorded as the best-effort commit field) and
+//! [`project_worktree`] (the on-disk bytes, or a caller-supplied buffer
+//! standing in for them, as a projection target).
//!
//! # Examples
//!
@@ -98,6 +102,6 @@
mod projection;
mod util;
-pub use anchor::{Anchor, LineRange, capture, snippet};
+pub use anchor::{Anchor, LineRange, capture, capture_worktree, snippet};
pub use error::{Error, Result};
-pub use projection::{Projection, project, project_exact, project_from_context};
+pub use projection::{Projection, project, project_exact, project_from_context, project_worktree};
crates/kernel/ents-anchor/src/projection.rs
@@ -297,6 +297,102 @@
})
}
+/// Project `anchor` onto the working tree (`anchor.working-tree`): diff the
+/// anchored blob — always available, it is embedded (`anchor.retention`) —
+/// against the path's current on-disk bytes, or against `buffer` standing
+/// in for them (`lens.working-tree`'s unsaved-editor-buffer case), and
+/// report the same four outcomes as [`project`].
+///
+/// There is no commit on the target side to diff trees against, so rename
+/// following degrades exactly as [`project_from_context`]'s does
+/// (`anchor.working-tree`): only `anchor.path` itself is consulted, and a
+/// file that moved on disk reports [`Projection::Deleted`] the same as a
+/// removed one. The line mapping itself never degrades: the embedded
+/// content makes the exact blob diff [`project_exact`] uses available even
+/// when the anchor's own commit is long gone.
+///
+/// # 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();
+/// use ents_anchor::{LineRange, Projection};
+///
+/// let repo = gix::open(dir.path()).expect("open");
+/// let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", Some(LineRange { start: 2, end: 2 }))
+/// .expect("capture");
+///
+/// // Dirty the working tree above the anchored line: the anchor relocates,
+/// // no commit involved on the target side.
+/// std::fs::write(dir.path().join("file.txt"), "inserted\na\nb\nc\n").unwrap();
+/// assert_eq!(
+/// ents_anchor::project_worktree(&repo, &anchor, None).expect("project"),
+/// Projection::Relocated {
+/// path: "file.txt".to_owned(),
+/// lines: Some(LineRange { start: 3, end: 3 }),
+/// }
+/// );
+///
+/// // A caller-supplied buffer stands in for the on-disk bytes.
+/// assert_eq!(
+/// ents_anchor::project_worktree(&repo, &anchor, Some(b"a\nb\nc\n")).expect("project"),
+/// Projection::Current
+/// );
+/// ```
+// @relation(anchor.working-tree, scope=function)
+pub fn project_worktree(
+ repo: &gix::Repository,
+ anchor: &Anchor,
+ buffer: Option<&[u8]>,
+) -> Result<Projection> {
+ let outdated = || {
+ Ok(Projection::Outdated {
+ path: anchor.path.clone(),
+ })
+ };
+ let owned;
+ let bytes: &[u8] = match buffer {
+ Some(bytes) => bytes,
+ None => {
+ let workdir = repo.workdir().ok_or(Error::NoWorkingTree)?;
+ let file = workdir.join(&anchor.path);
+ let Ok(metadata) = std::fs::metadata(&file) else {
+ return Ok(Projection::Deleted);
+ };
+ if !metadata.is_file() {
+ // The entry is no longer a regular file — the same
+ // taxonomy row `project_exact` reports for a mode change.
+ return outdated();
+ }
+ owned = std::fs::read(&file).map_err(|error| Error::Object(error.to_string()))?;
+ &owned
+ }
+ };
+ if bytes == anchor.content.as_slice() {
+ // Byte equality is blob-id equality: the exact anchored blob still
+ // sits at the anchored path.
+ return Ok(Projection::Current);
+ }
+ let Some(range) = anchor.lines else {
+ return Ok(Projection::Relocated {
+ path: anchor.path.clone(),
+ lines: None,
+ });
+ };
+ match map_range(&anchor.content, bytes, range) {
+ Some(lines) => Ok(Projection::Relocated {
+ path: anchor.path.clone(),
+ lines: Some(lines),
+ }),
+ None => outdated(),
+ }
+}
+
/// 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
@@ -661,6 +757,147 @@
);
}
+ /// One *uncommitted* working-tree edit per taxonomy row of
+ /// [`project_worktree_reports_the_spec_outcomes`] — the same rows the
+ /// commit-target table enumerates, minus rename following, which the
+ /// working tree deliberately degrades (`anchor.working-tree`).
+ #[derive(Debug, Clone, Copy)]
+ enum DirtyMutation {
+ None,
+ PrependTwoLines,
+ EditLineFive,
+ Delete,
+ ReplaceWithDirectory,
+ }
+
+ impl DirtyMutation {
+ fn apply(self, dir: &std::path::Path) {
+ let file = dir.join("file.txt");
+ match self {
+ Self::None => {}
+ 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::Delete => {
+ std::fs::remove_file(&file).unwrap();
+ }
+ Self::ReplaceWithDirectory => {
+ std::fs::remove_file(&file).unwrap();
+ std::fs::create_dir(&file).unwrap();
+ }
+ }
+ }
+ }
+
+ /// `anchor.working-tree`'s projection target: the four
+ /// `anchor.projection` outcomes recovered against a dirty working
+ /// tree, with no commit on the target side.
+ #[rstest]
+ #[case::unchanged_is_current(DirtyMutation::None, range(3, 4), Projection::Current)]
+ #[case::edit_above_shifts(
+ DirtyMutation::PrependTwoLines,
+ range(5, 6),
+ Projection::Relocated { path: "file.txt".to_owned(), lines: range(7, 8) }
+ )]
+ #[case::edit_inside_outdates(
+ DirtyMutation::EditLineFive,
+ range(5, 6),
+ Projection::Outdated { path: "file.txt".to_owned() }
+ )]
+ #[case::deletion_is_deleted(DirtyMutation::Delete, range(3, 4), Projection::Deleted)]
+ #[case::not_a_regular_file_outdates(
+ DirtyMutation::ReplaceWithDirectory,
+ range(3, 4),
+ Projection::Outdated { path: "file.txt".to_owned() }
+ )]
+ #[case::whole_file_survives_an_edit(
+ DirtyMutation::EditLineFive,
+ None,
+ Projection::Relocated { path: "file.txt".to_owned(), lines: None }
+ )]
+ // @relation(anchor.working-tree, scope=function, role=Verifies)
+ fn project_worktree_reports_the_spec_outcomes(
+ #[case] mutation: DirtyMutation,
+ #[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();
+
+ // Dirty the working tree only: nothing is committed, so only the
+ // on-disk bytes can produce these outcomes.
+ mutation.apply(dir.path());
+ assert_eq!(
+ project_worktree(&git_repo, &anchor, None).unwrap(),
+ expected
+ );
+ }
+
+ /// A caller-supplied buffer stands in for the on-disk bytes
+ /// (`anchor.working-tree`): the projection follows the buffer, not the
+ /// file — even when the file is gone entirely.
+ // @relation(anchor.working-tree, scope=function, role=Verifies)
+ #[test]
+ fn project_worktree_prefers_a_caller_supplied_buffer_over_the_disk() {
+ 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();
+ let buffer = format!("added a\nadded b\n{}", numbered(1..=10));
+ assert_eq!(
+ project_worktree(&git_repo, &anchor, Some(buffer.as_bytes())).unwrap(),
+ Projection::Relocated {
+ path: "file.txt".to_owned(),
+ lines: range(7, 8),
+ }
+ );
+ // Without the buffer, the same call reads the (deleted) disk state.
+ assert_eq!(
+ project_worktree(&git_repo, &anchor, None).unwrap(),
+ Projection::Deleted
+ );
+ }
+
+ /// A working-tree projection also works for an anchor that was itself
+ /// captured from the working tree and whose bytes were never committed
+ /// anywhere: the embedded content is the diff's old side, no commit
+ /// participates (`anchor.working-tree`).
+ // @relation(anchor.working-tree, scope=function, role=Verifies)
+ #[test]
+ fn project_worktree_needs_no_commit_on_either_side() {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
+ commit_all(dir.path(), "one");
+ let dirty = numbered(1..=10).replace("line 9\n", "line nine\n");
+ std::fs::write(dir.path().join("file.txt"), &dirty).unwrap();
+ let git_repo = gix::open(dir.path()).unwrap();
+ let anchor = crate::capture_worktree(&git_repo, "file.txt", range(5, 6)).unwrap();
+
+ assert_eq!(
+ project_worktree(&git_repo, &anchor, None).unwrap(),
+ Projection::Current
+ );
+ std::fs::write(dir.path().join("file.txt"), format!("added a\n{dirty}")).unwrap();
+ assert_eq!(
+ project_worktree(&git_repo, &anchor, None).unwrap(),
+ Projection::Relocated {
+ path: "file.txt".to_owned(),
+ lines: range(6, 7),
+ }
+ );
+ }
+
// @relation(anchor.fuzzy-fallback, scope=function, role=Verifies)
#[test]
fn project_from_context_of_a_whole_file_anchor_survives_any_edit() {