on master help sign in
Fix large-repo performance and treeWith option defaults
46c0a5eiko
specs/tree.md+12−3
@@ -232,9 +232,18 @@ drawn with `⌂`.
232
232
233#### Colour
233#### Colour
234
234
235−Unchanged for text. Rails in lane 0 take the immutable colour; rails in other
235+Glyphs are coloured by meaning (the same precedence as their shape): root bold,
236−lanes are dim. Glyphs and rails carry the meaning; colour only adds emphasis,
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
237−so a colourless rendering loses nothing.
245+carry the meaning, so a colourless rendering loses nothing — the band is a
246+no-op when colour is off.
238
247
239#### Legend
248#### Legend
240
249
src/builtins.rs+2−2
@@ -523,7 +523,7 @@ fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
523fn b_text(_i: &mut Interp, args: &[Value]) -> BResult {
523fn b_text(_i: &mut Interp, args: &[Value]) -> BResult {
524 match &args[0] {
524 match &args[0] {
525 Value::Blob(b) => {
525 Value::Blob(b) => {
526− let bytes = b.bytes();
526+ let bytes = b.bytes()?;
527 let s = String::from_utf8(bytes)
527 let s = String::from_utf8(bytes)
528 .map_err(|_| Crash::new("text: blob content is not UTF-8"))?;
528 .map_err(|_| Crash::new("text: blob content is not UTF-8"))?;
529 Ok(Value::text(s))
529 Ok(Value::text(s))
@@ -588,7 +588,7 @@ fn b_diff(_i: &mut Interp, args: &[Value]) -> BResult {
588
588
589fn blob_text_utf8(v: &Value) -> Result<String, Crash> {
589fn blob_text_utf8(v: &Value) -> Result<String, Crash> {
590 match v {
590 match v {
591− Value::Blob(b) => String::from_utf8(b.bytes())
591+ Value::Blob(b) => String::from_utf8(b.bytes()?)
592 .map_err(|_| Crash::new("diff: blob content is not UTF-8")),
592 .map_err(|_| Crash::new("diff: blob content is not UTF-8")),
593 v => Err(Crash::new(format!(
593 v => Err(Crash::new(format!(
594 "diff: expected a Blob, got a {}",
594 "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> {
113 .collect()
113 .collect()
114}
114}
115
115
116−fn blob_eq(a: &Value, b: &Value) -> bool {
116+fn blob_eq(a: &Value, b: &Value) -> Result<bool, Crash> {
117− match (a, b) {
117+ Ok(match (a, b) {
118 (Value::Blob(x), Value::Blob(y)) => {
118 (Value::Blob(x), Value::Blob(y)) => {
119 x.kind == y.kind
119 x.kind == y.kind
120 && match (&x.content, &y.content) {
120 && match (&x.content, &y.content) {
121 (BlobContent::Resolved(p), BlobContent::Resolved(q)) => p == q,
121 (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,
122 (BlobContent::Conflict(p), BlobContent::Conflict(q)) => p == q,
125 (BlobContent::Conflict(p), BlobContent::Conflict(q)) => p == q,
123 _ => false,
126 _ => false,
124 }
127 }
125 }
128 }
126 _ => false,
129 _ => false,
127− }
130+ })
128}
131}
129
132
130fn empty_blob() -> Value {
133fn empty_blob() -> Value {
@@ -165,8 +168,8 @@ pub fn simple_replay(
165 let o_present = onto.contains_key(&p);
168 let o_present = onto.contains_key(&p);
166 let f_present = from.contains_key(&p);
169 let f_present = from.contains_key(&p);
167 let t_present = to.contains_key(&p);
170 let t_present = to.contains_key(&p);
168− let changed_to = !(f_present == t_present && (!f_present || blob_eq(&f, &t)));
171+ 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)));
172+ let changed_onto = !(f_present == o_present && (!f_present || blob_eq(&f, &o)?));
170 match (changed_to, changed_onto) {
173 match (changed_to, changed_onto) {
171 (false, false) => {
174 (false, false) => {
172 if o_present {
175 if o_present {
@@ -184,27 +187,27 @@ pub fn simple_replay(
184 }
187 }
185 }
188 }
186 (true, true) => {
189 (true, true) => {
187− if t_present && o_present && blob_eq(&t, &o) {
190+ if t_present && o_present && blob_eq(&t, &o)? {
188 out.insert(p, t);
191 out.insert(p, t);
189 } else if !t_present && !o_present {
192 } else if !t_present && !o_present {
190 // both deleted
193 // both deleted
191 } else {
194 } else {
192 // conflict: sides [to, from, onto] (add, remove, add)
195 // 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> {
194 if !present {
197 if !present {
195− return Rc::new(Vec::new());
198+ return Ok(Rc::new(Vec::new()));
196 }
199 }
197 match v {
200 match v {
198− Value::Blob(b) => Rc::new(b.bytes()),
201+ Value::Blob(b) => Ok(Rc::new(b.bytes()?)),
199− _ => Rc::new(Vec::new()),
202+ _ => Ok(Rc::new(Vec::new())),
200 }
203 }
201 };
204 };
202 out.insert(
205 out.insert(
203 p,
206 p,
204 conflict_blob(vec![
207 conflict_blob(vec![
205− get_bytes(&t, t_present),
208+ get_bytes(&t, t_present)?,
206− get_bytes(&f, f_present),
209+ get_bytes(&f, f_present)?,
207− get_bytes(&o, o_present),
210+ get_bytes(&o, o_present)?,
208 ]),
211 ]),
209 );
212 );
210 }
213 }
src/jj.rs+100−24
@@ -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, Crash, Value};
9+use crate::value::{BlobContent, BlobKind, BlobVal, Crash, LazyBlob, Value};
10use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
10use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
11use jj_lib::commit::Commit;
11use jj_lib::commit::Commit;
12use jj_lib::config::{ConfigLayer, ConfigSource, StackedConfig};
12use jj_lib::config::{ConfigLayer, ConfigSource, StackedConfig};
@@ -26,7 +26,8 @@ use jj_lib::settings::UserSettings;
26use jj_lib::store::Store;
26use jj_lib::store::Store;
27use jj_lib::working_copy::SnapshotOptions;
27use jj_lib::working_copy::SnapshotOptions;
28use jj_lib::workspace::Workspace;
28use jj_lib::workspace::Workspace;
29+use std::cell::RefCell;
29−use std::collections::{BTreeMap, BTreeSet};
30+use std::collections::{BTreeMap, BTreeSet, HashMap};
30use std::future::Future;
31use std::future::Future;
31use std::os::unix::io::AsRawFd;
32use std::os::unix::io::AsRawFd;
32use std::path::PathBuf;
33use std::path::PathBuf;
@@ -366,11 +367,21 @@ impl JjBackend {
366 // snapshot operation is written but unpublished — a persisting
367 // snapshot operation is written but unpublished — a persisting
367 // run folds it into its own operation (§7.7)
368 // run folds it into its own operation (§7.7)
368 let (post, pending) = block_on(self.snapshot_repo())?;
369 let (post, pending) = block_on(self.snapshot_repo())?;
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) => {
369− *self.inner.pending.lock().unwrap() = pending;
378+ *self.inner.pending.lock().unwrap() = Some(pending);
370− let vis = block_on(read_visible(&post))?;
379+ let vis = block_on(read_visible(&post))?;
371− let cur = build_repo_value(&vis, &vis.wc_change_id)?;
380+ let cur = build_repo_value(&vis, &vis.wc_change_id)?;
372− *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
381+ *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
373− cur
382+ cur
383+ }
384+ }
374 } else {
385 } else {
375 *self.inner.visible.lock().unwrap() = Some(Arc::new(loaded_visible));
386 *self.inner.visible.lock().unwrap() = Some(Arc::new(loaded_visible));
376 loaded.clone()
387 loaded.clone()
@@ -603,6 +614,8 @@ impl JjBackend {
603 old_stored = Arc::new(vis);
614 old_stored = Arc::new(vis);
604 }
615 }
605 let mut written: BTreeMap<String, CommitId> = BTreeMap::new();
616 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();
606
619
607 // the root commit must be the top and unchanged (§7.5 step 4)
620 // the root commit must be the top and unchanged (§7.5 step 4)
608 let top = crate::repo::by_id(new, ROOT_ID)?
621 let top = crate::repo::by_id(new, ROOT_ID)?
@@ -621,7 +634,7 @@ impl JjBackend {
621 if root_jj.description() != msg {
634 if root_jj.description() != msg {
622 return Err(Crash::new("persistence: the root commit cannot be changed"));
635 return Err(Crash::new("persistence: the root commit cannot be changed"));
623 }
636 }
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))
625 .map_err(|e| Crash::new(e.msg))?;
638 .map_err(|e| Crash::new(e.msg))?;
626 if !crate::value::value_eq(
639 if !crate::value::value_eq(
627 &Value::list(stored_files),
640 &Value::list(stored_files),
@@ -669,7 +682,8 @@ impl JjBackend {
669 let stored_commit = &stored.commit;
682 let stored_commit = &stored.commit;
670 let parent_changed = hex_of(stored_commit.parent_ids().first()) != parent_jj.hex();
683 let parent_changed = hex_of(stored_commit.parent_ids().first()) != parent_jj.hex();
671 let files_changed = {
684 let files_changed = {
685+ let stored_files =
672− let stored_files = block_on(tree_to_files(&store, &stored_commit.tree()))?;
686+ block_on(tree_to_files(&store, &stored_commit.tree(), &cache))?;
673 !crate::value::value_eq(
687 !crate::value::value_eq(
674 &Value::list(stored_files),
688 &Value::list(stored_files),
675 &Value::list(files_v.as_list()?.to_vec()),
689 &Value::list(files_v.as_list()?.to_vec()),
@@ -1800,7 +1814,7 @@ async fn jj_replay(
1800 .map_err(|e| Crash::new(format!("replay: {}", e)))?;
1814 .map_err(|e| Crash::new(format!("replay: {}", e)))?;
1801 // conflicts stay as unresolved blobs (§7.3)
1815 // conflicts stay as unresolved blobs (§7.3)
1802 let merged = MergedTree::new(store.clone(), merged, jj_lib::conflict_labels::ConflictLabels::unlabeled());
1816 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
1804}
1818}
1805
1819
1806// ----------------------------------------------------------------------
1820// ----------------------------------------------------------------------
@@ -1843,6 +1857,17 @@ async fn blob_to_tree_value(store: &Arc<Store>, content: &Value) -> Result<Merge
1843 return Err(Crash::new("persistence: content is not a Blob"));
1857 return Err(Crash::new("persistence: content is not a Blob"));
1844 };
1858 };
1845 match &b.content {
1859 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+ }
1846 BlobContent::Resolved(bytes) if b.kind == BlobKind::Symlink => {
1871 BlobContent::Resolved(bytes) if b.kind == BlobKind::Symlink => {
1847 let target = std::str::from_utf8(bytes)
1872 let target = std::str::from_utf8(bytes)
1848 .map_err(|_| Crash::new("persistence: symlink target is not UTF-8"))?;
1873 .map_err(|_| Crash::new("persistence: symlink target is not UTF-8"))?;
@@ -1911,7 +1936,31 @@ fn to_repo_path(path: &[String]) -> Result<RepoPathBuf, Crash> {
1911// jj trees -> values
1936// jj trees -> values
1912// ----------------------------------------------------------------------
1937// ----------------------------------------------------------------------
1913
1938
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+
1914−async fn read_file_bytes(store: &Arc<Store>, path: &RepoPath, id: &FileId) -> Result<Vec<u8>, Crash> {
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+ }
1915 use futures::AsyncReadExt;
1964 use futures::AsyncReadExt;
1916 let mut reader = store
1965 let mut reader = store
1917 .read_file(path, id)
1966 .read_file(path, id)
@@ -1922,6 +1971,8 @@ async fn read_file_bytes(store: &Arc<Store>, path: &RepoPath, id: &FileId) -> Re
1922 .read_to_end(&mut buf)
1971 .read_to_end(&mut buf)
1923 .await
1972 .await
1924 .map_err(|e| Crash::new(format!("cannot read a file: {}", e)))?;
1973 .map_err(|e| Crash::new(format!("cannot read a file: {}", e)))?;
1974+ let buf = Rc::new(buf);
1975+ cache.insert(id.clone(), buf.clone());
1925 Ok(buf)
1976 Ok(buf)
1926}
1977}
1927
1978
@@ -1929,15 +1980,16 @@ async fn side_to_bytes(
1929 store: &Arc<Store>,
1980 store: &Arc<Store>,
1930 path: &RepoPath,
1981 path: &RepoPath,
1931 v: &Option<TreeValue>,
1982 v: &Option<TreeValue>,
1983+ cache: &BlobCache,
1932−) -> Result<Vec<u8>, Crash> {
1984+) -> Result<Rc<Vec<u8>>, Crash> {
1933 match v {
1985 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,
1935 Some(TreeValue::Symlink(id)) => store
1987 Some(TreeValue::Symlink(id)) => store
1936 .read_symlink(path, id)
1988 .read_symlink(path, id)
1937 .await
1989 .await
1938− .map(|s| s.into_bytes())
1990+ .map(|s| Rc::new(s.into_bytes()))
1939 .map_err(|e| Crash::new(format!("cannot read a symlink: {}", e))),
1991 .map_err(|e| Crash::new(format!("cannot read a symlink: {}", e))),
1940− _ => Ok(Vec::new()),
1992+ _ => Ok(Rc::new(Vec::new())),
1941 }
1993 }
1942}
1994}
1943
1995
@@ -1945,6 +1997,7 @@ async fn merged_value_to_blob(
1945 store: &Arc<Store>,
1997 store: &Arc<Store>,
1946 path: &RepoPath,
1998 path: &RepoPath,
1947 value: &jj_lib::backend::MergedTreeValue,
1999 value: &jj_lib::backend::MergedTreeValue,
2000+ cache: &BlobCache,
1948) -> Result<Value, Crash> {
2001) -> Result<Value, Crash> {
1949 if let Some(v) = value.as_resolved() {
2002 if let Some(v) = value.as_resolved() {
1950 return match v {
2003 return match v {
@@ -1958,9 +2011,9 @@ async fn merged_value_to_blob(
1958 // conflict sides, jj order: [add, (remove, add)*] (§7.3)
2011 // conflict sides, jj order: [add, (remove, add)*] (§7.3)
1959 let mut sides: Vec<Rc<Vec<u8>>> = Vec::new();
2012 let mut sides: Vec<Rc<Vec<u8>>> = Vec::new();
1960 for (i, add) in value.adds().enumerate() {
2013 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?);
1962 if let Some(remove) = value.get_remove(i) {
2015 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?);
1964 }
2017 }
1965 }
2018 }
1966 Ok(Value::Blob(Rc::new(BlobVal {
2019 Ok(Value::Blob(Rc::new(BlobVal {
@@ -1976,14 +2029,30 @@ async fn tree_value_to_blob(
1976) -> Result<Value, Crash> {
2029) -> Result<Value, Crash> {
1977 match value {
2030 match value {
1978 TreeValue::File { id, executable, .. } => {
2031 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+ });
1980 Ok(Value::Blob(Rc::new(BlobVal {
2049 Ok(Value::Blob(Rc::new(BlobVal {
1981 kind: if *executable {
2050 kind: if *executable {
1982 BlobKind::Executable
2051 BlobKind::Executable
1983 } else {
2052 } else {
1984 BlobKind::Regular
2053 BlobKind::Regular
1985 },
2054 },
1986− content: BlobContent::Resolved(Rc::new(buf)),
2055+ content: BlobContent::Lazy(Rc::new(lazy)),
1987 })))
2056 })))
1988 }
2057 }
1989 TreeValue::Symlink(id) => {
2058 TreeValue::Symlink(id) => {
@@ -2005,7 +2074,11 @@ async fn tree_value_to_blob(
2005 }
2074 }
2006}
2075}
2007
2076
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> {
2009 let mut out = Vec::new();
2082 let mut out = Vec::new();
2010 for (path, value) in tree.entries() {
2083 for (path, value) in tree.entries() {
2011 let value = value.map_err(|e| Crash::new(format!("cannot read a tree entry: {}", e)))?;
2084 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
2013 .components()
2086 .components()
2014 .map(|c| c.as_internal_str().to_string())
2087 .map(|c| c.as_internal_str().to_string())
2015 .collect();
2088 .collect();
2016− let content = merged_value_to_blob(store, &path, &value).await?;
2089+ let content = merged_value_to_blob(store, &path, &value, cache).await?;
2017 let p = Value::list(comps.into_iter().map(Value::text).collect());
2090 let p = Value::list(comps.into_iter().map(Value::text).collect());
2018 out.push(Value::record(&[("content", content), ("path", p)]));
2091 out.push(Value::record(&[("content", content), ("path", p)]));
2019 }
2092 }
@@ -2057,7 +2130,8 @@ fn build_repo_value(vis: &VisibleRepo, focus_change: &str) -> Result<Value, Open
2057 .get(ROOT_ID)
2130 .get(ROOT_ID)
2058 .ok_or_else(|| (2, "no root commit".to_string()))?;
2131 .ok_or_else(|| (2, "no root commit".to_string()))?;
2059 let store = root_rec.commit.store().clone();
2132 let store = root_rec.commit.store().clone();
2133+ let cache = BlobCache::default();
2060− let mut subtree = build_subtree(vis, root_rec, &store)?;
2134+ let mut subtree = build_subtree(vis, root_rec, &store, &cache)?;
2061 sort_subtree(&mut subtree, vis);
2135 sort_subtree(&mut subtree, vis);
2062 let top = Value::record(&[
2136 let top = Value::record(&[
2063 ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
2137 ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
@@ -2074,8 +2148,10 @@ fn build_subtree(
2074 vis: &VisibleRepo,
2148 vis: &VisibleRepo,
2075 rec: &StoredCommit,
2149 rec: &StoredCommit,
2076 store: &Arc<Store>,
2150 store: &Arc<Store>,
2151+ cache: &BlobCache,
2077) -> Result<Value, OpenError> {
2152) -> Result<Value, OpenError> {
2153+ let files =
2078− let files = block_on(tree_to_files(store, &rec.commit.tree())).map_err(|c| (2, c.msg))?;
2154+ block_on(tree_to_files(store, &rec.commit.tree(), cache)).map_err(|c| (2, c.msg))?;
2079 let labels: Vec<Value> = vis
2155 let labels: Vec<Value> = vis
2080 .labels
2156 .labels
2081 .get(&rec.change_id)
2157 .get(&rec.change_id)
@@ -2094,7 +2170,7 @@ fn build_subtree(
2094 if let Some(kids) = vis.children.get(&rec.change_id) {
2170 if let Some(kids) = vis.children.get(&rec.change_id) {
2095 for kid in kids {
2171 for kid in kids {
2096 if let Some(krec) = vis.commits.get(kid) {
2172 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)?);
2098 }
2174 }
2099 }
2175 }
2100 }
2176 }
src/render.rs+179−90
@@ -69,6 +69,23 @@ impl Palette {
69 let code = ["31", "32", "33", "34", "35", "36"][hue as usize];
69 let code = ["31", "32", "33", "34", "35", "36"][hue as usize];
70 self.wrap(code, s)
70 self.wrap(code, s)
71 }
71 }
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+ }
72}
89}
73
90
74pub fn color_enabled(mode: &str) -> bool {
91pub fn color_enabled(mode: &str) -> bool {
@@ -421,7 +438,8 @@ fn node_glyph(
421}
438}
422
439
423fn has_conflict(c: &Value) -> Result<bool, Crash> {
440fn has_conflict(c: &Value) -> Result<bool, Crash> {
441+ let files = c.field("files")?;
424− for e in c.field("files").and_then(|v| v.as_list().map(|x| x.to_vec()))?.iter() {
442+ for e in files.as_list()?.iter() {
425 if let Value::Blob(b) = e.field("content")? {
443 if let Value::Blob(b) = e.field("content")? {
426 if b.is_unresolved() {
444 if b.is_unresolved() {
427 return Ok(true);
445 return Ok(true);
@@ -451,7 +469,7 @@ fn display_block(
451 return Ok(());
469 return Ok(());
452 }
470 }
453 Value::Blob(b) => {
471 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();
455 out.push_str(&indent_multiline(&content, indent));
473 out.push_str(&indent_multiline(&content, indent));
456 return Ok(());
474 return Ok(());
457 }
475 }
@@ -885,42 +903,38 @@ fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
885}
903}
886
904
887pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Value, Crash> {
905pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Value, Crash> {
888− let detail = match opts.field("detail")? {
906+ let defaults = default_tree_options(interp)?;
889− Value::Int(n) => n.to_string().parse::<i64>().unwrap_or(1),
907+ // a missing option falls back to its default, so an older config that
890− _ => return Err(Crash::new("treeWith: detail must be an Int")),
908+ // predates a newer option keeps working; a present option is type-checked
891− };
909+ let bool_opt = |name: &str, default: bool| -> Result<bool, Crash> {
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")? {
910+ match opts.field(name) {
897− Value::Bool(b) => b,
911+ Ok(Value::Bool(b)) => Ok(b),
898− _ => return Err(Crash::new("treeWith: elide must be a Bool")),
912+ Ok(_) => Err(Crash::new(format!("treeWith: {} must be a Bool", name))),
913+ Err(_) => Ok(default),
914+ }
899 };
915 };
916+ let int_opt = |name: &str, default: i64| -> Result<i64, Crash> {
900− let icons = match opts.field("icons")? {
917+ match opts.field(name) {
901− Value::Bool(b) => b,
918+ Ok(Value::Int(n)) => Ok(n.to_string().parse::<i64>().unwrap_or(default)),
902− _ => return Err(Crash::new("treeWith: icons must be a Bool")),
919+ Ok(_) => Err(Crash::new(format!("treeWith: {} must be an Int", name))),
920+ Err(_) => Ok(default),
921+ }
903 };
922 };
904− let color = opts.field("color")?.as_text()?.to_string();
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)?;
905− let lanes = match opts.field("lanes")? {
927+ let color = match opts.field("color") {
906− Value::Int(n) => n.to_string().parse::<i64>().unwrap_or(0),
928+ Ok(v) => v.as_text()?.to_string(),
907− _ => return Err(Crash::new("treeWith: lanes must be an Int")),
929+ Err(_) => defaults.color.clone(),
908 };
930 };
931+ let lanes = int_opt("lanes", defaults.lanes)?;
909 if lanes < 1 {
932 if lanes < 1 {
910 return Err(Crash::new("treeWith: lanes must be at least 1"));
933 return Err(Crash::new("treeWith: lanes must be at least 1"));
911 }
934 }
912− let author = match opts.field("author")? {
935+ let author = bool_opt("author", defaults.author)?;
913− Value::Bool(b) => b,
936+ let date = bool_opt("date", defaults.date)?;
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")? {
937+ let files = bool_opt("files", defaults.files)?;
921− Value::Bool(b) => b,
922− _ => return Err(Crash::new("treeWith: files must be a Bool")),
923− };
924 let pal = Palette {
938 let pal = Palette {
925 on: color_enabled(&color),
939 on: color_enabled(&color),
926 };
940 };
@@ -981,7 +995,6 @@ impl CommitInfo {
981fn build_info(
995fn build_info(
982 interp: &mut Interp,
996 interp: &mut Interp,
983 opts: &TreeOptions,
997 opts: &TreeOptions,
984− loc: &Value,
985 commit: &Value,
998 commit: &Value,
986 children: &[Value],
999 children: &[Value],
987 immutable: &BTreeSet<String>,
1000 immutable: &BTreeSet<String>,
@@ -990,6 +1003,7 @@ fn build_info(
990 parent_files: Option<&Value>,
1003 parent_files: Option<&Value>,
991 with_focus: bool,
1004 with_focus: bool,
992 parent_is_ancestor: bool,
1005 parent_is_ancestor: bool,
1006+ parent_is_focus: bool,
993) -> Result<CommitInfo, Crash> {
1007) -> Result<CommitInfo, Crash> {
994 let id = match commit.field("id")? {
1008 let id = match commit.field("id")? {
995 Value::Id(i) => i.to_string(),
1009 Value::Id(i) => i.to_string(),
@@ -1005,31 +1019,50 @@ fn build_info(
1005 let conflict = has_conflict(commit)?;
1019 let conflict = has_conflict(commit)?;
1006 let files = commit.field("files")?;
1020 let files = commit.field("files")?;
1007 let nfiles = files.as_list()?.len();
1021 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;
1008 let (empty, size, detail_marks) = match parent_files {
1033 let (empty, size, detail_marks) = match parent_files {
1009 Some(pf) => {
1034 Some(pf) => {
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 {
1010− let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1038+ let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1011− let marks = touched_paths(&change)?;
1039+ let marks = touched_paths(&change)?;
1012− let empty = marks.is_empty();
1013− // size: lines added+removed against the parent
1040+ // size: lines added+removed against the parent
1014− let from_map = snapshot_map_of(pf)?;
1041+ let from_map = snapshot_map_of(pf)?;
1015− let to_map = snapshot_map_of(&files)?;
1042+ let to_map = snapshot_map_of(&files)?;
1016− let mut lines = 0usize;
1043+ let mut lines = 0usize;
1017− for (p, _, _) in &marks {
1044+ for (p, _, _) in &marks {
1018− let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1045+ let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1019− let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1046+ let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1020− match m.get(&key) {
1047+ match m.get(&key) {
1021− Some(Value::Blob(b)) => {
1048+ Some(Value::Blob(b)) => {
1049+ let n = b
1050+ .bytes()
1022− let n = String::from_utf8_lossy(&b.bytes()).lines().count();
1051+ .map(|v| String::from_utf8_lossy(&v).lines().count())
1052+ .unwrap_or(1);
1023− n.max(1)
1053+ n.max(1)
1054+ }
1055+ _ => 1,
1024 }
1056 }
1025− _ => 1,
1026− }
1057+ };
1027− };
1028− lines += count(&from_map) + count(&to_map);
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())
1029 }
1065 }
1030− let marks2: Vec<(String, char)> =
1031− marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1032− (empty, Some(lines), marks2)
1033 }
1066 }
1034 None => {
1067 None => {
1035 let n = files.as_list()?.len();
1068 let n = files.as_list()?.len();
@@ -1041,15 +1074,9 @@ fn build_info(
1041 let is_ancestor_of_focus = anc.contains(&id);
1074 let is_ancestor_of_focus = anc.contains(&id);
1042 let mut infos = Vec::new();
1075 let mut infos = Vec::new();
1043 for c in children.iter() {
1076 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();
1049 infos.push(build_info(
1077 infos.push(build_info(
1050 interp,
1078 interp,
1051 opts,
1079 opts,
1052− &child_loc,
1053 &c.field("root")?,
1080 &c.field("root")?,
1054 c.field("children")?.as_list()?,
1081 c.field("children")?.as_list()?,
1055 immutable,
1082 immutable,
@@ -1058,6 +1085,7 @@ fn build_info(
1058 Some(&files),
1085 Some(&files),
1059 with_focus,
1086 with_focus,
1060 is_ancestor_of_focus,
1087 is_ancestor_of_focus,
1088+ is_focus_pre,
1061 )?);
1089 )?);
1062 }
1090 }
1063 let immutable_flag = immutable.contains(&id);
1091 let immutable_flag = immutable.contains(&id);
@@ -1092,20 +1120,19 @@ fn tree_render(
1092 Value::Id(i) => i.to_string(),
1120 Value::Id(i) => i.to_string(),
1093 _ => String::new(),
1121 _ => String::new(),
1094 };
1122 };
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²))
1096 let mut anc: BTreeSet<String> = BTreeSet::new();
1126 let mut anc: BTreeSet<String> = BTreeSet::new();
1097− let mut cur = repo.clone();
1127+ if let Ok(Value::Id(i)) = repo.field("root").and_then(|r| r.field("id")) {
1098− loop {
1099− let id = match cur.field("root")?.field("id")? {
1100− Value::Id(i) => i.to_string(),
1128+ anc.insert(i.to_string());
1101− _ => break,
1102− };
1129+ }
1103− anc.insert(id);
1104− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1130+ if let Ok(ctx) = repo.field("context").and_then(|v| v.as_list().map(|x| x.to_vec())) {
1105− if ctx.is_empty() {
1131+ for frame in &ctx {
1106− break;
1132+ if let Ok(pid) = id_of_frame_parent(frame) {
1133+ anc.insert(pid);
1134+ }
1107 }
1135 }
1108− cur = crate::repo::by_id(&cur, &id_of_frame_parent(&ctx[0])?)?.unwrap();
1109 }
1136 }
1110 // the whole history, from the top
1137 // the whole history, from the top
1111 let top = crate::repo::by_id(repo, &top_id(repo)?)?.unwrap_or_else(|| repo.clone());
1138 let top = crate::repo::by_id(repo, &top_id(repo)?)?.unwrap_or_else(|| repo.clone());
@@ -1114,7 +1141,6 @@ fn tree_render(
1114 let root_info = build_info(
1141 let root_info = build_info(
1115 interp,
1142 interp,
1116 opts,
1143 opts,
1117− &top,
1118 &root_commit,
1144 &root_commit,
1119 top_children.as_list()?,
1145 top_children.as_list()?,
1120 &immutable,
1146 &immutable,
@@ -1123,6 +1149,7 @@ fn tree_render(
1123 None,
1149 None,
1124 with_focus,
1150 with_focus,
1125 false,
1151 false,
1152+ false,
1126 )?;
1153 )?;
1127 // trunk T (§Trunk)
1154 // trunk T (§Trunk)
1128 let trunk = compute_trunk(interp, repo)?;
1155 let trunk = compute_trunk(interp, repo)?;
@@ -1182,17 +1209,16 @@ fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
1182}
1209}
1183
1210
1184fn top_id(repo: &Value) -> Result<String, Crash> {
1211fn top_id(repo: &Value) -> Result<String, Crash> {
1185− let mut cur = repo.clone();
1212+ // the topmost ancestor's id: the focus's own id when the context is empty,
1186− loop {
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
1187− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1215+ let ctx = repo.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1188− if ctx.is_empty() {
1216+ match ctx.last() {
1189− return match cur.field("root")?.field("id")? {
1217+ None => match repo.field("root")?.field("id")? {
1190− Value::Id(i) => Ok(i.to_string()),
1218+ Value::Id(i) => Ok(i.to_string()),
1191− v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
1219+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
1192− };
1193− }
1220+ },
1194− let pid = id_of_frame_parent(&ctx[0])?;
1221+ Some(frame) => id_of_frame_parent(frame),
1195− cur = crate::repo::by_id(&cur, &pid)?.unwrap();
1196 }
1222 }
1197}
1223}
1198
1224
@@ -1624,10 +1650,13 @@ fn build_row_text(
1624 } else {
1650 } else {
1625 String::new()
1651 String::new()
1626 };
1652 };
1627− // message
1653+ // message, coloured by state (§Colour): focus bold, conflict red, empty
1654+ // dim-italic; the collapsed marker is dim
1628 let msg_first = c.map(|x| x.message.lines().next().unwrap_or("")).unwrap_or("");
1655 let msg_first = c.map(|x| x.message.lines().next().unwrap_or("")).unwrap_or("");
1629 let mut msg = match c {
1656 let mut msg = match c {
1630 Some(info) if info.empty => pal.dim_italic(msg_first),
1657 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),
1631 _ => msg_first.to_string(),
1660 _ => msg_first.to_string(),
1632 };
1661 };
1633 if let Display::Collapsed { hidden, .. } = n {
1662 if let Display::Collapsed { hidden, .. } = n {
@@ -1635,7 +1664,7 @@ fn build_row_text(
1635 if !msg.is_empty() {
1664 if !msg.is_empty() {
1636 msg.push_str(" ");
1665 msg.push_str(" ");
1637 }
1666 }
1638− msg.push_str(&format!("⋯ {}", hidden));
1667+ msg.push_str(&pal.dim(&format!("⋯ {}", hidden)));
1639 }
1668 }
1640 }
1669 }
1641 let labels = c.map(|x| x.labels.join(" ")).unwrap_or_default();
1670 let labels = c.map(|x| x.labels.join(" ")).unwrap_or_default();
@@ -1755,7 +1784,12 @@ fn draw_rows(
1755 let (chars, lane0) = rail_row(rows, placements, idx, lanes_n, opts);
1784 let (chars, lane0) = rail_row(rows, placements, idx, lanes_n, opts);
1756 let mut line = String::new();
1785 let mut line = String::new();
1757 line.push_str(&t.gutter);
1786 line.push_str(&t.gutter);
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+ };
1758− line.push_str(&color_rails(&chars, &lane0, pal));
1792+ line.push_str(&color_rails(&chars, &lane0, pal, glyph));
1759 line.push(' ');
1793 line.push(' ');
1760 line.push_str(&t.id);
1794 line.push_str(&t.id);
1761 if !t.bar.is_empty() {
1795 if !t.bar.is_empty() {
@@ -1791,7 +1825,26 @@ fn draw_rows(
1791 line.push_str(&meta.join(" "));
1825 line.push_str(&meta.join(" "));
1792 }
1826 }
1793 let _ = pl;
1827 let _ = pl;
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+ };
1794− lines.push(line);
1844+ lines.push(pal.bg_line("48;5;236", &padded));
1845+ } else {
1846+ lines.push(line);
1847+ }
1795 // detail line (detail = 2, focus only)
1848 // detail line (detail = 2, focus only)
1796 if opts.detail >= 2
1849 if opts.detail >= 2
1797 && with_focus
1850 && with_focus
@@ -1801,7 +1854,7 @@ fn draw_rows(
1801 let (dchars, dlane0) = detail_rail_row(rows, placements, idx, lanes_n);
1854 let (dchars, dlane0) = detail_rail_row(rows, placements, idx, lanes_n);
1802 let mut dline = String::new();
1855 let mut dline = String::new();
1803 dline.push_str(" ");
1856 dline.push_str(" ");
1804− dline.push_str(&color_rails(&dchars, &dlane0, pal));
1857+ dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
1805 dline.push(' ');
1858 dline.push(' ');
1806 dline.push_str(&" ".repeat(id_w + 2));
1859 dline.push_str(&" ".repeat(id_w + 2));
1807 let marks: Vec<String> = c
1860 let marks: Vec<String> = c
@@ -2120,12 +2173,28 @@ fn detail_rail_row(
2120 (chars, lane0)
2173 (chars, lane0)
2121}
2174}
2122
2175
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.
2123−fn color_rails(chars: &[char], lane0: &[bool], pal: &Palette) -> String {
2179+fn color_rails(
2180+ chars: &[char],
2181+ lane0: &[bool],
2182+ pal: &Palette,
2183+ glyph: Option<(usize, &str)>,
2184+) -> String {
2124 let mut s = String::new();
2185 let mut s = String::new();
2125 for (i, c) in chars.iter().enumerate() {
2186 for (i, c) in chars.iter().enumerate() {
2126 let t = c.to_string();
2187 let t = c.to_string();
2127 if *c == ' ' {
2188 if *c == ' ' {
2128 s.push(*c);
2189 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+ }
2129 } else if lane0[i] {
2198 } else if lane0[i] {
2130 s.push_str(&pal.blue(&t));
2199 s.push_str(&pal.blue(&t));
2131 } else {
2200 } else {
@@ -2282,6 +2351,26 @@ fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
2282 }
2351 }
2283}
2352}
2284
2353
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+
2285fn size_bar(size: Option<usize>, pal: &Palette) -> String {
2374fn size_bar(size: Option<usize>, pal: &Palette) -> String {
2286 match size {
2375 match size {
2287 None | Some(0) => String::new(),
2376 None | Some(0) => String::new(),
src/repo.rs+9−1
@@ -73,8 +73,16 @@ fn refocus_child(repo: &Value, child: &Value) -> Result<Value, Crash> {
73 let mut left = Vec::new();
73 let mut left = Vec::new();
74 let mut right = Vec::new();
74 let mut right = Vec::new();
75 let mut found = false;
75 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();
76 for c in children.iter() {
80 for c in children.iter() {
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+ };
77− if !found && value_eq(c, child)? {
85+ if !found && same {
78 found = true;
86 found = true;
79 } else if !found {
87 } else if !found {
80 left.push(c.clone());
88 left.push(c.clone());
src/show.rs+9−5
@@ -42,9 +42,13 @@ fn render(interp: &Interp, v: &Value) -> String {
42 Ok(s) => format!("blob {}", text_literal(&s)),
42 Ok(s) => format!("blob {}", text_literal(&s)),
43 Err(_) => "blob \"<binary>\"".into(),
43 Err(_) => "blob \"<binary>\"".into(),
44 },
44 },
45− BlobContent::Conflict(_) => {
45+ BlobContent::Lazy(_) | BlobContent::Conflict(_) => {
46+ let s = b
47+ .bytes()
46− let s = String::from_utf8_lossy(&b.bytes()).to_string();
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 { "" };
47− format!("{{- unresolved -}} blob {}", text_literal(&s))
51+ format!("{}blob {}", marker, text_literal(&s))
48 }
52 }
49 },
53 },
50 Value::Shape(s) => s.name.clone(),
54 Value::Shape(s) => s.name.clone(),
@@ -275,11 +279,11 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
275 .transpose()?
279 .transpose()?
276 .unwrap_or_else(|| "file".to_string());
280 .unwrap_or_else(|| "file".to_string());
277 let a_bytes = match a {
281 let a_bytes = match a {
278− Value::Blob(bl) => bl.bytes(),
282+ Value::Blob(bl) => bl.bytes()?,
279 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
283 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
280 };
284 };
281 let b_bytes = match b {
285 let b_bytes = match b {
282− Value::Blob(bl) => bl.bytes(),
286+ Value::Blob(bl) => bl.bytes()?,
283 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
287 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
284 };
288 };
285 let dir = tempfile::tempdir()
289 let dir = tempfile::tempdir()
src/value.rs+92−9
@@ -27,15 +27,79 @@ pub enum BlobKind {
27 Symlink,
27 Symlink,
28}
28}
29
29
30−#[derive(Clone, Debug)]
30+#[derive(Clone)]
31pub enum BlobContent {
31pub enum BlobContent {
32 Resolved(Rc<Vec<u8>>),
32 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>),
33 /// Conflict sides, jj order: alternating adds and removes, starting and
38 /// Conflict sides, jj order: alternating adds and removes, starting and
34 /// ending with an add: [add, (remove, add)*]. For the in-memory backend:
39 /// ending with an add: [add, (remove, add)*]. For the in-memory backend:
35 /// [to, from, onto]-style three sides as [add, remove, add].
40 /// [to, from, onto]-style three sides as [add, remove, add].
36 Conflict(Vec<Rc<Vec<u8>>>),
41 Conflict(Vec<Rc<Vec<u8>>>),
37}
42}
38
43
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+
39#[derive(Clone, Debug)]
103#[derive(Clone, Debug)]
40pub struct BlobVal {
104pub struct BlobVal {
41 pub kind: BlobKind,
105 pub kind: BlobKind,
@@ -52,17 +116,30 @@ impl BlobVal {
52 pub fn is_unresolved(&self) -> bool {
116 pub fn is_unresolved(&self) -> bool {
53 matches!(self.content, BlobContent::Conflict(_))
117 matches!(self.content, BlobContent::Conflict(_))
54 }
118 }
119+ /// size in bytes; a lazy blob reports the size of its (memoized) bytes,
120+ /// forcing on first call
55 pub fn size(&self) -> usize {
121 pub fn size(&self) -> usize {
56 match &self.content {
122 match &self.content {
57 BlobContent::Resolved(b) => b.len(),
123 BlobContent::Resolved(b) => b.len(),
124+ BlobContent::Lazy(l) => l.force().map(|b| b.len()).unwrap_or(0),
58 BlobContent::Conflict(sides) => sides.iter().map(|s| s.len()).sum(),
125 BlobContent::Conflict(sides) => sides.iter().map(|s| s.len()).sum(),
59 }
126 }
60 }
127 }
61− /// content as bytes; conflicts render with jj-style conflict markers
128+ /// content as bytes, forcing a lazy blob; conflicts render with jj-style
129+ /// conflict markers
62− pub fn bytes(&self) -> Vec<u8> {
130+ pub fn bytes(&self) -> Result<Vec<u8>, Crash> {
63 match &self.content {
131 match &self.content {
64− BlobContent::Resolved(b) => b.as_ref().clone(),
132+ BlobContent::Resolved(b) => Ok(b.as_ref().clone()),
133+ BlobContent::Lazy(l) => Ok(l.force()?.as_ref().clone()),
65− BlobContent::Conflict(sides) => render_conflict(sides),
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,
66 }
143 }
67 }
144 }
68}
145}
@@ -483,7 +560,9 @@ pub fn value_eq(a: &Value, b: &Value) -> Result<bool, Crash> {
483 }
560 }
484 true
561 true
485 }
562 }
563+ (Value::Blob(x), Value::Blob(y)) => {
486− (Value::Blob(x), Value::Blob(y)) => x.kind == y.kind && blob_content_eq(&x.content, &y.content),
564+ x.kind == y.kind && blob_content_eq(&x.content, &y.content)?
565+ }
487 (Value::Shape(x), Value::Shape(y)) => x == y,
566 (Value::Shape(x), Value::Shape(y)) => x == y,
488 (Value::Fun(_), Value::Fun(_)) => {
567 (Value::Fun(_), Value::Fun(_)) => {
489 return Err(Crash::new("cannot compare functions for equality"))
568 return Err(Crash::new("cannot compare functions for equality"))
@@ -492,12 +571,16 @@ pub fn value_eq(a: &Value, b: &Value) -> Result<bool, Crash> {
492 })
571 })
493}
572}
494
573
574+/// content equality; two lazy blobs compare by content hash, without forcing
495−fn blob_content_eq(a: &BlobContent, b: &BlobContent) -> bool {
575+fn blob_content_eq(a: &BlobContent, b: &BlobContent) -> Result<bool, Crash> {
496− match (a, b) {
576+ Ok(match (a, b) {
497 (BlobContent::Resolved(x), BlobContent::Resolved(y)) => x == y,
577 (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,
498 (BlobContent::Conflict(x), BlobContent::Conflict(y)) => x == y,
581 (BlobContent::Conflict(x), BlobContent::Conflict(y)) => x == y,
499 _ => false,
582 _ => false,
500− }
583+ })
501}
584}
502
585
503impl std::fmt::Debug for Value {
586impl std::fmt::Debug for Value {
tests/domain.rs+1−1
@@ -59,7 +59,7 @@ fn unresolved_at(snap: &[Value], path: &str) -> bool {
59
59
60fn blob_text(v: &Value) -> String {
60fn blob_text(v: &Value) -> String {
61 match v {
61 match v {
62− Value::Blob(b) => String::from_utf8(b.bytes()).unwrap(),
62+ Value::Blob(b) => String::from_utf8(b.bytes().unwrap()).unwrap(),
63 _ => panic!("not a blob"),
63 _ => panic!("not a blob"),
64 }
64 }
65}
65}
tests/show.rs+1−1
@@ -261,7 +261,7 @@ fn conflict_blob_markers() {
261 ]),
261 ]),
262 }));
262 }));
263 let Value::Blob(b) = &v else { panic!() };
263 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();
265 assert!(text.contains("<<<<<<<"), "{}", text);
265 assert!(text.contains("<<<<<<<"), "{}", text);
266 assert!(text.contains(">>>>>>>"), "{}", text);
266 assert!(text.contains(">>>>>>>"), "{}", text);
267 assert!(text.contains("+++++++"), "{}", text);
267 assert!(text.contains("+++++++"), "{}", text);