on master help sign in
Index the history before printing a revset; resolve config id literals

Printing a list of ids was close to cubic: 16.7s for `j all` on an 800-commit history, 3.2s for `j ancestors` on a 401-commit one. Two causes, both per displayed id in `display_list_block`: a `by_id` walk of the whole history to find the commit, and `unique_prefix`, which called `visible_ids` — cloning every id into a fresh String — and then scanned all of them once per prefix length, counting matches rather than stopping at the first. The history is now indexed once before the loop, and unique prefixes come from one sorted id list: only an id's neighbours in sorted order can share its longest prefix, so `partition_point` and two comparisons settle it. `tree_render` computed its id column the same way and gets the same treatment. `j all` on 800 commits: 16697ms -> 343ms. `ancestors` on 401 commits: 3176ms -> 388ms. 160 command outputs across ten repositories are byte-identical to before. Also: - `@prefix` literals in config.j were validated but never rewritten, so the literal reached the evaluator and crashed with `internal: unresolved id literal` — and only when the definition was finally used, since a lambda body is not evaluated at load. §1.2.5 says they are resolved at load; `resolve_config_ids` now does that, in `build_interp` as soon as the visible ids are known and before any definition is evaluated. - `j` read stdin whenever it was not a terminal, so it hung forever when the process had inherited a pipe nobody was writing to — a shell loop, a CI step. It now waits 50ms for input when an expression was given in arguments; a closed or exhausted stdin is ready at once, so redirection from /dev/null pays nothing. `echo EXPR | j` and the both-given error are unchanged. - `persist` walked its queue with `Vec::remove(0)`, quadratic for a wide history; it is a `VecDeque` now. - Removed the unreachable `visited` list in the crash unwinder (a `Try` cannot catch its own handler's crash: every `on_crash` continues through the continuation outside it), `render_conflict`'s empty-block guard, `is_repo_value`'s unused parameter, and `exit_code_number`, which recovered a status by parsing `ExitCode`'s `Debug` output — the fallible helpers return the number. Every bug found in this repository lately hid in the same place: the in-memory backend has no lazy file lists and answers `has_conflict` and `is_empty` with None, so it never reaches the paths the jj backend takes. `MemBackend::answering` and `domain::lazy_files` reproduce both, and the tests for the `extract` and tree-emptiness bugs are written against them — each verified to fail with its fix reverted.

