Skip to main content

amont_runtime/hooks/
run_tests.rs

1//! pre-push-run-tests-js — run each touched JS package's gate before pushing.
2//!
3//! git feeds pre-push one line per ref on stdin. That list is parsed ONCE by
4//! the dispatcher (see `pushrefs`) and lent here, because stdin can only be
5//! consumed once and more than one check needs it:
6//!     <local ref> <local oid> <remote ref> <remote oid>
7//! An all-zero local oid is a deletion; an all-zero remote oid means the branch
8//! is new, so everything it carries is in range.
9
10use super::common::program;
11use crate::check::Outcome;
12use crate::git;
13use std::process::{Command, Stdio};
14
15/// Whichever of these the package defines, cheapest first, stopping at the
16/// first failure — a type error costs seconds, not a full suite.
17///
18/// `lint` is deliberately absent: pre-commit-lint-js already lints staged files
19/// with the repo's pinned eslint, so repeating it here costs time and catches
20/// nothing new.
21const GATE: [&str; 3] = ["typecheck", "test:unit", "test"];
22
23/// The extensions this check treats as "JS worth testing". Exported so
24/// `registry.rs` declares the scope from the same constant — see
25/// `lint_json_yaml::EXTS` for the drift this prevents.
26pub const JS_EXTS: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue"];
27
28fn is_js(file: &str) -> bool {
29    JS_EXTS.iter().any(|e| file.ends_with(e))
30}
31
32fn parent_of(path: &str) -> &str {
33    match path.rfind('/') {
34        Some(i) => &path[..i],
35        None => "",
36    }
37}
38
39/// Does `package.json` define `script` under "scripts"?
40///
41/// A brace-matched scan rather than a JSON parser: the only question is whether
42/// one key exists in one object, and a dependency-free binary is the point of
43/// this migration. Restricted to the "scripts" object so a same-named key
44/// elsewhere (a dependency called `test`, say) cannot answer for it.
45pub fn defines_script(pkg_json: &str, script: &str) -> bool {
46    let Some(k) = pkg_json.find("\"scripts\"") else {
47        return false;
48    };
49    let Some(open_rel) = pkg_json[k..].find('{') else {
50        return false;
51    };
52    let open = k + open_rel;
53    let bytes = pkg_json.as_bytes();
54    let mut depth = 0usize;
55    let mut end = open;
56    let mut in_str = false;
57    let mut escaped = false;
58    for (i, &c) in bytes.iter().enumerate().skip(open) {
59        if in_str {
60            if escaped {
61                escaped = false;
62            } else if c == b'\\' {
63                escaped = true;
64            } else if c == b'"' {
65                in_str = false;
66            }
67            continue;
68        }
69        match c {
70            b'"' => in_str = true,
71            b'{' => depth += 1,
72            b'}' => {
73                depth -= 1;
74                if depth == 0 {
75                    end = i;
76                    break;
77                }
78            }
79            _ => {}
80        }
81    }
82    if end <= open {
83        return false;
84    }
85    let body = &pkg_json[open..=end];
86    let needle = format!("\"{script}\"");
87    let mut from = 0;
88    while let Some(i) = body[from..].find(&needle) {
89        let at = from + i;
90        let after = &body[at + needle.len()..];
91        if after.trim_start().starts_with(':') {
92            return true;
93        }
94        from = at + needle.len();
95    }
96    false
97}
98
99/// Tracked `package.json` paths, as directories relative to the repo root.
100///
101/// `git ls-files` instead of the shell version's `fd package.json`: it drops
102/// the `fd` dependency (undeclared, and one of two binaries the hooks silently
103/// required), and it is the more correct set anyway — only TRACKED packages can
104/// be part of a push, and node_modules is excluded by construction rather than
105/// by fd happening to honour .gitignore.
106pub fn package_dirs(ls_files: &[String]) -> Vec<String> {
107    let mut dirs: Vec<String> = ls_files
108        .iter()
109        .map(String::as_str)
110        .filter(|f| *f == "package.json" || f.ends_with("/package.json"))
111        .map(|f| parent_of(f).to_string())
112        .collect();
113    dirs.sort();
114    dirs.dedup();
115    dirs
116}
117
118/// Packages that actually contain one of the changed files.
119///
120/// `.iter().any()`, not the shell version's original `.filter()` — an empty
121/// array is truthy in JS, so that selected EVERY package regardless of what
122/// changed. Invisible in a single-package repo, quadratic noise in a monorepo.
123pub fn packages_to_test(pkg_dirs: &[String], changed_dirs: &[String]) -> Vec<String> {
124    pkg_dirs
125        .iter()
126        .filter(|pkg| {
127            changed_dirs.iter().any(|dir| {
128                if pkg.is_empty() {
129                    true
130                } else {
131                    dir == *pkg || dir.starts_with(&format!("{pkg}/"))
132                }
133            })
134        })
135        .cloned()
136        .collect()
137}
138
139/// True when the gate passed (or there was none to run).
140fn run_gate(root: &str, folder: &str) -> bool {
141    let dir = if folder.is_empty() {
142        root.to_string()
143    } else {
144        format!("{root}/{folder}")
145    };
146    let Ok(pkg) = std::fs::read_to_string(format!("{dir}/package.json")) else {
147        return true;
148    };
149    for script in GATE.iter().filter(|s| defines_script(&pkg, s)) {
150        // Same hazard as cargo test: git exports GIT_DIR to hooks, and a JS
151        // test that shells out to git would then operate on this repo rather
152        // than its own fixture.
153        let mut cmd = Command::new(program("npm"));
154        cmd.args(["run", script])
155            .current_dir(&dir)
156            .stdin(Stdio::null());
157        super::common::strip_git_env(&mut cmd);
158        let status = cmd.status();
159        match status {
160            Ok(status) if status.success() => {}
161            // The gate's own exit code is not propagated: git only
162            // distinguishes zero from non-zero, and npm's codes said nothing
163            // the message above has not already said.
164            Ok(_) | Err(_) => return false,
165        }
166    }
167    true
168}
169
170pub fn run(refs: &[crate::pushrefs::PushRef]) -> Outcome {
171    // An all-zero oid, of whatever length this repo's hash is (sha1 or sha256).
172    let zero = git::stdout(&["hash-object", "--stdin"])
173        .map(|h| "0".repeat(h.len()))
174        .unwrap_or_else(|| "0".repeat(40));
175
176    let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
177        return Outcome::Passed;
178    };
179    let pkg_dirs = git::stdout_paths(&["ls-files"])
180        .map(|f| package_dirs(&f))
181        .unwrap_or_default();
182
183    for r in refs {
184        let local_oid = r.local_oid.as_str();
185        if local_oid == zero {
186            continue; // deleting a ref pushes no code
187        }
188        // `pushrefs::changed_files_for` exists for exactly this question, and
189        // this check used to recompute it inline with all three bugs that
190        // function's doc comment records fixing:
191        //
192        //   - a brand-new branch (`remote_oid == zero`) diffed only its TIP,
193        //     so on a multi-commit push an earlier commit's `.ts` change was
194        //     invisible and the suite never ran;
195        //   - a two-dot range handed to `diff-tree` is a two-TREE compare, not
196        //     a commit walk, so a file changed and reverted later in the same
197        //     push netted to nothing;
198        //   - merge commits show NOTHING without `-m`, so a file touched only
199        //     to resolve a conflict selected no package.
200        //
201        // Every one of those let a push proceed GREEN with the suite never
202        // having run. `rust_tools::test` was already the model.
203        let changed = crate::pushrefs::changed_files_for(r, &zero);
204        let changed_dirs: Vec<String> = changed
205            .iter()
206            .map(String::as_str)
207            .filter(|f| is_js(f))
208            .map(|f| parent_of(f).to_string())
209            .collect();
210        if changed_dirs.is_empty() {
211            continue;
212        }
213
214        // Same question as cargo-test: the suite should be answering about
215        // the commits being pushed, not about whatever is open in the
216        // editor — and about THIS ref's commits, not some other ref in the
217        // same push. A single worktree shared across every ref would run a
218        // second ref's tests against a first ref's tree.
219        let (run_in, _guard) = crate::pushed_tree::where_to_run(local_oid, &root);
220        let where_ = run_in.to_string_lossy().into_owned();
221        for folder in packages_to_test(&pkg_dirs, &changed_dirs) {
222            if !run_gate(&where_, &folder) {
223                return Outcome::Failed;
224            }
225        }
226    }
227    Outcome::Passed
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn finds_scripts_only_inside_the_scripts_object() {
236        let pkg = r#"{"name":"x","scripts":{"test":"vitest","typecheck":"tsc"},"devDependencies":{"lint":"1"}}"#;
237        assert!(defines_script(pkg, "test"));
238        assert!(defines_script(pkg, "typecheck"));
239        assert!(!defines_script(pkg, "test:unit"));
240        // present, but as a DEPENDENCY — must not answer for the scripts object
241        assert!(!defines_script(pkg, "lint"));
242    }
243
244    #[test]
245    fn survives_nested_objects_and_escaped_quotes() {
246        let pkg = r#"{"scripts":{"test":"echo \"hi\"","build":"x"},"other":{"test":"no"}}"#;
247        assert!(defines_script(pkg, "test"));
248        assert!(defines_script(pkg, "build"));
249        assert!(!defines_script(pkg, "other"));
250    }
251
252    #[test]
253    fn no_scripts_object_means_no_scripts() {
254        assert!(!defines_script(r#"{"name":"x"}"#, "test"));
255    }
256
257    #[test]
258    fn collects_package_directories() {
259        let ls: Vec<String> = [
260            "package.json",
261            "apps/web/package.json",
262            "apps/web/src/a.ts",
263            "README.md",
264        ]
265        .into_iter()
266        .map(String::from)
267        .collect();
268        assert_eq!(
269            package_dirs(&ls),
270            vec!["".to_string(), "apps/web".to_string()]
271        );
272    }
273
274    /// The JS bug: `.filter()` returns an array, `[]` is truthy, so every
275    /// package was selected whatever changed.
276    #[test]
277    fn selects_only_packages_containing_a_change() {
278        let pkgs = vec!["apps/web".to_string(), "apps/api".to_string()];
279        let changed = vec!["apps/web/src".to_string()];
280        assert_eq!(
281            packages_to_test(&pkgs, &changed),
282            vec!["apps/web".to_string()]
283        );
284    }
285
286    #[test]
287    fn the_repo_root_package_matches_any_change() {
288        let pkgs = vec!["".to_string()];
289        assert_eq!(
290            packages_to_test(&pkgs, &["src".to_string()]),
291            vec!["".to_string()]
292        );
293    }
294}