on master help sign in
Stop recomputing what a run already knows; make list tails share storage

treeFull 1339ms -> 668ms and tree 312ms -> 209ms on a 401-commit, 360-file repository; status -16%, log -11%, and the floor every command pays -12%. No output changes: 160 command outputs across ten repositories, including conflicted and branchy ones, are byte-identical before and after. Timing the tree path showed the work was not where the recent laziness commits had been looking. Four things: - `immutable` cost 153ms — 95% of even `j 1`. It was evaluated twice per `tree` (the focus-mutability check, then rendering) and `trunk` three times, and each evaluation walks the whole history through the interpreter. `focus_if_immutable` also built a throwaway interpreter and re-evaluated the config only to discard the result; it now uses the interpreter the run goes on to use, and `Interp::apply_cached_revset` memoises `trunk` and `immutable` against the identity of the Repo value they were applied to. Sound because the language is pure (§4.1); the `Rc` is held so the address cannot be reused as a key. - `build_info` keyed each snapshot four times per commit: twice inside `touched_paths` and twice again for the line counts, and every commit's map was rebuilt once per child. Keying allocates a `Vec<String>` per entry, which was 577k keyings on that repository. Each commit now keys its snapshot at most once, lazily, and shares it with its children; `touched_in_maps` merges the two sorted maps rather than collecting every key of both into a `BTreeSet`. The per-commit diffs went 1047ms -> 323ms. - `up` is `tail repo.context`, so copying in `tail` made every zipper walk quadratic in the depth of history: repeated `tail` measured 2/51/184ms at n=1000/2000/4000. `Value::List` now carries the offset of its first element, so `tail` and `drop` share the elements; the same measurement is flat, and list-heavy expressions came out ~12% faster rather than slower. A tail holds the elements before it alive, which costs nothing in a single short run. - The size bar cloned a file's whole content twice per changed path only to count its lines. `BlobVal::line_count` counts `\n` in place. `j 1` still spends its remaining ~128ms evaluating `immutable` once. That is a full interpreted walk by construction — it is defined in config.j, so the binary cannot shortcut it without overriding the user's configuration.

