git-ents.gitmain
⌘K
foforge
commit adcd8ba
anchor: 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

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

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() {