@@ -1,7 +1,6 @@
1//! j — a functional-programming-centered CLI for jj repositories (§1).
1//! j — a functional-programming-centered CLI for jj repositories (§1).
3use j::config::{self, Config};
3use j::config::{self, Config};
5use j::jj::JjBackend;
4use j::jj::JjBackend;
6use j::parse::parse_expr;
5use j::parse::parse_expr;
7use j::value::{Crash, Value};
6use j::value::{Crash, Value};
@@ -17,6 +16,13 @@ fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
17 ExitCode::from(status)
16 ExitCode::from(status)
19+/// Report a failure that ends the process, keeping the status as a number so
20+/// callers do not have to recover it from an `ExitCode`.
21+fn fail(status: u8, msg: impl std::fmt::Display) -> u8 {
22+ eprintln!("j: {}", msg);
20fn crash_err(c: &Crash, expr_text: Option<&str>) -> ExitCode {
26fn crash_err(c: &Crash, expr_text: Option<&str>) -> ExitCode {
21 eprintln!("j: crash: {}", c.msg);
27 eprintln!("j: crash: {}", c.msg);
22 match (&c.def, expr_text) {
28 match (&c.def, expr_text) {
@@ -42,7 +48,7 @@ fn config_path() -> String {
42/// installed (the share/j/config.j file may not exist next to the binary).
48/// installed (the share/j/config.j file may not exist next to the binary).
43const DEFAULT_CONFIG: &str = include_str!("../config.j");
49const DEFAULT_CONFIG: &str = include_str!("../config.j");
45−fn load_config_file() -> Result<(Config, String), ExitCode> {
51+fn load_config_file() -> Result<(Config, String), u8> {
46 let path = config_path();
52 let path = config_path();
47 let src = match std::fs::read_to_string(&path) {
53 let src = match std::fs::read_to_string(&path) {
48 Ok(s) => s,
54 Ok(s) => s,
@@ -50,18 +56,18 @@ fn load_config_file() -> Result<(Config, String), ExitCode> {
50 // No user config yet: materialise the default there, editable by
56 // No user config yet: materialise the default there, editable by
51 // the current user, so they have a starting point to customise.
57 // the current user, so they have a starting point to customise.
52 if let Err(e) = write_default_config(&path) {
58 if let Err(e) = write_default_config(&path) {
53− return Err(err(3, format!("cannot create config at {}: {}", path, e)));
59+ return Err(fail(3, format!("cannot create config at {}: {}", path, e)));
55 eprintln!("j: created an editable default config at {}", path);
61 eprintln!("j: created an editable default config at {}", path);
56 match std::fs::read_to_string(&path) {
62 match std::fs::read_to_string(&path) {
57 Ok(s) => s,
63 Ok(s) => s,
58− Err(e) => return Err(err(3, format!("cannot read config at {}: {}", path, e))),
64+ Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
62 match config::load_config(&src) {
68 match config::load_config(&src) {
63 Ok(c) => Ok((c, src)),
69 Ok(c) => Ok((c, src)),
64− Err(e) => Err(err(3, e.message())),
70+ Err(e) => Err(fail(3, e.message())),
@@ -95,18 +101,36 @@ fn main() -> ExitCode {
95 .expect("worker thread panicked")
101 .expect("worker thread panicked")
104+/// True when stdin has input (or end-of-input) waiting within `ms`
105+/// milliseconds. A closed or exhausted stdin reports ready at once, so the
106+/// wait is only paid for a pipe that is open but idle.
107+fn stdin_is_ready(ms: i32) -> bool {
108+ let mut fds = libc::pollfd {
109+ fd: libc::STDIN_FILENO,
110+ events: libc::POLLIN,
113+ unsafe { libc::poll(&mut fds, 1, ms) > 0 }
98fn run() -> ExitCode {
116fn run() -> ExitCode {
99 let args: Vec<String> = std::env::args().skip(1).collect();
117 let args: Vec<String> = std::env::args().skip(1).collect();
100− // §1: stdin vs arguments
118+ // §1: stdin vs arguments. With no arguments the expression comes from
119+ // stdin, so reading to end-of-input is the whole point. With arguments,
120+ // stdin is only read to report the "both" error — and reading it blindly
121+ // hangs whenever the process inherited a pipe nobody is writing to (a
122+ // shell loop, a CI step), so wait only briefly for input to appear.
101 let mut stdin_content = String::new();
123 let mut stdin_content = String::new();
102 let stdin_has_data = {
124 let stdin_has_data = {
103 if std::io::stdin().is_terminal() {
125 if std::io::stdin().is_terminal() {
105− } else {
127+ } else if args.is_empty() || stdin_is_ready(50) {
106 match std::io::stdin().read_to_string(&mut stdin_content) {
128 match std::io::stdin().read_to_string(&mut stdin_content) {
107 Ok(n) => n > 0,
129 Ok(n) => n > 0,
108 Err(_) => false,
130 Err(_) => false,
112 if stdin_has_data && !args.is_empty() {
136 if stdin_has_data && !args.is_empty() {
@@ -185,10 +209,10 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
185 if rest.is_empty() {
209 if rest.is_empty() {
186 return err(2, "usage: j push EXPR");
210 return err(2, "usage: j push EXPR");
188− let (cfg, _) = or_exit(load_config_file());
212+ let (mut cfg, _) = or_exit(load_config_file());
189 let backend = or_exit(open_repo_locked(&cfg));
213 let backend = or_exit(open_repo_locked(&cfg));
190 // evaluate EXPR against the recorded repository (no snapshot)
214 // evaluate EXPR against the recorded repository (no snapshot)
191− match backend.cmd_push(&cfg, rest) {
215+ match backend.cmd_push(&mut cfg, rest) {
192 Ok(()) => ExitCode::SUCCESS,
216 Ok(()) => ExitCode::SUCCESS,
193 Err(e) => err(e.0, e.1),
217 Err(e) => err(e.0, e.1),
@@ -232,58 +256,44 @@ fn default_clone_dir(url: &str) -> String {
232 last.strip_suffix(".git").unwrap_or(last).to_string()
256 last.strip_suffix(".git").unwrap_or(last).to_string()
235−fn or_exit<T>(r: Result<T, ExitCode>) -> T {
259+fn or_exit<T>(r: Result<T, u8>) -> T {
236 match r {
260 match r {
237 Ok(v) => v,
261 Ok(v) => v,
238− Err(c) => std::process::exit(exit_code_number(c)),
262+ Err(code) => std::process::exit(code as i32),
242−/// ExitCode doesn't expose its value; track it alongside.
243−fn exit_code_number(c: ExitCode) -> i32 {
244− // ExitCode's Debug is "ExitCode(unix_exit_status(N))" on unix
245− let s = format!("{:?}", c);
246− for tok in s.split(|ch: char| !ch.is_ascii_digit()) {
247− if !tok.is_empty() {
248− if let Ok(n) = tok.parse() {
256−fn open_repo() -> Result<Rc<JjBackend>, ExitCode> {
266+fn open_repo() -> Result<Rc<JjBackend>, u8> {
257 match JjBackend::open(None) {
267 match JjBackend::open(None) {
258 Ok(b) => Ok(Rc::new(b)),
268 Ok(b) => Ok(Rc::new(b)),
259− Err(e) => Err(err(e.0, e.1)),
269+ Err(e) => Err(fail(e.0, e.1)),
263−fn open_repo_noconfig() -> Result<Rc<JjBackend>, ExitCode> {
273+fn open_repo_noconfig() -> Result<Rc<JjBackend>, u8> {
264 // undo/redo/ops do not read the config (§6.1), but take the lock
274 // undo/redo/ops do not read the config (§6.1), but take the lock
265 match JjBackend::open(None) {
275 match JjBackend::open(None) {
266 Ok(b) => {
276 Ok(b) => {
267 b.take_lock();
277 b.take_lock();
268 Ok(Rc::new(b))
278 Ok(Rc::new(b))
270− Err(e) => Err(err(e.0, e.1)),
280+ Err(e) => Err(fail(e.0, e.1)),
274−fn open_repo_locked(cfg: &Config) -> Result<Rc<JjBackend>, ExitCode> {
284+fn open_repo_locked(cfg: &Config) -> Result<Rc<JjBackend>, u8> {
275 match JjBackend::open(Some(cfg)) {
285 match JjBackend::open(Some(cfg)) {
276 Ok(b) => {
286 Ok(b) => {
277 b.take_lock();
287 b.take_lock();
278 Ok(Rc::new(b))
288 Ok(Rc::new(b))
280− Err(e) => Err(err(e.0, e.1)),
290+ Err(e) => Err(fail(e.0, e.1)),
284fn run_expression(text: &str, snapshot: bool) -> ExitCode {
294fn run_expression(text: &str, snapshot: bool) -> ExitCode {
285 // §1.2.1: locate the repository
295 // §1.2.1: locate the repository
286− let (cfg, _cfg_src) = or_exit(load_config_file());
296+ let (mut cfg, _cfg_src) = or_exit(load_config_file());
287 let backend = or_exit(open_repo_locked(&cfg));
297 let backend = or_exit(open_repo_locked(&cfg));
289 // §1.2.2: parse the expression
299 // §1.2.2: parse the expression
@@ -295,23 +305,15 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
296 // §1.2.4: build the current repository value (with snapshot)
306 // §1.2.4: build the current repository value (with snapshot)
297 let (mut interp, loaded_repo, current_repo) =
307 let (mut interp, loaded_repo, current_repo) =
298− match backend.build_interp(&cfg, text, snapshot) {
308+ match backend.build_interp(&mut cfg, text, snapshot) {
299 Ok(t) => t,
309 Ok(t) => t,
300 Err((2, m)) => return err(2, m),
310 Err((2, m)) => return err(2, m),
311+ Err((3, m)) => return err(3, m),
301 Err((_, m)) => return err(1, m),
312 Err((_, m)) => return err(1, m),
304− // §1.2.5: resolve Id literals in config and expression
315+ // §1.2.5: the config's Id literals were resolved by build_interp, as soon
305− for (def_name, prefix, candidates) in config::config_id_failures(&cfg, &interp) {
316+ // as the visible ids were known; the expression's are resolved here
310− "config.j: `@{}` in `{}` does not resolve to a unique commit",
315 expr = match config::resolve_ids(&expr, &interp) {
317 expr = match config::resolve_ids(&expr, &interp) {
316 Ok(e) => e,
318 Ok(e) => e,
317 Err(failures) => {
319 Err(failures) => {
@@ -349,7 +351,7 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
351 // §1.2.8: a Repo is persisted (unless unchanged)
353 // §1.2.8: a Repo is persisted (unless unchanged)
352− if is_repo_value(&interp, &v) {
354+ if is_repo_value(&v) {
353 match backend.persist(&mut interp, &cfg, &loaded_repo, ¤t_repo, &v, text) {
355 match backend.persist(&mut interp, &cfg, &loaded_repo, ¤t_repo, &v, text) {
354 Ok(()) => ExitCode::SUCCESS,
356 Ok(()) => ExitCode::SUCCESS,
355 Err(c) => crash_err(&c, Some(text)),
357 Err(c) => crash_err(&c, Some(text)),
@@ -367,7 +369,7 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
370−fn is_repo_value(interp: &Interp, v: &Value) -> bool {
372+fn is_repo_value(v: &Value) -> bool {
371 // §4.4: shape Repo and root's shape Commit
373 // §4.4: shape Repo and root's shape Commit
372 let fields = match v {
374 let fields = match v {
373 Value::Record(m) => m.keys().cloned().collect::<BTreeSet<String>>(),
375 Value::Record(m) => m.keys().cloned().collect::<BTreeSet<String>>(),
@@ -392,6 +394,5 @@ fn is_repo_value(interp: &Interp, v: &Value) -> bool {
393 .map(|s| s.to_string())
395 .map(|s| s.to_string())
394 .collect();
396 .collect();
396 root_fields == want_root
397 root_fields == want_root