commitd86146b009e1e7021382a059e9a61e0e9c7263ed authoriko <ilyakooo0@gmail.com> dateMon 21 Sep 2026, 19:46 (3 weeks ago) parentfb18d86
8 files changed+260−66
src/builtins.rs+13−7 src/eval.rs+35 src/jj.rs+8−10 src/render.rs+83−41 src/repo.rs+1−5 src/value.rs+63−3 tests/builtins.rs+36 tests/laws.rs+21
src/builtins.rs+13−7
@@ -341,11 +341,15 @@ fn b_head(_i: &mut Interp, args: &[Value]) -> BResult {
341341}
342342
343343fn b_tail(_i: &mut Interp, args: &[Value]) -> BResult {
344− let xs = args[0].as_list()?;
345− if xs.is_empty() {
344+ if args[0].as_list()?.is_empty() {
346345 return Err(Crash::new("tail: empty list"));
347346 }
348− Ok(Value::list(xs[1..].to_vec()))
347+ // share the elements rather than copying them: `up` is `tail
348+ // repo.context`, so a copying tail made every zipper walk quadratic
349+ match &args[0] {
350+ Value::List(xs) => Ok(Value::List(xs.skip(1))),
351+ v => Err(Crash::new(format!("expected a list, got a {}", v.kind_name()))),
352+ }
349353}
350354
351355fn b_last(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -389,9 +393,11 @@ fn b_take(_i: &mut Interp, args: &[Value]) -> BResult {
389393}
390394
391395fn b_drop(_i: &mut Interp, args: &[Value]) -> BResult {
392− let xs = args[1].as_list()?;
393− let n = clamp(args[0].as_int()?, xs.len())?;
394− Ok(Value::list(xs[n..].to_vec()))
396+ let n = clamp(args[0].as_int()?, args[1].as_list()?.len())?;
397+ match &args[1] {
398+ Value::List(xs) => Ok(Value::List(xs.skip(n))),
399+ v => Err(Crash::new(format!("expected a list, got a {}", v.kind_name()))),
400+ }
395401}
396402
397403fn b_member(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -456,7 +462,7 @@ fn b_concat(_i: &mut Interp, args: &[Value]) -> BResult {
456462fn b_append(_i: &mut Interp, args: &[Value]) -> BResult {
457463 match (&args[0], &args[1]) {
458464 (Value::List(a), Value::List(b)) => {
459− let mut out = a.as_ref().clone();
465+ let mut out = a.to_vec();
460466 out.extend_from_slice(b);
461467 Ok(Value::list(out))
462468 }
src/eval.rs+35
@@ -22,6 +22,14 @@ pub struct Interp {
2222 pub current_def: RefCell<Option<String>>,
2323 /// the repository as loaded, before snapshotting (for `validate`)
2424 pub old_repo: RefCell<Option<Value>>,
25+ /// Results of applying a config revset the binary itself calls (`trunk`,
26+ /// `immutable`), keyed by the identity of the Repo record they were
27+ /// applied to. Both are pure and both are asked for more than once per
28+ /// run — `immutable` by the focus check and again by rendering or
29+ /// persisting — and each evaluation is a full interpreted walk of the
30+ /// history. The `Rc` is kept so the address cannot be reused while it is
31+ /// a key (§4.1: definitions are pure, so the result cannot change).
32+ revsets: RefCell<Vec<(&'static str, Rc<crate::value::RecordMap>, Value)>>,
2533}
2634
2735/// machine state
@@ -170,9 +178,35 @@ impl Interp {
170178 fresh: RefCell::new(0),
171179 current_def: RefCell::new(None),
172180 old_repo: RefCell::new(None),
181+ revsets: RefCell::new(Vec::new()),
173182 }
174183 }
175184
185+ /// Apply one of the config revsets the binary calls itself, reusing the
186+ /// result when the same Repo value has already been asked (§7.5).
187+ pub fn apply_cached_revset(&mut self, name: &'static str, repo: &Value) -> EResult {
188+ let key = match repo {
189+ Value::Record(m) => Some(m.clone()),
190+ _ => None,
191+ };
192+ if let Some(k) = &key {
193+ for (n, r, v) in self.revsets.borrow().iter() {
194+ if *n == name && Rc::ptr_eq(r, k) {
195+ return Ok(v.clone());
196+ }
197+ }
198+ }
199+ let f = self
200+ .globals
201+ .lookup(name)
202+ .ok_or_else(|| Crash::new(format!("`{}` is not defined", name)))?;
203+ let v = self.apply(f, repo.clone())?;
204+ if let Some(k) = key {
205+ self.revsets.borrow_mut().push((name, k, v.clone()));
206+ }
207+ Ok(v)
208+ }
209+
176210 /// An interpreter shell used only for recursive rendering; panics if any
177211 /// backend interaction is attempted.
178212 pub fn dummy() -> Interp {
@@ -184,6 +218,7 @@ impl Interp {
184218 fresh: RefCell::new(0),
185219 current_def: RefCell::new(None),
186220 old_repo: RefCell::new(None),
221+ revsets: RefCell::new(Vec::new()),
187222 }
188223 }
189224
src/jj.rs+8−10
@@ -413,14 +413,13 @@ impl JjBackend {
413413 cfg: &Config,
414414 current: &Value,
415415 ) -> Result<Option<Value>, OpenError> {
416− // evaluate the config to compute the immutable set
417− let mut probe = Interp::new(
418− Rc::new(self.clone()),
419− cfg.shapes.clone(),
420− crate::value::Env::empty(),
421− );
422− crate::config::eval_config(&mut probe, cfg).map_err(|c| (3, c.msg))?;
423− let immutable = crate::repo::compute_immutable(&mut probe, current)
416+ // Evaluate the config to compute the immutable set. This uses the
417+ // interpreter the run will go on to use, rather than a throwaway one,
418+ // so that the `immutable` (and the `trunk` inside it) computed here is
419+ // reused when rendering or persisting asks for the same Repo value —
420+ // each is a full interpreted walk of the history.
421+ crate::config::eval_config(interp, cfg).map_err(|c| (3, c.msg))?;
422+ let immutable = crate::repo::compute_immutable(interp, current)
424423 .map_err(|c| (1, c.msg))?;
425424 let focus_id = match current.field("root").and_then(|r| r.field("id")) {
426425 Ok(Value::Id(i)) => i.to_string(),
@@ -430,7 +429,6 @@ impl JjBackend {
430429 return Ok(None);
431430 }
432431 // build: new empty child of the focus with the focus's files, minted id
433− let _ = interp;
434432 let root = current.field("root").map_err(|c| (1, c.msg))?;
435433 let files = root.field("files").map_err(|c| (1, c.msg))?;
436434 let child = crate::value::Value::record(&[
@@ -439,7 +437,7 @@ impl JjBackend {
439437 ("labels", crate::value::Value::list(vec![])),
440438 (
441439 "id",
442− crate::value::Value::Id(Rc::new(probe.mint_id())),
440+ crate::value::Value::Id(Rc::new(interp.mint_id())),
443441 ),
444442 ]);
445443 let child_subtree = crate::value::Value::record(&[
src/render.rs+83−41
@@ -335,37 +335,64 @@ fn path_string(p: &Value) -> Result<String, Crash> {
335335 Ok(parts.join("/"))
336336}
337337
338+/// entries of one snapshot, keyed by path components
339+type SnapMap = BTreeMap<Vec<String>, Value>;
340+
338341fn touched_paths(change: &Value) -> Result<Vec<(String, char, bool)>, Crash> {
339− // (path, mark, unresolved)
340342 let from = snapshot_map_of(&change.field("from")?)?;
341343 let to = snapshot_map_of(&change.field("to")?)?;
342− let mut paths: BTreeSet<Vec<String>> = BTreeSet::new();
343− paths.extend(from.keys().cloned());
344− paths.extend(to.keys().cloned());
344+ Ok(touched_in_maps(&from, &to)?
345+ .into_iter()
346+ .map(|(p, mark, unresolved)| (p.join("/"), mark, unresolved))
347+ .collect())
348+}
349+
350+/// The changed paths between two snapshots that have already been keyed, as
351+/// (path components, mark, unresolved). Both maps are sorted, so the union is
352+/// a merge — the previous version collected every key of both sides into a
353+/// `BTreeSet`, cloning each one.
354+fn touched_in_maps(from: &SnapMap, to: &SnapMap) -> Result<Vec<(Vec<String>, char, bool)>, Crash> {
345355 let mut out = Vec::new();
346− for p in paths {
347− let f = from.get(&p);
348− let t = to.get(&p);
349− let mark = match (f, t) {
350− (None, Some(_)) => '+',
351− (Some(_), None) => '−',
352− (Some(a), Some(b)) => {
356+ let mut fi = from.iter().peekable();
357+ let mut ti = to.iter().peekable();
358+ loop {
359+ let ord = match (fi.peek(), ti.peek()) {
360+ (None, None) => break,
361+ (Some(_), None) => std::cmp::Ordering::Less,
362+ (None, Some(_)) => std::cmp::Ordering::Greater,
363+ (Some((fp, _)), Some((tp, _))) => fp.cmp(tp),
364+ };
365+ let (path, mark, t) = match ord {
366+ std::cmp::Ordering::Less => {
367+ let (p, _) = fi.next().unwrap();
368+ (p, '−', None)
369+ }
370+ std::cmp::Ordering::Greater => {
371+ let (p, v) = ti.next().unwrap();
372+ (p, '+', Some(v))
373+ }
374+ std::cmp::Ordering::Equal => {
375+ let (p, a) = fi.next().unwrap();
376+ let (_, b) = ti.next().unwrap();
353377 if crate::value::value_eq(a, b)? {
354378 continue;
355379 }
356− '~'
380+ (p, '~', Some(b))
357381 }
358− (None, None) => continue,
359382 };
360383 let unresolved = t
361384 .map(|b| matches!(b, Value::Blob(bl) if bl.is_unresolved()))
362385 .unwrap_or(false);
363− out.push((p.join("/"), if unresolved { '✖' } else { mark }, unresolved));
386+ out.push((
387+ path.clone(),
388+ if unresolved { '✖' } else { mark },
389+ unresolved,
390+ ));
364391 }
365392 Ok(out)
366393}
367394
368−fn snapshot_map_of(snap: &Value) -> Result<BTreeMap<Vec<String>, Value>, Crash> {
395+fn snapshot_map_of(snap: &Value) -> Result<SnapMap, Crash> {
369396 let mut m = BTreeMap::new();
370397 for e in snap.as_list()?.iter() {
371398 let path = e
@@ -998,6 +1025,24 @@ impl CommitInfo {
9981025 }
9991026}
10001027
1028+/// A parent commit's snapshot as seen by its children: the files value, plus
1029+/// its path-keyed map built at most once and shared by every child's diff.
1030+/// Keying a snapshot allocates a `Vec<String>` per entry, so rebuilding it for
1031+/// each child dominated `treeFull`/`treeData` on a large history.
1032+struct ParentSnap<'a> {
1033+ files: &'a Value,
1034+ map: &'a std::cell::OnceCell<SnapMap>,
1035+}
1036+
1037+impl ParentSnap<'_> {
1038+ fn map(&self) -> Result<&SnapMap, Crash> {
1039+ if self.map.get().is_none() {
1040+ let _ = self.map.set(snapshot_map_of(self.files)?);
1041+ }
1042+ Ok(self.map.get().expect("just set"))
1043+ }
1044+}
1045+
10011046#[allow(clippy::too_many_arguments)]
10021047fn build_info(
10031048 interp: &mut Interp,
@@ -1007,7 +1052,7 @@ fn build_info(
10071052 immutable: &BTreeSet<String>,
10081053 focus_id: &str,
10091054 anc: &BTreeSet<String>,
1010− parent_files: Option<&Value>,
1055+ parent_files: Option<&ParentSnap<'_>>,
10111056 with_focus: bool,
10121057 parent_is_ancestor: bool,
10131058 parent_is_focus: bool,
@@ -1049,38 +1094,39 @@ fn build_info(
10491094 None
10501095 };
10511096 let nfiles = files.as_ref().map(|f| f.as_list().map(|l| l.len())).transpose()?.unwrap_or(0);
1097+ // this commit's own snapshot map, built on first use and shared with every
1098+ // child that diffs against it
1099+ let my_map: std::cell::OnceCell<SnapMap> = std::cell::OnceCell::new();
1100+ let my_snap = files.as_ref().map(|f| ParentSnap {
1101+ files: f,
1102+ map: &my_map,
1103+ });
10521104 let (empty, size, detail_marks) = match parent_files {
1053− Some(pf) => {
1105+ Some(parent) => {
1106+ let pf = parent.files;
10541107 let empty = match backend_empty {
10551108 Some(e) => e,
10561109 None => crate::value::value_eq(files.as_ref().expect("files loaded"), pf)?,
10571110 };
10581111 if want_diff {
1059− let files = files.as_ref().expect("files loaded");
1060− let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1061− let marks = touched_paths(&change)?;
1112+ // both maps are built at most once per commit and reused by
1113+ // this commit's children, so a diff costs one keying, not four
1114+ let from_map = parent.map()?;
1115+ let to_map = my_snap.as_ref().expect("files loaded").map()?;
1116+ let marks = touched_in_maps(from_map, to_map)?;
10621117 // size: lines added+removed against the parent
1063− let from_map = snapshot_map_of(pf)?;
1064− let to_map = snapshot_map_of(files)?;
10651118 let mut lines = 0usize;
1066− for (p, _, _) in &marks {
1067− let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1068− let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1069− match m.get(&key) {
1070− Some(Value::Blob(b)) => {
1071− let n = b
1072− .bytes()
1073− .map(|v| String::from_utf8_lossy(&v).lines().count())
1074− .unwrap_or(1);
1075− n.max(1)
1076− }
1119+ for (key, _, _) in &marks {
1120+ let count = |m: &SnapMap| -> usize {
1121+ match m.get(key) {
1122+ Some(Value::Blob(b)) => b.line_count().max(1),
10771123 _ => 1,
10781124 }
10791125 };
1080− lines += count(&from_map) + count(&to_map);
1126+ lines += count(from_map) + count(to_map);
10811127 }
10821128 let marks2: Vec<(String, char)> =
1083− marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1129+ marks.iter().map(|(p, m, _)| (p.join("/"), *m)).collect();
10841130 (marks.is_empty(), Some(lines), marks2)
10851131 } else {
10861132 (empty, None, Vec::new())
@@ -1114,7 +1160,7 @@ fn build_info(
11141160 immutable,
11151161 focus_id,
11161162 anc,
1117− files.as_ref(),
1163+ my_snap.as_ref(),
11181164 with_focus,
11191165 is_ancestor_of_focus,
11201166 is_focus_pre,
@@ -1258,11 +1304,7 @@ fn top_id(repo: &Value) -> Result<String, Crash> {
12581304fn compute_trunk(interp: &mut Interp, repo: &Value) -> Result<BTreeSet<String>, Crash> {
12591305 let mut set = BTreeSet::new();
12601306 set.insert(top_id(repo)?);
1261− let trunk_fn = interp
1262− .globals
1263− .lookup("trunk")
1264− .ok_or_else(|| Crash::new("`trunk` is not defined"))?;
1265− let v = interp.apply(trunk_fn, repo.clone())?;
1307+ let v = interp.apply_cached_revset("trunk", repo)?;
12661308 let mut named: Vec<String> = Vec::new();
12671309 for idv in v.as_list()?.iter() {
12681310 match idv {
src/repo.rs+1−5
@@ -266,11 +266,7 @@ fn validate_labels(old: &Value, new: &Value) -> Result<(), Crash> {
266266/// The immutable set: the config's `immutable` revset against `old`, plus
267267/// every merge commit and its ancestors (§7.5 step 3).
268268pub fn compute_immutable(i: &mut Interp, old: &Value) -> Result<BTreeSet<String>, Crash> {
269− let immutable_fn = i
270− .globals
271− .lookup("immutable")
272− .ok_or_else(|| Crash::new("`immutable` is not defined"))?;
273− let v = i.apply(immutable_fn, old.clone())?;
269+ let v = i.apply_cached_revset("immutable", old)?;
274270 let mut set = BTreeSet::new();
275271 for idv in v.as_list()?.iter() {
276272 match idv {
src/value.rs+63−3
@@ -12,7 +12,7 @@ pub enum Value {
1212 Int(BigInt),
1313 Text(Rc<String>),
1414 Bool(bool),
15− List(Rc<Vec<Value>>),
15+ List(ListVal),
1616 Record(Rc<RecordMap>),
1717 Fun(Rc<FunVal>),
1818 Id(Rc<String>),
@@ -24,6 +24,44 @@ pub enum Value {
2424 Thunk(Rc<ThunkVal>),
2525}
2626
27+/// A list: shared elements plus the offset of its first one, so dropping a
28+/// prefix (`tail`, `drop`) shares the storage instead of copying it. The
29+/// zipper walks — `up` is `tail repo.context`, and `top`/`ancestors` apply it
30+/// once per level — made a copying `tail` quadratic in the depth of history.
31+/// A tail keeps the elements before it alive; `j` is a single short run, so
32+/// holding them costs nothing.
33+#[derive(Clone)]
34+pub struct ListVal {
35+ items: Rc<Vec<Value>>,
36+ start: usize,
37+}
38+
39+impl ListVal {
40+ pub fn new(items: Vec<Value>) -> ListVal {
41+ ListVal {
42+ items: Rc::new(items),
43+ start: 0,
44+ }
45+ }
46+ /// the list without its first `n` elements, sharing the storage
47+ pub fn skip(&self, n: usize) -> ListVal {
48+ ListVal {
49+ items: self.items.clone(),
50+ start: self.items.len().min(self.start.saturating_add(n)),
51+ }
52+ }
53+ pub fn as_slice(&self) -> &[Value] {
54+ &self.items[self.start..]
55+ }
56+}
57+
58+impl std::ops::Deref for ListVal {
59+ type Target = [Value];
60+ fn deref(&self) -> &[Value] {
61+ self.as_slice()
62+ }
63+}
64+
2765pub struct ThunkVal {
2866 state: std::cell::RefCell<ThunkState>,
2967}
@@ -194,6 +232,28 @@ impl BlobVal {
194232 BlobContent::Conflict(sides) => Ok(render_conflict(sides)),
195233 }
196234 }
235+ /// number of lines, counted over the bytes in place. Equivalent to
236+ /// `String::from_utf8_lossy(&self.bytes()?).lines().count()` — a `\n`
237+ /// byte is always a `\n` character in UTF-8, and lossy replacement never
238+ /// introduces one — but without copying the content out.
239+ pub fn line_count(&self) -> usize {
240+ fn count(b: &[u8]) -> usize {
241+ let nl = bytecount(b);
242+ if b.is_empty() || b.ends_with(b"\n") {
243+ nl
244+ } else {
245+ nl + 1
246+ }
247+ }
248+ fn bytecount(b: &[u8]) -> usize {
249+ b.iter().filter(|c| **c == b'\n').count()
250+ }
251+ match &self.content {
252+ BlobContent::Resolved(b) => count(b),
253+ BlobContent::Lazy(l) => l.force().map(|b| count(&b)).unwrap_or(1),
254+ BlobContent::Conflict(sides) => count(&render_conflict(sides)),
255+ }
256+ }
197257 /// the content hash of a lazy blob, if it is one (used to persist an
198258 /// unchanged blob without reading its bytes)
199259 pub fn lazy_id(&self) -> Option<&str> {
@@ -364,7 +424,7 @@ impl Value {
364424
365425 pub fn as_list(&self) -> Result<&[Value], Crash> {
366426 match self {
367− Value::List(xs) => Ok(xs),
427+ Value::List(xs) => Ok(xs.as_slice()),
368428 _ => Err(Crash::new(format!("expected a list, got a {}", self.kind_name()))),
369429 }
370430 }
@@ -392,7 +452,7 @@ impl Value {
392452 }
393453
394454 pub fn list(xs: Vec<Value>) -> Value {
395− Value::List(Rc::new(xs))
455+ Value::List(ListVal::new(xs))
396456 }
397457
398458 pub fn bool(b: bool) -> Value {
tests/builtins.rs+36
@@ -302,3 +302,39 @@ fn boolean_operators_as_values() {
302302 check!(i, cfg, "false && (crash \"boom\")", Value::Bool(false));
303303 check!(i, cfg, "true || (crash \"boom\")", Value::Bool(true));
304304}
305+
306+#[test]
307+fn tail_and_drop_share_storage_without_changing_semantics() {
308+ // `tail`/`drop` now return a view over the same elements instead of
309+ // copying them (a copying `tail` made every zipper walk quadratic, since
310+ // `up` is `tail repo.context`). The results must still be ordinary lists.
311+ let (mut i, cfg) = make_interp();
312+ check!(i, cfg, "tail [1 2 3]", ints(&[2, 3]));
313+ check!(i, cfg, "tail (tail [1 2 3])", ints(&[3]));
314+ check!(i, cfg, "tail (tail (tail [1 2 3]))", ints(&[]));
315+ check!(i, cfg, "drop 2 [1 2 3 4]", ints(&[3, 4]));
316+ check!(i, cfg, "drop 0 [1 2]", ints(&[1, 2]));
317+ check!(i, cfg, "drop 99 [1 2]", ints(&[]));
318+ check!(i, cfg, "drop 1 (drop 1 [1 2 3 4])", ints(&[3, 4]));
319+ // a view is equal to the same list built directly
320+ check!(i, cfg, "tail [1 2 3] == [2 3]", Value::Bool(true));
321+ check!(i, cfg, "drop 1 (tail [1 2 3 4]) == [3 4]", Value::Bool(true));
322+ // and behaves like one everywhere else
323+ check!(i, cfg, "length (tail [1 2 3])", Value::int(2));
324+ check!(i, cfg, "head (tail [1 2 3])", Value::int(2));
325+ check!(i, cfg, "last (tail [1 2 3])", Value::int(3));
326+ check!(i, cfg, "nth 0 (tail [1 2 3])", Value::int(2));
327+ check!(i, cfg, "tail [1 2 3] ++ [9]", ints(&[2, 3, 9]));
328+ check!(i, cfg, "0 :: tail [1 2 3]", ints(&[0, 2, 3]));
329+ check!(i, cfg, "take 1 (tail [1 2 3])", ints(&[2]));
330+ check!(i, cfg, "map (\\x -> x * 2) (tail [1 2 3])", ints(&[4, 6]));
331+ check!(i, cfg, "member 2 (tail [1 2 3])", Value::Bool(true));
332+ check!(i, cfg, "member 1 (tail [1 2 3])", Value::Bool(false));
333+ check!(i, cfg, "concat [(tail [1 2]) (tail [3 4])]", ints(&[2, 4]));
334+ check!(i, cfg, "null (tail [1])", Value::Bool(true));
335+ // the original is untouched by taking a tail of it
336+ check!(i, cfg, "let xs = [1 2 3] in tail xs ++ xs", ints(&[2, 3, 1, 2, 3]));
337+ // show round-trips a view
338+ check!(i, cfg, "show (tail [1 2 3])", Value::text("[2 3]"));
339+ assert!(crash(&mut i, &cfg, "tail []").contains("empty"));
340+}
tests/laws.rs+21
@@ -477,6 +477,27 @@ proptest! {
477477 prop_assert!(repos_match(&got.unwrap(), &repo, &stored));
478478 }
479479
480+ #[test]
481+ fn law_cached_revset_matches_direct((tree, focus, backend) in arb_repo()) {
482+ // `trunk` and `immutable` are memoised per Repo value, because the
483+ // binary asks for each more than once per run and every evaluation
484+ // walks the whole history. The cached answer must be the answer the
485+ // config gives, and must not change on a second ask.
486+ let repo = repo_value(&tree, &focus);
487+ let (mut i, cfg) = make_interp(backend);
488+ for name in ["trunk", "immutable"] {
489+ let direct = eval_fn(&mut i, &cfg, name, repo.clone());
490+ prop_assert!(direct.is_ok(), "{} failed: {:?}", name, direct.err());
491+ let direct = direct.unwrap();
492+ let first = i.apply_cached_revset(name, &repo);
493+ prop_assert!(first.is_ok(), "cached {} failed", name);
494+ let first = first.unwrap();
495+ let second = i.apply_cached_revset(name, &repo).unwrap();
496+ prop_assert!(value_eq(&direct, &first).unwrap_or(false), "{}: cached differs", name);
497+ prop_assert!(value_eq(&first, &second).unwrap_or(false), "{}: second ask differs", name);
498+ }
499+ }
500+
480501 #[test]
481502 fn law_top_top((tree, focus, backend) in arb_repo()) {
482503 let repo = repo_value(&tree, &focus);