on master help sign in
Fix large-repo performance and treeWith option defaults

Loading or rendering a repository with many commits (e.g. a clone of a real project) was effectively unusable: j status/tree burned minutes of CPU or never finished. Three root causes, plus a config robustness fix: Lazy blob contents (value.rs, jj.rs). Building the Repo value inflated every file of every visible commit — O(commits x files) blob reads. Files are now BlobContent::Lazy, a content-addressed thunk holding the jj FileId; bytes are read from the store only when an expression actually inspects them, memoized on first use. Equality compares FileIds without reading; persistence re-emits an unchanged blob's FileId without rewriting its bytes. Symlinks and conflict blobs stay eager. Quadratic zipper walks (repo.rs, render.rs). refocus_child matched siblings by deep value_eq (whole subtrees, files included); the tree renderer refocused every child with a from-the-top by_id, and top_id / the ancestors-of-focus walk refocused each zipper frame. All now match by commit id or read parent ids straight from the context frames. Skipped work. build_repo_value ran twice per command (before and after the working-copy snapshot); the second build is skipped when the snapshot is a no-op. The tree renderer computed line counts and change marks for every commit; now only for detail >= 2 and the focus's neighbourhood, with emptiness decided by id-based snapshot equality. treeWith option defaults (render.rs). A missing option (e.g. the newer author/date/files fields in an older user config) falls back to its default instead of crashing; present options are still type-checked. Also includes the tree colour work from the dc38b51 effort that was left uncommitted: meaning-coloured glyphs and a full-width focus-row background band (specs/tree.md, render.rs Palette). On a 1625-commit, 866-file repository: status 8min+ -> ~12s, tree never finishing -> ~11s. Co-authored-by: vix <290354907+vix-agent@users.noreply.github.com>

