amont_runtime/hooks/prettier.rs
1//! pre-commit-prettier — format check over staged files, scoped to repos that
2//! actually opt into prettier.
3
4use super::common::{
5 fail, first_existing, fixing_enabled, hl, ok, repo_root, resolve_tool, restage,
6 run as run_tool, run_quiet, staged_files, warn, Restaged,
7};
8use crate::check::Outcome;
9use std::path::Path;
10
11/// Everything prettier is asked to look at.
12///
13/// Exported, even though `registry.rs` deliberately declares
14/// `Scope::files(&[])` for this check (it is opt-in by CONFIG, not by file
15/// type): the drift guard needs to know what the check actually consumes, and
16/// an empty declared set is a subset of this one.
17pub const EXTS: &[&str] = &[
18 ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".jsonc", ".css", ".scss", ".less",
19 ".html", ".vue", ".md", ".mdx", ".yaml", ".yml",
20];
21
22/// A repo opts in via a prettier config file or a `"prettier"` key in
23/// package.json. Without that signal the hook does nothing — in a repo that
24/// does not use prettier, `npx prettier` would download it and flag every file
25/// against defaults.
26fn has_config(root: &str) -> bool {
27 if first_existing(
28 root,
29 &[
30 ".prettierrc",
31 "prettier.config.js",
32 "prettier.config.mjs",
33 "prettier.config.cjs",
34 ".prettierrc.json",
35 ".prettierrc.yml",
36 ".prettierrc.yaml",
37 ".prettierrc.js",
38 ".prettierrc.cjs",
39 ".prettierrc.mjs",
40 ".prettierrc.toml",
41 ],
42 )
43 .is_some()
44 {
45 return true;
46 }
47 std::fs::read_to_string(Path::new(root).join("package.json"))
48 .map(|p| p.contains("\"prettier\""))
49 .unwrap_or(false)
50}
51
52pub fn run(_args: &[std::ffi::OsString]) -> Outcome {
53 let files = staged_files(EXTS);
54 if files.is_empty() {
55 return Outcome::Passed;
56 }
57 let root = repo_root();
58
59 let tool = resolve_tool(&root, "prettier");
60 // A pinned local prettier is itself an opt-in signal; without config AND
61 // without a local binary the repo simply does not use prettier.
62 let local_pinned = tool
63 .as_ref()
64 .map(|t| t[0].contains("node_modules/.bin"))
65 .unwrap_or(false);
66 if !has_config(&root) && !local_pinned {
67 return Outcome::Passed;
68 }
69 let Some(argv) = tool else {
70 warn(&format!(
71 "prettier config found but no prettier binary. Run {}",
72 hl("npm install")
73 ));
74 return Outcome::Unavailable;
75 };
76
77 // Ignore a GLOBAL ~/.editorconfig when this repo defines none of its own:
78 // prettier otherwise walks up past the repo and enforces machine-wide
79 // defaults (4-space, say) on a repo formatted differently — a false
80 // positive that also disagrees with CI, which has no ~/.editorconfig.
81 // A repo-level .editorconfig is still honoured, matching CI.
82 let mut flags: Vec<String> = Vec::new();
83 if !Path::new(&root).join(".editorconfig").is_file() {
84 flags.push("--no-editorconfig".into());
85 }
86
87 // `--` before the file list at each of these: a staged file named e.g.
88 // `-x.js` would otherwise be read as a flag by prettier's own parser —
89 // and prettier does not even error on that, it exits 0 having checked
90 // nothing, so the file would silently never be linted at all.
91 // ONE `--check` pass. It used to run twice on the clean path — once to
92 // decide whether to repair, once to decide the verdict — which is a whole
93 // extra prettier startup on every commit that had nothing wrong with it.
94 let mut check = flags.clone();
95 check.push("--check".into());
96 check.push("--".into());
97 check.extend(files.iter().cloned());
98 if run_quiet(&root, &argv, &check) {
99 ok("Prettier passed");
100 return Outcome::Passed;
101 }
102
103 if fixing_enabled() {
104 // Asked to repair, so repair rather than reporting an instruction the
105 // author would carry out identically by hand.
106 let mut write = flags.clone();
107 write.push("--write".into());
108 write.push("--".into());
109 write.extend(files.iter().cloned());
110 if run_quiet(&root, &argv, &write) {
111 // Dropping the confirming re-`--check` after the write is safe:
112 // `prettier --write` exits NON-ZERO on a parse error, so a
113 // successful write means the files are formatted.
114 match restage(&files) {
115 Restaged::Staged => {
116 ok("Prettier reformatted and re-staged");
117 return Outcome::Fixed;
118 }
119 // The index still holds the content prettier has just replaced
120 // on disk. Reporting anything but a failure here is how
121 // unformatted code reached a commit the hook called clean.
122 Restaged::Failed(stuck) => {
123 fail(&format!(
124 "Prettier reformatted these files but {} failed — the index still \
125 holds the UNFORMATTED content: {}",
126 hl("git add"),
127 stuck.join(", ")
128 ));
129 return Outcome::Failed;
130 }
131 // Nothing differed from the index, so the write changed
132 // nothing that was staged. Fall through and report.
133 Restaged::Nothing => {}
134 }
135 }
136 }
137
138 fail(&format!(
139 "Prettier found unformatted files. Run {} on:",
140 hl("prettier --write")
141 ));
142 let mut list = flags;
143 list.push("--list-different".into());
144 list.push("--".into());
145 list.extend(files);
146 let _ = run_tool(&root, &argv, &list);
147 Outcome::Failed
148}