@@ -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 {