Skip to main content

jj_hooks/
setup.rs

1//! Pre-hook setup steps run inside the ephemeral worktree.
2//!
3//! When the user declares `jj-hooks.setup` in jj config (user- or
4//! repo-level), each step's command runs inside the freshly-created
5//! `git worktree add --detach` checkout before the hook runner
6//! fires. The motivating use case (issue #9) is `node_modules`:
7//! hooks like `tsc` need install-time resources that aren't in the
8//! committed tree, so the worktree starts without them and the
9//! hook fails with `command not found`. A user-declared setup
10//! command (`bun install`, `pnpm install --frozen-lockfile`, etc.)
11//! restores them.
12//!
13//! Config shape (matches the precedent set by pre-commit / hk
14//! per-step tables):
15//!
16//! ```toml
17//! [[jj-hooks.setup]]
18//! name = "install deps"          # optional; falls back to run[0]
19//! run = ["bun", "install", "--frozen-lockfile"]
20//!
21//! [[jj-hooks.setup]]
22//! run = ["bun", "run", "prepare"]
23//! ```
24//!
25//! A non-zero exit from any step aborts the pipeline before the
26//! hook runner is invoked — `run_once` propagates the failure
27//! through the same path as a failing hook.
28
29use std::path::Path;
30use std::process::Command;
31
32use serde::Deserialize;
33
34use crate::error::{JjHooksError, Result};
35use crate::jj::JjCli;
36
37/// One entry in `jj-hooks.setup`.
38#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
39pub struct SetupStep {
40    /// Optional label used in failure messages. Falls back to
41    /// `run[0]` (the bare program name) when omitted.
42    #[serde(default)]
43    pub name: Option<String>,
44    /// argv list — `Command::new(run[0]).args(&run[1..])`. We use
45    /// argv (not a shell string) so quoting rules can't bite. For
46    /// chained commands the user runs `bash -c "..."` explicitly.
47    pub run: Vec<String>,
48}
49
50impl SetupStep {
51    /// Label to print in failure messages — `name` if set,
52    /// otherwise the bare program name.
53    pub fn label(&self) -> &str {
54        if let Some(name) = self.name.as_deref() {
55            return name;
56        }
57        self.run.first().map_or("<empty>", String::as_str)
58    }
59}
60
61/// Parse `[[jj-hooks.setup]]` array-of-tables from a TOML fragment.
62///
63/// Input is the value `jj config get jj-hooks.setup` prints, which
64/// for an array-of-tables comes back as a TOML *array of inline
65/// tables* on a single line, e.g.
66/// `[{ run = ["bun", "install"] }, { run = ["echo", "done"] }]`.
67pub fn parse_steps(toml_fragment: &str) -> Result<Vec<SetupStep>> {
68    // Wrap the array in a dummy key so we can use toml::de's table
69    // deserializer (which is the only one stable across all the
70    // shapes jj config get spits out — array-of-tables, inline,
71    // empty array, etc.).
72    let wrapped = format!("setup = {toml_fragment}");
73    let table: SetupConfig = toml::from_str(&wrapped).map_err(|e| {
74        JjHooksError::Parse(format!(
75            "jj-hooks.setup must be an array of tables with a `run` field: {e}"
76        ))
77    })?;
78    Ok(table.setup)
79}
80
81#[derive(Debug, Deserialize)]
82struct SetupConfig {
83    #[serde(default)]
84    setup: Vec<SetupStep>,
85}
86
87/// Load `jj-hooks.setup` from jj's config, returning `Ok(vec![])`
88/// when the key isn't set (`jj config get` exits non-zero for a
89/// missing key — that's the "no setup configured" path, not an
90/// error).
91pub fn load_steps(jj: &JjCli) -> Result<Vec<SetupStep>> {
92    // We can't distinguish "key missing" from "jj failed for some
93    // other reason" without parsing jj's stderr, but the common
94    // failure is exactly "key missing" — anything else surfaces
95    // as a parse error on the empty input, which is fine.
96    let Ok(value) = jj.run(&["config", "get", "jj-hooks.setup"]) else {
97        return Ok(vec![]);
98    };
99    let value = value.trim();
100    if value.is_empty() {
101        return Ok(vec![]);
102    }
103    parse_steps(value)
104}
105
106/// Execute each setup step in `worktree`. Returns `Ok(())` when all
107/// steps succeed. The first non-zero exit aborts the rest and
108/// returns a [`JjHooksError::JjFailed`] carrying the step's
109/// label + the captured stderr.
110///
111/// `workspace_root` is exposed to each subprocess via the
112/// `JJ_HOOKS_WORKSPACE` env var so steps can resolve paths from
113/// the user's invocation workspace (e.g. `cp -al
114/// "$JJ_HOOKS_WORKSPACE/node_modules" .` to hardlink-copy the
115/// already-installed deps instead of running a full install).
116pub fn run_steps(steps: &[SetupStep], worktree: &Path, workspace_root: &Path) -> Result<()> {
117    for step in steps {
118        if step.run.is_empty() {
119            return Err(JjHooksError::Parse(format!(
120                "jj-hooks.setup step `{}` has empty `run` array",
121                step.label()
122            )));
123        }
124
125        tracing::info!("setup step `{}`: running {:?}", step.label(), step.run);
126
127        let status = Command::new(&step.run[0])
128            .args(&step.run[1..])
129            .current_dir(worktree)
130            .env("JJ_HOOKS_WORKSPACE", workspace_root)
131            .status()?;
132
133        if !status.success() {
134            return Err(JjHooksError::SetupFailed {
135                name: step.label().to_owned(),
136                status: status.code().unwrap_or(-1),
137            });
138        }
139    }
140    Ok(())
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn parse_single_step_with_run_only() {
149        // The shape `jj config get` prints when the user writes
150        // `setup = [{ run = ["bun", "install"] }]`.
151        let steps = parse_steps(r#"[{ run = ["bun", "install"] }]"#).unwrap();
152        assert_eq!(steps.len(), 1);
153        assert_eq!(steps[0].run, vec!["bun", "install"]);
154        assert_eq!(steps[0].name, None);
155    }
156
157    #[test]
158    fn parse_named_step() {
159        let steps = parse_steps(r#"[{ name = "deps", run = ["bun", "install"] }]"#).unwrap();
160        assert_eq!(steps[0].name.as_deref(), Some("deps"));
161        assert_eq!(steps[0].label(), "deps");
162    }
163
164    #[test]
165    fn parse_multiple_steps_preserves_order() {
166        let steps = parse_steps(
167            r#"[
168              { run = ["echo", "one"] },
169              { name = "two", run = ["echo", "two"] },
170              { run = ["echo", "three"] },
171            ]"#,
172        )
173        .unwrap();
174        assert_eq!(steps.len(), 3);
175        assert_eq!(steps[0].run, vec!["echo", "one"]);
176        assert_eq!(steps[1].label(), "two");
177        assert_eq!(steps[2].run, vec!["echo", "three"]);
178    }
179
180    #[test]
181    fn parse_empty_array_is_no_steps() {
182        let steps = parse_steps("[]").unwrap();
183        assert!(steps.is_empty());
184    }
185
186    #[test]
187    fn parse_missing_run_field_errors() {
188        // A step table without `run` is a user typo we should catch
189        // at config-load time, not at execute time.
190        let err = parse_steps(r#"[{ name = "no-run" }]"#).unwrap_err();
191        assert!(
192            matches!(err, JjHooksError::Parse(_)),
193            "missing `run` should be a parse error, got: {err:?}"
194        );
195    }
196
197    #[test]
198    fn parse_rejects_bare_string_value() {
199        // The "convenient" shape some users might try (a single
200        // command instead of an array) should error clearly rather
201        // than silently no-op.
202        let err = parse_steps(r#""bun install""#).unwrap_err();
203        assert!(matches!(err, JjHooksError::Parse(_)));
204    }
205
206    #[test]
207    fn label_falls_back_to_first_argv_when_name_missing() {
208        let step = SetupStep {
209            name: None,
210            run: vec!["bun".into(), "install".into()],
211        };
212        assert_eq!(step.label(), "bun");
213    }
214
215    #[test]
216    fn label_prefers_name_over_argv() {
217        let step = SetupStep {
218            name: Some("install deps".into()),
219            run: vec!["bun".into(), "install".into()],
220        };
221        assert_eq!(step.label(), "install deps");
222    }
223
224    #[test]
225    fn run_steps_passes_workspace_env_var() {
226        // We can't easily intercept Command::env, but we *can* run
227        // a real subprocess that records its env to a file. Using
228        // /bin/sh keeps the test cheap (~1ms) and is portable to
229        // any platform that has sh — which is every CI runner we
230        // ship to.
231        let tmp = tempfile::TempDir::new().unwrap();
232        let out = tmp.path().join("env.txt");
233        let step = SetupStep {
234            name: None,
235            run: vec![
236                "sh".into(),
237                "-c".into(),
238                format!("printf %s \"$JJ_HOOKS_WORKSPACE\" > {}", out.display()),
239            ],
240        };
241        let workspace = tmp.path().join("workspace");
242        std::fs::create_dir(&workspace).unwrap();
243        run_steps(&[step], tmp.path(), &workspace).unwrap();
244        let captured = std::fs::read_to_string(&out).unwrap();
245        assert_eq!(captured, workspace.display().to_string());
246    }
247
248    #[test]
249    fn run_steps_aborts_on_first_failure() {
250        // Step 1 fails; step 2 must NOT run. We assert step 2
251        // didn't run by checking that its side-effect file is
252        // absent after the call.
253        let tmp = tempfile::TempDir::new().unwrap();
254        let marker = tmp.path().join("step2_ran");
255        let steps = vec![
256            SetupStep {
257                name: Some("step1".into()),
258                run: vec!["false".into()],
259            },
260            SetupStep {
261                name: Some("step2".into()),
262                run: vec![
263                    "sh".into(),
264                    "-c".into(),
265                    format!("touch {}", marker.display()),
266                ],
267            },
268        ];
269        let err = run_steps(&steps, tmp.path(), tmp.path()).unwrap_err();
270        let JjHooksError::SetupFailed { name, .. } = err else {
271            panic!("expected SetupFailed, got {err:?}");
272        };
273        assert!(
274            name.contains("step1"),
275            "abort message should name the failed step `step1`: {name}"
276        );
277        assert!(
278            !marker.exists(),
279            "step2 must not run after step1 fails (marker file present)",
280        );
281    }
282
283    #[test]
284    fn run_steps_empty_run_array_errors() {
285        let steps = vec![SetupStep {
286            name: Some("bad".into()),
287            run: vec![],
288        }];
289        let err = run_steps(
290            &steps,
291            std::env::temp_dir().as_path(),
292            std::env::temp_dir().as_path(),
293        )
294        .unwrap_err();
295        assert!(matches!(err, JjHooksError::Parse(_)));
296    }
297
298    #[test]
299    fn run_steps_no_steps_is_silent_ok() {
300        run_steps(
301            &[],
302            std::env::temp_dir().as_path(),
303            std::env::temp_dir().as_path(),
304        )
305        .unwrap();
306    }
307}