@@ -6,7 +6,7 @@
66use crate::config::Config;
77use crate::domain::{Backend, MetaInfo, ROOT_ID};
88use crate::eval::Interp;
9−use crate::value::{BlobContent, BlobKind, BlobVal, ConflictSide, Crash, LazyBlob, ThunkVal, Value};
9+use crate::value::{BlobContent, BlobKind, BlobVal, ConflictSide, Crash, LazyBlob, ListVal, ThunkVal, Value};
1010use futures::{StreamExt, TryStreamExt};
1111use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
1212use jj_lib::commit::Commit;
@@ -31,12 +31,12 @@ use jj_lib::store::Store;
3131use jj_lib::transaction::UnpublishedOperation;
3232use jj_lib::working_copy::{LockedWorkingCopy, SnapshotError, SnapshotOptions};
3333use jj_lib::workspace::Workspace;
34−use std::cell::RefCell;
34+use std::cell::{Cell, RefCell};
3535use std::collections::{BTreeMap, BTreeSet, HashMap};
3636use std::future::Future;
3737use std::os::unix::io::AsRawFd;
3838use std::path::PathBuf;
39−use std::rc::Rc;
39+use std::rc::{Rc, Weak};
4040use std::sync::{Arc, Mutex, OnceLock};
4141
4242fn block_on<F: Future>(f: F) -> F::Output {
@@ -1416,6 +1416,7 @@ fn with_tree(commit: &Commit, tree: MergedTree) -> Commit {
14161416#[derive(Clone)]
14171417pub struct JjBackend {
14181418 inner: Arc<JjInner>,
1419+ replay_cache: Rc<ReplayCache>,
14191420}
14201421
14211422impl JjBackend {
@@ -1448,6 +1449,7 @@ impl JjBackend {
14481449 let repo = block_on(workspace.repo_loader().load_at_head())
14491450 .map_err(|e| (2, format!("cannot load the repository at head: {}", e)))?;
14501451 Ok(JjBackend {
1452+ replay_cache: Rc::new(ReplayCache::default()),
14511453 inner: Arc::new(JjInner {
14521454 workspace_root: root,
14531455 workspace: Mutex::new(workspace),
@@ -1502,7 +1504,7 @@ impl JjBackend {
15021504 .map_err(|e| (2, format!("cannot reload the repository at head: {}", e)))?;
15031505 *self.inner.repo.lock().unwrap() = head_repo;
15041506 let loaded_visible = Arc::new(block_on(read_visible(&self.current_repo()))?);
1505− let loaded = build_repo_value(&loaded_visible, &loaded_visible.wc_change_id)?;
1507+ let loaded = build_repo_value(&loaded_visible, &loaded_visible.wc_change_id, &self.replay_cache)?;
15061508
15071509 let current = if snapshot {
15081510 // §7.4: snapshot the working directory into the wc commit; the
@@ -1519,7 +1521,7 @@ impl JjBackend {
15191521 Some(pending) => {
15201522 *self.inner.pending.lock().unwrap() = Some(pending);
15211523 let vis = block_on(read_visible(&post))?;
1522− let cur = build_repo_value(&vis, &vis.wc_change_id)?;
1524+ let cur = build_repo_value(&vis, &vis.wc_change_id, &self.replay_cache)?;
15231525 *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
15241526 cur
15251527 }
@@ -2729,6 +2731,7 @@ pub fn cmd_init(cfg: &Config) -> Result<(), OpenError> {
27292731 }
27302732 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
27312733 let backend = JjBackend {
2734+ replay_cache: Rc::new(ReplayCache::default()),
27322735 inner: Arc::new(JjInner {
27332736 workspace_root: cwd.clone(),
27342737 workspace: Mutex::new(workspace),
@@ -2973,6 +2976,7 @@ fn clone_into(
29732976 ))
29742977 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
29752978 let backend = JjBackend {
2979+ replay_cache: Rc::new(ReplayCache::default()),
29762980 inner: Arc::new(JjInner {
29772981 workspace_root: dir_path.to_path_buf(),
29782982 workspace: Mutex::new(workspace),
@@ -3749,8 +3753,16 @@ impl Backend for JjBackend {
37493753 }
37503754
37513755 fn replay(&self, onto: &[Value], from: &[Value], to: &[Value]) -> Result<Vec<Value>, Crash> {
3756+ self.replay_snapshot(
3757+ &Value::list(onto.to_vec()),
3758+ &Value::list(from.to_vec()),
3759+ &Value::list(to.to_vec()),
3760+ )?.as_list().map(<[Value]>::to_vec)
3761+ }
3762+
3763+ fn replay_snapshot(&self, onto: &Value, from: &Value, to: &Value) -> Result<Value, Crash> {
37523764 let store = self.current_repo().store().clone();
3753− block_on(jj_replay(&store, onto, from, to))
3765+ block_on(jj_replay(&store, &self.replay_cache, onto, from, to))
37543766 }
37553767}
37563768
@@ -3758,15 +3770,119 @@ impl Backend for JjBackend {
37583770// jj three-way tree merge for `replay` (§7.3)
37593771// ----------------------------------------------------------------------
37603772
3773+/// Replay sees the same lists repeatedly: a parent's old and new files
3774+/// become each child's bases. Retain their validated trees by list identity,
3775+/// including its offset, without keeping discarded snapshots alive.
3776+#[derive(Default)]
3777+struct ReplayCache {
3778+ trees: RefCell<HashMap<(*const Vec<Value>, usize), ReplayTree>>,
3779+ empty_subtrees: RefCell<HashMap<TreeId, bool>>,
3780+ next_sweep: Cell<usize>,
3781+ blobs: BlobCache,
3782+ entries: EntryCache,
3783+}
3784+
3785+struct ReplayTree {
3786+ storage: Weak<Vec<Value>>,
3787+ tree: MergedTree,
3788+ /// A list read from a resolved tree, already in canonical path order.
3789+ canonical: bool,
3790+}
3791+
3792+impl ReplayCache {
3793+ fn remember(&self, list: &ListVal, tree: MergedTree, canonical: bool) {
3794+ let mut trees = self.trees.borrow_mut();
3795+ trees.insert(list.identity(), ReplayTree {
3796+ storage: list.weak_storage(),
3797+ tree,
3798+ canonical,
3799+ });
3800+ if trees.len() >= self.next_sweep.get().max(256) {
3801+ trees.retain(|_, t| t.storage.strong_count() != 0);
3802+ self.next_sweep.set((trees.len() * 2).max(256));
3803+ }
3804+ }
3805+
3806+ async fn tree_of(&self, store: &Arc<Store>, files: &Value) -> Result<(MergedTree, bool), Crash> {
3807+ let Value::List(list) = files else {
3808+ files.as_list()?;
3809+ unreachable!();
3810+ };
3811+ if let Some(cached) = self.trees.borrow().get(&list.identity()) {
3812+ // The weak reference keeps its allocation reserved, so another
3813+ // list cannot reuse this address while the key is cached.
3814+ if cached.storage.strong_count() != 0 {
3815+ return Ok((cached.tree.clone(), cached.canonical));
3816+ }
3817+ }
3818+ // Validate every new list before caching it, even when from == to:
3819+ // an unchanged change must still reject malformed snapshots.
3820+ let tree = build_tree(store, files).await?;
3821+ self.remember(list, tree.clone(), false);
3822+ Ok((tree, false))
3823+ }
3824+
3825+ async fn files_of(&self, store: &Arc<Store>, tree: &MergedTree) -> Result<Value, Crash> {
3826+ let (files, reusable) = tree_to_files_inner(store, tree, &self.blobs, &self.entries).await?;
3827+ let list = ListVal::new(files);
3828+ if reusable && !self.has_empty_subtrees(tree).await? {
3829+ self.remember(&list, tree.clone(), true);
3830+ }
3831+ Ok(Value::List(list))
3832+ }
3833+
3834+ /// Flattening a Git tree omits empty directories. Reusing such a tree
3835+ /// would let a replay conflict with a directory absent from its input
3836+ /// snapshot. Inspect directories once per tree, without rebuilding files.
3837+ async fn has_empty_subtrees(&self, tree: &MergedTree) -> Result<bool, Crash> {
3838+ let Some(root) = tree.tree_ids().as_resolved() else { return Ok(true) };
3839+ if let Some(found) = self.empty_subtrees.borrow().get(root) {
3840+ return Ok(*found);
3841+ }
3842+ let store = tree.store();
3843+ let mut pending = vec![(RepoPathBuf::root(), root.clone())];
3844+ let mut visited = BTreeSet::new();
3845+ while let Some((dir, id)) = pending.pop() {
3846+ if !visited.insert(id.clone()) {
3847+ continue;
3848+ }
3849+ let known = self.empty_subtrees.borrow().get(&id).copied();
3850+ if let Some(found) = known {
3851+ if found {
3852+ self.empty_subtrees.borrow_mut().insert(root.clone(), true);
3853+ return Ok(true);
3854+ }
3855+ continue;
3856+ }
3857+ let loaded = store.get_tree(dir.clone(), &id).await
3858+ .map_err(|e| Crash::new(format!("cannot read a tree: {}", e)))?;
3859+ for entry in loaded.entries_non_recursive() {
3860+ if let TreeValue::Tree(child) = entry.value() {
3861+ if child == store.empty_tree_id() {
3862+ self.empty_subtrees.borrow_mut().insert(root.clone(), true);
3863+ return Ok(true);
3864+ }
3865+ pending.push((dir.join(entry.name()), child.clone()));
3866+ }
3867+ }
3868+ }
3869+ // Every subtree visited was nonempty all the way down. Sharing this
3870+ // answer also avoids rescanning unchanged directories of later trees.
3871+ self.empty_subtrees.borrow_mut().extend(visited.into_iter().map(|id| (id, false)));
3872+ Ok(false)
3873+ }
3874+}
3875+
37613876async fn jj_replay(
37623877 store: &Arc<Store>,
3763− onto: &[Value],
3764− from: &[Value],
3765− to: &[Value],
3766−) -> Result<Vec<Value>, Crash> {
3767− let onto_tree = build_tree(store, &Value::list(onto.to_vec())).await?;
3768− let from_tree = build_tree(store, &Value::list(from.to_vec())).await?;
3769− let to_tree = build_tree(store, &Value::list(to.to_vec())).await?;
3878+ cache: &ReplayCache,
3879+ onto: &Value,
3880+ from: &Value,
3881+ to: &Value,
3882+) -> Result<Value, Crash> {
3883+ let (onto_tree, onto_canonical) = cache.tree_of(store, onto).await?;
3884+ let (from_tree, from_canonical) = cache.tree_of(store, from).await?;
3885+ let (to_tree, to_canonical) = cache.tree_of(store, to).await?;
37703886 // any of the three may itself be conflicted (a snapshot with unresolved
37713887 // blobs, §7.3), so merge the trees the way jj's rebase does:
37723888 // MergedTree::merge flattens conflicted inputs into one merge, simplifies
@@ -3780,8 +3896,20 @@ async fn jj_replay(
37803896 .await
37813897 .map_err(|e| Crash::new(format!("replay: {}", e)))?;
37823898 refuse_file_directory_clash(&merged, &onto_tree, &from_tree, &to_tree).await?;
3899+ // Reusing a resolved tree's list preserves replay's sorted output. A
3900+ // constructed list may have another order, and conflicts may simplify
3901+ // on reading, so neither can take this shortcut.
3902+ for (files, tree, canonical) in [
3903+ (onto, &onto_tree, onto_canonical),
3904+ (to, &to_tree, to_canonical),
3905+ (from, &from_tree, from_canonical),
3906+ ] {
3907+ if canonical && merged.tree_ids() == tree.tree_ids() {
3908+ return Ok(files.clone());
3909+ }
3910+ }
37833911 // conflicts stay as unresolved blobs (§7.3)
3784− tree_to_files(store, &merged, &BlobCache::default(), &EntryCache::default()).await
3912+ cache.files_of(store, &merged).await
37853913}
37863914
37873915/// Crash if the merge left a conflict with a file on one side and, on
@@ -4077,34 +4205,49 @@ fn to_repo_path(path: &[String]) -> Result<RepoPathBuf, Crash> {
40774205// jj trees -> values
40784206// ----------------------------------------------------------------------
40794207
4080−/// blob contents keyed by file id, shared across the whole repo load: most
4081−/// files are unchanged between commits, so inflating each blob once (instead
4082−/// of once per commit that references it) keeps loading linear in the
4083−/// repository's unique data rather than commits × files
4208+/// Blob contents keyed by file id, shared while snapshots hold their bytes.
4209+/// Weak entries let discarded replay results release their conflict sides.
40844210#[derive(Clone, Default)]
4085−struct BlobCache(Rc<RefCell<HashMap<FileId, Rc<Vec<u8>>>>>);
4211+struct BlobCache {
4212+ bytes: Rc<RefCell<HashMap<FileId, Weak<Vec<u8>>>>>,
4213+ next_sweep: Rc<Cell<usize>>,
4214+}
40864215
40874216impl BlobCache {
40884217 fn get(&self, id: &FileId) -> Option<Rc<Vec<u8>>> {
4089− self.0.borrow().get(id).cloned()
4218+ self.bytes.borrow().get(id).and_then(Weak::upgrade)
40904219 }
40914220 fn insert(&self, id: FileId, bytes: Rc<Vec<u8>>) {
4092− self.0.borrow_mut().insert(id, bytes);
4221+ let mut cached = self.bytes.borrow_mut();
4222+ cached.insert(id, Rc::downgrade(&bytes));
4223+ if cached.len() >= self.next_sweep.get().max(256) {
4224+ cached.retain(|_, bytes| bytes.strong_count() != 0);
4225+ self.next_sweep.set((cached.len() * 2).max(256));
4226+ }
40934227 }
40944228}
40954229
4096−/// whole file *entries* ({path, content}) keyed by (path, blob identity),
4097−/// shared across the whole repo load: an unchanged file is the same Value in
4098−/// every commit, so it is built once and cloned (an Rc bump) thereafter
4230+/// Whole file entries keyed by (path, blob identity), shared by loaded
4231+/// snapshots and replays. Weak references let transient replay results go
4232+/// away while unchanged entries still held by a snapshot are reused.
40994233#[derive(Clone, Default)]
4100−struct EntryCache(Rc<RefCell<HashMap<String, Value>>>);
4234+struct EntryCache {
4235+ entries: Rc<RefCell<HashMap<String, Weak<crate::value::RecordMap>>>>,
4236+ next_sweep: Rc<Cell<usize>>,
4237+}
41014238
41024239impl EntryCache {
41034240 fn get(&self, key: &str) -> Option<Value> {
4104− self.0.borrow().get(key).cloned()
4241+ self.entries.borrow().get(key).and_then(Weak::upgrade).map(Value::Record)
41054242 }
41064243 fn insert(&self, key: String, entry: Value) {
4107− self.0.borrow_mut().insert(key, entry);
4244+ let Value::Record(record) = entry else { return };
4245+ let mut entries = self.entries.borrow_mut();
4246+ entries.insert(key, Rc::downgrade(&record));
4247+ if entries.len() >= self.next_sweep.get().max(256) {
4248+ entries.retain(|_, entry| entry.strong_count() != 0);
4249+ self.next_sweep.set((entries.len() * 2).max(256));
4250+ }
41084251 }
41094252}
41104253
@@ -4284,9 +4427,27 @@ async fn tree_to_files(
42844427 cache: &BlobCache,
42854428 entries: &EntryCache,
42864429) -> Result<Vec<Value>, Crash> {
4430+ tree_to_files_inner(store, tree, cache, entries).await.map(|(files, _)| files)
4431+}
4432+
4433+/// Whether these files can reuse the original tree in replay. Reading a
4434+/// conflict simplifies it, and an unsupported git submodule reads as an
4435+/// empty file, so their original trees cannot represent the resulting list.
4436+async fn tree_to_files_inner(
4437+ store: &Arc<Store>,
4438+ tree: &MergedTree,
4439+ cache: &BlobCache,
4440+ entries: &EntryCache,
4441+) -> Result<(Vec<Value>, bool), Crash> {
42874442 let mut out = Vec::new();
4443+ let mut reusable = tree.tree_ids().is_resolved();
42884444 for (path, value) in tree.entries() {
42894445 let value = value.map_err(|e| Crash::new(format!("cannot read a tree entry: {}", e)))?;
4446+ reusable &= match value.as_resolved() {
4447+ Some(Some(TreeValue::File { copy_id, .. })) => *copy_id == CopyId::placeholder(),
4448+ Some(Some(TreeValue::Symlink(_))) => true,
4449+ _ => false,
4450+ };
42904451 // cache key: the entry is fully determined by its path and the
42914452 // identity of its (resolved) content; conflicts are not cached
42924453 let key = match value.as_resolved() {
@@ -4317,7 +4478,7 @@ async fn tree_to_files(
43174478 };
43184479 out.push(entry);
43194480 }
4320− Ok(out)
4481+ Ok((out, reusable))
43214482}
43224483
43234484// ----------------------------------------------------------------------
@@ -4360,15 +4521,13 @@ fn new_id_set(repo: &Value) -> Result<BTreeSet<String>, Crash> {
43604521// building the Repo value from the visible map (§7.2)
43614522// ----------------------------------------------------------------------
43624523
4363−fn build_repo_value(vis: &VisibleRepo, focus_change: &str) -> Result<Value, OpenError> {
4524+fn build_repo_value(vis: &VisibleRepo, focus_change: &str, replay_cache: &Rc<ReplayCache>) -> Result<Value, OpenError> {
43644525 let root_rec = vis
43654526 .commits
43664527 .get(ROOT_ID)
43674528 .ok_or_else(|| (2, "no root commit".to_string()))?;
43684529 let store = root_rec.commit.store().clone();
4369− let cache = BlobCache::default();
4370− let entries = EntryCache::default();
4371− let mut subtree = build_subtree(vis, root_rec, &store, &cache, &entries)?;
4530+ let mut subtree = build_subtree(vis, root_rec, &store, replay_cache)?;
43724531 sort_subtree(&mut subtree, vis);
43734532 let top = Value::record(&[
43744533 ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
@@ -4385,8 +4544,7 @@ fn build_subtree(
43854544 vis: &VisibleRepo,
43864545 rec: &StoredCommit,
43874546 store: &Arc<Store>,
4388− cache: &BlobCache,
4389− entries: &EntryCache,
4547+ replay_cache: &Rc<ReplayCache>,
43904548) -> Result<Value, OpenError> {
43914549 // the files list is lazy (§7.2): most commits are never inspected, so
43924550 // their file entries are materialized only on first `field("files")`.
@@ -4397,11 +4555,9 @@ fn build_subtree(
43974555 let store = store.clone();
43984556 let tree = rec.commit.tree();
43994557 let name = Some(tree_name(&tree));
4400− let cache = cache.clone();
4401− let entries = entries.clone();
4558+ let replay_cache = replay_cache.clone();
44024559 Value::Thunk(Rc::new(crate::value::ThunkVal::stored(rec.change_id.clone(), name, move || {
4403− let files = block_on(tree_to_files(&store, &tree, &cache, &entries))?;
4404− Ok(Value::list(files))
4560+ block_on(replay_cache.files_of(&store, &tree))
44054561 })))
44064562 };
44074563 let labels: Vec<Value> = vis
@@ -4422,7 +4578,7 @@ fn build_subtree(
44224578 if let Some(kids) = vis.children.get(&rec.change_id) {
44234579 for kid in kids {
44244580 if let Some(krec) = vis.commits.get(kid) {
4425− children.push(build_subtree(vis, krec, store, cache, entries)?);
4581+ children.push(build_subtree(vis, krec, store, replay_cache)?);
44264582 }
44274583 }
44284584 }
@@ -4464,6 +4620,112 @@ mod tests {
44644620 use jj_lib::backend::SymlinkId;
44654621 use jj_lib::conflict_labels::ConflictLabels;
44664622
4623+ fn replay_store() -> (tempfile::TempDir, Arc<Store>) {
4624+ let dir = tempfile::tempdir().unwrap();
4625+ let (_, repo) = block_on(Workspace::init_colocated_git(
4626+ &user_settings(None), dir.path(), gix::hash::Kind::Sha1,
4627+ )).unwrap();
4628+ (dir, repo.store().clone())
4629+ }
4630+
4631+ fn file(path: &str, content: &str) -> Value {
4632+ Value::record(&[
4633+ ("path", Value::list(vec![Value::text(path)])),
4634+ ("content", BlobVal::text_blob(content)),
4635+ ])
4636+ }
4637+
4638+ fn loaded_files(store: &Arc<Store>, cache: &ReplayCache, files: Vec<Value>) -> Value {
4639+ let tree = block_on(build_tree(store, &Value::list(files))).unwrap();
4640+ block_on(cache.files_of(store, &tree)).unwrap()
4641+ }
4642+
4643+ #[test]
4644+ fn replay_reuses_loaded_snapshots_and_unchanged_entries() {
4645+ let (_dir, store) = replay_store();
4646+ let cache = ReplayCache::default();
4647+ let base = loaded_files(&store, &cache, vec![file("a", "base a"), file("b", "base b")]);
4648+ let same = block_on(jj_replay(&store, &cache, &base, &base, &base)).unwrap();
4649+ let (Value::List(before), Value::List(after)) = (&base, &same) else { panic!() };
4650+ assert_eq!(before.identity(), after.identity(), "no-op replay must retain the snapshot");
4651+
4652+ let onto = loaded_files(&store, &cache, vec![file("a", "onto"), file("b", "base b")]);
4653+ let to = loaded_files(&store, &cache, vec![file("a", "base a"), file("b", "to")]);
4654+ let merged = block_on(jj_replay(&store, &cache, &onto, &base, &to)).unwrap();
4655+ let expected = Value::list(vec![file("a", "onto"), file("b", "to")]);
4656+ assert!(crate::value::value_eq(&merged, &expected).unwrap());
4657+ for (result, input) in [
4658+ (&merged.as_list().unwrap()[0], &onto.as_list().unwrap()[0]),
4659+ (&merged.as_list().unwrap()[1], &to.as_list().unwrap()[1]),
4660+ ] {
4661+ let (Value::Record(result), Value::Record(input)) = (result, input) else { panic!() };
4662+ assert!(Rc::ptr_eq(result, input), "unchanged file entries must be shared");
4663+ }
4664+ }
4665+
4666+ #[test]
4667+ fn replay_cache_preserves_validation_ordering_and_list_offsets() {
4668+ let (_dir, store) = replay_store();
4669+ let cache = ReplayCache::default();
4670+ let unsorted = Value::list(vec![file("b", "b"), file("a", "a")]);
4671+ let empty = Value::list(vec![]);
4672+ let sorted = block_on(jj_replay(&store, &cache, &unsorted, &empty, &empty)).unwrap();
4673+ assert!(crate::value::value_eq(&sorted, &Value::list(vec![file("a", "a"), file("b", "b")])).unwrap());
4674+
4675+ let Value::List(list) = &sorted else { panic!() };
4676+ let tail = Value::List(list.skip(1));
4677+ let replayed_tail = block_on(jj_replay(&store, &cache, &tail, &empty, &empty)).unwrap();
4678+ assert!(crate::value::value_eq(&tail, &replayed_tail).unwrap());
4679+ assert_eq!(replayed_tail.as_list().unwrap().len(), 1);
4680+
4681+ let malformed = Value::list(vec![file("a", "one"), file("a", "two")]);
4682+ let err = block_on(jj_replay(&store, &cache, &sorted, &malformed, &malformed)).unwrap_err();
4683+ assert!(err.msg.contains("duplicate paths"), "{}", err.msg);
4684+ }
4685+
4686+ #[test]
4687+ fn replay_ignores_empty_directories_not_represented_by_snapshots() {
4688+ let (_dir, store) = replay_store();
4689+ let cache = ReplayCache::default();
4690+ let entry = |id| jj_lib::backend::Tree::from_sorted_entries(vec![
4691+ (RepoPathComponentBuf::new("d".to_string()).unwrap(), TreeValue::Tree(id)),
4692+ ]);
4693+ let direct = block_on(store.write_tree(RepoPath::root(), entry(store.empty_tree_id().clone()))).unwrap();
4694+ let nested = block_on(store.write_tree(RepoPath::root(), entry(direct.id().clone()))).unwrap();
4695+ let empty = Value::list(vec![]);
4696+ let to = Value::list(vec![file("d", "file")]);
4697+ for tree in [direct, nested] {
4698+ let tree = MergedTree::resolved(store.clone(), tree.id().clone());
4699+ let onto = block_on(cache.files_of(&store, &tree)).unwrap();
4700+ assert!(onto.as_list().unwrap().is_empty());
4701+ let result = block_on(jj_replay(&store, &cache, &onto, &empty, &to)).unwrap();
4702+ assert!(crate::value::value_eq(&result, &to).unwrap());
4703+ }
4704+ }
4705+
4706+ #[test]
4707+ fn replay_cache_does_not_keep_discarded_snapshots_alive() {
4708+ let (_dir, store) = replay_store();
4709+ let cache = ReplayCache::default();
4710+ let files = loaded_files(&store, &cache, vec![file("a", "a")]);
4711+ let Value::List(list) = &files else { panic!() };
4712+ let storage = list.weak_storage();
4713+ let Value::Record(entry) = &files.as_list().unwrap()[0] else { panic!() };
4714+ let entry = Rc::downgrade(entry);
4715+ drop(files);
4716+ assert!(storage.upgrade().is_none());
4717+ assert!(entry.upgrade().is_none());
4718+
4719+ let bytes = Rc::new(vec![1, 2, 3]);
4720+ let weak_bytes = Rc::downgrade(&bytes);
4721+ let id = FileId::new(vec![1; 20]);
4722+ cache.blobs.insert(id.clone(), bytes.clone());
4723+ assert!(Rc::ptr_eq(&cache.blobs.get(&id).unwrap(), &bytes));
4724+ drop(bytes);
4725+ assert!(weak_bytes.upgrade().is_none());
4726+ assert!(cache.blobs.get(&id).is_none());
4727+ }
4728+
44674729 #[test]
44684730 fn a_description_names_its_terms_whatever_their_labels_and_the_sides_that_cancel() {
44694731 // what a checkout writes for a symlink/file conflict of five terms,