commit46c0a5e3df89504701c09465f0164f1e821dcc5e authoriko <mail@iko.soy> dateSat 19 Sep 2026, 07:23 (3 weeks ago) parentdc38b51
10 files changed+421−149
specs/tree.md+12−3 src/builtins.rs+2−2 src/domain.rs+16−13 src/jj.rs+100−24 src/render.rs+179−90 src/repo.rs+9−1 src/show.rs+9−5 src/value.rs+92−9 tests/domain.rs+1−1 tests/show.rs+1−1
specs/tree.md+12−3
@@ -232,9 +232,18 @@ drawn with `⌂`.
232232
233233#### Colour
234234
235−Unchanged for text. Rails in lane 0 take the immutable colour; rails in other
236−lanes are dim. Glyphs and rails carry the meaning; colour only adds emphasis,
237−so a colourless rendering loses nothing.
235+Glyphs are coloured by meaning (the same precedence as their shape): root bold,
236+conflict red, empty dim, immutable blue, focus bold cyan, ancestor of focus
237+green, other light grey. Ids keep their existing colours (conflict red,
238+immutable blue). Messages are bold on the focus row, red on a conflict, and
239+dim-italic when empty. Labels are green; the margin and the data columns are
240+grey with author-hued initials. Rail connectors in lane 0 take the immutable
241+colour; connectors in other lanes are dim.
242+
243+The whole focus row is highlighted with a background band that spans the full
244+terminal width. This is colour only: glyphs, rails, and the `▶` gutter still
245+carry the meaning, so a colourless rendering loses nothing — the band is a
246+no-op when colour is off.
238247
239248#### Legend
240249
src/builtins.rs+2−2
@@ -523,7 +523,7 @@ fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
523523fn b_text(_i: &mut Interp, args: &[Value]) -> BResult {
524524 match &args[0] {
525525 Value::Blob(b) => {
526− let bytes = b.bytes();
526+ let bytes = b.bytes()?;
527527 let s = String::from_utf8(bytes)
528528 .map_err(|_| Crash::new("text: blob content is not UTF-8"))?;
529529 Ok(Value::text(s))
@@ -588,7 +588,7 @@ fn b_diff(_i: &mut Interp, args: &[Value]) -> BResult {
588588
589589fn blob_text_utf8(v: &Value) -> Result<String, Crash> {
590590 match v {
591− Value::Blob(b) => String::from_utf8(b.bytes())
591+ Value::Blob(b) => String::from_utf8(b.bytes()?)
592592 .map_err(|_| Crash::new("diff: blob content is not UTF-8")),
593593 v => Err(Crash::new(format!(
594594 "diff: expected a Blob, got a {}",
src/domain.rs+16−13
@@ -113,18 +113,21 @@ pub fn map_to_snapshot(m: BTreeMap<Vec<String>, Value>) -> Vec<Value> {
113113 .collect()
114114}
115115
116−fn blob_eq(a: &Value, b: &Value) -> bool {
117− match (a, b) {
116+fn blob_eq(a: &Value, b: &Value) -> Result<bool, Crash> {
117+ Ok(match (a, b) {
118118 (Value::Blob(x), Value::Blob(y)) => {
119119 x.kind == y.kind
120120 && match (&x.content, &y.content) {
121121 (BlobContent::Resolved(p), BlobContent::Resolved(q)) => p == q,
122+ (BlobContent::Lazy(p), BlobContent::Lazy(q)) => p.id == q.id,
123+ (BlobContent::Lazy(p), BlobContent::Resolved(q))
124+ | (BlobContent::Resolved(q), BlobContent::Lazy(p)) => p.force()? == *q,
122125 (BlobContent::Conflict(p), BlobContent::Conflict(q)) => p == q,
123126 _ => false,
124127 }
125128 }
126129 _ => false,
127− }
130+ })
128131}
129132
130133fn empty_blob() -> Value {
@@ -165,8 +168,8 @@ pub fn simple_replay(
165168 let o_present = onto.contains_key(&p);
166169 let f_present = from.contains_key(&p);
167170 let t_present = to.contains_key(&p);
168− let changed_to = !(f_present == t_present && (!f_present || blob_eq(&f, &t)));
169− let changed_onto = !(f_present == o_present && (!f_present || blob_eq(&f, &o)));
171+ let changed_to = !(f_present == t_present && (!f_present || blob_eq(&f, &t)?));
172+ let changed_onto = !(f_present == o_present && (!f_present || blob_eq(&f, &o)?));
170173 match (changed_to, changed_onto) {
171174 (false, false) => {
172175 if o_present {
@@ -184,27 +187,27 @@ pub fn simple_replay(
184187 }
185188 }
186189 (true, true) => {
187− if t_present && o_present && blob_eq(&t, &o) {
190+ if t_present && o_present && blob_eq(&t, &o)? {
188191 out.insert(p, t);
189192 } else if !t_present && !o_present {
190193 // both deleted
191194 } else {
192195 // conflict: sides [to, from, onto] (add, remove, add)
193− let get_bytes = |v: &Value, present: bool| -> Rc<Vec<u8>> {
196+ let get_bytes = |v: &Value, present: bool| -> Result<Rc<Vec<u8>>, Crash> {
194197 if !present {
195− return Rc::new(Vec::new());
198+ return Ok(Rc::new(Vec::new()));
196199 }
197200 match v {
198− Value::Blob(b) => Rc::new(b.bytes()),
199− _ => Rc::new(Vec::new()),
201+ Value::Blob(b) => Ok(Rc::new(b.bytes()?)),
202+ _ => Ok(Rc::new(Vec::new())),
200203 }
201204 };
202205 out.insert(
203206 p,
204207 conflict_blob(vec![
205− get_bytes(&t, t_present),
206− get_bytes(&f, f_present),
207− get_bytes(&o, o_present),
208+ get_bytes(&t, t_present)?,
209+ get_bytes(&f, f_present)?,
210+ get_bytes(&o, o_present)?,
208211 ]),
209212 );
210213 }
src/jj.rs+100−24
@@ -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, Crash, Value};
9+use crate::value::{BlobContent, BlobKind, BlobVal, Crash, LazyBlob, Value};
1010use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
1111use jj_lib::commit::Commit;
1212use jj_lib::config::{ConfigLayer, ConfigSource, StackedConfig};
@@ -26,7 +26,8 @@ use jj_lib::settings::UserSettings;
2626use jj_lib::store::Store;
2727use jj_lib::working_copy::SnapshotOptions;
2828use jj_lib::workspace::Workspace;
29−use std::collections::{BTreeMap, BTreeSet};
29+use std::cell::RefCell;
30+use std::collections::{BTreeMap, BTreeSet, HashMap};
3031use std::future::Future;
3132use std::os::unix::io::AsRawFd;
3233use std::path::PathBuf;
@@ -366,11 +367,21 @@ impl JjBackend {
366367 // snapshot operation is written but unpublished — a persisting
367368 // run folds it into its own operation (§7.7)
368369 let (post, pending) = block_on(self.snapshot_repo())?;
369− *self.inner.pending.lock().unwrap() = pending;
370− let vis = block_on(read_visible(&post))?;
371− let cur = build_repo_value(&vis, &vis.wc_change_id)?;
372− *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
373− cur
370+ match pending {
371+ // no working-copy change: the repo is the one already loaded,
372+ // so reuse its value instead of rebuilding it
373+ None => {
374+ *self.inner.visible.lock().unwrap() = Some(Arc::new(loaded_visible));
375+ loaded.clone()
376+ }
377+ Some(pending) => {
378+ *self.inner.pending.lock().unwrap() = Some(pending);
379+ let vis = block_on(read_visible(&post))?;
380+ let cur = build_repo_value(&vis, &vis.wc_change_id)?;
381+ *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
382+ cur
383+ }
384+ }
374385 } else {
375386 *self.inner.visible.lock().unwrap() = Some(Arc::new(loaded_visible));
376387 loaded.clone()
@@ -603,6 +614,8 @@ impl JjBackend {
603614 old_stored = Arc::new(vis);
604615 }
605616 let mut written: BTreeMap<String, CommitId> = BTreeMap::new();
617+ // shared across the whole persist walk so unchanged blobs are inflated once
618+ let cache = BlobCache::default();
606619
607620 // the root commit must be the top and unchanged (§7.5 step 4)
608621 let top = crate::repo::by_id(new, ROOT_ID)?
@@ -621,7 +634,7 @@ impl JjBackend {
621634 if root_jj.description() != msg {
622635 return Err(Crash::new("persistence: the root commit cannot be changed"));
623636 }
624− let stored_files = block_on(tree_to_files(&store, &root_jj.tree()))
637+ let stored_files = block_on(tree_to_files(&store, &root_jj.tree(), &cache))
625638 .map_err(|e| Crash::new(e.msg))?;
626639 if !crate::value::value_eq(
627640 &Value::list(stored_files),
@@ -669,7 +682,8 @@ impl JjBackend {
669682 let stored_commit = &stored.commit;
670683 let parent_changed = hex_of(stored_commit.parent_ids().first()) != parent_jj.hex();
671684 let files_changed = {
672− let stored_files = block_on(tree_to_files(&store, &stored_commit.tree()))?;
685+ let stored_files =
686+ block_on(tree_to_files(&store, &stored_commit.tree(), &cache))?;
673687 !crate::value::value_eq(
674688 &Value::list(stored_files),
675689 &Value::list(files_v.as_list()?.to_vec()),
@@ -1800,7 +1814,7 @@ async fn jj_replay(
18001814 .map_err(|e| Crash::new(format!("replay: {}", e)))?;
18011815 // conflicts stay as unresolved blobs (§7.3)
18021816 let merged = MergedTree::new(store.clone(), merged, jj_lib::conflict_labels::ConflictLabels::unlabeled());
1803− tree_to_files(store, &merged).await
1817+ tree_to_files(store, &merged, &BlobCache::default()).await
18041818}
18051819
18061820// ----------------------------------------------------------------------
@@ -1843,6 +1857,17 @@ async fn blob_to_tree_value(store: &Arc<Store>, content: &Value) -> Result<Merge
18431857 return Err(Crash::new("persistence: content is not a Blob"));
18441858 };
18451859 match &b.content {
1860+ // an unchanged blob loaded from this same store: reuse its file id
1861+ // instead of inflating and rewriting the bytes
1862+ BlobContent::Lazy(l) => {
1863+ let id = FileId::try_from_hex(&l.id)
1864+ .ok_or_else(|| Crash::new("persistence: bad blob id".to_string()))?;
1865+ Ok(ConflictTreeValue::resolved(TreeValue::File {
1866+ id,
1867+ executable: b.kind == BlobKind::Executable,
1868+ copy_id: CopyId::placeholder(),
1869+ }))
1870+ }
18461871 BlobContent::Resolved(bytes) if b.kind == BlobKind::Symlink => {
18471872 let target = std::str::from_utf8(bytes)
18481873 .map_err(|_| Crash::new("persistence: symlink target is not UTF-8"))?;
@@ -1911,7 +1936,31 @@ fn to_repo_path(path: &[String]) -> Result<RepoPathBuf, Crash> {
19111936// jj trees -> values
19121937// ----------------------------------------------------------------------
19131938
1914−async fn read_file_bytes(store: &Arc<Store>, path: &RepoPath, id: &FileId) -> Result<Vec<u8>, Crash> {
1939+/// blob contents keyed by file id, shared across the whole repo load: most
1940+/// files are unchanged between commits, so inflating each blob once (instead
1941+/// of once per commit that references it) keeps loading linear in the
1942+/// repository's unique data rather than commits × files
1943+#[derive(Default)]
1944+struct BlobCache(RefCell<HashMap<FileId, Rc<Vec<u8>>>>);
1945+
1946+impl BlobCache {
1947+ fn get(&self, id: &FileId) -> Option<Rc<Vec<u8>>> {
1948+ self.0.borrow().get(id).cloned()
1949+ }
1950+ fn insert(&self, id: FileId, bytes: Rc<Vec<u8>>) {
1951+ self.0.borrow_mut().insert(id, bytes);
1952+ }
1953+}
1954+
1955+async fn read_file_bytes(
1956+ store: &Arc<Store>,
1957+ path: &RepoPath,
1958+ id: &FileId,
1959+ cache: &BlobCache,
1960+) -> Result<Rc<Vec<u8>>, Crash> {
1961+ if let Some(bytes) = cache.get(id) {
1962+ return Ok(bytes);
1963+ }
19151964 use futures::AsyncReadExt;
19161965 let mut reader = store
19171966 .read_file(path, id)
@@ -1922,6 +1971,8 @@ async fn read_file_bytes(store: &Arc<Store>, path: &RepoPath, id: &FileId) -> Re
19221971 .read_to_end(&mut buf)
19231972 .await
19241973 .map_err(|e| Crash::new(format!("cannot read a file: {}", e)))?;
1974+ let buf = Rc::new(buf);
1975+ cache.insert(id.clone(), buf.clone());
19251976 Ok(buf)
19261977}
19271978
@@ -1929,15 +1980,16 @@ async fn side_to_bytes(
19291980 store: &Arc<Store>,
19301981 path: &RepoPath,
19311982 v: &Option<TreeValue>,
1932−) -> Result<Vec<u8>, Crash> {
1983+ cache: &BlobCache,
1984+) -> Result<Rc<Vec<u8>>, Crash> {
19331985 match v {
1934− Some(TreeValue::File { id, .. }) => read_file_bytes(store, path, id).await,
1986+ Some(TreeValue::File { id, .. }) => read_file_bytes(store, path, id, cache).await,
19351987 Some(TreeValue::Symlink(id)) => store
19361988 .read_symlink(path, id)
19371989 .await
1938− .map(|s| s.into_bytes())
1990+ .map(|s| Rc::new(s.into_bytes()))
19391991 .map_err(|e| Crash::new(format!("cannot read a symlink: {}", e))),
1940− _ => Ok(Vec::new()),
1992+ _ => Ok(Rc::new(Vec::new())),
19411993 }
19421994}
19431995
@@ -1945,6 +1997,7 @@ async fn merged_value_to_blob(
19451997 store: &Arc<Store>,
19461998 path: &RepoPath,
19471999 value: &jj_lib::backend::MergedTreeValue,
2000+ cache: &BlobCache,
19482001) -> Result<Value, Crash> {
19492002 if let Some(v) = value.as_resolved() {
19502003 return match v {
@@ -1958,9 +2011,9 @@ async fn merged_value_to_blob(
19582011 // conflict sides, jj order: [add, (remove, add)*] (§7.3)
19592012 let mut sides: Vec<Rc<Vec<u8>>> = Vec::new();
19602013 for (i, add) in value.adds().enumerate() {
1961− sides.push(Rc::new(side_to_bytes(store, path, add).await?));
2014+ sides.push(side_to_bytes(store, path, add, cache).await?);
19622015 if let Some(remove) = value.get_remove(i) {
1963− sides.push(Rc::new(side_to_bytes(store, path, remove).await?));
2016+ sides.push(side_to_bytes(store, path, remove, cache).await?);
19642017 }
19652018 }
19662019 Ok(Value::Blob(Rc::new(BlobVal {
@@ -1976,14 +2029,30 @@ async fn tree_value_to_blob(
19762029) -> Result<Value, Crash> {
19772030 match value {
19782031 TreeValue::File { id, executable, .. } => {
1979− let buf = read_file_bytes(store, path, id).await?;
2032+ // lazy: the bytes are read from the store only if an expression
2033+ // actually looks at them; equality and persistence use `id`
2034+ let id_hex = id.hex();
2035+ let store2 = store.clone();
2036+ let path2 = path.to_owned();
2037+ let id2 = id.clone();
2038+ let lazy = LazyBlob::new(id_hex, move || {
2039+ let mut reader = block_on(store2.read_file(&path2, &id2))
2040+ .map_err(|e| Crash::new(format!("cannot read a file: {}", e)))?;
2041+ let mut buf = Vec::new();
2042+ {
2043+ use futures::AsyncReadExt;
2044+ block_on(reader.read_to_end(&mut buf))
2045+ .map_err(|e| Crash::new(format!("cannot read a file: {}", e)))?;
2046+ }
2047+ Ok(Rc::new(buf))
2048+ });
19802049 Ok(Value::Blob(Rc::new(BlobVal {
19812050 kind: if *executable {
19822051 BlobKind::Executable
19832052 } else {
19842053 BlobKind::Regular
19852054 },
1986− content: BlobContent::Resolved(Rc::new(buf)),
2055+ content: BlobContent::Lazy(Rc::new(lazy)),
19872056 })))
19882057 }
19892058 TreeValue::Symlink(id) => {
@@ -2005,7 +2074,11 @@ async fn tree_value_to_blob(
20052074 }
20062075}
20072076
2008−async fn tree_to_files(store: &Arc<Store>, tree: &MergedTree) -> Result<Vec<Value>, Crash> {
2077+async fn tree_to_files(
2078+ store: &Arc<Store>,
2079+ tree: &MergedTree,
2080+ cache: &BlobCache,
2081+) -> Result<Vec<Value>, Crash> {
20092082 let mut out = Vec::new();
20102083 for (path, value) in tree.entries() {
20112084 let value = value.map_err(|e| Crash::new(format!("cannot read a tree entry: {}", e)))?;
@@ -2013,7 +2086,7 @@ async fn tree_to_files(store: &Arc<Store>, tree: &MergedTree) -> Result<Vec<Valu
20132086 .components()
20142087 .map(|c| c.as_internal_str().to_string())
20152088 .collect();
2016− let content = merged_value_to_blob(store, &path, &value).await?;
2089+ let content = merged_value_to_blob(store, &path, &value, cache).await?;
20172090 let p = Value::list(comps.into_iter().map(Value::text).collect());
20182091 out.push(Value::record(&[("content", content), ("path", p)]));
20192092 }
@@ -2057,7 +2130,8 @@ fn build_repo_value(vis: &VisibleRepo, focus_change: &str) -> Result<Value, Open
20572130 .get(ROOT_ID)
20582131 .ok_or_else(|| (2, "no root commit".to_string()))?;
20592132 let store = root_rec.commit.store().clone();
2060− let mut subtree = build_subtree(vis, root_rec, &store)?;
2133+ let cache = BlobCache::default();
2134+ let mut subtree = build_subtree(vis, root_rec, &store, &cache)?;
20612135 sort_subtree(&mut subtree, vis);
20622136 let top = Value::record(&[
20632137 ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
@@ -2074,8 +2148,10 @@ fn build_subtree(
20742148 vis: &VisibleRepo,
20752149 rec: &StoredCommit,
20762150 store: &Arc<Store>,
2151+ cache: &BlobCache,
20772152) -> Result<Value, OpenError> {
2078− let files = block_on(tree_to_files(store, &rec.commit.tree())).map_err(|c| (2, c.msg))?;
2153+ let files =
2154+ block_on(tree_to_files(store, &rec.commit.tree(), cache)).map_err(|c| (2, c.msg))?;
20792155 let labels: Vec<Value> = vis
20802156 .labels
20812157 .get(&rec.change_id)
@@ -2094,7 +2170,7 @@ fn build_subtree(
20942170 if let Some(kids) = vis.children.get(&rec.change_id) {
20952171 for kid in kids {
20962172 if let Some(krec) = vis.commits.get(kid) {
2097− children.push(build_subtree(vis, krec, store)?);
2173+ children.push(build_subtree(vis, krec, store, cache)?);
20982174 }
20992175 }
21002176 }
src/render.rs+179−90
@@ -69,6 +69,23 @@ impl Palette {
6969 let code = ["31", "32", "33", "34", "35", "36"][hue as usize];
7070 self.wrap(code, s)
7171 }
72+ /// colour with a raw ANSI code (used for meaning-coloured glyphs)
73+ pub fn code(&self, code: &str, s: &str) -> String {
74+ self.wrap(code, s)
75+ }
76+ /// Apply a background band across a whole line. The line already contains
77+ /// colour codes whose `\x1b[0m` resets would clear the background mid-line,
78+ /// so the background is re-applied after every reset. No-op when colour is
79+ /// off, so the focus stays marked only by the `▶` gutter (§Colour).
80+ pub fn bg_line(&self, bg: &str, s: &str) -> String {
81+ if !self.on {
82+ return s.to_string();
83+ }
84+ let onset = format!("\x1b[{}m", bg);
85+ let reapply = format!("\x1b[0m{}", onset);
86+ let body = s.replace("\x1b[0m", &reapply);
87+ format!("{}{}\x1b[0m", onset, body)
88+ }
7289}
7390
7491pub fn color_enabled(mode: &str) -> bool {
@@ -421,7 +438,8 @@ fn node_glyph(
421438}
422439
423440fn has_conflict(c: &Value) -> Result<bool, Crash> {
424− for e in c.field("files").and_then(|v| v.as_list().map(|x| x.to_vec()))?.iter() {
441+ let files = c.field("files")?;
442+ for e in files.as_list()?.iter() {
425443 if let Value::Blob(b) = e.field("content")? {
426444 if b.is_unresolved() {
427445 return Ok(true);
@@ -451,7 +469,7 @@ fn display_block(
451469 return Ok(());
452470 }
453471 Value::Blob(b) => {
454− let content = String::from_utf8_lossy(&b.bytes()).to_string();
472+ let content = String::from_utf8_lossy(&b.bytes()?).to_string();
455473 out.push_str(&indent_multiline(&content, indent));
456474 return Ok(());
457475 }
@@ -885,42 +903,38 @@ fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
885903}
886904
887905pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Value, Crash> {
888− let detail = match opts.field("detail")? {
889− Value::Int(n) => n.to_string().parse::<i64>().unwrap_or(1),
890− _ => return Err(Crash::new("treeWith: detail must be an Int")),
891− };
892− let margin = match opts.field("margin")? {
893− Value::Bool(b) => b,
894− _ => return Err(Crash::new("treeWith: margin must be a Bool")),
895− };
896− let elide = match opts.field("elide")? {
897− Value::Bool(b) => b,
898− _ => return Err(Crash::new("treeWith: elide must be a Bool")),
906+ let defaults = default_tree_options(interp)?;
907+ // a missing option falls back to its default, so an older config that
908+ // predates a newer option keeps working; a present option is type-checked
909+ let bool_opt = |name: &str, default: bool| -> Result<bool, Crash> {
910+ match opts.field(name) {
911+ Ok(Value::Bool(b)) => Ok(b),
912+ Ok(_) => Err(Crash::new(format!("treeWith: {} must be a Bool", name))),
913+ Err(_) => Ok(default),
914+ }
899915 };
900− let icons = match opts.field("icons")? {
901− Value::Bool(b) => b,
902− _ => return Err(Crash::new("treeWith: icons must be a Bool")),
916+ let int_opt = |name: &str, default: i64| -> Result<i64, Crash> {
917+ match opts.field(name) {
918+ Ok(Value::Int(n)) => Ok(n.to_string().parse::<i64>().unwrap_or(default)),
919+ Ok(_) => Err(Crash::new(format!("treeWith: {} must be an Int", name))),
920+ Err(_) => Ok(default),
921+ }
903922 };
904− let color = opts.field("color")?.as_text()?.to_string();
905− let lanes = match opts.field("lanes")? {
906− Value::Int(n) => n.to_string().parse::<i64>().unwrap_or(0),
907− _ => return Err(Crash::new("treeWith: lanes must be an Int")),
923+ let detail = int_opt("detail", defaults.detail)?;
924+ let margin = bool_opt("margin", defaults.margin)?;
925+ let elide = bool_opt("elide", defaults.elide)?;
926+ let icons = bool_opt("icons", defaults.icons)?;
927+ let color = match opts.field("color") {
928+ Ok(v) => v.as_text()?.to_string(),
929+ Err(_) => defaults.color.clone(),
908930 };
931+ let lanes = int_opt("lanes", defaults.lanes)?;
909932 if lanes < 1 {
910933 return Err(Crash::new("treeWith: lanes must be at least 1"));
911934 }
912− let author = match opts.field("author")? {
913− Value::Bool(b) => b,
914− _ => return Err(Crash::new("treeWith: author must be a Bool")),
915− };
916− let date = match opts.field("date")? {
917− Value::Bool(b) => b,
918− _ => return Err(Crash::new("treeWith: date must be a Bool")),
919− };
920− let files = match opts.field("files")? {
921− Value::Bool(b) => b,
922− _ => return Err(Crash::new("treeWith: files must be a Bool")),
923− };
935+ let author = bool_opt("author", defaults.author)?;
936+ let date = bool_opt("date", defaults.date)?;
937+ let files = bool_opt("files", defaults.files)?;
924938 let pal = Palette {
925939 on: color_enabled(&color),
926940 };
@@ -981,7 +995,6 @@ impl CommitInfo {
981995fn build_info(
982996 interp: &mut Interp,
983997 opts: &TreeOptions,
984− loc: &Value,
985998 commit: &Value,
986999 children: &[Value],
9871000 immutable: &BTreeSet<String>,
@@ -990,6 +1003,7 @@ fn build_info(
9901003 parent_files: Option<&Value>,
9911004 with_focus: bool,
9921005 parent_is_ancestor: bool,
1006+ parent_is_focus: bool,
9931007) -> Result<CommitInfo, Crash> {
9941008 let id = match commit.field("id")? {
9951009 Value::Id(i) => i.to_string(),
@@ -1005,31 +1019,50 @@ fn build_info(
10051019 let conflict = has_conflict(commit)?;
10061020 let files = commit.field("files")?;
10071021 let nfiles = files.as_list()?.len();
1022+ // `empty` (no change against the parent) is needed at every detail level;
1023+ // compute it by id-based snapshot equality, which never forces blob bytes.
1024+ // `size` (a line count) and `detail_marks` are only rendered for detail ≥
1025+ // 2 and for the focus and its neighbours, so skip the per-commit line
1026+ // counting elsewhere — it dominates render time on large histories.
1027+ let is_focus_pre = with_focus && id == focus_id;
1028+ let child_is_focus = with_focus
1029+ && children.iter().any(|c| {
1030+ matches!(c.field("root").and_then(|r| r.field("id")), Ok(Value::Id(i)) if *i == focus_id)
1031+ });
1032+ let want_diff = opts.detail >= 2 || is_focus_pre || child_is_focus || parent_is_focus;
10081033 let (empty, size, detail_marks) = match parent_files {
10091034 Some(pf) => {
1010− let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1011− let marks = touched_paths(&change)?;
1012− let empty = marks.is_empty();
1013− // size: lines added+removed against the parent
1014− let from_map = snapshot_map_of(pf)?;
1015− let to_map = snapshot_map_of(&files)?;
1016− let mut lines = 0usize;
1017− for (p, _, _) in &marks {
1018− let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1019− let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1020− match m.get(&key) {
1021− Some(Value::Blob(b)) => {
1022− let n = String::from_utf8_lossy(&b.bytes()).lines().count();
1023− n.max(1)
1035+ // empty ⟺ the snapshot equals the parent's (id-based, no forcing)
1036+ let empty = crate::value::value_eq(&files, pf)?;
1037+ if want_diff {
1038+ let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1039+ let marks = touched_paths(&change)?;
1040+ // size: lines added+removed against the parent
1041+ let from_map = snapshot_map_of(pf)?;
1042+ let to_map = snapshot_map_of(&files)?;
1043+ let mut lines = 0usize;
1044+ for (p, _, _) in &marks {
1045+ let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1046+ let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1047+ match m.get(&key) {
1048+ Some(Value::Blob(b)) => {
1049+ let n = b
1050+ .bytes()
1051+ .map(|v| String::from_utf8_lossy(&v).lines().count())
1052+ .unwrap_or(1);
1053+ n.max(1)
1054+ }
1055+ _ => 1,
10241056 }
1025− _ => 1,
1026− }
1027− };
1028− lines += count(&from_map) + count(&to_map);
1057+ };
1058+ lines += count(&from_map) + count(&to_map);
1059+ }
1060+ let marks2: Vec<(String, char)> =
1061+ marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1062+ (marks.is_empty(), Some(lines), marks2)
1063+ } else {
1064+ (empty, None, Vec::new())
10291065 }
1030− let marks2: Vec<(String, char)> =
1031− marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1032− (empty, Some(lines), marks2)
10331066 }
10341067 None => {
10351068 let n = files.as_list()?.len();
@@ -1041,15 +1074,9 @@ fn build_info(
10411074 let is_ancestor_of_focus = anc.contains(&id);
10421075 let mut infos = Vec::new();
10431076 for c in children.iter() {
1044− let child_loc = crate::repo::by_id(loc, &match c.field("root")?.field("id")? {
1045− Value::Id(i) => i.to_string(),
1046− _ => continue,
1047− })?
1048− .unwrap();
10491077 infos.push(build_info(
10501078 interp,
10511079 opts,
1052− &child_loc,
10531080 &c.field("root")?,
10541081 c.field("children")?.as_list()?,
10551082 immutable,
@@ -1058,6 +1085,7 @@ fn build_info(
10581085 Some(&files),
10591086 with_focus,
10601087 is_ancestor_of_focus,
1088+ is_focus_pre,
10611089 )?);
10621090 }
10631091 let immutable_flag = immutable.contains(&id);
@@ -1092,20 +1120,19 @@ fn tree_render(
10921120 Value::Id(i) => i.to_string(),
10931121 _ => String::new(),
10941122 };
1095− // ancestors of focus
1123+ // ancestors of focus: the focus id plus every parent recorded in the
1124+ // zipper's context frames — read directly rather than refocusing each
1125+ // frame (an O(n) by_id per level would make this O(n²))
10961126 let mut anc: BTreeSet<String> = BTreeSet::new();
1097− let mut cur = repo.clone();
1098− loop {
1099− let id = match cur.field("root")?.field("id")? {
1100− Value::Id(i) => i.to_string(),
1101− _ => break,
1102− };
1103− anc.insert(id);
1104− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1105− if ctx.is_empty() {
1106− break;
1127+ if let Ok(Value::Id(i)) = repo.field("root").and_then(|r| r.field("id")) {
1128+ anc.insert(i.to_string());
1129+ }
1130+ if let Ok(ctx) = repo.field("context").and_then(|v| v.as_list().map(|x| x.to_vec())) {
1131+ for frame in &ctx {
1132+ if let Ok(pid) = id_of_frame_parent(frame) {
1133+ anc.insert(pid);
1134+ }
11071135 }
1108− cur = crate::repo::by_id(&cur, &id_of_frame_parent(&ctx[0])?)?.unwrap();
11091136 }
11101137 // the whole history, from the top
11111138 let top = crate::repo::by_id(repo, &top_id(repo)?)?.unwrap_or_else(|| repo.clone());
@@ -1114,7 +1141,6 @@ fn tree_render(
11141141 let root_info = build_info(
11151142 interp,
11161143 opts,
1117− &top,
11181144 &root_commit,
11191145 top_children.as_list()?,
11201146 &immutable,
@@ -1123,6 +1149,7 @@ fn tree_render(
11231149 None,
11241150 with_focus,
11251151 false,
1152+ false,
11261153 )?;
11271154 // trunk T (§Trunk)
11281155 let trunk = compute_trunk(interp, repo)?;
@@ -1182,17 +1209,16 @@ fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
11821209}
11831210
11841211fn top_id(repo: &Value) -> Result<String, Crash> {
1185− let mut cur = repo.clone();
1186− loop {
1187− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1188− if ctx.is_empty() {
1189− return match cur.field("root")?.field("id")? {
1190− Value::Id(i) => Ok(i.to_string()),
1191− v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
1192− };
1193− }
1194− let pid = id_of_frame_parent(&ctx[0])?;
1195− cur = crate::repo::by_id(&cur, &pid)?.unwrap();
1212+ // the topmost ancestor's id: the focus's own id when the context is empty,
1213+ // otherwise the parent recorded in the *last* (outermost) zipper frame —
1214+ // read directly, since refocusing each frame with by_id is O(n²) overall
1215+ let ctx = repo.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1216+ match ctx.last() {
1217+ None => match repo.field("root")?.field("id")? {
1218+ Value::Id(i) => Ok(i.to_string()),
1219+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
1220+ },
1221+ Some(frame) => id_of_frame_parent(frame),
11961222 }
11971223}
11981224
@@ -1624,10 +1650,13 @@ fn build_row_text(
16241650 } else {
16251651 String::new()
16261652 };
1627− // message
1653+ // message, coloured by state (§Colour): focus bold, conflict red, empty
1654+ // dim-italic; the collapsed marker is dim
16281655 let msg_first = c.map(|x| x.message.lines().next().unwrap_or("")).unwrap_or("");
16291656 let mut msg = match c {
16301657 Some(info) if info.empty => pal.dim_italic(msg_first),
1658+ Some(info) if info.is_focus => pal.bold(msg_first),
1659+ Some(info) if info.conflict => pal.red(msg_first),
16311660 _ => msg_first.to_string(),
16321661 };
16331662 if let Display::Collapsed { hidden, .. } = n {
@@ -1635,7 +1664,7 @@ fn build_row_text(
16351664 if !msg.is_empty() {
16361665 msg.push_str(" ");
16371666 }
1638− msg.push_str(&format!("⋯ {}", hidden));
1667+ msg.push_str(&pal.dim(&format!("⋯ {}", hidden)));
16391668 }
16401669 }
16411670 let labels = c.map(|x| x.labels.join(" ")).unwrap_or_default();
@@ -1755,7 +1784,12 @@ fn draw_rows(
17551784 let (chars, lane0) = rail_row(rows, placements, idx, lanes_n, opts);
17561785 let mut line = String::new();
17571786 line.push_str(&t.gutter);
1758− line.push_str(&color_rails(&chars, &lane0, pal));
1787+ // the node glyph (on a commit row) is meaning-coloured; rails lane-coloured
1788+ let glyph = match (n, pl.lane) {
1789+ (Display::Commit { info, .. }, Some(l)) => Some((2 * l, glyph_ansi(info))),
1790+ _ => None,
1791+ };
1792+ line.push_str(&color_rails(&chars, &lane0, pal, glyph));
17591793 line.push(' ');
17601794 line.push_str(&t.id);
17611795 if !t.bar.is_empty() {
@@ -1791,7 +1825,26 @@ fn draw_rows(
17911825 line.push_str(&meta.join(" "));
17921826 }
17931827 let _ = pl;
1794− lines.push(line);
1828+ // the whole focus row is highlighted with a background band across the
1829+ // full terminal width (§Colour; colour only — the ▶ gutter still marks
1830+ // the focus when colour is off)
1831+ let is_focus_row = n.commit().map(|c| c.is_focus).unwrap_or(false);
1832+ if is_focus_row && pal.on {
1833+ let target_w = if crate::show::stdout_is_tty() {
1834+ crate::show::terminal_width()
1835+ } else {
1836+ None
1837+ };
1838+ let padded = match target_w {
1839+ Some(w) if width(&line) < w => {
1840+ format!("{}{}", line, " ".repeat(w - width(&line)))
1841+ }
1842+ _ => line.clone(),
1843+ };
1844+ lines.push(pal.bg_line("48;5;236", &padded));
1845+ } else {
1846+ lines.push(line);
1847+ }
17951848 // detail line (detail = 2, focus only)
17961849 if opts.detail >= 2
17971850 && with_focus
@@ -1801,7 +1854,7 @@ fn draw_rows(
18011854 let (dchars, dlane0) = detail_rail_row(rows, placements, idx, lanes_n);
18021855 let mut dline = String::new();
18031856 dline.push_str(" ");
1804− dline.push_str(&color_rails(&dchars, &dlane0, pal));
1857+ dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
18051858 dline.push(' ');
18061859 dline.push_str(&" ".repeat(id_w + 2));
18071860 let marks: Vec<String> = c
@@ -2120,12 +2173,28 @@ fn detail_rail_row(
21202173 (chars, lane0)
21212174}
21222175
2123−fn color_rails(chars: &[char], lane0: &[bool], pal: &Palette) -> String {
2176+/// Colour the rails. Rail connectors are lane-coloured (lane 0 = immutable
2177+/// blue, others dim); the node glyph at `glyph` (its lane position and ANSI
2178+/// code) is meaning-coloured.
2179+fn color_rails(
2180+ chars: &[char],
2181+ lane0: &[bool],
2182+ pal: &Palette,
2183+ glyph: Option<(usize, &str)>,
2184+) -> String {
21242185 let mut s = String::new();
21252186 for (i, c) in chars.iter().enumerate() {
21262187 let t = c.to_string();
21272188 if *c == ' ' {
21282189 s.push(*c);
2190+ } else if let Some((pos, code)) = glyph {
2191+ if i == pos {
2192+ s.push_str(&pal.code(code, &t));
2193+ } else if lane0[i] {
2194+ s.push_str(&pal.blue(&t));
2195+ } else {
2196+ s.push_str(&pal.dim(&t));
2197+ }
21292198 } else if lane0[i] {
21302199 s.push_str(&pal.blue(&t));
21312200 } else {
@@ -2282,6 +2351,26 @@ fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
22822351 }
22832352}
22842353
2354+/// The ANSI colour code for a node glyph, by meaning (§Colour: glyphs carry
2355+/// meaning; colour only adds emphasis). Mirrors the precedence of `glyph_for`.
2356+fn glyph_ansi(info: &CommitInfo) -> &'static str {
2357+ if info.id == ROOT_ID {
2358+ "1" // root: bold
2359+ } else if info.conflict {
2360+ "31" // conflict: red
2361+ } else if info.empty {
2362+ "2" // empty: dim
2363+ } else if info.immutable {
2364+ "34" // immutable: blue
2365+ } else if info.is_focus {
2366+ "1;36" // focus: bold cyan
2367+ } else if info.is_ancestor_of_focus {
2368+ "32" // ancestor of focus: green
2369+ } else {
2370+ "37" // other commit: light grey
2371+ }
2372+}
2373+
22852374fn size_bar(size: Option<usize>, pal: &Palette) -> String {
22862375 match size {
22872376 None | Some(0) => String::new(),
src/repo.rs+9−1
@@ -73,8 +73,16 @@ fn refocus_child(repo: &Value, child: &Value) -> Result<Value, Crash> {
7373 let mut left = Vec::new();
7474 let mut right = Vec::new();
7575 let mut found = false;
76+ // match by the child's root id: ids are unique per repo (§7.5 validates
77+ // this at persist time), and a deep value_eq here would compare the whole
78+ // subtree — files included — making by_id quadratic in history size
79+ let want_id = id_of(&child.field("root")?).ok();
7680 for c in children.iter() {
77− if !found && value_eq(c, child)? {
81+ let same = match (&want_id, id_of(&c.field("root")?).ok()) {
82+ (Some(w), Some(cid)) => *w == cid,
83+ _ => value_eq(c, child)?,
84+ };
85+ if !found && same {
7886 found = true;
7987 } else if !found {
8088 left.push(c.clone());
src/show.rs+9−5
@@ -42,9 +42,13 @@ fn render(interp: &Interp, v: &Value) -> String {
4242 Ok(s) => format!("blob {}", text_literal(&s)),
4343 Err(_) => "blob \"<binary>\"".into(),
4444 },
45− BlobContent::Conflict(_) => {
46− let s = String::from_utf8_lossy(&b.bytes()).to_string();
47− format!("{{- unresolved -}} blob {}", text_literal(&s))
45+ BlobContent::Lazy(_) | BlobContent::Conflict(_) => {
46+ let s = b
47+ .bytes()
48+ .map(|v| String::from_utf8_lossy(&v).to_string())
49+ .unwrap_or_else(|_| "<unreadable>".into());
50+ let marker = if b.is_unresolved() { "{- unresolved -} " } else { "" };
51+ format!("{}blob {}", marker, text_literal(&s))
4852 }
4953 },
5054 Value::Shape(s) => s.name.clone(),
@@ -275,11 +279,11 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
275279 .transpose()?
276280 .unwrap_or_else(|| "file".to_string());
277281 let a_bytes = match a {
278− Value::Blob(bl) => bl.bytes(),
282+ Value::Blob(bl) => bl.bytes()?,
279283 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
280284 };
281285 let b_bytes = match b {
282− Value::Blob(bl) => bl.bytes(),
286+ Value::Blob(bl) => bl.bytes()?,
283287 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
284288 };
285289 let dir = tempfile::tempdir()
src/value.rs+92−9
@@ -27,15 +27,79 @@ pub enum BlobKind {
2727 Symlink,
2828}
2929
30−#[derive(Clone, Debug)]
30+#[derive(Clone)]
3131pub enum BlobContent {
3232 Resolved(Rc<Vec<u8>>),
33+ /// not yet read from the store; `force` reads the bytes on first use and
34+ /// memoizes them. `id` is the content hash (jj FileId hex), which is
35+ /// enough for equality and for persisting an unchanged blob without ever
36+ /// reading its bytes.
37+ Lazy(Rc<LazyBlob>),
3338 /// Conflict sides, jj order: alternating adds and removes, starting and
3439 /// ending with an add: [add, (remove, add)*]. For the in-memory backend:
3540 /// [to, from, onto]-style three sides as [add, remove, add].
3641 Conflict(Vec<Rc<Vec<u8>>>),
3742}
3843
44+/// A blob whose bytes are read from the store only on first use (§7.2):
45+/// building the Repo value must not inflate every file of every commit.
46+pub struct LazyBlob {
47+ /// content hash (jj FileId hex)
48+ pub id: String,
49+ state: std::cell::RefCell<LazyState>,
50+}
51+
52+enum LazyState {
53+ Pending(Box<dyn FnOnce() -> Result<Rc<Vec<u8>>, Crash>>),
54+ Ready(Rc<Vec<u8>>),
55+}
56+
57+impl std::fmt::Debug for LazyBlob {
58+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59+ write!(f, "Lazy({})", self.id)
60+ }
61+}
62+
63+impl std::fmt::Debug for BlobContent {
64+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65+ match self {
66+ BlobContent::Resolved(b) => write!(f, "Resolved({} bytes)", b.len()),
67+ BlobContent::Lazy(l) => write!(f, "{:?}", l),
68+ BlobContent::Conflict(s) => write!(f, "Conflict({} sides)", s.len()),
69+ }
70+ }
71+}
72+
73+impl LazyBlob {
74+ pub fn new(id: String, force: impl FnOnce() -> Result<Rc<Vec<u8>>, Crash> + 'static) -> Self {
75+ LazyBlob {
76+ id,
77+ state: std::cell::RefCell::new(LazyState::Pending(Box::new(force))),
78+ }
79+ }
80+
81+ /// the bytes, reading from the store on first call and memoizing
82+ pub fn force(&self) -> Result<Rc<Vec<u8>>, Crash> {
83+ // fast path: already forced
84+ if let LazyState::Ready(b) = &*self.state.borrow() {
85+ return Ok(b.clone());
86+ }
87+ let thunk = {
88+ let mut st = self.state.borrow_mut();
89+ match std::mem::replace(&mut *st, LazyState::Ready(Rc::new(Vec::new()))) {
90+ LazyState::Pending(t) => t,
91+ LazyState::Ready(b) => {
92+ *st = LazyState::Ready(b.clone());
93+ return Ok(b);
94+ }
95+ }
96+ };
97+ let bytes = thunk()?;
98+ *self.state.borrow_mut() = LazyState::Ready(bytes.clone());
99+ Ok(bytes)
100+ }
101+}
102+
39103#[derive(Clone, Debug)]
40104pub struct BlobVal {
41105 pub kind: BlobKind,
@@ -52,17 +116,30 @@ impl BlobVal {
52116 pub fn is_unresolved(&self) -> bool {
53117 matches!(self.content, BlobContent::Conflict(_))
54118 }
119+ /// size in bytes; a lazy blob reports the size of its (memoized) bytes,
120+ /// forcing on first call
55121 pub fn size(&self) -> usize {
56122 match &self.content {
57123 BlobContent::Resolved(b) => b.len(),
124+ BlobContent::Lazy(l) => l.force().map(|b| b.len()).unwrap_or(0),
58125 BlobContent::Conflict(sides) => sides.iter().map(|s| s.len()).sum(),
59126 }
60127 }
61− /// content as bytes; conflicts render with jj-style conflict markers
62− pub fn bytes(&self) -> Vec<u8> {
128+ /// content as bytes, forcing a lazy blob; conflicts render with jj-style
129+ /// conflict markers
130+ pub fn bytes(&self) -> Result<Vec<u8>, Crash> {
63131 match &self.content {
64− BlobContent::Resolved(b) => b.as_ref().clone(),
65− BlobContent::Conflict(sides) => render_conflict(sides),
132+ BlobContent::Resolved(b) => Ok(b.as_ref().clone()),
133+ BlobContent::Lazy(l) => Ok(l.force()?.as_ref().clone()),
134+ BlobContent::Conflict(sides) => Ok(render_conflict(sides)),
135+ }
136+ }
137+ /// the content hash of a lazy blob, if it is one (used to persist an
138+ /// unchanged blob without reading its bytes)
139+ pub fn lazy_id(&self) -> Option<&str> {
140+ match &self.content {
141+ BlobContent::Lazy(l) => Some(&l.id),
142+ _ => None,
66143 }
67144 }
68145}
@@ -483,7 +560,9 @@ pub fn value_eq(a: &Value, b: &Value) -> Result<bool, Crash> {
483560 }
484561 true
485562 }
486− (Value::Blob(x), Value::Blob(y)) => x.kind == y.kind && blob_content_eq(&x.content, &y.content),
563+ (Value::Blob(x), Value::Blob(y)) => {
564+ x.kind == y.kind && blob_content_eq(&x.content, &y.content)?
565+ }
487566 (Value::Shape(x), Value::Shape(y)) => x == y,
488567 (Value::Fun(_), Value::Fun(_)) => {
489568 return Err(Crash::new("cannot compare functions for equality"))
@@ -492,12 +571,16 @@ pub fn value_eq(a: &Value, b: &Value) -> Result<bool, Crash> {
492571 })
493572}
494573
495−fn blob_content_eq(a: &BlobContent, b: &BlobContent) -> bool {
496− match (a, b) {
574+/// content equality; two lazy blobs compare by content hash, without forcing
575+fn blob_content_eq(a: &BlobContent, b: &BlobContent) -> Result<bool, Crash> {
576+ Ok(match (a, b) {
497577 (BlobContent::Resolved(x), BlobContent::Resolved(y)) => x == y,
578+ (BlobContent::Lazy(x), BlobContent::Lazy(y)) => x.id == y.id,
579+ (BlobContent::Lazy(x), BlobContent::Resolved(y))
580+ | (BlobContent::Resolved(y), BlobContent::Lazy(x)) => x.force()? == *y,
498581 (BlobContent::Conflict(x), BlobContent::Conflict(y)) => x == y,
499582 _ => false,
500− }
583+ })
501584}
502585
503586impl std::fmt::Debug for Value {
tests/domain.rs+1−1
@@ -59,7 +59,7 @@ fn unresolved_at(snap: &[Value], path: &str) -> bool {
5959
6060fn blob_text(v: &Value) -> String {
6161 match v {
62− Value::Blob(b) => String::from_utf8(b.bytes()).unwrap(),
62+ Value::Blob(b) => String::from_utf8(b.bytes().unwrap()).unwrap(),
6363 _ => panic!("not a blob"),
6464 }
6565}
tests/show.rs+1−1
@@ -261,7 +261,7 @@ fn conflict_blob_markers() {
261261 ]),
262262 }));
263263 let Value::Blob(b) = &v else { panic!() };
264− let text = String::from_utf8(b.bytes()).unwrap();
264+ let text = String::from_utf8(b.bytes().unwrap()).unwrap();
265265 assert!(text.contains("<<<<<<<"), "{}", text);
266266 assert!(text.contains(">>>>>>>"), "{}", text);
267267 assert!(text.contains("+++++++"), "{}", text);