git-ents.gitmain
⌘K
foforge
anchor.rs510 lines · 21.4 KB · rusthistorycomment on this file
1//! [`Anchor`] itself: identity, embedded retention, and capture.
2//!
3//! Spec coverage: `anchor.definition`, `anchor.immutable`, `anchor.retention`.
4
5use facet::Facet;
6use gix::ObjectId;
7use gix::bstr::ByteSlice as _;
8
9use crate::error::{Error, Result};
10use crate::util::{lines_of, read_blob, resolve_commit};
11
12/// A 1-based inclusive range of lines within an anchored file.
13///
14/// # Examples
15///
16/// ```
17/// use ents_anchor::LineRange;
18///
19/// let range = LineRange { start: 3, end: 4 };
20/// assert_eq!(range.end - range.start + 1, 2, "two lines, inclusive");
21/// ```
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
23pub struct LineRange {
24 /// The first line of the range, 1-based.
25 pub start: u64,
26 /// The last line of the range, inclusive.
27 pub end: u64,
28}
29
30/// How many lines of surrounding source [`capture`] retains on each side of
31/// an anchored range as `context` — enough for
32/// [`crate::project_from_context`]'s line-window scan to recognize the
33/// anchored lines' neighborhood even after they themselves moved a little,
34/// without dragging in unrelated parts of a large file.
35pub(crate) const CONTEXT_MARGIN: u64 = 3;
36
37/// A durable pointer into source: authoritative at creation
38/// (`anchor.immutable`) and never mutated afterward — every function that
39/// takes one borrows it immutably, and projecting onto another commit
40/// ([`crate::project`]) only ever produces a new [`crate::Projection`], never
41/// a changed `Anchor`.
42///
43/// `commit` and `blob` identify exactly what was captured
44/// (`anchor.definition`); `content` and `context` are the retained copies
45/// (`anchor.retention`) that make the anchor durable — `content` is the
46/// anchored blob's own bytes (so writing it into a store reproduces `blob`'s
47/// object id exactly: content addressing makes "referenced rather than
48/// copied" a fact about the bytes, not extra machinery), and `context` is a
49/// small window around the anchored range, captured fresh, that
50/// [`crate::project_from_context`] falls back to once `commit` itself has
51/// been garbage collected. Neither is ever recomputed from the other after
52/// capture: the anchored *text* ([`snippet`]) is always re-derived from
53/// `content` and `lines` at read time rather than cached a third time.
54///
55/// `commit` is recorded on a best-effort basis only (`anchor.retention`):
56/// nothing in this crate keeps it reachable, so it may already be gone by
57/// the time the anchor is read back — that is exactly the case
58/// [`crate::project_from_context`] exists for.
59///
60/// Serializing an `Anchor` (`facet_git_tree::serialize_into`) writes
61/// `content` and `context` as ordinary blob tree entries alongside the
62/// identity fields, in the same tree — never a gitlink, which names a commit
63/// in another repository and would keep nothing reachable
64/// (`anchor.retention`).
65///
66/// # Examples
67///
68/// ```
69/// use ents_anchor::{Anchor, LineRange};
70/// use facet_git_tree::{EntryKind, ObjectStore, serialize};
71///
72/// # fn write_numbered_file(dir: &std::path::Path) {
73/// # std::fs::write(dir.join("file.txt"), (1..=10).map(|n| format!("line {n}\n")).collect::<String>()).unwrap();
74/// # }
75/// # fn commit(dir: &std::path::Path) {
76/// # std::process::Command::new("git").arg("-C").arg(dir).args(["add", "-A"]).status().unwrap();
77/// # std::process::Command::new("git").arg("-C").arg(dir)
78/// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"])
79/// # .status().unwrap();
80/// # }
81/// let dir = tempfile::tempdir().expect("tempdir");
82/// std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap();
83/// write_numbered_file(dir.path());
84/// commit(dir.path());
85///
86/// let repo = gix::open(dir.path()).expect("open");
87/// let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", Some(LineRange { start: 3, end: 4 }))
88/// .expect("capture");
89///
90/// // The embedded content reproduces the exact anchored blob's own object
91/// // id — "referenced ... rather than copied" (`anchor.retention`).
92/// let (root, store) = serialize(&anchor).expect("serialize");
93/// let (kind, oid) = {
94/// let entries = store.get_tree(&root).expect("tree");
95/// let entry = entries.iter().find(|e| e.filename == "content").expect("content entry");
96/// (entry.mode.kind(), entry.oid)
97/// };
98/// assert_eq!(kind, EntryKind::Blob, "never a gitlink");
99/// assert_eq!(oid, anchor.blob());
100/// ```
101// @relation(anchor.definition, anchor.immutable, anchor.retention, scope=file)
102#[derive(Debug, Clone, PartialEq, Eq, Facet)]
103pub struct Anchor {
104 pub(crate) commit: [u8; 20],
105 /// The repository-relative path of the anchored file at `commit`.
106 pub path: String,
107 pub(crate) blob: [u8; 20],
108 /// The anchored lines, or `None` for a whole-file anchor.
109 pub lines: Option<LineRange>,
110 /// The anchored blob's full bytes, embedded verbatim
111 /// (`anchor.retention`) — reproduces [`Anchor::blob`]'s object id when
112 /// written into any store, by content addressing.
113 pub content: Vec<u8>,
114 /// A window of up to `CONTEXT_MARGIN` (three) lines on either side of `lines`
115 /// (or the whole file, for a whole-file anchor), captured fresh at
116 /// [`capture`] time for [`crate::project_from_context`] to fuzzy-match
117 /// against once `commit` is gone.
118 pub context: Vec<u8>,
119}
120
121impl Anchor {
122 /// The commit `self` was captured against, recorded on a best-effort
123 /// basis: nothing keeps it reachable, so it may be gone (garbage
124 /// collected) by the time the anchor is read back.
125 /// [`crate::project_exact`] needs it to still exist;
126 /// [`crate::project_from_context`] does not.
127 #[must_use]
128 pub fn commit(&self) -> ObjectId {
129 ObjectId::from_bytes_or_panic(&self.commit)
130 }
131
132 /// The object id of the anchored file's blob at [`Anchor::commit`] — an
133 /// integrity check and the fast path for "has this file changed at
134 /// all".
135 #[must_use]
136 pub fn blob(&self) -> ObjectId {
137 ObjectId::from_bytes_or_panic(&self.blob)
138 }
139}
140
141/// Build the [`Anchor`] for `path` (and optionally `lines`) as it exists at
142/// `revision` in `repo`, embedding the file's full content and a
143/// `CONTEXT_MARGIN`-line (three-line) window around `lines`
144/// (`anchor.retention`).
145/// Fails when the path is not a file at that commit or the range does not
146/// fit it (`anchor.definition`).
147///
148/// # Examples
149///
150/// ```
151/// # let dir = tempfile::tempdir().expect("tempdir");
152/// # std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap();
153/// # std::fs::write(dir.path().join("file.txt"), "line 1\nline 2\nline 3\n").unwrap();
154/// # std::process::Command::new("git").arg("-C").arg(dir.path()).args(["add", "-A"]).status().unwrap();
155/// # std::process::Command::new("git").arg("-C").arg(dir.path())
156/// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"])
157/// # .status().unwrap();
158/// let repo = gix::open(dir.path()).expect("open");
159/// let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", None).expect("capture");
160/// assert_eq!(anchor.path, "file.txt");
161/// assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "line 1\nline 2\nline 3\n");
162/// ```
163// @relation(anchor.definition, anchor.retention, scope=function)
164pub fn capture(
165 repo: &gix::Repository,
166 revision: &str,
167 path: &str,
168 lines: Option<LineRange>,
169) -> Result<Anchor> {
170 let commit = resolve_commit(repo, revision)?;
171 let commit_id = commit.id().detach();
172 let tree = commit
173 .tree()
174 .map_err(|error| Error::Object(error.to_string()))?;
175 let entry = tree
176 .lookup_entry_by_path(path)
177 .map_err(|error| Error::Object(error.to_string()))?
178 .filter(|entry| entry.mode().is_blob())
179 .ok_or_else(|| Error::MissingPath {
180 commit: commit_id,
181 path: path.to_owned(),
182 })?;
183 let blob = entry.object_id();
184 let content = read_blob(repo, blob)?;
185 if let Some(range) = lines {
186 lines_of(&content, path, range)?;
187 }
188 let context = capture_context(&content, lines);
189
190 let mut commit_bytes = [0u8; 20];
191 commit_bytes.copy_from_slice(commit_id.as_slice());
192 let mut blob_bytes = [0u8; 20];
193 blob_bytes.copy_from_slice(blob.as_slice());
194 Ok(Anchor {
195 commit: commit_bytes,
196 path: path.to_owned(),
197 blob: blob_bytes,
198 lines,
199 content,
200 context,
201 })
202}
203
204/// Build the [`Anchor`] for `path` (and optionally `lines`) as it currently
205/// sits in `repo`'s working tree (`anchor.working-tree`): the file's
206/// on-disk bytes are written to the object database as a blob and embedded
207/// exactly as [`capture`] embeds a committed blob (`anchor.retention`), so
208/// an anchor to uncommitted content survives that content being committed,
209/// amended, or discarded.
210///
211/// The anchor's commit field records `HEAD`'s commit — the same
212/// best-effort, never-load-bearing data field it is for a [`capture`]d
213/// anchor (`anchor.immutable`): the anchored blob at `HEAD` is usually a
214/// *different* blob than the one recorded here, and nothing ever diffs
215/// against `HEAD`'s tree to read this anchor back — its content is
216/// embedded.
217///
218/// Fails with [`Error::NoWorkingTree`] on a bare repository, with
219/// [`Error::MissingPath`] when `path` is not a readable file on disk, and
220/// with [`Error::LinesOutOfRange`] when the range does not fit the on-disk
221/// content (`anchor.definition`'s validation, applied to the bytes actually
222/// captured).
223///
224/// # Examples
225///
226/// ```
227/// # let dir = tempfile::tempdir().expect("tempdir");
228/// # std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap();
229/// # std::fs::write(dir.path().join("file.txt"), "committed\n").unwrap();
230/// # std::process::Command::new("git").arg("-C").arg(dir.path()).args(["add", "-A"]).status().unwrap();
231/// # std::process::Command::new("git").arg("-C").arg(dir.path())
232/// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"])
233/// # .status().unwrap();
234/// // Dirty the file after the commit: the anchor captures the *on-disk*
235/// // bytes, not what HEAD holds.
236/// std::fs::write(dir.path().join("file.txt"), "edited, not yet committed\n").unwrap();
237/// let repo = gix::open(dir.path()).expect("open");
238/// let anchor = ents_anchor::capture_worktree(&repo, "file.txt", None).expect("capture");
239/// assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "edited, not yet committed\n");
240/// assert_eq!(anchor.commit(), repo.head_id().expect("head").detach());
241/// ```
242// @relation(anchor.working-tree, anchor.definition, anchor.retention, scope=function)
243pub fn capture_worktree(
244 repo: &gix::Repository,
245 path: &str,
246 lines: Option<LineRange>,
247) -> Result<Anchor> {
248 let workdir = repo.workdir().ok_or(Error::NoWorkingTree)?;
249 // HEAD is recorded as plain data (`anchor.working-tree`); a repository
250 // with no commit yet has no best-effort commit to record, and the
251 // Resolve error names exactly that.
252 let commit_id = resolve_commit(repo, "HEAD")?.id().detach();
253 let file = workdir.join(path);
254 let missing = || Error::MissingPath {
255 commit: commit_id,
256 path: path.to_owned(),
257 };
258 if !file.is_file() {
259 return Err(missing());
260 }
261 let content = std::fs::read(&file).map_err(|_source| missing())?;
262 if let Some(range) = lines {
263 lines_of(&content, path, range)?;
264 }
265 // Written to the odb now (`anchor.working-tree`), so the blob exists
266 // under its own id from the moment of capture — embedding it in the
267 // anchor's stored tree later reproduces this same id by content
268 // addressing (`anchor.retention`).
269 let blob = repo
270 .write_blob(content.as_slice())
271 .map_err(|error| Error::Object(error.to_string()))?
272 .detach();
273 let context = capture_context(&content, lines);
274
275 let mut commit_bytes = [0u8; 20];
276 commit_bytes.copy_from_slice(commit_id.as_slice());
277 let mut blob_bytes = [0u8; 20];
278 blob_bytes.copy_from_slice(blob.as_slice());
279 Ok(Anchor {
280 commit: commit_bytes,
281 path: path.to_owned(),
282 blob: blob_bytes,
283 lines,
284 content,
285 context,
286 })
287}
288
289/// The exact text of `anchor`'s lines — the whole file for a whole-file
290/// anchor — derived at read time from [`Anchor::content`], so it can never
291/// disagree with what was captured and is never itself stored
292/// (`anchor.immutable`).
293///
294/// # Examples
295///
296/// ```
297/// # let dir = tempfile::tempdir().expect("tempdir");
298/// # std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap();
299/// # std::fs::write(dir.path().join("file.txt"), "a\nb\nc\n").unwrap();
300/// # std::process::Command::new("git").arg("-C").arg(dir.path()).args(["add", "-A"]).status().unwrap();
301/// # std::process::Command::new("git").arg("-C").arg(dir.path())
302/// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"])
303/// # .status().unwrap();
304/// let repo = gix::open(dir.path()).expect("open");
305/// let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", Some(ents_anchor::LineRange { start: 2, end: 2 }))
306/// .expect("capture");
307/// assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "b\n");
308/// ```
309// @relation(anchor.immutable, scope=function)
310pub fn snippet(anchor: &Anchor) -> Result<String> {
311 match anchor.lines {
312 None => Ok(String::from_utf8_lossy(&anchor.content).into_owned()),
313 Some(range) => lines_of(&anchor.content, &anchor.path, range),
314 }
315}
316
317/// The anchored range (or, for a whole-file anchor, the whole file) plus up
318/// to [`CONTEXT_MARGIN`] lines on either side within `content` — a small,
319/// independently-retainable snapshot of the anchor's surroundings for
320/// [`crate::project_from_context`] to fuzzy-match once the anchor's commit
321/// is gone.
322fn capture_context(content: &[u8], lines: Option<LineRange>) -> Vec<u8> {
323 let Some(range) = lines else {
324 return content.to_vec();
325 };
326 let all: Vec<&[u8]> = content.lines_with_terminator().collect();
327 let len = u64::try_from(all.len()).unwrap_or(u64::MAX);
328 let start0 = range.start.saturating_sub(1);
329 let margin_before = CONTEXT_MARGIN.min(start0);
330 let ctx_start = start0.saturating_sub(margin_before);
331 let margin_after = CONTEXT_MARGIN.min(len.saturating_sub(range.end));
332 let ctx_end = range.end.saturating_add(margin_after).min(len);
333 let (Ok(ctx_start), Ok(ctx_end)) = (usize::try_from(ctx_start), usize::try_from(ctx_end))
334 else {
335 return Vec::new();
336 };
337 all.get(ctx_start..ctx_end).unwrap_or_default().concat()
338}
339
340#[cfg(test)]
341mod tests {
342 #![allow(
343 clippy::unwrap_used,
344 clippy::panic,
345 reason = "unit test; the panic is an assertion the type reflects as a struct at all"
346 )]
347
348 use facet::{Facet as _, Type, UserType};
349 use rstest::rstest;
350
351 use super::*;
352 use crate::fixture::{commit_all, head, numbered, repo};
353
354 fn range(start: u64, end: u64) -> Option<LineRange> {
355 Some(LineRange { start, end })
356 }
357
358 // @relation(anchor.definition, scope=function, role=Verifies)
359 #[test]
360 fn capture_records_the_commit_and_blob_and_snippet_derives_the_text() {
361 let dir = repo();
362 std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
363 commit_all(dir.path(), "one");
364 let git_repo = gix::open(dir.path()).unwrap();
365
366 let anchor = capture(&git_repo, "HEAD", "file.txt", range(3, 4)).unwrap();
367 assert_eq!(anchor.commit().to_string(), head(dir.path()));
368 assert_eq!(anchor.path, "file.txt");
369 assert_eq!(anchor.lines, range(3, 4));
370 assert_eq!(anchor.content, numbered(1..=10).into_bytes());
371 assert_eq!(snippet(&anchor).unwrap(), "line 3\nline 4\n");
372 }
373
374 #[rstest]
375 #[case::missing_path("absent.txt", None)]
376 #[case::oversized_range("file.txt", range(2, 9))]
377 // @relation(anchor.definition, scope=function, role=Verifies)
378 fn capture_rejects_a_missing_path_and_an_oversized_range(
379 #[case] path: &str,
380 #[case] lines: Option<LineRange>,
381 ) {
382 let dir = repo();
383 std::fs::write(dir.path().join("file.txt"), numbered(1..=3)).unwrap();
384 commit_all(dir.path(), "one");
385 let git_repo = gix::open(dir.path()).unwrap();
386
387 let error = capture(&git_repo, "HEAD", path, lines).unwrap_err();
388 assert!(matches!(
389 error,
390 Error::MissingPath { .. } | Error::LinesOutOfRange { .. }
391 ));
392 }
393
394 // @relation(anchor.retention, scope=function, role=Verifies)
395 #[test]
396 fn context_captures_a_margin_around_the_anchored_range() {
397 let dir = repo();
398 std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
399 commit_all(dir.path(), "one");
400 let git_repo = gix::open(dir.path()).unwrap();
401 let anchor = capture(&git_repo, "HEAD", "file.txt", range(5, 6)).unwrap();
402
403 // 3 lines of margin on each side of a 2-line range: lines 2..=9.
404 let expected: String = (2..=9).map(|n| format!("line {n}\n")).collect();
405 assert_eq!(anchor.context, expected.into_bytes());
406 }
407
408 // @relation(anchor.retention, scope=function, role=Verifies)
409 #[test]
410 fn context_clamps_to_the_file_when_the_margin_would_overrun_it() {
411 let dir = repo();
412 std::fs::write(dir.path().join("file.txt"), numbered(1..=4)).unwrap();
413 commit_all(dir.path(), "one");
414 let git_repo = gix::open(dir.path()).unwrap();
415 let anchor = capture(&git_repo, "HEAD", "file.txt", range(1, 2)).unwrap();
416
417 assert_eq!(anchor.context, numbered(1..=4).into_bytes());
418 }
419
420 // @relation(anchor.retention, scope=function, role=Verifies)
421 #[test]
422 fn context_of_a_whole_file_anchor_is_the_whole_file() {
423 let dir = repo();
424 std::fs::write(dir.path().join("file.txt"), numbered(1..=5)).unwrap();
425 commit_all(dir.path(), "one");
426 let git_repo = gix::open(dir.path()).unwrap();
427 let anchor = capture(&git_repo, "HEAD", "file.txt", None).unwrap();
428
429 assert_eq!(anchor.context, numbered(1..=5).into_bytes());
430 }
431
432 /// `anchor.working-tree`: capture reads the *on-disk* bytes (not
433 /// `HEAD`'s blob), writes them to the odb as a blob, and records
434 /// `HEAD`'s commit as the plain-data commit field.
435 // @relation(anchor.working-tree, anchor.retention, scope=function, role=Verifies)
436 #[test]
437 fn capture_worktree_records_dirty_bytes_head_and_an_odb_blob() {
438 let dir = repo();
439 std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
440 commit_all(dir.path(), "one");
441 let dirty = numbered(1..=10).replace("line 5\n", "line five\n");
442 std::fs::write(dir.path().join("file.txt"), &dirty).unwrap();
443 let git_repo = gix::open(dir.path()).unwrap();
444
445 let anchor = capture_worktree(&git_repo, "file.txt", range(5, 6)).unwrap();
446 assert_eq!(anchor.commit().to_string(), head(dir.path()));
447 assert_eq!(anchor.content, dirty.clone().into_bytes());
448 assert_eq!(snippet(&anchor).unwrap(), "line five\nline 6\n");
449 // The blob exists in the odb from the moment of capture, under the
450 // on-disk bytes' own id — not HEAD's version of the file.
451 assert!(git_repo.has_object(anchor.blob()));
452 let committed = capture(&git_repo, "HEAD", "file.txt", None).unwrap();
453 assert_ne!(anchor.blob(), committed.blob());
454 }
455
456 /// The anchor survives the uncommitted content being committed
457 /// (`anchor.working-tree`): after `git commit`, the same blob sits at
458 /// the anchored path, so projection reports it current.
459 // @relation(anchor.working-tree, scope=function, role=Verifies)
460 #[test]
461 fn capture_worktree_anchor_survives_the_content_being_committed() {
462 let dir = repo();
463 std::fs::write(dir.path().join("file.txt"), numbered(1..=3)).unwrap();
464 commit_all(dir.path(), "one");
465 std::fs::write(dir.path().join("file.txt"), numbered(1..=4)).unwrap();
466 let git_repo = gix::open(dir.path()).unwrap();
467 let anchor = capture_worktree(&git_repo, "file.txt", range(4, 4)).unwrap();
468
469 commit_all(dir.path(), "two");
470 let git_repo = gix::open(dir.path()).unwrap();
471 assert_eq!(
472 crate::project(&git_repo, &anchor, "HEAD").unwrap(),
473 crate::Projection::Current
474 );
475 }
476
477 #[rstest]
478 #[case::missing_path("absent.txt", None)]
479 #[case::oversized_range("file.txt", range(2, 9))]
480 // @relation(anchor.working-tree, anchor.definition, scope=function, role=Verifies)
481 fn capture_worktree_rejects_a_missing_path_and_an_oversized_range(
482 #[case] path: &str,
483 #[case] lines: Option<LineRange>,
484 ) {
485 let dir = repo();
486 std::fs::write(dir.path().join("file.txt"), numbered(1..=3)).unwrap();
487 commit_all(dir.path(), "one");
488 let git_repo = gix::open(dir.path()).unwrap();
489
490 let error = capture_worktree(&git_repo, path, lines).unwrap_err();
491 assert!(matches!(
492 error,
493 Error::MissingPath { .. } | Error::LinesOutOfRange { .. }
494 ));
495 }
496
497 // @relation(anchor.immutable, scope=function, role=Verifies)
498 #[test]
499 fn snippet_derives_text_from_content_and_never_stores_it_separately() {
500 let Type::User(UserType::Struct(struct_ty)) = Anchor::SHAPE.ty else {
501 panic!("Anchor must reflect as a struct");
502 };
503 let names: Vec<_> = struct_ty.fields.iter().map(|f| f.name).collect();
504 assert_eq!(
505 names,
506 vec!["commit", "path", "blob", "lines", "content", "context"],
507 "Anchor must derive its snippet from `content`, never cache it in a separate field"
508 );
509 }
510}