on master help sign in
Fix data-loss, validation, rendering, and interpreter bugs (#6)
d06d519iko
CLAUDE.md+1
@@ -38,6 +38,7 @@ Language pipeline, all in `src/`:
38- `lex.rs` → `parse.rs` → `ast.rs`: the parser implements the two layout rules and needs the set of global names (`Config::global_names`) so expressions cannot shadow them.
38- `lex.rs` → `parse.rs` → `ast.rs`: the parser implements the two layout rules and needs the set of global names (`Config::global_names`) so expressions cannot shadow them.
39- `eval.rs`: strict, call-by-value, **trampolined** CEK-style machine (`State`/`Cont`) so deep recursion in user code cannot overflow the native stack. Keep new evaluation paths on the trampoline rather than recursing in Rust.
39- `eval.rs`: strict, call-by-value, **trampolined** CEK-style machine (`State`/`Cont`) so deep recursion in user code cannot overflow the native stack. Keep new evaluation paths on the trampoline rather than recursing in Rust.
40- `value.rs`: `Value`. Two kinds of laziness exist purely for large-repo performance and must stay invisible to the language: `Value::Thunk` (a commit's `files` list, forced by `Value::field`/`value_eq`) and `BlobContent::Lazy` (file bytes; lazy blobs compare by content id without being read). Prefer comparing ids/tree hashes over forcing content.
40- `value.rs`: `Value`. Two kinds of laziness exist purely for large-repo performance and must stay invisible to the language: `Value::Thunk` (a commit's `files` list, forced by `Value::field`/`value_eq`) and `BlobContent::Lazy` (file bytes; lazy blobs compare by content id without being read). Prefer comparing ids/tree hashes over forcing content.
41+ Recursive `let` environments can form ownership cycles through their closures. The cycle collector traces their shared values while preserving externally held values, and runs during scope allocation, after evaluation, and when an interpreter is dropped. Keep its ownership traversal in sync with changes to values and function wrappers; `tests/env_lifetimes.rs` covers reclamation and escaping closures.
41- `shape.rs`: shapes from `config.j` type declarations, and runtime **contracts** compiled from signatures — checked at every call, one level deep (kind, list-ness, field names). Records are structural: a value *is* a `Commit` iff its field set matches.
42- `shape.rs`: shapes from `config.j` type declarations, and runtime **contracts** compiled from signatures — checked at every call, one level deep (kind, list-ness, field names). Records are structural: a value *is* a `Commit` iff its field set matches.
42- `builtins.rs`: native implementations of the names `config.j` declares without defining.
43- `builtins.rs`: native implementations of the names `config.j` declares without defining.
43- `config.rs`: parses and validates `config.j`, orders definitions by dependency, resolves `@id` literals. Four definitions are read by the binary itself: `user`, `tree`, `immutable`, `labelled`.
44- `config.rs`: parses and validates `config.j`, orders definitions by dependency, resolves `@id` literals. Four definitions are read by the binary itself: `user`, `tree`, `immutable`, `labelled`.
config.j+1−1
@@ -155,7 +155,7 @@ subtreeCommits : a -> [Commit] -- every commit of a tree in preorder
155meta : Id -> Meta -- git hash, author, email, committer time;
155meta : Id -> Meta -- git hash, author, email, committer time;
156 -- crash for a commit not yet persisted
156 -- crash for a commit not yet persisted
157validate : Repo -> Repo -- the repo unchanged, or the crash persisting
157validate : Repo -> Repo -- the repo unchanged, or the crash persisting
158− -- it would produce (shape, labels, immutability,
158+ -- it would produce (shape, root, labels, immutability,
159 -- focus). `tree . validate . rebase trunk` is an
159 -- focus). `tree . validate . rebase trunk` is an
160 -- honest dry run.
160 -- honest dry run.
161diff : Blob -> Blob -> Text -- unified diff from the first to the second
161diff : Blob -> Blob -> Text -- unified diff from the first to the second
docs/base.md+5−3
@@ -301,9 +301,11 @@ Defined on `Int` only; crash on anything else. Non-associative (cannot chain:
301show : a -> Text
301show : a -> Text
302```
302```
303
303
304−Renders any value in the language's literal syntax, such that (except for
304+Renders values in the language's literal syntax, such that (except for
305closures and unresolved blobs) the text parses and evaluates back to an equal
305closures and unresolved blobs) the text parses and evaluates back to an equal
306+value. Resolved binary, executable, and symlink blobs have no equal literal;
307+`show` crashes on them, even inside other values. A top-level `Text` prints
306−value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
308+raw, so `j show …` is how you inspect values.
307See §5.2 of the spec for the exact rendering.
309See §5.2 of the spec for the exact rendering.
308
310
309```
311```
@@ -513,7 +515,7 @@ validate : Repo -> Repo
513```
515```
514
516
515Returns the repo unchanged if persisting it would succeed; otherwise crashes
517Returns the repo unchanged if persisting it would succeed; otherwise crashes
516−with exactly the error persistence would produce (shape, labels, immutability,
518+with exactly the error persistence would produce (shape, root, labels, immutability,
517focus). `tree . validate . rebase trunk` is an honest dry run.
519focus). `tree . validate . rebase trunk` is an honest dry run.
518
520
519### `diff` and `difft` — rendering differences
521### `diff` and `difft` — rendering differences
docs/language.md+5−3
@@ -696,10 +696,12 @@ unchecked except through the functions they call.
696
696
697## 12. `show`: rendering values as text
697## 12. `show`: rendering values as text
698
698
699−`show v` renders any value as an expression in the language, such that
699+`show v` renders values as expressions in the language, such that
700(except for closures and unresolved blobs) the result parses and evaluates
700(except for closures and unresolved blobs) the result parses and evaluates
701+back to an equal value. Resolved binary, executable, and symlink blobs have
702+no equal literal, so `show` crashes on them, even inside lists or records.
701−back to an equal value. Since `show` returns a `Text`, and a top-level `Text`
703+Since `show` returns a `Text`, and a top-level `Text` prints raw, `j show …`
702−prints raw, `j show …` is how you inspect values.
704+is how you inspect values.
703
705
704```
706```
705show 42 => "42"
707show 42 => "42"
specs/main.md+30−18
@@ -40,7 +40,8 @@ echo EXPRESSION | j
40 `::`, `or`, `.`, and the literals `%name`, `@id`, `./a/b` are all safe
40 `::`, `or`, `.`, and the literals `%name`, `@id`, `./a/b` are all safe
41 unquoted.
41 unquoted.
423. If arguments are present and rule 1 also applies, exit 2 with
423. If arguments are present and rule 1 also applies, exit 2 with
43− `j: expression given both as arguments and on stdin`.
43+ `j: expression given both as arguments and on stdin`. One byte is
44+ sufficient to reject the invocation; the pipe need not reach EOF.
444. If neither is present, or the input is only whitespace, print a concise
454. If neither is present, or the input is only whitespace, print a concise
45 usage and getting-started guide to stderr and exit 2. The guide points to
46 usage and getting-started guide to stderr and exit 2. The guide points to
46 `j help`, `j commands`, and commands for starting or inspecting a repository.
47 `j help`, `j commands`, and commands for starting or inspecting a repository.
@@ -491,7 +492,9 @@ negation. Ordering operators are defined on `Int` only and crash otherwise.
491
492
492`if`, `or`, `&&`, and `||` do not evaluate all operands. `&&` and `||`
493`if`, `or`, `&&`, and `||` do not evaluate all operands. `&&` and `||`
493short-circuit. The interpreter recognises `&&` and `||` by name; `or` is a
494short-circuit. The interpreter recognises `&&` and `||` by name; `or` is a
494−keyword.
495+keyword. Like other builtins, `&&` and `||` must be declared. Their contracts
496+check each evaluated operand and the final result; a skipped operand is
497+neither evaluated nor checked.
495
498
496`a or b` evaluates `a`. If that crashes, the crash is discarded and `b` is
499`a or b` evaluates `a`. If that crashes, the crash is discarded and `b` is
497evaluated and returned. Otherwise, if the value of `a` is a function, `b` is
500evaluated and returned. Otherwise, if the value of `a` is a function, `b` is
@@ -567,7 +570,7 @@ as in §4.13.
567| `treeWith o r` | the history rendered as a tree with options record `o`; §7.11 |
570| `treeWith o r` | the history rendered as a tree with options record `o`; §7.11 |
568| `extract S v` | every subvalue of `v` that is an `S`; §4.12 |
571| `extract S v` | every subvalue of `v` that is an `S`; §4.12 |
569| `meta i` | `{ hash, author, email, time }` for the visible commit with change id `i`: its current git hash, author name and email, committer time. Crashes for an id with no stored commit (one minted in this program) |
572| `meta i` | `{ hash, author, email, time }` for the visible commit with change id `i`: its current git hash, author name and email, committer time. Crashes for an id with no stored commit (one minted in this program) |
570−| `validate repo` | `repo` unchanged if persisting it would succeed; otherwise the crash persistence would raise (§7.5 steps 1–3 and 6). Persistence runs the same checks |
573+| `validate repo` | `repo` unchanged if persisting it would succeed; otherwise the crash persistence would raise (§7.5 steps 1–3, the root invariant of 4, and 6). Persistence runs the same checks |
571
574
572### 4.10 Ids: existing and new
575### 4.10 Ids: existing and new
573
576
@@ -795,13 +798,16 @@ j: crash: expected one revision, got 0
795
798
796### 5.2 `show`
799### 5.2 `show`
797
800
798−`show v` renders `v` as an expression in the language, such that for every
801+`show v` renders `v` as an expression in the language. Resolved blobs that
802+are not UTF-8 regular files cannot be represented by `blob "…"`: `show`
803+crashes on binary, executable, or symlink blobs, including inside other
804+values, instead of substituting different contents or file types. For every
799−value except closures, unresolved blobs, and values nested past the parser's
805+value it renders except closures, unresolved blobs, and values nested past
800−bound (§3.4: a list 40,000 deep, a record about 20,000), `show v` parses and
806+the parser's bound (§3.4: a list 40,000 deep, a record about 20,000), `show v`
801−evaluates to a value equal to `v` under the same `config.j`. It never uses
807+parses and evaluates to a value equal to `v` under the same `config.j`. It
802−colour and never truncates. A value holding a part the store cannot supply (a
808+never uses colour and never truncates. A value holding a part the store
803−`files` list or a file's content, §7.2) crashes `show` as it crashes any other
809+cannot supply (a `files` list or a file's content, §7.2) crashes `show` as it
804−use: `show` writes no stand-in for the part.
810+crashes any other use: `show` writes no stand-in for the part.
805
811
806| kind | rendering |
812| kind | rendering |
807|---|---|
813|---|---|
@@ -811,7 +817,7 @@ use: `show` writes no stand-in for the part.
811| list | `[v1 v2 …]` |
817| list | `[v1 v2 …]` |
812| record | `{ a = v, b = w }`, fields in ascending lexicographic order |
818| record | `{ a = v, b = w }`, fields in ascending lexicographic order |
813| `Id` | `@` followed by the full id, e.g. `@wqztkpqxmnrvyxskptlmzzzzabcd` |
819| `Id` | `@` followed by the full id, e.g. `@wqztkpqxmnrvyxskptlmzzzzabcd` |
814−| resolved `Blob` | `blob "…"` |
820+| resolved UTF-8 regular `Blob` | `blob "…"`; other resolved blobs crash |
815| unresolved `Blob` | `{- unresolved -} blob "…"` with the conflict-marker rendering |
821| unresolved `Blob` | `{- unresolved -} blob "…"` with the conflict-marker rendering |
816| builtin | its declared name, operators parenthesised: `map`, `(++)` |
822| builtin | its declared name, operators parenthesised: `map`, `(++)` |
817| selector | the selector itself: `.id` |
823| selector | the selector itself: `.id` |
@@ -854,7 +860,10 @@ Expression evaluation, `push`, and `fetch` load the configuration; `init` and
854`user` is evaluated; `Id` literals are not resolved and no other definition
860`user` is evaluated; `Id` literals are not resolved and no other definition
855is evaluated. `remote`, `undo`, `redo`, and `ops` do not load configuration.
861is evaluated. `remote`, `undo`, `redo`, and `ops` do not load configuration.
856When a command needs configuration and none exists, the binary writes its
862When a command needs configuration and none exists, the binary writes its
857−embedded reference `config.j` (§9) to that path as an editable starting point.
863+embedded reference `config.j` (§9) to that path as an editable starting point,
864+readable and writable by its owner, and reports its path. An existing file is
865+never overwritten, including when it cannot be read or contains invalid
866+UTF-8; such errors exit 3.
858An existing user configuration is not updated when the binary changes. The
867An existing user configuration is not updated when the binary changes. The
859reference file is also distributed at `<prefix>/share/j/config.j`.
868reference file is also distributed at `<prefix>/share/j/config.j`.
860
869
@@ -1255,8 +1264,9 @@ language: `fetch` and `push` are the only things that change them.
1255
1264
1256 Duplicates are merged; two records with the same `name` and different
1265 Duplicates are merged; two records with the same `name` and different
1257 effects are a crash. It is refused (exit 1, nothing pushed) if:
1266 effects are a crash. It is refused (exit 1, nothing pushed) if:
1258− - a `name` is not a valid git branch name, an `id` is not a visible commit,
1267+ - a `name` is not a valid git branch name, an `id` is not a visible commit
1268+ or names the synthetic root (which has no Git commit), or a delete names
1259− or a delete names a bookmark the remote does not have;
1269+ a bookmark the remote does not have;
1260 - any commit that would be sent has an unresolved file or an empty
1270 - any commit that would be sent has an unresolved file or an empty
1261 description (the commits that would be sent are the records' `id`s and
1271 description (the commits that would be sent are the records' `id`s and
1262 their ancestors that no bookmark on `origin` reaches as of the last
1272 their ancestors that no bookmark on `origin` reaches as of the last
@@ -1426,9 +1436,11 @@ configuration error.
1426`difft p a b` runs difftastic. It is the only builtin that executes another
1436`difft p a b` runs difftastic. It is the only builtin that executes another
1427program.
1437program.
1428
1438
1429−1. Write the content of `a` and `b` to two temporary files whose names end in
1439+1. Write the content of `a` and `b` to files in separate `old` and `new`
1430− the last component of `p` (so difftastic's language detection sees the
1440+ subdirectories of a fresh temporary directory. Each file keeps the last
1431− real filename), inside a fresh temporary directory.
1441+ component of `p` unchanged, so valid names stay within the filesystem's
1442+ length limit and difftastic sees the real filename. A last component
1443+ that is not a filename is a crash; an empty path uses `file`.
14322. Run `difft OLD NEW` with the process's stdin closed and its environment
14442. Run `difft OLD NEW` with the process's stdin closed and its environment
1433 inherited unchanged, except: if `DFT_COLOR` is unset and `j`'s stdout is a
1445 inherited unchanged, except: if `DFT_COLOR` is unset and `j`'s stdout is a
1434 terminal, set `DFT_COLOR=always`; if `DFT_WIDTH` is unset and `j`'s stdout
1446 terminal, set `DFT_COLOR=always`; if `DFT_WIDTH` is unset and `j`'s stdout
@@ -1764,7 +1776,7 @@ subtreeCommits : a -> [Commit] -- every commit of a tree in preorder
1764meta : Id -> Meta -- git hash, author, email, committer time;
1776meta : Id -> Meta -- git hash, author, email, committer time;
1765 -- crash for a commit not yet persisted
1777 -- crash for a commit not yet persisted
1766validate : Repo -> Repo -- the repo unchanged, or the crash persisting
1778validate : Repo -> Repo -- the repo unchanged, or the crash persisting
1767− -- it would produce (shape, labels, immutability,
1779+ -- it would produce (shape, root, labels, immutability,
1768 -- focus). `tree . validate . rebase trunk` is an
1780 -- focus). `tree . validate . rebase trunk` is an
1769 -- honest dry run.
1781 -- honest dry run.
1770diff : Blob -> Blob -> Text -- unified diff from the first to the second
1782diff : Blob -> Blob -> Text -- unified diff from the first to the second
src/eval.rs+78−45
@@ -47,6 +47,19 @@ pub struct Interp {
47 sorted_ids: RefCell<Option<Rc<Vec<String>>>>,
47 sorted_ids: RefCell<Option<Rc<Vec<String>>>>,
48}
48}
49
49
50+impl Drop for Interp {
51+ fn drop(&mut self) {
52+ // These are the interpreter's language-value roots. Release them
53+ // before collecting, so config definitions and cached closures do
54+ // not keep their recursive environments alive between invocations.
55+ self.globals = Env::empty();
56+ self.old_repo.get_mut().take();
57+ self.given_repo.get_mut().take();
58+ self.revsets.get_mut().clear();
59+ crate::value::collect_cycles();
60+ }
61+}
62+
50/// length of the common leading run of two ids (ids are ASCII, so bytes)
63/// length of the common leading run of two ids (ids are ASCII, so bytes)
51fn common_prefix_len(a: &str, b: &str) -> usize {
64fn common_prefix_len(a: &str, b: &str) -> usize {
52 a.bytes()
65 a.bytes()
@@ -104,11 +117,14 @@ pub(crate) enum Cont {
104 env: Env,
117 env: Env,
105 cont: Rc<Cont>,
118 cont: Rc<Cont>,
106 },
119 },
107− /// short-circuit ops with the unevaluated rhs
120+ /// `&&` or `||` after applying its first operand normally, so the
108− And(Rc<Expr>, Env, Rc<Cont>),
121+ /// declared contract has checked it; the rhs is still unevaluated
109− Or2(Rc<Expr>, Env, Rc<Cont>),
122+ ShortCircuit {
110− /// check the rhs of && / || evaluated to a Bool
111− ExpectBool(&'static str, Rc<Cont>),
123+ op: &'static str,
124+ rhs: Rc<Expr>,
125+ env: Env,
126+ cont: Rc<Cont>,
127+ },
112 /// `or` after both evaluated as functions: build the lifted function
128 /// `or` after both evaluated as functions: build the lifted function
113 OrBoth(Value, Rc<Cont>),
129 OrBoth(Value, Rc<Cont>),
114 /// a shared continuation (used when both `or` outcomes continue the same way)
130 /// a shared continuation (used when both `or` outcomes continue the same way)
@@ -258,9 +274,7 @@ fn parent_rc(k: &Cont) -> Option<&Rc<Cont>> {
258 Cont::Fun(_, k) => Some(k),
274 Cont::Fun(_, k) => Some(k),
259 Cont::If(_, _, _, k) => Some(k),
275 Cont::If(_, _, _, k) => Some(k),
260 Cont::LetRest { cont, .. } => Some(cont),
276 Cont::LetRest { cont, .. } => Some(cont),
261− Cont::And(_, _, k) => Some(k),
277+ Cont::ShortCircuit { cont, .. } => Some(cont),
262− Cont::Or2(_, _, k) => Some(k),
263− Cont::ExpectBool(_, k) => Some(k),
264 Cont::OrBoth(_, k) => Some(k),
278 Cont::OrBoth(_, k) => Some(k),
265 Cont::Shared(k) => Some(k),
279 Cont::Shared(k) => Some(k),
266 Cont::ApplyTo(_, k) => Some(k),
280 Cont::ApplyTo(_, k) => Some(k),
@@ -609,10 +623,14 @@ impl Interp {
609 }
623 }
610
624
611 fn run(&mut self, st: State) -> EResult {
625 fn run(&mut self, st: State) -> EResult {
612− match self.trampoline(st) {
626+ let result = match self.trampoline(st) {
613 Ok(v) => Ok(v),
627 Ok(v) => Ok(v),
614 Err(c) => Err(self.decorate(c)),
628 Err(c) => Err(self.decorate(c)),
615− }
629+ };
630+ // Keep a returned closure alive as a root while reclaiming local
631+ // recursive environments abandoned by the completed evaluation.
632+ crate::value::collect_cycles();
633+ result
616 }
634 }
617
635
618 fn trampoline(&mut self, st: State) -> EResult {
636 fn trampoline(&mut self, st: State) -> EResult {
@@ -776,29 +794,26 @@ impl Interp {
776 Cont::Arg(x.clone(), env, Rc::new(k)),
794 Cont::Arg(x.clone(), env, Rc::new(k)),
777 )),
795 )),
778 Expr::BinOp(op, a, b) => {
796 Expr::BinOp(op, a, b) => {
779− if *op == "&&" {
780− return Run::Step(State::Eval(
781− a.clone(),
782− env.clone(),
783− Cont::And(b.clone(), env, Rc::new(k)),
784− ));
785− }
786− if *op == "||" {
787− return Run::Step(State::Eval(
788− a.clone(),
789− env.clone(),
790− Cont::Or2(b.clone(), env, Rc::new(k)),
791− ));
792− }
793 let fv = match env.lookup(op) {
797 let fv = match env.lookup(op) {
794 Some(v) => v,
798 Some(v) => v,
795 None => return Run::Crash(Crash::new(format!("unbound name `{}`", op))),
799 None => return Run::Crash(Crash::new(format!("unbound name `{}`", op))),
796 };
800 };
797− // eval a, apply the operator to it, then apply the result to b
801+ let next = if *op == "&&" || *op == "||" {
802+ Cont::ShortCircuit {
803+ op,
804+ rhs: b.clone(),
805+ env: env.clone(),
806+ cont: Rc::new(k),
807+ }
808+ } else {
809+ Cont::Arg(b.clone(), env.clone(), Rc::new(k))
810+ };
811+ // Every operator must be declared, and its first operand
812+ // is checked by ordinary application, including && and ||.
798 Run::Step(State::Eval(
813 Run::Step(State::Eval(
799 a.clone(),
814 a.clone(),
800− env.clone(),
815+ env,
801− Cont::Fun(fv, Rc::new(Cont::Arg(b.clone(), env, Rc::new(k)))),
816+ Cont::Fun(fv, Rc::new(next)),
802 ))
817 ))
803 }
818 }
804 Expr::Or(a, b) => {
819 Expr::Or(a, b) => {
@@ -913,24 +928,42 @@ impl Interp {
913 ))
928 ))
914 }
929 }
915 }
930 }
916− Cont::And(b, env, k) => match v {
931+ Cont::ShortCircuit { op, rhs, env, cont } => {
917− Value::Bool(false) => Run::Step(State::Ret(Value::Bool(false), (*k).clone())),
932+ // Operator names are reserved: resolving && or || and
918− Value::Bool(true) => Run::Step(State::Eval(b, env, Cont::ExpectBool("&&", k))),
933+ // applying one operand always gives its builtin partial.
919− _ => Run::Crash(Crash::new(format!("&&: expected Bool, got a {}", v.kind_name()))),
934+ let (lhs, pending) = match &v {
920− },
935+ Value::Fun(f) => match &**f {
921− Cont::Or2(b, env, k) => match v {
936+ FunVal::Builtin { args, pending, .. } if args.len() == 1 => {
922− Value::Bool(true) => Run::Step(State::Ret(Value::Bool(true), (*k).clone())),
937+ (&args[0], pending)
923− Value::Bool(false) => Run::Step(State::Eval(b, env, Cont::ExpectBool("||", k))),
938+ }
924− _ => Run::Crash(Crash::new(format!("||: expected Bool, got a {}", v.kind_name()))),
939+ _ => return Run::Crash(Crash::new("internal: invalid boolean operator")),
940+ },
941+ _ => return Run::Crash(Crash::new("internal: invalid boolean operator")),
925− },
942+ };
926− Cont::ExpectBool(op, k) => match v {
943+ let lhs = match lhs {
927− Value::Bool(_) => Run::Step(State::Ret(v, (*k).clone())),
944+ Value::Bool(b) => *b,
928− _ => Run::Crash(Crash::new(format!(
945+ other => return Run::Crash(Crash::new(format!(
929− "{}: expected Bool, got a {}",
946+ "{}: expected Bool, got a {}", op, other.kind_name()
930− op,
947+ ))),
931− v.kind_name()
948+ };
932− ))),
949+ if lhs == (op == "||") {
950+ let result = Value::Bool(lhs);
951+ if let Some((name, c, _)) = pending {
952+ // Skipping an operand must not evaluate or check
953+ // it, but the operator still returns a checked
954+ // result after its two parameter positions (§4.13).
955+ let c = crate::shape::ContractExpr::apply_first_rc(c.clone());
956+ if let Err(cr) = c.check_result(&self.shapes, name, &result) {
957+ return Run::Crash(self.blame_builtin(cr, op, name));
958+ }
959+ }
960+ Run::Step(State::Ret(result, (*cont).clone()))
961+ } else {
962+ // The normal application path checks the rhs and the
963+ // builtin's result, with the same argument numbering.
964+ Run::Step(State::Eval(rhs, env, Cont::Fun(v, cont)))
965+ }
933− },
966+ }
934 Cont::OrBoth(a, k) => {
967 Cont::OrBoth(a, k) => {
935 if matches!(v, Value::Fun(_)) {
968 if matches!(v, Value::Fun(_)) {
936 Run::Step(State::Ret(Value::Fun(Rc::new(FunVal::OrFun(a, v))), (*k).clone()))
969 Run::Step(State::Ret(Value::Fun(Rc::new(FunVal::OrFun(a, v))), (*k).clone()))
src/jj.rs+24−15
@@ -69,6 +69,7 @@ fn toml_string(s: &str) -> String {
69 '\n' => out.push_str("\\n"),
69 '\n' => out.push_str("\\n"),
70 '\r' => out.push_str("\\r"),
70 '\r' => out.push_str("\\r"),
71 '\t' => out.push_str("\\t"),
71 '\t' => out.push_str("\\t"),
72+ c if c.is_ascii_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
72 c => out.push(c),
73 c => out.push(c),
73 }
74 }
74 }
75 }
@@ -3407,6 +3408,11 @@ fn parse_push_records(
3407 let rec = vis.commits.get(&change_id).ok_or_else(|| {
3408 let rec = vis.commits.get(&change_id).ok_or_else(|| {
3408 (1, format!("push: `@{}` is not a visible commit", change_id))
3409 (1, format!("push: `@{}` is not a visible commit", change_id))
3409 })?;
3410 })?;
3411+ // jj's root has no Git object: its all-zero commit id is
3412+ // interpreted by `git push` as a deletion, not a target.
3413+ if rec.commit.id() == repo.store().root_commit_id() {
3414+ return Err((1, "push: the root commit cannot be pushed".to_string()));
3415+ }
3410 out.push((name, Some(rec.commit.id().clone())));
3416 out.push((name, Some(rec.commit.id().clone())));
3411 }
3417 }
3412 None => {
3418 None => {
@@ -3618,23 +3624,11 @@ fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<Stri
3618
3624
3619/// git-check-ref-format --branch rules
3625/// git-check-ref-format --branch rules
3620fn is_valid_git_branch_name(name: &str) -> bool {
3626fn is_valid_git_branch_name(name: &str) -> bool {
3621− if name.is_empty()
3622− || name.starts_with('-')
3627+ if name.starts_with('-') {
3623− || name.starts_with('.')
3624− || name.ends_with('.')
3625− || name.ends_with('/')
3626− || name.ends_with(".lock")
3627− || name.contains("..")
3628− || name.contains("//")
3629− || name.contains("@{")
3630− || name == "@"
3631− {
3632 return false;
3628 return false;
3633 }
3629 }
3634− name.chars().all(|c| {
3630+ let full_name = format!("refs/heads/{name}");
3635− !(c.is_ascii_control()
3631+ gix::validate::reference::branch_name(full_name.as_str().into()).is_ok()
3636− || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\'))
3637− })
3638}
3632}
3639
3633
3640// ----------------------------------------------------------------------
3634// ----------------------------------------------------------------------
@@ -4726,6 +4720,21 @@ mod tests {
4726 assert!(cache.blobs.get(&id).is_none());
4720 assert!(cache.blobs.get(&id).is_none());
4727 }
4721 }
4728
4722
4723+ #[test]
4724+ fn user_settings_preserve_every_ascii_control_character() {
4725+ let identity: String = (0u8..=127).map(char::from).collect();
4726+ let text = format!(
4727+ "[user]\nname = {}\nemail = {}\n",
4728+ toml_string(&identity),
4729+ toml_string(&identity)
4730+ );
4731+ let mut config = StackedConfig::with_defaults();
4732+ config.add_layer(ConfigLayer::parse(ConfigSource::User, &text).unwrap());
4733+ let settings = UserSettings::from_config(config).unwrap();
4734+ assert_eq!(settings.user_name(), identity);
4735+ assert_eq!(settings.user_email(), identity);
4736+ }
4737+
4729 #[test]
4738 #[test]
4730 fn a_description_names_its_terms_whatever_their_labels_and_the_sides_that_cancel() {
4739 fn a_description_names_its_terms_whatever_their_labels_and_the_sides_that_cancel() {
4731 // what a checkout writes for a symlink/file conflict of five terms,
4740 // what a checkout writes for a symlink/file conflict of five terms,
src/main.rs+31−9
@@ -72,18 +72,22 @@ fn load_config_file() -> Result<(Config, String), u8> {
72 let path = config_path();
72 let path = config_path();
73 let src = match std::fs::read_to_string(&path) {
73 let src = match std::fs::read_to_string(&path) {
74 Ok(s) => s,
74 Ok(s) => s,
75− Err(_) => {
75+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
76 // No user config yet: materialise the default there, editable by
76 // No user config yet: materialise the default there, editable by
77 // the current user, so they have a starting point to customise.
77 // the current user, so they have a starting point to customise.
78− if let Err(e) = write_default_config(&path) {
78+ match write_default_config(&path) {
79+ Ok(()) => j::report!("j: created an editable default config at {}", path),
80+ // Another invocation may have created it after our read.
81+ // Read that file below, without overwriting it.
82+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
79− return Err(fail(3, format!("cannot create config at {}: {}", path, e)));
83+ Err(e) => return Err(fail(3, format!("cannot create config at {}: {}", path, e))),
80 }
84 }
81− j::report!("j: created an editable default config at {}", path);
82 match std::fs::read_to_string(&path) {
85 match std::fs::read_to_string(&path) {
83 Ok(s) => s,
86 Ok(s) => s,
84 Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
87 Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
85 }
88 }
86 }
89 }
90+ Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
87 };
91 };
88 match config::load_config(&src) {
92 match config::load_config(&src) {
89 Ok(c) => Ok((c, src)),
93 Ok(c) => Ok((c, src)),
@@ -94,17 +98,19 @@ fn load_config_file() -> Result<(Config, String), u8> {
94/// Write the embedded default config to `path`, creating parent directories
98/// Write the embedded default config to `path`, creating parent directories
95/// and making the file readable and writable by the current user.
99/// and making the file readable and writable by the current user.
96fn write_default_config(path: &str) -> std::io::Result<()> {
100fn write_default_config(path: &str) -> std::io::Result<()> {
101+ use std::io::Write;
97 if let Some(parent) = std::path::Path::new(path).parent() {
102 if let Some(parent) = std::path::Path::new(path).parent() {
98 std::fs::create_dir_all(parent)?;
103 std::fs::create_dir_all(parent)?;
99 }
104 }
100− std::fs::write(path, DEFAULT_CONFIG)?;
105+ let mut options = std::fs::OpenOptions::new();
106+ options.write(true).create_new(true);
101 // user-read+write (0o600 on unix): the file is meant to be edited by them
107 // user-read+write (0o600 on unix): the file is meant to be edited by them
102 #[cfg(unix)]
108 #[cfg(unix)]
103 {
109 {
104− use std::os::unix::fs::PermissionsExt;
110+ use std::os::unix::fs::OpenOptionsExt;
105− std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
111+ options.mode(0o600);
106 }
112 }
107− Ok(())
113+ options.open(path)?.write_all(DEFAULT_CONFIG.as_bytes())
108}
114}
109
115
110const RESERVED: &[&str] = &[
116const RESERVED: &[&str] = &[
@@ -227,7 +233,10 @@ fn run() -> ExitCode {
227 if std::io::stdin().is_terminal() {
233 if std::io::stdin().is_terminal() {
228 false
234 false
229 } else if args.is_empty() || stdin_is_ready(50) {
235 } else if args.is_empty() || stdin_is_ready(50) {
236+ // With arguments, a single byte proves the invocation invalid.
237+ // Waiting for EOF would hang while a pipe's writer remains open.
238+ let limit = if args.is_empty() { u64::MAX } else { 1 };
230− match std::io::stdin().read_to_end(&mut stdin_content) {
239+ match std::io::stdin().take(limit).read_to_end(&mut stdin_content) {
231 Ok(n) => n > 0,
240 Ok(n) => n > 0,
232 Err(_) => false,
241 Err(_) => false,
233 }
242 }
@@ -537,3 +546,16 @@ fn is_repo_value(v: &Value) -> bool {
537 .collect();
546 .collect();
538 root_fields == want_root
547 root_fields == want_root
539}
548}
549+
550+#[cfg(test)]
551+mod tests {
552+ #[test]
553+ fn default_config_does_not_replace_a_file_created_since_the_read() {
554+ let dir = tempfile::tempdir().unwrap();
555+ let path = dir.path().join("config.j");
556+ std::fs::write(&path, "user-owned configuration").unwrap();
557+ let error = super::write_default_config(path.to_str().unwrap()).unwrap_err();
558+ assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
559+ assert_eq!(std::fs::read_to_string(path).unwrap(), "user-owned configuration");
560+ }
561+}
src/repo.rs+22−1
@@ -382,7 +382,8 @@ pub fn same_repo(a: &Value, b: &Value) -> Result<bool, Crash> {
382}
382}
383
383
384// ----------------------------------------------------------------------
384// ----------------------------------------------------------------------
385−// validation (§7.5 steps 1–3 and 6), shared by `validate` and persistence
385+// validation (§7.5 steps 1–3, the root invariant of 4, and 6), shared by
386+// `validate` and persistence
386// ----------------------------------------------------------------------
387// ----------------------------------------------------------------------
387
388
388#[derive(Debug)]
389#[derive(Debug)]
@@ -417,9 +418,29 @@ pub fn validate_repo(i: &mut Interp, new: &Value) -> Result<Validated, Crash> {
417 "the focus must be a mutable commit; compose with new",
418 "the focus must be a mutable commit; compose with new",
418 ));
419 ));
419 }
420 }
421+ validate_root(old.as_ref(), new)?;
420 Ok(Validated { immutable })
422 Ok(Validated { immutable })
421}
423}
422
424
425+/// The root is fixed even when the configured immutable set is empty.
426+/// Compare with the loaded root, preserving synthetic backends' contents
427+/// and the lazy files of a root that has not changed.
428+fn validate_root(old: Option<&Value>, new: &Value) -> Result<(), Crash> {
429+ let root = top_of(new)?.field("root")?;
430+ if id_of(&root)? != ROOT_ID {
431+ return Err(Crash::new("persistence: the root commit must be the top"));
432+ }
433+ if let Some(old) = old {
434+ let stored = top_of(old)?.field("root")?;
435+ if !value_eq(&stored.field("message")?, &root.field("message")?)?
436+ || !same_files(&stored, &root)?
437+ {
438+ return Err(Crash::new("persistence: the root commit cannot be changed"));
439+ }
440+ }
441+ Ok(())
442+}
443+
423fn check_repo_shape(v: &Value) -> Result<(), Crash> {
444fn check_repo_shape(v: &Value) -> Result<(), Crash> {
424 let fields = v
445 let fields = v
425 .field_set()
446 .field_set()
src/show.rs+35−19
@@ -1,7 +1,7 @@
1//! `show` (§5.2), unified diff (§4.9 `diff`), and the difftastic bridge (§7.10).
1//! `show` (§5.2), unified diff (§4.9 `diff`), and the difftastic bridge (§7.10).
2
2
3use crate::eval::Interp;
3use crate::eval::Interp;
4−use crate::value::{BlobContent, BlobVal, Crash, FunVal, ThunkVal, Value};
4+use crate::value::{BlobContent, BlobKind, BlobVal, Crash, FunVal, ThunkVal, Value};
5use num_bigint::BigInt;
5use num_bigint::BigInt;
6use unicode_width::UnicodeWidthChar;
6use unicode_width::UnicodeWidthChar;
7
7
@@ -85,23 +85,30 @@ fn render_int(n: &BigInt, out: &mut String) {
85
85
86#[inline(never)]
86#[inline(never)]
87fn render_blob(b: &BlobVal, out: &mut String) -> Result<(), Crash> {
87fn render_blob(b: &BlobVal, out: &mut String) -> Result<(), Crash> {
88− match &b.content {
88+ // Conflicts deliberately show their markers, without promising a round
89− BlobContent::Resolved(bytes) => match std::str::from_utf8(bytes) {
89+ // trip (§5.2). Every resolved blob must instead have an equal literal:
90− Ok(s) => {
90+ // `blob` constructs only a regular UTF-8 file, so other kinds and bytes
91− out.push_str("blob ");
91+ // cannot be silently replaced by a different blob.
92− push_text_literal(s, out);
92+ if b.is_unresolved() {
93− }
94− Err(_) => out.push_str("blob \"<binary>\""),
95− },
96− BlobContent::Lazy(_) | BlobContent::Conflict(_) => {
97− let bytes = b.bytes()?;
93+ let bytes = b.bytes()?;
98− if b.is_unresolved() {
99− out.push_str("{- unresolved -} ");
94+ out.push_str("{- unresolved -} blob ");
100− }
101− out.push_str("blob ");
102− push_text_literal(&String::from_utf8_lossy(&bytes), out);
95+ push_text_literal(&String::from_utf8_lossy(&bytes), out);
103− }
96+ return Ok(());
104 }
97 }
98+ match b.kind {
99+ BlobKind::Regular => {}
100+ BlobKind::Executable => return Err(Crash::new("show: cannot represent an executable blob")),
101+ BlobKind::Symlink => return Err(Crash::new("show: cannot represent a symlink blob")),
102+ }
103+ let bytes = match &b.content {
104+ BlobContent::Resolved(bytes) => bytes.clone(),
105+ BlobContent::Lazy(lazy) => lazy.force()?,
106+ BlobContent::Conflict(_) => unreachable!("handled above"),
107+ };
108+ let text = std::str::from_utf8(&bytes)
109+ .map_err(|_| Crash::new("show: cannot represent a non-UTF-8 blob"))?;
110+ out.push_str("blob ");
111+ push_text_literal(text, out);
105 Ok(())
112 Ok(())
106}
113}
107
114
@@ -436,6 +443,9 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
436 .map(|c| c.as_text().map(|s| s.to_string()))
443 .map(|c| c.as_text().map(|s| s.to_string()))
437 .transpose()?
444 .transpose()?
438 .unwrap_or_else(|| "file".to_string());
445 .unwrap_or_else(|| "file".to_string());
446+ if std::path::Path::new(&last).file_name().and_then(|n| n.to_str()) != Some(last.as_str()) {
447+ return Err(Crash::new("difft: expected a filename as the last path component"));
448+ }
439 let a_bytes = match a {
449 let a_bytes = match a {
440 Value::Blob(bl) => bl.bytes()?,
450 Value::Blob(bl) => bl.bytes()?,
441 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
451 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
@@ -446,8 +456,14 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
446 };
456 };
447 let dir = tempfile::tempdir()
457 let dir = tempfile::tempdir()
448 .map_err(|e| Crash::new(format!("difft: cannot create temp dir: {}", e)))?;
458 .map_err(|e| Crash::new(format!("difft: cannot create temp dir: {}", e)))?;
459+ // Keep the filename unchanged: adding a prefix can exceed NAME_MAX for
460+ // a valid tracked path, and some languages are detected by exact name.
449− let old = dir.path().join(format!("old-{}", last));
461+ let old_dir = dir.path().join("old");
450− let new = dir.path().join(format!("new-{}", last));
462+ let new_dir = dir.path().join("new");
463+ std::fs::create_dir(&old_dir).map_err(|e| Crash::new(format!("difft: {}", e)))?;
464+ std::fs::create_dir(&new_dir).map_err(|e| Crash::new(format!("difft: {}", e)))?;
465+ let old = old_dir.join(&last);
466+ let new = new_dir.join(&last);
451 std::fs::write(&old, &a_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
467 std::fs::write(&old, &a_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
452 std::fs::write(&new, &b_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
468 std::fs::write(&new, &b_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
453 let mut cmd = std::process::Command::new("difft");
469 let mut cmd = std::process::Command::new("difft");
src/value.rs+314−14
@@ -3,7 +3,7 @@
3use crate::ast::{Expr, Pattern};
3use crate::ast::{Expr, Pattern};
4use num_bigint::BigInt;
4use num_bigint::BigInt;
5use std::collections::{BTreeMap, BTreeSet, HashMap};
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6−use std::rc::Rc;
6+use std::rc::{Rc, Weak};
7
7
8pub type RecordMap = BTreeMap<String, Value>;
8pub type RecordMap = BTreeMap<String, Value>;
9
9
@@ -764,10 +764,10 @@ pub struct Env {
764 frame: Option<Rc<Frame>>,
764 frame: Option<Rc<Frame>>,
765}
765}
766
766
767−/// A large acyclic let can have one frame per binding. Release unique
767+/// A large acyclic let or a collected recursive cycle can release a long
768+/// chain of parent frames. Release unique frames iteratively, keeping the
768−/// frames iteratively, keeping the next parent alive while its child's
769+/// next parent alive while its child's values drop, so closures capturing
769−/// values drop, so closures capturing earlier frames cannot recurse down
770+/// earlier frames cannot recurse down that chain either (§4.1).
770−/// that chain either (§4.1).
771impl Drop for Env {
771impl Drop for Env {
772 fn drop(&mut self) {
772 fn drop(&mut self) {
773 let mut next = self.frame.take();
773 let mut next = self.frame.take();
@@ -820,16 +820,20 @@ impl Env {
820 /// extend with a recursive frame (for `let` blocks) binding `names`,
820 /// extend with a recursive frame (for `let` blocks) binding `names`,
821 /// whose values are pushed to the returned cell in the same order
821 /// whose values are pushed to the returned cell in the same order
822 pub fn extend_rec(&self, names: Rc<[String]>) -> (Env, Rc<std::cell::RefCell<Vec<Value>>>) {
822 pub fn extend_rec(&self, names: Rc<[String]>) -> (Env, Rc<std::cell::RefCell<Vec<Value>>>) {
823+ // Collect before allocating: `self` and all values the evaluator is
824+ // using are still ordinary strong roots during this check.
825+ maybe_collect_cycles();
823 let cell = Rc::new(std::cell::RefCell::new(Vec::with_capacity(names.len())));
826 let cell = Rc::new(std::cell::RefCell::new(Vec::with_capacity(names.len())));
824− (
825− Env {
826− frame: Some(Rc::new(Frame {
827+ let frame = Rc::new(Frame {
827− kind: FrameKind::Rec(names, cell.clone()),
828+ kind: FrameKind::Rec(names, cell.clone()),
828− parent: self.clone(),
829+ parent: self.clone(),
829− })),
830+ });
830− },
831+ RECURSIVE_FRAMES.with(|registry| {
831− cell,
832+ let mut registry = registry.borrow_mut();
832− )
833+ registry.frames.push(Rc::downgrade(&frame));
834+ registry.allocations += 1;
835+ });
836+ (Env { frame: Some(frame) }, cell)
833 }
837 }
834
838
835 pub fn lookup(&self, name: &str) -> Option<Value> {
839 pub fn lookup(&self, name: &str) -> Option<Value> {
@@ -903,6 +907,302 @@ impl Env {
903 }
907 }
904}
908}
905
909
910+// A recursive environment owns its bindings, and a closure in those bindings
911+// may own the environment again. Weak captures would lose an escaping
912+// closure's bindings, so these are real strong references. Collect their
913+// unreachable cycles by subtracting the references inside their owned graph
914+// from each node's reference count. References from outside that graph are
915+// roots: returned values, the interpreter, and the running machine's states
916+// and continuations. No explicit evaluator root stack is needed.
917+//
918+// Only recursive frames can introduce cycles in language values. The
919+// registry is weak, and all traversal and marking below use explicit work
920+// lists, including through shared lists, records, and function wrappers.
921+#[derive(Default)]
922+struct RecursiveFrames {
923+ frames: Vec<Weak<Frame>>,
924+ allocations: usize,
925+ next_collection: usize,
926+ without_environments: HashMap<(u8, usize), EnvironmentFree>,
927+ cached_since_pruning: usize,
928+ collecting: bool,
929+}
930+
931+thread_local! {
932+ static RECURSIVE_FRAMES: std::cell::RefCell<RecursiveFrames> =
933+ std::cell::RefCell::new(RecursiveFrames::default());
934+}
935+
936+const MIN_CYCLE_BATCH: usize = 256;
937+
938+fn maybe_collect_cycles() {
939+ let due = RECURSIVE_FRAMES.with(|registry| {
940+ let registry = registry.borrow();
941+ !registry.collecting
942+ && registry.allocations >= registry.next_collection.max(MIN_CYCLE_BATCH)
943+ });
944+ if due {
945+ collect_cycles();
946+ }
947+}
948+
949+/// Reclaim dead recursive environments. The caller must retain any result
950+/// it wants to return while this runs, as an ordinary strong reference.
951+pub(crate) fn collect_cycles() {
952+ RECURSIVE_FRAMES.with(|shared| {
953+ let mut registry = {
954+ let mut registry = shared.borrow_mut();
955+ if registry.collecting { return; }
956+ let previous = std::mem::take(&mut *registry);
957+ registry.collecting = true;
958+ previous
959+ };
960+ // Do not hold the registry borrow while dropping values. Opaque
961+ // Rust backend captures can run destructors that allocate frames or
962+ // drop another interpreter; such nested collections are deferred.
963+ let mut graph = CycleGraph::default();
964+ for frame in &registry.frames {
965+ if let Some(frame) = frame.upgrade() {
966+ graph.add(CycleNode::Frame(frame));
967+ }
968+ }
969+ graph.trace(&registry.without_environments);
970+ registry.cached_since_pruning += graph.cache_environment_free(&mut registry.without_environments);
971+ graph.collect();
972+ // Release the collector's temporary roots before pruning the weak
973+ // registry. Live recursive frames set the next batch size so a
974+ // growing set of escaping closures costs amortized linear work.
975+ drop(graph);
976+ registry.frames.retain(|frame| frame.strong_count() != 0);
977+ registry.allocations = 0;
978+ registry.next_collection = registry.frames.len().max(MIN_CYCLE_BATCH);
979+ // Immutable repository trees usually hold no functions. Remember
980+ // that once, without keeping their values alive. Pruning is batched
981+ // too: a large, live tree must not be scanned on every collection.
982+ if registry.frames.is_empty() {
983+ registry.without_environments.clear();
984+ registry.cached_since_pruning = 0;
985+ } else if registry.cached_since_pruning >=
986+ (registry.without_environments.len() / 2).max(MIN_CYCLE_BATCH)
987+ {
988+ registry.without_environments.retain(|_, cached| cached.is_alive());
989+ registry.cached_since_pruning = 0;
990+ }
991+ let mut pending = shared.borrow_mut();
992+ registry.frames.append(&mut pending.frames);
993+ registry.allocations += pending.allocations;
994+ *pending = registry;
995+ });
996+}
997+
998+// These containers are immutable while shared. Weak references both avoid
999+// extending their lives and prevent address reuse from giving a false hit.
1000+// Rc::make_mut dissociates weak pointers before it can change a container.
1001+enum EnvironmentFree {
1002+ List(Weak<Vec<Value>>),
1003+ Record(Weak<RecordMap>),
1004+}
1005+
1006+impl EnvironmentFree {
1007+ fn is_alive(&self) -> bool {
1008+ match self {
1009+ Self::List(v) => v.strong_count() != 0,
1010+ Self::Record(v) => v.strong_count() != 0,
1011+ }
1012+ }
1013+}
1014+
1015+enum CycleNode {
1016+ Frame(Rc<Frame>),
1017+ Bindings(Rc<std::cell::RefCell<Vec<Value>>>),
1018+ Function(Rc<FunVal>),
1019+ List(Rc<Vec<Value>>),
1020+ Record(Rc<RecordMap>),
1021+}
1022+
1023+impl CycleNode {
1024+ fn key(&self) -> (u8, usize) {
1025+ match self {
1026+ Self::Frame(v) => (0, Rc::as_ptr(v) as usize),
1027+ Self::Bindings(v) => (1, Rc::as_ptr(v) as usize),
1028+ Self::Function(v) => (2, Rc::as_ptr(v) as usize),
1029+ Self::List(v) => (3, Rc::as_ptr(v) as usize),
1030+ Self::Record(v) => (4, Rc::as_ptr(v) as usize),
1031+ }
1032+ }
1033+
1034+ fn strong_count(&self) -> usize {
1035+ match self {
1036+ Self::Frame(v) => Rc::strong_count(v),
1037+ Self::Bindings(v) => Rc::strong_count(v),
1038+ Self::Function(v) => Rc::strong_count(v),
1039+ Self::List(v) => Rc::strong_count(v),
1040+ Self::Record(v) => Rc::strong_count(v),
1041+ }
1042+ }
1043+
1044+ fn children(&self) -> Vec<CycleNode> {
1045+ let mut out = Vec::new();
1046+ let mut value = |v: &Value| match v {
1047+ Value::Fun(f) => out.push(Self::Function(f.clone())),
1048+ // A list view owns its whole backing vector, including the
1049+ // prefix that `tail`/`drop` no longer exposes.
1050+ Value::List(xs) => out.push(Self::List(xs.items.clone())),
1051+ Value::Record(fields) => out.push(Self::Record(fields.clone())),
1052+ // Backend thunks are opaque Rust closures. Their references,
1053+ // if any, remain external roots; never guess what they own.
1054+ _ => {}
1055+ };
1056+ match self {
1057+ Self::Frame(frame) => {
1058+ match &frame.kind {
1059+ FrameKind::Small(bindings) => {
1060+ for (_, v) in bindings { value(v); }
1061+ }
1062+ FrameKind::Big(bindings) => {
1063+ for v in bindings.values() { value(v); }
1064+ }
1065+ FrameKind::Rec(_, bindings) => {
1066+ out.push(Self::Bindings(bindings.clone()));
1067+ }
1068+ }
1069+ if let Some(parent) = &frame.parent.frame {
1070+ out.push(Self::Frame(parent.clone()));
1071+ }
1072+ }
1073+ Self::Bindings(bindings) => {
1074+ for v in bindings.borrow().iter() { value(v); }
1075+ }
1076+ Self::Function(f) => {
1077+ match f.as_ref() {
1078+ FunVal::Closure { env, applied_args, .. } => {
1079+ for v in applied_args { value(v); }
1080+ if let Some(frame) = &env.frame {
1081+ out.push(Self::Frame(frame.clone()));
1082+ }
1083+ }
1084+ FunVal::Builtin { args, .. } => {
1085+ for v in args { value(v); }
1086+ }
1087+ FunVal::OrFun(a, b) | FunVal::ComposeLazy(a, b) => {
1088+ value(a);
1089+ value(b);
1090+ }
1091+ FunVal::Labelled(_, f) => value(f),
1092+ }
1093+ }
1094+ Self::List(items) => {
1095+ for v in items.iter() { value(v); }
1096+ }
1097+ Self::Record(fields) => {
1098+ for v in fields.values() { value(v); }
1099+ }
1100+ }
1101+ out
1102+ }
1103+}
1104+
1105+#[derive(Default)]
1106+struct CycleGraph {
1107+ // Exactly one temporary strong reference per node. All temporary edge
1108+ // clones are consumed by `add` before reference counts are examined.
1109+ nodes: Vec<CycleNode>,
1110+ indices: HashMap<(u8, usize), usize>,
1111+ edges: Vec<Vec<usize>>,
1112+ incoming: Vec<usize>,
1113+}
1114+
1115+impl CycleGraph {
1116+ fn add(&mut self, node: CycleNode) -> usize {
1117+ let key = node.key();
1118+ if let Some(&index) = self.indices.get(&key) {
1119+ return index;
1120+ }
1121+ let index = self.nodes.len();
1122+ self.indices.insert(key, index);
1123+ self.nodes.push(node);
1124+ self.edges.push(Vec::new());
1125+ self.incoming.push(0);
1126+ index
1127+ }
1128+
1129+ fn trace(&mut self, without_environments: &HashMap<(u8, usize), EnvironmentFree>) {
1130+ let mut index = 0;
1131+ while index < self.nodes.len() {
1132+ for child in self.nodes[index].children() {
1133+ if without_environments.contains_key(&child.key()) {
1134+ continue;
1135+ }
1136+ let next = self.add(child);
1137+ self.edges[index].push(next);
1138+ self.incoming[next] += 1;
1139+ }
1140+ index += 1;
1141+ }
1142+ }
1143+
1144+ fn cache_environment_free(&self, cache: &mut HashMap<(u8, usize), EnvironmentFree>) -> usize {
1145+ let mut parents = vec![Vec::new(); self.nodes.len()];
1146+ for (index, edges) in self.edges.iter().enumerate() {
1147+ for &next in edges { parents[next].push(index); }
1148+ }
1149+ let mut reaches_frame = vec![false; self.nodes.len()];
1150+ let mut work = Vec::new();
1151+ for (index, node) in self.nodes.iter().enumerate() {
1152+ if matches!(node, CycleNode::Frame(_)) {
1153+ reaches_frame[index] = true;
1154+ work.push(index);
1155+ }
1156+ }
1157+ while let Some(index) = work.pop() {
1158+ for &parent in &parents[index] {
1159+ if !reaches_frame[parent] {
1160+ reaches_frame[parent] = true;
1161+ work.push(parent);
1162+ }
1163+ }
1164+ }
1165+ let mut added = 0;
1166+ for (index, node) in self.nodes.iter().enumerate() {
1167+ if !reaches_frame[index] {
1168+ let cached = match node {
1169+ CycleNode::List(v) => EnvironmentFree::List(Rc::downgrade(v)),
1170+ CycleNode::Record(v) => EnvironmentFree::Record(Rc::downgrade(v)),
1171+ _ => continue,
1172+ };
1173+ if cache.insert(node.key(), cached).is_none() { added += 1; }
1174+ }
1175+ }
1176+ added
1177+ }
1178+
1179+ fn collect(&self) {
1180+ let mut reachable = vec![false; self.nodes.len()];
1181+ let mut work = Vec::new();
1182+ for (index, node) in self.nodes.iter().enumerate() {
1183+ if node.strong_count() > self.incoming[index] + 1 {
1184+ reachable[index] = true;
1185+ work.push(index);
1186+ }
1187+ }
1188+ while let Some(index) = work.pop() {
1189+ for &next in &self.edges[index] {
1190+ if !reachable[next] {
1191+ reachable[next] = true;
1192+ work.push(next);
1193+ }
1194+ }
1195+ }
1196+ for (index, node) in self.nodes.iter().enumerate() {
1197+ if !reachable[index] {
1198+ if let CycleNode::Bindings(bindings) = node {
1199+ bindings.borrow_mut().clear();
1200+ }
1201+ }
1202+ }
1203+ }
1204+}
1205+
906// ----------------------------------------------------------------------
1206// ----------------------------------------------------------------------
907// structural equality (§4.5); comparing functions is a crash
1207// structural equality (§4.5); comparing functions is a crash
908// ----------------------------------------------------------------------
1208// ----------------------------------------------------------------------
tests/cli.rs+80−2
@@ -190,6 +190,40 @@ fn both_stdin_and_args_rejected() {
190 assert!(out.stderr.contains("both"), "{}", out.stderr);
190 assert!(out.stderr.contains("both"), "{}", out.stderr);
191}
191}
192
192
193+#[test]
194+fn conflicting_stdin_is_rejected_without_waiting_for_eof() {
195+ use std::io::Write;
196+ use std::time::{Duration, Instant};
197+ let r = setup();
198+ let (reader, mut writer) = std::io::pipe().unwrap();
199+ writer.write_all(b"1").unwrap();
200+ let mut child = Command::new(j_bin())
201+ .arg("describe \"must not run\"")
202+ .current_dir(&r.dir)
203+ .env("XDG_CONFIG_HOME", &r.cfg)
204+ .stdin(reader)
205+ .stdout(Stdio::piped())
206+ .stderr(Stdio::piped())
207+ .spawn()
208+ .unwrap();
209+ let deadline = Instant::now() + Duration::from_secs(5);
210+ while child.try_wait().unwrap().is_none() {
211+ if Instant::now() >= deadline {
212+ child.kill().unwrap();
213+ child.wait().unwrap();
214+ panic!("conflicting stdin waited for the still-open writer");
215+ }
216+ std::thread::sleep(Duration::from_millis(10));
217+ }
218+ let out = child.wait_with_output().unwrap();
219+ // Keep the writer alive until after the command has exited.
220+ drop(writer);
221+ assert_eq!(out.status.code(), Some(2));
222+ assert_eq!(String::from_utf8(out.stderr).unwrap(),
223+ "j: expression given both as arguments and on stdin\n");
224+ assert_eq!(r.j(&["\\r -> show (focus r).message"]).ok().stdout, "\"\"\n");
225+}
226+
193#[test]
227#[test]
194fn an_expression_that_is_not_utf8_is_a_usage_error() {
228fn an_expression_that_is_not_utf8_is_a_usage_error() {
195 // §1: an argument that was not UTF-8 (a path with a Latin-1 byte)
229 // §1: an argument that was not UTF-8 (a path with a Latin-1 byte)
@@ -416,9 +450,26 @@ fn validate_catches_what_persistence_refuses() {
416 let r = setup();
450 let r = setup();
417 r.write("a.txt", "x\n");
451 r.write("a.txt", "x\n");
418 r.j(&["describe \"work\""]).ok();
452 r.j(&["describe \"work\""]).ok();
453+ let before = r.j(&["ops"]).ok().stdout.lines().count();
419− // tree . validate . squash is an honest dry run
454+ let preview = r.j(&["tree . validate . squash"]);
455+ let persist = r.j(&["squash"]);
456+ assert_eq!(preview.code, 1, "{}", preview.stdout);
457+ assert_eq!(persist.code, 1, "{}", persist.stderr);
458+ assert_eq!(preview.stderr.lines().next(), persist.stderr.lines().next());
459+ assert!(preview.stderr.contains("the root commit cannot be changed"));
460+ assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), before);
461+ assert_eq!(r.read("a.txt"), "x\n");
462+
463+ // Once the parent is an ordinary commit, both preview and persistence
464+ // succeed, and the preview itself still records nothing.
465+ r.j(&["new"]).ok();
466+ r.write("b.txt", "y\n");
467+ r.j(&["describe \"second\""]).ok();
468+ let before = r.j(&["ops"]).ok().stdout.lines().count();
420 let out = r.j(&["tree . validate . squash"]).ok();
469 let out = r.j(&["tree . validate . squash"]).ok();
421 assert!(!out.stdout.is_empty());
470 assert!(!out.stdout.is_empty());
471+ assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), before);
472+ r.j(&["squash"]).ok();
422}
473}
423
474
424#[test]
475#[test]
@@ -649,7 +700,7 @@ fn review_says_no_changes_only_when_nothing_changed() {
649 std::fs::write(&stub, "#!/bin/sh\nprintf '%s -> %s\\n' \"${1##*/}\" \"${2##*/}\"\n").unwrap();
700 std::fs::write(&stub, "#!/bin/sh\nprintf '%s -> %s\\n' \"${1##*/}\" \"${2##*/}\"\n").unwrap();
650 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
701 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
651 r.write("g", "world\n");
702 r.write("g", "world\n");
652− assert_eq!(review(&bin).ok().stdout, "old-f -> new-f\nold-g -> new-g\n");
703+ assert_eq!(review(&bin).ok().stdout, "f -> f\ng -> g\n");
653}
704}
654
705
655#[test]
706#[test]
@@ -3268,6 +3319,33 @@ fn missing_config_is_created_editable() {
3268 let _ = std::fs::remove_dir_all(&dir);
3319 let _ = std::fs::remove_dir_all(&dir);
3269}
3320}
3270
3321
3322+#[test]
3323+fn unreadable_config_is_not_replaced_with_defaults() {
3324+ let r = setup();
3325+ let path = r.cfg.join("j/config.j");
3326+ let mut original = std::fs::read(&path).unwrap();
3327+ original.extend_from_slice(b"\n-- non-UTF-8 comment: \xff\n");
3328+ std::fs::write(&path, &original).unwrap();
3329+ let out = r.j(&["1 + 1"]);
3330+ assert_eq!(out.code, 3, "{}", out.stderr);
3331+ assert!(out.stderr.starts_with("j: cannot read config at "), "{}", out.stderr);
3332+ assert!(out.stdout.is_empty());
3333+ assert_eq!(std::fs::read(&path).unwrap(), original);
3334+}
3335+
3336+#[test]
3337+fn missing_config_symlink_target_is_not_created() {
3338+ let r = setup();
3339+ let path = r.cfg.join("j/config.j");
3340+ let target = r.cfg.join("missing-config.j");
3341+ std::fs::remove_file(&path).unwrap();
3342+ std::os::unix::fs::symlink(&target, &path).unwrap();
3343+ let out = r.j(&["1 + 1"]);
3344+ assert_eq!(out.code, 3, "{}", out.stderr);
3345+ assert!(std::fs::symlink_metadata(&path).unwrap().file_type().is_symlink());
3346+ assert!(!target.exists());
3347+}
3348+
3271// ----------------------------------------------------------------------
3349// ----------------------------------------------------------------------
3272// regressions
3350// regressions
3273// ----------------------------------------------------------------------
3351// ----------------------------------------------------------------------
tests/difft.rsnew file+63
@@ -0,0 +1,63 @@
1+//! Difftastic receives valid filenames without changing their basenames.
2+
3+#![cfg(unix)]
4+
5+use std::os::unix::fs::PermissionsExt;
6+use std::process::{Command, Output};
7+
8+const CONFIG: &str = include_str!("../config.j");
9+
10+#[test]
11+fn difft_preserves_long_and_special_filenames() {
12+ let temp = tempfile::tempdir().unwrap();
13+ let repo = temp.path().join("repo");
14+ let config = temp.path().join("config");
15+ let bin = temp.path().join("bin");
16+ std::fs::create_dir(&repo).unwrap();
17+ std::fs::create_dir_all(config.join("j")).unwrap();
18+ std::fs::create_dir(&bin).unwrap();
19+ std::fs::write(config.join("j/config.j"), CONFIG).unwrap();
20+ let stub = bin.join("difft");
21+ std::fs::write(&stub, r#"#!/bin/sh
22+case "$1" in */old/*) ;; *) exit 10 ;; esac
23+case "$2" in */new/*) ;; *) exit 11 ;; esac
24+[ "${1##*/}" = "${2##*/}" ] || exit 12
25+[ "$1" != "$2" ] || exit 13
26+IFS= read -r before < "$1"
27+IFS= read -r after < "$2"
28+printf '%s|%s|%s\n' "${1##*/}" "$before" "$after"
29+"#).unwrap();
30+ std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
31+ let run = |expr: &str| -> Output {
32+ Command::new(env!("CARGO_BIN_EXE_j"))
33+ .arg(expr)
34+ .current_dir(&repo)
35+ .env("XDG_CONFIG_HOME", &config)
36+ .env("PATH", &bin)
37+ .env("NO_COLOR", "1")
38+ .output()
39+ .unwrap()
40+ };
41+ let init = run("init");
42+ assert!(init.status.success(), "{}", String::from_utf8_lossy(&init.stderr));
43+ for name in [
44+ format!("{}.rs", "x".repeat(252)), // NAME_MAX = 255 bytes, including the suffix
45+ "Makefile".to_string(),
46+ "file with spaces.rs".to_string(),
47+ ] {
48+ let expr = format!("difft [{}] (blob \"before\\n\") (blob \"after\\n\")", j::show::text_literal(&name));
49+ let result = run(&expr);
50+ assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
51+ assert_eq!(String::from_utf8(result.stdout).unwrap(), format!("{}|before|after\n", name));
52+ }
53+ // Paths are structural lists at run time. A malformed last component
54+ // must not let `join` put temporary blob contents outside their directory.
55+ let escaped = temp.path().join("escaped");
56+ for name in ["", ".", "..", "../escaped", escaped.to_str().unwrap()] {
57+ let expr = format!("difft [{}] (blob \"before\") (blob \"after\")", j::show::text_literal(name));
58+ let result = run(&expr);
59+ assert_eq!(result.status.code(), Some(1));
60+ assert!(String::from_utf8_lossy(&result.stderr).contains("last path component"));
61+ }
62+ assert!(!escaped.exists());
63+}
tests/env_lifetimes.rsnew file+215
@@ -0,0 +1,215 @@
1+//! Recursive bindings retain escaping closures, but not unreachable cycles.
2+
3+use j::config;
4+use j::domain::MemBackend;
5+use j::eval::Interp;
6+use j::parse::parse_expr;
7+use j::value::{Crash, Env, FunVal, Value};
8+use std::cell::RefCell;
9+use std::rc::{Rc, Weak};
10+
11+thread_local! {
12+ static OBSERVED: RefCell<Vec<Weak<FunVal>>> = const { RefCell::new(Vec::new()) };
13+ static PEAK_ALIVE: RefCell<usize> = const { RefCell::new(0) };
14+}
15+
16+fn observe(_: &mut Interp, args: &[Value]) -> Result<Value, Crash> {
17+ let Value::Fun(f) = &args[0] else { panic!("observe needs a function") };
18+ OBSERVED.with(|observed| {
19+ let mut observed = observed.borrow_mut();
20+ observed.push(Rc::downgrade(f));
21+ let alive = observed.iter().filter(|f| f.strong_count() != 0).count();
22+ PEAK_ALIVE.with(|peak| {
23+ let mut peak = peak.borrow_mut();
24+ *peak = (*peak).max(alive);
25+ });
26+ });
27+ Ok(args[0].clone())
28+}
29+
30+fn interpreter() -> (Interp, config::Config) {
31+ OBSERVED.with(|observed| observed.borrow_mut().clear());
32+ PEAK_ALIVE.with(|peak| *peak.borrow_mut() = 0);
33+ let cfg = config::load_config(include_str!("../config.j")).unwrap();
34+ let mut interp = Interp::new(Rc::new(MemBackend::new()), cfg.shapes.clone(), Env::empty());
35+ config::eval_config(&mut interp, &cfg).unwrap();
36+ interp.globals = interp.globals.extend(vec![("observe".into(), Value::Fun(Rc::new(FunVal::Builtin {
37+ name: "observe".into(), arity: 1, args: vec![], f: observe, pending: None,
38+ })))]);
39+ (interp, cfg)
40+}
41+
42+fn eval(interp: &mut Interp, cfg: &config::Config, src: &str) -> Value {
43+ let expr = parse_expr(src, Rc::new(cfg.global_names.clone())).unwrap();
44+ interp.eval(&Rc::new(expr), &interp.global_env()).unwrap()
45+}
46+
47+fn alive() -> usize {
48+ OBSERVED.with(|observed| observed.borrow().iter().filter(|f| f.strong_count() != 0).count())
49+}
50+
51+#[test]
52+fn unreachable_local_closures_and_their_payloads_are_reclaimed() {
53+ let (mut interp, cfg) = interpreter();
54+ let function = eval(&mut interp, &cfg, "let helper = observe (\\x -> x) in helper");
55+ assert_eq!(alive(), 1);
56+ drop(function);
57+ eval(&mut interp, &cfg, "0");
58+ assert_eq!(alive(), 0);
59+
60+ // A recursive helper captures the block's shared frame, including a
61+ // payload such as a large snapshot, until the dead cycle is collected.
62+ eval(&mut interp, &cfg,
63+ "let payload = range 0 10000; helper = observe (\\x -> \
64+ if x == 0 then payload else helper (x - 1)) in 0");
65+ assert_eq!(alive(), 0);
66+}
67+
68+#[test]
69+fn mutually_recursive_closures_escape_inside_shared_records_and_lists() {
70+ let (mut interp, cfg) = interpreter();
71+ let bundle = eval(&mut interp, &cfg,
72+ "let evenLocal = observe (\\n -> if n == 0 then true else \
73+ let scratch = observe (\\v -> v) in oddLocal (n - 1)); \
74+ oddLocal = observe (\\n -> if n == 0 then false else evenLocal (n - 1)) \
75+ in { callbacks = [evenLocal oddLocal evenLocal] }");
76+ eval(&mut interp, &cfg, "0");
77+ let callbacks = bundle.field("callbacks").unwrap();
78+ let function = callbacks.as_list().unwrap()[0].clone();
79+ drop(bundle);
80+ drop(callbacks);
81+ assert!(matches!(interp.apply(function.clone(), Value::int(2048)).unwrap(), Value::Bool(true)));
82+ assert_eq!(alive(), 2);
83+ drop(function);
84+ eval(&mut interp, &cfg, "0");
85+ assert_eq!(alive(), 0);
86+}
87+
88+#[test]
89+fn nested_captures_and_partially_applied_functions_keep_their_scopes() {
90+ let (mut interp, cfg) = interpreter();
91+ let function = eval(&mut interp, &cfg,
92+ "let maker = \\n -> let recurse = observe (\\m -> \
93+ if m == 0 then n else recurse (m - 1)) \
94+ in \\addend -> recurse 1000 + addend in maker 40");
95+ eval(&mut interp, &cfg, "0");
96+ assert!(matches!(interp.apply(function.clone(), Value::int(2)).unwrap(), Value::Int(n) if n == 42.into()));
97+ drop(function);
98+ eval(&mut interp, &cfg, "0");
99+ assert_eq!(alive(), 0);
100+
101+ let partial = eval(&mut interp, &cfg,
102+ "let helper = observe (\\a b -> a + b) in helper 40");
103+ eval(&mut interp, &cfg, "0");
104+ assert!(matches!(interp.apply(partial.clone(), Value::int(2)).unwrap(), Value::Int(n) if n == 42.into()));
105+ drop(partial);
106+ eval(&mut interp, &cfg, "0");
107+ assert_eq!(alive(), 0);
108+}
109+
110+#[test]
111+fn wrappers_and_list_views_preserve_all_owned_references() {
112+ let (mut interp, cfg) = interpreter();
113+ for expression in [
114+ "let helper = observe (\\x -> x); wrapper = helper . helper in wrapper",
115+ "let helper = observe (\\x -> x); wrapper = helper or helper in wrapper",
116+ "let helper = observe (\\x -> x); wrapper = const helper in wrapper 0",
117+ "let bundle = { callbacks = [(observe (\\n -> if n == 0 then 7 else \
118+ (head bundle.callbacks) (n - 1)))] } in head bundle.callbacks",
119+ ] {
120+ let function = eval(&mut interp, &cfg, expression);
121+ eval(&mut interp, &cfg, "0");
122+ interp.apply(function.clone(), Value::int(7)).unwrap();
123+ assert!(alive() > 0);
124+ drop(function);
125+ eval(&mut interp, &cfg, "0");
126+ assert_eq!(alive(), 0, "{expression}");
127+ }
128+
129+ let empty_view = eval(&mut interp, &cfg,
130+ "let helper = observe (\\x -> x); xs = [helper helper] in drop 2 xs");
131+ assert!(empty_view.as_list().unwrap().is_empty());
132+ eval(&mut interp, &cfg, "0");
133+ // An empty view still owns the two hidden entries in its backing Vec.
134+ assert_eq!(alive(), 1);
135+ drop(empty_view);
136+ eval(&mut interp, &cfg, "0");
137+ assert_eq!(alive(), 0);
138+}
139+
140+#[test]
141+fn long_running_tail_calls_collect_dead_local_bindings_during_evaluation() {
142+ let (mut interp, cfg) = interpreter();
143+ // Acyclic helpers use immutable frames and need no cycle collection.
144+ // Make each local helper recursive so this exercises collection while
145+ // the enclosing tail-recursive evaluation is still running.
146+ let result = eval(&mut interp, &cfg,
147+ "let loop = \\n -> if n == 0 then 0 else \
148+ let helper = observe (\\x -> if x == 0 then 0 else helper (x - 1)); payload = range 0 32 \
149+ in loop (n - 1) in loop 4000");
150+ assert!(matches!(result, Value::Int(n) if n == 0.into()));
151+ assert_eq!(alive(), 0);
152+ PEAK_ALIVE.with(|peak| assert!(*peak.borrow() < 1024, "dead closures accumulate: {}", peak.borrow()));
153+}
154+
155+#[test]
156+fn crashes_and_caught_crashes_release_abandoned_recursive_scopes() {
157+ let (mut interp, cfg) = interpreter();
158+ eval(&mut interp, &cfg,
159+ "(let helper = observe (\\x -> x) in crash \"discard\") or 42");
160+ assert_eq!(alive(), 0);
161+ let expr = parse_expr("let helper = observe (\\x -> x) in crash \"discard\"",
162+ Rc::new(cfg.global_names.clone())).unwrap();
163+ assert!(interp.eval(&Rc::new(expr), &interp.global_env()).is_err());
164+ assert_eq!(alive(), 0);
165+}
166+
167+#[test]
168+fn dropping_the_interpreter_reclaims_its_global_recursive_environment() {
169+ let (interp, _) = interpreter();
170+ let Value::Fun(f) = interp.globals.lookup("new").unwrap() else { panic!() };
171+ let weak = Rc::downgrade(&f);
172+ drop(f);
173+ drop(interp);
174+ assert_eq!(weak.strong_count(), 0);
175+}
176+
177+#[test]
178+fn returned_closures_outlive_the_interpreter_that_created_them() {
179+ let (mut interp, cfg) = interpreter();
180+ let function = eval(&mut interp, &cfg,
181+ "let recurse = observe (\\n -> if n == 0 then 42 else recurse (n - 1)) in recurse");
182+ drop(interp);
183+ assert_eq!(alive(), 1);
184+ let mut other = Interp::dummy();
185+ assert!(matches!(other.apply(function.clone(), Value::int(1000)).unwrap(), Value::Int(n) if n == 42.into()));
186+ drop(function);
187+ drop(other);
188+ assert_eq!(alive(), 0);
189+}
190+
191+#[test]
192+fn collecting_deep_recursive_parent_chains_uses_a_bounded_native_stack() {
193+ std::thread::Builder::new().stack_size(2 * 1024 * 1024).spawn(|| {
194+ use j::ast::{Expr, Pattern, Source};
195+ let interp = Interp::dummy();
196+ let mut env = Env::empty();
197+ let source = Source::new(Rc::from("\\x -> true"), 0, 10);
198+ let body = Rc::new(Expr::Bool(true));
199+ let mut observed = Vec::new();
200+ for _ in 0..10000 {
201+ let (next, cell) = env.extend_rec(Rc::from(vec!["recursive".to_owned()]));
202+ let function = Rc::new(FunVal::Closure {
203+ name: None, params: vec![Pattern::Var("x".to_owned())],
204+ applied: 0, applied_args: vec![], body: body.clone(),
205+ env: next.clone(), src: source.clone(), pending: None,
206+ });
207+ observed.push(Rc::downgrade(&function));
208+ cell.borrow_mut().push(Value::Fun(function));
209+ env = next;
210+ }
211+ drop(env);
212+ drop(interp);
213+ assert!(observed.iter().all(|f| f.strong_count() == 0));
214+ }).unwrap().join().unwrap();
215+}
tests/eval.rs+135−2
@@ -10,10 +10,14 @@ use std::rc::Rc;
10const CONFIG: &str = include_str!("../config.j");
10const CONFIG: &str = include_str!("../config.j");
11
11
12fn make_interp() -> (Interp, config::Config) {
12fn make_interp() -> (Interp, config::Config) {
13+ make_interp_from(CONFIG)
14+}
15+
16+fn make_interp_from(src: &str) -> (Interp, config::Config) {
13− let cfg = config::load_config(CONFIG).expect("reference config must load");
17+ let cfg = config::load_config(src).expect("config must load");
14 let backend = Rc::new(MemBackend::new());
18 let backend = Rc::new(MemBackend::new());
15 let mut interp = Interp::new(backend, cfg.shapes.clone(), Env::empty());
19 let mut interp = Interp::new(backend, cfg.shapes.clone(), Env::empty());
16− config::eval_config(&mut interp, &cfg).expect("reference config must evaluate");
20+ config::eval_config(&mut interp, &cfg).expect("config must evaluate");
17 (interp, cfg)
21 (interp, cfg)
18}
22}
19
23
@@ -127,6 +131,135 @@ fn short_circuit_booleans() {
127 assert!(crash(&mut i, &cfg, "false || 1").contains("Bool"));
131 assert!(crash(&mut i, &cfg, "false || 1").contains("Bool"));
128}
132}
129
133
134+fn boolean_config(op: &str, signature: Option<&str>) -> String {
135+ CONFIG
136+ .lines()
137+ .filter_map(|line| {
138+ if line.starts_with(&format!("({})", op)) {
139+ signature.map(|ty| format!("({}) : {}", op, ty))
140+ } else {
141+ Some(line.to_string())
142+ }
143+ })
144+ .collect::<Vec<_>>()
145+ .join("\n")
146+}
147+
148+#[test]
149+fn short_circuit_operators_must_be_declared() {
150+ for op in ["&&", "||"] {
151+ let (mut i, cfg) = make_interp_from(&boolean_config(op, None));
152+ for expr in [
153+ format!("false {} true", op),
154+ format!("true {} false", op),
155+ format!("({}) true false", op),
156+ format!("(true {}) false", op),
157+ format!("({} false) true", op),
158+ ] {
159+ assert_eq!(crash(&mut i, &cfg, &expr), format!("crash: unbound name `{}`", op));
160+ }
161+ }
162+}
163+
164+#[test]
165+fn short_circuit_contracts_check_only_evaluated_arguments() {
166+ for (op, skipped, reached) in [("&&", false, true), ("||", true, false)] {
167+ let (mut i, cfg) = make_interp_from(&boolean_config(op, Some("Text -> Bool -> Bool")));
168+ for lhs in [skipped, reached] {
169+ let expr = format!("{} {} crash \"rhs must stay unevaluated\"", lhs, op);
170+ assert_eq!(
171+ crash(&mut i, &cfg, &expr),
172+ format!("crash: contract: {} expected Text as argument 1, got Bool", op)
173+ );
174+ }
175+
176+ let (mut i, cfg) = make_interp_from(&boolean_config(op, Some("Bool -> Text -> Bool")));
177+ for expr in [
178+ format!("{} {} true", reached, op),
179+ format!("({} {}) true", reached, op),
180+ format!("({} true) {}", op, reached),
181+ ] {
182+ assert_eq!(
183+ crash(&mut i, &cfg, &expr),
184+ format!("crash: contract: {} expected Text as argument 2, got Bool", op)
185+ );
186+ }
187+ for expr in [
188+ format!("{} {} crash \"rhs must stay unevaluated\"", skipped, op),
189+ format!("({} crash \"rhs must stay unevaluated\") {}", op, skipped),
190+ format!("({} {}) true", skipped, op),
191+ ] {
192+ check!(i, cfg, &expr, Value::Bool(skipped));
193+ }
194+ // A declaration that allows every kind cannot remove the
195+ // operator's own requirement that its operands are booleans.
196+ let (mut i, cfg) = make_interp_from(&boolean_config(op, Some("a -> b -> c")));
197+ assert!(crash(&mut i, &cfg, &format!("1 {} true", op)).contains("expected Bool"));
198+ assert!(crash(&mut i, &cfg, &format!("{} {} 1", reached, op)).contains("expected Bool"));
199+ }
200+}
201+
202+#[test]
203+fn short_circuit_contracts_check_results_including_aliases() {
204+ for op in ["&&", "||"] {
205+ for signature in ["Bool -> Bool -> Text", "Switch"] {
206+ let src = format!(
207+ "{}\nSwitch = Bool -> Outcome\nOutcome = Bool -> Text\n",
208+ boolean_config(op, Some(signature))
209+ );
210+ let (mut i, cfg) = make_interp_from(&src);
211+ for lhs in [true, false] {
212+ for expr in [
213+ format!("{} {} true", lhs, op),
214+ format!("({} {}) true", lhs, op),
215+ format!("({} true) {}", op, lhs),
216+ ] {
217+ assert_eq!(
218+ crash(&mut i, &cfg, &expr),
219+ format!("crash: contract: {}: result expected Text, got Bool", op)
220+ );
221+ }
222+ }
223+ let skipped = op == "||";
224+ let expr = format!("{} {} crash \"rhs must stay unevaluated\"", skipped, op);
225+ assert_eq!(
226+ crash(&mut i, &cfg, &expr),
227+ format!("crash: contract: {}: result expected Text, got Bool", op)
228+ );
229+ check!(i, cfg, &format!("({}) or 9", expr), Value::int(9));
230+ }
231+ // A signature longer than the builtin's arity requires a
232+ // function result on both the skipped and the evaluated paths.
233+ let (mut i, cfg) = make_interp_from(&boolean_config(op, Some("Bool -> Bool -> Bool -> Bool")));
234+ for lhs in [true, false] {
235+ assert_eq!(
236+ crash(&mut i, &cfg, &format!("{} {} true", lhs, op)),
237+ format!("crash: contract: {} expected a function, got Bool", op)
238+ );
239+ }
240+ }
241+}
242+
243+#[test]
244+fn short_circuit_contract_crashes_name_the_executing_definition() {
245+ for op in ["&&", "||"] {
246+ let src = format!(
247+ "{}\nsigned : Bool -> Bool\nsigned = \\b -> b {} true\nplain = \\b -> b {} true\n",
248+ boolean_config(op, Some("Bool -> Bool -> Text")), op, op
249+ );
250+ let (mut i, cfg) = make_interp_from(&src);
251+ for lhs in [true, false] {
252+ for name in ["signed", "plain"] {
253+ let (msg, def) = crash_in(&mut i, &cfg, &format!("{} {}", name, lhs));
254+ assert_eq!(def.as_deref(), Some(name), "{}", msg);
255+ assert_eq!(msg, format!("contract: {}: result expected Text, got Bool", op));
256+ }
257+ let (_, def) = crash_in(&mut i, &cfg, &format!("{} {} true", lhs, op));
258+ assert_eq!(def, None);
259+ }
260+ }
261+}
262+
130#[test]
263#[test]
131fn if_requires_bool() {
264fn if_requires_bool() {
132 let (mut i, cfg) = make_interp();
265 let (mut i, cfg) = make_interp();
tests/interop.rs+10−6
@@ -168,17 +168,21 @@ fn jj_undo_interop_marker() {
168#[test]
168#[test]
169fn jj_reads_conflicts_written_by_j() {
169fn jj_reads_conflicts_written_by_j() {
170 let Some(t) = setup() else { return };
170 let Some(t) = setup() else { return };
171+ let j_ok = |expr: &str| {
172+ let (code, _, err) = t.j(&[expr]);
173+ assert_eq!(code, 0, "{}: {}", expr, err);
174+ };
171− t.j(&["init"]);
175+ j_ok("init");
172 std::fs::write(t.dir.join("f.txt"), "base\n").unwrap();
176 std::fs::write(t.dir.join("f.txt"), "base\n").unwrap();
173− t.j(&["describe \"base\""]);
177+ j_ok("describe \"base\"");
174 // side 1
178 // side 1
175− t.j(&["new"]);
179+ j_ok("new");
176 std::fs::write(t.dir.join("f.txt"), "one\n").unwrap();
180 std::fs::write(t.dir.join("f.txt"), "one\n").unwrap();
177− t.j(&["describe \"one\""]);
181+ j_ok("describe \"one\"");
178 // side 2 from the parent
182 // side 2 from the parent
179− t.j(&["new . goto (kids (top id))"]);
183+ j_ok("new . prev");
180 std::fs::write(t.dir.join("f.txt"), "two\n").unwrap();
184 std::fs::write(t.dir.join("f.txt"), "two\n").unwrap();
181− t.j(&["describe \"two\""]);
185+ j_ok("describe \"two\"");
182 // rebase two onto one: conflicts
186 // rebase two onto one: conflicts
183 let (code, _, err) = t.j(&["rebase (matching (\\c -> c.message == \"one\") all)"]);
187 let (code, _, err) = t.j(&["rebase (matching (\\c -> c.message == \"one\") all)"]);
184 assert_eq!(code, 0, "{}", err);
188 assert_eq!(code, 0, "{}", err);
tests/remote.rs+78
@@ -973,6 +973,84 @@ fn push_refusals() {
973 assert_eq!(out.code, 0, "{}", out.stderr);
973 assert_eq!(out.code, 0, "{}", out.stderr);
974}
974}
975
975
976+#[test]
977+fn push_refuses_the_root_without_deleting_a_bookmark() {
978+ let env = setup();
979+ let dest = env.dir.join("clone");
980+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
981+ std::fs::write(dest.join("a.txt"), "work\n").unwrap();
982+ env.j(&dest, &["describe \"work\""]).ok();
983+ env.j(&dest, &["push (label \"feature\" here)"]).ok();
984+ let refs = git(&env.remote, &["show-ref"]);
985+ let ops = env.j(&dest, &["ops"]).ok().stdout.lines().count();
986+ // The synthetic root has an all-zero Git id. Sending it as a target
987+ // deleted feature, then failed to record the deletion locally.
988+ for expr in [
989+ "push (label \"feature\" (here . top))",
990+ "push (\\r -> label \"another\" here r ++ label \"root\" (here . top) r)",
991+ ] {
992+ let out = env.j(&dest, &[expr]);
993+ assert_eq!(out.code, 1, "{}: {}", expr, out.stderr);
994+ assert!(out.stderr.contains("root commit cannot be pushed"), "{}", out.stderr);
995+ assert_eq!(git(&env.remote, &["show-ref"]), refs, "{} changed the remote", expr);
996+ assert_eq!(env.j(&dest, &["ops"]).ok().stdout.lines().count(), ops);
997+ }
998+ assert!(env.j(&dest, &["labelled \"feature\""]).ok().stdout.contains("work"));
999+}
1000+
1001+#[test]
1002+fn push_checks_every_branch_name_before_sending_updates() {
1003+ let env = setup();
1004+ let dest = env.dir.join("clone");
1005+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1006+ std::fs::write(dest.join("a.txt"), "work\n").unwrap();
1007+ env.j(&dest, &["describe \"work\""]).ok();
1008+ let refs = git(&env.remote, &["show-ref"]);
1009+ let ops = env.j(&dest, &["ops"]).ok().stdout.lines().count();
1010+ // HEAD was created remotely before its local export failed. Dot and
1011+ // .lock components also escaped the partial hand-written validation.
1012+ for name in ["HEAD", "topic/.hidden", "topic.lock/child", "-topic", "/topic", ""] {
1013+ let expr = format!(
1014+ "push (\\r -> label \"valid\" here r ++ label {} here r)",
1015+ j::show::text_literal(name)
1016+ );
1017+ let out = env.j(&dest, &[&expr]);
1018+ assert_eq!(out.code, 1, "{}: {}", name, out.stderr);
1019+ assert!(out.stderr.contains("not a valid git branch name"), "{}", out.stderr);
1020+ assert_eq!(git(&env.remote, &["show-ref"]), refs, "{} changed the remote", name);
1021+ assert_eq!(env.j(&dest, &["ops"]).ok().stdout.lines().count(), ops);
1022+ }
1023+ // Git permits @ as a branch and HEAD below another path component.
1024+ for name in ["@", "topic/HEAD"] {
1025+ env.j(&dest, &[&format!("push (label {} here)", j::show::text_literal(name))]).ok();
1026+ git(&env.remote, &["rev-parse", "--verify", &format!("refs/heads/{name}")]);
1027+ }
1028+}
1029+
1030+#[test]
1031+fn user_control_characters_survive_init_clone_and_open() {
1032+ let env = setup();
1033+ let name = "Test\u{1b}User\u{7f}Name";
1034+ let email = "test\u{1f}user@example.com";
1035+ let path = env.cfg.join("j/config.j");
1036+ let config = std::fs::read_to_string(&path).unwrap()
1037+ .replace("Test User", name)
1038+ .replace("test@example.com", email);
1039+ std::fs::write(path, config).unwrap();
1040+ // These are valid Text values, but writing their controls literally
1041+ // into the generated TOML used to panic before opening a repository.
1042+ let init = env.dir.join("init");
1043+ std::fs::create_dir(&init).unwrap();
1044+ env.j(&init, &["init"]).ok();
1045+ let clone = env.dir.join("clone");
1046+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), clone.to_str().unwrap()]).ok();
1047+ for dir in [&init, &clone] {
1048+ env.j(dir, &["describe \"work\""]).ok();
1049+ assert_eq!(env.j(dir, &["\\r -> (meta r.root.id).author"]).ok().stdout, format!("{name}\n"));
1050+ assert_eq!(env.j(dir, &["\\r -> (meta r.root.id).email"]).ok().stdout, format!("{email}\n"));
1051+ }
1052+}
1053+
976#[test]
1054#[test]
977fn push_of_an_empty_list_pushes_nothing() {
1055fn push_of_an_empty_list_pushes_nothing() {
978 // §7.6: an empty list of records sets and deletes no bookmark. It went to
1056 // §7.6: an empty list of records sets and deletes no bookmark. It went to
tests/show.rs+60−1
@@ -5,7 +5,7 @@ use j::domain::MemBackend;
5use j::eval::Interp;
5use j::eval::Interp;
6use j::parse::parse_expr;
6use j::parse::parse_expr;
7use j::show::{show, text_literal, unified_diff};
7use j::show::{show, text_literal, unified_diff};
8−use j::value::{value_eq, BlobContent, BlobKind, BlobVal, ConflictSide, Env, Value};
8+use j::value::{value_eq, BlobContent, BlobKind, BlobVal, ConflictSide, Env, LazyBlob, Value};
9use std::alloc::{GlobalAlloc, Layout, System};
9use std::alloc::{GlobalAlloc, Layout, System};
10use std::cell::Cell;
10use std::cell::Cell;
11use std::rc::Rc;
11use std::rc::Rc;
@@ -234,6 +234,65 @@ fn show_unresolved_blob() {
234 assert!(s.starts_with("{- unresolved -} blob"), "{}", s);
234 assert!(s.starts_with("{- unresolved -} blob"), "{}", s);
235}
235}
236
236
237+#[test]
238+fn show_refuses_resolved_blobs_without_an_equal_literal() {
239+ let (mut i, cfg) = make_interp();
240+ let catcher = parse_expr("\\x -> show x or \"caught\"", Rc::new(cfg.global_names.clone())).unwrap();
241+ let env = i.global_env();
242+ let catcher = i.eval(&Rc::new(catcher), &env).unwrap();
243+ for lazy in [false, true] {
244+ for (kind, bytes, message) in [
245+ (BlobKind::Regular, vec![0xff, 0xfe], "non-UTF-8"),
246+ (BlobKind::Executable, b"echo hi\n".to_vec(), "executable"),
247+ (BlobKind::Symlink, b"target".to_vec(), "symlink"),
248+ ] {
249+ let content = if lazy {
250+ BlobContent::Lazy(Rc::new(LazyBlob::new("stored".into(), move || Ok(Rc::new(bytes)))))
251+ } else {
252+ BlobContent::Resolved(Rc::new(bytes))
253+ };
254+ let blob = Value::Blob(Rc::new(BlobVal { kind, content }));
255+ // The failure propagates through nested snapshots and is caught
256+ // by the language, instead of yielding a damaged replacement.
257+ let snapshot = Value::list(vec![Value::record(&[
258+ ("content", blob),
259+ ("path", Value::list(vec![Value::text("file")])),
260+ ])]);
261+ let error = show(&i, &snapshot).unwrap_err();
262+ assert!(error.msg.contains(message), "{}", error.msg);
263+ let caught = i.apply(catcher.clone(), snapshot.clone()).unwrap();
264+ assert_eq!(caught.as_text().unwrap(), "caught");
265+ assert!(show(&i, &snapshot).is_err());
266+ }
267+ }
268+ // A stored regular UTF-8 blob still has exactly the same representation
269+ // as an eager one and round-trips, including embedded NUL bytes.
270+ let regular = Value::Blob(Rc::new(BlobVal {
271+ kind: BlobKind::Regular,
272+ content: BlobContent::Lazy(Rc::new(LazyBlob::new("text".into(), || {
273+ Ok(Rc::new(b"hello\0\n".to_vec()))
274+ }))),
275+ }));
276+ roundtrip(&mut i, &cfg, &regular);
277+}
278+
279+#[test]
280+fn show_keeps_the_unresolved_blob_fallback_for_binary_and_typed_conflicts() {
281+ let (i, _cfg) = make_interp();
282+ for kind in [BlobKind::Regular, BlobKind::Executable, BlobKind::Symlink] {
283+ let v = Value::Blob(Rc::new(BlobVal {
284+ kind: kind.clone(),
285+ content: BlobContent::Conflict(vec![Some(ConflictSide {
286+ kind,
287+ bytes: Rc::new(vec![0xff, 0xfe]),
288+ tree: None,
289+ })]),
290+ }));
291+ let s = show(&i, &v).unwrap();
292+ assert!(s.starts_with("{- unresolved -} blob"), "{}", s);
293+ }
294+}
295+
237#[test]
296#[test]
238fn show_line_breaking() {
297fn show_line_breaking() {
239 let (i, _cfg) = make_interp();
298 let (i, _cfg) = make_interp();
tests/validation.rs+64−3
@@ -1,4 +1,4 @@
1−//! Persistence validation tests (§7.5 steps 1–3, 6) and config validation (§6.2).
1+//! Persistence validation tests (§7.5 steps 1–4, 6) and config validation (§6.2).
2
2
3use j::config;
3use j::config;
4use j::domain::{MemBackend, MetaInfo, ROOT_ID};
4use j::domain::{MemBackend, MetaInfo, ROOT_ID};
@@ -45,6 +45,10 @@ fn subtree(root: Value, kids: Vec<Value>) -> Value {
45/// root -> a, focused on a
45/// root -> a, focused on a
46fn two_commit_repo(labels_a: &[&str]) -> Value {
46fn two_commit_repo(labels_a: &[&str]) -> Value {
47 let root = commit(ROOT_ID, "", &[], vec![]);
47 let root = commit(ROOT_ID, "", &[], vec![]);
48+ two_commit_repo_with_root(root, labels_a)
49+}
50+
51+fn two_commit_repo_with_root(root: Value, labels_a: &[&str]) -> Value {
48 let a = commit("kaaaaaaa", "a", labels_a, vec![]);
52 let a = commit("kaaaaaaa", "a", labels_a, vec![]);
49 let frame = Value::record(&[
53 let frame = Value::record(&[
50 ("left", Value::list(vec![])),
54 ("left", Value::list(vec![])),
@@ -632,8 +636,7 @@ fn snapshots_left_as_stored_are_not_read() {
632}
636}
633
637
634#[test]
638#[test]
635−fn root_must_stay_on_top() {
639+fn persistence_requires_a_repo() {
636− // covered implicitly by navigation invariants; validate shape only here
637 let old = two_commit_repo(&[]);
640 let old = two_commit_repo(&[]);
638 let not_repo = Value::record(&[("root", old.field("root").unwrap())]);
641 let not_repo = Value::record(&[("root", old.field("root").unwrap())]);
639 let mut i = make_interp(backend_with(&[]));
642 let mut i = make_interp(backend_with(&[]));
@@ -642,6 +645,64 @@ fn root_must_stay_on_top() {
642 assert!(e.msg.contains("not a Repo"), "{}", e.msg);
645 assert!(e.msg.contains("not a Repo"), "{}", e.msg);
643}
646}
644
647
648+#[test]
649+fn root_must_stay_on_top() {
650+ // With no remote labels the default immutable set is empty, but the
651+ // root still cannot be abandoned, replaced, or placed below a commit.
652+ let old = two_commit_repo(&[]);
653+ let a = old.field("root").unwrap();
654+ let root = old.field("context").unwrap().as_list().unwrap()[0]
655+ .field("parent").unwrap();
656+ for children in [vec![], vec![subtree(root, vec![])]] {
657+ let new = Value::record(&[
658+ ("children", Value::list(children)),
659+ ("context", Value::list(vec![])),
660+ ("root", a.clone()),
661+ ]);
662+ assert_eq!(
663+ validate_with(&old, &new).unwrap_err(),
664+ "persistence: the root commit must be the top",
665+ );
666+ }
667+ let replaced = two_commit_repo_with_root(commit("kbbbbbbb", "", &[], vec![]), &[]);
668+ assert_eq!(
669+ validate_with(&old, &replaced).unwrap_err(),
670+ "persistence: the root commit must be the top",
671+ );
672+}
673+
674+#[test]
675+fn root_message_and_files_cannot_change() {
676+ let old = two_commit_repo(&[]);
677+ for root in [
678+ commit(ROOT_ID, "edited", &[], vec![]),
679+ commit(ROOT_ID, "", &[], vec![entry("a", "squashed into root")]),
680+ ] {
681+ let new = two_commit_repo_with_root(root, &[]);
682+ assert_eq!(
683+ validate_with(&old, &new).unwrap_err(),
684+ "persistence: the root commit cannot be changed",
685+ );
686+ }
687+}
688+
689+#[test]
690+fn unchanged_synthetic_root_is_accepted_without_reading_its_tree() {
691+ let loaded = || two_commit_repo_with_root(
692+ commit_holding(ROOT_ID, "synthetic root", &[], unreadable_tree(ROOT_ID, "root-tree")),
693+ &[],
694+ );
695+ // Each load has its own thunk, as a snapshot rebuilding the repo does.
696+ assert_eq!(validate_with(&loaded(), &loaded()), Ok(()));
697+ let listed = |entries| two_commit_repo_with_root(
698+ commit(ROOT_ID, "synthetic root", &[], entries),
699+ &[],
700+ );
701+ let old = listed(vec![entry("a", "1"), entry("b", "2")]);
702+ let reordered = listed(vec![entry("b", "2"), entry("a", "1")]);
703+ assert_eq!(validate_with(&old, &reordered), Ok(()));
704+}
705+
645// ------------------------------------------------------------------
706// ------------------------------------------------------------------
646// config validation (§6.2)
707// config validation (§6.2)
647// ------------------------------------------------------------------
708// ------------------------------------------------------------------