crates/kernel/ents-anchor/tests/retention.rs
retention.rshistorycomment on this file
| 1 | //! Integration tests for `anchor.retention`: the serialized anchor embeds |
| 2 | //! the anchored blob and context blob as ordinary tree entries — reachable |
| 3 | //! from the storing document's tree, reproducing the original blob's object |
| 4 | //! id by content addressing, and never via a gitlink. |
| 5 | |
| 6 | #![allow(clippy::unwrap_used, clippy::expect_used, reason = "integration test")] |
| 7 | |
| 8 | use std::process::Command; |
| 9 | |
| 10 | use ents_anchor::LineRange; |
| 11 | use facet_git_tree::{EntryKind, ObjectStore, RawTree, serialize_into}; |
| 12 | |
| 13 | /// A stand-in for `ents-forge`'s `Comment` (this crate cannot depend |
| 14 | /// on `ents-forge`, which itself depends on this crate): any struct |
| 15 | /// embedding an anchor's tree by [`RawTree`] exercises the same |
| 16 | /// reachability property `anchor.retention` requires. |
| 17 | #[derive(facet::Facet)] |
| 18 | struct Comment { |
| 19 | body: String, |
| 20 | anchor: RawTree, |
| 21 | } |
| 22 | |
| 23 | fn fixture_repo(content: &str) -> tempfile::TempDir { |
| 24 | let dir = tempfile::tempdir().unwrap(); |
| 25 | let run = |args: &[&str]| { |
| 26 | let status = Command::new("git") |
| 27 | .arg("-C") |
| 28 | .arg(dir.path()) |
| 29 | .args(["-c", "user.name=test", "-c", "user.email=test@example.com"]) |
| 30 | .args(args) |
| 31 | .status() |
| 32 | .unwrap(); |
| 33 | assert!(status.success()); |
| 34 | }; |
| 35 | run(&["init", "-q"]); |
| 36 | std::fs::write(dir.path().join("file.txt"), content).unwrap(); |
| 37 | run(&["add", "-A"]); |
| 38 | run(&["commit", "-q", "-m", "one"]); |
| 39 | dir |
| 40 | } |
| 41 | |
| 42 | fn numbered(range: std::ops::RangeInclusive<u32>) -> String { |
| 43 | range.map(|n| format!("line {n}\n")).collect() |
| 44 | } |
| 45 | |
| 46 | /// The serialized anchor's `content` and `context` entries are blobs — mode |
| 47 | /// `100644`, never a gitlink (`160000`) — and `content`'s object id is the |
| 48 | /// anchored blob's own id: referenced by content addressing, not copied |
| 49 | /// under a new identity. |
| 50 | // @relation(anchor.retention, scope=function, role=Verifies) |
| 51 | #[test] |
| 52 | fn retention_embeds_blobs_by_the_original_object_id_and_never_a_gitlink() { |
| 53 | let dir = fixture_repo(&numbered(1..=10)); |
| 54 | let repo = gix::open(dir.path()).unwrap(); |
| 55 | let anchor = ents_anchor::capture( |
| 56 | &repo, |
| 57 | "HEAD", |
| 58 | "file.txt", |
| 59 | Some(LineRange { start: 3, end: 4 }), |
| 60 | ) |
| 61 | .unwrap(); |
| 62 | |
| 63 | let store = ObjectStore::default(); |
| 64 | let root = serialize_into(&anchor, &store).expect("serialize"); |
| 65 | let entries = store.get_tree(&root).expect("anchor tree"); |
| 66 | |
| 67 | for entry in &entries { |
| 68 | assert_ne!( |
| 69 | entry.mode.kind(), |
| 70 | EntryKind::Commit, |
| 71 | "a gitlink retains nothing (anchor.retention): {:?}", |
| 72 | entry.filename |
| 73 | ); |
| 74 | } |
| 75 | let content = entries |
| 76 | .iter() |
| 77 | .find(|e| e.filename == "content") |
| 78 | .expect("content entry"); |
| 79 | assert_eq!(content.mode.kind(), EntryKind::Blob); |
| 80 | assert_eq!( |
| 81 | content.oid, |
| 82 | anchor.blob(), |
| 83 | "content addressing must reproduce the anchored blob's own id" |
| 84 | ); |
| 85 | let context = entries |
| 86 | .iter() |
| 87 | .find(|e| e.filename == "context") |
| 88 | .expect("context entry"); |
| 89 | assert_eq!(context.mode.kind(), EntryKind::Blob); |
| 90 | } |
| 91 | |
| 92 | /// The anchored content stays reachable from the storing document's own |
| 93 | /// tree: walking the comment's tree (the shape `refs/meta/comments/*` |
| 94 | /// points at) reaches the anchored blob, so the ref keeps it alive through |
| 95 | /// force-push, branch deletion, and gc with no special-casing. |
| 96 | // @relation(anchor.retention, scope=function, role=Verifies) |
| 97 | #[test] |
| 98 | fn anchored_content_is_reachable_from_the_storing_documents_tree() { |
| 99 | let dir = fixture_repo(&numbered(1..=10)); |
| 100 | let repo = gix::open(dir.path()).unwrap(); |
| 101 | let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", None).unwrap(); |
| 102 | |
| 103 | let store = ObjectStore::default(); |
| 104 | let anchor_tree = serialize_into(&anchor, &store).expect("serialize anchor"); |
| 105 | let comment = Comment { |
| 106 | body: "anchored".to_owned(), |
| 107 | anchor: RawTree::new(anchor_tree), |
| 108 | }; |
| 109 | let root = serialize_into(&comment, &store).expect("serialize comment"); |
| 110 | |
| 111 | // Walk every tree reachable from the comment root; the anchored blob |
| 112 | // must be among the reachable objects. |
| 113 | let mut stack = vec![root]; |
| 114 | let mut found = false; |
| 115 | while let Some(tree) = stack.pop() { |
| 116 | for entry in store.get_tree(&tree).expect("tree") { |
| 117 | match entry.mode.kind() { |
| 118 | EntryKind::Tree => stack.push(entry.oid), |
| 119 | _ => { |
| 120 | if entry.oid == anchor.blob() { |
| 121 | found = true; |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | assert!( |
| 128 | found, |
| 129 | "the anchored blob must be reachable from the comment's own tree" |
| 130 | ); |
| 131 | } |
| 132 | |
| 133 | /// A captured anchor round-trips through its tree unchanged — the struct is |
| 134 | /// the schema, and the retained bytes survive storage verbatim, non-ASCII |
| 135 | /// included. |
| 136 | // @relation(anchor.retention, scope=function, role=Verifies) |
| 137 | #[test] |
| 138 | fn anchor_round_trips_through_its_tree() { |
| 139 | let dir = fixture_repo("line 1\nline 2\n\u{fe}\u{ff} non-ascii bytes\n"); |
| 140 | let repo = gix::open(dir.path()).unwrap(); |
| 141 | for lines in [None, Some(LineRange { start: 2, end: 3 })] { |
| 142 | let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", lines).unwrap(); |
| 143 | let store = ObjectStore::default(); |
| 144 | let root = serialize_into(&anchor, &store).unwrap(); |
| 145 | let back: ents_anchor::Anchor = facet_git_tree::deserialize(&root, &store).unwrap(); |
| 146 | assert_eq!(back, anchor); |
| 147 | } |
| 148 | } |