commit33cb7e34069c802f48660763cdd35f2f02d2dc00 authoriko <ilyakooo0@gmail.com> dateMon 21 Sep 2026, 20:36 (3 weeks ago) parentd86146b
10 files changed+385−99
src/config.rs+19−7 src/domain.rs+42−3 src/eval.rs+61−13 src/jj.rs+23−8 src/main.rs+48−47 src/render.rs+19−13 src/value.rs−5 tests/display.rs+112 tests/show.rs+43 tests/validation.rs+18−3
src/config.rs+19−7
@@ -464,17 +464,29 @@ fn resolve_ids_rec(
464464 }
465465}
466466
467−/// find Id literals per definition for config validation (§6.2.5)
468−pub fn config_id_failures(
469− cfg: &Config,
467+/// Resolve every `@prefix` literal in the config's definitions against the
468+/// visible ids, in place (§1.2.5: "in `config.j`, every literal is resolved at
469+/// load"). Returns the ones that did not resolve to exactly one commit, as
470+/// (definition, prefix, candidates); those definitions are left untouched.
471+///
472+/// Checking without rewriting is not enough: an unresolved literal reaching
473+/// the evaluator crashes with `internal: unresolved id literal`, and because a
474+/// lambda body is not evaluated at load, it does so only when the definition
475+/// is eventually used.
476+pub fn resolve_config_ids(
477+ cfg: &mut Config,
470478 interp: &Interp,
471479) -> Vec<(String, String, Vec<String>)> {
472480 let mut out = Vec::new();
473− for (name, expr) in &cfg.defs {
481+ for (name, expr) in cfg.defs.iter_mut() {
474482 let mut failures = Vec::new();
475− resolve_ids_rec(expr, interp, &mut failures);
476− for (prefix, matches) in failures {
477− out.push((name.clone(), prefix, matches));
483+ let resolved = resolve_ids_rec(expr, interp, &mut failures);
484+ if failures.is_empty() {
485+ *expr = Rc::new(resolved);
486+ } else {
487+ for (prefix, matches) in failures {
488+ out.push((name.clone(), prefix, matches));
489+ }
478490 }
479491 }
480492 out
src/domain.rs+42−3
@@ -237,17 +237,42 @@ pub struct MemBackend {
237237 pub metas: HashMap<String, MetaInfo>,
238238 /// id -> parent change ids (first parent first)
239239 pub parents: HashMap<String, Vec<String>>,
240+ /// Answer `has_conflict` and `is_empty` from the two maps below instead of
241+ /// returning None, the way the jj backend answers them from tree ids. A
242+ /// backend that answers them takes different paths through rendering and
243+ /// never forces a commit's file list, so tests that only ever used the
244+ /// None-answering default could not reach those paths at all.
245+ pub answers_tree_queries: bool,
246+ /// id -> the commit's tree holds a conflict
247+ pub conflicts: HashMap<String, bool>,
248+ /// id -> the commit's tree equals its first parent's
249+ pub empties: HashMap<String, bool>,
240250}
241251
242252impl MemBackend {
243253 pub fn new() -> Self {
254+ MemBackend::default()
255+ }
256+
257+ /// A backend that answers the tree queries, like the jj backend does.
258+ pub fn answering() -> Self {
244259 MemBackend {
245− metas: HashMap::new(),
246− parents: HashMap::new(),
260+ answers_tree_queries: true,
261+ ..MemBackend::default()
247262 }
248263 }
249264}
250265
266+/// A snapshot that is only materialised on first use, the way the jj backend
267+/// builds a commit's `files` (§7.2). The laziness must stay invisible to the
268+/// language, which is exactly what is easy to get wrong, so tests need to be
269+/// able to build repos this way.
270+pub fn lazy_files(entries: Vec<Value>) -> Value {
271+ Value::Thunk(Rc::new(crate::value::ThunkVal::new(move || {
272+ Ok(Value::list(entries))
273+ })))
274+}
275+
251276impl Backend for MemBackend {
252277 fn visible_ids(&self) -> Vec<String> {
253278 let mut ids: Vec<String> = self.metas.keys().cloned().collect();
@@ -258,11 +283,25 @@ impl Backend for MemBackend {
258283 self.metas
259284 .get(id)
260285 .cloned()
261− .ok_or_else(|| Crash::new(format!("meta: no stored commit for id")))
286+ .ok_or_else(|| Crash::new(format!("meta: no stored commit for `{}`", id)))
262287 }
263288 fn is_merge(&self, id: &str) -> bool {
264289 self.parents.get(id).map(|p| p.len() >= 2).unwrap_or(false)
265290 }
291+ fn has_conflict(&self, id: &str) -> Option<bool> {
292+ if !self.answers_tree_queries {
293+ return None;
294+ }
295+ Some(self.conflicts.get(id).copied().unwrap_or(false))
296+ }
297+ fn is_empty(&self, id: &str) -> Option<bool> {
298+ if !self.answers_tree_queries {
299+ return None;
300+ }
301+ // like jj: only answerable against a recorded first parent
302+ self.parents.get(id)?.first()?;
303+ Some(self.empties.get(id).copied().unwrap_or(false))
304+ }
266305 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String> {
267306 let mut seen = BTreeSet::new();
268307 let mut stack: Vec<String> = ids.iter().cloned().collect();
src/eval.rs+61−13
@@ -30,6 +30,43 @@ pub struct Interp {
3030 /// history. The `Rc` is kept so the address cannot be reused while it is
3131 /// a key (§4.1: definitions are pure, so the result cannot change).
3232 revsets: RefCell<Vec<(&'static str, Rc<crate::value::RecordMap>, Value)>>,
33+ /// visible ids in sorted order, built on first use. The backend's
34+ /// `visible_ids` allocates every id afresh, and a shortest-unique-prefix
35+ /// needs the whole set, so computing one per displayed commit was
36+ /// quadratic in both allocations and comparisons.
37+ sorted_ids: RefCell<Option<Rc<Vec<String>>>>,
38+}
39+
40+/// length of the common leading run of two ids (ids are ASCII, so bytes)
41+fn common_prefix_len(a: &str, b: &str) -> usize {
42+ a.bytes()
43+ .zip(b.bytes())
44+ .take_while(|(x, y)| x == y)
45+ .count()
46+}
47+
48+/// The shortest prefix of `id` that no other id in `sorted` shares, at least
49+/// four characters long. Only the neighbours of `id` in sorted order can share
50+/// its longest prefix, so two lookups settle it.
51+pub fn unique_prefix_in(sorted: &[String], id: &str) -> String {
52+ let pos = sorted.partition_point(|o| o.as_str() < id);
53+ let mut shared = 0usize;
54+ if pos > 0 {
55+ shared = shared.max(common_prefix_len(&sorted[pos - 1], id));
56+ }
57+ let mut next = pos;
58+ while next < sorted.len() && sorted[next] == id {
59+ next += 1;
60+ }
61+ if next < sorted.len() {
62+ shared = shared.max(common_prefix_len(&sorted[next], id));
63+ }
64+ let n = 4.max(shared + 1);
65+ if n >= id.len() {
66+ id.to_string()
67+ } else {
68+ id[..n].to_string()
69+ }
3370}
3471
3572/// machine state
@@ -179,9 +216,27 @@ impl Interp {
179216 current_def: RefCell::new(None),
180217 old_repo: RefCell::new(None),
181218 revsets: RefCell::new(Vec::new()),
219+ sorted_ids: RefCell::new(None),
182220 }
183221 }
184222
223+ /// Shortest prefix of `id` unique among the visible ids, minimum four
224+ /// (§5.1). The sorted id list is built once per run.
225+ pub fn unique_prefix(&self, id: &str) -> String {
226+ let cached = self.sorted_ids.borrow().clone();
227+ let sorted = match cached {
228+ Some(s) => s,
229+ None => {
230+ let mut ids = self.backend.visible_ids();
231+ ids.sort();
232+ let rc = Rc::new(ids);
233+ *self.sorted_ids.borrow_mut() = Some(rc.clone());
234+ rc
235+ }
236+ };
237+ unique_prefix_in(&sorted, id)
238+ }
239+
185240 /// Apply one of the config revsets the binary calls itself, reusing the
186241 /// result when the same Repo value has already been asked (§7.5).
187242 pub fn apply_cached_revset(&mut self, name: &'static str, repo: &Value) -> EResult {
@@ -219,6 +274,7 @@ impl Interp {
219274 current_def: RefCell::new(None),
220275 old_repo: RefCell::new(None),
221276 revsets: RefCell::new(Vec::new()),
277+ sorted_ids: RefCell::new(None),
222278 }
223279 }
224280
@@ -296,26 +352,18 @@ impl Interp {
296352 Run::Step(next) => st = next,
297353 Run::Done(v) => return Ok(v),
298354 Run::Crash(c) => {
299− // unwind to the nearest Try continuation, restoring the
355+ // Unwind to the nearest Try continuation, restoring the
300356 // fresh-id snapshot so a caught crash leaves no trace.
301− // A `Shared` continuation links back to a context already
302− // containing a Try, so track visited Try nodes and skip
303− // ones already unwound past (a caught crash must not be
304− // re-caught by the same Try, which would loop forever).
357+ // A Try cannot catch a crash raised by its own handler:
358+ // every `on_crash` state continues through `Cont::Shared`
359+ // holding the continuation *outside* the Try, so the Try
360+ // is no longer on the chain once the handler runs.
305361 let mut k = Some(active.clone());
306− let mut visited: Vec<*const Cont> = Vec::new();
307362 let caught = loop {
308363 match k {
309364 Some(Cont::Try {
310365 snapshot, on_crash, ..
311366 }) => {
312− let ptr = &*on_crash as *const State as *const Cont;
313− let already = visited.contains(&ptr);
314− visited.push(ptr);
315− if already {
316− k = None;
317− continue;
318− }
319367 *self.fresh.borrow_mut() = snapshot;
320368 break Some(*on_crash);
321369 }
src/jj.rs+23−8
@@ -350,7 +350,7 @@ impl JjBackend {
350350
351351 pub fn build_interp(
352352 &self,
353− cfg: &Config,
353+ cfg: &mut Config,
354354 _text: &str,
355355 snapshot: bool,
356356 ) -> Result<(Interp, Value, Value), OpenError> {
@@ -392,6 +392,19 @@ impl JjBackend {
392392 cfg.shapes.clone(),
393393 crate::value::Env::empty(),
394394 );
395+ // §1.2.5: resolve the config's `@prefix` literals now that the visible
396+ // ids are known, and before anything evaluates a definition
397+ if let Some((def, prefix, _)) =
398+ crate::config::resolve_config_ids(cfg, &interp).into_iter().next()
399+ {
400+ return Err((
401+ 3,
402+ format!(
403+ "config.j: `@{}` in `{}` does not resolve to a unique commit",
404+ prefix, def
405+ ),
406+ ));
407+ }
395408 // §7.2: if the focused commit is in the immutable set, the snapshot
396409 // cannot go into it; the focus is a new empty child of it holding the
397410 // snapshot, and persisting records that child
@@ -648,12 +661,14 @@ impl JjBackend {
648661 written.insert(ROOT_ID.to_string(), root_jj.id().clone());
649662
650663 // walk `new` top-down (§7.5 step 4), parents before children
651− let mut queue: Vec<(Value, String)> = Vec::new(); // (subtree value, parent change id)
664+ // a deque, not a Vec: `remove(0)` shifts everything after it, which
665+ // is quadratic in the number of commits for a wide history
666+ let mut queue: std::collections::VecDeque<(Value, String)> =
667+ std::collections::VecDeque::new(); // (subtree value, parent change id)
652668 for kid in top.field("children")?.as_list()?.iter() {
653− queue.push((kid.clone(), ROOT_ID.to_string()));
669+ queue.push_back((kid.clone(), ROOT_ID.to_string()));
654670 }
655− while !queue.is_empty() {
656− let (subtree, parent_change) = queue.remove(0);
671+ while let Some((subtree, parent_change)) = queue.pop_front() {
657672 let commit_v = subtree.field("root")?;
658673 let id = commit_id_of(&commit_v)?;
659674 let parent_jj = written
@@ -712,7 +727,7 @@ impl JjBackend {
712727 };
713728 written.insert(id.clone(), new_id);
714729 for kid in subtree.field("children")?.as_list()?.iter() {
715− queue.push((kid.clone(), id.clone()));
730+ queue.push_back((kid.clone(), id.clone()));
716731 }
717732 }
718733
@@ -909,7 +924,7 @@ impl JjBackend {
909924 let _ = block_on(self.publish_tx(tx, "fetch"))?;
910925 Ok(())
911926 }
912− pub fn cmd_push(&self, cfg: &Config, expr_text: &str) -> Result<(), OpenError> {
927+ pub fn cmd_push(&self, cfg: &mut Config, expr_text: &str) -> Result<(), OpenError> {
913928 let origin = RemoteName::new("origin");
914929 let base = self.head_repo()?;
915930 let (value, vis) = self.eval_push_expr(cfg, expr_text, &base)?;
@@ -923,7 +938,7 @@ impl JjBackend {
923938 /// (§1.1), apply a function result to the repo value once (§7.6)
924939 fn eval_push_expr(
925940 &self,
926− cfg: &Config,
941+ cfg: &mut Config,
927942 expr_text: &str,
928943 base: &Arc<ReadonlyRepo>,
929944 ) -> Result<(Value, Arc<VisibleRepo>), OpenError> {
src/main.rs+48−47
@@ -1,7 +1,6 @@
11//! j — a functional-programming-centered CLI for jj repositories (§1).
22
33use j::config::{self, Config};
4−use j::eval::Interp;
54use j::jj::JjBackend;
65use j::parse::parse_expr;
76use j::value::{Crash, Value};
@@ -17,6 +16,13 @@ fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
1716 ExitCode::from(status)
1817}
1918
19+/// Report a failure that ends the process, keeping the status as a number so
20+/// callers do not have to recover it from an `ExitCode`.
21+fn fail(status: u8, msg: impl std::fmt::Display) -> u8 {
22+ eprintln!("j: {}", msg);
23+ status
24+}
25+
2026fn crash_err(c: &Crash, expr_text: Option<&str>) -> ExitCode {
2127 eprintln!("j: crash: {}", c.msg);
2228 match (&c.def, expr_text) {
@@ -42,7 +48,7 @@ fn config_path() -> String {
4248/// installed (the share/j/config.j file may not exist next to the binary).
4349const DEFAULT_CONFIG: &str = include_str!("../config.j");
4450
45−fn load_config_file() -> Result<(Config, String), ExitCode> {
51+fn load_config_file() -> Result<(Config, String), u8> {
4652 let path = config_path();
4753 let src = match std::fs::read_to_string(&path) {
4854 Ok(s) => s,
@@ -50,18 +56,18 @@ fn load_config_file() -> Result<(Config, String), ExitCode> {
5056 // No user config yet: materialise the default there, editable by
5157 // the current user, so they have a starting point to customise.
5258 if let Err(e) = write_default_config(&path) {
53− return Err(err(3, format!("cannot create config at {}: {}", path, e)));
59+ return Err(fail(3, format!("cannot create config at {}: {}", path, e)));
5460 }
5561 eprintln!("j: created an editable default config at {}", path);
5662 match std::fs::read_to_string(&path) {
5763 Ok(s) => s,
58− Err(e) => return Err(err(3, format!("cannot read config at {}: {}", path, e))),
64+ Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
5965 }
6066 }
6167 };
6268 match config::load_config(&src) {
6369 Ok(c) => Ok((c, src)),
64− Err(e) => Err(err(3, e.message())),
70+ Err(e) => Err(fail(3, e.message())),
6571 }
6672}
6773
@@ -95,18 +101,36 @@ fn main() -> ExitCode {
95101 .expect("worker thread panicked")
96102}
97103
104+/// True when stdin has input (or end-of-input) waiting within `ms`
105+/// milliseconds. A closed or exhausted stdin reports ready at once, so the
106+/// wait is only paid for a pipe that is open but idle.
107+fn stdin_is_ready(ms: i32) -> bool {
108+ let mut fds = libc::pollfd {
109+ fd: libc::STDIN_FILENO,
110+ events: libc::POLLIN,
111+ revents: 0,
112+ };
113+ unsafe { libc::poll(&mut fds, 1, ms) > 0 }
114+}
115+
98116fn run() -> ExitCode {
99117 let args: Vec<String> = std::env::args().skip(1).collect();
100− // §1: stdin vs arguments
118+ // §1: stdin vs arguments. With no arguments the expression comes from
119+ // stdin, so reading to end-of-input is the whole point. With arguments,
120+ // stdin is only read to report the "both" error — and reading it blindly
121+ // hangs whenever the process inherited a pipe nobody is writing to (a
122+ // shell loop, a CI step), so wait only briefly for input to appear.
101123 let mut stdin_content = String::new();
102124 let stdin_has_data = {
103125 if std::io::stdin().is_terminal() {
104126 false
105− } else {
127+ } else if args.is_empty() || stdin_is_ready(50) {
106128 match std::io::stdin().read_to_string(&mut stdin_content) {
107129 Ok(n) => n > 0,
108130 Err(_) => false,
109131 }
132+ } else {
133+ false
110134 }
111135 };
112136 if stdin_has_data && !args.is_empty() {
@@ -185,10 +209,10 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
185209 if rest.is_empty() {
186210 return err(2, "usage: j push EXPR");
187211 }
188− let (cfg, _) = or_exit(load_config_file());
212+ let (mut cfg, _) = or_exit(load_config_file());
189213 let backend = or_exit(open_repo_locked(&cfg));
190214 // evaluate EXPR against the recorded repository (no snapshot)
191− match backend.cmd_push(&cfg, rest) {
215+ match backend.cmd_push(&mut cfg, rest) {
192216 Ok(()) => ExitCode::SUCCESS,
193217 Err(e) => err(e.0, e.1),
194218 }
@@ -232,58 +256,44 @@ fn default_clone_dir(url: &str) -> String {
232256 last.strip_suffix(".git").unwrap_or(last).to_string()
233257}
234258
235−fn or_exit<T>(r: Result<T, ExitCode>) -> T {
259+fn or_exit<T>(r: Result<T, u8>) -> T {
236260 match r {
237261 Ok(v) => v,
238− Err(c) => std::process::exit(exit_code_number(c)),
239− }
240−}
241−
242−/// ExitCode doesn't expose its value; track it alongside.
243−fn exit_code_number(c: ExitCode) -> i32 {
244− // ExitCode's Debug is "ExitCode(unix_exit_status(N))" on unix
245− let s = format!("{:?}", c);
246− for tok in s.split(|ch: char| !ch.is_ascii_digit()) {
247− if !tok.is_empty() {
248− if let Ok(n) = tok.parse() {
249− return n;
250− }
251− }
262+ Err(code) => std::process::exit(code as i32),
252263 }
253− 1
254264}
255265
256−fn open_repo() -> Result<Rc<JjBackend>, ExitCode> {
266+fn open_repo() -> Result<Rc<JjBackend>, u8> {
257267 match JjBackend::open(None) {
258268 Ok(b) => Ok(Rc::new(b)),
259− Err(e) => Err(err(e.0, e.1)),
269+ Err(e) => Err(fail(e.0, e.1)),
260270 }
261271}
262272
263−fn open_repo_noconfig() -> Result<Rc<JjBackend>, ExitCode> {
273+fn open_repo_noconfig() -> Result<Rc<JjBackend>, u8> {
264274 // undo/redo/ops do not read the config (§6.1), but take the lock
265275 match JjBackend::open(None) {
266276 Ok(b) => {
267277 b.take_lock();
268278 Ok(Rc::new(b))
269279 }
270− Err(e) => Err(err(e.0, e.1)),
280+ Err(e) => Err(fail(e.0, e.1)),
271281 }
272282}
273283
274−fn open_repo_locked(cfg: &Config) -> Result<Rc<JjBackend>, ExitCode> {
284+fn open_repo_locked(cfg: &Config) -> Result<Rc<JjBackend>, u8> {
275285 match JjBackend::open(Some(cfg)) {
276286 Ok(b) => {
277287 b.take_lock();
278288 Ok(Rc::new(b))
279289 }
280− Err(e) => Err(err(e.0, e.1)),
290+ Err(e) => Err(fail(e.0, e.1)),
281291 }
282292}
283293
284294fn run_expression(text: &str, snapshot: bool) -> ExitCode {
285295 // §1.2.1: locate the repository
286− let (cfg, _cfg_src) = or_exit(load_config_file());
296+ let (mut cfg, _cfg_src) = or_exit(load_config_file());
287297 let backend = or_exit(open_repo_locked(&cfg));
288298
289299 // §1.2.2: parse the expression
@@ -295,23 +305,15 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
295305
296306 // §1.2.4: build the current repository value (with snapshot)
297307 let (mut interp, loaded_repo, current_repo) =
298− match backend.build_interp(&cfg, text, snapshot) {
308+ match backend.build_interp(&mut cfg, text, snapshot) {
299309 Ok(t) => t,
300310 Err((2, m)) => return err(2, m),
311+ Err((3, m)) => return err(3, m),
301312 Err((_, m)) => return err(1, m),
302313 };
303314
304− // §1.2.5: resolve Id literals in config and expression
305− for (def_name, prefix, candidates) in config::config_id_failures(&cfg, &interp) {
306− let _ = candidates;
307− return err(
308− 3,
309− format!(
310− "config.j: `@{}` in `{}` does not resolve to a unique commit",
311− prefix, def_name
312− ),
313− );
314− }
315+ // §1.2.5: the config's Id literals were resolved by build_interp, as soon
316+ // as the visible ids were known; the expression's are resolved here
315317 expr = match config::resolve_ids(&expr, &interp) {
316318 Ok(e) => e,
317319 Err(failures) => {
@@ -349,7 +351,7 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
349351 }
350352
351353 // §1.2.8: a Repo is persisted (unless unchanged)
352− if is_repo_value(&interp, &v) {
354+ if is_repo_value(&v) {
353355 match backend.persist(&mut interp, &cfg, &loaded_repo, &current_repo, &v, text) {
354356 Ok(()) => ExitCode::SUCCESS,
355357 Err(c) => crash_err(&c, Some(text)),
@@ -367,7 +369,7 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
367369 }
368370}
369371
370−fn is_repo_value(interp: &Interp, v: &Value) -> bool {
372+fn is_repo_value(v: &Value) -> bool {
371373 // §4.4: shape Repo and root's shape Commit
372374 let fields = match v {
373375 Value::Record(m) => m.keys().cloned().collect::<BTreeSet<String>>(),
@@ -392,6 +394,5 @@ fn is_repo_value(interp: &Interp, v: &Value) -> bool {
392394 .iter()
393395 .map(|s| s.to_string())
394396 .collect();
395− let _ = interp;
396397 root_fields == want_root
397398}
src/render.rs+19−13
@@ -185,7 +185,7 @@ pub fn display(interp: &mut Interp, v: &Value, color: bool) -> Result<String, Cr
185185}
186186
187187fn display_id(interp: &Interp, id: &str, pal: &Palette) -> String {
188− let p = interp.backend.unique_prefix(id);
188+ let p = interp.unique_prefix(id);
189189 let n = p.len();
190190 format!("{}{}", pal.bold(p.as_str()), pal.dim(&id[n..]))
191191}
@@ -712,16 +712,24 @@ fn display_list_block(
712712 }
713713 // list of Id: commit table (one commit line per id, in list order)
714714 if xs.iter().all(|x| matches!(x, Value::Id(_))) {
715+ // index the history once: a `by_id` walk per id made printing a
716+ // revset quadratic in the size of the history
717+ let repo = top_of(interp, &xs[0])?;
718+ let mut commits: BTreeMap<String, Value> = BTreeMap::new();
719+ for c in crate::repo::all_commits(&repo)? {
720+ if let Ok(Value::Id(i)) = c.field("id") {
721+ commits.insert(i.to_string(), c);
722+ }
723+ }
715724 for x in xs {
716725 let id = match x {
717726 Value::Id(i) => i.to_string(),
718727 _ => unreachable!(),
719728 };
720729 out.push_str(&pad);
721− let repo = top_of(interp, x)?;
722− match crate::repo::by_id(&repo, &id)? {
723− Some(loc) => {
724− let c = loc.field("root")?;
730+ match commits.get(&id) {
731+ Some(c) => {
732+ let c = c.clone();
725733 let conflict = has_conflict(&c)?;
726734 let glyph = if conflict {
727735 pal.red("⊗")
@@ -737,7 +745,7 @@ fn display_list_block(
737745 .map(|l| l.as_text().map(|s| s.to_string()))
738746 .collect::<Result<_, _>>()?;
739747 let id_s = if conflict {
740− pal.red(&interp.backend.unique_prefix(&id))
748+ pal.red(&interp.unique_prefix(&id))
741749 } else {
742750 display_id(interp, &id, pal)
743751 };
@@ -1263,16 +1271,14 @@ fn tree_render(
12631271 // Step 3 — lanes
12641272 let placements = assign_lanes(&rows, &trunk, lanes_n);
12651273 // id column: shortest unique prefix among the display tree's commits, min 4
1266− let ids: Vec<&str> = rows
1274+ // (sorted once, so each row costs a lookup rather than a scan of them all)
1275+ let mut ids: Vec<String> = rows
12671276 .iter()
1268− .filter_map(|n| n.commit().map(|c| c.id.as_str()))
1277+ .filter_map(|n| n.commit().map(|c| c.id.clone()))
12691278 .collect();
1279+ ids.sort();
12701280 let prefix_len = |id: &str| -> usize {
1271− let mut n = 4.min(id.len());
1272− while n < id.len() && ids.iter().any(|o| *o != id && o.starts_with(&id[..n])) {
1273− n += 1;
1274− }
1275− n
1281+ crate::eval::unique_prefix_in(&ids, id).len()
12761282 };
12771283 // Step 4 — draw
12781284 draw_rows(&rows, &placements, opts, pal, lanes_n, with_focus, &prefix_len)
src/value.rs−5
@@ -270,16 +270,11 @@ pub fn render_conflict(sides: &[Rc<Vec<u8>>]) -> Vec<u8> {
270270 let mut out = Vec::new();
271271 out.extend_from_slice(b"<<<<<<<\n");
272272 let mut i = 0;
273− let mut first_add = true;
274273 while i < sides.len() {
275274 if i % 2 == 0 {
276− if !first_add {
277− // another add after removes: separate
278− }
279275 out.extend_from_slice(b"+++++++\n");
280276 out.extend_from_slice(&sides[i]);
281277 ensure_newline(&mut out);
282− first_add = false;
283278 } else {
284279 out.extend_from_slice(b"%%%%%%%\n");
285280 out.extend_from_slice(&sides[i]);
tests/display.rs+112
@@ -328,3 +328,115 @@ fn entry_list_aligns_by_display_width() {
328328 out
329329 );
330330}
331+
332+// ----------------------------------------------------------------------
333+// The jj backend differs from the default in-memory one in two ways that
334+// changed behaviour and were invisible to the suite: a commit's `files` is a
335+// thunk until asked for, and `has_conflict`/`is_empty` are answered from tree
336+// ids rather than by walking files. `MemBackend::answering` plus `lazy_files`
337+// reproduce both.
338+// ----------------------------------------------------------------------
339+
340+fn lazy_commit(id: &str, msg: &str, files: Vec<(&str, &str)>) -> Value {
341+ let entries: Vec<Value> = files
342+ .into_iter()
343+ .map(|(p, c)| {
344+ Value::record(&[
345+ ("content", BlobVal::text_blob(c)),
346+ ("path", Value::list(p.split('/').map(Value::text).collect())),
347+ ])
348+ })
349+ .collect();
350+ Value::record(&[
351+ ("files", j::domain::lazy_files(entries)),
352+ ("id", Value::Id(Rc::new(id.to_string()))),
353+ ("labels", Value::list(vec![])),
354+ ("message", Value::text(msg)),
355+ ])
356+}
357+
358+/// A linear repo of `n` commits above the root whose file lists are lazy,
359+/// focused on the last. It has to be long enough that a commit in the middle
360+/// has a parent nothing asked the files of — that is the case where emptiness
361+/// had nothing to fall back on.
362+fn lazy_repo_chain(n: usize) -> (Value, MemBackend) {
363+ let ids: Vec<String> = (0..n).map(|k| format!("kpqx{:04}", k)).collect();
364+ let mut be = MemBackend::answering();
365+ be.metas.insert(ROOT_ID.to_string(), meta(ROOT_ID, "Root", 1_700_000_000).1);
366+ be.parents.insert(ROOT_ID.to_string(), vec![]);
367+ let mut frames: Vec<Value> = vec![Value::record(&[
368+ ("left", Value::list(vec![])),
369+ ("parent", lazy_commit(ROOT_ID, "", vec![])),
370+ ("right", Value::list(vec![])),
371+ ])];
372+ let mut files: Vec<(String, String)> = Vec::new();
373+ let mut focus = None;
374+ for (k, id) in ids.iter().enumerate() {
375+ files.push((format!("src/f{}.rs", k), format!("fn f{}() {{}}\n", k)));
376+ let as_refs: Vec<(&str, &str)> =
377+ files.iter().map(|(p, c)| (p.as_str(), c.as_str())).collect();
378+ let c = lazy_commit(id, &format!("commit {}", k), as_refs);
379+ be.metas.insert(id.clone(), meta(id, "M", 1_700_000_100 + k as i64).1);
380+ let parent = if k == 0 { ROOT_ID.to_string() } else { ids[k - 1].clone() };
381+ be.parents.insert(id.clone(), vec![parent]);
382+ be.empties.insert(id.clone(), false);
383+ if k + 1 == n {
384+ focus = Some(c);
385+ } else {
386+ frames.push(Value::record(&[
387+ ("left", Value::list(vec![])),
388+ ("parent", c),
389+ ("right", Value::list(vec![])),
390+ ]));
391+ }
392+ }
393+ frames.reverse();
394+ let repo = Value::record(&[
395+ ("children", Value::list(vec![])),
396+ ("context", Value::list(frames)),
397+ ("root", focus.unwrap()),
398+ ]);
399+ (repo, be)
400+}
401+
402+fn lazy_repo() -> (Value, MemBackend) {
403+ lazy_repo_chain(2)
404+}
405+
406+#[test]
407+fn lazy_file_lists_stay_invisible_to_the_language() {
408+ // `extract` walked past an unforced `files` thunk, so it found nothing on
409+ // a real repository while finding everything in memory
410+ let (repo, be) = lazy_repo();
411+ let (mut i, cfg) = make_interp(be);
412+ for (src, want) in [
413+ ("length (extract Entry repo)", "3"),
414+ ("length (extract Blob repo)", "3"),
415+ ("length (extract Commit repo)", "3"),
416+ ("length (files repo)", "2"),
417+ ] {
418+ let out = eval_and_display(
419+ &mut i,
420+ &cfg,
421+ &format!("(\\repo -> {})", src),
422+ repo.clone(),
423+ );
424+ assert_eq!(out.trim(), want, "{}", src);
425+ }
426+}
427+
428+#[test]
429+fn tree_emptiness_uses_the_backend_when_files_are_lazy() {
430+ // `empty` fell back to a file count that is 0 whenever the list was never
431+ // materialised, so a backend that answers `is_empty` (and therefore never
432+ // forces the list) marked every commit empty
433+ // six commits: the middle ones have a parent whose files nothing needs
434+ let (repo, be) = lazy_repo_chain(6);
435+ let (mut i, cfg) = make_interp(be);
436+ let out = eval_and_display(&mut i, &cfg, "tree", repo);
437+ let rows: Vec<&str> = out.lines().filter(|l| l.contains("commit ")).collect();
438+ assert_eq!(rows.len(), 6, "{}", out);
439+ for row in rows {
440+ assert!(!row.contains('◌'), "wrongly marked empty: {:?}\n{}", row, out);
441+ }
442+}
tests/show.rs+43
@@ -335,3 +335,46 @@ fn render_date_matches_utc_calendar() {
335335 assert_eq!(j::render::render_date(t), want, "t={}", t);
336336 }
337337}
338+
339+#[test]
340+fn unique_prefix_is_shortest_and_unambiguous() {
341+ // computed from sorted neighbours rather than a scan of every id; the
342+ // answers must match the definition: shortest prefix no other id shares,
343+ // minimum four characters
344+ let ids: Vec<String> = [
345+ "kkkkllll", "kkkkmmmm", "kkkmnnnn", "lllloooo", "zzzzzzzz",
346+ ]
347+ .iter()
348+ .map(|s| s.to_string())
349+ .collect();
350+ let p = |id: &str| j::eval::unique_prefix_in(&ids, id);
351+ // shares "kkkk" with kkkkmmmm, so needs five
352+ assert_eq!(p("kkkkllll"), "kkkkl");
353+ assert_eq!(p("kkkkmmmm"), "kkkkm");
354+ // differs from the others by the fourth character: the minimum applies
355+ assert_eq!(p("kkkmnnnn"), "kkkm");
356+ assert_eq!(p("lllloooo"), "llll");
357+ assert_eq!(p("zzzzzzzz"), "zzzz");
358+ // an id not in the set is measured against the set all the same:
359+ // "kkkkll" would still be a prefix of kkkkllll, so it needs seven
360+ assert_eq!(p("kkkkllmm"), "kkkkllm");
361+ // and every answer really is unique among the others
362+ for id in &ids {
363+ let pre = p(id);
364+ assert!(pre.len() >= 4, "{} -> {}", id, pre);
365+ let sharers = ids.iter().filter(|o| *o != id && o.starts_with(&pre)).count();
366+ assert_eq!(sharers, 0, "{} -> {} is shared", id, pre);
367+ // and it is the shortest such prefix
368+ if pre.len() > 4 {
369+ let shorter = &id[..pre.len() - 1];
370+ assert!(
371+ ids.iter().any(|o| o != id && o.starts_with(shorter)),
372+ "{} -> {} is longer than needed",
373+ id,
374+ pre
375+ );
376+ }
377+ }
378+ // an empty set still respects the minimum
379+ assert_eq!(j::eval::unique_prefix_in(&[], "kkkkllll"), "kkkk");
380+}
tests/validation.rs+18−3
@@ -330,11 +330,26 @@ fn id_literals_in_config_resolve() {
330330 },
331331 );
332332 let i = Interp::new(Rc::new(b), cfg.shapes.clone(), Env::empty());
333− let failures = config::config_id_failures(&cfg, &i);
333+ let mut resolved = config::load_config(&src).unwrap();
334+ let failures = config::resolve_config_ids(&mut resolved, &i);
334335 assert!(failures.is_empty());
335− // unknown id fails
336+ // resolving rewrites the definition: evaluating it must not hit the
337+ // literal again (it used to crash with `internal: unresolved id literal`,
338+ // and only when the definition was finally used)
339+ let mut i3 = Interp::new(
340+ Rc::new(MemBackend::new()),
341+ resolved.shapes.clone(),
342+ Env::empty(),
343+ );
344+ config::eval_config(&mut i3, &resolved).expect("resolved config evaluates");
345+ match i3.globals.lookup("target") {
346+ Some(Value::Id(id)) => assert_eq!(&*id, "kqqqqqqq"),
347+ other => panic!("target is {:?}", other),
348+ }
349+ // unknown id fails, naming the definition
336350 let i2 = Interp::new(Rc::new(MemBackend::new()), cfg.shapes.clone(), Env::empty());
337− let failures = config::config_id_failures(&cfg, &i2);
351+ let mut unresolved = config::load_config(&src).unwrap();
352+ let failures = config::resolve_config_ids(&mut unresolved, &i2);
338353 assert_eq!(failures.len(), 1);
339354 assert_eq!(failures[0].0, "target");
340355}