@@ -6,7 +6,7 @@
6use crate::config::Config;
6use crate::config::Config;
7use crate::domain::{Backend, MetaInfo, ROOT_ID};
7use crate::domain::{Backend, MetaInfo, ROOT_ID};
8use crate::eval::Interp;
8use 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};
10use futures::{StreamExt, TryStreamExt};
10use futures::{StreamExt, TryStreamExt};
11use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
11use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
12use jj_lib::commit::Commit;
12use jj_lib::commit::Commit;
@@ -31,12 +31,12 @@ use jj_lib::store::Store;
31use jj_lib::transaction::UnpublishedOperation;
31use jj_lib::transaction::UnpublishedOperation;
32use jj_lib::working_copy::{LockedWorkingCopy, SnapshotError, SnapshotOptions};
32use jj_lib::working_copy::{LockedWorkingCopy, SnapshotError, SnapshotOptions};
33use jj_lib::workspace::Workspace;
33use jj_lib::workspace::Workspace;
34−use std::cell::RefCell;
34+use std::cell::{Cell, RefCell};
35use std::collections::{BTreeMap, BTreeSet, HashMap};
35use std::collections::{BTreeMap, BTreeSet, HashMap};
36use std::future::Future;
36use std::future::Future;
37use std::os::unix::io::AsRawFd;
37use std::os::unix::io::AsRawFd;
38use std::path::PathBuf;
38use std::path::PathBuf;
39−use std::rc::Rc;
39+use std::rc::{Rc, Weak};
40use std::sync::{Arc, Mutex, OnceLock};
40use std::sync::{Arc, Mutex, OnceLock};
42fn block_on<F: Future>(f: F) -> F::Output {
42fn block_on<F: Future>(f: F) -> F::Output {
@@ -1416,6 +1416,7 @@ fn with_tree(commit: &Commit, tree: MergedTree) -> Commit {
1416#[derive(Clone)]
1416#[derive(Clone)]
1417pub struct JjBackend {
1417pub struct JjBackend {
1418 inner: Arc<JjInner>,
1418 inner: Arc<JjInner>,
1419+ replay_cache: Rc<ReplayCache>,
1421impl JjBackend {
1422impl JjBackend {
@@ -1448,6 +1449,7 @@ impl JjBackend {
1448 let repo = block_on(workspace.repo_loader().load_at_head())
1449 let repo = block_on(workspace.repo_loader().load_at_head())
1449 .map_err(|e| (2, format!("cannot load the repository at head: {}", e)))?;
1450 .map_err(|e| (2, format!("cannot load the repository at head: {}", e)))?;
1450 Ok(JjBackend {
1451 Ok(JjBackend {
1452+ replay_cache: Rc::new(ReplayCache::default()),
1451 inner: Arc::new(JjInner {
1453 inner: Arc::new(JjInner {
1452 workspace_root: root,
1454 workspace_root: root,
1453 workspace: Mutex::new(workspace),
1455 workspace: Mutex::new(workspace),
@@ -1502,7 +1504,7 @@ impl JjBackend {
1502 .map_err(|e| (2, format!("cannot reload the repository at head: {}", e)))?;
1504 .map_err(|e| (2, format!("cannot reload the repository at head: {}", e)))?;
1503 *self.inner.repo.lock().unwrap() = head_repo;
1505 *self.inner.repo.lock().unwrap() = head_repo;
1504 let loaded_visible = Arc::new(block_on(read_visible(&self.current_repo()))?);
1506 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)?;
1507 let current = if snapshot {
1509 let current = if snapshot {
1508 // §7.4: snapshot the working directory into the wc commit; the
1510 // §7.4: snapshot the working directory into the wc commit; the
@@ -1519,7 +1521,7 @@ impl JjBackend {
1519 Some(pending) => {
1521 Some(pending) => {
1520 *self.inner.pending.lock().unwrap() = Some(pending);
1522 *self.inner.pending.lock().unwrap() = Some(pending);
1521 let vis = block_on(read_visible(&post))?;
1523 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)?;
1523 *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
1525 *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
@@ -2729,6 +2731,7 @@ pub fn cmd_init(cfg: &Config) -> Result<(), OpenError> {
2730 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
2732 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
2731 let backend = JjBackend {
2733 let backend = JjBackend {
2734+ replay_cache: Rc::new(ReplayCache::default()),
2732 inner: Arc::new(JjInner {
2735 inner: Arc::new(JjInner {
2733 workspace_root: cwd.clone(),
2736 workspace_root: cwd.clone(),
2734 workspace: Mutex::new(workspace),
2737 workspace: Mutex::new(workspace),
@@ -2973,6 +2976,7 @@ fn clone_into(
2974 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
2977 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
2975 let backend = JjBackend {
2978 let backend = JjBackend {
2979+ replay_cache: Rc::new(ReplayCache::default()),
2976 inner: Arc::new(JjInner {
2980 inner: Arc::new(JjInner {
2977 workspace_root: dir_path.to_path_buf(),
2981 workspace_root: dir_path.to_path_buf(),
2978 workspace: Mutex::new(workspace),
2982 workspace: Mutex::new(workspace),
@@ -3749,8 +3753,16 @@ impl Backend for JjBackend {
3751 fn replay(&self, onto: &[Value], from: &[Value], to: &[Value]) -> Result<Vec<Value>, Crash> {
3755 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)
3763+ fn replay_snapshot(&self, onto: &Value, from: &Value, to: &Value) -> Result<Value, Crash> {
3752 let store = self.current_repo().store().clone();
3764 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))
@@ -3758,15 +3770,119 @@ impl Backend for JjBackend {
3758// jj three-way tree merge for `replay` (§7.3)
3770// jj three-way tree merge for `replay` (§7.3)
3759// ----------------------------------------------------------------------
3771// ----------------------------------------------------------------------
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.
3777+struct ReplayCache {
3778+ trees: RefCell<HashMap<(*const Vec<Value>, usize), ReplayTree>>,
3779+ empty_subtrees: RefCell<HashMap<TreeId, bool>>,
3780+ next_sweep: Cell<usize>,
3782+ entries: EntryCache,
3786+ storage: Weak<Vec<Value>>,
3788+ /// A list read from a resolved tree, already in canonical path order.
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(),
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));
3806+ async fn tree_of(&self, store: &Arc<Store>, files: &Value) -> Result<(MergedTree, bool), Crash> {
3807+ let Value::List(list) = files else {
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));
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);
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);
3831+ Ok(Value::List(list))
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) {
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()) {
3849+ let known = self.empty_subtrees.borrow().get(&id).copied();
3850+ if let Some(found) = known {
3852+ self.empty_subtrees.borrow_mut().insert(root.clone(), true);
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);
3865+ pending.push((dir.join(entry.name()), child.clone()));
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)));
3761async fn jj_replay(
3876async fn jj_replay(
3762 store: &Arc<Store>,
3877 store: &Arc<Store>,
3878+ cache: &ReplayCache,
3763− onto: &[Value],
3879+ onto: &Value,
3764− from: &[Value],
3880+ from: &Value,
3765− to: &[Value],
3881+ to: &Value,
3766−) -> Result<Vec<Value>, Crash> {
3882+) -> Result<Value, Crash> {
3767− let onto_tree = build_tree(store, &Value::list(onto.to_vec())).await?;
3883+ let (onto_tree, onto_canonical) = cache.tree_of(store, onto).await?;
3768− let from_tree = build_tree(store, &Value::list(from.to_vec())).await?;
3884+ let (from_tree, from_canonical) = cache.tree_of(store, from).await?;
3769− let to_tree = build_tree(store, &Value::list(to.to_vec())).await?;
3885+ let (to_tree, to_canonical) = cache.tree_of(store, to).await?;
3770 // any of the three may itself be conflicted (a snapshot with unresolved
3886 // any of the three may itself be conflicted (a snapshot with unresolved
3771 // blobs, §7.3), so merge the trees the way jj's rebase does:
3887 // blobs, §7.3), so merge the trees the way jj's rebase does:
3772 // MergedTree::merge flattens conflicted inputs into one merge, simplifies
3888 // MergedTree::merge flattens conflicted inputs into one merge, simplifies
@@ -3780,8 +3896,20 @@ async fn jj_replay(
3781 .map_err(|e| Crash::new(format!("replay: {}", e)))?;
3897 .map_err(|e| Crash::new(format!("replay: {}", e)))?;
3782 refuse_file_directory_clash(&merged, &onto_tree, &from_tree, &to_tree).await?;
3898 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),
3907+ if canonical && merged.tree_ids() == tree.tree_ids() {
3908+ return Ok(files.clone());
3783 // conflicts stay as unresolved blobs (§7.3)
3911 // 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
3787/// Crash if the merge left a conflict with a file on one side and, on
3915/// 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> {
4077// jj trees -> values
4205// jj trees -> values
4078// ----------------------------------------------------------------------
4206// ----------------------------------------------------------------------
4080−/// blob contents keyed by file id, shared across the whole repo load: most
4208+/// Blob contents keyed by file id, shared while snapshots hold their bytes.
4081−/// files are unchanged between commits, so inflating each blob once (instead
4209+/// Weak entries let discarded replay results release their conflict sides.
4082−/// of once per commit that references it) keeps loading linear in the
4083−/// repository's unique data rather than commits × files
4084#[derive(Clone, Default)]
4210#[derive(Clone, Default)]
4085−struct BlobCache(Rc<RefCell<HashMap<FileId, Rc<Vec<u8>>>>>);
4212+ bytes: Rc<RefCell<HashMap<FileId, Weak<Vec<u8>>>>>,
4213+ next_sweep: Rc<Cell<usize>>,
4087impl BlobCache {
4216impl BlobCache {
4088 fn get(&self, id: &FileId) -> Option<Rc<Vec<u8>>> {
4217 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)
4091 fn insert(&self, id: FileId, bytes: Rc<Vec<u8>>) {
4220 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));
4096−/// whole file *entries* ({path, content}) keyed by (path, blob identity),
4230+/// Whole file entries keyed by (path, blob identity), shared by loaded
4097−/// shared across the whole repo load: an unchanged file is the same Value in
4231+/// snapshots and replays. Weak references let transient replay results go
4098−/// every commit, so it is built once and cloned (an Rc bump) thereafter
4232+/// away while unchanged entries still held by a snapshot are reused.
4099#[derive(Clone, Default)]
4233#[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>>,
4102impl EntryCache {
4239impl EntryCache {
4103 fn get(&self, key: &str) -> Option<Value> {
4240 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)
4106 fn insert(&self, key: String, entry: Value) {
4243 fn insert(&self, key: String, entry: Value) {
4244+ let Value::Record(record) = entry else { return };
4107− self.0.borrow_mut().insert(key, entry);
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));
@@ -4284,9 +4427,27 @@ async fn tree_to_files(
4284 cache: &BlobCache,
4427 cache: &BlobCache,
4285 entries: &EntryCache,
4428 entries: &EntryCache,
4286) -> Result<Vec<Value>, Crash> {
4429) -> Result<Vec<Value>, Crash> {
4430+ tree_to_files_inner(store, tree, cache, entries).await.map(|(files, _)| files)
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>,
4440+ entries: &EntryCache,
4441+) -> Result<(Vec<Value>, bool), Crash> {
4287 let mut out = Vec::new();
4442 let mut out = Vec::new();
4443+ let mut reusable = tree.tree_ids().is_resolved();
4288 for (path, value) in tree.entries() {
4444 for (path, value) in tree.entries() {
4289 let value = value.map_err(|e| Crash::new(format!("cannot read a tree entry: {}", e)))?;
4445 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,
4290 // cache key: the entry is fully determined by its path and the
4451 // cache key: the entry is fully determined by its path and the
4291 // identity of its (resolved) content; conflicts are not cached
4452 // identity of its (resolved) content; conflicts are not cached
4292 let key = match value.as_resolved() {
4453 let key = match value.as_resolved() {
@@ -4317,7 +4478,7 @@ async fn tree_to_files(
4318 out.push(entry);
4479 out.push(entry);
4320− Ok(out)
4481+ Ok((out, reusable))
4323// ----------------------------------------------------------------------
4484// ----------------------------------------------------------------------
@@ -4360,15 +4521,13 @@ fn new_id_set(repo: &Value) -> Result<BTreeSet<String>, Crash> {
4360// building the Repo value from the visible map (§7.2)
4521// building the Repo value from the visible map (§7.2)
4361// ----------------------------------------------------------------------
4522// ----------------------------------------------------------------------
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> {
4364 let root_rec = vis
4525 let root_rec = vis
4365 .commits
4526 .commits
4366 .get(ROOT_ID)
4527 .get(ROOT_ID)
4367 .ok_or_else(|| (2, "no root commit".to_string()))?;
4528 .ok_or_else(|| (2, "no root commit".to_string()))?;
4368 let store = root_rec.commit.store().clone();
4529 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)?;
4372 sort_subtree(&mut subtree, vis);
4531 sort_subtree(&mut subtree, vis);
4373 let top = Value::record(&[
4532 let top = Value::record(&[
4374 ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
4533 ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
@@ -4385,8 +4544,7 @@ fn build_subtree(
4385 vis: &VisibleRepo,
4544 vis: &VisibleRepo,
4386 rec: &StoredCommit,
4545 rec: &StoredCommit,
4387 store: &Arc<Store>,
4546 store: &Arc<Store>,
4388− cache: &BlobCache,
4547+ replay_cache: &Rc<ReplayCache>,
4389− entries: &EntryCache,
4390) -> Result<Value, OpenError> {
4548) -> Result<Value, OpenError> {
4391 // the files list is lazy (§7.2): most commits are never inspected, so
4549 // the files list is lazy (§7.2): most commits are never inspected, so
4392 // their file entries are materialized only on first `field("files")`.
4550 // their file entries are materialized only on first `field("files")`.
@@ -4397,11 +4555,9 @@ fn build_subtree(
4397 let store = store.clone();
4555 let store = store.clone();
4398 let tree = rec.commit.tree();
4556 let tree = rec.commit.tree();
4399 let name = Some(tree_name(&tree));
4557 let name = Some(tree_name(&tree));
4400− let cache = cache.clone();
4558+ let replay_cache = replay_cache.clone();
4401− let entries = entries.clone();
4402 Value::Thunk(Rc::new(crate::value::ThunkVal::stored(rec.change_id.clone(), name, move || {
4559 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))?;
4560+ block_on(replay_cache.files_of(&store, &tree))
4404− Ok(Value::list(files))
4407 let labels: Vec<Value> = vis
4563 let labels: Vec<Value> = vis
@@ -4422,7 +4578,7 @@ fn build_subtree(
4422 if let Some(kids) = vis.children.get(&rec.change_id) {
4578 if let Some(kids) = vis.children.get(&rec.change_id) {
4423 for kid in kids {
4579 for kid in kids {
4424 if let Some(krec) = vis.commits.get(kid) {
4580 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)?);
@@ -4464,6 +4620,112 @@ mod tests {
4464 use jj_lib::backend::SymlinkId;
4620 use jj_lib::backend::SymlinkId;
4465 use jj_lib::conflict_labels::ConflictLabels;
4621 use jj_lib::conflict_labels::ConflictLabels;
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,
4628+ (dir, repo.store().clone())
4631+ fn file(path: &str, content: &str) -> Value {
4633+ ("path", Value::list(vec![Value::text(path)])),
4634+ ("content", BlobVal::text_blob(content)),
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()
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");
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]),
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");
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());
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);
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);
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)),
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());
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);
4716+ assert!(storage.upgrade().is_none());
4717+ assert!(entry.upgrade().is_none());
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));
4725+ assert!(weak_bytes.upgrade().is_none());
4726+ assert!(cache.blobs.get(&id).is_none());
4468 fn a_description_names_its_terms_whatever_their_labels_and_the_sides_that_cancel() {
4730 fn a_description_names_its_terms_whatever_their_labels_and_the_sides_that_cancel() {
4469 // what a checkout writes for a symlink/file conflict of five terms,
4731 // what a checkout writes for a symlink/file conflict of five terms,