git-ents.gitmain
⌘K
foforge
binding.rs1137 lines · 41.0 KB · rusthistorycomment on this file
1//! [`Binding`]: the single typed-reference vocabulary into the object
2//! graph — history-bound, content-bound, transformation-bound,
3//! position-bound, or relational — plus read-time [`revalidate`] of a
4//! binding against a revision under evaluation.
5//!
6//! [`Binding::Position`] is [`crate::Anchor`] unchanged: every anchor is a
7//! binding, but not every binding is an anchor. The other four variants
8//! name a target without a line-level position at all — a commit itself, a
9//! tree (optionally at an advisory path), a `(base_tree, head_tree)`
10//! transformation, or a commit-plus-tree pair.
11//!
12//! Every binding carries at least one *witness* — a commit whose ancestry
13//! reaches the bound object(s) — so a claim's ledger commit can carry the
14//! witness as an extra parent and keep the bound objects reachable. The
15//! witness is provenance, not identity: [`Binding::same_target`] ignores it
16//! entirely.
17//!
18//! `Binding` itself is a plain Rust enum, not a `facet::Facet` type — the
19//! generic derive would encode an enum externally tagged (a tree with one
20//! variant-named entry), which would not round-trip the existing anchor
21//! storage format byte for byte. Instead [`Binding::serialize_into`] and
22//! [`Binding::deserialize`] hand-encode each variant as a *bare* tree (no
23//! variant tag), inferring the variant back from which entry names are
24//! present on read.
25
26use facet::Facet;
27use gix::ObjectId;
28use gix_object::{Find, Kind, TreeRef, Write};
29
30use crate::anchor::Anchor;
31use crate::error::{Error, Result};
32use crate::projection::{Projection, project};
33use crate::util::resolve_commit;
34
35/// The single typed-reference vocabulary into the object graph: what a
36/// claim, comment, or review is *about*.
37///
38/// # Examples
39///
40/// ```
41/// use ents_anchor::Binding;
42///
43/// let commit = gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap();
44/// let binding = Binding::Commit { commit };
45/// assert_eq!(binding.witnesses(), vec![commit]);
46/// ```
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Binding {
49 /// History-bound: the commit itself is the target. Its own witness is
50 /// itself.
51 Commit {
52 /// The commit named by this binding.
53 commit: ObjectId,
54 },
55 /// Content-bound: a tree, independent of any particular commit or
56 /// path. `path` is advisory metadata only — identity is `tree` alone,
57 /// [`Binding::same_target`] ignores `path` — because the same tree can
58 /// sit at different paths across history without changing what is
59 /// bound.
60 Tree {
61 /// The bound tree's identity.
62 tree: ObjectId,
63 /// Where `tree` was found when this binding was made, retained for
64 /// display only — never part of identity.
65 path: String,
66 /// A commit whose ancestry reaches `tree`.
67 witness: ObjectId,
68 },
69 /// Transformation-bound: the pair `(base_tree, head_tree)` — an edit,
70 /// not either endpoint alone. A commit range is *evidence for* a
71 /// `Delta`; it is never a binding itself. `path` is advisory, same as
72 /// [`Binding::Tree`]'s.
73 Delta {
74 /// The tree before the transformation.
75 base_tree: ObjectId,
76 /// The tree after the transformation.
77 head_tree: ObjectId,
78 /// Where the transformation was found when this binding was made,
79 /// retained for display only — never part of identity.
80 path: String,
81 /// A commit whose ancestry reaches `base_tree`.
82 base_witness: ObjectId,
83 /// A commit whose ancestry reaches `head_tree`.
84 head_witness: ObjectId,
85 },
86 /// Position-bound: [`Anchor`] verbatim — a durable pointer to specific
87 /// lines (or a whole file) at a specific blob, retained and
88 /// projectable exactly as [`crate::project`] describes.
89 Position(Anchor),
90 /// Relational: a parent commit plus a body tree, bound as a pair
91 /// distinct from either [`Binding::Commit`] or [`Binding::Tree`] alone.
92 Hybrid {
93 /// The parent commit.
94 commit: ObjectId,
95 /// The body tree.
96 tree: ObjectId,
97 },
98}
99
100/// `[u8; 20]` payload for [`Binding::Commit`], encoded bare (no variant
101/// tag) so [`Binding::serialize_into`] reproduces the exact byte layout
102/// [`Binding::deserialize`] sniffs on.
103#[derive(Debug, Clone, Facet)]
104struct CommitPayload {
105 commit: [u8; 20],
106}
107
108/// `[u8; 20]`/`String` payload for [`Binding::Tree`], encoded bare.
109#[derive(Debug, Clone, Facet)]
110struct TreePayload {
111 tree: [u8; 20],
112 path: String,
113 witness: [u8; 20],
114}
115
116/// `[u8; 20]`/`String` payload for [`Binding::Delta`], encoded bare.
117#[derive(Debug, Clone, Facet)]
118struct DeltaPayload {
119 base_tree: [u8; 20],
120 head_tree: [u8; 20],
121 path: String,
122 base_witness: [u8; 20],
123 head_witness: [u8; 20],
124}
125
126/// `[u8; 20]` payload for [`Binding::Hybrid`], encoded bare.
127#[derive(Debug, Clone, Facet)]
128struct HybridPayload {
129 commit: [u8; 20],
130 tree: [u8; 20],
131}
132
133/// `id`'s raw 20 bytes, for embedding in a `Facet`-derived payload struct —
134/// [`Anchor`]'s own `commit`/`blob` pattern, applied to every oid field a
135/// [`Binding`] variant carries.
136fn oid_bytes(id: ObjectId) -> [u8; 20] {
137 let mut bytes = [0u8; 20];
138 bytes.copy_from_slice(id.as_slice());
139 bytes
140}
141
142impl Binding {
143 /// Every commit whose ancestry must reach the bound object(s) for this
144 /// binding to stay alive — never empty: exactly the commit itself for
145 /// [`Binding::Commit`] and [`Binding::Hybrid`], the anchor's own commit
146 /// for [`Binding::Position`], the recorded witness for
147 /// [`Binding::Tree`], and both witnesses for [`Binding::Delta`].
148 ///
149 /// # Examples
150 ///
151 /// ```
152 /// use ents_anchor::Binding;
153 ///
154 /// let base_witness = gix::ObjectId::from_hex(b"1111111111111111111111111111111111111111").unwrap();
155 /// let head_witness = gix::ObjectId::from_hex(b"2222222222222222222222222222222222222222").unwrap();
156 /// let binding = Binding::Delta {
157 /// base_tree: gix::ObjectId::from_hex(b"3333333333333333333333333333333333333333").unwrap(),
158 /// head_tree: gix::ObjectId::from_hex(b"4444444444444444444444444444444444444444").unwrap(),
159 /// path: "src/lib.rs".to_owned(),
160 /// base_witness,
161 /// head_witness,
162 /// };
163 /// assert_eq!(binding.witnesses(), vec![base_witness, head_witness]);
164 /// ```
165 #[must_use]
166 pub fn witnesses(&self) -> Vec<ObjectId> {
167 match self {
168 Self::Commit { commit } | Self::Hybrid { commit, .. } => vec![*commit],
169 Self::Tree { witness, .. } => vec![*witness],
170 Self::Delta {
171 base_witness,
172 head_witness,
173 ..
174 } => vec![*base_witness, *head_witness],
175 Self::Position(anchor) => vec![anchor.commit()],
176 }
177 }
178
179 /// Whether `self` and `other` name the same target, ignoring
180 /// provenance: derived [`PartialEq`] is full structural equality (every
181 /// field, including advisory `path` and `witness`/`base_witness`/
182 /// `head_witness`), while `same_target` compares identity only —
183 /// `commit` for [`Binding::Commit`]; the tree oid alone for
184 /// [`Binding::Tree`] (`path` and `witness` ignored); the
185 /// `(base_tree, head_tree)` pair for [`Binding::Delta`] (`path` and
186 /// both witnesses ignored); the anchor's `(blob, lines, commit)` for
187 /// [`Binding::Position`]; `(commit, tree)` for [`Binding::Hybrid`].
188 /// Bindings of different variants are never the same target, even when
189 /// they happen to name overlapping objects.
190 ///
191 /// # Examples
192 ///
193 /// ```
194 /// use ents_anchor::Binding;
195 ///
196 /// let tree = gix::ObjectId::from_hex(b"5555555555555555555555555555555555555555").unwrap();
197 /// let a = Binding::Tree {
198 /// tree,
199 /// path: "a.rs".to_owned(),
200 /// witness: gix::ObjectId::from_hex(b"6666666666666666666666666666666666666666").unwrap(),
201 /// };
202 /// let b = Binding::Tree {
203 /// tree,
204 /// path: "b.rs".to_owned(),
205 /// witness: gix::ObjectId::from_hex(b"7777777777777777777777777777777777777777").unwrap(),
206 /// };
207 /// assert_ne!(a, b, "different path and witness: not structurally equal");
208 /// assert!(a.same_target(&b), "same tree oid: same target regardless of path/witness");
209 /// ```
210 #[must_use]
211 pub fn same_target(&self, other: &Self) -> bool {
212 match (self, other) {
213 (Self::Commit { commit: a }, Self::Commit { commit: b }) => a == b,
214 (Self::Tree { tree: a, .. }, Self::Tree { tree: b, .. }) => a == b,
215 (
216 Self::Delta {
217 base_tree: base_a,
218 head_tree: head_a,
219 ..
220 },
221 Self::Delta {
222 base_tree: base_b,
223 head_tree: head_b,
224 ..
225 },
226 ) => base_a == base_b && head_a == head_b,
227 (Self::Position(a), Self::Position(b)) => {
228 a.blob() == b.blob() && a.lines == b.lines && a.commit() == b.commit()
229 }
230 (
231 Self::Hybrid {
232 commit: ca,
233 tree: ta,
234 },
235 Self::Hybrid {
236 commit: cb,
237 tree: tb,
238 },
239 ) => ca == cb && ta == tb,
240 _ => false,
241 }
242 }
243
244 /// Write `self` into `store` as a *bare* tree — no variant tag —
245 /// keyed by the variant's own field names: [`Binding::Position`]
246 /// writes exactly what [`facet_git_tree::serialize_into`] has always
247 /// written for an [`Anchor`] (the existing stored format, unchanged
248 /// byte for byte); every other variant writes its payload struct's
249 /// fields the same way. [`Binding::deserialize`] recovers the variant
250 /// by sniffing which entry names are present, since no discriminant is
251 /// stored.
252 ///
253 /// `store` takes the same bound `facet_git_tree::serialize_into` does:
254 /// any `gix` object-write sink — a real repository's object database,
255 /// an in-memory [`facet_git_tree::ObjectStore`], or any other
256 /// `gix_object::Write` implementation.
257 ///
258 /// # Examples
259 ///
260 /// ```
261 /// use ents_anchor::Binding;
262 /// use facet_git_tree::ObjectStore;
263 ///
264 /// let store = ObjectStore::default();
265 /// let binding = Binding::Commit {
266 /// commit: gix::ObjectId::from_hex(b"8888888888888888888888888888888888888888").unwrap(),
267 /// };
268 /// let root = binding.serialize_into(&store).expect("serialize");
269 /// let back = Binding::deserialize(&root, &store).expect("deserialize");
270 /// assert_eq!(back, binding);
271 /// ```
272 ///
273 /// # Errors
274 ///
275 /// [`Error::Codec`] when the underlying `facet-git-tree` write fails
276 /// (a backend error from `store`).
277 pub fn serialize_into<W>(&self, store: &W) -> Result<ObjectId>
278 where
279 W: Write + ?Sized,
280 {
281 match self {
282 Self::Commit { commit } => {
283 let payload = CommitPayload {
284 commit: oid_bytes(*commit),
285 };
286 Ok(facet_git_tree::serialize_into(&payload, store)?)
287 }
288 Self::Tree {
289 tree,
290 path,
291 witness,
292 } => {
293 let payload = TreePayload {
294 tree: oid_bytes(*tree),
295 path: path.clone(),
296 witness: oid_bytes(*witness),
297 };
298 Ok(facet_git_tree::serialize_into(&payload, store)?)
299 }
300 Self::Delta {
301 base_tree,
302 head_tree,
303 path,
304 base_witness,
305 head_witness,
306 } => {
307 let payload = DeltaPayload {
308 base_tree: oid_bytes(*base_tree),
309 head_tree: oid_bytes(*head_tree),
310 path: path.clone(),
311 base_witness: oid_bytes(*base_witness),
312 head_witness: oid_bytes(*head_witness),
313 };
314 Ok(facet_git_tree::serialize_into(&payload, store)?)
315 }
316 Self::Position(anchor) => Ok(facet_git_tree::serialize_into(anchor, store)?),
317 Self::Hybrid { commit, tree } => {
318 let payload = HybridPayload {
319 commit: oid_bytes(*commit),
320 tree: oid_bytes(*tree),
321 };
322 Ok(facet_git_tree::serialize_into(&payload, store)?)
323 }
324 }
325 }
326
327 /// Read the [`Binding`] stored bare (no variant tag) at `id` in
328 /// `store`, inferring the variant from which entry names the tree
329 /// holds: `blob`+`content` → [`Binding::Position`] (decoded as
330 /// [`Anchor`]); `base_tree` → [`Binding::Delta`]; `witness` (with
331 /// `tree`) → [`Binding::Tree`]; exactly `{commit, tree}` →
332 /// [`Binding::Hybrid`]; exactly `{commit}` → [`Binding::Commit`].
333 ///
334 /// `store` takes the same bound `facet_git_tree::deserialize` does:
335 /// any `gix` object-read source.
336 ///
337 /// # Errors
338 ///
339 /// [`Error::Codec`] when the recognized shape fails to decode;
340 /// [`Error::UnknownBindingShape`] when the entry names match none of
341 /// the five shapes; [`Error::Object`] when `id` cannot be read as a
342 /// tree at all.
343 pub fn deserialize<F>(id: &ObjectId, store: &F) -> Result<Self>
344 where
345 F: Find + ?Sized,
346 {
347 let entries = tree_entries(id, store)?;
348 let names: std::collections::BTreeSet<&str> =
349 entries.iter().map(|(name, _)| name.as_str()).collect();
350
351 if names.contains("blob") && names.contains("content") {
352 let anchor: Anchor = facet_git_tree::deserialize(id, store)?;
353 return Ok(Self::Position(anchor));
354 }
355 if names.contains("base_tree") {
356 let payload: DeltaPayload = facet_git_tree::deserialize(id, store)?;
357 return Ok(Self::Delta {
358 base_tree: ObjectId::from_bytes_or_panic(&payload.base_tree),
359 head_tree: ObjectId::from_bytes_or_panic(&payload.head_tree),
360 path: payload.path,
361 base_witness: ObjectId::from_bytes_or_panic(&payload.base_witness),
362 head_witness: ObjectId::from_bytes_or_panic(&payload.head_witness),
363 });
364 }
365 if names.contains("witness") && names.contains("tree") {
366 let payload: TreePayload = facet_git_tree::deserialize(id, store)?;
367 return Ok(Self::Tree {
368 tree: ObjectId::from_bytes_or_panic(&payload.tree),
369 path: payload.path,
370 witness: ObjectId::from_bytes_or_panic(&payload.witness),
371 });
372 }
373 if names.len() == 2 && names.contains("commit") && names.contains("tree") {
374 let payload: HybridPayload = facet_git_tree::deserialize(id, store)?;
375 return Ok(Self::Hybrid {
376 commit: ObjectId::from_bytes_or_panic(&payload.commit),
377 tree: ObjectId::from_bytes_or_panic(&payload.tree),
378 });
379 }
380 if names.len() == 1 && names.contains("commit") {
381 let payload: CommitPayload = facet_git_tree::deserialize(id, store)?;
382 return Ok(Self::Commit {
383 commit: ObjectId::from_bytes_or_panic(&payload.commit),
384 });
385 }
386
387 Err(Error::UnknownBindingShape {
388 id: *id,
389 entries: entries.into_iter().map(|(name, _)| name).collect(),
390 })
391 }
392}
393
394/// The name and object id of every entry directly under the tree at `id` —
395/// this crate's own copy of `facet-git-tree`'s private `find_tree_entries`,
396/// needed because [`Binding::deserialize`] must inspect entry names *before*
397/// it knows which `Facet` type to hand `facet_git_tree::deserialize`.
398fn tree_entries<F>(id: &ObjectId, store: &F) -> Result<Vec<(String, ObjectId)>>
399where
400 F: Find + ?Sized,
401{
402 let mut buf = Vec::new();
403 let data = store
404 .try_find(id, &mut buf)
405 .map_err(|error| Error::Object(error.to_string()))?
406 .ok_or_else(|| Error::Object(format!("object {id} not found")))?;
407 if data.kind != Kind::Tree {
408 return Err(Error::Object(format!("object {id} is not a tree")));
409 }
410 let tree_ref = TreeRef::from_bytes(data.data, data.object_hash)
411 .map_err(|error| Error::Object(error.to_string()))?;
412 let mut out = Vec::with_capacity(tree_ref.entries.len());
413 for entry in &tree_ref.entries {
414 let name = std::str::from_utf8(entry.filename)
415 .map_err(|_error| Error::Object("tree entry name is not valid UTF-8".to_owned()))?;
416 out.push((name.to_owned(), entry.oid.to_owned()));
417 }
418 Ok(out)
419}
420
421/// How up to date a [`Binding`] is as of the revision [`EvalState`]
422/// describes, as computed by [`revalidate`].
423///
424/// # Examples
425///
426/// ```
427/// use ents_anchor::Validity;
428///
429/// assert_ne!(Validity::Valid, Validity::Stale);
430/// ```
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432pub enum Validity {
433 /// The binding's target is still present as of the state under
434 /// evaluation.
435 Valid,
436 /// The binding's target no longer holds as of the state under
437 /// evaluation, though the check itself completed.
438 Stale,
439 /// Whether the binding still holds could not be determined — an
440 /// unresolvable revision, a missing object, or (for a
441 /// [`Binding::Delta`]) no delta pair supplied to check against.
442 Unknown,
443}
444
445/// The minimum state [`revalidate`] needs beyond the [`Binding`] itself: the
446/// revision every variant but [`Binding::Delta`] is checked against, plus
447/// the tree pair a [`Binding::Delta`] is checked against.
448///
449/// # Examples
450///
451/// ```
452/// use ents_anchor::EvalState;
453///
454/// let state = EvalState { at: "HEAD", delta: None };
455/// assert_eq!(state.at, "HEAD");
456/// ```
457#[derive(Debug, Clone, Copy)]
458pub struct EvalState<'a> {
459 /// The revision (hex id, ref name, or revspec) the binding is being
460 /// evaluated against.
461 pub at: &'a str,
462 /// The `(base_tree, head_tree)` pair a [`Binding::Delta`] is being
463 /// evaluated against — irrelevant to every other variant.
464 pub delta: Option<(ObjectId, ObjectId)>,
465}
466
467/// Check `binding`'s [`Validity`] against `state`.
468///
469/// Per-variant semantics: [`Binding::Commit`] is
470/// [`Validity::Valid`] iff the commit is `state.at` itself or one of its
471/// ancestors; [`Binding::Tree`] is [`Validity::Valid`] iff the tree appears
472/// — at the recorded path, checked first as a fast path, or anywhere else
473/// in `state.at`'s tree; [`Binding::Delta`] is [`Validity::Valid`] iff
474/// `state.delta` is exactly the `(base_tree, head_tree)` pair, and
475/// [`Validity::Unknown`] whenever `state.delta` is `None` (the pair being
476/// evaluated is caller context this crate has no other way to learn);
477/// [`Binding::Position`] relocates [`crate::project`]'s existing four-outcome
478/// taxonomy onto three (`Current`/`Relocated` → `Valid`,
479/// `Outdated`/`Deleted` → `Stale`); [`Binding::Hybrid`] — not one of the
480/// four listed above — is given the natural composition: `Valid` iff both
481/// its commit (checked as [`Binding::Commit`] would be) and its tree
482/// (checked as [`Binding::Tree`] would be, with no recorded path so only
483/// the anywhere-in-the-tree search applies) are `Valid`, `Unknown` if
484/// either check is, `Stale` otherwise.
485///
486/// # Errors
487///
488/// Propagates a [`crate::project`] error other than an unresolvable
489/// revision (which becomes [`Validity::Unknown`] instead, since it means
490/// the state under evaluation could not be evaluated at all, not that the
491/// binding itself is broken), and any I/O or decode error surfaced while
492/// walking `state.at`'s tree for a [`Binding::Tree`] or [`Binding::Hybrid`]
493/// check.
494///
495/// # Examples
496///
497/// ```
498/// use ents_anchor::{Binding, EvalState, Validity};
499///
500/// # let dir = tempfile::tempdir().expect("tempdir");
501/// # std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap();
502/// # std::fs::write(dir.path().join("file.txt"), "a\n").unwrap();
503/// # std::process::Command::new("git").arg("-C").arg(dir.path()).args(["add", "-A"]).status().unwrap();
504/// # std::process::Command::new("git").arg("-C").arg(dir.path())
505/// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"])
506/// # .status().unwrap();
507/// let repo = gix::open(dir.path()).expect("open");
508/// let commit = repo.head_id().expect("head").detach();
509/// let binding = Binding::Commit { commit };
510/// let state = EvalState { at: "HEAD", delta: None };
511/// assert_eq!(ents_anchor::revalidate(&repo, &binding, &state).unwrap(), Validity::Valid);
512/// ```
513pub fn revalidate(
514 repo: &gix::Repository,
515 binding: &Binding,
516 state: &EvalState<'_>,
517) -> Result<Validity> {
518 match binding {
519 Binding::Commit { commit } => Ok(commit_validity(repo, *commit, state.at)),
520 Binding::Tree { tree, path, .. } => tree_validity(repo, *tree, path, state.at),
521 Binding::Delta {
522 base_tree,
523 head_tree,
524 ..
525 } => Ok(delta_validity(*base_tree, *head_tree, state.delta)),
526 Binding::Position(anchor) => position_validity(repo, anchor, state.at),
527 Binding::Hybrid { commit, tree } => {
528 let commit_v = commit_validity(repo, *commit, state.at);
529 let tree_v = tree_reachable(repo, *tree, state.at)?;
530 Ok(combine(commit_v, tree_v))
531 }
532 }
533}
534
535/// [`Validity::Unknown`] if either input is; [`Validity::Valid`] iff both
536/// are; [`Validity::Stale`] otherwise — [`Binding::Hybrid`]'s composition of
537/// its commit check and its tree check.
538fn combine(a: Validity, b: Validity) -> Validity {
539 if a == Validity::Unknown || b == Validity::Unknown {
540 Validity::Unknown
541 } else if a == Validity::Valid && b == Validity::Valid {
542 Validity::Valid
543 } else {
544 Validity::Stale
545 }
546}
547
548/// [`Binding::Commit`]'s (and [`Binding::Hybrid`]'s commit half's)
549/// [`Validity`]: [`Validity::Unknown`] when `commit` is absent from the odb
550/// or `at` cannot be resolved, else [`Validity::Valid`] iff `commit` is `at`
551/// itself or one of its ancestors (via the repository's own merge-base
552/// machinery, the same idiom `ents_forge` uses for review-target ancestry),
553/// else [`Validity::Stale`].
554fn commit_validity(repo: &gix::Repository, commit: ObjectId, at: &str) -> Validity {
555 if !repo.has_object(commit) {
556 return Validity::Unknown;
557 }
558 let Ok(target) = resolve_commit(repo, at) else {
559 return Validity::Unknown;
560 };
561 let target_id = target.id().detach();
562 if commit == target_id
563 || repo
564 .merge_base(commit, target_id)
565 .is_ok_and(|base| base.detach() == commit)
566 {
567 Validity::Valid
568 } else {
569 Validity::Stale
570 }
571}
572
573/// [`Binding::Tree`]'s [`Validity`]: the fast path (`tree` at `path` in
574/// `at`'s own tree) first, falling back to [`tree_reachable`]'s recursive
575/// anywhere-in-the-tree search.
576fn tree_validity(repo: &gix::Repository, tree: ObjectId, path: &str, at: &str) -> Result<Validity> {
577 let Ok(commit) = resolve_commit(repo, at) else {
578 return Ok(Validity::Unknown);
579 };
580 let root = commit
581 .tree()
582 .map_err(|error| Error::Object(error.to_string()))?;
583 if let Ok(Some(entry)) = root.lookup_entry_by_path(path)
584 && entry.mode().is_tree()
585 && entry.object_id() == tree
586 {
587 return Ok(Validity::Valid);
588 }
589 if tree_contains(&root, tree)? {
590 Ok(Validity::Valid)
591 } else {
592 Ok(Validity::Stale)
593 }
594}
595
596/// [`Binding::Hybrid`]'s tree-half [`Validity`]: [`tree_validity`] without a
597/// recorded path to try as a fast path first — [`Binding::Hybrid`] carries
598/// none.
599fn tree_reachable(repo: &gix::Repository, tree: ObjectId, at: &str) -> Result<Validity> {
600 let Ok(commit) = resolve_commit(repo, at) else {
601 return Ok(Validity::Unknown);
602 };
603 let root = commit
604 .tree()
605 .map_err(|error| Error::Object(error.to_string()))?;
606 if tree_contains(&root, tree)? {
607 Ok(Validity::Valid)
608 } else {
609 Ok(Validity::Stale)
610 }
611}
612
613/// Whether `target` is `tree` itself or the id of any subtree reachable
614/// from it, at any depth — git trees form a DAG with no cycles (an entry
615/// cannot name its own not-yet-written parent by content-addressed id), so
616/// this recursion terminates on any well-formed tree with no explicit depth
617/// guard needed.
618fn tree_contains(tree: &gix::Tree<'_>, target: ObjectId) -> Result<bool> {
619 if tree.id() == target {
620 return Ok(true);
621 }
622 for entry in tree.iter() {
623 let entry = entry.map_err(|error| Error::Object(error.to_string()))?;
624 if !entry.mode().is_tree() {
625 continue;
626 }
627 if entry.object_id() == target {
628 return Ok(true);
629 }
630 let subtree = entry
631 .object()
632 .map_err(|error| Error::Object(error.to_string()))?
633 .try_into_tree()
634 .map_err(|error| Error::Object(error.to_string()))?;
635 if tree_contains(&subtree, target)? {
636 return Ok(true);
637 }
638 }
639 Ok(false)
640}
641
642/// [`Binding::Delta`]'s [`Validity`]: identity comparison against
643/// `state.delta` only, per `revalidate`'s spec — no repository access at
644/// all, since a `Delta`'s evidence (the tree pair under evaluation) is
645/// caller context, not something derivable from a single revision.
646fn delta_validity(
647 base_tree: ObjectId,
648 head_tree: ObjectId,
649 delta: Option<(ObjectId, ObjectId)>,
650) -> Validity {
651 match delta {
652 Some(pair) if pair == (base_tree, head_tree) => Validity::Valid,
653 Some(_) => Validity::Stale,
654 None => Validity::Unknown,
655 }
656}
657
658/// [`Binding::Position`]'s [`Validity`]: [`crate::project`]'s four outcomes
659/// collapsed to three, with an unresolvable `at` reported as
660/// [`Validity::Unknown`] rather than propagated — every other
661/// [`crate::project`] error is a clearer sign of a broken anchor than of an
662/// unevaluable state, so those propagate.
663fn position_validity(repo: &gix::Repository, anchor: &Anchor, at: &str) -> Result<Validity> {
664 match project(repo, anchor, at) {
665 Ok(Projection::Current | Projection::Relocated { .. }) => Ok(Validity::Valid),
666 Ok(Projection::Outdated { .. } | Projection::Deleted) => Ok(Validity::Stale),
667 Err(Error::Resolve(_)) => Ok(Validity::Unknown),
668 Err(other) => Err(other),
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 #![allow(
675 clippy::unwrap_used,
676 clippy::expect_used,
677 clippy::panic,
678 reason = "unit test"
679 )]
680
681 use facet_git_tree::ObjectStore;
682 use rstest::rstest;
683
684 use super::*;
685 use crate::LineRange;
686 use crate::anchor::capture;
687 use crate::fixture::{commit_all, numbered, repo};
688
689 fn hex(byte: u8) -> ObjectId {
690 let hex_digit = format!("{byte:x}");
691 let full = hex_digit.repeat(40);
692 ObjectId::from_hex(full.as_bytes()).unwrap()
693 }
694
695 fn sample_tree() -> Binding {
696 Binding::Tree {
697 tree: hex(1),
698 path: "src/lib.rs".to_owned(),
699 witness: hex(2),
700 }
701 }
702
703 fn sample_delta() -> Binding {
704 Binding::Delta {
705 base_tree: hex(3),
706 head_tree: hex(4),
707 path: "src/lib.rs".to_owned(),
708 base_witness: hex(5),
709 head_witness: hex(6),
710 }
711 }
712
713 fn sample_commit() -> Binding {
714 Binding::Commit { commit: hex(7) }
715 }
716
717 fn sample_hybrid() -> Binding {
718 Binding::Hybrid {
719 commit: hex(8),
720 tree: hex(9),
721 }
722 }
723
724 fn sample_position() -> Binding {
725 let dir = repo();
726 std::fs::write(dir.path().join("file.txt"), numbered(1..=5)).unwrap();
727 commit_all(dir.path(), "one");
728 let git_repo = gix::open(dir.path()).unwrap();
729 let anchor = capture(&git_repo, "HEAD", "file.txt", None).unwrap();
730 Binding::Position(anchor)
731 }
732
733 #[rstest]
734 #[case::commit(sample_commit())]
735 #[case::tree(sample_tree())]
736 #[case::delta(sample_delta())]
737 #[case::position(sample_position())]
738 #[case::hybrid(sample_hybrid())]
739 fn every_variant_round_trips_through_serialize_and_deserialize(#[case] binding: Binding) {
740 let store = ObjectStore::default();
741 let root = binding.serialize_into(&store).expect("serialize");
742 let back = Binding::deserialize(&root, &store).expect("deserialize");
743 assert_eq!(back, binding);
744 }
745
746 // @relation is intentionally absent: `binding.*` has no spec id yet.
747 #[test]
748 fn sniffing_rejects_an_unknown_entry_set() {
749 let store = ObjectStore::default();
750 let root = gix_object::Write::write(&store, &gix_object::Tree { entries: vec![] }).unwrap();
751 let error = Binding::deserialize(&root, &store).unwrap_err();
752 assert!(matches!(error, Error::UnknownBindingShape { .. }));
753 }
754
755 #[rstest]
756 #[case::commit(sample_commit(), vec![hex(7)])]
757 #[case::tree(sample_tree(), vec![hex(2)])]
758 #[case::delta(sample_delta(), vec![hex(5), hex(6)])]
759 #[case::hybrid(sample_hybrid(), vec![hex(8)])]
760 fn witnesses_are_never_empty_and_match_the_spec(
761 #[case] binding: Binding,
762 #[case] expected: Vec<ObjectId>,
763 ) {
764 assert_eq!(binding.witnesses(), expected);
765 assert!(!binding.witnesses().is_empty());
766 }
767
768 #[test]
769 fn witnesses_of_a_position_is_the_anchors_own_commit() {
770 let binding = sample_position();
771 let Binding::Position(anchor) = &binding else {
772 panic!("sample_position must build a Position");
773 };
774 assert_eq!(binding.witnesses(), vec![anchor.commit()]);
775 }
776
777 #[test]
778 fn same_target_ignores_path_and_witness_for_tree() {
779 let a = Binding::Tree {
780 tree: hex(1),
781 path: "a.rs".to_owned(),
782 witness: hex(2),
783 };
784 let b = Binding::Tree {
785 tree: hex(1),
786 path: "b.rs".to_owned(),
787 witness: hex(9),
788 };
789 assert_ne!(a, b);
790 assert!(a.same_target(&b));
791 }
792
793 #[test]
794 fn same_target_ignores_path_and_witnesses_for_delta() {
795 let a = Binding::Delta {
796 base_tree: hex(3),
797 head_tree: hex(4),
798 path: "a.rs".to_owned(),
799 base_witness: hex(5),
800 head_witness: hex(6),
801 };
802 let b = Binding::Delta {
803 base_tree: hex(3),
804 head_tree: hex(4),
805 path: "b.rs".to_owned(),
806 base_witness: hex(1),
807 head_witness: hex(2),
808 };
809 assert_ne!(a, b);
810 assert!(a.same_target(&b));
811 }
812
813 #[test]
814 fn same_target_distinguishes_different_variants_naming_overlapping_objects() {
815 let commit = Binding::Commit { commit: hex(1) };
816 let hybrid = Binding::Hybrid {
817 commit: hex(1),
818 tree: hex(2),
819 };
820 assert!(!commit.same_target(&hybrid));
821 }
822
823 #[test]
824 fn same_target_of_position_compares_blob_lines_and_commit() {
825 let dir = repo();
826 std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
827 commit_all(dir.path(), "one");
828 let git_repo = gix::open(dir.path()).unwrap();
829 let anchor = capture(
830 &git_repo,
831 "HEAD",
832 "file.txt",
833 Some(LineRange { start: 3, end: 4 }),
834 )
835 .unwrap();
836 assert!(
837 Binding::Position(anchor.clone()).same_target(&Binding::Position(anchor.clone())),
838 "an anchor is always the same target as an identical copy of itself"
839 );
840
841 std::fs::write(dir.path().join("file.txt"), numbered(1..=12)).unwrap();
842 commit_all(dir.path(), "two");
843 let git_repo = gix::open(dir.path()).unwrap();
844 let other = capture(
845 &git_repo,
846 "HEAD",
847 "file.txt",
848 Some(LineRange { start: 3, end: 4 }),
849 )
850 .unwrap();
851 assert!(!Binding::Position(anchor).same_target(&Binding::Position(other)));
852 }
853
854 #[test]
855 fn revalidate_commit_is_valid_for_self_and_ancestor_and_stale_otherwise() {
856 let dir = repo();
857 std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
858 commit_all(dir.path(), "one");
859 let git_repo = gix::open(dir.path()).unwrap();
860 let first = git_repo.head_id().unwrap().detach();
861
862 std::fs::write(dir.path().join("file.txt"), "two\n").unwrap();
863 commit_all(dir.path(), "two");
864 let git_repo = gix::open(dir.path()).unwrap();
865 let second = git_repo.head_id().unwrap().detach();
866
867 let state = EvalState {
868 at: "HEAD",
869 delta: None,
870 };
871 assert_eq!(
872 revalidate(&git_repo, &Binding::Commit { commit: second }, &state).unwrap(),
873 Validity::Valid
874 );
875 assert_eq!(
876 revalidate(&git_repo, &Binding::Commit { commit: first }, &state).unwrap(),
877 Validity::Valid,
878 "an ancestor of the revision under evaluation is still valid"
879 );
880
881 // A commit that only exists on an unrelated, unmerged branch is
882 // neither `second` nor an ancestor of it — evaluated against
883 // `second` explicitly, since `HEAD` itself is about to move to the
884 // unrelated branch.
885 std::process::Command::new("git")
886 .arg("-C")
887 .arg(dir.path())
888 .args(["checkout", "-q", "--orphan", "other"])
889 .status()
890 .unwrap();
891 std::fs::write(dir.path().join("other.txt"), "other\n").unwrap();
892 commit_all(dir.path(), "unrelated");
893 let git_repo = gix::open(dir.path()).unwrap();
894 let unrelated = git_repo.head_id().unwrap().detach();
895
896 let second_hex = second.to_string();
897 let state_at_second = EvalState {
898 at: &second_hex,
899 delta: None,
900 };
901 assert_eq!(
902 revalidate(
903 &git_repo,
904 &Binding::Commit { commit: unrelated },
905 &state_at_second
906 )
907 .unwrap(),
908 Validity::Stale
909 );
910 }
911
912 #[test]
913 fn revalidate_commit_is_unknown_when_absent_or_revision_unresolvable() {
914 let dir = repo();
915 std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
916 commit_all(dir.path(), "one");
917 let git_repo = gix::open(dir.path()).unwrap();
918
919 let missing = Binding::Commit {
920 commit: gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap(),
921 };
922 let state = EvalState {
923 at: "HEAD",
924 delta: None,
925 };
926 assert_eq!(
927 revalidate(&git_repo, &missing, &state).unwrap(),
928 Validity::Unknown
929 );
930
931 let head = Binding::Commit {
932 commit: git_repo.head_id().unwrap().detach(),
933 };
934 let unresolvable = EvalState {
935 at: "not-a-revision",
936 delta: None,
937 };
938 assert_eq!(
939 revalidate(&git_repo, &head, &unresolvable).unwrap(),
940 Validity::Unknown
941 );
942 }
943
944 #[test]
945 fn revalidate_tree_checks_the_recorded_path_then_falls_back_to_any_path() {
946 let dir = repo();
947 std::fs::create_dir(dir.path().join("sub")).unwrap();
948 std::fs::write(dir.path().join("sub/file.txt"), "one\n").unwrap();
949 commit_all(dir.path(), "one");
950 let git_repo = gix::open(dir.path()).unwrap();
951 let commit = git_repo.head_id().unwrap().detach();
952 let root = git_repo.find_commit(commit).unwrap().tree().unwrap();
953 let sub_tree = root
954 .lookup_entry_by_path("sub")
955 .unwrap()
956 .unwrap()
957 .object_id();
958
959 let state = EvalState {
960 at: "HEAD",
961 delta: None,
962 };
963
964 // Fast path: recorded at its real path.
965 let at_path = Binding::Tree {
966 tree: sub_tree,
967 path: "sub".to_owned(),
968 witness: commit,
969 };
970 assert_eq!(
971 revalidate(&git_repo, &at_path, &state).unwrap(),
972 Validity::Valid
973 );
974
975 // Anywhere fallback: recorded at a wrong path, but the same tree
976 // still sits somewhere in the target's tree.
977 let wrong_path = Binding::Tree {
978 tree: sub_tree,
979 path: "not/the/real/path".to_owned(),
980 witness: commit,
981 };
982 assert_eq!(
983 revalidate(&git_repo, &wrong_path, &state).unwrap(),
984 Validity::Valid
985 );
986
987 let missing = Binding::Tree {
988 tree: gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap(),
989 path: "sub".to_owned(),
990 witness: commit,
991 };
992 assert_eq!(
993 revalidate(&git_repo, &missing, &state).unwrap(),
994 Validity::Stale
995 );
996 }
997
998 #[test]
999 fn revalidate_tree_is_unknown_when_the_revision_is_unresolvable() {
1000 let dir = repo();
1001 std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
1002 commit_all(dir.path(), "one");
1003 let git_repo = gix::open(dir.path()).unwrap();
1004 let binding = sample_tree();
1005 let state = EvalState {
1006 at: "not-a-revision",
1007 delta: None,
1008 };
1009 assert_eq!(
1010 revalidate(&git_repo, &binding, &state).unwrap(),
1011 Validity::Unknown
1012 );
1013 }
1014
1015 #[rstest]
1016 #[case::matching_pair(Some((hex(3), hex(4))), Validity::Valid)]
1017 #[case::different_pair(Some((hex(1), hex(2))), Validity::Stale)]
1018 #[case::no_pair(None, Validity::Unknown)]
1019 fn revalidate_delta_compares_identity_against_state_delta_only(
1020 #[case] delta: Option<(ObjectId, ObjectId)>,
1021 #[case] expected: Validity,
1022 ) {
1023 let dir = repo();
1024 std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
1025 commit_all(dir.path(), "one");
1026 let git_repo = gix::open(dir.path()).unwrap();
1027
1028 let binding = sample_delta();
1029 let state = EvalState { at: "HEAD", delta };
1030 assert_eq!(revalidate(&git_repo, &binding, &state).unwrap(), expected);
1031 }
1032
1033 #[test]
1034 fn revalidate_position_maps_current_and_relocated_to_valid() {
1035 let dir = repo();
1036 std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
1037 commit_all(dir.path(), "one");
1038 let git_repo = gix::open(dir.path()).unwrap();
1039 let anchor = capture(&git_repo, "HEAD", "file.txt", None).unwrap();
1040 let binding = Binding::Position(anchor);
1041 let state = EvalState {
1042 at: "HEAD",
1043 delta: None,
1044 };
1045 assert_eq!(
1046 revalidate(&git_repo, &binding, &state).unwrap(),
1047 Validity::Valid
1048 );
1049 }
1050
1051 #[test]
1052 fn revalidate_position_maps_deleted_to_stale() {
1053 let dir = repo();
1054 std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
1055 commit_all(dir.path(), "one");
1056 let git_repo = gix::open(dir.path()).unwrap();
1057 let anchor = capture(&git_repo, "HEAD", "file.txt", None).unwrap();
1058
1059 std::fs::remove_file(dir.path().join("file.txt")).unwrap();
1060 std::fs::write(dir.path().join("other.txt"), "x\n").unwrap();
1061 commit_all(dir.path(), "two");
1062 let git_repo = gix::open(dir.path()).unwrap();
1063
1064 let binding = Binding::Position(anchor);
1065 let state = EvalState {
1066 at: "HEAD",
1067 delta: None,
1068 };
1069 assert_eq!(
1070 revalidate(&git_repo, &binding, &state).unwrap(),
1071 Validity::Stale
1072 );
1073 }
1074
1075 #[test]
1076 fn revalidate_position_is_unknown_when_the_revision_is_unresolvable() {
1077 let dir = repo();
1078 std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
1079 commit_all(dir.path(), "one");
1080 let git_repo = gix::open(dir.path()).unwrap();
1081 let anchor = capture(&git_repo, "HEAD", "file.txt", None).unwrap();
1082
1083 let binding = Binding::Position(anchor);
1084 let state = EvalState {
1085 at: "not-a-revision",
1086 delta: None,
1087 };
1088 assert_eq!(
1089 revalidate(&git_repo, &binding, &state).unwrap(),
1090 Validity::Unknown
1091 );
1092 }
1093
1094 #[test]
1095 fn revalidate_hybrid_is_valid_iff_both_commit_and_tree_check_out() {
1096 let dir = repo();
1097 std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
1098 commit_all(dir.path(), "one");
1099 let git_repo = gix::open(dir.path()).unwrap();
1100 let commit = git_repo.head_id().unwrap().detach();
1101 let tree = git_repo
1102 .find_commit(commit)
1103 .unwrap()
1104 .tree()
1105 .unwrap()
1106 .id()
1107 .detach();
1108
1109 let state = EvalState {
1110 at: "HEAD",
1111 delta: None,
1112 };
1113 let valid = Binding::Hybrid { commit, tree };
1114 assert_eq!(
1115 revalidate(&git_repo, &valid, &state).unwrap(),
1116 Validity::Valid
1117 );
1118
1119 let stale_tree = Binding::Hybrid {
1120 commit,
1121 tree: gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap(),
1122 };
1123 assert_eq!(
1124 revalidate(&git_repo, &stale_tree, &state).unwrap(),
1125 Validity::Stale
1126 );
1127
1128 let unknown_commit = Binding::Hybrid {
1129 commit: gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap(),
1130 tree,
1131 };
1132 assert_eq!(
1133 revalidate(&git_repo, &unknown_commit, &state).unwrap(),
1134 Validity::Unknown
1135 );
1136 }
1137}