on master help sign in
Fix data-loss, validation, rendering, and interpreter bugs (#6)
commitd06d51913798ad796255dcfd760f3753d1e3201a authoriko <mail@iko.soy> dateSat 10 Oct 2026, 19:36 (3 days ago) parent69120bd
19 files changed+1251−142
CLAUDE.md+1 config.j+1−1 docs/base.md+5−3 docs/language.md+5−3 specs/main.md+30−18 src/eval.rs+78−45 src/jj.rs+24−15 src/main.rs+31−9 src/repo.rs+22−1 src/show.rs+35−19 src/value.rs+314−14 tests/cli.rs+80−2 tests/difft.rs+63 tests/env_lifetimes.rs+215 tests/eval.rs+135−2 tests/interop.rs+10−6 tests/remote.rs+78 tests/show.rs+60−1 tests/validation.rs+64−3
CLAUDE.md+1
@@ -38,6 +38,7 @@ Language pipeline, all in `src/`:
3838- `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.
3939- `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.
4040- `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.
4142- `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.
4243- `builtins.rs`: native implementations of the names `config.j` declares without defining.
4344- `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
155155meta : Id -> Meta -- git hash, author, email, committer time;
156156 -- crash for a commit not yet persisted
157157validate : Repo -> Repo -- the repo unchanged, or the crash persisting
158− -- it would produce (shape, labels, immutability,
158+ -- it would produce (shape, root, labels, immutability,
159159 -- focus). `tree . validate . rebase trunk` is an
160160 -- honest dry run.
161161diff : 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:
301301show : a -> Text
302302```
303303
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
305305closures and unresolved blobs) the text parses and evaluates back to an equal
306−value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
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
308+raw, so `j show …` is how you inspect values.
307309See §5.2 of the spec for the exact rendering.
308310
309311```
@@ -513,7 +515,7 @@ validate : Repo -> Repo
513515```
514516
515517Returns 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,
517519focus). `tree . validate . rebase trunk` is an honest dry run.
518520
519521### `diff` and `difft` — rendering differences
docs/language.md+5−3
@@ -696,10 +696,12 @@ unchecked except through the functions they call.
696696
697697## 12. `show`: rendering values as text
698698
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
700700(except for closures and unresolved blobs) the result parses and evaluates
701−back to an equal value. Since `show` returns a `Text`, and a top-level `Text`
702−prints raw, `j show …` is how you inspect values.
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.
703+Since `show` returns a `Text`, and a top-level `Text` prints raw, `j show …`
704+is how you inspect values.
703705
704706```
705707show 42 => "42"
specs/main.md+30−18
@@ -40,7 +40,8 @@ echo EXPRESSION | j
4040 `::`, `or`, `.`, and the literals `%name`, `@id`, `./a/b` are all safe
4141 unquoted.
42423. 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.
44454. If neither is present, or the input is only whitespace, print a concise
4546 usage and getting-started guide to stderr and exit 2. The guide points to
4647 `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.
491492
492493`if`, `or`, `&&`, and `||` do not evaluate all operands. `&&` and `||`
493494short-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.
495498
496499`a or b` evaluates `a`. If that crashes, the crash is discarded and `b` is
497500evaluated and returned. Otherwise, if the value of `a` is a function, `b` is
@@ -567,7 +570,7 @@ as in §4.13.
567570| `treeWith o r` | the history rendered as a tree with options record `o`; §7.11 |
568571| `extract S v` | every subvalue of `v` that is an `S`; §4.12 |
569572| `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 |
571574
572575### 4.10 Ids: existing and new
573576
@@ -795,13 +798,16 @@ j: crash: expected one revision, got 0
795798
796799### 5.2 `show`
797800
798−`show v` renders `v` as an expression in the language, such that for every
799−value except closures, unresolved blobs, and values nested past the parser's
800−bound (§3.4: a list 40,000 deep, a record about 20,000), `show v` parses and
801−evaluates to a value equal to `v` under the same `config.j`. It never uses
802−colour and never truncates. A value holding a part the store cannot supply (a
803−`files` list or a file's content, §7.2) crashes `show` as it crashes any other
804−use: `show` writes no stand-in for the part.
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
805+value it renders except closures, unresolved blobs, and values nested past
806+the parser's bound (§3.4: a list 40,000 deep, a record about 20,000), `show v`
807+parses and evaluates to a value equal to `v` under the same `config.j`. It
808+never uses colour and never truncates. A value holding a part the store
809+cannot supply (a `files` list or a file's content, §7.2) crashes `show` as it
810+crashes any other use: `show` writes no stand-in for the part.
805811
806812| kind | rendering |
807813|---|---|
@@ -811,7 +817,7 @@ use: `show` writes no stand-in for the part.
811817| list | `[v1 v2 …]` |
812818| record | `{ a = v, b = w }`, fields in ascending lexicographic order |
813819| `Id` | `@` followed by the full id, e.g. `@wqztkpqxmnrvyxskptlmzzzzabcd` |
814−| resolved `Blob` | `blob "…"` |
820+| resolved UTF-8 regular `Blob` | `blob "…"`; other resolved blobs crash |
815821| unresolved `Blob` | `{- unresolved -} blob "…"` with the conflict-marker rendering |
816822| builtin | its declared name, operators parenthesised: `map`, `(++)` |
817823| selector | the selector itself: `.id` |
@@ -854,7 +860,10 @@ Expression evaluation, `push`, and `fetch` load the configuration; `init` and
854860`user` is evaluated; `Id` literals are not resolved and no other definition
855861is evaluated. `remote`, `undo`, `redo`, and `ops` do not load configuration.
856862When 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.
858867An existing user configuration is not updated when the binary changes. The
859868reference file is also distributed at `<prefix>/share/j/config.j`.
860869
@@ -1255,8 +1264,9 @@ language: `fetch` and `push` are the only things that change them.
12551264
12561265 Duplicates are merged; two records with the same `name` and different
12571266 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,
1259− or a delete names a bookmark the remote does not have;
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
1269+ a bookmark the remote does not have;
12601270 - any commit that would be sent has an unresolved file or an empty
12611271 description (the commits that would be sent are the records' `id`s and
12621272 their ancestors that no bookmark on `origin` reaches as of the last
@@ -1426,9 +1436,11 @@ configuration error.
14261436`difft p a b` runs difftastic. It is the only builtin that executes another
14271437program.
14281438
1429−1. Write the content of `a` and `b` to two temporary files whose names end in
1430− the last component of `p` (so difftastic's language detection sees the
1431− real filename), inside a fresh temporary directory.
1439+1. Write the content of `a` and `b` to files in separate `old` and `new`
1440+ subdirectories of a fresh temporary directory. Each file keeps the last
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`.
143214442. Run `difft OLD NEW` with the process's stdin closed and its environment
14331445 inherited unchanged, except: if `DFT_COLOR` is unset and `j`'s stdout is a
14341446 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
17641776meta : Id -> Meta -- git hash, author, email, committer time;
17651777 -- crash for a commit not yet persisted
17661778validate : Repo -> Repo -- the repo unchanged, or the crash persisting
1767− -- it would produce (shape, labels, immutability,
1779+ -- it would produce (shape, root, labels, immutability,
17681780 -- focus). `tree . validate . rebase trunk` is an
17691781 -- honest dry run.
17701782diff : Blob -> Blob -> Text -- unified diff from the first to the second
src/eval.rs+78−45
@@ -47,6 +47,19 @@ pub struct Interp {
4747 sorted_ids: RefCell<Option<Rc<Vec<String>>>>,
4848}
4949
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+
5063/// length of the common leading run of two ids (ids are ASCII, so bytes)
5164fn common_prefix_len(a: &str, b: &str) -> usize {
5265 a.bytes()
@@ -104,11 +117,14 @@ pub(crate) enum Cont {
104117 env: Env,
105118 cont: Rc<Cont>,
106119 },
107− /// short-circuit ops with the unevaluated rhs
108− And(Rc<Expr>, Env, Rc<Cont>),
109− Or2(Rc<Expr>, Env, Rc<Cont>),
110− /// check the rhs of && / || evaluated to a Bool
111− ExpectBool(&'static str, Rc<Cont>),
120+ /// `&&` or `||` after applying its first operand normally, so the
121+ /// declared contract has checked it; the rhs is still unevaluated
122+ ShortCircuit {
123+ op: &'static str,
124+ rhs: Rc<Expr>,
125+ env: Env,
126+ cont: Rc<Cont>,
127+ },
112128 /// `or` after both evaluated as functions: build the lifted function
113129 OrBoth(Value, Rc<Cont>),
114130 /// a shared continuation (used when both `or` outcomes continue the same way)
@@ -258,9 +274,7 @@ fn parent_rc(k: &Cont) -> Option<&Rc<Cont>> {
258274 Cont::Fun(_, k) => Some(k),
259275 Cont::If(_, _, _, k) => Some(k),
260276 Cont::LetRest { cont, .. } => Some(cont),
261− Cont::And(_, _, k) => Some(k),
262− Cont::Or2(_, _, k) => Some(k),
263− Cont::ExpectBool(_, k) => Some(k),
277+ Cont::ShortCircuit { cont, .. } => Some(cont),
264278 Cont::OrBoth(_, k) => Some(k),
265279 Cont::Shared(k) => Some(k),
266280 Cont::ApplyTo(_, k) => Some(k),
@@ -609,10 +623,14 @@ impl Interp {
609623 }
610624
611625 fn run(&mut self, st: State) -> EResult {
612− match self.trampoline(st) {
626+ let result = match self.trampoline(st) {
613627 Ok(v) => Ok(v),
614628 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
616634 }
617635
618636 fn trampoline(&mut self, st: State) -> EResult {
@@ -776,29 +794,26 @@ impl Interp {
776794 Cont::Arg(x.clone(), env, Rc::new(k)),
777795 )),
778796 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− }
793797 let fv = match env.lookup(op) {
794798 Some(v) => v,
795799 None => return Run::Crash(Crash::new(format!("unbound name `{}`", op))),
796800 };
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 ||.
798813 Run::Step(State::Eval(
799814 a.clone(),
800− env.clone(),
801− Cont::Fun(fv, Rc::new(Cont::Arg(b.clone(), env, Rc::new(k)))),
815+ env,
816+ Cont::Fun(fv, Rc::new(next)),
802817 ))
803818 }
804819 Expr::Or(a, b) => {
@@ -913,24 +928,42 @@ impl Interp {
913928 ))
914929 }
915930 }
916− Cont::And(b, env, k) => match v {
917− Value::Bool(false) => Run::Step(State::Ret(Value::Bool(false), (*k).clone())),
918− Value::Bool(true) => Run::Step(State::Eval(b, env, Cont::ExpectBool("&&", k))),
919− _ => Run::Crash(Crash::new(format!("&&: expected Bool, got a {}", v.kind_name()))),
920− },
921− Cont::Or2(b, env, k) => match v {
922− Value::Bool(true) => Run::Step(State::Ret(Value::Bool(true), (*k).clone())),
923− Value::Bool(false) => Run::Step(State::Eval(b, env, Cont::ExpectBool("||", k))),
924− _ => Run::Crash(Crash::new(format!("||: expected Bool, got a {}", v.kind_name()))),
925− },
926− Cont::ExpectBool(op, k) => match v {
927− Value::Bool(_) => Run::Step(State::Ret(v, (*k).clone())),
928− _ => Run::Crash(Crash::new(format!(
929− "{}: expected Bool, got a {}",
930− op,
931− v.kind_name()
932− ))),
933− },
931+ Cont::ShortCircuit { op, rhs, env, cont } => {
932+ // Operator names are reserved: resolving && or || and
933+ // applying one operand always gives its builtin partial.
934+ let (lhs, pending) = match &v {
935+ Value::Fun(f) => match &**f {
936+ FunVal::Builtin { args, pending, .. } if args.len() == 1 => {
937+ (&args[0], pending)
938+ }
939+ _ => return Run::Crash(Crash::new("internal: invalid boolean operator")),
940+ },
941+ _ => return Run::Crash(Crash::new("internal: invalid boolean operator")),
942+ };
943+ let lhs = match lhs {
944+ Value::Bool(b) => *b,
945+ other => return Run::Crash(Crash::new(format!(
946+ "{}: expected Bool, got a {}", op, other.kind_name()
947+ ))),
948+ };
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+ }
966+ }
934967 Cont::OrBoth(a, k) => {
935968 if matches!(v, Value::Fun(_)) {
936969 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 {
6969 '\n' => out.push_str("\\n"),
7070 '\r' => out.push_str("\\r"),
7171 '\t' => out.push_str("\\t"),
72+ c if c.is_ascii_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
7273 c => out.push(c),
7374 }
7475 }
@@ -3407,6 +3408,11 @@ fn parse_push_records(
34073408 let rec = vis.commits.get(&change_id).ok_or_else(|| {
34083409 (1, format!("push: `@{}` is not a visible commit", change_id))
34093410 })?;
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+ }
34103416 out.push((name, Some(rec.commit.id().clone())));
34113417 }
34123418 None => {
@@ -3618,23 +3624,11 @@ fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<Stri
36183624
36193625/// git-check-ref-format --branch rules
36203626fn is_valid_git_branch_name(name: &str) -> bool {
3621− if name.is_empty()
3622− || 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− {
3627+ if name.starts_with('-') {
36323628 return false;
36333629 }
3634− name.chars().all(|c| {
3635− !(c.is_ascii_control()
3636− || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\'))
3637− })
3630+ let full_name = format!("refs/heads/{name}");
3631+ gix::validate::reference::branch_name(full_name.as_str().into()).is_ok()
36383632}
36393633
36403634// ----------------------------------------------------------------------
@@ -4726,6 +4720,21 @@ mod tests {
47264720 assert!(cache.blobs.get(&id).is_none());
47274721 }
47284722
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+
47294738 #[test]
47304739 fn a_description_names_its_terms_whatever_their_labels_and_the_sides_that_cancel() {
47314740 // 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> {
7272 let path = config_path();
7373 let src = match std::fs::read_to_string(&path) {
7474 Ok(s) => s,
75− Err(_) => {
75+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7676 // No user config yet: materialise the default there, editable by
7777 // the current user, so they have a starting point to customise.
78− if let Err(e) = write_default_config(&path) {
79− return Err(fail(3, format!("cannot create config at {}: {}", path, e)));
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 => {}
83+ Err(e) => return Err(fail(3, format!("cannot create config at {}: {}", path, e))),
8084 }
81− j::report!("j: created an editable default config at {}", path);
8285 match std::fs::read_to_string(&path) {
8386 Ok(s) => s,
8487 Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
8588 }
8689 }
90+ Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
8791 };
8892 match config::load_config(&src) {
8993 Ok(c) => Ok((c, src)),
@@ -94,17 +98,19 @@ fn load_config_file() -> Result<(Config, String), u8> {
9498/// Write the embedded default config to `path`, creating parent directories
9599/// and making the file readable and writable by the current user.
96100fn write_default_config(path: &str) -> std::io::Result<()> {
101+ use std::io::Write;
97102 if let Some(parent) = std::path::Path::new(path).parent() {
98103 std::fs::create_dir_all(parent)?;
99104 }
100− std::fs::write(path, DEFAULT_CONFIG)?;
105+ let mut options = std::fs::OpenOptions::new();
106+ options.write(true).create_new(true);
101107 // user-read+write (0o600 on unix): the file is meant to be edited by them
102108 #[cfg(unix)]
103109 {
104− use std::os::unix::fs::PermissionsExt;
105− std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
110+ use std::os::unix::fs::OpenOptionsExt;
111+ options.mode(0o600);
106112 }
107− Ok(())
113+ options.open(path)?.write_all(DEFAULT_CONFIG.as_bytes())
108114}
109115
110116const RESERVED: &[&str] = &[
@@ -227,7 +233,10 @@ fn run() -> ExitCode {
227233 if std::io::stdin().is_terminal() {
228234 false
229235 } else if args.is_empty() || stdin_is_ready(50) {
230− match std::io::stdin().read_to_end(&mut stdin_content) {
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 };
239+ match std::io::stdin().take(limit).read_to_end(&mut stdin_content) {
231240 Ok(n) => n > 0,
232241 Err(_) => false,
233242 }
@@ -537,3 +546,16 @@ fn is_repo_value(v: &Value) -> bool {
537546 .collect();
538547 root_fields == want_root
539548}
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> {
382382}
383383
384384// ----------------------------------------------------------------------
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
386387// ----------------------------------------------------------------------
387388
388389#[derive(Debug)]
@@ -417,9 +418,29 @@ pub fn validate_repo(i: &mut Interp, new: &Value) -> Result<Validated, Crash> {
417418 "the focus must be a mutable commit; compose with new",
418419 ));
419420 }
421+ validate_root(old.as_ref(), new)?;
420422 Ok(Validated { immutable })
421423}
422424
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+
423444fn check_repo_shape(v: &Value) -> Result<(), Crash> {
424445 let fields = v
425446 .field_set()
src/show.rs+35−19
@@ -1,7 +1,7 @@
11//! `show` (§5.2), unified diff (§4.9 `diff`), and the difftastic bridge (§7.10).
22
33use crate::eval::Interp;
4−use crate::value::{BlobContent, BlobVal, Crash, FunVal, ThunkVal, Value};
4+use crate::value::{BlobContent, BlobKind, BlobVal, Crash, FunVal, ThunkVal, Value};
55use num_bigint::BigInt;
66use unicode_width::UnicodeWidthChar;
77
@@ -85,23 +85,30 @@ fn render_int(n: &BigInt, out: &mut String) {
8585
8686#[inline(never)]
8787fn render_blob(b: &BlobVal, out: &mut String) -> Result<(), Crash> {
88− match &b.content {
89− BlobContent::Resolved(bytes) => match std::str::from_utf8(bytes) {
90− Ok(s) => {
91− out.push_str("blob ");
92− push_text_literal(s, out);
93− }
94− Err(_) => out.push_str("blob \"<binary>\""),
95− },
96− BlobContent::Lazy(_) | BlobContent::Conflict(_) => {
97− let bytes = b.bytes()?;
98− if b.is_unresolved() {
99− out.push_str("{- unresolved -} ");
100− }
101− out.push_str("blob ");
102− push_text_literal(&String::from_utf8_lossy(&bytes), out);
103− }
88+ // Conflicts deliberately show their markers, without promising a round
89+ // trip (§5.2). Every resolved blob must instead have an equal literal:
90+ // `blob` constructs only a regular UTF-8 file, so other kinds and bytes
91+ // cannot be silently replaced by a different blob.
92+ if b.is_unresolved() {
93+ let bytes = b.bytes()?;
94+ out.push_str("{- unresolved -} blob ");
95+ push_text_literal(&String::from_utf8_lossy(&bytes), out);
96+ return Ok(());
10497 }
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);
105112 Ok(())
106113}
107114
@@ -436,6 +443,9 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
436443 .map(|c| c.as_text().map(|s| s.to_string()))
437444 .transpose()?
438445 .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+ }
439449 let a_bytes = match a {
440450 Value::Blob(bl) => bl.bytes()?,
441451 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> {
446456 };
447457 let dir = tempfile::tempdir()
448458 .map_err(|e| Crash::new(format!("difft: cannot create temp dir: {}", e)))?;
449− let old = dir.path().join(format!("old-{}", last));
450− let new = dir.path().join(format!("new-{}", last));
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.
461+ let old_dir = dir.path().join("old");
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);
451467 std::fs::write(&old, &a_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
452468 std::fs::write(&new, &b_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
453469 let mut cmd = std::process::Command::new("difft");
src/value.rs+314−14
@@ -3,7 +3,7 @@
33use crate::ast::{Expr, Pattern};
44use num_bigint::BigInt;
55use std::collections::{BTreeMap, BTreeSet, HashMap};
6−use std::rc::Rc;
6+use std::rc::{Rc, Weak};
77
88pub type RecordMap = BTreeMap<String, Value>;
99
@@ -764,10 +764,10 @@ pub struct Env {
764764 frame: Option<Rc<Frame>>,
765765}
766766
767−/// A large acyclic let can have one frame per binding. Release unique
768−/// frames iteratively, keeping the next parent alive while its child's
769−/// values drop, so closures capturing earlier frames cannot recurse down
770−/// that chain either (§4.1).
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
769+/// next parent alive while its child's values drop, so closures capturing
770+/// earlier frames cannot recurse down that chain either (§4.1).
771771impl Drop for Env {
772772 fn drop(&mut self) {
773773 let mut next = self.frame.take();
@@ -820,16 +820,20 @@ impl Env {
820820 /// extend with a recursive frame (for `let` blocks) binding `names`,
821821 /// whose values are pushed to the returned cell in the same order
822822 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();
823826 let cell = Rc::new(std::cell::RefCell::new(Vec::with_capacity(names.len())));
824− (
825− Env {
826− frame: Some(Rc::new(Frame {
827− kind: FrameKind::Rec(names, cell.clone()),
828− parent: self.clone(),
829− })),
830− },
831− cell,
832− )
827+ let frame = Rc::new(Frame {
828+ kind: FrameKind::Rec(names, cell.clone()),
829+ parent: self.clone(),
830+ });
831+ RECURSIVE_FRAMES.with(|registry| {
832+ let mut registry = registry.borrow_mut();
833+ registry.frames.push(Rc::downgrade(&frame));
834+ registry.allocations += 1;
835+ });
836+ (Env { frame: Some(frame) }, cell)
833837 }
834838
835839 pub fn lookup(&self, name: &str) -> Option<Value> {
@@ -903,6 +907,302 @@ impl Env {
903907 }
904908}
905909
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+
9061206// ----------------------------------------------------------------------
9071207// structural equality (§4.5); comparing functions is a crash
9081208// ----------------------------------------------------------------------
tests/cli.rs+80−2
@@ -190,6 +190,40 @@ fn both_stdin_and_args_rejected() {
190190 assert!(out.stderr.contains("both"), "{}", out.stderr);
191191}
192192
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+
193227#[test]
194228fn an_expression_that_is_not_utf8_is_a_usage_error() {
195229 // §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() {
416450 let r = setup();
417451 r.write("a.txt", "x\n");
418452 r.j(&["describe \"work\""]).ok();
419− // tree . validate . squash is an honest dry run
453+ let before = r.j(&["ops"]).ok().stdout.lines().count();
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();
420469 let out = r.j(&["tree . validate . squash"]).ok();
421470 assert!(!out.stdout.is_empty());
471+ assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), before);
472+ r.j(&["squash"]).ok();
422473}
423474
424475#[test]
@@ -649,7 +700,7 @@ fn review_says_no_changes_only_when_nothing_changed() {
649700 std::fs::write(&stub, "#!/bin/sh\nprintf '%s -> %s\\n' \"${1##*/}\" \"${2##*/}\"\n").unwrap();
650701 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
651702 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");
653704}
654705
655706#[test]
@@ -3268,6 +3319,33 @@ fn missing_config_is_created_editable() {
32683319 let _ = std::fs::remove_dir_all(&dir);
32693320}
32703321
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+
32713349// ----------------------------------------------------------------------
32723350// regressions
32733351// ----------------------------------------------------------------------
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;
1010const CONFIG: &str = include_str!("../config.j");
1111
1212fn make_interp() -> (Interp, config::Config) {
13− let cfg = config::load_config(CONFIG).expect("reference config must load");
13+ make_interp_from(CONFIG)
14+}
15+
16+fn make_interp_from(src: &str) -> (Interp, config::Config) {
17+ let cfg = config::load_config(src).expect("config must load");
1418 let backend = Rc::new(MemBackend::new());
1519 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");
1721 (interp, cfg)
1822}
1923
@@ -127,6 +131,135 @@ fn short_circuit_booleans() {
127131 assert!(crash(&mut i, &cfg, "false || 1").contains("Bool"));
128132}
129133
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+
130263#[test]
131264fn if_requires_bool() {
132265 let (mut i, cfg) = make_interp();
tests/interop.rs+10−6
@@ -168,17 +168,21 @@ fn jj_undo_interop_marker() {
168168#[test]
169169fn jj_reads_conflicts_written_by_j() {
170170 let Some(t) = setup() else { return };
171− t.j(&["init"]);
171+ let j_ok = |expr: &str| {
172+ let (code, _, err) = t.j(&[expr]);
173+ assert_eq!(code, 0, "{}: {}", expr, err);
174+ };
175+ j_ok("init");
172176 std::fs::write(t.dir.join("f.txt"), "base\n").unwrap();
173− t.j(&["describe \"base\""]);
177+ j_ok("describe \"base\"");
174178 // side 1
175− t.j(&["new"]);
179+ j_ok("new");
176180 std::fs::write(t.dir.join("f.txt"), "one\n").unwrap();
177− t.j(&["describe \"one\""]);
181+ j_ok("describe \"one\"");
178182 // side 2 from the parent
179− t.j(&["new . goto (kids (top id))"]);
183+ j_ok("new . prev");
180184 std::fs::write(t.dir.join("f.txt"), "two\n").unwrap();
181− t.j(&["describe \"two\""]);
185+ j_ok("describe \"two\"");
182186 // rebase two onto one: conflicts
183187 let (code, _, err) = t.j(&["rebase (matching (\\c -> c.message == \"one\") all)"]);
184188 assert_eq!(code, 0, "{}", err);
tests/remote.rs+78
@@ -973,6 +973,84 @@ fn push_refusals() {
973973 assert_eq!(out.code, 0, "{}", out.stderr);
974974}
975975
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+
9761054#[test]
9771055fn push_of_an_empty_list_pushes_nothing() {
9781056 // §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;
55use j::eval::Interp;
66use j::parse::parse_expr;
77use 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};
99use std::alloc::{GlobalAlloc, Layout, System};
1010use std::cell::Cell;
1111use std::rc::Rc;
@@ -234,6 +234,65 @@ fn show_unresolved_blob() {
234234 assert!(s.starts_with("{- unresolved -} blob"), "{}", s);
235235}
236236
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+
237296#[test]
238297fn show_line_breaking() {
239298 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).
22
33use j::config;
44use j::domain::{MemBackend, MetaInfo, ROOT_ID};
@@ -45,6 +45,10 @@ fn subtree(root: Value, kids: Vec<Value>) -> Value {
4545/// root -> a, focused on a
4646fn two_commit_repo(labels_a: &[&str]) -> Value {
4747 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 {
4852 let a = commit("kaaaaaaa", "a", labels_a, vec![]);
4953 let frame = Value::record(&[
5054 ("left", Value::list(vec![])),
@@ -632,8 +636,7 @@ fn snapshots_left_as_stored_are_not_read() {
632636}
633637
634638#[test]
635−fn root_must_stay_on_top() {
636− // covered implicitly by navigation invariants; validate shape only here
639+fn persistence_requires_a_repo() {
637640 let old = two_commit_repo(&[]);
638641 let not_repo = Value::record(&[("root", old.field("root").unwrap())]);
639642 let mut i = make_interp(backend_with(&[]));
@@ -642,6 +645,64 @@ fn root_must_stay_on_top() {
642645 assert!(e.msg.contains("not a Repo"), "{}", e.msg);
643646}
644647
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+
645706// ------------------------------------------------------------------
646707// config validation (§6.2)
647708// ------------------------------------------------------------------